Closed Bug 2038575 (CVE-2026-8391) Opened 4 months ago Closed 4 months ago

JS_ELEMENTS_HOLE Leak and Magic Leak to RCE

Categories

(Core :: JavaScript Engine, defect, P1)

defect

Tracking

()

RESOLVED FIXED
152 Branch
Tracking Status
firefox-esr115 151+ fixed
firefox-esr140 151+ fixed
firefox150 + fixed
firefox151 + fixed
firefox152 + fixed

People

(Reporter: gaddofpwn, Assigned: jandem)

References

(Blocks 2 open bugs, Regression)

Details

(4 keywords, Whiteboard: [client-bounty-form][adv-main150.0.3+][adv-esr140.11+][adv-esr115.36+])

Attachments

(10 files, 1 obsolete file)

67 bytes, text/html
Details
2.13 KB, application/x-javascript
Details
31.17 KB, application/x-javascript
Details
1.84 KB, application/x-javascript
Details
48 bytes, text/x-phabricator-request
Details | Review
48 bytes, text/x-phabricator-request
Details | Review
48 bytes, text/x-phabricator-request
Details | Review
48 bytes, text/x-phabricator-request
Details | Review
48 bytes, text/x-phabricator-request
Details | Review
48 bytes, text/x-phabricator-request
Details | Review
Attached file last.html

JS_ELEMENTS_HOLE Leak and Magic Leak to RCE

Summary

SpiderMonkey's iterator-indices optimization can keep using a dense element
PropertyIndex after Object.defineProperty converts that dense element into a
sparse indexed property and a later addProperty hook failure removes only the
sparse property. The dense element remains JS_ELEMENTS_HOLE, iterator indices
remain available, and the object shape can be restored to the original shape.

Relevant Source

Iterator snapshot records dense element locations as PropertyIndex::ForElement.

Source: js/src/vm/Iteration.cpp

// Collect any dense elements from this object.
size_t firstElemIndex = props_.length();
size_t initlen = pobj->getDenseInitializedLength();
const Value* elements = pobj->getDenseElements();
bool elementsAreFrozen = pobj->denseElementsAreFrozen();
bool hasHoles = false;
for (uint32_t i = 0; i < initlen; ++i) {
  if (elements[i].isMagic(JS_ELEMENTS_HOLE)) {
    hasHoles = true;
  } else {
    PropertyIndex index = elementsAreFrozen ? PropertyIndex::Invalid()
                                            : PropertyIndex::ForElement(i);
    // Dense arrays never get so large that i would not fit into an
    // integer id.
    if (!enumerate<CheckForDuplicates>(cx, PropertyKey::Int(i),
                                       /* enumerable = */ true, index)) {
      return false;
    }
  }
}

Iterator creation marks indices as available when actual indices are copied.

Source: js/src/vm/Iteration.cpp

if (hasActualIndices) {
  PropertyIndex* cursor = indicesBegin();
  for (size_t i = 0; i < numProps; i++) {
    *cursor++ = (*indices)[i];
  }
  flags_ |= Flags::IndicesAvailable;
}

For dense elements in for-in, the object is marked maybe-in-iteration, but
indices are still supported.

Source: js/src/vm/Iteration.cpp

if (obj->is<NativeObject>() &&
    obj->as<NativeObject>().getDenseInitializedLength() > 0) {
  if (forObjectKeys) {
    supportsIndices = false;
  } else {
    obj->as<NativeObject>().markDenseElementsMaybeInIteration();
  }
}

The Ion guard checks IndicesAvailable and object shape.

Source: js/src/jit/CodeGenerator.cpp

void CodeGenerator::emitIteratorHasIndicesAndBranch(Register iterator,
                                                    Register object,
                                                    Register temp,
                                                    Register temp2,
                                                    Label* ifFalse) {
  // Check that the iterator has indices available.
  Address nativeIterAddr(iterator,
                         PropertyIteratorObject::offsetOfIteratorSlot());
  masm.loadPrivate(nativeIterAddr, temp);
  masm.branchTest32(Assembler::Zero,
                    Address(temp, NativeIterator::offsetOfFlags()),
                    Imm32(NativeIterator::Flags::IndicesAvailable), ifFalse);

  // Guard that the first shape stored in the iterator matches the current
  // shape of the iterated object.
  Address objShapeAddr(temp, NativeIterator::offsetOfObjectShape());
  masm.loadPtr(objShapeAddr, temp);
  masm.branchTestObjShape(Assembler::NotEqual, object, temp, temp2, object,
                          ifFalse);
}

The dense-element load checks bounds but not JS_ELEMENTS_HOLE.

Source: js/src/jit/CodeGenerator.cpp

Label indexOkay;
Address initLength(kindScratch, ObjectElements::offsetOfInitializedLength());
masm.branch32(Assembler::Above, initLength, indexScratch, &indexOkay);
masm.assumeUnreachable("Dense element out of bounds");
masm.bind(&indexOkay);

masm.loadValue(BaseObjectElementIndex(kindScratch, indexScratch), result);
masm.bind(&done);

Object.defineProperty enters AddOrChangeProperty for existing properties.

Source: js/src/vm/NativeObject.cpp

if (!AddOrChangeProperty<IsAddOrChange::Change>(cx, obj, id, desc, &prop)) {
  return false;
}

Only default data properties use the dense fast path.

Source: js/src/vm/NativeObject.cpp

// Use dense storage for indexed properties where possible: when we have an
// integer key with default property attributes and are either adding a new
// property or changing a dense element.
PropertyFlags flags = ComputePropertyFlags(desc);
if (id.isInt() && flags == PropertyFlags::defaultDataPropFlags &&
    (AddOrChange == IsAddOrChange::Add || existing->isDenseElement())) {
  MOZ_ASSERT(!desc.isAccessorDescriptor());
  MOZ_ASSERT(!obj->is<TypedArrayObject>());
  uint32_t index = id.toInt();
  DenseElementResult edResult = obj->ensureDenseElements(cx, index, 1);
  if (edResult == DenseElementResult::Failure) {
    return false;
  }
  if (edResult == DenseElementResult::Success) {
    obj->setDenseElement(index, desc.value());
    if (!CallAddPropertyHookDense(cx, obj, index, desc.value())) {
      return false;
    }
    return true;
  }
}

Accessor descriptors or non-default data descriptors add a sparse indexed
property first.

Source: js/src/vm/NativeObject.cpp

} else {
  if (existing->isNativeProperty()) {
    if (!NativeObject::changeProperty(cx, obj, id, flags, &slot)) {
      return false;
    }
    Watchtower::watchPropertyValueChange<AllowGC::CanGC>(
        cx, obj, id, desc.value(), existing->propertyInfo());
    obj->setSlot(slot, desc.value());
  } else {
    if (!NativeObject::addProperty(cx, obj, id, flags, &slot)) {
      return false;
    }
    obj->initSlot(slot, desc.value());
  }
}

After adding the sparse indexed property, the dense element is removed.

Source: js/src/vm/NativeObject.cpp

// Clear any existing dense index after adding a sparse indexed property,
// and investigate converting the object to dense indexes.
if (id.isInt()) {
  uint32_t index = id.toInt();
  if constexpr (AddOrChange == IsAddOrChange::Add) {
    MOZ_ASSERT(!obj->containsDenseElement(index));
  } else {
    obj->removeDenseElementForSparseIndex(index);
  }

removeDenseElementForSparseIndex writes JS_ELEMENTS_HOLE and does not
disable iterator indices.

Source: js/src/vm/NativeObject-inl.h

inline void NativeObject::removeDenseElementForSparseIndex(uint32_t index) {
  MOZ_ASSERT(containsPure(PropertyKey::Int(index)));
  if (containsDenseElement(index)) {
    setDenseElementHole(index);
  }
}

Source: js/src/vm/NativeObject-inl.h

inline void NativeObject::setDenseElementHole(uint32_t index) {
  markDenseElementsNotPacked();
  setDenseElementUnchecked(index, MagicValue(JS_ELEMENTS_HOLE));
}

After the dense slot is changed to a hole, the generic add-property hook is
called.

Source: js/src/vm/NativeObject.cpp

if (desc.isDataDescriptor()) {
  return CallAddPropertyHook(cx, obj, id, desc.value());
}

return CallAddPropertyHook(cx, obj, id, UndefinedHandleValue);

The hook call can fail before the class hook body is invoked.

Source: js/src/vm/NativeObject.cpp

static bool CallJSAddPropertyOp(JSContext* cx, JSAddPropertyOp op,
                                HandleObject obj, HandleId id, HandleValue v) {
  AutoCheckRecursionLimit recursion(cx);
  if (!recursion.check(cx)) {
    return false;
  }

  cx->check(obj, id, v);
  return op(cx, obj, id, v);
}

On failure, only the sparse property is removed. The dense element is not
restored.

Source: js/src/vm/NativeObject.cpp

static MOZ_ALWAYS_INLINE bool CallAddPropertyHook(JSContext* cx,
                                                  Handle<NativeObject*> obj,
                                                  HandleId id,
                                                  HandleValue value) {
  // Ensure any wrapper is preserved first.
  if (!PreserveAnyUnpreservedWrapper(cx, obj)) {
    return false;
  }

  JSAddPropertyOp addProperty = obj->getClass()->getAddProperty();
  if (MOZ_UNLIKELY(addProperty)) {
    if (!CallJSAddPropertyOp(cx, addProperty, obj, id, value)) {
      NativeObject::removeProperty(cx, obj, id);
      return false;
    }
  }

  return true;
}

Array objects have a built-in addProperty hook.

Source: js/src/builtin/Array.cpp

static bool array_addProperty(JSContext* cx, HandleObject obj, HandleId id,
                              HandleValue v) {
  ArrayObject* arr = &obj->as<ArrayObject>();

  uint32_t index;
  if (!IdIsIndex(id, &index)) {
    return true;
  }

  uint32_t length = arr->length();
  if (index >= length) {
    MOZ_ASSERT(arr->lengthIsWritable(),
               "how'd this element get added if length is non-writable?");
    arr->setLength(cx, index + 1);
  }
  return true;
}

Source: js/src/builtin/Array.cpp

static const JSClassOps ArrayObjectClassOps = {
    array_addProperty,  // addProperty
    nullptr,            // delProperty
    nullptr,            // enumerate
    nullptr,            // newEnumerate
    nullptr,            // resolve
    nullptr,            // mayResolve
    nullptr,            // finalize

Removing the just-added sparse property can restore the previous shape.

Source: js/src/vm/Shape.cpp

if (map->isShared()) {
  // Fast path for removing the last property from a SharedPropMap. In this
  // case we can just call getPrevious and then look up a shape for the
  // resulting map/mapLength.
  //
  // Don't reuse a previously-used Shape if the object has an ObjectFuse and
  // we are removing a tracked property, to avoid the following bug:
  //
  // 1) The object has Shape S1 and a property P that was marked NotConstant.
  // 2) We attach a SetSlot IC stub that guards on the object + S1 and stores
  //    a new value for this property.
  // 3) We remove property P (we are here now).
  // 4) We add a new property P, reuse Shape S1, and mark P Constant.
  // 5) We use the SetSlot IC stub again but this is invalid because P is
  //    still marked Constant.
  if (propMap == map && propIndex == mapLength - 1 &&
      !wasTrackedObjectFuseProp) {
    MOZ_ASSERT(obj->getLastProperty().key() == id);

    Rooted<SharedPropMap*> sharedMap(cx, map->asShared());
    SharedPropMap::getPrevious(&sharedMap, &mapLength);

    SharedShape* shape = obj->sharedShape();
    SharedShape* newShape;
    if (sharedMap) {
      newShape = SharedShape::getPropMapShape(
          cx, shape->base(), shape->numFixedSlots(), sharedMap, mapLength,
          shape->objectFlags());
    } else {
      newShape = SharedShape::getInitialShape(
          cx, shape->getObjectClass(), shape->realm(), shape->proto(),
          shape->numFixedSlots(), shape->objectFlags());
    }
    if (!newShape) {
      return false;
    }

    if (MOZ_LIKELY(prop.hasSlot())) {
      if (MOZ_LIKELY(prop.slot() == newShape->slotSpan())) {
        obj->setShapeAndRemoveLastSlot(cx, newShape, prop.slot());
        return true;
      }
      // Uncommon case: the property is stored in a reserved slot.
      // See NativeObject::addPropertyInReservedSlot.
      MOZ_ASSERT(prop.slot() < JSCLASS_RESERVED_SLOTS(obj->getClass()));
      obj->setSlot(prop.slot(), UndefinedValue());
    }
    obj->setShape(newShape);
    return true;
  }

Iterator deletion suppression disables indices, but this path is not used by
removeDenseElementForSparseIndex.

Source: js/src/vm/Iteration.cpp

static bool SuppressDeletedProperty(JSContext* cx, NativeIterator* ni,
                                    HandleObject obj,
                                    Handle<JSLinearString*> str) {
  if (ni->objectBeingIterated() != obj) {
    return true;
  }

  ni->disableIndices();

Source: js/src/vm/Iteration.h

void disableIndices() {
  // Clear the IndicesAvailable flag so we won't use the indices on this
  // iterator, and ensure IndicesSupported is cleared as well, so we don't
  // re-request an iterator with indices. However, we leave the
  // IndicesAllocated flag because we need to free them later, and skip them
  // when looking for shapes.
  flags_ &= ~(Flags::IndicesAvailable | Flags::IndicesSupported);
}

Exploit Primitive: CallArgs newTarget OOB Read

The leaked value is specifically MagicValue(JS_ELEMENTS_HOLE), not
MagicValue(JS_IS_CONSTRUCTING). The primitive depends on a mismatch in
CallArgs: CallArgsFromVp computes the stored constructing_ bit by checking
for the exact JS_IS_CONSTRUCTING magic value, while isConstructing() only
checks whether the this slot has any Magic tag.

Source: js/public/Value.h

enum JSWhyMagic {
  /** a hole in a native object's elements */
  JS_ELEMENTS_HOLE,

  /** there is not a pending iterator value */
  JS_NO_ITER_VALUE,

  /** exception value thrown when closing a generator */
  JS_GENERATOR_CLOSING,

  /** used in debug builds to catch tracing errors */
  JS_ARG_POISON,

  /** an empty subnode in the AST serializer */
  JS_SERIALIZE_NO_NODE,

  /** magic value passed to natives to indicate construction */
  JS_IS_CONSTRUCTING,

Source: js/public/Value.h

bool isMagic() const { return toTag() == JSVAL_TAG_MAGIC; }

bool isMagic(JSWhyMagic why) const {
  if (!isMagic()) {
    return false;
  }
  MOZ_RELEASE_ASSERT(whyMagic() == why);
  return true;
}

// Like isMagic, but without the release assertion.
// Note that in release builds this will return *false* for
// non-matching magic values, because it is generally safer to
// ignore an unexpected magic value than to misinterpret it. See bug
// 2032226.
bool isMagicNoReleaseCheck(JSWhyMagic why) const {
  MOZ_ASSERT_IF(isMagic(), whyMagic() == why);
  return asBits_ == bitsFromTagAndPayload(JSVAL_TAG_MAGIC, uint32_t(why));
}

Source: js/public/CallArgs.h

bool isConstructing() const {
  if (!argv_[-1].isMagic()) {
    return false;
  }

#ifdef JS_DEBUG
  if (!this->usedRval()) {
    CheckIsValidConstructible(calleev());
  }
#endif

  return true;
}

Source: js/public/CallArgs.h

MutableHandleValue newTarget() const {
  MOZ_ASSERT(constructing_);
  return MutableHandleValue::fromMarkedLocation(&this->argv_[argc_]);
}

Source: js/public/CallArgs.h

static CallArgs create(unsigned argc, Value* argv, bool constructing,
                       bool ignoresReturnValue = false) {
  CallArgs args;
  args.clearUsedRval();
  args.argv_ = argv;
  args.argc_ = argc;
  args.constructing_ = constructing;
  args.ignoresReturnValue_ = ignoresReturnValue;

Source: js/public/CallArgs.h

MOZ_ALWAYS_INLINE CallArgs CallArgsFromVp(unsigned argc, Value* vp) {
  return CallArgs::create(argc, vp + 2,
                          vp[1].isMagicNoReleaseCheck(JS_IS_CONSTRUCTING));
}

For a native call where the leaked JS_ELEMENTS_HOLE is used as the this
slot, the state becomes:

argv_[-1] = MagicValue(JS_ELEMENTS_HOLE)

CallArgsFromVp:
  constructing_ = isMagicNoReleaseCheck(JS_IS_CONSTRUCTING)
  constructing_ = false

CallArgs::isConstructing:
  argv_[-1].isMagic() = true
  returns true

CallArgs::newTarget:
  MOZ_ASSERT(constructing_) is debug-only
  release build reads argv_[argc_]

The non-construct call frame does not reserve a new.target slot. Construct
calls reserve one extra slot, but ordinary calls only reserve callee, this, and
arguments.

Source: js/src/vm/Stack.h

// callee, this, arguments[, new.target iff constructing]
size_t len = 2 + argc + uint32_t(Construct);
MOZ_ASSERT(len > argc);  // no overflow
if (!v_.resize(len)) {
  return false;
}

*static_cast<JS::CallArgs*>(this) = CallArgsFromVp(argc, v_.begin());
this->constructing_ = Construct;
if (Construct) {
  this->CallArgs::setThis(MagicValue(JS_IS_CONSTRUCTING));
}

obj_construct is a direct sink because it gates newTarget() with
args.isConstructing(). With JS_ELEMENTS_HOLE in the this slot, that guard
can pass even though the call was not constructed and constructing_ is false.

Source: js/src/builtin/Object.cpp

bool js::obj_construct(JSContext* cx, unsigned argc, Value* vp) {
  CallArgs args = CallArgsFromVp(argc, vp);

  JSObject* obj;
  if (args.isConstructing() &&
      (&args.newTarget().toObject() != &args.callee())) {
    RootedObject newTarget(cx, &args.newTarget().toObject());
    obj = CreateThis(cx, newTarget);
  } else if (args.length() > 0 && !args[0].isNullOrUndefined()) {
    obj = ToObject(cx, args[0]);
  } else {
    /* Make an object whether this was called with 'new' or not. */
    gc::AllocKind allocKind = NewObjectGCKind();
    obj = NewPlainObjectWithAllocKind(cx, allocKind);
  }

Primitive summary:

Input value:
  MagicValue(JS_ELEMENTS_HOLE) is leaked as a JS Value

Native call state:
  leaked value occupies argv_[-1] / thisv
  CallArgsFromVp records constructing_ = false
  CallArgs::isConstructing() returns true because the value is Magic

Read primitive:
  args.newTarget() reads argv_[argc_]
  for a non-construct call, argv_[argc_] is one Value past the arguments area

Exploit Chain: Worker Spray to UAF

The newTarget() primitive gives an out-of-bounds Value read from immediately
after the non-construct call arguments area. The exploitation strategy can use a
worker to shape that adjacent memory because the general allocator is not split
into isolated JavaScript heaps for the main thread and worker thread. The
allocator has a process-wide arena collection and ordinary malloc chooses an
arena through choose_arena() when no explicit private arena is supplied.

Source: memory/build/mozjemalloc.cpp

// Choose an arena based on a per-thread value.
static inline arena_t* choose_arena(size_t size) {
  arena_t* ret = nullptr;

  // We can only use TLS if this is a PIC library, since for the static
  // library version, libc's malloc is used by TLS allocation, which
  // introduces a bootstrapping issue.

  if (size > kMaxQuantumClass) {
    // Force the default arena for larger allocations.
    ret = gArenas.GetDefault();
  } else {
    // Check TLS to see if our thread has requested a pinned arena.
    ret = thread_arena.get();
    // If ret is non-null, it must not be in the first page.
    MOZ_DIAGNOSTIC_ASSERT_IF(ret, (size_t)ret >= gPageSize);
    if (!ret) {
      // Nothing in TLS. Pin this thread to the default arena.
      ret = thread_local_arena(false);
    }
  }

  MOZ_DIAGNOSTIC_ASSERT(ret);
  return ret;
}

Source: memory/build/mozjemalloc.cpp

// The BaseAllocator class is a helper class that implements the base allocator
// functions (malloc, calloc, realloc, free, memalign) for a given arena,
// or an appropriately chosen arena (per choose_arena()) when none is given.
struct BaseAllocator {

Source: memory/build/mozjemalloc.cpp

inline void* BaseAllocator::malloc(size_t aSize) {
  void* ret;
  arena_t* arena;

  if (!malloc_init()) {
    ret = nullptr;
    goto RETURN;
  }

  if (aSize == 0) {
    aSize = 1;
  }
  // If mArena is non-null, it must not be in the first page.
  MOZ_DIAGNOSTIC_ASSERT_IF(mArena, (size_t)mArena >= gPageSize);
  arena = mArena ? mArena : choose_arena(aSize);
  ret = arena->Malloc(aSize, /* aZero = */ false);

Worker execution uses its own WorkerJSContext created on the worker thread.
This gives the exploit separate JavaScript execution and GC scheduling from the
main thread, while allocations still go through the same process allocator
machinery.

Source: dom/workers/RuntimeService.cpp

class WorkerJSContext final : public mozilla::CycleCollectedJSContext {
 public:
  // The heap size passed here doesn't matter, we will change it later in the
  // call to JS_SetGCParameter inside InitJSContextForWorker.
  explicit WorkerJSContext(WorkerPrivate* aWorkerPrivate)
      : mWorkerPrivate(aWorkerPrivate) {

Source: dom/workers/RuntimeService.cpp

{
  nsCycleCollector_startup();

  auto context = MakeUnique<WorkerJSContext>(mWorkerPrivate);
  nsresult rv = context->Initialize(mParentRuntime);
  if (NS_WARN_IF(NS_FAILED(rv))) {
    return rv;
  }

  JSContext* cx = context->Context();

  if (!InitJSContextForWorker(mWorkerPrivate, cx)) {
    return NS_ERROR_FAILURE;
  }

The main-thread and worker GC entry points are distinct. The same API-level GC
request dispatches to nsJSContext::GarbageCollectNow on the main thread, but
to WorkerPrivate::GarbageCollectInternal on a worker thread.

Source: dom/base/TestUtils.cpp

NS_DispatchToCurrentThread(
    NS_NewCancelableRunnableFunction("TestUtils::Gc", [promise] {
      if (NS_IsMainThread()) {
        nsJSContext::GarbageCollectNow(JS::GCReason::DOM_TESTUTILS,
                                       nsJSContext::NonShrinkingGC);
        nsJSContext::CycleCollectNow(CCReason::API);
      } else {
        WorkerPrivate* workerPrivate = GetCurrentThreadWorkerPrivate();
        workerPrivate->GarbageCollectInternal(workerPrivate->GetJSContext(),
                                              false /* shrinking */,
                                              false /* collect children */);
        workerPrivate->CycleCollectInternal(false);
      }

      promise->MaybeResolveWithUndefined();
    }));

Source: dom/base/nsJSEnvironment.cpp

static void GarbageCollectImpl(JS::GCReason aReason,
                               nsJSContext::IsShrinking aShrinking,
                               const JS::SliceBudget& aBudget) {
  AUTO_PROFILER_LABEL_DYNAMIC_CSTR_NONSENSITIVE(
      "nsJSContext::GarbageCollectNow", GCCC, JS::ExplainGCReason(aReason));

  bool wantIncremental = !aBudget.isUnlimited();

  // We use danger::GetJSContext() since AutoJSAPI will assert if the current
  // thread's context is null (such as during shutdown).
  JSContext* cx = danger::GetJSContext();

Source: dom/workers/WorkerPrivate.cpp

void WorkerPrivate::GarbageCollectInternal(JSContext* aCx, bool aShrinking,
                                           bool aCollectChildren) {
  // Perform GC followed by CC (the CC is triggered by
  // WorkerJSRuntime::CustomGCCallback at the end of the collection).

  auto data = mWorkerThreadAccessible.Access();

  if (!GlobalScope()) {
    // We haven't compiled anything yet. Just bail out.
    return;
  }

Exploit-chain summary:

Primitive:
  args.newTarget() gives a one-Value OOB read from a non-construct call frame

Allocator condition:
  worker and main-thread allocations are not isolated into separate heaps
  ordinary allocations use the shared process allocator arena machinery
  worker-side spray can influence adjacent or reusable allocator state

GC condition:
  main-thread GC and worker GC are driven through distinct entry points
  worker GC can reclaim worker-owned objects independently of main-thread GC

UAF connection:
  worker-side allocation/free pressure shapes memory visible to the OOB read
  independent worker GC provides a controlled lifetime break
  the OOB read can then observe stale or reused Value/object memory as a UAF

Final State

NativeIterator:
  IndicesAvailable remains set
  saved PropertyIndex remains ForElement(0)

Array object:
  shape can be restored to the shape captured by the iterator
  dense element 0 is JS_ELEMENTS_HOLE
  sparse property "0" has been removed by rollback

Ion fast path:
  IndicesAvailable check passes
  shape guard passes
  dense element bounds check passes
  JS_ELEMENTS_HOLE is loaded without a hole check

Exploit primitive:
  JS_ELEMENTS_HOLE is a Magic value but not JS_IS_CONSTRUCTING
  CallArgsFromVp sets constructing_ to false
  CallArgs::isConstructing() still returns true for any Magic value
  args.newTarget() can read argv_[argc_] in a non-construct call

Exploit chain:
  the newTarget OOB read is paired with worker-side heap shaping
  worker and main-thread allocations use shared process allocator machinery
  worker GC and main-thread GC are driven through separate paths
  this allows a worker-controlled lifetime break to be observed as UAF

Suggested Fix

When removeDenseElementForSparseIndex writes JS_ELEMENTS_HOLE,
invalidate active iterator indices for the object.

Make CallArgs::isConstructing() use the same exact construction marker check
as CallArgsFromVp, or return the stored constructing_ bit instead of checking
argv_[-1].isMagic().

PoC Status

The PoC was written as a full chain for the Firefox 150.0.2 Windows build,
using the shellcode from bug 2038573.
Flags: sec-bounty?
Attached file last.js
Attached file worker.js
Group: firefox-core-security → javascript-core-security
Component: Security → JavaScript Engine
Product: Firefox → Core
Assignee: nobody → jdemooij
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Severity: -- → S1
Priority: -- → P1
Attached file Shell test

This asserts in a Linux debugopt shell for me when I run it with --no-threads.

Attached file (secure)

This matches CallAddPropertyHookDense.

I've posted a patch that does two things:

It inlines the ArrayObject addProperty hook code in CallAddPropertyHook similar to what we already do in CallAddPropertyHookDense. This ensures CallAddPropertyHook is infallible for arrays because we no longer have the unnecessary recursion check there. The other remaining places where we still use addPropertyhooks are XPConnect objects (see bug 1973249).

The patch also changes CodeGenerator::visitLoadSlotByIteratorIndexCommon to crash safely if we read the hole MagicValue, in addition to the bounds check we already had there.

There's more work to do in follow-up bugs but for now these two changes are relatively small and safe.

Keywords: sec-high, regression
Regressed by: 1995077
Regressed by: 1799025
No longer regressed by: 1995077

Comment on attachment 9585091 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Not very easily but maybe with AI analysis..
  • 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?: Should be easy to backport.
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely. The inlining part is very safe and the JIT part turns a bad scenario into a safe crash. If we hit this before we'd likely have crashed later.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9585091 - Flags: sec-approval?
See Also: → 2038573
Attachment #9585091 - Flags: sec-approval? → sec-approval+

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: Minimal fix for the security bug. In cases that would very likely have crashed before, it'll now introduce a safe crash.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9585212 - Flags: approval-mozilla-beta?
Attached file (secure)

This matches CallAddPropertyHookDense.

Original Revision: https://phabricator.services.mozilla.com/D299696

firefox-release 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: Minimal fix for the security bug. In cases that would very likely have crashed before, it'll now introduce a safe crash.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9585215 - Flags: approval-mozilla-release?
Attached file (secure)

This matches CallAddPropertyHookDense.

Original Revision: https://phabricator.services.mozilla.com/D299696

:jandem, this will also need uplift requests for ESR115 and ESR140. You might already be working on them, but adding a need-info just in case.

Flags: needinfo?(jdemooij)

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: Minimal fix for the security bug. In cases that would very likely have crashed before, it'll now introduce a safe crash.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9585260 - Flags: approval-mozilla-esr140?
Attached file (secure) (obsolete) —

This matches CallAddPropertyHookDense.

Original Revision: https://phabricator.services.mozilla.com/D299696

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: Minimal fix for the security bug. In cases that would very likely have crashed before, it'll now introduce a safe crash.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9585263 - Flags: approval-mozilla-esr140?
Attached file (secure)
Attachment #9585260 - Attachment is obsolete: true
Attachment #9585260 - Flags: approval-mozilla-esr140?
Attachment #9585215 - Flags: approval-mozilla-release? → approval-mozilla-release+

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: Minimal fix for the security bug. In cases that would very likely have crashed before, it'll now introduce a safe crash.
  • String changes made/needed?: N/A
  • Is Android affected?: yes
Attachment #9585266 - Flags: approval-mozilla-esr115?
Attached file (secure)

(In reply to Donal Meehan [:dmeehan] from comment #12)

:jandem, this will also need uplift requests for ESR115 and ESR140. You might already be working on them, but adding a need-info just in case.

Yeah we were still working on this :) ESR140/ESR115 needed a slightly different approach because there we still have DOM objects with addProperty hooks too. Like the array hook, these DOM hooks are infallible, so not doing the overrecursion check seemed the safest option. These hooks can't call each other recursively.

Flags: needinfo?(jdemooij)
Attachment #9585212 - Flags: approval-mozilla-beta? → approval-mozilla-beta+

worker and main-thread allocations are not isolated into separate heaps

Is this something we want to fix? Should we file a follow-up?

Flags: needinfo?(jdemooij)
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-main150.0.3+]

(In reply to Simon Friedberger [:simonf] from comment #22)

Is this something we want to fix? Should we file a follow-up?

It's complicated because some memory sharing is necessary to efficiently pass eg ArrayBuffers and string buffers between threads. I think this bug would have been exploitable without workers too because you could use heap spraying/grooming on a single thread. The write-up mentions jemalloc but from reading the code it (also) might be relying on GC allocator behavior.

Heap partitioning (bug 1052575) seems interesting but I lack the expertise to suggest meaningful mitigations to deploy in Firefox. Input from the security team on this would be valuable.

Flags: needinfo?(jdemooij)
Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 4 months ago
Resolution: --- → FIXED
Target Milestone: --- → 152 Branch
Alias: CVE-2026-8395
Attachment #9585263 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Alias: CVE-2026-8395 → CVE-2026-8391
Attachment #9585266 - Flags: approval-mozilla-esr115? → approval-mozilla-esr115+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c152/b151]
Whiteboard: [client-bounty-form][adv-main150.0.3+] → [client-bounty-form][adv-main150.0.3+][adv-esr140.11+][adv-esr115.36+
Whiteboard: [client-bounty-form][adv-main150.0.3+][adv-esr140.11+][adv-esr115.36+ → [client-bounty-form][adv-main150.0.3+][adv-esr140.11+][adv-esr115.36+]
Flags: sec-bounty? → sec-bounty+
Group: core-security-release
Attachment #9585538 - Attachment description: (secure) → Bug 2038575 - Add test. r?tschuster!
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: