Closed Bug 2027985 Opened 7 months ago Closed 7 months ago

Type Confusion in HasNativeElementPure via Incorrect AliasSet on MGuardIsNativeObject

Categories

(Core :: JavaScript Engine: JIT, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2027984

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

  1. Setup: Create a transplantable NativeObject with a sparse element (object[1000000] = 1). This makes Object.hasOwn(o, 1000000) attach the HasProp.Sparse CacheIR stub, which emits only GuardIsNativeObject → CallObjectHasSparseElement (the hasOwn path has no proto-chain guards, unlike the in operator).

  2. Warmup: With --only-inline-selfhosted, the self-hosted Object.hasOwn is inlined into target()'s Ion compilation, placing MGuardIsNativeObject(o) inside the loop body. The user function maybeTransplant() remains an opaque call.

  3. LICM: Because o is loop-invariant and GuardIsNativeObject has alias_set: none, LICM hoists the guard to the loop preheader. The guard now executes once before the loop, not on every iteration.

  4. Swap: Mid-loop, maybeTransplant() calls transplant(g), which performs JSObject::swap turning the NativeObject into a CrossCompartmentWrapper (a ProxyObject). Watchtower::watchObjectSwap does NOT invalidate Ion because the object isn't used as a prototype and has no fuse.

  5. Type Confusion: Subsequent loop iterations call HasNativeElementPure(cx, obj, 1000000, vp) with obj now a ProxyObject. The function casts it to NativeObject* and calls containsDenseElement() → getDenseInitializedLength(), which reads elements_[-offsetof(ObjectElements, initializedLength)]. But ProxyObject::data.handler overlays NativeObject::elements_ — so elements_ now holds the CrossCompartmentWrapper::singleton handler 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.

Attached file poc.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: