Closed Bug 2028009 Opened 7 months ago Closed 7 months ago

Type confusion via incorrect GLB algorithm in [@ wasm::RefType::greatestLowerBound] when nullability and heap-type ordering are crossed

Categories

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

defect

Tracking

()

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

People

(Reporter: bugmon, Assigned: bvisness)

References

(Blocks 1 open bug)

Details

(6 keywords, Whiteboard: [prefs-checked][adv-main150+r])

Attachments

(3 files, 3 external files, 3 obsolete files)

Summary

Vulnerability type: Type confusion (Wasm GC type system bypass)

Affected file: js/src/wasm/WasmValType.cpp, lines 146–173 (RefType::greatestLowerBound)

Root cause: RefType::greatestLowerBound(a, b) checks isSubTypeOf(a, b) and isSubTypeOf(b, a) as whole-type comparisons (heap type and nullability together). When a has a more-precise heap type but is nullable, and b has a less-precise heap type but is non-nullable, neither subtype check passes (nullability blocks one direction, heap hierarchy blocks the other). The function falls through to returning bottomType() — i.e., (ref none) — even though the true GLB is a real, inhabited type.

This bogus (ref none) propagates through GVN's ReplaceAllUsesWith, making MWasmRefCastConcrete::foldsTo treat every downstream cast as a trivially-valid upcast (since (ref none) ⊑ everything), eliding the runtime type check entirely.

Affected Code

File: js/src/wasm/WasmValType.cpp, lines 146–173

RefType RefType::greatestLowerBound(RefType a, RefType b) {
  MOZ_RELEASE_ASSERT(a.hierarchy() == b.hierarchy());

  // ...
  bool nullable = a.isNullable() && b.isNullable();

  // If one type is a subtype of the other, the lower type is the GLB.
  if (RefType::isSubTypeOf(a, b)) {          // <-- checks heap AND nullability
    MOZ_RELEASE_ASSERT(a.isNullable() == nullable);
    return a;
  }
  if (RefType::isSubTypeOf(b, a)) {          // <-- checks heap AND nullability
    MOZ_RELEASE_ASSERT(b.isNullable() == nullable);
    return b;
  }

  // The only possible common type now is the hierarchy's bottom type.
  return a.bottomType().withIsNullable(nullable);   // <-- WRONG when heap types ARE related
}

Why this is wrong: Consider a = (ref null $sub) and b = (ref $super) where $sub <: $super:

Check Result Reason
isSubTypeOf((ref null $sub), (ref $super)) false Line 1398: nullable subtype, non-nullable supertype → reject
isSubTypeOf((ref $super), (ref null $sub)) false Line 1443: TypeDef::isSubTypeOf($super, $sub) is false
Returned value (ref none) Falls through to bottom
Correct GLB (ref $sub) {null ∪ $sub-instances} ∩ {$super-instances} = {$sub-instances}

The algorithm conflates the two axes. A correct GLB must compute the heap-type GLB independently of nullability.

Secondary enabling code:

js/src/jit/MIR-wasm.h, line 2182 — MWasmSelect::congruentTo only compares operand pointers, allowing two selects with different (stale) wasmRefType stamps to be merged:

bool congruentTo(const MDefinition* ins) const override {
  return congruentIfOperandsEqual(ins);   // does not compare wasmRefType_
}

js/src/jit/ValueNumbering.cpp, line 165 — GVN applies GLB when merging congruent nodes. The code's reasoning (lines 127–164) is actually sound — two congruent nodes do represent the same value, so GLB is the right operation. But the GLB implementation is broken:

to->setWasmRefType(wasm::MaybeRefType::greatestLowerBound(from->wasmRefType(),
                                                          to->wasmRefType()));

js/src/jit/MIR-wasm.cpp, lines 944–957 — FoldTrivialWasmCasts elides the cast because (ref none) is a subtype of everything:

static MDefinition* FoldTrivialWasmCasts(MDefinition* ref,
                                         wasm::RefType sourceType,
                                         wasm::RefType destType) {
  // Upcasts are trivially valid.
  if (wasm::RefType::isSubTypeOf(sourceType, destType)) {
    return ref;   // <-- elides the cast; returns raw input
  }
  return nullptr;
}

Correct Pattern

RefType RefType::greatestLowerBound(RefType a, RefType b) {
  MOZ_RELEASE_ASSERT(a.hierarchy() == b.hierarchy());

  // GLB of nullability: non-nullable wins.
  bool nullable = a.isNullable() && b.isNullable();

  // Compute the heap-type GLB independently of nullability.
  // Normalize both to nullable so isSubTypeOf compares only heap types.
  RefType ha = a.withIsNullable(true);
  RefType hb = b.withIsNullable(true);

  if (RefType::isSubTypeOf(ha, hb)) {
    return a.withIsNullable(nullable);
  }
  if (RefType::isSubTypeOf(hb, ha)) {
    return b.withIsNullable(nullable);
  }

  // Heap types are genuinely unrelated — bottom is correct.
  return a.bottomType().withIsNullable(nullable);
}

With this fix, GLB((ref null $sub), (ref $super)) → (ref $sub), and the subsequent cast to $unrelated correctly does not fold (since $sub ⊄ $unrelated).

Exploit Chain

Given Wasm types: $sub <: $super, and $unrelated (no relationship). Parameters $x, $y : (ref $sub).

  1. Construct Select A with operands (ref.cast (ref $super) $x, $y, $cond). During TrackWasmRefTypes, computeWasmRefType stamps it as LUB((ref $super), (ref $sub)) = (ref $super).

  2. Construct Select B with operands ($x, ref.cast (ref null $sub) $y, $cond). TrackWasmRefTypes stamps it as LUB((ref $sub), (ref null $sub)) = (ref null $sub).

  3. GVN folds cast A. ref.cast (ref $super) on (ref $sub) input: isSubTypeOf((ref $sub), (ref $super)) = true → FoldTrivialWasmCasts returns $x directly. Select A's operand 0 is now the raw $x MParameter.

  4. GVN folds cast B. ref.cast (ref null $sub) on (ref $sub) input: isSubTypeOf((ref $sub), (ref null $sub)) = true → returns $y directly. Select B's operand 1 is now the raw $y MParameter.

  5. Both selects now have identical operands ($x, $y, $cond). MWasmSelect::congruentTo returns true.

  6. GVN merges Select B into Select A. ReplaceAllUsesWith computes GLB((ref null $sub), (ref $super)). Neither isSubTypeOf check passes (nullability blocks one, heap order blocks the other) → returns (ref none). Select A's wasmRefType is overwritten with (ref none).

  7. GVN processes ref.cast (ref null $unrelated) on the merged select. ref()->wasmRefType() = (ref none). isSubTypeOf((ref none), (ref null $unrelated)) = true (none is bottom of Any hierarchy). The cast folds away — no runtime check is emitted.

  8. struct.set $unrelated N executes on a $sub object. Fields are read/written at $unrelated's offsets against $sub's memory layout → out-of-bounds access, ref/i64 confusion, or wild pointer dereference depending on field types.

Security Impact

Severity: Critical (sec-critical)

Attacker capability: Full Wasm GC type confusion. A web-content-controlled Wasm module compiled with Ion can:

  • addrOf primitive (demonstrated): Confuse a struct holding i64 with one holding anyref at the same offset → write a JS object reference via struct.set, read it back as i64 via struct.get. I reproduced this and leaked a JS object pointer: 0x14c1a1863038.
  • fakeObj primitive: The reverse — write an attacker-chosen i64 where the engine later reads it as a GC reference.
  • OOB write (original PoC): Confuse a small inline-storage struct with a large outline-storage struct → struct.set on a high-index field dereferences a bogus outline-data pointer and writes attacker-controlled data (rdx = 0xdeadbeefdeadbeef).

Preconditions:

  • Wasm GC enabled (shipped by default in Firefox).
  • Ion compilation of the Wasm module. In production Firefox, Baseline runs first and traps correctly; the attacker must wait for Ion tier-up (warm-up threshold) or the engine must select Ion as the sole compiler.
  • No sandbox escape required — this is reachable from ordinary web content via WebAssembly.Module / WebAssembly.Instance.

ASAN Report

AddressSanitizer:DEADLYSIGNAL
=================================================================
==372783==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000178 (pc 0x3d1e8b84607f bp 0x7ffebb508d90 sp 0x7ffebb508d90 T0)
==372783==The signal is caused by a WRITE memory access.
==372783==Hint: address points to the zero page.
    #0 0x3d1e8b84607f  (<unknown module>)
    #1 0x3d1e8b8461d8  (<unknown module>)
    #2 0x568788f57054 in js::wasm::Instance::callExport(JSContext*, unsigned int, JS::CallArgs const&, js::wasm::CoercionLevel) /firefox/js/src/wasm/WasmInstance.cpp:4067:10
    #3 0x568788f55d3c in WasmCall(JSContext*, unsigned int, JS::Value*) /firefox/js/src/wasm/WasmInstance.cpp:3853:19
    #4 0x5687887198aa in CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:490:13
    #5 0x5687887198aa in js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:586:12
    #6 0x5687887360d1 in InternalCall(JSContext*, js::AnyInvokeArgs const&, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:653:10
    #7 0x5687887360d1 in js::CallFromStack(JSContext*, JS::CallArgs const&, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:658:10
    #8 0x5687887360d1 in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3272:16
    #9 0x568788718796 in MaybeEnterInterpreterTrampoline(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:384:10
    #10 0x568788718796 in js::RunScript(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:460:13
    #11 0x56878871d3d7 in js::ExecuteKernel(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, js::AbstractFramePtr, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:850:10
    #12 0x568788a9fee2 in JS_ExecuteScript(JSContext*, JS::Handle<JSScript*>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:572:10
    #13 0x568786bd14c7 in RunFile(JSContext*, char const*, _IO_FILE*, CompileUtf8, bool, bool) /firefox/js/src/shell/js.cpp:1388:10
    #14 0x568786bd0b90 in Process(JSContext*, char const*, bool, FileKind) /firefox/js/src/shell/js.cpp
    #15 0x568786b7c665 in ProcessArgs(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12107:10
    #16 0x568786b7c665 in Shell(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12360:12
    #17 0x568786b69d98 in main /firefox/js/src/shell/js.cpp:12766:12
    #18 0x78f1014611c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
    #19 0x78f10146128a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
    #20 0x568786a72b18 in _start (/firefox/obj-x86_64-pc-linux-gnu/dist/bin/js+0x2d7eb18)

==372783==Register values:
rax = 0x4141414141414141  rbx = 0x00007ffebb509000  rcx = 0x0000000000000000  rdx = 0xdeadbeefdeadbeef
rdi = 0x00003edbffb009f0  rsi = 0x00003edbffb00a10  rbp = 0x00007ffebb508d90  rsp = 0x00007ffebb508d90
 r8 = 0x0000000000000050   r9 = 0x000000000000000a  r10 = 0x00007ffebb509000  r11 = 0x00000edaa01142b8
r12 = 0x00007ffebb508e08  r13 = 0x00001000576991e0  r14 = 0x00007661008e7080  r15 = 0x0000000000000000
SUMMARY: AddressSanitizer: SEGV (<unknown module>)
==372783==ABORTING

Register forensics: rax = 0x4141414141414141 is the controlled i64 field value from $sub (the struct.get $super 0 result). rdx = 0xdeadbeefdeadbeef is the controlled struct.set write value. rcx = 0 is the outline-data pointer slot — zero because $sub is an inline-storage struct and has no such pointer; the write lands at [rcx + 0x178] (field 63's offset in the outline buffer). With different type layouts, rcx becomes attacker-controlled.

addrOf variant output (no crash, clean pointer leak):

Iteration 0: leaked value = 0x14c1a1863038
*** TYPE CONFUSION CONFIRMED: leaked a pointer as i64! ***

Suggested Fix

Primary fix — js/src/wasm/WasmValType.cpp: Compute the heap-type GLB independently of nullability.

RefType RefType::greatestLowerBound(RefType a, RefType b) {
  MOZ_RELEASE_ASSERT(a.hierarchy() == b.hierarchy());

  // GLB of nullability is independent of heap type.
  bool nullable = a.isNullable() && b.isNullable();

  // Compare heap types ignoring nullability: normalize both to nullable
  // so only the heap-type relationship is tested.
  RefType ha = a.withIsNullable(true);
  RefType hb = b.withIsNullable(true);

  if (RefType::isSubTypeOf(ha, hb)) {
    // a's heap type is more precise (or equal).
    return ha.withIsNullable(nullable);
  }
  if (RefType::isSubTypeOf(hb, ha)) {
    // b's heap type is more precise.
    return hb.withIsNullable(nullable);
  }

  // Heap types are genuinely unrelated in the tree — bottom is correct.
  return a.bottomType().withIsNullable(nullable);
}

Defense in depth (optional) — js/src/jit/MIR-wasm.cpp: Reject cast folding when the source type is a non-nullable bottom type. (ref none) is uninhabited — if it appears as a source type, something has already gone wrong and folding should be conservative.

static MDefinition* FoldTrivialWasmCasts(MDefinition* ref,
                                         wasm::RefType sourceType,
                                         wasm::RefType destType) {
  // (ref none), (ref nofunc), etc. are uninhabited. A live value with this
  // type indicates an analysis bug — do not fold.
  if (!sourceType.isNullable() && sourceType.isBottomType()) {
    return nullptr;
  }
  if (wasm::RefType::isSubTypeOf(sourceType, destType)) {
    return ref;
  }
  return nullptr;
}
Attached file glb_type_confusion.js —
Attached file flags.txt —
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Flags: needinfo?(bvisness)

Seems legit. Haven't verified the exploit yet but it does seem the logic is incorrect in the way it says, which would have a variety of knock-on effects.

Flags: needinfo?(bvisness)
Attached file (secure) —
Assignee: nobody → bvisness
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attached file (secure) —
Blocks: sm-security
Severity: -- → S2
Priority: -- → P1
Whiteboard: [prefs-checked]

--wasm-compiler=ion is a reproduction aid, not a precondition — Ion is reached via default tier-up in production, and Wasm GC ships by default. Tagging [prefs-checked]; sec-high looks right (type confusion with demonstrated addrOf, web-reachable). Not attaching a fix since D291228/D291229 are already up.

This is an automated analysis result. If this result is incorrect please add a needinfo and feel free to correct the error.

Attached file (secure) (obsolete) —
Attached file (secure) (obsolete) —

Unable to reproduce bug 2028009 using build mozilla-central 20260331210827-1369941f425d. Without a baseline, bugmon is unable to analyze this bug.
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.

Keywords: bugmon
Attachment #9562409 - Attachment is obsolete: true
Attachment #9562410 - Attachment is obsolete: true

Comment on attachment 9562214 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: With medium difficulty, I expect. The patch points directly at the source of a problem (a bad GLB that would produce (ref none), and then an incorrectly-removed cast) but that's only part of a larger exploit chain.
  • 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
  • If not all supported branches, which bug introduced the flaw?: Bug 2000677
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: Easy to create, should be no significant differences. Risk will be unchanged.
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely to cause regressions as every change is just checking conditions and bailing out early.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Unknown
Attachment #9562214 - Flags: sec-approval?

Comment on attachment 9562214 [details]
(secure)

Approved to land and request uplift

Attachment #9562214 - Flags: sec-approval? → sec-approval+
Attached file (secure) —
Attachment #9563775 - Flags: approval-mozilla-beta?

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: Type confusion bug due to mis-optimization of wasm reference types.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: no
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: All modifications are bailing out early from an optimization.
  • String changes made/needed?: none
  • Is Android affected?: unknown

firefox-release Uplift Approval Request

  • User impact if declined/Reason for urgency: Type confusion bug due to mis-optimization of wasm reference types.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: no
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: All modifications are bailing out early from an optimization.
  • String changes made/needed?: none
  • Is Android affected?: unknown
Attachment #9563785 - Flags: approval-mozilla-release?
Attached file (secure) (obsolete) —
[task 2026-04-02T17:37:59.565+00:00] testWasmRefType_GLB
[task 2026-04-02T17:37:59.576+00:00] /builds/worker/checkouts/gecko/js/src/jsapi-tests/testWasmRefSubtypes.cpp:478:bad GLB of (ref null 0) and (ref any): expected (ref none), got (ref 0)
[task 2026-04-02T17:37:59.576+00:00] TEST-UNEXPECTED-FAIL | testWasmRefType_GLB | /builds/worker/checkouts/gecko/js/src/jsapi-tests/testWasmRefSubtypes.cpp:478:bad GLB of (ref null 0) and (ref any): expected (ref none), got (ref 0)
Flags: needinfo?(bvisness)

Comment on attachment 9563785 [details]
(secure)

Not ready for our planned dot release.

Attachment #9563785 - Flags: approval-mozilla-release? → approval-mozilla-release-

Comment on attachment 9563775 [details]
(secure)

This patch has been updated with the required test changes that have also just landed on Nightly. Assuming it's good on Nightly now, all the info in the uplift request should still be valid and a review would be appreciated.

Flags: needinfo?(bvisness)

Comment on attachment 9563785 [details]
(secure)

For good measure, this patch has also been updated. I don't know what the process allows here, but it would be good for this modified patch to be uplifted to release as well if possible.

Group: javascript-core-security → mozilla-employee-confidential
Status: ASSIGNED → RESOLVED
Closed: 7 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch
Group: mozilla-employee-confidential → core-security-release
Attachment #9563785 - Attachment is obsolete: true
Attachment #9563775 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c151/b150]
Whiteboard: [prefs-checked] → [prefs-checked][adv-main150+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: