DEV Community

Cover image for [PT-BR] Introdução ao Frida: Como Manipular Memória em Tempo Real com TypeScript (e DOOM)
StealthC
StealthC

Posted on

[PT-BR] Introdução ao Frida: Como Manipular Memória em Tempo Real com TypeScript (e DOOM)

Tradução: This post is also available in English

Introdução

Outro dia eu estava lendo as notícias do site de uma ferramenta que eu acompanho, chamado Frida que é, como eles mesmo dizem no site, um Greasemonkey para programas nativos, ou seja, é um kit de ferramentas com uma API própria que ajuda a analisar, interagir e manipular programas em execução com suporte para várias plataformas diferentes.

Mas enfim, me deparei com essa notícia de setembro de 2024 que, além de informar novidades no projeto, demonstra uma utilização bastante interessante. Usa como base, o jogo "Doom + Doom II", que foi lançado a pouco tempo. No exemplo, ele cria uma ferramenta que busca na memória, onde número de munição é armazenado e com muita facilidade, cria uma espécie de Cheat Engine para manter as balas infinitas. Eu adorei a praticidade do exemplo e resolvi refazê-lo, mas detalhando aqui cada etapa.


Instalando o Frida

Como eu disse no início, o Frida está disponível para várias plataformas. Aqui, estou usando o Windows e já tenho o python instalado, então vou simplificar o processo de instalação usando o pip:

pip install frida-tools
Enter fullscreen mode Exit fullscreen mode

Exemplo de utilização

O pacote frida-tools vem com vários utilitários para trabalhar com o ecossistema do Frida (Você pode saber mais sobre eles visitando a documentação oficial).

Porém, um exemplo bem prático é usar o frida para anexar-se diretamente em um programa em execução, por exemplo:

frida -n CalculatorApp.exe
Enter fullscreen mode Exit fullscreen mode

Vai abrir o frida e se anexar no processo chamado CalculatorApp.exe, que é a calculadora do windows.

Imagem demonstrando a execução do frida

A partir desse novo prompt, você pode usar diversas funções embutidas e chamar JavaScript diretamente para acessar a memória do aplicativo, registros, interrupções, etc.

É ótimo para fazer testes rápidos, mas o melhor mesmo é fazer seus próprios scripts, os chamados "agentes".


Escolhendo a linguagem da API

Você pode escrever esses scripts de agentes em várias linguagens diferentes, o Frida oferece já suporte para linguagens como Python, JavaScript, C, Go e Swift, dentre outras.

Neste meu exemplo, vou de JavaScript, ou melhor ainda, TypeScript, que oferece algum suporte para autocompletar e verificar rapidamente a documentação, conforme mostrado abaixo:

Imagem mostrando o recurso de documentação do TypeScript no VS Code)

Documentação e autocompletar, as melhores coisas de IDE modernos (até a chegada das IAs)

Preparando a estrutura do projeto

A documentação do Frida sobre JavaScript sugere clonar o seguinte repositório para começar a desenvolver seu "agente": (https://github.com/oleavr/frida-agent-example), então vou clonar ele, instalar as dependências e abrir no VS Code:

git clone https://github.com/oleavr/frida-agent-example.git doom_example
cd .\doom_example\
npm install
code .
Enter fullscreen mode Exit fullscreen mode

A ideia do repositório é simplificar a tarefa de programar em TypeScripte compilar o JavaScript para ser usado pelo Frida, ele vem com alguns scripts no package.json, como npm watch que observa e compila automaticamente o arquivo ./agent/index.ts.

Porém, nas últimas versões do Frida, ele consegue ler automaticamente nosso TypeScript, então não precisamos compilar o .js.


Mais uma coisa antes de seguirmos

É bem provável, como foi o caso quando eu estava testando, que o repositório esteja com algumas dependências já ultrapassadas, não esqueça de rodar npm update --save para atualizar as dependências, principalmente as tipagens.


Agente básico

Vamos começar usando o mesmo exemplo da postagem original, adicionando alguns comentários, inserindo tipagens e adaptando para o TypeScript:

Uma observação importante: Se você usar JavaScript puro para fazer o agente, poderá chamar diretamente qualquer função ou variável declarada no script. Porém, ao usar TypeScript, as funções e variáveis não são inseridas no objeto global e portanto, você não conseguirá acessá-las diretamente a menos as exporte para o objeto globalThis. Estou fazendo essa exportação logo no fim do script.

let matches: NativePointer[] = [];

// Escaneia a memória do processo em busca de um padrão
function scan(pattern: string | MatchPattern) {
  const locations = new Set<string>();
  for (const r of Process.enumerateMallocRanges()) {
    for (const match of Memory.scanSync(r.base, r.size, pattern)) {
      locations.add(match.address.toString());
    }
  }
  matches = Array.from(locations).map(ptr);
  console.log('Found', matches.length, 'matches');
}

// Filtra ainda mais os resultados para aqueles que contém um valor específico
function reduce(val: number) {
  matches = matches.filter(location => location.readU32() === val);
  console.log('Filtered down to:');
  console.log(JSON.stringify(matches));
}

// Converte um valor numérico em um padrão para número inteiro unsigened de 32 bits
function patternFromU32(val: number) {
  return new MatchPattern(ptr(val).toMatchPattern().slice(0, 11));
}

globalThis['scan'] = scan;
globalThis['reduce'] = reduce;
globalThis['patternFromU32'] = patternFromU32;
Enter fullscreen mode Exit fullscreen mode

Anexando o Frida ao jogo

Agora rodamos o Frida e anexamos ao jogo.

frida -n doom_gog.exe -l agent/index.ts
Enter fullscreen mode Exit fullscreen mode

Você deve estar com o jogo em execução para que o frida possa encontrar o processo usando a opção -n, se você tiver dúvidas sobre o nome do processo, use o frida-ps para listar todos os processos em execução.

Imagem demonstrando a execução do frida


Encontrando um valor na memória (uma palha em um agulheiro)

Na postagem original, o autor busca na memória o local onde o valor da munição é armazenado, mas vamos tentar diferente e procurar onde está nossa vida (ou "saúde", "life", "HP", ou como você gostar de chamar)

Gameplay image of Doom

Vamos torcer para que esse 100% seja um número inteiro…

No console aberto do frida, vamos executar nossa função scan com um padrão do número 100 criado como argumento.

[Local::doom_gog.exe ]-> scan(patternFromU32(100))
Found 8073 matches`
Enter fullscreen mode Exit fullscreen mode

Eita, 8073 resultados para o número 100, isso parece muito, mas nem tanto, precisamos diminuir as possibilidades, para isso, vamos primeiro sofrer algum dano no jogo:

O jogador atira em um barril, que explode

Tome isto, barril com líquido verde estranho!

Agora que reduzimos nossa vida para 67%, vamos filtrar ainda mais nossa busca usando a função criada no script reduce:

[Local::doom_gog.exe ]-> reduce(67)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]`
Enter fullscreen mode Exit fullscreen mode

Ainda sobraram duas possibilidades, qual será? Espera, que já resolvo isso…

O jogador pega uma poção azul

Bebendo desse frasco azul que encontrei no chão…

Agora que estamos com 68% de vida, vamos filtrar novamente:

[Local::doom_gog.exe ]-> reduce(68)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]`
Enter fullscreen mode Exit fullscreen mode

Ué, continuamos com os dois endereços... É provável que a vida esteja mesmo sendo armazenada em dois locais diferentes na memória ou ainda podem ser estados diferentes de atualização. Talvez eu não devesse ter testado sofrendo e depois recuperando vida, mas enfim, vamos seguir com a experiência...


Criando watchpoints dinamicamente

Vamos continuar com o artigo e criar uma função helper para designar watchpoints. Essa função vai receber como argumentos, o endereço da memória, o tamanho do dado armazenado e o tipo de condição para o break, que posteriormente vamos usar w para dizer para ele parar apenas em condições de write, ou seja, de escrita.
Não se esqueça de registrar a função no globalThis, no final.

function installWatchpoint(address: NativePointerValue, size: number | UInt64, conditions: HardwareWatchpointConditions) {
    const thread = Process.enumerateThreads()[0];

    Process.setExceptionHandler(e => {
      console.log(`\n=== Handler got ${e.type} exception at ${e.context.pc}`);

      if (Process.getCurrentThreadId() === thread.id &&
          ['breakpoint', 'single-step'].includes(e.type)) {
        thread.unsetHardwareWatchpoint(0);
        console.log('\tDisabled hardware watchpoint');
        return true;
      }

      console.log('\tPassing to application');
      return false;
    });

    thread.setHardwareWatchpoint(0, address, size, conditions);

    console.log('Ready');
  }

globalThis['installWatchpoint'] = installWatchpoint;
Enter fullscreen mode Exit fullscreen mode

Obtendo a localização do código (via watchpoints)

O objetivo agora é obter onde no código do jogo, o dano é executado. Então vamos começar registrando o watchpoint do primeiro endereço:

[Local::doom_gog.exe ]-> installWatchpoint(ptr('0x179b96a0d0c'), 4, 'w')
Ready
Enter fullscreen mode Exit fullscreen mode

Após inserir o watchpoint, tentei sofrer mais dano, porém nada aconteceu. Mas aí, ao pegar uma poção, o código é executado:

[Local::doom_gog.exe ]->
=== Handler got single-step exception at 0x7ff6332f7b08
        Disabled hardware watchpoint
Enter fullscreen mode Exit fullscreen mode

Ou seja, esse primeiro endereço, endereço que localizamos provavelmente é usado no contexto de recuperação de vida.
Estamos mais interessados no contexto de dano sofrido, então, testei com o outro endereço e dessa vez sim ele ativou quando eu tomei dano no jogo:

[Local::doom_gog.exe ]-> installWatchpoint(ptr('0x179f5499984'), 4, 'w')
Ready
[Local::doom_gog.exe ]->
=== Handler got single-step exception at 0x7ff6332fafcc
        Disabled hardware watchpoint
Enter fullscreen mode Exit fullscreen mode

Obtendo a localização exata da execução do código

De posse do endereço onde a mudança do valor ocorre 0x7ff6332fafcc, vamos obter o endereço relativo à base do programa.

[Local::doom_gog.exe ]-> healthCode = ptr('0x7ff6332fafcc')
"0x7ff6332fafcc"
[Local::doom_gog.exe ]-> healthModule = Process.getModuleByAddress(healthCode)
{
    "base": "0x7ff633020000",
    "name": "doom_gog.exe",
    "path": "F:\\GOG Games\\DOOM + DOOM II\\doom_gog.exe",
    "size": 15450112
}
[Local::doom_gog.exe ]-> offset = healthCode.sub(healthModule.base)
"0x2dafcc"
Enter fullscreen mode Exit fullscreen mode

Agora sabemos que o endereço está no módulo doom_gog.exe e que sua posição relativa à base do programa é 0x2dafcc


Conferindo o endereço em um disassembler

Podemos usar algum debugger com suporte a disasm para ver esse trecho e definir sua funcionalidade, você pode usar qualquer tipo de ferramenta: radare2, ida, GHidra, nesse exemplo eu abri com o x64dbg, e fui para o endereço correto pressionando Ctrl+G e inserindo a expressão:

doom_gog.exe+0x2dafcc
Enter fullscreen mode Exit fullscreen mode

... que resultou no disasm abaixo:

O endereço mostrado no x64dbg

Aqui, nós temos a instrução que é executada exatamente depois do valor da vida ter diminuído, imaginamos então que [rsi+24] é o nosso valor de vida e r15d é o dano recebido. Vamos nos concentrar no dano recebido por enquanto...


Criando interceptadores para receber dados dinamicamente

Seguindo o exemplo do artigo original, vamos criar um Interceptador para essa instrução, que vai nos dizer a quantidade de dano recebido.

Como essa função é executada diretamente no script, não precisamos adicionar ao globalThis.

Interceptor.attach(Module.getBaseAddress('doom_gog.exe').add(0x2dafcc), function () {
    const context = this.context as X64CpuContext;
    const damageReceived = context.r15;
    console.log(`Damage Received: ${damageReceived}`);
});
Enter fullscreen mode Exit fullscreen mode

Logo depois de salvar o script, se continuarmos a receber dano no jogo, o console do frida irá imprimir mensagens informando a quantidade de dano:

Dano sendo mostrado no console


E agora, a manipulação suprema!

Se quisermos, igual ao exemplo original, que o dano seja totalmente ignorado, basta trazer o interceptador para a instrução anterior (endereço 0x2dafc8) e substituir dinamicamente o valor de registro para zero!

Interceptor.attach(Module.getBaseAddress('doom_gog.exe').add(0x2dafc8), function () {
    const context = this.context as X64CpuContext;
    const damageReceived = context.r15;
    console.log(`Damage Received: ${damageReceived.toUInt32()}, but we will just ignore it`);
    context.r15 = ptr(0);
});
Enter fullscreen mode Exit fullscreen mode

Animação do player sofrendo dano, porém, o valor da saúde não diminui

Pra que o IDDQD?

Conclusão

Esse exercício foi ótimo para testar o que uma ferramenta como o Frida pode oferecer, essa forma dinâmica de lidar com os dados é muito interessante também para quem está aprendendo, e o exemplo com o Doom foi muito bem-vindo pela simplicidade. Note que ao fazer um cheat de um jogo, estamos apenas "arranhando" a superfície das possibilidades, ferramentas como o Frida podem e devem ser amplamente utilizadas para auxilio e simplificação de análise de malware, estudo de comportamento de software, segurança e outros processos.

Top comments (0)