Closed Bug 2050662 Opened 4 months ago Closed 4 months ago

Heap-use-after-free in [@ js::BytecodeRange::popFront] via DebuggerScript.getAllOffsets after relazification

Categories

(Core :: JavaScript: Debugger API, defect, P3)

defect

Tracking

()

RESOLVED FIXED
154 Branch
Tracking Status
firefox-esr115 --- wontfix
firefox-esr140 --- wontfix
firefox152 --- wontfix
firefox153 + fixed
firefox154 + fixed

People

(Reporter: bugmon, Assigned: arai)

References

(Blocks 2 open bugs)

Details

(5 keywords, Whiteboard: [adv-main153+r])

Attachments

(5 files, 2 external files)

DebuggerScript::CallData::getAllOffsets() iterates a script's bytecode using BytecodeRangeWithPosition, which captures raw pointers (pc, end, sn, snEnd, snpc, mainPC) into the heap-allocated ImmutableScriptData buffer owned by JSScript::sharedData_. Inside the iteration loop the function performs GC allocations (NewDenseEmptyArray at Script.cpp:1821). Although the JSScript itself is rooted, rooting does not prevent relazification: if the target script lives in a separate compartment whose realm is no longer a debuggee and is not currently entered, a shrinking GC triggered at the allocation point will call JSFunction::maybeRelazify(), drop the script's SharedImmutableScriptData reference, and SweepScriptData() then frees the underlying ImmutableScriptData in the same GC cycle. The subsequent popFront() dereferences the freed bytecode pointer.

The testcase makes this deterministic with gczeal(14, 2) (compacting/shrinking GC every 2 allocations) and produces an ASAN heap-use-after-free read in BytecodeRange::popFront() (BytecodeUtil-inl.h:101). On a debug build without ASAN the freed read is followed by 'Assertion failure: sharedData_, at js/src/vm/JSScript.h:1870' from frontOffset() -> pcToOffset() -> containsPC() -> length(). The intended guard for this hazard, JSScript::AutoDelazify (which clears AllowRelazify while bytecode pointers are held), is not used by getAllOffsets() or the other Debugger.Script bytecode iterators.

The Debugger API is privileged (DevTools / chrome JS), so this is not directly reachable from web content, but it is a real use-after-free in release configurations: gczeal only forces the timing — the underlying race exists whenever a shrinking GC fires at the NewDenseEmptyArray call and the target realm has been removed as a debuggee. With a reclaimed allocation in place of the freed ImmutableScriptData, the iterator interprets attacker-controlled bytes as opcodes/operand lengths, walking pc to an arbitrary offset before subsequent reads.

Build Info

Affected Code

File: js/src/vm/BytecodeUtil-inl.h, line 93-107

class BytecodeRange {
 public:
  BytecodeRange(JSContext* cx, JSScript* script)
      : script(cx, script), pc(script->code()), end(pc + script->length()) {}
  bool empty() const { return pc == end; }
  jsbytecode* frontPC() const { return pc; }
  JSOp frontOpcode() const { return JSOp(*pc); }
  size_t frontOffset() const { return script->pcToOffset(pc); }
  void popFront() { pc += GetBytecodeLength(pc); }   // <-- UAF read of *pc

 private:
  RootedScript script;   // roots the JSScript cell, does NOT pin sharedData_
  jsbytecode* pc;
  jsbytecode* end;
};

File: js/src/debugger/Script.cpp, line 1769-1837

bool DebuggerScript::CallData::getAllOffsets() {
  if (!ensureScript()) {            // delazifies, but does NOT clear AllowRelazify
    return false;
  }
  ...
  for (BytecodeRangeWithPosition r(cx, script, SkipPrologueOps::Yes);
       !r.empty(); r.popFront()) {  // r holds raw pc/end/sn pointers into ImmutableScriptData
    ...
    size_t offset = r.frontOffset();
    ...
        offsets = NewDenseEmptyArray(cx);   // <-- can GC; shrinking GC relazifies `script`
        if (!offsets || !PrimitiveValueToId<CanGC>(cx, v, &id)) {
          return false;
        }
    ...
  }
  ...
}

File: js/src/vm/JSFunction.cpp, line 1273-1317

void JSFunction::maybeRelazify(JSRuntime* rt) {
  ...
  Realm* realm = this->realm();
  if (!rt->allowRelazificationForTesting) {
    if (realm->compartment()->gcState.hasEnteredRealm) {
      return;                       // target is in a *separate* compartment -> not entered
    }
    MOZ_ASSERT(!realm->hasBeenEnteredIgnoringJit());
  }

  if (realm->isDebuggee()) {        // removeAllDebuggees() cleared this
    return;
  }
  ...
  JSScript* script = nonLazyScript();
  if (!script->allowRelazify()) {   // delazified scripts get AllowRelazify set
    return;
  }
  ...
  script->relazify(rt);             // -> freeSharedData(): drops ref on SharedImmutableScriptData
}

File: js/src/vm/JSScript.cpp, line 2412-2429

static void SweepScriptDataTable(SharedImmutableScriptDataTable& table) {
  for (auto iter = table.modIter(); !iter.done(); iter.next()) {
    SharedImmutableScriptData* sharedData = iter.get();
    if (sharedData->refCount() == 1) {   // only the table holds it after relazify
      sharedData->Release();             // -> js_delete<ImmutableScriptData>: frees bytecode buffer
      iter.remove();
    }
  }
}

void js::SweepScriptData(JSRuntime* rt) {
  SweepScriptDataTable(rt->scriptDataTableHolder().getWithoutLock());
  ...
}

File: js/src/vm/JSScript.h, line 1736-1741

SharedImmutableScriptData* sharedData() const { return sharedData_; }
void initSharedData(SharedImmutableScriptData* data) {
  MOZ_ASSERT(sharedData_ == nullptr);
  sharedData_ = data;
}
void freeSharedData() { sharedData_ = nullptr; }   // called from relazify()

BytecodeRange/BytecodeRangeWithPosition cache raw pointers into ImmutableScriptData. getAllOffsets() allocates inside the iteration loop without pinning the bytecode via AutoDelazify. JSFunction::maybeRelazify() permits relazification because the target compartment is not entered and is no longer a debuggee, and SweepScriptData frees the buffer in the same GC.

Exploit Chain

  1. Privileged JS creates a Debugger and adds a cross-compartment global g as a debuggee (newGlobal({newCompartment: true})).
  2. Define a relazifiable function f in g and obtain its Debugger.Script wrapper s via dbg.findScripts(). The first s.getAllOffsets() forces delazification (after which the script carries the AllowRelazify flag) and is also used so that f is later lazy again before the second call.
  3. Call dbg.removeAllDebuggees(): g's realm is no longer a debuggee, so JSFunction::maybeRelazify no longer bails on isDebuggee(). g's compartment is also not the currently-entered compartment, so the hasEnteredRealm guard does not apply.
  4. Call s.getAllOffsets() again. ensureScript() delazifies f, allocating a fresh ImmutableScriptData on the malloc heap; BytecodeRangeWithPosition captures raw pc/end/sn pointers into it.
  5. Inside the iteration loop, NewDenseEmptyArray(cx) (Script.cpp:1821) triggers a shrinking GC (forced deterministically here via gczeal(14, 2); in the wild this is a memory-pressure shrinking GC).
  6. GCRuntime::relazifyFunctionsForShrinkingGC() relazifies f -> JSScript::relazify -> freeSharedData() drops the script's reference; refCount on the SharedImmutableScriptData falls to 1 (table-only).
  7. In endSweepPhase, SweepScriptData() sees refCount==1 and Releases it, which js_delete()s the ImmutableScriptData heap allocation containing the bytecode and source notes.
  8. Control returns to the iteration loop; r.popFront() executes pc += GetBytecodeLength(pc), reading *pc from the freed buffer (heap-use-after-free). updatePosition() then continues reading the freed source-notes region.
  9. If the freed region is reclaimed by an attacker-shaped allocation between the GC and popFront(), GetBytecodeLength() and the source-note walker interpret attacker bytes as opcodes/lengths, advancing pc/sn out of the original bounds and enabling further out-of-bounds reads whose results are surfaced to JS as offsets/line numbers in the returned array.

Steps to Reproduce

  1. Build the SpiderMonkey shell with --enable-application=js --enable-debug --enable-fuzzing --enable-address-sanitizer --disable-jemalloc (e.g. the supplied mozconfig.js.linux.debug.asan).
  2. Save the testcase: var g = newGlobal({newCompartment: true}); var dbg = new Debugger(g); g.eval("function f(o){\n o.a=1;\n o.b=2;\n ... \n o.t=20;\n return o}"); var s = dbg.findScripts().find(s => s.displayName === "f"); s.getAllOffsets(); dbg.removeAllDebuggees(); gczeal(14, 2); s.getAllOffsets();
  3. Run: obj-js-debug-asan/dist/bin/js --fuzzing-safe testcase.js
  4. Observe: AddressSanitizer: heap-use-after-free in js::BytecodeRange::popFront() at js/src/vm/BytecodeUtil-inl.h:101, freed by SweepScriptData under GCRuntime::runDebugGC triggered from NewDenseEmptyArray inside getAllOffsets().
  5. Alternatively, on a plain --enable-debug build (no ASAN) the same testcase hits 'Assertion failure: sharedData_, at js/src/vm/JSScript.h:1870' from BytecodeRange::frontOffset() -> JSScript::pcToOffset -> containsPC -> length() inside getAllOffsets() at Script.cpp:1792.

Security Impact

  • Severity: Moderate
  • Attacker capability: Use-after-free read of a freed malloc-heap ImmutableScriptData buffer while it is being interpreted as bytecode opcodes and source notes. With heap grooming the freed allocation can be reclaimed by attacker-controlled data, letting the attacker control GetBytecodeLength(pc) and the source-note delta walker so that pc/sn march to attacker-chosen offsets; the resulting out-of-bounds bytes are returned to JS as numeric offsets/line numbers in the result object, providing a heap info leak primitive from privileged JS. Further corruption is plausible if downstream consumers index PrivateScriptData/gcthings using the bogus offsets.
  • Preconditions: Requires access to the privileged SpiderMonkey Debugger API (chrome/DevTools context — not web-exposed). The target script must live in a compartment that is not currently entered and whose realm is not a debuggee at GC time (achieved via newGlobal({newCompartment: true}) + Debugger.removeAllDebuggees()). A shrinking GC must occur at the NewDenseEmptyArray allocation inside the getAllOffsets() loop; the testcase forces this with gczeal(14, 2), but the same path exists in release builds under memory-pressure shrinking GC.

ASAN Report

==146543==ERROR: AddressSanitizer: heap-use-after-free on address 0x78addcae8561 at pc 0x59566617f024 bp 0x7fff7a802300 sp 0x7fff7a8022f8
READ of size 1 at 0x78addcae8561 thread T0
    #0 0x59566617f023 in js::BytecodeRange::popFront() /firefox/js/src/vm/BytecodeUtil-inl.h:101:27
    #1 0x59566617f023 in js::BytecodeRangeWithPosition::popFront() /firefox/js/src/vm/BytecodeUtil-inl.h:143:20
    #2 0x595666152a57 in js::DebuggerScript::CallData::getAllOffsets() /firefox/js/src/debugger/Script.cpp:1787:22
    #3 0x59566618bdd4 in bool js::DebuggerScript::CallData::ToNative<&js::DebuggerScript::CallData::getAllOffsets()>(JSContext*, unsigned int, JS::Value*) /firefox/js/src/debugger/Script.cpp:256:10
    #4 0x5956680c9125 in CallJSNative(...) /firefox/js/src/vm/Interpreter.cpp:498:13
    ...

0x78addcae8561 is located 33 bytes inside of 324-byte region [0x78addcae8540,0x78addcae8684)
freed by thread T0 here:
    #0 0x595665822726 in free
    #1 0x595668815e4e in js_free(void*) /firefox/obj-js-debug-asan/dist/include/js/Utility.h:432:3
    #2 0x595668815e4e in void js_delete<js::ImmutableScriptData>(js::ImmutableScriptData const*) /firefox/obj-js-debug-asan/dist/include/js/Utility.h:579:5
    #3 0x595668815e4e in js::SharedImmutableScriptData::reset() /firefox/js/src/vm/SharedStencil.h:674:7
    #4 0x595668815e4e in js::SharedImmutableScriptData::Release() /firefox/js/src/vm/SharedStencil.h:702:7
    #5 0x595668815e4e in SweepScriptDataTable(...) /firefox/js/src/vm/JSScript.cpp:2419:19
    #6 0x5956688159db in js::SweepScriptData(JSRuntime*) /firefox/js/src/vm/JSScript.cpp:2426:3
    #7 0x595666989d83 in js::gc::GCRuntime::endSweepPhase(bool) /firefox/js/src/gc/Sweeping.cpp:2833:5
    #8 0x5956667827be in js::gc::GCRuntime::incrementalSlice(...) /firefox/js/src/gc/GC.cpp:4506:7
    #9 0x59566678a223 in js::gc::GCRuntime::gcCycle(...) /firefox/js/src/gc/GC.cpp:5068:3
    #10 0x59566678d7cd in js::gc::GCRuntime::collect(...) /firefox/js/src/gc/GC.cpp:5258:9
    #11 0x595666756e94 in js::gc::GCRuntime::gc(JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GC.cpp:5338:3
    #12 0x59566679321b in js::gc::GCRuntime::runDebugGC() /firefox/js/src/gc/GC.cpp
    #13 0x595665ab18f5 in js::gc::PreAllocGCChecks(JSContext*) /firefox/js/src/gc/Allocator-inl.h:178:9
    ...
    #21 0x595666152f35 in js::DebuggerScript::CallData::getAllOffsets() /firefox/js/src/debugger/Script.cpp:1821:19

previously allocated by thread T0 here:
    #0 0x5956658229c4 in malloc
    ...
    #5 0x59566881402a in js::ImmutableScriptData::new_(...) /firefox/js/src/vm/JSScript.cpp:2267:35
    ...
    #16 0x5956661617a4 in DelazifyScript(JSContext*, JS::Handle<js::BaseScript*>) /firefox/js/src/debugger/Script.cpp:152:10
    #17 0x59566617ae09 in js::DebuggerScript::CallData::ensureScript() /firefox/js/src/debugger/Script.cpp:199:14
    #18 0x5956661527da in js::DebuggerScript::CallData::getAllOffsets() /firefox/js/src/debugger/Script.cpp:1770:8

SUMMARY: AddressSanitizer: heap-use-after-free /firefox/js/src/vm/BytecodeUtil-inl.h:101:27 in js::BytecodeRange::popFront()
==146543==ABORTING
Attached file trace.jsonl —
Attached file testcase.js —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Attached file NOTES.txt —
Group: core-security → javascript-core-security
Severity: -- → S3
Component: JavaScript Engine → JavaScript: Debugger API
Priority: -- → P3
Assignee: nobody → arai.unmht
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attached file (secure) —

Given JSScript::AutoDelazify has been unused, modified it to more specific to
the case where the script already has the bytecode.

Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 4 months ago
Resolution: --- → FIXED
Target Milestone: --- → 154 Branch

Please add an uplift request for beta

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: Possible crash when using DevTools debugger on webpage
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing: none
  • Risk associated with taking this patch: low
  • Explanation of risk level: This is mostly specific to the debugger API usage, which doesn't happen on regular browsing. And also the functions called in the added code path is well-tested.
  • String changes made/needed?: none
  • Is Android affected?: yes
Attachment #9606772 - Flags: approval-mozilla-beta?
Attached file (secure) —

Given JSScript::AutoDelazify has been unused, modified it to more specific to
the case where the script already has the bytecode.

Original Revision: https://phabricator.services.mozilla.com/D311030

Flags: needinfo?(arai.unmht)
Attachment #9606772 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c154/b153]
Whiteboard: [adv-main153+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: