Neste artigo analisaremos alguns tópicos sobre o tema tanto sob a ótica do desenvolvedor, quanto do hacker, comparando os desafios e técnicas referentes ao desenvolvimento de cheats para jogos online e offline.
Jogos single player
Do ponto de vista do desenvolvedor a integridade de um jogo single player raramente apresenta um problema para o produto e a comunidade. Devido à ausência de interação entre os jogadores, hacks pouco podem fazer além de obter feitos incríveis como concluir o jogo em tempos recordes ou desbloquear todas as conquistas para que o jogador possa exibir-se para seus amigos. Às vezes é até mesmo desejável pelo desenvolvedor que os jogadores comprometam a integridade do jogo através da criação de Mods, por exemplo, pois eles expandem e renovam o jogo, criando formas completamente diferentes de jogá-lo.
Do ponto de vista do profissional de segurança, jogos são uma maneira incrível de testar o seu potencial de engenharia reversa e até mesmo programação, quando você reescreve alguma seção crítica do game, por exemplo.
Scanners de memória
Sem dúvida jogos são programas complexos. Além de toda a lógica de gameplay o programa deve conter as instruções necessárias para renderizar itens na tela, gerenciar dispositivos de som, receber input do usuário, além de diversas outras funções comuns presentes nas engines como pathfinding, sistema de partículas, gerenciamento de animações, etc. Fazer a engenharia reversa de um jogo tentando compreender cada etapa do programa até encontrar as seções relacionadas à lógica de jogo seria como procurar uma agulha no palheiro.
Por este motivo desenvolveram-se ferramentas para inverter esse processo. Com a ajuda de Scanners de memória, a partir de elementos visuais do jogo como vida ou dinheiro, é possível descobrir o valor da variável desejada. Isso permite ao hacker alterar aquele valor ou usar aquela variável (quem acessa esse endereço?) para identificar seções de código relacionadas, criando exploits ainda mais complexos.
O processo de scan por valor funciona em três etapas:
- Escaneia-se um valor conhecido, como vida, dinheiro, pontuação, etc;
- Altera-se essa variável dentro do jogo para que ela mude de valor;
- Repete-se as etapas 1 e 2 até obter um número pequeno de possíveis endereços.
Ao fim do processo, é possível trocar o valor da variável dentro do jogo, obtendo vantagens como grandes quantias de vida ou dinheiro.
O scan de memória funciona buscando todos os endereços de memória do processo que contém aquele exato valor. Quando o segundo scan é feito, ele utiliza a primeira lista de resultado e remove os endereços que não se adequam ao valor atual. Ao fim do processo, sobram bons palpites para o real endereço da variável:
Exemplo de Scan de memória usando Cheat engine
Scan por proximidade
Esta técnica se baseia a partir da relação de proximidade espacial entre as variáveis. Por exemplo, se em um jogo o personagem possuir uma barra de vida cujo valor exato é incerto, uma estratégia possível é buscar por algum valor conhecido associado e após encontrar aquele valor olhar em suas proximidades para checar se a variável vida está lá. Isso ocorre porque na maioria das vezes os programadores armazenam os dados de cada elemento do jogo em objetos ou structs, que são contínuos na memória:
Struct player{ Char nome[50]; Int dinheiro; float vida ; float vidaMax ; Imagem *sprite; }
Exemplo de Scan por proximidade usando Cheat engine
Fuzzing
As vezes vale a pena alterar uma região de memória próxima a variáveis importantes e observar se algo mudou dentro do jogo. Apesar de diversas vezes resultar em crashes, às vezes pode render resultados interessantes,descobrindo listas ou IDs de itens:
Exemplo de Fuzzing obtendo os endereços da lista de parceiros e desbloqueando personagens não jogáveis
Quem acessa esse endereço? e Injeção de código
Na maioria dos debuggers é possível adicionar um breakpoint de dado, que trava a execução do programa quando uma instrução faz uma leitura ou uma escrita naquele endereço. Isso pode ser muito útil para encontrar funções relacionadas à aquela variável.
Em alguns casos além de apenas alterar o valor de variáveis é interessante alterar também trechos de código para que o programa se comporte diferente. Para isso, uma técnica usada para alterar dinamicamente o código em execução é o code hooking. Esta técnica consiste em substituir uma instrução por um jump para uma região de memória não utilizada ou alocada pelo debugger. Nessa região, além do código removido dar espaço ao jump, podemos adicionar novas instruções. Ao fim do hook ele retorna para o endereço subsequente ao pulo:
Esquema de mapa de memória e execução durante um hook
Em um dos casos que encontrei tratava-se de um jogo onde a pontuação mudava dinamicamente a cada nível, com isto, apesar do valor da variável da pontuação ser conhecida, trocar manualmente seu valor era desconfortável.
Portanto, adicionando um breakpoint de dado no valor da pontuação encontrei a função que checa se a pontuação foi atingida a cada quadro:
1.mov eax,[ecx+000000A0] ;Pontuação atual lida 2.mov ecx,[ecx+000000A4] ;Pontuação meta lida 3.cmp eax,ecx ; compara os pontos atuais com a meta 4.<--je 05AE94C6 5.mov eax,[ebp+08] 6.mov eax,[eax+000000A8] 7.test eax,eax 8.jne 05AE9C7F ;Todo este trecho é pulado se não atingirmos a meta 9.mov eax,[ebp+08] 10.movzx eax,byte ptr [eax+7C] 11.test eax,eax 12.jne 05AE9C7F 13.-->mov eax,[ebp+08] 14.mov eax,[eax+28] ;resto da função 15.mov ecx,eax ...
Apesar de ser muito difícil interpretar o que toda a função faz, apenas as 4 primeiras instruções são importantes.
Se a pontuação atual for diferente do objetivo, o trecho do meio responsável por passar de fase não roda. Eu tentei remover apenas o jump e forçar sua execução mas o jogo trava, provavelmente devido a uma comparação futura com a pontuação. Portanto, a ideia é roubar o fluxo de execução logo antes da comparação da instrução 3 ser executada.
Para descobrir o endereço de onde deve ser injetado o pulo é possível fazer um scan por Array of bytes. No caso buscamos o padrão das instruções acima. Essa é uma maneira consistente de manter o cheat funcionando mesmo após atualizações que modifiquem o binário, pois mesmo que o endereço da função mude, dificilmente suas instruções serão alteradas
O Cheat Engine em especial permite escrever um script que realiza essa busca e aplica o hook. Quando o script está ativo ele aplica os patches necessários, enquanto está desativado ele reverte as alterações para o código original. Dessa forma foi simples criar um botão de auto win para qualquer estágio do jogo:
aobscan(INJECT,8B 89 A4 00 00 00 3B C1 74) ;sequência de instruções a ser buscada alloc(newmem,$1000) [ENABLE] code: mov eax,[ecx+000000A4] ;Instrução original de leitura - lê a meta mov [ecx+000000A0],eax ;copia a meta para a pontuação atual mov ecx,eax ;e também para o registrador onde está a pontuação atual jmp return ;volta para a execução normal de código
Exemplo de injeção de código criando um botão de auto win
Exemplo de injeção de código alterando o comportamento dos inimigos para curar o jogador
Online multiplayer
Enquanto nos jogos single player o uso de cheats traz pouco ou nenhum problema para os demais jogadores, quando se trata de multiplayer online, sobretudo dos jogos mais competitivos, cheats podem se tornar um pesadelo quando jogadores passam a obter vantagens absurdas sobre os demais, criando um ambiente injusto e frustrante dentro da partida.
Felizmente as engines que suportam multiplayer já são pensadas com a segurança em mente, mitigando a maioria das técnicas discutidas na seção sobre single player.
Modelo de cliente servidor
O modelo de cliente servidor assume que existe uma máquina na rede que possui autoridade sobre as demais máquinas, máquina esta chamada de servidor. O servidor também contém um único estado do jogo que é replicado para os demais clientes. Toda a interação entre jogadores deve passar pelo servidor, já que os clientes não possuem nenhuma conexão entre si. Com isto, é tarefa do servidor validar todos os inputs recebidos antes de replicá-los. Existem duas formas de interação servidor-cliente, são elas Remote Procedure Calls (RPCs) e Replicação de Variáveis.
RPCs são um pedido feito para outra máquina rodar um código específico e estes pedidos podem ser feitos pelo servidor ou pelo cliente. Por exemplo, se o jogador 1 de um jogo tiro deseja atirar, os outros clientes devem ser capazes de observar que o disparo foi realizado. Para isso ele deve solicitar ao servidor que realize um disparo usando uma RPC. Em seguida, o servidor deve solicitar aos demais jogadores que repliquem esse disparo, partindo da cópia que cada um deles tem do jogador 1:
Exemplo de código multiplayer para FPS implementado na Unreal Engine 4
A segunda forma de comunicação é a replicação de variáveis. Diferente de RPCs, a replicação ocorre apenas do servidor para o cliente. No exemplo acima a função dar dano altera (server side) a variável "vidaDoPersonagem". Quando isso acontece, o servidor envia um pacote atualizando o seu valor para todos os clientes. Portanto, nenhum cliente possui de fato a variável vida mas uma cópia dela. Por isso as técnicas de alteração de variáveis discutidas na seção de jogos offline raramente funcionam em multiplayers. Embora seja possível alterar essa cópia, o estado do jogo no servidor permanece intacto, então mesmo que você tenha 1000 de vida em sua cópia, quando você morrer no servidor, ainda ocorrerá uma RPC para a sua máquina e para os outros jogadores avisando que você morreu.
Tradeoff entre jogabilidade e segurança - O problema do input
Sempre que o servidor recebe uma atualização do cliente ele deve checar se a ação feita pelo jogador é válida, como por exemplo se o jogador ainda possui munição antes de atirar, se a arma não está em recarga, etc. Todavia, algumas ações são praticamente impossíveis de serem verificadas. Um servidor poderia verificar por exemplo todo o input feito pelo jogador, enviando cada botão pressionado para que o servidor calcule coisas como posição ou ângulo da câmera, porém devido à latência de rede e o tempo de processamento isso significaria uma jogabilidade travada e lenta. A menos que o jogo seja lento como uma partida de xadrez, o servidor precisa confiar parcialmente no input dado pelo usuário. Por isso, cheats como speedhacks e aimbots são tão comuns. Posição e orientação de onde o jogador está olhando são basicamente controladas pelo cliente.
Algumas engines como a Unreal conseguem mitigar os efeitos de possíveis speed hacks calculando em server side a posição que o personagem deveria estar e aplicando as devidas correções no cliente. Essas correções também servem para sincronizar o cliente com o servidor em situações de dessincronia como durante perdas de pacotes:
Exemplo de correção de posicionamento pelo servidor na Unreal Engine 4
Padrões inseguros de programação para multiplayer
- Não validar corretamente a entrada de RPCs;
- No caso acima um cliente não íntegro poderia passar um valor elevado como parâmetro, dando danos absurdos aos adversários;
- Não validar o tempo entre RPCs;
- O jogador não deve atirar milhares de vezes por segundo, mas não tem nada no servidor que impeça isso;
- Não replicar as variáveis necessárias;
- O desenvolvedor não deve confiar ao cliente variáveis importantes como vida ou munição.
Exemplo de código multiplayer vulnerável
RCEs e ataques a outros jogadores
Quando um desenvolvedor publica um jogo ele está distribuindo software que vai rodar no computador de diversos usuários. Como qualquer outro programa, ele é suscetível à ataques clássicos de corrupção de memória como buffer overflow, Use After Free, etc, que podem permitir execução remota de código para outros atacantes que possam interagir com o programa. Esse foi o caso do CVE-2019-15943, onde no Counter-Strike: Global Offensive (CSGO) um jogador poderia convidar outro player para uma partida utilizando um mapa customizado. O mapa customizado era criado especificamente para fazer o jogo crashar, corrompendo a cadeia SHE e potencialmente permitindo a execução de código na máquina do usuário que entrou na partida.
Acessibilidade ao código, Unity e Among Us
Há alguns meses atrás eu estava curioso para ver como o game Among Us implementa algumas coisas. Uma breve pesquisa no google revela que o jogo foi desenvolvido usando a Unity Engine. Saber em qual engine o programa é produzido é útil na hora de olhar pelos arquivos do game durante uma análise estática. A Unity utiliza C# como linguagem de programação, gerando arquivos .NET (quando no Windows) que são facilmente decompilados e editáveis por ferramentas como dnSpy, tornando o processo de modificação e engenharia reversa bem simples. No entando, atualmente a Unity suporta também o uso do IL2cpp, um conversor de linguagem intermediária (IL) para C++ antes de gerar o pacote do projeto. Isso gera benefícios como performance para o jogo e também dificulta o acesso ao código.
Entretanto, o processo de engenharia reversa não é impossível. Utilizando a ferramenta IL2cppDumper é possível extrair a partir dos arquivos de metadados os offsets de cada função do GameAssembly.dll, bem como seus respectivos nomes. Vasculhando pelas funções foi possível encontrar a rotina que gerencia o cooldown do botão de matar quando o jogador é o impostor:
Método responsável pelo cooldown do botão, decompilado pelo ghidra
Código assembly referente ao método SetTimeKiller
Aqui vemos que a função recebe o tempo como parâmetro e seu primeiro uso é escrevê-lo em um dos atributos do objeto. Um hack interessante e bem crítico seria transformar qualquer tempo de cooldown em 0.
Código assembly após o patch
Troquei a instrução xmm0,[ebp +0xc] que lia o argumento da pilha por um simples xor eax,eax, zerando o registrador.
No lugar de movss [esi+0x44],xmm0 basta mover o valor de eax, como se todo o argumento fosse 0. Com apenas duas instruções alteradas, o exploit funcionou bem mesmo em sessões online.
O servidor não checava o tempo entre as RPCs para matar o jogador, permitindo assim abusos por clientes modificados e eventualmente frustrando os demais jogadores depois de morrerem em partidas de 10 segundos ou menos.
Método após o patch, decompilado pelo Ghidra
Referências
- 1
- 2