Este artigo discute a interceptação de funções gráficas da API usando o exemplo do DirectX 9 para x64 em relação ao jogo
Dota 2 .
Será descrito em detalhes como se infiltrar no processo do jogo, como alterar o fluxo de execução, uma breve descrição da lógica implementada. No final, falaremos sobre outros recursos de renderização que o mecanismo fornece.

Isenção de responsabilidade: O autor não é responsável pelo uso dos conhecimentos adquiridos neste artigo ou por danos resultantes do uso. Todas as informações aqui apresentadas são apenas para fins educacionais. Especialmente para empresas que desenvolvem MOBA para ajudá-las a lidar com trapaceiros. E, é claro, o autor do artigo é um motorista de bot, um trapaceiro, e sempre foi.
Vale a pena explicar a última frase - sou a favor da concorrência justa. Uso truques apenas como interesse esportivo, aprimoro as habilidades reversas, estudo o trabalho de anti-truques e apenas fora das competições de classificação.
1. Introdução
Este artigo é planejado como o primeiro de uma série e fornece uma idéia de como você pode usar a API gráfica para seus próprios fins, descreve a funcionalidade necessária para entender a próxima parte. Pretendo dedicar o segundo artigo a procurar um ponteiro para a lista de entidades na Origem 2 (também usando o Dota 2 como exemplo) e usá-lo em conjunto com o
Source2Gen para escrever lógica "adicional" (algo como
isso provavelmente mostrará "map hack" (verifique atenção às citações, o que está em jogo pode ser visto no vídeo) ou a automação do primeiro artigo). O terceiro artigo é planejado na forma de escrever um driver, comunicar-se com ele (IOCTL), usá-lo para ignorar a proteção VAC (algo semelhante a
isso ).
2. Por que eu preciso disso
Eu precisava do uso da API gráfica para depurar visualmente meu bot, que escrevi para o Dota 2 (informações visualizadas em tempo real são muito convenientes). Eu sou um estudante de graduação e estou envolvido na reconstrução de cabeças 3D e morphing usando imagens e uma câmera de profundidade - o tópico é bastante interessante, mas não o meu favorito. Desde que faço isso pelo quinto ano (começando pelo programa de mestrado), entendi uma coisa - sim, estudei bastante essa área, estudo facilmente artigos com métodos e abordagens e os implemento. Mas é tudo, eu mesmo só posso otimizar o próximo algoritmo aprendido, compará-lo com os já estudados e implementados e decidir se deve usá-lo em uma tarefa específica. É o fim da otimização, não é possível inventar algo novo, o que é muito importante para a pós-graduação (a novidade do estudo). Comecei a pensar - enquanto há tempo, você pode encontrar um novo tópico. Você já precisa entender bem o tópico (no nível atual) ou pode puxá-lo rapidamente.
Ao mesmo tempo, trabalhei no game dev, e este é provavelmente o mais interessante do que um programador pode fazer (opinião pessoal) e estava muito interessado no tópico da IA, bots. Naquela época, havia dois tópicos que eu conhecia bastante bem - então eu estava construindo uma malha de navegação dinâmica (cliente-servidor) e estudando a parte da rede de um shooter dinâmico. Um tópico com um navegador dinâmico não se encaixou imediatamente - eu fiz isso durante o horário de trabalho, tive que pedir permissão à gerência para uso no diploma; além disso, o tópico da novidade estava aberto - também estudei e implementei as abordagens por artigo, mas isso não era novo. O tópico com a parte de rede do shooter dinâmico (planejei usá-lo para interação em realidade virtual) voltou a se dividir tanto pelo fato de estar fazendo isso durante o horário de trabalho quanto pela novidade, você pode ler uma
série de artigos da Pixonic, onde o próprio autor diz que o tópico isso é interessante, apenas abordagens foram inventadas há 30 anos e não mudaram muito.
Por volta dessa época, a OpenAI lançou seu bot. Certamente não é
5 por 5 , mas foi incrível! Não consegui pensar fora para tentar criar um bot e, antes de tudo, comecei a pensar em como usá-lo como dissertação, sobre novidade e como apresentá-lo a um líder. Com a novidade a esse respeito, tudo ficou muito melhor - com certeza foi possível criar algo para os dois tópicos anteriores, mas aparentemente o bot me fez pensar, me apegar, desenvolver e procurar idéias muito mais fortes. Então, eu decidi fazer um bot 1-on-1 (uma batalha no meio, como a OpenAI), apresentá-lo ao líder, dizer o quão legal é, quantas abordagens diferentes, matemática e, o mais importante, a nova.
A coisa mais necessária que o bot precisa no primeiro estágio é o conhecimento do ambiente em que ele está - pretendi tirar o estado do mundo da memória do jogo e passei o primeiro estágio procurando um ponteiro para a Lista de Entidades e integração com a ideia da oração Dog2 Source2Gen - isso gera a estrutura do mecanismo Source2, que é retirada dos circuitos. A principal idéia e pré-requisito para o surgimento de esquemas é a replicação de estado entre o cliente e o servidor, mas aparentemente os desenvolvedores realmente gostaram da idéia e a distribuíram muito mais amplamente, aconselho que você leia
aqui .
Eu tinha experiência em engenharia reversa: fiz truques para Silent Storm, fiz geradores de chaves (o mais interessante foi para Black & White) - o que é keygen pode ser lido no
DrMefistO aqui , execução de
combo no Cabal Online (tudo foi complicado pelo fato de este jogo ser protegido pelo Game Guard , protegeu-o do ring0 (sob o driver no modo kernel), ocultando o processo (que pelo menos não facilita a infiltração) - mais detalhes podem ser lidos
aqui ).
Conseqüentemente, eu tive desenvolvimentos nessa área, o bot teve acesso ao ambiente pelo tempo planejado. É incrível a quantidade de informações que o servidor bunker replica através do delta para o cliente, por exemplo, o cliente tem informações sobre qualquer teleportador, saúde e suas mudanças entre os agentes (exceto Roshan, ele não replica) - tudo isso está no nevoeiro da guerra. Embora tenha encontrado algumas dificuldades, é sobre isso que vou falar no próximo artigo.
Se você tiver alguma dúvida sobre por que não usei o
Dota Bot Scripting , responderei com um trecho da documentação:
A API é restrita, de modo que os scripts não podem trapacear - as unidades no FoW não podem ser consultadas, os comandos não podem ser emitidos para as unidades que o script não controla etc.
Esta série de artigos é direcionada a iniciantes interessados no tópico de engenharia reversa.
3. Por que estou escrevendo sobre isso
Como resultado, enfrentei muitos problemas na implementação do bot do ml, com os quais dediquei tempo suficiente para entender que dois anos antes do final do treinamento eu não conseguia superar meu conhecimento e experiência no tópico atual. No Dota 2, não jogo desde o lançamento do costume Dota Auto Chess, agora passo meu tempo livre no diploma e no verso do Apex Legend (cuja estrutura é bastante semelhante ao Dota 2, como me parece). Consequentemente, o único benefício do trabalho realizado é a publicação de um artigo técnico sobre esse assunto.
4. Dota 2
Eu pretendo mostrar esses princípios em um jogo real - Dota 2. O jogo usa o
anti- cheat
Valve Anti Cheat . Eu realmente gosto da Valve como empresa: produtos muito legais, diretor, atitude para com os jogadores, Steam, Source Engine 2, ... VAC. O VAC funciona no modo de usuário (ring3), não verifica tudo e é inofensivo em comparação com outros anti-cheats (tudo o que o esea faz (especificamente o anti-cheat) faz desaparecer todo o desejo de usar esta plataforma). Tenho certeza de que o VAC faz seu trabalho de maneira tão poupadora - não monitora no modo kernel, não bane hardware (apenas uma conta), não insere marcas d'água em capturas de tela - graças à atitude da Valve em relação aos jogadores, eles não instalam um antivírus completo para você, como fazem Game Guard, BattlEye, Warden e outros, porque tudo é hackeado e gasta os recursos do processador que o jogo pode levar (mesmo que isso seja feito periodicamente), há falsos positivos (especialmente para jogadores em laptops). Não existe um hack de parede, aimbot, speed hack, ESP em PUBG, Apex, Fortnite?
Na verdade, sobre o Dota 2. O jogo roda a uma frequência de
40Hz (25 ms), o cliente interpola o estado do jogo, a previsão de entrada não é usada - se você tiver um atraso, um jogo - é importante nem mesmo um jogo, unidades controladas - ficarem completamente congelados. O servidor de mecânica de jogos troca mensagens criptografadas com o cliente via RUDP (UDP confiável), o cliente basicamente envia entradas (se você hospedar o lobby, os comandos podem ser enviados), o servidor envia uma réplica do mundo do jogo e da equipe. A navegação é realizada em uma grade 3D, cada célula possui seu próprio tipo de permeabilidade. O movimento é realizado usando a navegação e a física (a impossibilidade de passar pela fissura de um shaker, kogi clokverka, etc.).
O estado do mundo com todas as entidades está na memória em sua forma mais pura, sem criptografia - você pode estudar a memória do jogo usando o Cheat Engine. Ofuscação não se aplica a seqüências de caracteres e código.
DirectX9, DirectX11, Vulkan, OpenGL estão disponíveis na API gráfica. 5. Declaração do problema
No jogo Dota 2, há um "antigo" neutro, cuja matança dá uma boa recompensa: experiência, ouro, a capacidade de reverter as recargas de habilidades e objetos, Aegis (second life), seu nome é Roshan. Conseguir Aegis pode fundamentalmente mudar o jogo ou dar uma vantagem ainda maior ao lado mais forte, respectivamente, os jogadores tentam se lembrar / registrar o tempo de sua morte para planejar quando ficarem juntos e atacá-lo, ou estar perto para sua proteção. Todos os dez jogadores são notificados da morte de Roshan, independentemente de ele estar escondido no nevoeiro da guerra. O tempo de reaparecimento tem oito minutos obrigatórios, após os quais Roshan pode aparecer aleatoriamente no intervalo de três minutos.
A tarefa é a seguinte : fornecer ao jogador informações sobre o estado atual de Roshan (vivo-vivo, ressurect_base-revive o tempo base, ressurect_extra-revive o tempo extra).
Figura 1 - Condições para transições entre estados e ações durante a transiçãoPara condições nas quais Roshan está morto, exiba o horário final da estadia nesse estado. A transição do estado ativo para o ressurect_base deve ser feita pelo jogador no modo manual pelo botão. No caso de detecção / morte de Roshan no estado ressurect_extra (por exemplo, uma equipe inimiga entrou furtivamente no esconderijo e o matou), a transição para o estado vivo / ressurect_base também é realizada manualmente pelo botão. O status de Roshan (e a hora final de um estado de reavivamento) deve ser mostrado em forma de texto, a entrada necessária (interrupção e interrupção do estado de ressurect_extra) deve ser fornecida com um botão.
Figura 2 - Elementos da interface - etiqueta, botão e telaEsta é a única tarefa que eu pude realizar para não precisar trabalhar com a memória do jogo e haver pelo menos algum valor para o jogador - mesmo para derivar quaisquer características elementares, como saúde, mana e posições de entidades, você precisa encontrá-las antecipadamente ajude o Cheat Engine na memória do jogo, que precisa ser
explicado adicionalmente
por um longo período de tempo, ou com a ajuda do Source2Gen, que será o próximo artigo. A afirmação do problema força o jogador a seguir Roshan, mudando muitas ações para ele, o que é bastante inconveniente - mas haverá algo em que confiar na segunda parte.
Escreveremos nosso injected.dll, que conterá a lógica comercial baseada no MVC e a implementará no processo do Dota 2. A DLL usará nossa biblioteca silk_way.lib, que conterá a lógica de interceptação para alterar o fluxo de execução, o criador de logs, o scanner de memória e as estruturas de dados. .
6. Injetor
Crie um projeto C ++ vazio, chame NativeInjector. O código principal está na função Injetar.
void Inject(string & dllPath, string & processName) { DWORD processId = GetProcessIdentificator(processName); if (processId == NULL) throw invalid_argument("Process dont existed"); HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE, FALSE, processId); HMODULE hModule = GetModuleHandle("kernel32.dll"); FARPROC address = GetProcAddress(hModule, "LoadLibraryA"); int payloadSize = sizeof(char) * dllPath.length() + 1; LPVOID allocAddress = VirtualAllocEx( hProcess, NULL, payloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); SIZE_T written; bool writeResult = WriteProcessMemory(hProcess, allocAddress, dllPath.c_str(), payloadSize, & written); DWORD treadId; CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE) address, allocAddress, 0, & treadId); CloseHandle(hProcess); }
A função obtém o caminho e o nome do processo, procura seu ID pelo nome do processo usando GetProcessIdentificator.
função GetProcessIdentificator DWORD GetProcessIdentificator(string & processName) { PROCESSENTRY32 processEntry; processEntry.dwSize = sizeof(PROCESSENTRY32); HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL); DWORD processId = NULL; if (Process32First(snapshot, & processEntry)) { while (Process32Next(snapshot, & processEntry)) { if (!_stricmp(processEntry.szExeFile, processName.c_str())) { processId = processEntry.th32ProcessID; break; } } } CloseHandle(snapshot); return processId; }
Em resumo, GetProcessIdentificator executa todos os processos em execução e procura um processo com o nome apropriado.
Figura 3 - o estado inicial do processoEm seguida, a implementação direta da biblioteca, criando um fluxo remoto.
Explicação detalhada da função InjetarCom base no ID encontrado, o processo é aberto usando a função OpenProcess com os direitos de criar um encadeamento, receber informações do processo, recursos de gravação e leitura. A função GetModuleHandle recupera o módulo da biblioteca kernel32; isso é feito para obter o endereço da função LoadLibraryA contida nela pela função GetProcAddress. O objetivo do LoadLibrary é carregar nosso injected.dll no processo especificado. Ou seja, precisamos chamar o LoadLibrary a partir do processo de seu interesse (“Dota2.exe”), para isso criamos remotamente um novo thread usando CreateRemoteThread. Como um ponteiro para a função a partir da qual o novo thread inicia, passamos o endereço da função LoadLibraryA. Se você observar a assinatura da função LoadLibraryA, será necessário o caminho para a biblioteca carregada como argumento - HMODULE LoadLibraryA (LPCSTR lpLibFileName). Entregamos esse argumento da seguinte maneira: CreateRemoteThread nos parâmetros após o endereço da função start levar um ponteiro para seus parâmetros, formamos um ponteiro para lpLibFileName gravando o valor na memória do processo usando a função WriteProcessMemory (depois de alocar memória usando o VirtualAllocEx).
Figura 4 - Criando um fluxo remotoCertifique-se de fechar o manipulador de processos no final com a função CloseHandle; também é possível liberar a memória alocada. Nosso injetor está pronto e esperando que gravemos a lógica de negócios em injected.dll com a biblioteca silk_way.lib.
Figura 5 - Concluindo a implementação da bibliotecaPara uma melhor compreensão do princípio, você pode assistir ao
vídeo . Concluindo, direi que é uma abordagem mais segura com a implementação direta de código no
thread principal do processo.
7. Caminho da Seda
Vamos começar a implementar o silk_way.lib, uma biblioteca estática que contém estruturas de dados, um registrador, um scanner de memória e traps. De fato, peguei uma pequena parte do meu trabalho, algo que pode ser explicado de maneira mais simples, que não está muito ligada ao resto, mas ao mesmo tempo resolve o problema.
7.1 Estruturas de dados.
Brevemente sobre estruturas de dados: Vetor - lista clássica, tempo de inserção e exclusão O (N), pesquisa O (N), memória O (N); Fila - uma fila circular, o tempo de inserção e exclusão de O (1), sem pesquisa, memória O (N); RBTree - árvore vermelho-preta, tempo de inserção e exclusão O (logN), pesquisa O (logN), memória O (N). Prefiro o hash usado para implementar dicionários em C # e Python, as árvores vermelho-pretas que a biblioteca C ++ padrão usa. O motivo é que um hash é mais difícil de implementar corretamente em comparação com uma árvore (eu encontro e tento variedades de hashes a cada meio ano) e geralmente um hash ocupa mais memória (embora funcione mais rápido). Essas estruturas são usadas para criar coleções na lógica de negócios e nas armadilhas.
Eu tento não usar estruturas da biblioteca padrão e implementá-las eu mesmo, especificamente não importa no nosso caso, mas é importante se a sua DLL for depurada ou se o assembly estiver claro (isso é mais provável para truques comerciais, que condenamos ) Aconselho você a escrever todas as estruturas você mesmo, isso oferece mais oportunidades.
Como exemplo, se você cria um jogo e não deseja que os “alunos” o façam a varredura usando o Cheat Engine, é possível criar invólucros para tipos primitivos e armazenar o valor
criptografado na memória. De fato, isso não é uma salvação, mas pode eliminar alguns daqueles que estão tentando ler e mudar a memória do jogo.
7.2 Logger
Saída implementada no console e gravada em um arquivo. Interface:
class ILogger { protected: ILogger(const char * _path) { path = path; } public: virtual ~ILogger() {} virtual void Log(const char * format, ...) = 0; protected: const char * path; };
Implementação para saída em um arquivo:
class MemoryLogger: public ILogger { public: MemoryLogger(const char * _path): ILogger(_path) { fopen_s( & fptr, _path, "w+"); } ~MemoryLogger() { fclose(fptr); } void Log(const char * format, ...) { char log[MAX_LOG_SIZE]; log[MAX_LOG_SIZE - 1] = 0; va_list args; va_start(args, format); vsprintf_s(log, MAX_LOG_SIZE, format, args); va_end(args); fprintf(fptr, log); } protected: FILE * fptr; };
A implementação para saída no console é a mesma. Se quisermos usar o log, é necessário definir a interface ILogger *, declarar o logger necessário, chamar a função Log com o formato necessário, por exemplo:
ILogger* logger = new MemoryLogger(filename); logger->Log("(%llu)%s: %d\n", GetCurrentThreadId(), "EnumerateThread result", result);
7.3 Scanner
O scanner está comprometido com o fato de exibir o valor da memória apontado pelo ponteiro transferido e compará-lo com a amostra na memória. A comparação funcional com o padrão será considerada posteriormente.
Interface:
class IScanner { protected: IScanner() {} public: virtual ~IScanner() {} virtual void PrintMemory(const char * title, unsigned char * memPointer, int size) = 0; };
Implementação de arquivo de cabeçalho:
class FileScanner : public IScanner { public: FileScanner(const char* _path) : IScanner() { fopen_s(&fptr, _path, "w+"); } ~FileScanner() { fclose(fptr); } void PrintMemory(const char* title, unsigned char* memPointer, int size); protected: FILE* fptr; };
Implementação do arquivo de origem:
void FileScanner::PrintMemory(const char* title, unsigned char* memPointer, int size) { fprintf(fptr, "%s:\n", title); for (int i = 0; i < size; i++) fprintf(fptr, "%x ", (int)(*(memPointer + i))); fprintf(fptr, "\n", title); }
Para usá-lo, você precisa definir a interface IScanner *, declarar o scanner desejado e chamar a função PrintMemory, onde é possível definir o título, o ponteiro e o comprimento, por exemplo:
IScanner* scan = new ConsoleScanner(); scan->PrintMemory("source orig", (unsigned char*)source, 30);
7.4 Armadilhas
A parte mais interessante da biblioteca silk_way.lib. Ganchos são usados para alterar o fluxo de execução do programa. Crie um projeto executável chamado Sandbox.
A classe Device será o nosso manequim para investigar a operação de armadilhas. class Unknown { protected: Unknown() {} public: ~Unknown() {} virtual HRESULT QueryInterface() = 0; virtual ULONG AddRef(void) = 0; virtual ULONG Release(void) = 0; }; class Device : public Unknown { public: Device() : Unknown() {} ~Device() {} virtual HRESULT QueryInterface() { return 0; } virtual ULONG AddRef(void) { return 0; } virtual ULONG Release(void) { return 0; } virtual int Present() { cout << "Present()" << " " << i << endl; return i; } virtual void EndScene(int j) { cout << "EndScene()" << " " << i << " " << j << endl; } void Dispose() { cout << "Dispose()" << " " << i << endl; } public: int i; };
A classe Device é herdada da interface IUnknown, nossa tarefa é interceptar a chamada das funções Present e EndScene de qualquer instância do Device e chamar as funções originais no receptor. Não sabemos o local no código onde e por que essas funções são chamadas, em qual thread.
Observando as funções Present e EndScene, vemos que elas são virtuais. Funções virtuais são necessárias para substituir o comportamento da classe pai. Funções virtuais, assim como não virtuais, são um ponteiro para uma memória na qual os códigos de opcodes e argumentos são gravados. Como as funções virtuais diferem entre herdeiros e pais, elas têm ponteiros diferentes (são funções completamente diferentes) e são armazenadas na Tabela de método virtual (VMT). Esta tabela é armazenada na memória e é um ponteiro para um ponteiro de classe. Encontramos para Device:
Device* device = new Device(); unsigned long long vmt = **(unsigned long long**)&device;
O VMT armazena ponteiros para funções virtuais; se quisermos herdar do dispositivo, o herdeiro conterá seu VMT. O VMT armazena ponteiros de função sequencialmente com uma etapa igual ao tamanho do ponteiro (para x86 é 4 bytes, para x64 é 8), correspondendo à ordem em que a função é definida na classe. Encontre os ponteiros para as funções Present e EndScene, localizadas nos terceiro e quarto lugares:
typedef int (*pPresent)(Device*); typedef void (*pEndScene)(Device*, int j); pPresent ptrPresent = nullptr; pEndScene ptrEndScene = nullptr; int main() {
Também é importante que o ponteiro para o método de classe contenha o primeiro argumento como referência à instância da classe. Em C ++, C #, isso está oculto para nós, e o compilador sabe disso - no Python self é explicitamente indicado pelo primeiro parâmetro no método de classe. Mais sobre a convenção de chamadas
aqui , você precisa procurar por essa chamada.
Considere a instrução e9 ff 3a fd ff - aqui e9 é um código de operação (com mnemônicos JMP) que instrui o processador a mudar o ponteiro para a instrução (EIP para x86, RIP para x64), pule do endereço atual para FFFD3AFF (4294785791). Também é importante notar que na memória os números são armazenados "vice-versa". As funções têm um prólogo e um epílogo e são armazenadas na seção .code. Vamos ver o que é armazenado com o ponteiro para a função Present usando o scanner:
IScanner* scan = new ConsoleScanner(); scan->PrintMemory("Present", (unsigned char*)ptrPresent, 30);
No console, vemos:
Present: 48 89 4c 24 8 48 83 ec 28 48 8d 15 40 4a 0 0 48 8b d 71 47 0 0 e8 64 10 0 0 48 8d
Para entender o conjunto desses códigos, você pode olhar para a
tabela ou usar os desmontadores disponíveis. Vamos usar um desmontador pronto -
hde (mecanismo de desmontagem de hackers). Você também pode olhar para
distorm e
capstone para comparação. Passe um ponteiro para uma função para qualquer desmontador e ele dirá quais códigos de código ele usa, os valores dos argumentos e assim por diante.
7.4.1 Gancho Opcode
Agora estamos prontos para ir diretamente às armadilhas. Veremos o gancho Opcode e o ponto de interrupção do hardware. As
armadilhas mais
comuns que eu recomendo implementar e explorar.
Provavelmente, a armadilha mais comumente usada e simples é o Opcode Hook (no artigo que lista as armadilhas, é chamado de patch de byte) - observe que ele é facilmente reconhecido pelo anti-cheat quando usado incorretamente (sem entender como o anti-cheat funciona, sem saber em que área e seção da memória ele é varrido). o momento atual e outras coisas que a proibição não diminuirá a espera). Quando usada com habilidade, é uma ótima armadilha, rápida e fácil de entender.
Se, ao ler um artigo, você estiver reproduzindo código simultaneamente e estiver no modo Debug, mude para Release - isso é importante.
Então, deixe-me lembrá-lo, precisamos interceptar a execução das funções Present e EndScene.
Implementamos interceptores - funções nas quais queremos transferir o controle:
int PresentHook(Device* device) { cout << "PresentHook" << endl; return 1; } void EndSceneHook(Device* device, int j) { cout << "EndSceneHook" << " " << j << endl; }
Vamos pensar nas abstrações que precisamos. Precisamos de uma interface que nos permita definir uma armadilha, removê-la e fornecer informações sobre ela. As informações sobre a armadilha devem conter um ponteiro para a função interceptada, o receptor e as funções de trampolim (o fato de termos interceptado a função não significa que ela não é mais necessária, também queremos poder usá-la - o trampolim ajudará a chamar a função interceptada original).
#pragma pack(push, 1) struct HookRecord { HookRecord() { reservationLen = 0; sourceReservation = new void*[RESERV_SIZE](); } ~HookRecord() { reservationLen = 0; delete[] sourceReservation; } void* source; void* destination; void* pTrampoline; int reservationLen; void* sourceReservation; }; #pragma pack(pop) class IHook { protected: IHook() {} public: virtual ~IHook() {} virtual void SetExceptionHandler( PVECTORED_EXCEPTION_HANDLER pVecExcHandler) = 0; virtual int SetHook(void* source, void* destination) = 0; virtual int UnsetHook(void* source) = 0; virtual silk_data::Vector<HookRecord*>* GetInfo() = 0; virtual HookRecord* GetRecordBySource(void* source) = 0; };
A interface do IHook nos fornece esses recursos. Queremos que quando qualquer instância da classe Device chama as funções Present e EndScene (ou seja, o ponteiro RIP vai para esses endereços), nossas funções PresentHook e EndSceneHook são executadas de acordo.
Imagine visualmente como a função interceptada, o receptor e o trampolim estão localizados na memória (seção .code) no momento em que o controle entra na função interceptada:
Figura 6 - O estado inicial da memória, a execução entra na função interceptadaAgora queremos que o RIP (seta vermelha) vá da origem até o início do destino. Como fazer isso? Como já foi dito acima, a memória de origem contém um código operacional que o processador executará quando a execução chegar à fonte. Em essência, precisamos pular de uma parte para outra, redirecionar o ponteiro do RIP. Como você deve ter adivinhado, existe um código de operação que permite transferir o controle do endereço atual para o desejado, esses mnemônicos JMP são chamados.
Você pode pular diretamente para o endereço desejado ou, em relação ao endereço atual, esses saltos podem ser encontrados na placa - ff e e9, respectivamente. Crie estruturas para estas instruções:
#pragma pack(push, 1)
A instrução de salto relativa é mais curta, mas há uma limitação - unsigned int diz que você pode pular dentro de 4.294.967.295, o que não é suficiente para x64.
Assim, o endereço da função de destino do receptor de destino pode facilmente exceder esse valor e ficar fora do int não assinado, o que é bastante possível para o processo x64 (para x86 tudo é muito mais simples e você pode limitar-se a esse salto relativamente relativo para implementar o gancho Opcode). Um salto direto leva 14 bytes; para comparação, um salto relativo é de apenas 5 (empacotamos as estruturas, preste atenção ao pacote #pragma (push, 1)).
Precisamos reescrever o valor na fonte para uma dessas instruções de salto.
Antes de capturar uma função, você deve estudá-la - a maneira mais fácil de fazer isso é com um depurador (mostrarei como fazer isso com o x64dbg posteriormente) ou com um desmontador. No momento, já produzimos 30 bytes desde o início, a instrução 48 89 4c 24 8 ocupa 5 bytes.
Vamos implementar um salto relativo. Eu gosto mais dessa opção por causa do comprimento da instrução. A idéia é a seguinte: substituímos os 5 primeiros bytes da função original, preservando os bytes alterados, substituindo-os por um salto relativo para o endereço da instrução, que fica dentro do int não assinado.
Figura 7 - Os 5 bytes de origem da função de origem são substituídos por um salto relativoO que nos dá um salto para a memória alocada (região roxa), como nos aproximamos da transferência do controle para o destino com esta ação? Na memória alocada por nós, há um salto direto, que moverá o RIP para o destino.
Figura 8 - Alternando o RIP para a função receptorResta descobrir como chamar a função capturada. Precisamos executar as instruções atoladas e iniciar a execução da parte intocada da fonte. Nós procedemos da seguinte maneira - salve as instruções danificadas no início do trampolim, lembre-se de quantos bytes foram danificados e pule diretamente para source + corruptLen, para as instruções "saudáveis".
Execução de instruções salvas apagadas por um salto relativo:
Figura 9 - Usando um trampolim para chamar uma função interceptadaExecução adicional de instruções que não afetaram o mashing:
Figura 10 - Continuação da execução das instruções da função interceptadaCódigo que implementa a ideia descrita acima int OpcodeHook::SetHook(void* source, void* destination) { auto record = new HookRecord(); record->source = source; record->destination = destination; info->PushBack(record); JMP_ABS pattern = {0xFF, 0x25, 0x00000000,
Explicação da função SetHookÉ criado um registro que armazena informações sobre a interceptação, após o que o registro é adicionado à coleção. As instruções são rastreadas desde o início do endereço de origem até que a instrução de salto relativo possa ser completamente inserida (5 bytes), as instruções bloqueadas são copiadas para a reserva e seu comprimento é lembrado.
Um ponto muito importante é que precisamos alocar memória para o trampolim e para a retransmissão, na qual armazenaremos instruções para redirecionar o fluxo da origem para o destino, e o endereço dessa memória deve estar dentro dos limites que um salto relativo pode se dar ao luxo de pular (sem sinal) int).
Essa funcionalidade implementa a função AllocateMemory.
void* OpcodeHook::AllocateMemory(void* origin, int size) { const unsigned int MEMORY_RANGE = 0x40000000; SYSTEM_INFO sysInfo; GetSystemInfo(&sysInfo); ULONG_PTR minAddr = (ULONG_PTR)sysInfo.lpMinimumApplicationAddress; ULONG_PTR maxAddr = (ULONG_PTR)sysInfo.lpMaximumApplicationAddress; ULONG_PTR castedOrigin = (ULONG_PTR)origin; ULONG_PTR minDesired = castedOrigin - MEMORY_RANGE; if (minDesired > minAddr && minDesired < castedOrigin) minAddr = minDesired; int test = sizeof(ULONG_PTR); ULONG_PTR maxDesired = castedOrigin + MEMORY_RANGE - size; if (maxDesired < maxAddr && maxDesired > castedOrigin) maxAddr = maxDesired; DWORD granularity = sysInfo.dwAllocationGranularity; ULONG_PTR freeMemory = 0; ULONG_PTR ptr = castedOrigin; while (ptr >= minAddr) { ptr = FindPrev(ptr, minAddr, granularity, size); if (ptr == 0) break; LPVOID pAlloc = VirtualAlloc((LPVOID)ptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pAlloc != 0) return pAlloc; } while (ptr < maxAddr) { ptr = FindNext(ptr, maxAddr, granularity, size); if (ptr == 0) break; LPVOID pAlloc = VirtualAlloc((LPVOID)ptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pAlloc != 0) return pAlloc; } return NULL; }
A idéia é simples - vamos partir da memória, começando de um determinado endereço (no nosso caso, um ponteiro para a fonte) para cima e para baixo até encontrarmos um pedaço adequado de tamanho livre.
Voltar para a função SetHook. Copie os bytes gastos da origem para a memória alocada e insira imediatamente um salto direto na origem + corrompido para continuar a execução com instruções não danificadas.
A seguir, é a instalação do ponteiro de retransmissão, responsável por redirecionar o encadeamento de execução para o destino, saltando diretamente para o endereço do receptor. No final, alteramos a fonte - configuramos as permissões de gravação para o local da memória em que a função está localizada e substituímos os primeiros 5 bytes por um salto relativo que leva ao endereço do relé.
Montamos uma armadilha, mas ela também precisa ser capaz de limpar. Quebrando - não criando, a idéia é simples - retornaremos os bytes gastos da fonte, excluiremos o registro sobre a interceptação da coleção e liberaremos a memória alocada:
int OpcodeHook::UnsetHook(void* source) { auto record = GetRecordBySource(source); DWORD oldProtect = 0; VirtualProtect(source, sizeof(JMP_REL), PAGE_EXECUTE_READWRITE, &oldProtect); memcpy(source, record->sourceReservation, record->reservationLen); VirtualProtect(source, sizeof(JMP_REL), oldProtect, &oldProtect); info->Erase(record); FreeMemory(record); return SUCCESS_CODE; }
Testando o trabalho. Troque imediatamente nossos receptores para que eles possam chamar as funções interceptadas usando o trampolim:
int PresentHook(Device* device) { auto record = hook->GetRecordBySource(ptrPresent); pPresent pTrampoline = (pPresent)record->pTrampoline; auto result = pTrampoline(device); cout << "PresentHook" << endl; return result; } void EndSceneHook(Device* device, int j) { auto record = hook->GetRecordBySource(ptrEndScene); pEndScene pTrampoline = (pEndScene)record->pTrampoline; pTrampoline(device, 2); cout << "EndSceneHook" << " " << j << endl; }
Testamos se fizemos tudo corretamente, se a memória está fluindo, se tudo está sendo executado corretamente. int main() { while (true) { Device* device = new Device(); device->i = 3; unsigned long long vmt = **(unsigned long long**)&device; ptrPresent = (pPresent)(*(unsigned long long*)(vmt + 8 * 3)); ptrEndScene = (pEndScene)(*(unsigned long long*)(vmt + 8 * 4)); IScanner* scan = new ConsoleScanner(); scan->PrintMemory("Present", (unsigned char*)ptrPresent, 30); hook = new OpcodeHook(); hook->SetHook(ptrPresent, &PresentHook); hook->SetHook(ptrEndScene, &EndSceneHook); device->Present(); device->EndScene(7); device->Present(); device->EndScene(7); device->i = 5; ptrPresent(device); ptrEndScene(device, 9); hook->UnsetHook(ptrPresent); hook->UnsetHook(ptrEndScene); ptrPresent(device); ptrEndScene(device, 7); delete hook; delete device; } }
Isso funciona.
Você também pode verificar x64dgb.Lembre-se, no começo eu pedi para você trabalhar na versão Release? Agora vá para Debug e execute o programa. O programa trava ... A armadilha é acionada, mas uma tentativa de chamar o trampolim gera uma exceção, que diz que o endereço no qual chamamos trampolim não é para execução. Do que perdemos? Qual é o problema da compilação de depuração? Começamos e olhamos para o código de operação da função Presente: Present: e9 f4 36 0 0 e9 df 8d 0 0 e9 aa b0 0 0 e9 75 3e 0 0 e9 80 38 0 0 e9 da 81 0 0
Ao executar em x64dbg, você pode ver o seguinte. Figura 11 - Instruções de construção de depuração No Debug, o código de operação mudou, agora o compilador adiciona o salto relativo e9 f4 36 0. Todas as funções são agrupadas no salto, incluindo o principal e o ponto de entrada no mainCRTStartup. Outro código de operação, bem, ok, ele teve que ser copiado para o trampolim, quando o trampolim foi chamado, esse salto relativo deveria ser chamado, depois um salto direto para a parte não danificada da fonte. Aqui fica claro que tudo é feito conforme implementamos, apenas o salto relativo a esse e ao relativo, que sua execução a partir de diferentes endereços, fonte e trampolim, expõe o RIP a valores completamente diferentes.
Na minha humilde experiência, a implementação do caso de salto relativo cobre 99% do uso. Existem vários outros códigos de operação que devem ser tratados separadamente. Lembre-se de que antes de colocar uma armadilha em uma função, você não deve ser muito preguiçoso e estudá-la. Não vou incomodá-lo e acrescentarei funcionalidade à versão 100% (novamente, na minha humilde experiência); se você precisar ou estiver interessado, poderá ver como essas bibliotecas são organizadas e especificamente quais outros casos eles verificam - será fácil fazer isso. se você descobriu do que se trata.Um salto relativo é realmente bastante comum, então proponho implementá-lo. Um salto relativo consiste no código de operação e9 e no valor que você precisa para saltar em relação ao endereço atual. Dessa forma, você pode descobrir onde pular e pular direto do trampolim com um pulo direto. Mesmo se encontrarmos um novo salto relativo lá, ele já será do endereço correto.Implementação da instalação da armadilha levando em consideração o salto relativo int OpcodeHook::SetHook(void* source, void* destination) { auto record = new HookRecord(); record->source = source; record->destination = destination; info->PushBack(record); JMP_ABS pattern = {0xFF, 0x25, 0x00000000,
Se o desmontador retornar informações de que o código de operação deste comando é e9, calculamos o endereço para ir para (ULONG_PTR ripPtr = (ULONG_PTR) pSource + context.len + (INT32) context.imm.imm32) e gravamos o endereço no trampolim como o valor do argumento de salto direto.Também observo que em um ambiente com vários threads, uma situação pode surgir quando, no momento da instalação / remoção de um gancho, um dos threads pode começar a executar a função que capturamos - como resultado, o processo irá cair. Parte de como lidar com isso será descrita em Hardware Breakpoint.Se você precisar de uma ferramenta comprovada, quer ter certeza de que sua armadilha funcionará, não terá suas próprias idéias e não estudará o prólogo de funções - use soluções prontas, por exemplo, a Microsoft oferece sua própria biblioteca de desvio. Eu não uso essas bibliotecas e uso uma solução criada por vários motivos; portanto, não posso aconselhar algo, só posso nomear as bibliotecas que estudei para descobrir algo novo e usá-lo: PolyHook , MinHook , EasyHook (especialmente se você precisar de ganchos em C #).7.4.2 Ponto de interrupção do hardware
O Opcode Hook é uma armadilha simples e rápida, mas não a mais eficiente. Um anti-cheat pode rastrear facilmente uma alteração em uma parte da memória, mas o Gancho Opcode pode ser usado com relação ao próprio anti-cheat ou à interceptação de chamadas do sistema (por exemplo, NtSetInformationThread) que ele usa. O ponto de interrupção do hardware é uma armadilha que não altera a memória do processo. Vi tópicos em fóruns perguntando se o VAC estava seguindo essa armadilha - as respostas geralmente são variadas. Pessoalmente, o VAC não me proibiu de usá-los e não redefiniu os registros (foi há pouco menos de seis meses atrás, talvez algo tenha mudado)., , VAC DR /, - , . HWBP , - , , , DR0-DR7 .
O HWBP usa registros especiais do processador para interromper a execução do encadeamento. Se o contexto do fluxo contiver os registradores DR0-DR7 definidos de uma certa maneira e o RIP for para um dos quatro endereços armazenados em DR0-DR3, é lançada uma exceção que pode ser capturada pelo tipo de exceção e pelo estado do contexto, determinar em qual endereço o controle lançou a exceção e concluir - uma armadilha ou não. Uma limitação significativa dessa abordagem é que você pode usar apenas quatro funções por vez e defini-las separadamente para cada encadeamento, o que gera inconvenientes se a interceptação for configurada e uma nova for criada, ou se for criada uma nova / se o encadeamento antigo for recriado, causando a interceptação. Este não é um obstáculo especial e é governado pela interceptação da função BaseThreadInitThunk; a restrição no uso de 4 traps não me incomodou pessoalmente.Se o número de ganchos for crítico para você, observe a abordagem do PageGuard.Portanto, a tarefa é a mesma - como estamos na sandbox (projeto Sandbox), é necessário interceptar os métodos da classe Device Present e EndScene na qual chamar os métodos originais. Já temos uma interface pronta para armadilhas - IHook, vamos lidar com o trabalho de pontos de interrupção "de ferro".O princípio é o seguinte: existem quatro registradores DR0-DR3 "ativos" nos quais o endereço pode ser gravado, dependendo da configuração do registro de controle DR7 ao tentar escrever, ler ou executar no endereço especificado, uma exceção no tipo EXCEPTION_SINGLE_STEP ocorrerá, que deverá ser processada em um manipulador registrado anteriormente . Você pode usar o manipulador SEH e VEH - nós o usaremos, pois ele tem uma prioridade mais alta.Percebemos esta ideia: int HardwareBPHook::SetHook(void* source, void* destination, HANDLE* hThread, int* reg) { CONTEXT context; ZeroMemory(&context, sizeof(context)); context.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(*hThread, &context)) return ERROR_GET_CONTEXT; *(&context.Dr0 + *reg) = (unsigned long long)source; context.Dr7 |= 1ULL << (2 * (*reg)); context.Dr7 |= HW_EXECUTE << ((*reg) * 4 + 16); context.Dr7 |= HW_LENGTH << ((*reg) * 4 + 18); if (!SetThreadContext(*hThread, &context)) return ERROR_SET_CONTEXT; return SUCCESS_CODE; }
O que acontece no códigoEsse código recebe o contexto de um encadeamento específico, após o recebimento bem-sucedido, insere o endereço da função que está sendo interceptada em um registro livre, define o registro de controle DR7. Finalmente, o contexto alterado é definido.
Em mais detalhes sobre o que são DR6 e DR7, bem como a abordagem PageGuard, posso aconselhar o Gray Hat Python: programação em Python para hackers e engenheiros reversos. Em suma, o DR7 ativa / desativa o uso de um registro "ativo" - mesmo se algum dos registros DR0-DR3 contiver um endereço, mas no DR7 o sinalizador do registro correspondente estiver desativado, o ponto de interrupção não funcionará. O DR7 também define o tipo de trabalho com o endereço no qual é necessário gerar uma exceção - se o endereço foi lido, se o registro foi feito ou se o endereço é usado para executar a instrução (estamos interessados na última opção).A remoção de uma armadilha também é bastante simples e é feita através do registro de controle DR7. int HardwareBPHook::UnsetHook(void* source, HANDLE* hThread) { CONTEXT context; ZeroMemory(&context, sizeof(context)); context.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(*hThread, &context)) return ERROR_GET_CONTEXT; for (int i = 0; i < DEBUG_REG_COUNT; i++) { if ((unsigned long long)source == *(&context.Dr0 + i)) { info->GetItem(i)->source = 0; *(&context.Dr0 + i) = 0; context.Dr7 &= ~(1ULL << (2 * i)); context.Dr7 &= ~(3 << (i * 4 + 16)); context.Dr7 &= ~(3 << (i * 4 + 18)); break; } } if (!SetThreadContext(*hThread, &context)) return ERROR_SET_CONTEXT; return SUCCESS_CODE; }
Resta lidar com os threads - a interceptação deve ser configurada para os threads que chamam a função interceptada. Nós não vamos nos preocupar com isso.Montamos uma armadilha para todos os threads do processo. int HardwareBPHook::SetHook(void* source, void* destination) { THREADENTRY32 te32; HANDLE hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (hThread == INVALID_HANDLE_VALUE) return ERROR_ENUM_THREAD_START; te32.dwSize = sizeof(THREADENTRY32); if (!Thread32First(hThread, &te32)) { CloseHandle(hThread); return ERROR_ENUM_THREAD_START; } DWORD dwOwnerPID = GetCurrentProcessId(); bool isRegDefined = false; int freeReg = -1; Freeze(); do { if (te32.th32OwnerProcessID == dwOwnerPID) { HANDLE openThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID); if (!isRegDefined) { CONTEXT context; ZeroMemory(&context, sizeof(context)); context.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(openThread, &context)) return ERROR_GET_CONTEXT; freeReg = GetFreeReg(&context.Dr7); if (freeReg == -1) return ERROR_GET_FREE_REG; isRegDefined = true; } SetHook(source, destination, &openThread, &freeReg); CloseHandle(openThread); } } while (Thread32Next(hThread, &te32)); CloseHandle(hThread); Unfreeze(); auto record = info->GetItem(freeReg); record->source = source; record->destination = destination; record->pTrampoline = source; return SUCCESS_CODE; }
O código acima ignora todos os processos visíveis e procura o processo atual. No processo encontrado para o próximo encadeamento, obtemos o manipulador de fluxo, localizamos um dos quatro registradores livres e configuramos uma armadilha. Vale a pena prestar atenção nas funções Congelar e Descongelar - foi o que o Opcode Hook falou sobre multithreading - elas interrompem completamente a execução de threads desse processo (exceto o atual), para que não haja situação em que um dos threads entre na função interceptada.Protegendo os threads de chamar uma função de gancho int IHook::Freeze() { THREADENTRY32 te32; HANDLE hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (hThread == INVALID_HANDLE_VALUE) return ERROR_ENUM_THREAD_START; te32.dwSize = sizeof(THREADENTRY32); if (!Thread32First(hThread, &te32)) { CloseHandle(hThread); return ERROR_ENUM_THREAD_START; } DWORD dwOwnerPID = GetCurrentProcessId(); do { if (te32.th32OwnerProcessID == dwOwnerPID && te32.th32ThreadID != GetCurrentThreadId()) { HANDLE openThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID); if (openThread != NULL) { SuspendThread(openThread); CloseHandle(openThread); } } } while (Thread32Next(hThread, &te32)); CloseHandle(hThread); return SUCCESS_CODE; } int IHook::Unfreeze() {
Necessidades semelhantes devem ser implementadas na função de remover a armadilha.Resta adicionar um manipulador de exceção VEH. A adição e remoção é feita pelas funções AddVectoredExceptionHandler e RemoveVectoredExceptionHandler de qualquer fluxo. void HardwareBPHook::SetExceptionHandler(PVECTORED_EXCEPTION_HANDLER pVecExcHandler) { pException = AddVectoredExceptionHandler(1, pVecExcHandler); } ~HardwareBPHook() { info->Clear(); delete info; RemoveVectoredExceptionHandler(pException); }
O manipulador deve verificar o tipo de exceção (EXCEPTION_SINGLE_STEP é necessário), verificar a correspondência do endereço no qual a exceção ocorreu com o que está nos registradores e, se esse endereço for encontrado, reorganiza o ponteiro RIP para o endereço do destinatário. O estado da pilha é preservado, para que, após uma execução adicional do receptor, todos os parâmetros na pilha estejam intactos.Implementamos o manipulador descrito na sandbox: LONG OnExceptionHandler( EXCEPTION_POINTERS* exceptionPointers) { if (exceptionPointers->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) return EXCEPTION_CONTINUE_EXECUTION; for (int i = 0; i < DEBUG_REG_COUNT; i++) { if (exceptionPointers->ContextRecord->Rip == (unsigned long long)hook->GetInfo()->GetItem(i)->source) { exceptionPointers->ContextRecord->Rip = (unsigned long long)hook->GetInfo()->GetItem(i)->destination; break; } } return EXCEPTION_CONTINUE_EXECUTION; }
Em teoria, tudo está pronto, rodamos o programa, esperando exatamente o mesmo trabalho que o OpcodeHook.Isso não acontece, nosso programa congela - mais precisamente, ele entra constantemente no PresentHook e, no momento em que o trampolim deve ser chamado, a função é chamada novamente. O fato é que o ponto de interrupção "de ferro" não desapareceu, porque quando você chama o trampolim (que, no caso de pontos de interrupção de "ferro", indica a função original), novamente alarme o mesmo endereço e criamos uma exceção. A solução é a seguinte: removeremos o ponto de interrupção quando ele for encontrado no manipulador de um encadeamento específico e, no momento certo, o definiremos novamente. O local da atualização escolherá o momento em que a função do receptor termina.Isso é implementado da seguinte maneira - no manipulador, junto com a remoção do ponto de interrupção, um comando pendente é adicionado, cujo significado é atualizar o ponto de interrupção no fluxo especificado. O comando é executado no final da função do receptor. IDeferredCommands* hookCommands; int PresentHook(Device* device) { auto record = hook->GetRecordBySource(ptrPresent); pPresent pTrampoline = (pPresent)record->pTrampoline; auto result = pTrampoline(device); cout << "PresentHook" << endl; hookCommands->Run(); return result; } void EndSceneHook(Device* device, int j) { auto record = hook->GetRecordBySource(ptrEndScene); pEndScene pTrampoline = (pEndScene)record->pTrampoline; pTrampoline(device, 2); cout << "EndSceneHook" << " " << j << endl; hookCommands->Run(); } LONG OnExceptionHandler(EXCEPTION_POINTERS* exceptionPointers) { if (exceptionPointers->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) return EXCEPTION_CONTINUE_EXECUTION; for (int i = 0; i < DEBUG_REG_COUNT; i++) { if (exceptionPointers->ContextRecord->Rip == (unsigned long long)hook->GetInfo()->GetItem(i)->source) { exceptionPointers->ContextRecord->Dr7 &= ~(1ULL << (2 * i)); exceptionPointers->ContextRecord->Rip = (unsigned long long)hook->GetInfo()->GetItem(i)->destination; IDeferredCommand* cmd = new SetD7Command(hook, GetCurrentThreadId(), i); hookCommands->Enqueue(cmd); break; } } return EXCEPTION_CONTINUE_EXECUTION; }
Implementação de comando pendente namespace silk_way { class IDeferredCommand { protected: IDeferredCommand(silk_way::IHook* _hook) { hook = _hook; } public: virtual ~IDeferredCommand() { hook = nullptr; } virtual void Run() = 0; protected: silk_way::IHook* hook; }; class SetD7Command : public IDeferredCommand { public: SetD7Command(silk_way::IHook* _hook, unsigned long long _threadId, int _reg) : IDeferredCommand(_hook) { threadId = _threadId; reg = _reg; } void Run() { HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, threadId); if (hThread != NULL) { bool res = SetD7(&hThread); CloseHandle(hThread); } } private: bool SetD7(HANDLE* hThread) { CONTEXT context; ZeroMemory(&context, sizeof(context)); context.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(*hThread, &context)) return false; *(&context.Dr0 + reg) = (unsigned long long)hook->GetInfo()->GetItem(reg)->source; context.Dr7 |= 1ULL << (2 * reg); if (!SetThreadContext(*hThread, &context)) return false; return true; } private: unsigned long long threadId; int reg; }; class IDeferredCommands : public silk_data::Queue<IDeferredCommand*>, public IDeferredCommand { protected: IDeferredCommands() : Queue(), IDeferredCommand(nullptr) {} public: virtual ~IDeferredCommands() {} }; }
Imagine visualmente o trabalho de pontos de interrupção "de ferro".
Figura 12 - Estado inicialNós configuramos uma armadilha, adicionamos um manipulador VEH, aguardamos o controle alcançar a função de origem:
Figura 13 - Estágio de preparação para interceptaçãoUma exceção é lançada, um manipulador é chamado que redireciona o RIP para o receptor e redefine o ponto de interrupção:
Figura 14 - Redireciona o thread de execução no receptor de funçõesNeste tópico, as interrupções podem ser concluídas, a biblioteca estática silk_way.lib está pronta. Por experiência própria, posso dizer que costumo usar OpcodeHook, VMT Hook, Forced Exception Hook (provavelmente a armadilha mais "hemorróida"), HardwareBreakpoint e PageGuard (quando o tempo de execução não é crítico, interceptações únicas).8. Arquitetura da lógica
A base da lógica é apresentada na forma de MVC (model-view-controller). Todas as entidades principais herdam da interface ISilkObject.8.1 Modelo
Ao desenvolver um bot na biblioteca, primeiro implementei o ECS (você pode ler sobre essa abordagem aqui e aqui ). Quando percebi que lançar um bot com jogadores reais era uma tarefa bastante longa, escrevi uma simulação em que testamos as bibliotecas ml (com uma grade tridimensional para navegação (o Dota 2 usa apenas uma grade 3D para navegação) e física 2D simplificada para um bloco corporal). Quando a necessidade de simulação desapareceu e eu descobri como e o que registrar, quais informações coletar durante a batalha, o ECS não era mais necessário e os modelos simplesmente começaram a conter um dicionário de componentes (para representar algo como os caras do SkyForge, seção “Avatares e mobs), que continha, de fato, wrappers sobre estruturas do Source2Gen. Para este artigo, não transferi essa implementação para simplificar o material. O modelo contém Esquema, no qual sua descrição é armazenada (este ponto é simplificado e, nesta implementação, o modelo não é criado de acordo com o esquema, apenas o esquema o descreve (armazena os valores predefinidos que podem ser codificados) - isso pode ser comparado ao armazenamento do conteúdo do jogo em xml / json )Esquematicamente, o dispositivo modelo pode ser representado da seguinte forma: Figura 15 - Representação esquemática da implementação do modelo no código:
template <class S> SILK_OBJ(IModel) { ACCESSOR(IIdentity, Id) ACCESSOR(S, Schema) public: IModel(IIdentity * id, ISchema * schema) { Id = id; Schema = dynamic_cast<S*>(schema); components = new silk_data::RBTree<SILK_STRING*, IComponent>( new StringCompareStrategy()); } ~IModel() { delete Id; Schema = nullptr; components->Clear(); delete components; } template <class T> T* Get(SILK_STRING * key) { return (T*)components->Find(key); } private: silk_data::RBTree<SILK_STRING*, IComponent>* components; };
O esquema inclui uma descrição de um modelo específico e contém o contexto que o modelo pode usar. class IModelSchema : public BaseSchema { ACCESSOR(ModelContext, Context) public: IModelSchema(const char* type, const char* name, IContext* context) : BaseSchema(type, name) { Context = dynamic_cast<ModelContext*>(context); } ~IModelSchema() { Context = nullptr; } }; class ModelContext : public SilkContext { ACCESSOR(ILogger, Logger) ACCESSOR(IChrono, Clock) ACCESSOR(GigaFactory, Factory) ACCESSOR(IGameModel*, Model) public: ModelContext(SILK_GUID* guid, ILogger* logger, IChrono* clock, GigaFactory* factory, IGameModel** model) : SilkContext(guid) { Logger = logger; Clock = clock; Factory = factory; Model = model; } ~ModelContext() { Logger = nullptr; Clock = nullptr; Factory = nullptr; Model = nullptr; } };
Coleção de modelos e coleção de esquemas template <class T, class S> class IModelCollection : public silk_data::Vector<T*>, public IModel<S> { protected: IModelCollection(IIdentity* id, ISchema* schema) : Vector(), IModel(id, schema) { auto factory = Schema->GetContext()->GetFactory(); auto guid = Schema->GetContext()->GetGuid(); foreach (Schema->Length()) { auto itemSchema = Schema->GetItem(i); auto item = factory->Build<T>(itemSchema->GetType()->GetValue(), guid->Get(), itemSchema); PushBack(item); } } public: ~IModelCollection() { Clear(); } T* GetByName(const char* name) { foreach (Length()) if (GetItem(i)->GetSchema()->CheckName(name)) return GetItem(i); return nullptr; } };
Por exemplo, a interface e implementação de um modelo que armazena o status de Roshan se parece com DEFINE_IMODEL(IRoshanStatusModel, IRoshanStatusSchema) { VIRTUAL_COMPONENT(IStatesModel, States) public: virtual void Resolve() = 0; protected: IRoshanStatusModel(IIdentity * id, ISchema * schema) : IModel(id, schema) {} }; DEFINE_MODEL(RoshanStatusModel, IRoshanStatusModel) { COMPONENT(IStatesModel, States) public : RoshanStatusModel(IIdentity * id, ISchema* schema) : IRoshanStatusModel( id, schema) { auto factory = Schema->GetContext()->GetFactory(); auto guid = Schema -> GetContext() -> GetGuid(); auto statesSchema = Schema -> GetStates(); States = factory->Build<IStatesModel>( statesSchema->GetType()->GetValue(), guid->Get(), statesSchema); } ~RoshanStatusModel() { delete States; } void Resolve() { auto currentStateSchema = States->GetCurrent()->GetSchema(); Schema->GetContext()->GetLogger()->Log("RESOLVE\n"); foreach (currentStateSchema->GetTransitions()->Length()) { auto transition = currentStateSchema->GetTransitions()->GetItem(i); if (transition->GetRequirement()->Check()) { transition->GetAction()->Make(); States->SetCurrent(States->GetByName( transition->GetTo()->GetValue())); break; } } } };
8.2 Exibir, exibir status e controlador
Não há muito a dizer sobre Apresentação, Estado da Apresentação e Controlador, a implementação é semelhante a Modelos. Eles também consistem em esquema e contexto. Para resolver o problema de View, Canvas, ViewCollection, Label e Button, são implementados, nos dois últimos, estados correspondentes aos estados em que Roshan está localizado.Visão esquemática
Figura 16 - Representação esquemática da vista Representação esquemática do estado de exibição
Figura 17 - Representação esquemática do estado de exibição 8.3 A fábrica
Os objetos são criados usando a fábrica. As fábricas usam um tipo de interface como chave, convertendo-o em uma string usando typeid (T) .raw_name (). Em geral, isso é ruim, por que e como ler corretamente em Andrei Alexandrescu, Design Moderno em C ++: Programação Genérica. Implementação de fábrica: class SilkFactory { public: SilkFactory() { items = new silk_data::RBTree<SILK_STRING*, IImplementator>( new StringCompareStrategy()); } ~SilkFactory() { items->Clear(); delete items; } template <class... Args> ISILK_WAY_OBJECT* Build(const char* type, Args... args) { auto key = new SILK_STRING(type); auto impl = items->Find(key)->payload; return impl->Build(args...); } void Register(const char* type, IImplementator* impl) { auto key = new SILK_STRING(type); items->Insert(*items->MakeNode(key, impl)); } protected: silk_data::RBTree<SILK_STRING*, IImplementator>* items; }; class GigaFactory { public: GigaFactory() { items = new silk_data::RBTree<SILK_STRING*, SilkFactory>( new StringCompareStrategy()); } ~GigaFactory() { items->Clear(); delete items; } template <class T, class... Args> T* Build(const char* concreteType, Args... args) { auto key = new SILK_STRING(typeid(T).raw_name()); auto factory = items->Find(key)->payload; return (T*)factory->Build(concreteType, args...); } template <class T> void Register(SilkFactory* factory) { auto key = new SILK_STRING(typeid(T).raw_name()); items->Insert(*items->MakeNode(key, factory)); } protected: silk_data::RBTree<SILK_STRING*, SilkFactory>* items; };
Antes de usar a fábrica para construir objetos, você precisa se registrar.Exemplo de registro de modelo void ModelRegistrator::Register( GigaFactory* factory) { auto requirement = new SilkFactory(); requirement->Register("true", new SchemaImplementator<TrueRequirement>); requirement->Register("false", new SchemaImplementator<FalseRequirement>); requirement->Register("roshan_killed", new SchemaImplementator<RoshanKilledRequirement>); requirement->Register("roshan_alive_manual", new SchemaImplementator<RoshanAliveManualRequirement>); requirement->Register("time", new SchemaImplementator<TimeRequirement>); requirement->Register("roshan_state", new SchemaImplementator<RoshanStateRequirement>); factory->Register<IRequirement>(requirement); auto action = new SilkFactory(); action->Register("action", new SchemaImplementator<EmptyAction>); action->Register("set_current_time", new SchemaImplementator<SetCurrentTimeAction>); factory->Register<IAction>(action); auto transition = new SilkFactory(); transition->Register("transition", new SchemaImplementator<TransitionSchema>); factory->Register<ITransitionSchema>(transition); auto transitions = new SilkFactory(); transitions->Register("transitions", new SchemaImplementator<TransitionsSchema>); factory->Register<ITransitionsSchema>(transitions); auto stateSchema = new SilkFactory(); stateSchema->Register("state", new SchemaImplementator<StateSchema>); factory->Register<IStateSchema>(stateSchema); auto statesSchema = new SilkFactory(); statesSchema->Register("states", new SchemaImplementator<StatesSchema>); factory->Register<IStatesSchema>(statesSchema); auto roshanStatusSchema = new SilkFactory(); roshanStatusSchema->Register("roshan_status", new SchemaImplementator<RoshanStatusSchema>); factory->Register<IRoshanStatusSchema>(roshanStatusSchema); auto triggerSchema = new SilkFactory(); triggerSchema->Register("trigger", new SchemaImplementator<TriggerSchema>); factory->Register<ITriggerSchema>(triggerSchema); auto triggersSchema = new SilkFactory(); triggersSchema->Register("triggers", new SchemaImplementator<TriggersSchema>); factory->Register<ITriggersSchema>(triggersSchema); auto resourceSchema = new SilkFactory(); resourceSchema->Register("resource", new SchemaImplementator<ResourceSchema>); factory->Register<IResourceSchema>(resourceSchema); auto resourcesSchema = new SilkFactory(); resourcesSchema->Register("resources", new SchemaImplementator<ResourcesSchema>); factory->Register<IResourcesSchema>(resourcesSchema); auto gameSchema = new SilkFactory(); gameSchema->Register("game", new SchemaImplementator<GameSchema>); factory->Register<IGameSchema>(gameSchema); auto gameModel = new SilkFactory(); gameModel->Register("game", new ConcreteImplementator<GameModel>); factory->Register<IGameModel>(gameModel); auto resources = new SilkFactory(); resources->Register("resources", new ConcreteImplementator<ResourceCollection>); factory->Register<IResourceCollection>(resources); auto resource = new SilkFactory(); resource->Register("resource", new ConcreteImplementator<Resource>); factory->Register<IResource>(resource); auto triggers = new SilkFactory(); triggers->Register("triggers", new ConcreteImplementator<TriggerCollection>); factory->Register<ITriggerCollection>(triggers); auto trigger = new SilkFactory(); trigger->Register("trigger", new ConcreteImplementator<Trigger>); factory->Register<ITrigger>(trigger); auto roshanStatus = new SilkFactory(); roshanStatus->Register("roshan_status", new ConcreteImplementator<RoshanStatusModel>); factory->Register<IRoshanStatusModel>(roshanStatus); auto states = new SilkFactory(); states->Register("states", new ConcreteImplementator<StatesModel>); factory->Register<IStatesModel>(states); auto state = new SilkFactory(); state->Register("state", new ConcreteImplementator<StateModel>); factory->Register<IStateModel>(state); }
O esquema pode ser preenchido de qualquer maneira - você pode usar json, diretamente no código.Opção para preencher o esquema para Modelos em json { "game": { "roshan_status": { "states": [ { "name": "alive", "transitions": [ { "from": "alive", "to": "ressurect_base", "requirement": { "typename": "roshan_killed", "action": { "typename": "set_current_time", "resource": "roshan_killed_ts" } } } ] }, { "name": "ressurect_base", "transitions": [ { "from": "ressurect_base", "to": "ressurect_extra", "requirement": { "typename": "time", "resource": "roshan_killed_ts", "offset": 480 }, "action": { "typename": "action" } } ] }, { "name": "ressurect_extra", "transitions": [ { "from": "ressurect_extra", "to": "alive", "requirement": { "typename": "time", "resource": "roshan_killed_ts", "offset": 660 }, "action": { "typename": "action" } }, { "from": "ressurect_extra", "to": "alive", "requirement": { "typename": "roshan_alive_manual" }, "action": { "typename": "action" } } ] } ] }, "triggers": { "roshan_killed": {}, "roshan_alive_manual": {} }, "resources": { "roshan_killed_ts": {} } } }
Opção para preencher um esquema para envio de código void GameController::InitViewSchema(ICanvasSchema** schema) { *schema = factory->Build<ICanvasSchema>("canvas_d9", "canvas_d9", "canvas_d9", viewContext); IViewCollectionSchema* elements = factory->Build<IViewCollectionSchema>( "elements", "elements", "elements", viewContext); (*schema)->SetElements(elements); ILabelSchema* labelSchema = factory->Build<ILabelSchema>( "label_d9", "label_d9", "roshan_status_label", viewContext); labelSchema->SetRecLeft(new SILK_INT(30)); labelSchema->SetRecTop(new SILK_INT(100)); labelSchema->SetRecRight(new SILK_INT(230)); labelSchema->SetRecDown(new SILK_INT(250)); labelSchema->SetColorR(new SILK_FLOAT(1.0f)); labelSchema->SetColorG(new SILK_FLOAT(1.0f)); labelSchema->SetColorB(new SILK_FLOAT(1.0f)); labelSchema->SetColorA(new SILK_FLOAT(1.0f)); labelSchema->SetText(new SILK_STRING("Roshan status: alive\0")); elements->PushBack((IViewSchema*&)labelSchema); IButtonSchema* buttonSchema = factory->Build<IButtonSchema>( "button_d9", "button_d9", "roshan_kill_button", viewContext); ILabelSchema* buttonLabelSchema = factory->Build<ILabelSchema>( "label_d9", "label_d9", "button_text", viewContext); buttonLabelSchema->SetRecLeft(new SILK_INT(30)); buttonLabelSchema->SetRecTop(new SILK_INT(115)); buttonLabelSchema->SetRecRight(new SILK_INT(110)); buttonLabelSchema->SetRecDown(new SILK_INT(130)); buttonLabelSchema->SetColorR(new SILK_FLOAT(1.0f)); buttonLabelSchema->SetColorG(new SILK_FLOAT(0.0f)); buttonLabelSchema->SetColorB(new SILK_FLOAT(0.0f)); buttonLabelSchema->SetColorA(new SILK_FLOAT(1.0f)); buttonLabelSchema->SetText(new SILK_STRING("Kill Roshan\0")); buttonSchema->SetLabel(buttonLabelSchema); buttonSchema->SetBorderColorR(new SILK_INT(0)); buttonSchema->SetBorderColorG(new SILK_INT(0)); buttonSchema->SetBorderColorB(new SILK_INT(0)); buttonSchema->SetBorderColorA(new SILK_INT(70)); buttonSchema->SetFillColorR(new SILK_INT(255)); buttonSchema->SetFillColorG(new SILK_INT(119)); buttonSchema->SetFillColorB(new SILK_INT(0)); buttonSchema->SetFillColorA(new SILK_INT(150)); buttonSchema->SetPushColorR(new SILK_INT(0)); buttonSchema->SetPushColorG(new SILK_INT(0)); buttonSchema->SetPushColorB(new SILK_INT(0)); buttonSchema->SetPushColorA(new SILK_INT(70)); buttonSchema->SetBorder(new SILK_FLOAT(5)); elements->PushBack((IViewSchema*&)buttonSchema); }
8.4 Eventos
A visualização aprende sobre a mudança no modelo através de eventos. Você pode obter feedback sobre métodos de classe e funções comuns. #define VIRTUAL_EVENT(e) public: virtual IEvent* Get##e() = 0; #define EVENT(e) private: IEvent* e; public: IEvent* Get##e() { return e; } const int MAX_EVENT_CALLBACKS = 1024; class IEventArgs {}; class ICallback { public: virtual void Invoke(IEventArgs* args) = 0; }; template <class A> class Callback : public ICallback { typedef void (*f)(A*); public: Callback(f _pFunc) { ptr = _pFunc; } ~Callback() { delete ptr; } void Invoke(IEventArgs* args) { ptr((A*)args); } private: f ptr = nullptr; }; template <typename T, class A> class MemberCallback : public ICallback { typedef void (T::*f)(A*); public: MemberCallback(f _pFunc, T* _obj) { ptr = _pFunc; obj = _obj; } ~MemberCallback() { delete ptr; obj = nullptr; } void Invoke(IEventArgs* args) { (obj->*(ptr))((A*)args); } private: f ptr = nullptr; T* obj; }; class IEvent { public: virtual void Invoke(IEventArgs* args) = 0; virtual void Add(ICallback* callback) = 0; virtual bool Remove(ICallback* callback) = 0; virtual ~IEvent() {} };
Se um objeto deseja relatar eventos que ocorrem dentro dele, você precisa adicionar o IEvent * para cada evento. Outro objeto interessado em eventos que ocorram dentro desse objeto deve criar ICallback * e passá-lo para o IEvent * (assinar o evento).Assinaturas de exemplo que ocorrem no controlador void Attach() { statesChangedCallback = new MemberCallback<GameController, IEventArgs>( &GameController::OnStatesChanged, this); Model->GetRoshanStatus()->GetStates()->GetCurrentChanged()->Add( statesChangedCallback); buttonClickedCallback = new MemberCallback<GameController, IEventArgs>( &GameController::OnKillRoshanClicked, this); killButton->GetClickedEvent()->Add(buttonClickedCallback); }
Um exemplo de declaração de um evento dentro de uma classe - com cada ocorrência do relógio (chamando o método Tick), um evento StruckEvent é gerado class IChrono { VIRTUAL_EVENT(Struck) public: virtual void Tick() = 0; virtual long long GetStamp() = 0; virtual long long GetDiffS(long long ts) = 0; }; class Chrono : public IChrono { EVENT(Struck) public: Chrono() { start = time(0); Struck = new Event(); } ~Chrono() { delete Struck; } void Tick() { auto cur = clock(); worked += cur - savepoint; bool isStriking = savepoint < cur; savepoint = cur; if (isStriking) Struck->Invoke(nullptr); } long long GetStamp() { return start * CLOCKS_PER_SEC + worked; } long long GetDiffS(long long ts) { return (GetStamp() - ts) / CLOCKS_PER_SEC; } private: long long worked = 0; time_t start; time_t savepoint; };
Os tipos primitivos básicos (SILK_INT, SILT_FLOAT, SILK_STRING, ...) são implementados no Core.h.9. DirectX 9
O DirectX 9 é uma das APIs gráficas suportadas pelo Dota 2. Device é uma classe herdada de IUnknown e contém funções virtuais. Assim, tendo recebido um ponteiro para uma tabela de método virtual, podemos obter ponteiros para as funções que precisamos. As funções de classe não virtual não estão incluídas na tabela e estão no segmento .code, pois são as únicas que não podem ser substituídas. A propósito, no OpenGL e no Vulkan, a interceptação de funções do dispositivo é muito mais fácil, pois elas não são virtuais e você pode obter um ponteiro usando GetProcAddress (). A arquitetura DirectX 11 é mais complexa que 9, mas não muito.Para interceptar o método da classe virtual (assim como o não virtual), precisamos de uma instância dessa classe, qualquer instância. Usando a instância, obtemos a tabela de métodos virtuais e obtemos os ponteiros necessários para as funções. A maneira mais fácil de encontrar uma instância de uma classe é criá-la.Para fazer isso, precisamos criar um objeto com a interface IDirect3D9 usando a função Direct3DCreate9, e criaremos o dispositivo usando esse objeto chamando o método CreateDevice. Podemos chamar essas funções diretamente da biblioteca DirectX, mas, para consolidar o material, as chamaremos através de ponteiros. Como pode ser visto no d3d9.h, o Direct3DCreate9 é uma função regular e um ponteiro para ele pode ser obtido através do GetProcAddress (como fizemos no NativeInjector para obter um ponteiro no LoadLibrary).
Figura 18 - Descrição do CreateDevice no d3d9.hCrie uma instância do IDirect3D9: typedef IDirect3D9* (WINAPI *SILK_Direct3DCreate9) (UINT SDKVersion);
Usando o IDirect3D9, podemos criar um dispositivo chamando pD3D-> CreateDevice (...). Para obter um ponteiro para as funções necessárias do VMT, precisamos descobrir o procedimento para determinar esses métodos. Figura 19 - Pesquisa de índice pelo método CreateDevice da interface IDirect3D9 Obtenha o 16º índice. Além de CreateDevice, também precisamos dos métodos Release e GetAdapterDisplayMode.
Implementamos a criação do dispositivo em código typedef HRESULT(WINAPI *SILK_GetAdapterDisplayMode)(IDirect3D9* direct3D9, UINT Adapter, D3DDISPLAYMODE* pMode); typedef HRESULT(WINAPI *SILK_CreateDevice)(IDirect3D9* direct3D9, UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow, DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pPresentationParameters, IDirect3DDevice9** ppReturnedDeviceInterface); typedef ULONG(WINAPI *SILK_Release)(IDirect3D9* direct3D9); const int RELEASE_INDEX = 2; const int GET_ADAPTER_DISPLAY_MODE_INDEX = 8; const int CREATE_DEVICE_INDEX = 16; BOOL CreateSearchDevice(IDirect3D9** d3d, IDirect3DDevice9** device) { if (!d3d || !device) return FALSE; *d3d = NULL; *device = NULL;
Bem, criamos o dispositivo DirectX 9, agora precisamos entender quais funções são usadas para renderizar a cena, o que precisamos interceptar. Precisamos responder à pergunta: "Como o DirectX 9 nos mostra a cena?" A função Presente é usada para exibir a cena . Também vale a pena introduzir conceitos como buffer frontal (um buffer que armazena o que é exibido (ação de longo prazo) na tela), buffer traseiro - contém o que está pronto para exibição e está se preparando para se tornar um buffer frontal, cadeia de troca - na verdade, um conjunto de buffers que inversão da frente para trás (o DirectX 9 possui apenas 1 cadeia de troca). Antes de chamar Present, algumas funções BeginScene e EndScene são chamadas , onde você pode modificar o buffer de fundo.Vamos interceptar duas funções (de fato, para executar a lógica de negócios, uma é suficiente para nós): EndScene e Present. Para fazer isso, observe o local dessas funções na classe IDirect3DDevice9 Figura 20 - Declarando a interface IDirect3DDevice9 Declare os ponteiros com as seguintes assinaturas de função:
typedef HRESULT(*VirtualOverloadPresent)(IDirect3DDevice9* pd3dDevice, CONST RECT* pSourceRect, CONST RECT* pDestRect, HWND hDestWindowOverride, CONST RGNDATA* pDirtyRegion); VirtualOverloadPresent oOverload = NULL; typedef HRESULT(*VirtualOverloadEndScene)(IDirect3DDevice9* pd3dDevice); VirtualOverloadEndScene oOverloadEndScene = NULL; const int PRESENT_INDEX = 17; const int END_SCENE_INDEX = 42;
Declararemos uma armadilha imediatamente com um manipulador de erros, pois o HardwareBreakpoint é, na verdade, a nossa única opção de interceptação segura implementada que não rastreia o VAC (você também pode testar com o Opcode Hook, mas é provável que sua conta seja banida): silk_way::IDeferredCommands* deferredCommands; silk_way::IHook* hook; LONG OnExceptionHandler(EXCEPTION_POINTERS* exceptionPointers) { if (exceptionPointers->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) return EXCEPTION_EXIT_UNWIND; for (int i = 0; i < silk_way::DEBUG_REG_COUNT; i++) { if (exceptionPointers->ContextRecord->Rip == (unsigned long long) hook->GetInfo()->GetItem(i)->source) { exceptionPointers->ContextRecord->Dr7 &= ~(1ULL << (2 * i)); exceptionPointers->ContextRecord->Rip = (unsigned long long) hook->GetInfo()->GetItem(i)->destination; silk_way::IDeferredCommand* cmd = new silk_way::SetD7Command(hook, GetCurrentThreadId(), i); deferredCommands->Enqueue(cmd); break; } } return EXCEPTION_CONTINUE_EXECUTION; }
Toque as funções designadas de qualquer uma de nossas duas armadilhas: BOOL HookDevice(IDirect3DDevice9* pDevice) { unsigned long long vmt = **(unsigned long long **)&pDevice; int pointerSize = sizeof(unsigned long long); VirtualOverloadPresent pointerPresent= (VirtualOverloadPresent) ((*(unsigned long long *)(vmt + pointerSize * PRESENT_INDEX))); VirtualOverloadEndScene pointerEndScene = (VirtualOverloadEndScene) ((*(unsigned long long *)(vmt + pointerSize * END_SCENE_INDEX))); oOverload = pointerPresent; oOverloadEndScene = pointerEndScene; deferredCommands = new silk_way::DeferredCommands();
Receptores de função: HRESULT WINAPI PresentHook(IDirect3DDevice9* pd3dDevice, CONST RECT* pSourceRect, CONST RECT* pDestRect, HWND hDestWindowOverride, CONST RGNDATA* pDirtyRegion) { Capture(pd3dDevice); auto record = hook->GetRecordBySource(oOverload); VirtualOverloadPresent pTrampoline = (VirtualOverloadPresent) record->pTrampoline; auto result = pTrampoline(pd3dDevice, pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion); deferredCommands->Run(); return result; } HRESULT WINAPI EndSceneHook(IDirect3DDevice9* pd3dDevice) { if (controller == nullptr) { controller = new GameController(); controller->SetDevice(pd3dDevice); } controller->Update(); auto record = hook->GetRecordBySource(oOverloadEndScene); VirtualOverloadEndScene pTrampoline = (VirtualOverloadEndScene) record->pTrampoline; auto result = pTrampoline(pd3dDevice); deferredCommands->Run(); return result; }
No presente, cada chamada faz uma captura de tela do buffer da placa de vídeo (para verificação) usando a função Capturar VOID WINAPI Capture(IDirect3DDevice9* pd3dDevice) { IDirect3DSurface9 *renderTarget = NULL; IDirect3DSurface9 *destTarget = NULL; HRESULT res1 = pd3dDevice->GetRenderTarget(0, &renderTarget); D3DSURFACE_DESC descr; HRESULT res2 = renderTarget->GetDesc(&descr); HRESULT res3 = pd3dDevice->CreateOffscreenPlainSurface( descr.Width, descr.Height, descr.Format, D3DPOOL_SYSTEMMEM, &destTarget, NULL); HRESULT res4 = pd3dDevice->GetRenderTargetData(renderTarget, destTarget); D3DLOCKED_RECT lockedRect; ZeroMemory(&lockedRect, sizeof(lockedRect)); if (destTarget == NULL) return; HRESULT res5 = destTarget->LockRect(&lockedRect, NULL, D3DLOCK_READONLY); HRESULT res7 = destTarget->UnlockRect(); HRESULT res6 = D3DXSaveSurfaceToFile(screenshootPath, D3DXIFF_BMP, destTarget, NULL, NULL); renderTarget->Release(); destTarget->Release(); }
EndScene cria um controlador de lógica de negócios. Após a criação, a atualização do controlador é chamada, onde toda a lógica é atualizada.Observo que agora implementamos o trabalho com o DirectX 9. Se queremos criar algum tipo de modificação, trapaça etc., todas as quatro APIs devem ser suportadas. Isso se justifica se o arsenal já tiver suas bibliotecas favoritas, espaços em branco para a interface do usuário; caso contrário, você poderá usar outra maneira - a funcionalidade que usa o mecanismo para renderizar o jogo.Também vale a pena dizer que chamar atualizações lógicas do EndScene () não é a melhor opção - você pode encontrar chamadas periódicas para funções do mecanismo ou lógica de chamada no seu fluxo. Se, no entanto, você estiver satisfeito com a chamada do EndScene, é melhor fazer isso com o lockstep.Agora, implementamos tudo o que planejamos.Recomendações de testeDirectX SDK , , DirectX 9 DirectX 11. DirectX 11, - SDK, ( , ) , , DXUT, , — , FPS .
21 — DirectX SDK StateManager.exe Agora você pode criar uma conta falsa no Steam e injetar o injected.dll no processo do Dota 2. Direi imediatamente que não sei como é a situação atual dos pontos de interrupção "de ferro" - usando o Opcode Hook (da maneira que fazemos na atual ), você definitivamente receberá uma proibição. Fiz isso cerca de seis meses atrás - não havia proibição para o Hardware Breakpoint, qual é a situação atual que não posso dizer. Antes de preparar o artigo, peguei duas contas e tentei o Opcode Hook e o HWBP nelas, a primeira entrou na proibição (cerca de duas semanas se passaram), a segunda não (três semanas se passaram). Mas ainda não há garantias de que a proibição não será no futuro. Então não se ofenda se acidentalmente fizer uma introdução a partir da sua conta principal ou se esquecer de fazer login na conta falsa - então já se cuide e tenha cuidado.( )
22 —
23 — Implementação no modo 1x1. Figura 24 - Injeção em uma partida Também vale ressaltar que existe outra maneira de renderizar - renderização de superfície criando uma segunda janela com o tamanho apropriado. Infelizmente, não pude perceber a possibilidade de usar uma abordagem de superfície para o caso do modo de tela cheia, mas a abordagem descrita no artigo permite implementar a renderização nos modos de tela inteira e janela sem problemas. Nossa interface do usuário incorporada contém apenas um rótulo de texto e um botão implementado no DirectX 9 puro - isso é tudo o que é necessário para resolver a tarefa. Você pode implementar tabelas complexas, belos menus e diagramas - em geral, uma interface do usuário de qualquer complexidade, em uma API pura e usando bibliotecas prontas. Claro, não apenas 2D.
10. Usando funções do motor
A implementação da mesma funcionalidade para cada API é bastante sombria; os desenvolvedores fazem wrappers convenientes, fornecendo funções para desenho, interface do usuário etc., que o jogo usa diretamente. A Valve também fornece APIs do Dota 2 para Javascript e Lua . Isso é feito para facilitar a vida de moderadores e designers de jogos para quem o C ++ é complicado (nem mesmo o C ++ em si, mas o uso adequado no contexto do mecanismo). Aqui existem funções para renderização e para a lógica do jogo - você pode prescrever o comportamento da unidade, por exemplo, selecionando itens, usando habilidades e muito mais. Na verdade, com a ajuda disso, cartas personalizadas são escritas.Estaremos interessados na função DoIncludeScript, que permite executar seus scripts em Lua e usar a API de script lá. Eu não o usei no meu projeto, porque não vi o valor nele, usando funções diretamente do C ++, vi a ideia de usá-lo com or_75 e decidi incluí-lo no artigo. Isso apresentará o que estará na segunda parte e economizará espaço; não é necessário explicar certos aspectos do depurador.Vamos começar.
A tarefa é a seguinte: você precisa encontrar um ponteiro para a função DoIncludeScript, que leva o nome do script e manipulador, para estudá-lo. Procuraremos a função usando o scanner da nossa biblioteca silk_way.lib. As funções, como já descobrimos, são codificadas na memória usando a tabela opcode - vamos examinar esta função e tentar identificar seu padrão de armazenamento na memória. Agora, o scanner não possui a funcionalidade necessária, precisamos da capacidade de procurar um modelo na memória do processo.Para acelerar a pesquisa, não procuraremos um padrão na memória do processo, mas em um módulo específico (nossa função está em client.dll, isso será visto no depurador e será discutido abaixo). Procuraremos o módulo usando tlHelp32 pelo nome, enumerando todos os módulos do processo, para os quais criaremos uma função para localizar o módulo no processo GetModuleInfo atual.Código da função GetModuleInfo int IScanner::GetModuleInfo(const char* name, MODULEENTRY32* entry) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE32 | TH32CS_SNAPMODULE, GetCurrentProcessId()); if (snapshot == INVALID_HANDLE_VALUE) return 1; entry->dwSize = sizeof(MODULEENTRY32); if (!Module32First(snapshot, entry)) { CloseHandle(snapshot); return 1; } do { if (!_stricmp(entry->szModule, name)) break; } while (Module32Next(snapshot, entry)); CloseHandle(snapshot); return 0; }
O padrão é uma sequência com um valor de bytes; pular um byte é indicado pelo símbolo "??" - por exemplo, "j9 ?? ?? ?? ?? 48 03 08 ?? f1 ff ”.Analisando a string, por conveniência, transferiremos o padrão da representação da string para a lista de valores de caracteres não assinados, definiremos os sinalizadores de bytes a serem ignorados. unsigned char* IScanner::Parse(int& len, const char* strPattern, unsigned char* skipByteMask) { int strPatternLen = strlen(strPattern); unsigned char* pattern = new unsigned char[strPatternLen]; for (int i = 0; i < strPatternLen; i++) pattern[i] = 0; len = 0; for (int i = 0; i < strPatternLen; i += 2) { unsigned char code = 0; if (strPattern[i] == SKIP_SYMBOL) skipByteMask[len] = 1; else code = Parse(strPattern[i]) * 16 + Parse(strPattern[i + 1]); i++; pattern[len++] = code; } return pattern; } unsigned char IScanner::Parse(char byte) {
O núcleo da pesquisa é implementado na função FindPattern, onde, com base nas informações recebidas sobre o módulo, são definidos os endereços inicial e final da pesquisa. As informações sobre a memória que será pesquisada são solicitadas pela função VirtualQuery, existem vários requisitos de memória - ela deve estar ocupada (será um erro procurar na memória livre), a memória deve ser legível, executável e não conter o sinalizador PageGuard: void* pStart = moduleEntry.modBaseAddr; void* pFinish = moduleEntry.modBaseAddr + moduleEntry.modBaseSize; unsigned char* current = (unsigned char*)pStart; for (; current < pFinish && j < patternLen; current++) { if (!VirtualQuery((LPCVOID)current, &info, sizeof(info))) continue; unsigned long long protectMask = PAGE_READONLY | PAGE_READWRITE | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE | PAGE_EXECUTE_READ; if (info.State == MEM_COMMIT && info.Protect & protectMask && !(info.Protect & PAGE_GUARD)) { unsigned long long finish = (unsigned long long)pFinish < (unsigned long long)info.BaseAddress + info.RegionSize ? (unsigned long long)pFinish : (unsigned long long) info.BaseAddress + info.RegionSize; current = (unsigned char*)info.BaseAddress; unsigned char* rip = 0; for (unsigned long long k = (unsigned long long)info.BaseAddress; k < finish && j < patternLen; k++, current++) { if (skipByteMask[j] || pattern[j] == *current) { if (j == 0) rip = current; j++; } else { j = 0; if (pattern[0] == *current) { rip = current; j = 1; } } } if (j == patternLen) { current = rip; break; } } else current += sysInfo.dwPageSize; }
Agora somos capazes de procurar o modelo desejado na memória do processo, mas ainda não sabemos o que procurar. Execute o Steam na conta Fake e abra seu depurador favorito (vamos concordar que, durante a leitura do artigo, x64dbg também é para você - não tenho uma licença paga para o IDA Pro), execute o dota2.exe no diretório ... \ Steam \ steamapps \ comum \ dota 2 beta \ jogo \ bin \ win64. Em princípio, não percebi que o VAC não era indiferente ao Cheat Engine e ao x64dbg, não me lembro que ao usar essas ferramentas a conta foi banida. A propósito, o depurador possui um plug- in ScyllaHide que intercepta funções do sistema como NtCreateThreadEx, NtSetInformationThread, etc., ocultando o fato de seu trabalho, você pode instalar esse plug-in.A cada parada (haverá 10 a 15), continuamos executando usando Run (F9). Quando o jogo começar, veremos o menu e podemos começar a pesquisar. Depois de iniciar o jogo, faça uma pesquisa nas linhas (Procurar-> Todos os Módulos-> Referências de String), defina o filtro “DoIncludeScript”. Figura 25 - Pesquisando as linhas na memória do processo do jogo Vamos para o desmontador (guia CPU) clicando duas vezes no primeiro resultado. Este será o nosso endereço inicial, já que está no client.dll, o restante dos resultados está no server.dll e no animationsystem.dll. Construímos um gráfico de chamadas a partir do endereço recebido. Figura 26 - Gráfico de chamadas Após a descompilação, encontramos o ponto de entrada onde o DoIncludeScript é usado - o quarto nó do gráfico. Na verdade, a própria função.

Figura 27 - A função DoIncludeScriptGraph. Figura 28 - Gráfico de chamada do DoIncludeScript A descompilação do uso da função mostra o código a seguir e o local da chamada (a descompilação é feita no gráfico, não no desmontador). Figura 29 - Descompilando uma chamada para a função DoIncludeScript Vamos compor um modelo a partir das instruções na Figura 27 da chamada para a função DoIncludeScript. Os argumentos podem mudar, respectivamente. Queremos pular os argumentos do modelo ao pesquisar, os denotamos por "??". Eu tenho o seguinte: 40 57 48 81 EC ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ?? 48 8B F9 0F 84. Para compilar o modelo, usamos o primeiro nó do gráfico da Figura 28, cujas instruções podem ser encontradas na Figura 27.
Crie um script em Lua silk_way.lua, coloque-o em "... \ Steam \ steamapps \ common \ dota 2 beta \ game \ dota \ scripts \ vscripts". print("SILK_WAY START") local first = Entities:First() while (first ~= nil) do local position = first:GetAbsOrigin() local strInfo = "[" .. "pos:" .. tostring(position.x) .. "," .. tostring(position.y) .. "," .. tostring(position.z) .. "]" DebugDrawText(position, strInfo, true, 300.0) first = Entities:Next(first) end print("SILK_WAY FINISH")
Esse script ignora todas as entidades e exibe as coordenadas de acordo com sua posição.Declare a função usando a documentação acima e o código descompilado da Figura 29. typedef bool(*fDoIncludeScript)(const char*, unsigned long long);
Chamada de função. HRESULT WINAPI EndSceneHook(IDirect3DDevice9* pd3dDevice) { if (controller == nullptr) { controller = new GameController(); controller->SetDevice(pd3dDevice); fDoIncludeScript DoIncludeScript = (fDoIncludeScript) scanner->FindPattern("client.dll", "40 57 48 81 EC ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ?? 48 8B F9 0F 84"); DoIncludeScript("silk_way", 0); }
Após a implementação, veremos informações sobre a posição das entidades do jogo. Figura 30 - Resultado da implementação Agora somos capazes de executar nossos scripts. Mas eles são executados em Lua, e digamos que o evento que Roshan morreu seja necessário para nós no código C ++ (já que temos a lógica principal escrita nele), o que devemos fazer? Teremos que encontrar indicadores para as funções necessárias da mesma maneira (como fizemos para DoIncludeScript), funções de mecanismo e outras funcionalidades de seu interesse usando o Source SDK e o Source2Gen. Mas mais sobre isso na próxima parte, onde encontraremos um ponteiro para uma lista de entidades e escreveremos a lógica mais próxima da mecânica do jogo. Se você quer tudo de uma vez, pode tentar, estou anexando isto , isto , isto e isto como sua ajuda
links.11. Conclusão
Concluindo, gostaria de agradecer a todos que compartilham suas melhores práticas e conhecimentos no campo da reversão, compartilhando sua experiência com outras pessoas. Falando apenas sobre o Dota 2 sem um cão de oração, eu teria gastado muito tempo para obter a estrutura de dados do jogo usando o Cheat Engine, e as realizações feitas poderiam quebrar com qualquer atualização da Valve. As atualizações quebram os ponteiros estáticos encontrados e ocasionalmente alteram a estrutura das entidades. No or75, vi o uso da função DoIncludeScript e, com sua ajuda, mostrei um exemplo de saída de texto usando o mecanismo de jogo.Em busca da simplicidade da apresentação, pude perder alguma coisa, omitir vários casos que considero indignos de atenção, ou vice-versa, inflar a explicação - se um leitor atento encontrar tais erros, terei o prazer de corrigi-los e ouvir os comentários. O código fonte pode ser encontrado no link .Obrigado a todos que tiveram tempo para ler o artigo.