Stack use-after-free via dangling GC root from ErrorResult double-throw in [@ CryptoKey::GetAlgorithm]
Categories
(Core :: DOM: Web Crypto, defect)
Tracking
()
People
(Reporter: bugmon, Assigned: tschuster)
References
Details
(5 keywords, Whiteboard: [prefs-checked][adv-main151+r])
Attachments
(4 files, 1 external file, 1 obsolete file)
Summary
Dangling GC root via double-throw state corruption in CryptoKey::GetAlgorithm. When RsaHashedKeyAlgorithmStorage::ToKeyAlgorithm fails with a JS exception stored in the caller's ErrorResult (via StealExceptionFromJSContext), CryptoKey::GetAlgorithm unconditionally calls aRv.Throw(NS_ERROR_DOM_OPERATION_ERR), which overwrites mResult without removing the GC root registered by ThrowJSException. The FastErrorResult destructor (which uses JustAssertCleanupPolicy with suppress=false) then destroys the object without cleanup, leaving a dangling JS::Value* in GCRuntime::rootsHash pointing to reclaimed stack memory.
Affected Code
File: /firefox/dom/crypto/CryptoKey.cpp, line 215
void CryptoKey::GetAlgorithm(JSContext* cx,
JS::MutableHandle<JSObject*> aRetVal,
ErrorResult& aRv) const {
bool converted = false;
JS::Rooted<JS::Value> val(cx);
switch (mAlgorithm.mType) {
...
case KeyAlgorithmProxy::RSA: {
RootedDictionary<RsaHashedKeyAlgorithm> rsa(cx);
converted = mAlgorithm.mRsa.ToKeyAlgorithm(cx, rsa, aRv); // may set JS exception on aRv
if (converted) {
converted = ToJSValue(cx, rsa, &val);
}
break;
}
...
}
if (!converted) {
aRv.Throw(NS_ERROR_DOM_OPERATION_ERR); // BUG: overwrites JS exception state, leaks root
return;
}
aRetVal.set(&val.toObject());
}
The vulnerability exists because aRv may already contain a rooted JS exception when Throw() is called. ToKeyAlgorithm → CryptoBuffer::ToUint8Array → TypedArray::CreateCommon (/firefox/dom/bindings/TypedArray.h:807) calls error.StealExceptionFromJSContext(cx) on allocation failure, which registers &aRv.mExtra.mJSException in GCRuntime::rootsHash via js::AddRawValueRoot and sets mResult = NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION.
File: /firefox/dom/bindings/ErrorResult.h, lines 540–557
void AssignErrorCode(nsresult aRv) {
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_TYPEERROR, ...);
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_RANGEERROR, ...);
MOZ_ASSERT(!IsErrorWithMessage(), "Don't overwrite errors with message");
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION, ...);
MOZ_ASSERT(!IsJSException(), "Don't overwrite JS exceptions"); // ← debug-only!
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_DOMEXCEPTION, ...);
MOZ_ASSERT(!IsDOMException(), "Don't overwrite DOM exceptions");
...
mResult = aRv; // ← unconditionally overwrites, root now orphaned
}
The invariant "don't overwrite JS exceptions" is enforced only by MOZ_ASSERT, which is a no-op in release builds.
File: /firefox/dom/bindings/BindingUtils.cpp, lines 509–524
template <typename CleanupPolicy>
void TErrorResult<CleanupPolicy>::ClearUnionData() {
AssertInOwningThread();
if (IsJSException()) { // ← FALSE after mResult was overwritten
JSContext* cx = dom::danger::GetJSContext();
MOZ_ASSERT(cx);
mExtra.mJSException.setUndefined();
js::RemoveRawValueRoot(cx, &mExtra.mJSException); // ← never reached
...
} else if ...
}
ClearUnionData is the only path that removes the root during cleanup, and it is gated on IsJSException() which reads the already-overwritten mResult.
File: /firefox/dom/bindings/ErrorResult.h, lines 175–190 and 660–664
~TErrorResult() {
AssertInOwningThread();
if (CleanupPolicy::assertHandled) {
AssertReportedOrSuppressed(); // all MOZ_ASSERT, no-op in release
}
if (CleanupPolicy::suppress) { // FALSE for JustAssertCleanupPolicy
SuppressException(); // ← never called
}
AssertReportedOrSuppressed(); // all MOZ_ASSERT, no-op in release
}
struct JustAssertCleanupPolicy {
static const bool assertHandled = true;
static const bool suppress = false; // ← no safety-net cleanup
static const bool assertSameThread = true;
};
The generated binding (/firefox/obj-x86_64-pc-linux-gnu/dom/bindings/SubtleCryptoBinding.cpp) uses FastErrorResult rv; which inherits TErrorResult<JustAssertCleanupPolicy>. The destructor performs no cleanup whatsoever in release builds.
Correct Pattern
// In CryptoKey::GetAlgorithm:
if (!converted) {
if (!aRv.Failed()) {
// Only throw if ToKeyAlgorithm didn't already set an error
aRv.Throw(NS_ERROR_DOM_OPERATION_ERR);
}
return;
}
Or defensively in TErrorResult::AssignErrorCode:
void AssignErrorCode(nsresult aRv) {
MOZ_RELEASE_ASSERT(!IsJSException(), "Overwriting JS exception leaks GC root");
MOZ_RELEASE_ASSERT(!IsErrorWithMessage(), "Overwriting message leaks heap allocation");
MOZ_RELEASE_ASSERT(!IsDOMException(), "Overwriting DOMException leaks heap allocation");
mResult = aRv;
}
Exploit Chain
- JavaScript calls
cryptoKey.algorithmgetter on an RSA key (publicKeyorprivateKeyfrom RSA-PSS/RSA-OAEP/RSASSA-PKCS1-v1_5) - Generated binding
get_algorithmallocatesFastErrorResult rv;on the stack (addressS) CryptoKey::GetAlgorithm→KeyAlgorithmProxy::RSAcase →mAlgorithm.mRsa.ToKeyAlgorithm(cx, rsa, aRv)ToKeyAlgorithm→mPublicExponent.ToUint8Array(aCx, aError)→Uint8Array::Create→CreateCommon- Trigger: JS engine allocation fails (OOM under memory pressure).
CreateCommon(TypedArray.h:807) callserror.StealExceptionFromJSContext(cx) StealExceptionFromJSContext(BindingUtils.cpp:695) →ThrowJSException(cx, exn)→js::AddRawValueRoot(cx, &mExtra.mJSException, ...)registers addressS+8inGCRuntime::rootsHash. SetsmResult = NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION(0x80700001)ToKeyAlgorithmseesaError.Failed(), returnsfalse- The bug:
CryptoKey::GetAlgorithm:215callsaRv.Throw(NS_ERROR_DOM_OPERATION_ERR)→AssignErrorCode→mResult = 0x80530020. Root atS+8remains inrootsHashbutIsJSException()is nowfalse - Binding calls
rv.MaybeSetPendingException(cx)→SetPendingException(BindingUtils.cpp:653) →IsJSException()isfalse→ falls through toSetPendingGenericErrorException(cx)→dom::Throw(cx, ...)+mResult = NS_OK. Root not removed get_algorithmreturnsfalse.~FastErrorResult()runs withsuppress=false→SuppressException()not called.AssertReportedOrSuppressed()is no-op (MOZ_ASSERTonly,!Failed()is true anyway sincemResult == NS_OK)- Stack frame for
get_algorithmis reclaimed.rootsHashnow holds dangling pointer toS+8 - Attacker makes subsequent calls that reuse stack region containing
S+8, arranging for the 8 bytes at that address to form an Object-taggedJS::Value(top 17 bits =0x1FFFC, low 47 bits = attacker-controlled pointer) - GC runs (triggered by allocation pressure or explicitly).
GCRuntime::traceRuntimeCommoniteratesrootsHash, callsTraceRoot(trc, S+8, ...) TraceRootreads the fakeJS::Value, decodes it as an object pointer, callsTenuringTracer::onObjectEdge→InCollectedNurseryRegionreads the chunk header at(fake_ptr & ~ChunkMask) + offset→ wild pointer dereference → memory corruption or controlled read primitive
Steps to Reproduce
- Set pref
fuzzing.enabled = true(required forFuzzingFunctions.garbageCollect()in the PoC; a real exploit would trigger GC via allocation pressure) - Apply the provided source patch (simulates the OOM in step 5 and the stack groom in step 12; the vulnerable logic at
CryptoKey.cpp:215andErrorResult.h:556is unchanged upstream code) - Rebuild Firefox
- Load the testcase
Security Impact
Severity: High (sec-high candidate). A dangling JS::Value* root in rootsHash gives the GC tracer an attacker-influenced read target on every subsequent GC. If the reclaimed stack slot contains an Object-tagged Value, the GC will treat an arbitrary 47-bit pointer as a JSObject* and perform reads/writes through it (nursery region check, tenuring moves, mark bit manipulation). This yields a type-confusion primitive usable for info-leak and arbitrary write.
Preconditions:
Uint8Array::Createmust fail duringpublicExponentmaterialization. This is a JS engine typed-array allocation — failure is rare for tiny exponents but achievable under induced memory pressure (attacker-controlled heap exhaustion via largeArrayBufferallocations). The exponent size is determined at key generation/import time.- Attacker must place an Object-tagged 8-byte value at the reclaimed stack slot before GC reaches
rootsHashiteration. The diagnostic output confirms this slot is naturally overwritten by subsequent stack activity (value changed from0xfffb1daeefc299e0to0x00000eedf3a1fef9without any patch intervention). Controlling the exact bytes requires stack grooming — callingkey.algorithmfrom a carefully chosen call depth, then invoking native functions that write controlled data at that depth.
Why fuzzing missed this: The MOZ_ASSERT(!IsJSException()) at ErrorResult.h:548 fires in DEBUG builds before the state corruption occurs, so any DEBUG-build fuzzer that hits the OOM path crashes safely on the assert and never reaches the dangling-root state.
ASAN Report
==423329==ERROR: AddressSanitizer: SEGV on unknown address 0x414141400010 (pc 0x749f8181bf12 bp 0x7ffdea2d9570 sp 0x7ffdea2d9560 T0)
==423329==The signal is caused by a READ memory access.
#0 0x749f8181bf12 in js::gc::InCollectedNurseryRegion(js::gc::Cell const*) /firefox/obj-x86_64-pc-linux-gnu/dist/include/js/HeapAPI.h:739:52
#1 0x749f8181bf12 in js::Nursery::inCollectedRegion(js::gc::Cell const*) const /firefox/js/src/gc/Nursery-inl.h:116:10
#2 0x749f8181bf12 in js::gc::TenuringTracer::onObjectEdge(JSObject**, char const*) /firefox/js/src/gc/Tenuring.cpp:107:17
#3 0x749f817b6fac in js::gc::TraceEdgeInternal(JSTracer*, JSObject**, char const*) /firefox/js/src/gc/Tracer.h:114:1
#4 0x749f817b6fac in auto bool TraceTaggedPtrEdge<JS::Value>(JSTracer*, JS::Value*, char const*)::'lambda'(JS::Value)::operator()<JSObject*>(JS::Value) const /firefox/js/src/gc/Marking.cpp:700:10
#5 0x749f817b6fac in auto js::MapGCThingTyped<bool TraceTaggedPtrEdge<JS::Value>(JSTracer*, JS::Value*, char const*)::'lambda'(JS::Value)>(JS::Value const&, JS::Value&&) /firefox/obj-x86_64-pc-linux-gnu/dist/include/js/Value.h:1398:28
#6 0x749f8176fc28 in bool TraceTaggedPtrEdge<JS::Value>(JSTracer*, JS::Value*, char const*) /firefox/js/src/gc/Marking.cpp:699:16
#7 0x749f8176fc28 in js::gc::TraceEdgeInternal(JSTracer*, JS::Value*, char const*) /firefox/js/src/gc/Marking.cpp:719:10
#8 0x749f8176fc28 in void js::TraceNullableRoot<JS::Value>(JSTracer*, JS::Value*, char const*) /firefox/js/src/gc/Tracer.h:255:5
#9 0x749f8176fc28 in void TraceRootHelper<JS::Value>(JSTracer*, JS::Value*, char const*) /firefox/js/src/gc/Marking.cpp:404:3
#10 0x749f8176fc28 in JS::TraceRoot(JSTracer*, JS::Value*, char const*) /firefox/js/src/gc/Marking.cpp:432:1
#11 0x749f817e969d in js::gc::GCRuntime::traceRuntimeCommon(JSTracer*, js::gc::GCRuntime::TraceOrMarkRuntime) /firefox/js/src/gc/RootMarking.cpp:331:7
#12 0x749f817e9b30 in js::gc::GCRuntime::traceRuntimeForMinorGC(JSTracer*, js::gc::AutoGCSession&) /firefox/js/src/gc/RootMarking.cpp:261:3
#13 0x749f81789093 in js::Nursery::traceRoots(js::gc::AutoGCSession&, js::gc::TenuringTracer&) /firefox/js/src/gc/Nursery.cpp:1702:9
#14 0x749f817861dc in js::Nursery::doCollection(js::gc::AutoGCSession&, JS::GCOptions, JS::GCReason) /firefox/js/src/gc/Nursery.cpp:1558:3
#15 0x749f81760b54 in js::Nursery::collect(JS::GCOptions, JS::GCReason) /firefox/js/src/gc/Nursery.cpp:1417:31
#16 0x749f81759836 in js::gc::GCRuntime::collectNursery(JS::GCOptions, JS::GCReason, js::gcstats::PhaseKind) /firefox/js/src/gc/GC.cpp:5325:13
#17 0x749f8174c832 in js::gc::GCRuntime::collectNurseryFromMajorGC(JS::GCReason) /firefox/js/src/gc/GC.cpp:4438:3
#18 0x749f8174c832 in js::gc::GCRuntime::endPreparePhase() /firefox/js/src/gc/GC.cpp:3090:3
#19 0x749f81757a94 in js::gc::GCRuntime::incrementalSlice(JS::SliceBudget&, JS::GCReason, bool) /firefox/js/src/gc/GC.cpp:4255:7
#20 0x749f8175cdcc in js::gc::GCRuntime::gcCycle(bool, JS::SliceBudget const&, JS::GCReason) /firefox/js/src/gc/GC.cpp:4888:3
#21 0x749f8175e6c2 in js::gc::GCRuntime::collect(bool, JS::SliceBudget const&, JS::GCReason) /firefox/js/src/gc/GC.cpp:5078:9
#22 0x749f81764c96 in js::gc::GCRuntime::gc(JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GC.cpp:5158:3
#23 0x749f81764c96 in JS::NonIncrementalGC(JSContext*, JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GCAPI.cpp:300:21
#24 0x749f76e106a3 in GarbageCollectImpl(JS::GCReason, nsJSContext::IsShrinking, JS::SliceBudget const&) /firefox/dom/base/nsJSEnvironment.cpp:924:5
#25 0x749f76e1017e in nsJSContext::GarbageCollectNow(JS::GCReason, nsJSContext::IsShrinking) /firefox/dom/base/nsJSEnvironment.cpp:931:3
#26 0x749f78b39aed in mozilla::dom::FuzzingFunctions_Binding::garbageCollect(JSContext*, unsigned int, JS::Value*) /firefox/obj-x86_64-pc-linux-gnu/dom/bindings/./FuzzingFunctionsBinding.cpp:53:3
==423329==Register values:
rax = 0x0000414141414140 rbx = 0x00000e93f380f86c rcx = 0x0000414141400010 rdx = 0x0000000000000000
rdi = 0x0000749f9c078d40 rsi = 0x0000749f9c07c360 rbp = 0x00007ffdea2d9570 rsp = 0x00007ffdea2d9560
r8 = 0x0000749f9c07c300 r9 = 0x00007fffffffff01 r10 = 0x0000000000000f01 r11 = 0x0000000000000000
r12 = 0x0000749f9c07c360 r13 = 0x0000749f9c07c300 r14 = 0x00000e93f380f860 r15 = 0x0000749f9c07c300
SUMMARY: AddressSanitizer: SEGV (/firefox/obj-x86_64-pc-linux-gnu/dist/bin/libxul.so+0x22abef12)
Diagnostic trace proving the state machine corruption:
[BUG] ToKeyAlgorithm: entering fault injection, aError=0x7ffdea2d9d70
[BUG] ToKeyAlgorithm: ThrowJSException done, Failed()=1
[BUG] GetAlgorithm: before Throw, aRv.Failed()=1 aRv.ErrorCode()=0x80700001 ← NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION
[BUG] GetAlgorithm: after Throw, aRv.Failed()=1 aRv.ErrorCode()=0x80530020 ← NS_ERROR_DOM_OPERATION_ERR
[BUG] Captured dangling root addr: 0x7ffdea2d9d78 (current value: 0xfffb1daeefc299e0)
[BUG] Arming GC poison for dangling root at 0x7ffdea2d9d78 (currently: 0xfffb1daeefc299e0) ← AFTER get_algorithm returned, root still live
[BUG] traceRuntimeCommon: poisoning 0x7ffdea2d9d78 (was 0x00000eedf3a1fef9) to 0xfffe414141414140 ← slot naturally overwritten before poison
Suggested Fix
Immediate fix in /firefox/dom/crypto/CryptoKey.cpp:
if (!converted) {
if (!aRv.Failed()) {
aRv.Throw(NS_ERROR_DOM_OPERATION_ERR);
}
return;
}
Defense-in-depth in /firefox/dom/bindings/ErrorResult.h::AssignErrorCode — upgrade the resource-leak invariants to release asserts, since violating them corrupts GC state or leaks heap:
void AssignErrorCode(nsresult aRv) {
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_TYPEERROR, "Use ThrowTypeError()");
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_RANGEERROR, "Use ThrowRangeError()");
// These three hold owned resources (GC root / heap allocation); overwriting leaks them.
MOZ_RELEASE_ASSERT(!IsErrorWithMessage(), "Overwriting error-with-message leaks Message*");
MOZ_RELEASE_ASSERT(!IsJSException(), "Overwriting JS exception leaks GC root (dangling Value* in rootsHash)");
MOZ_RELEASE_ASSERT(!IsDOMException(), "Overwriting DOMException leaks DOMExceptionInfo*");
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION, "Use ThrowJSException()");
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_DOMEXCEPTION, ...);
MOZ_ASSERT(aRv != NS_ERROR_XPC_NOT_ENOUGH_ARGS, ...);
MOZ_ASSERT(aRv != NS_ERROR_INTERNAL_ERRORRESULT_EXCEPTION_ON_JSCONTEXT, ...);
mResult = aRv;
}
This converts the silent memory corruption into a safe, diagnosable crash and will catch any other call sites with the same bug pattern.
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
| Reporter | ||
Comment 3•7 months ago
|
||
| Reporter | ||
Comment 4•7 months ago
|
||
Updated•7 months ago
|
Comment 6•7 months ago
|
||
Anna, can you take a look at this bug? Is the OOM scenario realistic? If not, should we patch it just to remove the code smell?
Updated•7 months ago
|
Updated•7 months ago
|
Comment 7•7 months ago
|
||
Tom, is the analysis here right about how bad it is to do a throw when there's already a pending exception?
The attached patch is a bit elaborate so I'm not sure what to think.
(Either way, it makes sense to bail immediately if ToKeyAlgorithm fails)
| Assignee | ||
Comment 8•7 months ago
|
||
Unless I am letting myself be influenced too much by the AI text, I think this is basically correct. I think while working on bug 2018379, I was already a bit confused why Throw doesn't call ClearUnionData unlike its counterparts like ThrowDOMException, but didn't realize the potential problem with roots not being removed.
Comment 9•7 months ago
|
||
I guess the actual severity and sec-rating depend on how reliable the OOM is to exploit, but we know that in the JS engine, this can be done somewhat deterministically and has been done before.
Comment 10•7 months ago
|
||
We should file a followup about calling ClearUnionData more places then.
I'll drop this to moderate because it requires an OOM but yeah maybe that undersells it.
Comment 11•7 months ago
|
||
It seems like the fix here is entirely in the DOM code to make Throw safe? Should this be moved to a different component?
| Assignee | ||
Comment 12•7 months ago
|
||
No, you are still miss using the API. You should not overwrite one error with an other.
Comment 13•7 months ago
|
||
Can you point me to where this part of the API contract is documented? I had a quick skim of the relevant functions and classes and nothing immediately leapt out at me.
I appreciate this usage is code smell at the very least, but what's the upside to having this function be unsafe?
Comment 14•7 months ago
•
|
||
(In reply to Dennis Jackson from comment #11)
It seems like the fix here is entirely in the DOM code to make
Throwsafe? Should this be moved to a different component?
passing-by comment, Throw (https://searchfox.org/firefox-main/source/dom/bindings/ErrorResult.h#212) seems to be deprecated
Or is it a wrong Throw?
Updated•7 months ago
|
Comment 15•7 months ago
|
||
Comment 16•7 months ago
|
||
Prefs: fuzzing.enabled only gates the GC helper; the source patch simulates an OOM in a real upstream path — supported config, tagged [prefs-checked]. sec-moderate looks right given the OOM precondition.
Fix verified against the repro harness:
- Before:
ErrorCode0x80700001→0x80530020, ASAN stack-use-after-scope intraceRuntimeCommon→rootsHashiteration. - After:
ErrorCodestays0x80700001, no crash (root removed normally viaSetPendingJSException→RemoveRawValueRoot), even with the OOM injection and stack poison still armed.
ToKeyAlgorithm has only this one caller. The ErrorResult.h hardening (comment 10) is better as a followup — this just fixes the API misuse as :evilpie noted.
This is the analysis tool's suggested fix. Feel welcome to adopt it as a starting point and evolve it as needed to meet our coding standards.
| Assignee | ||
Comment 17•7 months ago
|
||
The suggested fix is non idiomatic, let me fix this quickly.
Comment 18•7 months ago
|
||
Maybe we use ThrowOperationError("...")?
| Assignee | ||
Updated•7 months ago
|
| Assignee | ||
Comment 19•7 months ago
|
||
Comment 20•7 months ago
|
||
Comment 21•7 months ago
|
||
Updated•7 months ago
|
Comment 22•7 months ago
|
||
The patch landed in nightly and beta is affected.
:tschuster, is this bug important enough to require an uplift?
- If yes, please nominate the patch for beta approval.
- See https://wiki.mozilla.org/Release_Management/Requesting_an_Uplift for documentation on how to request an uplift.
- If no, please set
status-firefox150towontfix.
For more information, please visit BugBot documentation.
| Assignee | ||
Comment 23•7 months ago
|
||
(In reply to Dennis Jackson from comment #11)
It seems like the fix here is entirely in the DOM code to make
Throwsafe? Should this be moved to a different component?
We made the Throw API safe in bug 2025332, so from my point of view we don't necessarily need to uplift this.
| Assignee | ||
Updated•7 months ago
|
Updated•7 months ago
|
Updated•6 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Updated•2 months ago
|
Description
•