Type confusion in Warp-compiled [@ js::NativeGetElement] due to incorrect `alias_set: none` on MGuardIsNativeObject
Categories
(Core :: JavaScript Engine: JIT, defect, P1)
Tracking
()
People
(Reporter: bugmon, Assigned: jandem)
References
(Blocks 1 open bug)
Details
(5 keywords, Whiteboard: [adv-main150+r][adv-esr140.10+r][adv-esr115.35+r])
Attachments
(2 files, 5 external files)
Summary
Vulnerability type: Type confusion (JIT optimizer alias-set misconfiguration)
Affected file: js/src/jit/MIROps.yaml, line 2607
Root cause: MGuardIsNativeObject declares alias_set: none, telling alias analysis that the instruction reads no memory. However, its codegen (MacroAssembler::branchIfNonNativeObj) loads obj->shape() and tests shape->immutableFlags & isNativeBit. The shape pointer is a mutable object field that can be rewritten by JSObject::swap during cross-compartment transplant. Because AliasAnalysis::analyze skips None alias sets (if (set.isNone()) continue;), both instances of the guard receive dependency() == nullptr. GVN's congruence check (ValueNumberer::VisibleValues::ValueHasher::match) then merges them across an effectful call that swaps the object into a proxy, and the stale guard output flows into MCallNativeGetElement, which passes a ProxyObject* as Handle<NativeObject*> to js::NativeGetElement.
Affected Code
File: js/src/jit/MIROps.yaml, lines 2599–2610
# Guard the object is a native object.
- name: GuardIsNativeObject
operands:
object: Object
result_type: Object
guard: true
movable: true
congruent_to: if_operands_equal
alias_set: none # BUG: codegen reads obj->shape() (ObjectFields)
generate_lir: true
lir_result_type: none
lir_temps: 1
Why it is vulnerable:
The generated code (CodeGenerator::visitGuardIsNativeObject → MacroAssembler::branchIfNonNativeObj) performs:
void MacroAssembler::branchIfNonNativeObj(Register obj, Register scratch,
Label* label) {
loadPtr(Address(obj, JSObject::offsetOfShape()), scratch); // ← load ObjectFields
branchTest32(Assembler::Zero,
Address(scratch, Shape::offsetOfImmutableFlags()),
Imm32(Shape::isNativeBit()), label);
}
The shape_ word is overwritten by JSObject::swap (JSObject.cpp:1289–1291):
js_memcpy(tmp, a, size);
js_memcpy(a, b, size); // overwrites a->shape_ with b->shape_
js_memcpy(b, tmp, size);
ObjectMayBeSwapped explicitly permits DOM-class objects (clasp->isDOMClass()) and proxies to be swapped, so a native DOM object can become a CrossCompartmentWrapper proxy at the same heap address — the exact transition this guard exists to catch.
Compare with the immediately preceding entries GuardShapeListToOffset (line 2577) and GuardMultipleShapesToOffset (line 2590), which also load obj->shape() and correctly declare alias_set: load: ObjectFields.
Correct Pattern
# Guard the object is a native object.
- name: GuardIsNativeObject
operands:
object: Object
result_type: Object
guard: true
movable: true
congruent_to: if_operands_equal
alias_set:
load: ObjectFields
generate_lir: true
lir_result_type: none
lir_temps: 1
With load: ObjectFields, alias analysis assigns a store dependency pointing to the preceding effectful MCallNativeGetElement / MCallGetProperty (the trigger[0] getter call). The two guard instances then have different dependency() values and fail the GVN congruence check at ValueNumbering.cpp:53, so the second guard stays in place and bails out after the swap.
Exploit Chain
-
Attach megamorphic IC. Create 10
TransplantableDOMObjectinstances, each with a unique named property (p0..p9, producing distinct shapes) and a sparse element[1000000]. Executingo[1000000]in a loop over all 10 drives theGetElemIC megamorphic.GetPropIRGenerator::tryAttachGenericElementattaches theGenericElementMegamorphicstub:guardIsNativeObject(objId)→guardIndexIsNotDenseElement(objId, indexId)→callNativeGetElementResult(objId, indexId). -
Warp transpilation. After warmup, Warp transpiles the stub via
WarpCacheIRTranspiler::emitGuardIsNativeObjectandemitCallNativeGetElementResult. Functionfcontains twoo[1000000]accesses separated bytrigger[0](a getter with an effectful call), producing MIR roughly:g1 = MGuardIsNativeObject(o) ; alias_set=None, dependency=nullptr ... MGuardIndexIsNotDenseElement(g1, 1000000) c1 = MCallNativeGetElement(g1, 1000000) ; effectful, stores Any ... MCallGetProperty(trigger, 0) ; effectful — runs JS getter g2 = MGuardIsNativeObject(o) ; alias_set=None, dependency=nullptr ... MGuardIndexIsNotDenseElement(g2, 1000000) c2 = MCallNativeGetElement(g2, 1000000) -
Alias analysis skip.
AliasAnalysis::analyzeencounters both guards withset.isNone()and executescontinue;, leavingdependency_ = nullptron both. (In contrast,MGuardIndexIsNotDenseElementreadsObjectFieldsand correctly picks up a dependency on the preceding effectful call — but its object operand comes from the guard, not directly fromo.) -
GVN merge.
ValueNumberer::VisibleValues::ValueHasher::matchcomparesg1andg2: same opcode, same operand (o), samedependency()(nullptr).congruentTo(generated fromcongruent_to: if_operands_equal) returns true.g2is replaced byg1. The secondMGuardIndexIsNotDenseElementandMCallNativeGetElementnow both consumeg1's output — which is justopassed through, checked once before any JS ran. -
Transplant. On the final iteration,
doTransplant = truecauses thetrigger[0]getter to calltargetTransplant(g2), which invokesJS_TransplantObject→JSObject::swap(target, newIdentityWrapper). The heap memory attarget's address now holds aCrossCompartmentWrapperproxy:target->shape()->isNative() == false, and the word atoffsetof(NativeObject, elements_)(= offset 16) now holds the proxy's handler pointer&js::CrossCompartmentWrapper::singleton. -
Stale guard output. Execution reaches the second element access. No guard runs (it was merged away).
CodeGenerator::visitCallNativeGetElementpushes the object register (still holdingtarget's address) and callsjs::NativeGetElement(cx, Handle<NativeObject*>{proxy_ptr}, ...). -
Type-confused dereference.
NativeGetElement→NativeGetProperty→NativeGetPropertyInline→NativeLookupOwnPropertyInlinecallsobj->containsDenseElement(1000000)→getDenseInitializedLength()→getElementsHeader()->initializedLength.getElementsHeader()computesreinterpret_cast<ObjectElements*>(elements_ - sizeof(ObjectElements)). Withelements_holding&CrossCompartmentWrapper::singletonandoffsetof(ObjectElements, initializedLength) - sizeof(ObjectElements) == -12(NativeObject.h:376-379), the 4-byte read lands at&CrossCompartmentWrapper::singleton - 12— inside the ASAN global redzone between theCrossCompartmentWrappervtable and its singleton instance.
Steps to Reproduce
No environment changes required. Run the testcase in an ASAN JS shell with:
js --fuzzing-safe --no-threads --stub-folding=off --baseline-warmup-threshold=10 --ion-warmup-threshold=100 poc.js
--stub-folding=off keeps the megamorphic stub in a form Warp transpiles directly; --no-threads ensures deterministic Ion compilation ordering.
Security Impact
Severity: High (sec-high). This is a JIT-level type confusion between ProxyObject and NativeObject reaching C++ VM code.
Attacker capability:
- A
ProxyObject*is passed asHandle<NativeObject*>toNativeGetElement, which treats proxy internals (handlerpointer, reserved slots) asNativeObject::elements_. - In a non-ASAN build,
getDenseInitializedLength()reads 4 bytes athandler_ptr - 12. If that value happens to exceed the requested index (1000000), the next stepelements_[idx].isMagic(JS_ELEMENTS_HOLE)reads 8 bytes athandler_ptr + idx*8— an attacker-influenced large out-of-bounds read into .rodata/.data, potentially leakable through the returned property value. - More broadly, any
NativeObjectmethod reachable fromNativeGetPropertyInlineon this path operates on proxy memory with the wrong layout interpretation. Proto-chain walking, dense element reads, and slot accesses all become wild reads.
Preconditions:
- Requires Ion/Warp compilation of a function with two
GuardIsNativeObject-backed element accesses to the same object, separated by a call that can triggerJS_TransplantObject. - In the browser, transplant is triggered by cross-compartment navigation primitives (e.g.
document.adoptNode,document.domainchanges, WindowProxy remapping). The shell testcase usestransplantableObject()as a stand-in for a DOM reflector. - The target object must satisfy
ObjectMayBeSwapped(isDOMClass()orisProxyObject()) — all DOM reflectors qualify.
Related latent bugs: GuardIsProxy (MIROps.yaml:2632), GuardIsNotDOMProxy (:2644), and GuardIsNotProxy (:2657) have the same alias_set: none declaration and also read obj->shape() in their codegen. They are likely vulnerable to the same GVN merge across a swap.
ASAN Report
==636193==ERROR: AddressSanitizer: global-buffer-overflow on address 0x57b8289441f4 at pc 0x57b826f8b44e bp 0x7fff437d7750 sp 0x7fff437d7748
READ of size 4 at 0x57b8289441f4 thread T0
#0 0x57b826f8b44d in js::NativeObject::getDenseInitializedLength() const /firefox/js/src/vm/NativeObject.h:734:33
#1 0x57b826f8b44d in js::NativeObject::containsDenseElement(unsigned int) const /firefox/js/src/vm/NativeObject.h:730:18
#2 0x57b826f8b44d in bool js::NativeLookupOwnPropertyInline<(js::AllowGC)1, (js::LookupResolveMode)1>(JSContext*, js::MaybeRooted<js::NativeObject*, (js::AllowGC)1>::HandleType, js::MaybeRooted<JS::PropertyKey, (js::AllowGC)1>::HandleType, js::PropertyResult*) /firefox/js/src/vm/NativeObject-inl.h:730:14
#3 0x57b826f8b44d in bool NativeGetPropertyInline<(js::AllowGC)1>(JSContext*, js::MaybeRooted<js::NativeObject*, (js::AllowGC)1>::HandleType, js::MaybeRooted<JS::Value, (js::AllowGC)1>::HandleType, js::MaybeRooted<JS::PropertyKey, (js::AllowGC)1>::HandleType, IsNameLookup, js::MaybeRooted<JS::Value, (js::AllowGC)1>::MutableHandleType) /firefox/js/src/vm/NativeObject.cpp:2306:10
#4 0x57b826f8b44d in js::NativeGetProperty(JSContext*, JS::Handle<js::NativeObject*>, JS::Handle<JS::Value>, JS::Handle<JS::PropertyKey>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/NativeObject.cpp:2353:10
#5 0x57b826f8c854 in js::NativeGetElement(JSContext*, JS::Handle<js::NativeObject*>, JS::Handle<JS::Value>, int, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/NativeObject.cpp:2379:10
#6 0x3bea6a01fab2 (<unknown module>)
0x57b8289441f4 is located 12 bytes before global variable 'js::CrossCompartmentWrapper::singleton' defined in '/firefox/obj-x86_64-pc-linux-gnu/dist/include/js/Wrapper.h:262' (0x57b828944200) of size 24
0x57b8289441f4 is located 52 bytes after global variable 'vtable for js::CrossCompartmentWrapper' defined in 'Unified_cpp_js_src_proxy0.cpp' (0x57b828944080) of size 320
SUMMARY: AddressSanitizer: global-buffer-overflow /firefox/js/src/vm/NativeObject.h:734:33 in js::NativeObject::getDenseInitializedLength() const
Suggested Fix
Change the alias set to reflect the actual memory read. In js/src/jit/MIROps.yaml:
# Guard the object is a native object.
- name: GuardIsNativeObject
operands:
object: Object
result_type: Object
guard: true
movable: true
congruent_to: if_operands_equal
- alias_set: none
+ alias_set:
+ load: ObjectFields
generate_lir: true
lir_result_type: none
lir_temps: 1
The same fix should be applied to GuardIsProxy, GuardIsNotDOMProxy, and GuardIsNotProxy, whose codegen paths (MacroAssembler::branchTestObjectIsProxy and related) also load obj->shape().
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
Updated•7 months ago
|
Updated•7 months ago
|
| Assignee | ||
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
| Assignee | ||
Comment 4•7 months ago
|
||
Great find. This is a problem with 'brain transplants', a mechanism that swaps the guts of certain JS objects (eg a DOM object with a cross-compartment wrapper). This is the only way for a native object in SpiderMonkey to become non-native (a proxy).
I'll post patches to fix up the alias sets of various MIR instructions to account for this. Longer term I want to think about what it would take to get rid of brain transplants and JSObject::swap, at least for the case involving native (non-proxy) objects.
I expect this patch to be fine performance-wise. If it does cause performance issues we could add a fuse to keep the current behavior and deoptimize the first time an object is swapped.
| Assignee | ||
Comment 5•7 months ago
|
||
| Assignee | ||
Comment 6•7 months ago
|
||
| Assignee | ||
Comment 7•7 months ago
|
||
Comment on attachment 9557345 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Not trivial but doable.
- 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?: All
- 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?: Not hard but this needs different patches for ESR115 and ESR140
- How likely is this patch to cause regressions; how much testing does it need?: Unlikely. It makes some JIT optimizations more conservative.
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
Comment 8•7 months ago
|
||
Comment on attachment 9557345 [details]
(secure)
sec-approval+ to land and request uplifts; hold off on landing the test until May (I'll set a reminder)
Updated•7 months ago
|
Comment 10•7 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined/Reason for urgency: Security bugs.
- 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: Makes some JIT optimizations a bit more conservative; should be low risk.
- String changes made/needed?: N/A
- Is Android affected?: yes
| Assignee | ||
Comment 11•7 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D289518
| Assignee | ||
Comment 12•7 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D289518
Updated•7 months ago
|
Comment 13•7 months ago
|
||
firefox-esr140 Uplift Approval Request
- User impact if declined/Reason for urgency: Security bugs.
- 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: Makes some JIT optimizations a bit more conservative; should be low risk.
- String changes made/needed?: N/A
- Is Android affected?: yes
Comment 14•7 months ago
|
||
firefox-esr115 Uplift Approval Request
- User impact if declined/Reason for urgency: Security bugs.
- 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: Makes some JIT optimizations a bit more conservative; should be low risk.
- String changes made/needed?: N/A
- Is Android affected?: yes
| Assignee | ||
Comment 15•7 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D289518
Comment 16•7 months ago
•
|
||
Updated•7 months ago
|
Updated•7 months ago
|
Comment 17•7 months ago
|
||
| uplift | ||
Updated•7 months ago
|
Updated•7 months ago
|
Comment 18•7 months ago
|
||
| uplift | ||
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
Comment 19•7 months ago
|
||
| uplift | ||
Updated•6 months ago
|
Comment 22•5 months ago
|
||
2 months ago, dveditz placed a reminder on the bug using the whiteboard tag [reminder-test 2026-05-19] .
jandem, please refer to the original comment to better understand the reason for the reminder.
| Assignee | ||
Comment 23•5 months ago
|
||
For posterity, we've removed support for this class of bugs (swapping a native object with another object). We can only swap two proxy objects now and that's a lot less crazy and error-prone.
See bug 2037186. This was made possible by DOM changes in bug 2037172 and bug 1470017.
Comment 24•5 months ago
|
||
Comment 25•5 months ago
|
||
Updated•2 months ago
|
Description
•