Closed Bug 2024443 Opened 7 months ago Closed 7 months ago

Stack use-after-free via dangling GC root from ErrorResult double-throw in [@ CryptoKey::GetAlgorithm]

Categories

(Core :: DOM: Web Crypto, defect)

defect

Tracking

()

RESOLVED FIXED
151 Branch
Tracking Status
firefox-esr115 --- wontfix
firefox-esr140 --- wontfix
firefox149 --- wontfix
firefox150 --- wontfix
firefox151 + fixed

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

  1. JavaScript calls cryptoKey.algorithm getter on an RSA key (publicKey or privateKey from RSA-PSS/RSA-OAEP/RSASSA-PKCS1-v1_5)
  2. Generated binding get_algorithm allocates FastErrorResult rv; on the stack (address S)
  3. CryptoKey::GetAlgorithm → KeyAlgorithmProxy::RSA case → mAlgorithm.mRsa.ToKeyAlgorithm(cx, rsa, aRv)
  4. ToKeyAlgorithm → mPublicExponent.ToUint8Array(aCx, aError) → Uint8Array::Create → CreateCommon
  5. Trigger: JS engine allocation fails (OOM under memory pressure). CreateCommon (TypedArray.h:807) calls error.StealExceptionFromJSContext(cx)
  6. StealExceptionFromJSContext (BindingUtils.cpp:695) → ThrowJSException(cx, exn) → js::AddRawValueRoot(cx, &mExtra.mJSException, ...) registers address S+8 in GCRuntime::rootsHash. Sets mResult = NS_ERROR_INTERNAL_ERRORRESULT_JS_EXCEPTION (0x80700001)
  7. ToKeyAlgorithm sees aError.Failed(), returns false
  8. The bug: CryptoKey::GetAlgorithm:215 calls aRv.Throw(NS_ERROR_DOM_OPERATION_ERR) → AssignErrorCode → mResult = 0x80530020. Root at S+8 remains in rootsHash but IsJSException() is now false
  9. Binding calls rv.MaybeSetPendingException(cx) → SetPendingException (BindingUtils.cpp:653) → IsJSException() is false → falls through to SetPendingGenericErrorException(cx) → dom::Throw(cx, ...) + mResult = NS_OK. Root not removed
  10. get_algorithm returns false. ~FastErrorResult() runs with suppress=false → SuppressException() not called. AssertReportedOrSuppressed() is no-op (MOZ_ASSERT only, !Failed() is true anyway since mResult == NS_OK)
  11. Stack frame for get_algorithm is reclaimed. rootsHash now holds dangling pointer to S+8
  12. Attacker makes subsequent calls that reuse stack region containing S+8, arranging for the 8 bytes at that address to form an Object-tagged JS::Value (top 17 bits = 0x1FFFC, low 47 bits = attacker-controlled pointer)
  13. GC runs (triggered by allocation pressure or explicitly). GCRuntime::traceRuntimeCommon iterates rootsHash, calls TraceRoot(trc, S+8, ...)
  14. TraceRoot reads the fake JS::Value, decodes it as an object pointer, calls TenuringTracer::onObjectEdge → InCollectedNurseryRegion reads 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 for FuzzingFunctions.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:215 and ErrorResult.h:556 is 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::Create must fail during publicExponent materialization. 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 large ArrayBuffer allocations). 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 rootsHash iteration. The diagnostic output confirms this slot is naturally overwritten by subsequent stack activity (value changed from 0xfffb1daeefc299e0 to 0x00000eedf3a1fef9 without any patch intervention). Controlling the exact bytes requires stack grooming — calling key.algorithm from 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.

Attached file PATCH_NOTES.md —
Attached file Crash stack trace —
Attached file source_patch.diff —
Group: core-security → dom-core-security

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?

Component: DOM: Security → DOM: Web Crypto
Flags: needinfo?(anna.weine)

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)

Flags: needinfo?(tschuster)

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.

Flags: needinfo?(tschuster)

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.

Severity: -- → S2

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.

Keywords: sec-high → sec-moderate
See Also: → 2025332

It seems like the fix here is entirely in the DOM code to make Throw safe? Should this be moved to a different component?

Flags: needinfo?(anna.weine)

No, you are still miss using the API. You should not overwrite one error with an other.

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?

(In reply to Dennis Jackson from comment #11)

It seems like the fix here is entirely in the DOM code to make Throw safe? 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?

Whiteboard: [prefs-checked]

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: ErrorCode 0x80700001 → 0x80530020, ASAN stack-use-after-scope in traceRuntimeCommon → rootsHash iteration.
  • After: ErrorCode stays 0x80700001, no crash (root removed normally via SetPendingJSException → 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.

The suggested fix is non idiomatic, let me fix this quickly.

Assignee: nobody → tschuster
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true

Maybe we use ThrowOperationError("...")?

Attachment #9559030 - Attachment is obsolete: true
Attached file (secure) —
Group: dom-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 7 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch

The patch landed in nightly and beta is affected.
:tschuster, is this bug important enough to require an uplift?

For more information, please visit BugBot documentation.

Flags: needinfo?(tschuster)

(In reply to Dennis Jackson from comment #11)

It seems like the fix here is entirely in the DOM code to make Throw safe? 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.

Flags: needinfo?(tschuster)
Depends on: 2025332
See Also: 2025332 →
QA Whiteboard: [sec] [qa-triage-done-c152/b151]
Whiteboard: [prefs-checked] → [prefs-checked][adv-main151+r][adv-esr115.36+r][adv-esr140.11+r]
Whiteboard: [prefs-checked][adv-main151+r][adv-esr115.36+r][adv-esr140.11+r] → [prefs-checked][adv-main151+r]
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: