
Proof-of-Concept-Exploit für CVE-2026-2763, eine Use-after-Free-Schwachstelle in Mozillas JavaScript-Engine, die eine eingeschränkte 1-Bit-Schreibprimitive demonstriert, die zu Out-of-Bounds-Lesen/Schreiben führt.
Die Schwachstelle ergibt sich aus der Implementierung der for-in-Syntax.
Der Gültigkeitsbereich einer for-in-Schleife wird durch ein Paar von Bytecodes repräsentiert: JSOP::Iter und JSOP::EndIter.
CASE(Iter) {
MOZ_ASSERT(REGS.stackDepth() >= 1);
HandleValue val = REGS.stackHandleAt(-1);
JSObject* iter = ValueToIterator(cx, val); // [1]
if (!iter) {
goto error;
}
REGS.sp[-1].setObject(*iter); // [2]
}
...
CASE(EndIter) {
MOZ_ASSERT(REGS.stackDepth() >= 2);
CloseIterator(®S.sp[-2].toObject());
REGS.sp -= 2; // [3]
}
Beim Eintritt in den Gültigkeitsbereich wird bei [1] ValueToIterator aufgerufen, um ein PropertyIteratorObject zu erstellen. Der Interpreter speichert (pusht) es dann bei [2] auf dem Interpreter-Stack. Wenn der Gültigkeitsbereich endet, wird das PropertyIteratorObject bei [3] entfernt.
static PropertyIteratorObject* GetIteratorImpl(JSContext* cx, HandleObject obj,
bool wantIndices,
bool forObjectKeys) {
...
PropertyIteratorObject* iterobj = CreatePropertyIterator(
cx, obj, keys, supportsIndices, indicesPtr, cacheableProtoChainLength,
ownPropertyCount, forObjectKeys); // [1]
if (!iterobj) {
return nullptr;
}
if (!forObjectKeys) {
RegisterEnumerator(cx, iterobj->getNativeIterator(), obj); // [2]
}
...
return iterobj;
}
static inline void RegisterEnumerator(JSContext* cx, NativeIterator* ni,
HandleObject obj) {
ni->initObjectBeingIterated(*obj);
// Register non-escaping native enumerators (for-in) with the current
// context.
ni->link(cx->compartment()->enumeratorsAddr());
MOZ_ASSERT(!ni->isActive());
ni->markActive();
}
Innerhalb von ValueToIterator wird die Objekterstellung wie oben gezeigt durchgeführt. Nach der Zuweisung des Iterator-Objekts bei [1] wird es bei [2] in eine globale verknüpfte Liste eingebunden. Zu diesem Zeitpunkt wird die einzige Referenz auf das PropertyIteratorObject auf dem Interpreter-Stack gehalten.
void js::CloseIterator(JSObject* obj) {
if (!obj->is<PropertyIteratorObject>()) {
return;
}
// Remove iterator from the active list, which is a stack. The shared iterator
// used for for-in with null/undefined is immutable and unlinked.
NativeIterator* ni = obj->as<PropertyIteratorObject>().getNativeIterator();
if (ni->isEmptyIteratorSingleton()) {
return;
}
ni->unlink();
MOZ_ASSERT(ni->isActive());
ni->markInactive();
ni->clearObjectBeingIterated();
// Reset the enumerator; it may still be in the cached iterators for
// this thread and can be reused.
ni->resetPropertyCursorForReuse();
}
Wenn der Gültigkeitsbereich endet, wird CloseIterator aufgerufen und entfernt den Iterator aus der zuvor verknüpften Liste, wodurch ein Zugriff auf das PropertyIteratorObject außerhalb des Gültigkeitsbereichs verhindert wird.
Da yield jedoch nicht korrekt behandelt wird, ist es möglich, Code außerhalb des Schleifen-Gültigkeitsbereichs auszuführen, bevor CloseIterator läuft.
In dieser Situation, da die einzige starke Referenz auf das PropertyIteratorObject auf dem Interpreter-Stack liegt, kann das PropertyIteratorObject, wenn yield ausgeführt wird und der zurückgegebene Generator ebenfalls nicht referenziert wird, beim Ausführen des GC eingesammelt (freigegeben) werden.
static bool SuppressDeletedPropertyHelper(JSContext* cx, HandleObject obj,
Handle<JSLinearString*> str) {
NativeIteratorListIter iter(obj->compartment()->enumeratorsAddr());
while (!iter.done()) {
NativeIterator* ni = iter.next();
if (!SuppressDeletedProperty(cx, ni, obj, str)) { // [1]
return false;
}
}
return true;
}
Diese Funktion wird aufgerufen, wenn delete verwendet wird. Sie durchläuft die globale verknüpfte Liste und aktualisiert Iterator-Objekte, um die gelöschte Eigenschaft zu berücksichtigen. Bei [1] kann ein Callback aufgerufen werden; wenn dieser GC auslöst, kann ein nicht referenziertes PropertyIteratorObject freigegeben werden. Sein Finalizer gibt auch das entsprechende ni-Objekt frei, was zu einem Use-after-Free (UAF) führt.
static bool SuppressDeletedProperty(JSContext* cx, NativeIterator* ni,
HandleObject obj,
Handle<JSLinearString*> str) {
...
// Check whether id is still to come.
Rooted<JSLinearString*> idStr(cx);
IteratorProperty* cursor = ni->nextProperty();
for (; cursor < ni->propertiesEnd(); ++cursor) {
idStr = cursor->asString();
// Common case: both strings are atoms.
if (idStr->isAtom() && str->isAtom()) {
if (idStr != str) {
continue;
}
} else {
if (!EqualStrings(idStr, str)) {
continue;
}
}
...
cursor->markDeleted(); // [1]
ni->markHasUnvisitedPropertyDeletion();
return true;
}
return true;
}
Ein NativeIterator kann in seiner Größe über die Anzahl der Eigenschaften des Zielobjekts gesteuert werden, und cursor ist ein Zeiger auf den Eigenschafts-Slot, der als gelöscht markiert wird. Da [1] effektiv ein einzelnes Bit OR-verknüpft, ergibt sich daraus ein eingeschränktes Primitive: ein 1-Bit-Schreibzugriff an einer beliebigen (8-Byte-ausgerichteten) Adresse.
NativeIterator wird nicht über den GC-Allokator zugewiesen; stattdessen wird es aus js::MallocArena allokiert. Daher kann man nicht mit einem typischen JS-Object-Spray fortfahren, um kontrollierte GC-Heap-Objekte daneben zu platzieren.
js::UniquePtr<ImmutableScriptData> js::ImmutableScriptData::new_(
FrontendContext* fc, uint32_t codeLength, uint32_t noteLength,
uint32_t numResumeOffsets, uint32_t numScopeNotes, uint32_t numTryNotes) {
auto size = sizeFor(codeLength, noteLength, numResumeOffsets, numScopeNotes,
numTryNotes);
if (!size.isValid()) {
ReportAllocationOverflow(fc);
return nullptr;
}
// Allocate contiguous raw buffer.
void* raw = fc->getAllocator()->pod_malloc<uint8_t>(size.value());
MOZ_ASSERT(uintptr_t(raw) % alignof(ImmutableScriptData) == 0);
if (!raw) {
return nullptr;
}
// Constuct the ImmutableScriptData. Trailing arrays are uninitialized but
// GCPtrs are put into a safe state.
UniquePtr<ImmutableScriptData> result(new (raw) ImmutableScriptData(
codeLength, noteLength, numResumeOffsets, numScopeNotes, numTryNotes));
if (!result) {
return nullptr;
}
// Sanity check
MOZ_ASSERT(result->endOffset() == size.value());
return result;
}
Ein nützliches Objekt, das in js::MallocArena allokiert wird, ist ImmutableScriptData, das JS-Bytecode speichert.
static MOZ_ALWAYS_INLINE void InitElemArrayOperation(JSContext* cx,
jsbytecode* pc,
Handle<ArrayObject*> arr,
HandleValue val) {
MOZ_ASSERT(JSOp(*pc) == JSOp::InitElemArray);
// The dense elements must have been initialized up to this index. The JIT
// implementation also depends on this.
uint32_t index = GET_UINT32(pc); // [1]
MOZ_ASSERT(index < arr->getDenseCapacity());
MOZ_ASSERT(index == arr->getDenseInitializedLength());
// Bump the initialized length even for hole values to ensure the
// index == initLength invariant holds for later InitElemArray ops.
arr->setDenseInitializedLength(index + 1); // [2]
if (val.isMagic(JS_ELEMENTS_HOLE)) {
arr->initDenseElementHole(index);
} else {
arr->initDenseElement(index, val);
}
}
Dies ist der Bytecode-Handler, der beim Initialisieren eines JS-Arrays verwendet wird.
Er liest einen 4-Byte-Wert aus dem Bytecode-Stream und verwendet ihn, um die InitializedLength des Arrays bei [2] zu setzen.
Wenn der 1-Bit-Schreibzugriff auf den Bytecode bei [1] angewendet wird, wird es möglich, InitializedLength über die tatsächliche Kapazität des Arrays hinaus zu setzen. Von dort aus fährt der Exploit fort, indem er OOB-Lese-/Schreib-Primitives ausnutzt.