Type Confusion in HasNativeElementPure via Incorrect AliasSet on MGuardIsNativeObject
Categories
(Core :: JavaScript Engine: JIT, defect)
Tracking
()
People
(Reporter: bugmon, Unassigned)
Details
(5 keywords)
Attachments
(2 files)
Root Cause
The target file EdgeCaseAnalysis.cpp itself matches upstream — the vulnerability is in the MIR instruction definitions that the JIT optimization passes (including EdgeCaseAnalysis, AliasAnalysis, and LICM) operate over.
In js/src/jit/MIROps.yaml, the GuardIsNativeObject MIR instruction is defined with alias_set: none instead of the correct alias_set: load: ObjectFields:
- name: GuardIsNativeObject
operands:
object: Object
result_type: Object
guard: true
movable: true
congruent_to: if_operands_equal
alias_set: none # BUG: should be "load: ObjectFields"
This is one of ~10 similarly-affected MIR ops (GuardIsProxy, GuardIsNotProxy, GuardHasProxyHandler, MHasClass, MGuardToClass, MIsCallable, etc.) whose alias sets were weakened from Load(ObjectFields) to None().
Why alias_set:none Is Dangerous
In AliasAnalysis.cpp:217-220, instructions with AliasSet::None() are skipped entirely — setDependency() is never called, leaving their dependency null:
AliasSet set = def->getAliasSet();
if (set.isNone()) {
continue; // no dependency tracked
}
LICM then treats null-dependency instructions as freely hoistable (HasDependencyInLoop returns false). Since GuardIsNativeObject is also movable: true and non-effectful, LICM hoists it out of any loop whose operand is loop-invariant — even past calls that can change the object's class via JSObject::swap.
Attack Chain
-
Setup: Create a transplantable NativeObject with a sparse element (
object[1000000] = 1). This makesObject.hasOwn(o, 1000000)attach theHasProp.SparseCacheIR stub, which emits onlyGuardIsNativeObject → CallObjectHasSparseElement(the hasOwn path has no proto-chain guards, unlike theinoperator). -
Warmup: With
--only-inline-selfhosted, the self-hostedObject.hasOwnis inlined intotarget()'s Ion compilation, placingMGuardIsNativeObject(o)inside the loop body. The user functionmaybeTransplant()remains an opaque call. -
LICM: Because
ois loop-invariant andGuardIsNativeObjecthasalias_set: none, LICM hoists the guard to the loop preheader. The guard now executes once before the loop, not on every iteration. -
Swap: Mid-loop,
maybeTransplant()callstransplant(g), which performsJSObject::swapturning the NativeObject into aCrossCompartmentWrapper(a ProxyObject).Watchtower::watchObjectSwapdoes NOT invalidate Ion because the object isn't used as a prototype and has no fuse. -
Type Confusion: Subsequent loop iterations call
HasNativeElementPure(cx, obj, 1000000, vp)withobjnow a ProxyObject. The function casts it toNativeObject*and callscontainsDenseElement()→getDenseInitializedLength(), which readselements_[-offsetof(ObjectElements, initializedLength)]. ButProxyObject::data.handleroverlaysNativeObject::elements_— soelements_now holds theCrossCompartmentWrapper::singletonhandler pointer, and the read lands 12 bytes before that global in the ASAN redzone.
Crash Signature
AddressSanitizer: global-buffer-overflow
READ of size 4
#0 js::NativeObject::getDenseInitializedLength() NativeObject.h:734
#1 js::NativeObject::containsDenseElement() NativeObject.h:730
#2 js::jit::HasNativeElementPure() VMFunctions.cpp:2258
Address is located 12 bytes before global 'js::CrossCompartmentWrapper::singleton'
Address is located 52 bytes after global 'vtable for js::CrossCompartmentWrapper'
Exploitability
This is a NativeObject/ProxyObject type confusion. In a non-ASAN build, getDenseInitializedLength() would read a 4-byte value from between the CCW vtable and singleton (likely zero padding or vtable data), potentially allowing the attacker to continue executing with a corrupted initializedLength. More powerful primitives are likely reachable through other code paths guarded by the 9 other weakened MIR ops (e.g., MGuardToClass, MHasClass), enabling arbitrary read/write via controlled type confusion between object layouts.
Required Flags
--fast-warmup --no-threads --ion-offthread-compile=off --only-inline-selfhosted (all compatible with --fuzzing-safe).
The --only-inline-selfhosted flag is key: it ensures Object.hasOwn is inlined (bringing the vulnerable guard into the caller's loop for LICM) while keeping maybeTransplant as an opaque call that doesn't block hoisting of alias_set:none instructions.
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
Updated•7 months ago
|
Updated•7 months ago
|
| Reporter | ||
Comment 5•7 months ago
|
||
No valid actions for resolution (DUPLICATE).
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.
Updated•2 months ago
|
Description
•