Closed Bug 2027990 Opened 7 months ago Closed 7 months ago

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)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2027984

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

  1. Create a transplantable DOM object and store a sparse indexed property (domObj[100000] = 1) to make it indexed-but-not-dense.
  2. Call Object.hasOwn(domObj, 100000) repeatedly to warm up the HasProp IC. HasPropIRGenerator::tryAttachSparse attaches a stub emitting GuardIsNativeObject + CallObjectHasSparseElementResult.
  3. Warp-compile foo(obj, idx) which contains two Object.hasOwn(obj, idx) calls separated by cb(). The transpiler emits two MGuardIsNativeObject + MCallObjectHasSparseElement pairs.
  4. GVN runs: both MGuardIsNativeObject nodes have the same operand (obj), are congruent_to: if_operands_equal, and have alias_set: none so neither depends on the intervening MCall. The second guard is eliminated and replaced with the first guard's result.
  5. On the trigger iteration, the first GuardIsNativeObject passes (object is still native). cb() calls transplant(g2), invoking JSObject::swap to replace domObj's contents with a CrossCompartmentWrapper proxy.
  6. The second MCallObjectHasSparseElement executes without re-guarding. It calls HasNativeElementPure(cx, (NativeObject*)proxy, 100000, vp).
  7. HasNativeElementPure calls obj->containsDenseElement(index) → getDenseInitializedLength() → reads getElementsHeader()->initializedLength. On a ProxyObject, the memory slot at the elements_ offset actually holds the BaseProxyHandler* pointer (&CrossCompartmentWrapper::singleton).
  8. getElementsHeader() subtracts sizeof(ObjectElements) from this pointer and reads initializedLength at a negative offset, landing 12 bytes before the CrossCompartmentWrapper::singleton global → 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

Attached file testcase.js —
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Status: UNCONFIRMED → RESOLVED
Closed: 7 months ago
Duplicate of bug: 2027984
Resolution: --- → DUPLICATE

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.

Keywords: bugmon
Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: