Type confusion in [@ js::jit::HasNativeElementPure] due to incorrect alias_set on MGuardIsNativeObject allowing GVN elimination across JSObject::swap
Categories
(Core :: JavaScript Engine: JIT, defect)
Tracking
()
People
(Reporter: bugmon, Unassigned)
Details
(5 keywords)
Attachments
(2 files)
Summary
Type confusion vulnerability in js/src/jit/MIROps.yaml line 2608. The GuardIsNativeObject MIR opcode is declared with alias_set: none, which tells the optimizer the guard does not depend on any mutable memory. Combined with movable: true and congruent_to: if_operands_equal, this allows Global Value Numbering (GVN) to eliminate redundant GuardIsNativeObject guards even when an intervening call can change the object's class via JSObject::swap (transplant). When the guard is incorrectly eliminated, CallObjectHasSparseElement invokes HasNativeElementPure with a ProxyObject cast to NativeObject*, causing type confusion.
Affected Code
File: js/src/jit/MIROps.yaml, line 2601-2611
# 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: should be load: ObjectFields
generate_lir: true
lir_result_type: none
lir_temps: 1
With alias_set: none, alias analysis concludes this guard cannot be invalidated by any store. GVN therefore treats two GuardIsNativeObject(obj) instructions on the same SSA operand as fully redundant, even when separated by an arbitrary call. However, JSObject::swap (used by DOM transplanting / document.adoptNode) can change an object's class in-place, converting a NativeObject into a ProxyObject. The guard checks the object's shape/class, which is part of ObjectFields, so eliminating it across a call is unsound.
The consumer HasPropIRGenerator::tryAttachSparse emits:
writer.guardIsNativeObject(objId);
...
writer.callObjectHasSparseElementResult(objId, indexId);
And CodeGenerator::visitCallObjectHasSparseElement directly passes the raw object pointer to HasNativeElementPure which expects a NativeObject*:
using Fn = bool (*)(JSContext*, NativeObject*, int32_t, Value*);
...
masm.callWithABI<Fn, HasNativeElementPure>();
Correct Pattern
- 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
This matches the pattern used by MGuardToClass::getAliasSet which returns AliasSet::Load(AliasSet::ObjectFields). With this alias set, GVN will not eliminate the guard across any instruction that may store to ObjectFields (including calls, which are treated as storing to all alias sets).
Exploit Chain
- Create a transplantable DOM object and store a sparse indexed property (
domObj[100000] = 1) to make it indexed-but-not-dense. - Call
Object.hasOwn(domObj, 100000)repeatedly to warm up theHasPropIC.HasPropIRGenerator::tryAttachSparseattaches a stub emittingGuardIsNativeObject+CallObjectHasSparseElementResult. - Warp-compile
foo(obj, idx)which contains twoObject.hasOwn(obj, idx)calls separated bycb(). The transpiler emits twoMGuardIsNativeObject+MCallObjectHasSparseElementpairs. - GVN runs: both
MGuardIsNativeObjectnodes have the same operand (obj), arecongruent_to: if_operands_equal, and havealias_set: noneso neither depends on the interveningMCall. The second guard is eliminated and replaced with the first guard's result. - On the trigger iteration, the first
GuardIsNativeObjectpasses (object is still native).cb()callstransplant(g2), invokingJSObject::swapto replacedomObj's contents with aCrossCompartmentWrapperproxy. - The second
MCallObjectHasSparseElementexecutes without re-guarding. It callsHasNativeElementPure(cx, (NativeObject*)proxy, 100000, vp). HasNativeElementPurecallsobj->containsDenseElement(index)→getDenseInitializedLength()→ readsgetElementsHeader()->initializedLength. On aProxyObject, the memory slot at theelements_offset actually holds theBaseProxyHandler*pointer (&CrossCompartmentWrapper::singleton).getElementsHeader()subtractssizeof(ObjectElements)from this pointer and readsinitializedLengthat a negative offset, landing 12 bytes before theCrossCompartmentWrapper::singletonglobal → ASAN global-buffer-overflow.
Security Impact
Severity: High
Attacker capability: Type confusion between NativeObject and ProxyObject in JIT-compiled code. The proxy's handler pointer is interpreted as the native object's elements_ pointer. Subsequent operations in HasNativeElementPure (shape()->lookup, getClass(), is<TypedArrayObject>()) operate on the confused object layout. With a carefully crafted proxy or by grooming adjacent memory, this type confusion could be escalated to an out-of-bounds read/write primitive. The containsDenseElement check compares index against a misread initializedLength; if this spuriously passes, further dense element access would read attacker-influenced memory.
Preconditions: Requires Ion compilation (default-enabled). In the browser, JSObject::swap is reachable via DOM node adoption (document.adoptNode) across same-origin documents, so no special privileges are required beyond running JavaScript.
ASAN Report
==116350==ERROR: AddressSanitizer: global-buffer-overflow on address 0x55cf707cfe14 at pc 0x55cf6de697ca bp 0x7ffe77467f60 sp 0x7ffe77467f58
READ of size 4 at 0x55cf707cfe14 thread T0
#0 0x55cf6de697c9 in js::NativeObject::getDenseInitializedLength() const /firefox/js/src/vm/NativeObject.h:734:33
#1 0x55cf6de697c9 in js::NativeObject::containsDenseElement(unsigned int) const /firefox/js/src/vm/NativeObject.h:730:18
#2 0x55cf6de697c9 in js::jit::HasNativeElementPure(JSContext*, js::NativeObject*, int, JS::Value*) /firefox/js/src/jit/VMFunctions.cpp:2258:12
...
0x55cf707cfe14 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' (0x55cf707cfe20) of size 24
0x55cf707cfe14 is located 52 bytes after global variable 'vtable for js::CrossCompartmentWrapper' defined in 'Unified_cpp_js_src_proxy0.cpp' (0x55cf707cfca0) 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 of GuardIsNativeObject in js/src/jit/MIROps.yaml to depend on ObjectFields, matching MGuardToClass and other class/shape guards:
- 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
This ensures GVN treats the guard as dependent on object field stores, preventing elimination across calls that may invoke JSObject::swap.
Reproduction
Run with: js --fuzzing-safe --ion-offthread-compile=off --no-threads testcase.js
| 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
•