Closed Bug 2024918 Opened 5 months ago Closed 5 months ago

Escape analysis bypass via phi in [@ IsWasmStructEscaped] leaves uninitialized anyref in live Wasm GC struct

Categories

(Core :: JavaScript: WebAssembly, defect, P2)

defect

Tracking

()

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

People

(Reporter: bugmon, Assigned: rhunt)

References

(Regression)

Details

(6 keywords, Whiteboard: [adv-main150+r][adv-esr140.10+r])

Attachments

(6 files)

Summary

Type: Uninitialized heap memory → GC type confusion / wild pointer dereference
File: js/src/jit/ScalarReplacement.cpp, line 3900
Root cause: IsWasmStructEscaped is called recursively when walking through phi nodes (IsWasmStructEscaped(phi, newStruct)), but the WasmStoreFieldRef case compares value() against the original newStruct parameter instead of the current iteration cursor ins. When the cursor is a phi and the phi is the value() operand of a store into an escaping struct, the comparison phi == newStruct is always false — the escape is missed. Scalar replacement then discards the struct's init store while the MWasmNewStructObject(zeroFields=false) allocation — which skips field zeroing at MacroAssembler.cpp:7645 — still reaches the heap with raw nursery garbage in a GC-traced anyref slot.

Affected Code

File: js/src/jit/ScalarReplacement.cpp, line 3900

static bool IsWasmStructEscaped(MDefinition* ins, MInstruction* newStruct) {
  ...
  for (MUseIterator i(ins->usesBegin()); i != ins->usesEnd(); i++) {
    ...
    MDefinition* def = consumer->toDefinition();
    switch (def->op()) {
      ...
      case MDefinition::Opcode::WasmStoreFieldRef: {
        // Escaped if it's stored into another struct.
        if (def->toWasmStoreFieldRef()->value() == newStruct) {  // <-- BUG: should compare to `ins`
          JitSpewDef(JitSpew_Escape, "is escaped by\n", def);
          return true;
        }
        break;
      }
      ...
      case MDefinition::Opcode::Phi: {
        auto* phi = def->toPhi();
        if (!WasmStructPhiOperandsEqualTo(phi, newStruct)) { ... return true; }
        if (IsWasmStructEscaped(phi, newStruct)) {           // <-- recursion: ins becomes `phi`
          ... return true;
        }
        break;
      }

Why it's wrong: At line 3883 the loop iterates ins->usesBegin(). Any consumer found there uses ins, not newStruct. In the top-level call ins == newStruct so the check happens to work. But after the recursion at line 3917, ins is the phi — so a WasmStoreFieldRef whose value() is the phi will satisfy value() != newStruct, and the function treats the store as using the phi as its base (safe) when it's actually the value (escaping).

Connection to MIR-wasm.cpp

MWasmStructState at MIR-wasm.cpp:1019-1045 is the state-tracking infrastructure that scalar replacement uses. MWasmStructState::init() sizes fields_ based on wasmStruct_->toWasmNewStructObject()->structType().fields_.length(). The state machine assumes that after SR completes, struct_->hasUses() is false — enforced only by MOZ_ASSERT (debug-only) at ScalarReplacement.cpp:3648. When the escape bypass occurs, MWasmStructState correctly tracks the field values but the MWasmNewStructObject itself leaks into the graph with no init stores and zeroFields=false, violating the invariant that MWasmStructState relies on.

Exploit Chain

  1. Wasm source defines three struct types: $Inner (holds anyref), $Outer (holds ref $Inner), $Container (holds ref $Inner, escapes via global).
  2. Ion compiles run after warmup. MIR contains:
    • s1 = MWasmNewStructObject($Outer, zeroFields=false)
    • s2 = MWasmNewStructObject($Inner, zeroFields=false)
    • container = MWasmNewStructObject($Container, ...) — escapes via global, never SR-eligible.
    • StoreA = MWasmStoreFieldRef(base=s1, value=s2) before the loop.
    • Inside the loop: Load = MWasmLoadField(base=s1)StoreB = MWasmStoreFieldRef(base=container, value=Load)StoreC = MWasmStoreFieldRef(base=s1, value=s2).
  3. ScalarReplacement iterates RPO. s1 is defined first, processed first.
  4. Escape check for s1 passes: all its uses are as base() of loads/stores.
  5. SR on s1 runs. mergeIntoSuccessorState creates Phi1 at the loop header for s1's field (loop has 2 predecessors). StoreA sets state to s2 pre-loop; StoreC sets state to s2 at backedge → Phi1 = phi(s2, s2). visitWasmLoadField replaces Load with Phi1, so StoreB becomes MWasmStoreFieldRef(base=container, value=Phi1). StoreA and StoreC are discarded.
  6. Driver loop reaches s2. Escape check IsWasmStructEscaped(s2, s2):
    • s2's init store: value() = i31ref ≠ s2 → safe (s2 is base).
    • s2 feeds Phi1: WasmStructPhiOperandsEqualTo(Phi1, s2) → true (both inputs are s2). Recurse: IsWasmStructEscaped(Phi1, s2).
    • Phi1 is used by StoreB. Line 3900: StoreB->value() == s2? → Phi1 == s2? → FALSE. Escape missed.
    • Returns not escaped. ← Bug triggered.
  7. SR on s2 runs: visitWasmStoreFieldRef discards s2's init store. visitPhi replaces Phi1 with s2StoreB is now MWasmStoreFieldRef(base=container, value=s2).
  8. assertSuccess is a no-op in releaseMOZ_ASSERT(!struct_->hasUses()) would fire in debug since StoreB still uses s2.
  9. Codegen: wasmNewStructObject with zeroFields=false does wasmBumpPointerAllocate + shape/supertype stores, skips the zeroing loop at line 7645. s2's field 0 contains raw prior nursery contents.
  10. Runtime: s2 (uninitialized) is stored into container, container is in global $g. read() dereferences s2->field[0] as anyref → wild pointer. Or GC traces container → s2 → garbage in WasmStructObject::obj_trace → follows garbage as object edge → SEGV in TenuringTracer::onObjectEdge.

Security Impact

Severity: sec-high / sec-critical

Attacker capability: A malicious webpage can deliver wasm with this struct-chaining pattern to the content-process Ion compiler (Wasm GC ships by default). The nursery is a bump-pointer allocator with no reuse poisoning in release; the attacker can spray it with arbitrary 64-bit values before triggering the bug, placing a chosen 64-bit word into the uninitialized anyref slot. Since the GC will subsequently TraceEdge this value as a JSObject*, this is a fake-object primitive — the attacker can point the GC at attacker-controlled memory, leading to arbitrary read/write and code execution in the content process.

Preconditions: None beyond default Ion/Wasm-GC — triggers under --fuzzing-safe with standard tiering (--fast-warmup only accelerates). No user interaction beyond visiting a page.

Differential: --ion-scalar-replacement=off → correct output final: -1052688063. Default → crash.

Suggested Fix

Compare value() against the current iteration cursor ins:

--- a/js/src/jit/ScalarReplacement.cpp
+++ b/js/src/jit/ScalarReplacement.cpp
@@ -3897,7 +3897,7 @@ static bool IsWasmStructEscaped(MDefinition* ins, MInstruction* newStruct) {
       }
       case MDefinition::Opcode::WasmStoreFieldRef: {
         // Escaped if it's stored into another struct.
-        if (def->toWasmStoreFieldRef()->value() == newStruct) {
+        if (def->toWasmStoreFieldRef()->value() == ins) {
           JitSpewDef(JitSpew_Escape, "is escaped by\n", def);
           return true;
         }

Additionally, upgrade assertSuccess at line 3648 from MOZ_ASSERT to MOZ_RELEASE_ASSERT(!struct_->hasUses()) — an escaping non-zeroed struct is always a memory-safety hazard.

Attached file Crash stack trace
Attached file Testcase: testcase.js
Group: core-security → javascript-core-security
Component: JavaScript Engine: JIT → JavaScript: WebAssembly
Attachment #9555540 - Attachment mime type: application/javascript → text/plain

Julien, needinfo'ing you since you did work on scalar replacement before.

Flags: needinfo?(jpages)
Assignee: nobody → jpages
Flags: needinfo?(jpages)
Severity: -- → S2
Priority: -- → P2

Sorry for the mid-air collision, but I wrote up a quick fix for this. The analysis and suggested fix are correct. The regressing bug is bug 1947614.

I "cleaned up" the hard coded '3' constant by changing:

       if (def->indexOf(*i) == 3) {

to:

        if (def->toWasmStoreFieldRef()->value() == newStruct) {

Which is the vulnerable form.

Keywords: regression
Regressed by: 1947614
Assignee: jpages → rhunt
Attached file (secure)

The analysis and the fix also look good to me.

The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.

Status: UNCONFIRMED → NEW
Ever confirmed: true

Comment on attachment 9555747 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Not easy for a human. Requires knowledge of Ion/scalar replacement algorithm. Maybe easier for Claude?
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
  • Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: Beta, release, ESR. Flags are right.
  • If not all supported branches, which bug introduced the flaw?: None
  • Do you have backports for the affected branches?: Yes
  • If not, how different, hard to create, and risky will they be?: One line change.
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely, it passes all our tests, and just reverts a bad change to a known good previous.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9555747 - Flags: sec-approval?

Comment on attachment 9555747 [details]
(secure)

sec-approval+ to land now and request uplifts; please wait until May to land the tests

Attachment #9555747 - Flags: sec-approval? → sec-approval+
Flags: in-testsuite?
Whiteboard: [reminder-test 2026-05-05]
Group: javascript-core-security → core-security-release
Status: NEW → RESOLVED
Closed: 5 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch

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

For more information, please visit BugBot documentation.

Flags: needinfo?(rhunt)
Duplicate of this bug: 2027986
Flags: needinfo?(rhunt)

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: fakeObj primitive that can lead to arbitrary read/write.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Reverts an optimization back to a previous good state.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9562243 - Flags: approval-mozilla-beta?

firefox-esr140 Uplift Approval Request

  • User impact if declined/Reason for urgency: fakeObj primitive that can lead to arbitrary read/write.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Reverts an optimization back to a previous good state.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9562244 - Flags: approval-mozilla-esr140?
Attachment #9562243 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c151/b150]
Attachment #9562244 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Whiteboard: [reminder-test 2026-05-05] → [reminder-test 2026-05-05][adv-main150+r][adv-esr140.10+r]

a month ago, dveditz placed a reminder on the bug using the whiteboard tag [reminder-test 2026-05-05] .

rhunt, please refer to the original comment to better understand the reason for the reminder.

Flags: needinfo?(rhunt)
Whiteboard: [reminder-test 2026-05-05][adv-main150+r][adv-esr140.10+r] → [adv-main150+r][adv-esr140.10+r]
Group: core-security-release
Flags: needinfo?(rhunt)
Attachment #9555748 - Attachment description: (secure) → Bug 2024918 - wasm: Add test. r?jseward
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: