
CVE-2026-2763の概念実証エクスプロイト。MozillaのJavaScriptエンジンにおけるuse-after-free脆弱性であり、制約付き1ビット書き込みプリミティブを実証し、境界外読み取り/書き込みへと導く。
この脆弱性は、for-in 構文の実装に起因します。
for-in ループのスコープは、JSOP::Iter と JSOP::EndIter という2つのバイトコードのペアで表されます。
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]
}
スコープの開始時、[1] で ValueToIterator が呼び出され、PropertyIteratorObject が作成されます。インタープリタはその後、[2] でそれをインタープリタスタックに格納(プッシュ)します。スコープが終了すると、PropertyIteratorObject は [3] で削除されます。
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();
}
ValueToIterator 内では、上記のようにオブジェクトの作成が行われます。[1] でイテレータオブジェクトを割り当てた後、[2] でグローバルなリンクリストにリンクされます。この時点で、PropertyIteratorObject への唯一の参照はインタープリタスタック上に保持されています。
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();
}
スコープが終了すると、CloseIterator が呼び出され、イテレータが先ほどリンクされたリストからリンク解除され、スコープ外からの PropertyIteratorObject へのアクセスが防止されます。
しかし、yield が正しく処理されないため、CloseIterator が実行される前にループスコープ外でコードを実行することが可能です。
この状況では、PropertyIteratorObject への唯一の強い参照がインタープリタスタック上にあるため、yield が実行され、返されたジェネレータも参照されない場合、GC が実行されると PropertyIteratorObject が収集(解放)される可能性があります。
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;
}
この関数は、delete が使用されたときに呼び出されます。グローバルなリンクリストを走査し、削除されたプロパティを考慮してイテレータオブジェクトを更新します。[1] でコールバックが呼び出される可能性があり、これが GC をトリガーすると、参照されていない PropertyIteratorObject が解放される可能性があります。そのファイナライザも対応する ni オブジェクトを解放するため、use-after-free(UAF)が発生します。
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;
}
NativeIterator のサイズは、ターゲットオブジェクトのプロパティ数によって制御でき、cursor は削除済みとしてマークされるプロパティスロットへのポインタです。[1] は実質的に単一ビットの OR を行うため、これにより制約付きプリミティブが得られます。つまり、任意の(8バイトアラインされた)アドレスへの1ビット書き込みです。
NativeIterator は GC アロケータを介して割り当てられるのではなく、js::MallocArena から割り当てられます。その結果、制御された GC ヒープオブジェクトをその隣に配置するための一般的な JS オブジェクトスプレーを進めることはできません。
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;
}
js::MallocArena に割り当てられる有用なオブジェクトの1つに、JS バイトコードを格納する ImmutableScriptData があります。
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);
}
}
これは、JS 配列を初期化するときに使用されるバイトコードハンドラです。
バイトコードストリームから4バイトの値を読み取り、[2] で配列の InitializedLength を設定するために使用します。
1ビット書き込みが [1] のバイトコードに適用された場合、InitializedLength を配列の実際の容量を超えて設定することが可能になります。そこから、エクスプロイトは OOB 読み取り/書き込みプリミティブを活用して進行します。