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
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
Vai abrir o frida e se anexar no processo chamado CalculatorApp.exe
, que é a calculadora do windows.
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:
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 .
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;
Anexando o Frida ao jogo
Agora rodamos o Frida e anexamos ao jogo.
frida -n doom_gog.exe -l agent/index.ts
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.
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)
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`
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:
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"]`
Ainda sobraram duas possibilidades, qual será? Espera, que já resolvo isso…
Agora que estamos com 68% de vida, vamos filtrar novamente:
[Local::doom_gog.exe ]-> reduce(68)
Filtered down to:
["0x179b96a0d0c","0x179f5499984"]`
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;
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
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
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
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"
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
... que resultou no disasm abaixo:
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}`);
});
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:
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);
});
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)