Comecei esta investigação com uma pergunta bem delimitada: até que ponto o Outplayed confiava em respostas produzidas localmente pelo Core do Overwolf para decidir quais recursos liberar?
Em vez de partir da interface ou do tráfego com o servidor, fui direto para OverWolf.Client.Core.dll. A assembly concentrava parte da lógica usada pelas extensões e expunha métodos relacionados às assinaturas associadas a cada uma delas. Minha primeira estratégia foi alterar o corpo desses métodos em IL, gravar a DLL modificada e observar o comportamento resultante.
O novo corpo funcionou em uma fixture. No aplicativo real, porém, o launcher rejeitou a DLL antes que o código modificado tivesse qualquer chance de executar. A investigação deixou então de ser apenas sobre o conteúdo do IL. Eu precisava entender todo o caminho entre o arquivo instalado, sua validação e a compilação do método pelo runtime.
Neste post, reconstruo o caminho que me levou da reescrita persistente em disco a uma intervenção em memória, entre a validação da DLL e sua compilação pelo JIT.
TL;DR
A análise de OverWolf.Client.Core.dll revelou dois métodos capazes de influenciar respostas locais consumidas pelo Outplayed. A primeira abordagem reescrevia esses métodos diretamente no arquivo, mas o launcher validava com WinTrust a assinatura Authenticode das DLLs instaladas. Bastava abrir e regravar uma cópia limpa com Mono.Cecil para a verificação falhar, mesmo sem diferenças nos 16.992 métodos comparados. Mantive então o arquivo original intacto e usei a API de profiling do CLR para fornecer outro corpo de IL em JITCompilationStarted, antes da compilação nativa. O teste alterou duas respostas locais somente para o UID do Outplayed. Ele não criou uma assinatura no servidor nem tornou a conta inteira premium. No entanto, para as extensões, localmente, todas as funções premium-only estavam totalmente desbloqueadas.
O ponto de entrada: as respostas locais consumidas pelo Outplayed
O Outplayed é uma extensão do Overwolf que grava partidas automaticamente. Na versão analisada, a interface exibia anúncios e mantinha alguns recursos atrás do plano premium.


Figura 1. Arraste o divisor para comparar o Outplayed antes e depois da instrumentação.
Ao seguir o fluxo que produzia essas decisões, cheguei ao Core do Overwolf. A pergunta passou a ser: seria possível alterar uma resposta local para um UID específico sem modificar o estado da conta no servidor?
A primeira hipótese era uma incompatibilidade de IL. Um método poderia ter mudado de assinatura, uma propriedade poderia usar outro tipo ou a estrutura esperada pelo Core poderia ter sido alterada entre versões. Encontrei uma diferença real desse tipo, mas ela explicava apenas parte do problema.
Primeira abordagem: reescrever o IL no arquivo
O Overwolf oferece a infraestrutura comum usada por extensões: integração com jogos, overlays, eventos, captura de vídeo e outras APIs. Parte desse comportamento fica em OverWolf.Client.Core.dll, uma assembly .NET consultada pelos aplicativos instalados na plataforma.
Quando o Outplayed pergunta ao Core quais assinaturas estão associadas a uma extensão, a decisão pode ser alterada em dois pontos: no código que interpreta a resposta ou na própria resposta produzida pelo Core. Escolhi investigar o segundo caminho.
O fluxo inicial está resumido na Figura 2.
flowchart TD
A[Outplayed] -->|Consulta a assinatura| B[OverWolf.Client.Core.dll]
B -->|Devolve informações de plano| C[Decisão local do Outplayed]
Figura 2. A decisão local do Outplayed consumia uma resposta produzida pelo Core.
Como essa DLL é uma assembly .NET, ela guarda os corpos dos métodos em IL (Intermediate Language). Usei Mono.Cecil para localizar métodos, apagar suas instruções e gravar novos corpos.
Um método C# como este:
int GetNumber(){ return 7;}pode ser representado, de forma simplificada, assim:
ldc.i4.7retUma substituição simples podia reduzir o corpo de um método ao equivalente de return false:
MethodDefinition showInsiderBlockMessageMethod = overwolfCoreWManager.Methods .SingleOrDefault(x => x.Name == "ShowInsiderBlockMessage");
showInsiderBlockMessageMethod.Body.Instructions.Clear();showInsiderBlockMessageMethod.Body.Instructions.Add( Instruction.Create(OpCodes.Ldc_I4_0));showInsiderBlockMessageMethod.Body.Instructions.Add( Instruction.Create(OpCodes.Ret));Depois de montar o IL, a rotina fazia backup da DLL e escrevia a assembly modificada sobre o arquivo original:
fullPath.Backup(true);overwolfCore.Write(fullPath.FullName);Console.WriteLine(Utils.Pad("Patched successfully"));A lógica era simples: modificar o arquivo que o Overwolf carregaria na próxima execução. O restante da investigação nasceu da descoberta de que o launcher não aceitava esse arquivo regravado.
Isolei o experimento em dois métodos do Core
O Core expunha uma superfície bem maior do que eu precisava analisar. Para testar a hipótese sem modificar componentes desnecessários, procurei o menor experimento capaz de afetar o Outplayed.
Encontrei um provedor legado de assinaturas que ainda consultava dois métodos do Core:
GetExtensionSubscriptionsGetExtensionSubscriptionsIds
O primeiro devolvia informações detalhadas sobre os planos de uma extensão. O segundo devolvia apenas seus IDs. Nesse caminho, o Outplayed reconhecia o plano legado 61. Também existia um sistema mais novo, baseado no Tebex e consultado no servidor, mas o provedor antigo ainda participava da decisão local.
Com isso, cheguei a uma hipótese pequena o bastante para testar:
Se esses dois métodos fornecerem o plano
61para o UID do Outplayed, alguma decisão local deverá mudar. Se nada mudar, esse caminho pode ser descartado.
flowchart TD
A[Outplayed consulta o Core] --> B[Dois métodos de assinatura]
B --> C[Core devolve o plano legado 61]
C --> D[Outplayed incorpora o resultado à decisão local]
Figura 3. Hipótese mínima usada para limitar o experimento a dois métodos.
Foi ao montar esse retorno que encontrei a primeira incompatibilidade real.
A primeira correção era válida, mas não resolvia o problema
A primeira implementação carregava um campo de preço com Ldc_R4, instrução que coloca na pilha um float de 32 bits. A propriedade da versão analisada esperava um System.Double, de 64 bits.
A correção cabia em uma linha:
Instruction.Create(OpCodes.Ldc_R4, 1.0f)Instruction.Create(OpCodes.Ldc_R8, 0d)Era exatamente o tipo de quebra que eu esperava encontrar. Corrigi o tipo e limitei a reescrita aos dois métodos que estava investigando.
Antes de testar no aplicativo completo, criei uma fixture, uma assembly pequena com a estrutura relevante dos métodos. Usei essa versão reduzida para conferir quatro coisas:
- o novo corpo de IL deveria ser aceito pelo runtime;
- o método detalhado deveria devolver o plano esperado;
- o método de IDs deveria incluir
61; - UIDs diferentes deveriam continuar pelo caminho original.
Todos os testes passaram. O código estava válido e o fallback se comportava como esperado.
Então apliquei a mesma reescrita ao Core real. O launcher encerrou a inicialização antes de abrir o Outplayed:
Refusing to start - deployed assembly failed verification:C:\Program Files (x86)\Overwolf\0.309.0.14\OverWolf.Client.Core.dllEsse resultado mudou a investigação. O IL corrigido funcionava na fixture, mas eu ainda não tinha mostrado que ele conseguiria chegar ao runtime do Overwolf. O bug de float para double era real. Só não era o que impedia a execução.
O launcher recusava o arquivo antes de avaliar seu código
Uma possibilidade ainda precisava ser eliminada: talvez o novo IL tivesse corrompido a assembly.
Antes de continuar, precisei separar dois mecanismos que costumam ser confundidos. O strong name participa da identidade de uma assembly .NET. O Authenticode aplica ao arquivo uma assinatura digital que o Windows consegue verificar.
OverWolf.Client.Core.dll não tinha strong name, mas possuía uma assinatura Authenticode válida da Overwolf. O arquivo continuava podendo ser alterado, só que a alteração passava a ser detectável.
Ao analisar o launcher, encontrei a etapa que fazia essa detecção: VerifyDeployedAssembliesOrFail(). Antes da inicialização normal, o caminho usava WinTrust para confirmar que as DLLs implantadas ainda tinham uma assinatura válida da Overwolf.
flowchart TD
A[DLL no disco] --> B{WinTrust aceita a assinatura?}
B -->|Sim| C[Inicialização continua]
B -->|Não| D[Launcher recusa o arquivo]
Figura 4. A validação da assinatura acontecia antes da inicialização normal.
A abordagem dependia de alterar a DLL. O launcher, por sua vez, verificava se ela continuava igual ao arquivo assinado. Ainda faltava descobrir se o novo corpo havia corrompido a estrutura ou se qualquer regravação já seria suficiente para quebrar a assinatura.
O experimento decisivo não modificou método nenhum
Peguei uma cópia limpa do Core, abri com Mono.Cecil e gravei de novo sem modificar deliberadamente nenhum método. Depois comparei os métodos da cópia regravada com os do arquivo original e executei a mesma verificação usada pelo launcher.
| Arquivo | WinTrust aceitou? | Diferenças nos métodos comparados |
|---|---|---|
| Core original | True |
referência |
| Core com os dois métodos modificados | False |
2 |
| Core apenas aberto e regravado | False |
0 |
Tabela 1. A regravação invalidava a assinatura mesmo sem diferenças nos métodos comparados.
O comparador percorreu 16.992 métodos e conferiu IL, propriedades, variáveis locais e regiões de exceção. Encontrar 0 diferenças não prova que os dois arquivos eram idênticos byte a byte. Para este teste, porém, bastava: a DLL podia falhar no WinTrust sem nenhuma mudança nos métodos que eu pretendia interceptar.
Esse controle descartou a hipótese de que o obstáculo principal fosse o corpo de IL corrigido. O simples ciclo de leitura e escrita já produzia outro arquivo, incompatível com a assinatura original.

Figura 5. A verificação do launcher impede que a DLL regravada chegue ao runtime, mesmo quando nenhum método é deliberadamente alterado.
Nesse ponto, duas coisas estavam claras:
- o retorno desejado podia ser expresso como IL válido;
- qualquer solução que regravasse a DLL instalada seria rejeitada antes da execução.
Já não adiantava perguntar qual IL eu deveria gravar. Eu precisava descobrir onde intervir sem modificar o arquivo verificado pelo launcher.
A fronteira útil ficava entre o arquivo validado e o código nativo
Uma assembly .NET não sai diretamente do disco para a CPU. A DLL contém IL, o CLR carrega a assembly e o JIT (just-in-time compiler) transforma os métodos em código nativo quando eles precisam executar.
flowchart TD
A[DLL no disco] -->|Contém IL| B[CLR carrega a assembly]
B --> C[JIT compila o método]
C -->|Produz código nativo| D[CPU executa]
Figura 6. Caminho simplificado entre a DLL instalada e o código executado pela CPU.
A assinatura protegia o arquivo verificado pelo launcher. Depois que o processo começava, o estado do método ainda podia mudar.
Uma alternativa viável precisaria satisfazer três condições ao mesmo tempo:
- manter a DLL original, intacta e validamente assinada no disco;
- intervir antes que o método virasse o código nativo executado;
- alterar o resultado apenas para o Outplayed, preservando o comportamento do Core para outras extensões.
Havia, portanto, um intervalo aproveitável. O desafio era chegar nele cedo o bastante.
Redirecionar a entrada do método chegava tarde demais
Minha primeira tentativa em memória usou um AppDomainManager para entrar cedo na inicialização e redirecionar os dois métodos para implementações geradas durante a execução.
Na fixture, consegui redirecionar GetExtensionSubscriptions, mas não GetExtensionSubscriptionsIds. A diferença estava no inlining.
Em uma chamada comum, existe uma entrada de método para interceptar: caller → GetExtensionSubscriptionsIds → resultado.
Mas o JIT pode copiar o corpo de um método pequeno diretamente para o chamador, como mostra a Figura 7.
flowchart TB
subgraph S[Sem inlining]
A1[caller] --> B1[GetExtensionSubscriptionsIds]
B1 --> C1[resultado]
end
subgraph I[Com inlining]
A2[caller] --> B2[corpo copiado no chamador]
B2 --> C2[resultado]
end
Figura 7. Com inlining, a entrada do método deixa de ser um ponto confiável de redirecionamento.
Quando marquei a fixture com NoInlining, o teste passou. Isso confirmou que o redirecionamento só funcionava se eu chegasse antes de uma otimização que não controlava.
O fallback falhou também. Eu havia guardado um delegate para a implementação original, com a intenção de chamá-lo para UIDs diferentes do Outplayed. Depois do redirecionamento, o delegate ainda entrava pelo mesmo endereço que eu tinha substituído:
flowchart TD
A[Substituição] --> B{UID é o alvo?}
B -->|Não| C[Chamar implementação original]
C --> D[Entrada já redirecionada]
D --> A
Figura 8. O fallback voltava para a entrada redirecionada e reiniciava o mesmo fluxo.
O resultado foi um StackOverflowException.

Figura 9. O delegate preservava uma referência ao mesmo ponto de entrada, não uma rota independente para o corpo anterior.
Tentei ainda capturar um ponteiro antes da troca. Isso levou a problemas com Marshal.GetDelegateForFunctionPointer, arrays gerenciados, convenções de chamada e uma tentativa com calli que terminou em InvalidProgramException.
Essas tentativas falharam de maneiras diferentes, mas todas apontavam para o mesmo problema. Eu estava tentando recuperar o corpo original quando o runtime já tinha começado a preparar, compilar e otimizar o método. Ainda estava chegando tarde demais.
O callback anterior ao JIT oferecia o ponto exato
A API de profiling do CLR costuma aparecer em ferramentas de desempenho, mas também permite que uma DLL nativa receba callbacks de eventos internos do runtime.
Dois recursos formavam o mecanismo necessário:
JITCompilationStarted, chamado quando o CLR está prestes a compilar uma função;SetILFunctionBody, que permite fornecer outro corpo de IL antes dessa compilação.
Com eles, o arquivo original podia atravessar o WinTrust sem alterações. Só depois do carregamento, imediatamente antes do JIT, o profiler entregaria o corpo alternativo:
flowchart TD
A[DLL original e assinada] -->|WinTrust aceita| B[CLR carrega a assembly]
B --> C[Método chega ao JIT]
C -->|Profiler fornece outro corpo de IL| D[JIT compila o corpo fornecido]
D --> E[CPU executa]
Figura 10. A substituição do IL acontece depois da validação do arquivo e antes da compilação nativa.
Na prática, o launcher verificava o arquivo original, enquanto o runtime compilava o corpo fornecido em memória.

Figura 11. O arquivo assinado permanece intacto. A substituição existe somente dentro do processo, antes da compilação nativa.
O profiler era uma DLL nativa x64 em C++. Quando uma função chegava ao JIT, o callback obtinha seu módulo e metadata token, verificava se era um dos dois alvos e transferia o novo corpo usando memória fornecida pelo CLR:
info->GetILFunctionBodyAllocator(module, &allocator);BYTE *destination = reinterpret_cast<BYTE *>( allocator->Alloc(static_cast<ULONG>(body.size())));CopyMemory(destination, body.data(), body.size());hr = info->SetILFunctionBody(module, method, destination);allocator->Release();Já não havia Write() nem uma cópia modificada de OverWolf.Client.Core.dll para o WinTrust rejeitar. Restava preservar o comportamento do Core para todas as extensões além do Outplayed.
O fallback original passou a fazer parte do corpo novo
Alterar o retorno para todo mundo seria um experimento ruim. Esses métodos pertenciam a um componente compartilhado e podiam ser chamados por outras extensões.
O comportamento desejado era condicional:
se o UID é o do Outplayed: devolver o resultado experimentalsenão: executar o comportamento originalO delegate havia falhado porque tentava voltar para uma entrada já redirecionada. Com SetILFunctionBody, eu podia evitar a chamada externa: o profiler montava um corpo composto, formado por um prefixo de decisão seguido das instruções originais.
ldarg.0call get_UIDldstr "identificador da extensão alvo"call string::op_Equalitybrfalse ORIGINAL
// Constrói e devolve o resultado experimental.ret
ORIGINAL:// Instruções originais copiadas para o mesmo corpo.Se o UID correspondesse ao Outplayed, o método produziria o retorno alterado. Caso contrário, brfalse seguiria para o código original dentro do próprio método.

Figura 12. O caminho experimental e o original coexistem no mesmo corpo de IL. Assim, nenhum delegate volta por engano à entrada redirecionada.
Essa composição resolveu a recursão, mas introduziu outro problema técnico: mover as instruções também move as coordenadas internas do método.
Preservar o IL exigia preservar a estrutura do método
Um corpo .NET guarda mais do que uma sequência de opcodes. Ele também descreve variáveis locais e regiões de exceção, como try, catch, finally e filtros. Alguns desses campos apontam para posições dentro do método.
Ao inserir um prefixo com tamanho P, todas as instruções antigas começam P bytes adiante. Desvios relativos internos continuam corretos quando origem e destino se deslocam juntos. Offsets medidos a partir do início do método, não.
Um mesmo campo das cláusulas de exceção pode representar um token de tipo para catch ou, dependendo das flags, um offset de filtro:
const ULONG classOrFilter = (clause.flags & 0x1u) != 0 ? clause.classOrFilter + static_cast<ULONG>(originalEntry) : clause.classOrFilter;Se o campo contém um offset, ele precisa acompanhar o código. Se contém o token de uma classe, somar bytes corrompe a referência. O construtor também preserva a assinatura das variáveis locais, a flag de inicialização e o restante do cabeçalho necessário ao JIT.
Por isso, copiar o corpo original envolvia bem mais do que concatenar bytes. Eu precisava reconstruir um método executável. Ainda assim, existe um limite para essa equivalência: o prefixo chama o getter de UID antes do corpo anterior, e meus testes não cobrem todo chamador nem todo estado possível.
Tokens de método não são identificadores universais
O profiler localizava os alvos por metadata tokens. Um valor como 0x060030B7 só tem significado dentro de uma versão específica da assembly. Em outra build, o mesmo número pode apontar para outro método.
Por isso, antes de substituir qualquer corpo, a implementação validava:
- arquitetura;
- SHA-256 da DLL;
- MVID (Module Version ID);
- tokens e metadados esperados.
Se a assembly não fosse exatamente a versão que eu havia investigado, o profiler recusava a alteração. A intervenção continuava específica para aquela versão, sem fingir compatibilidade com builds que eu nunca tinha analisado.
Também precisei garantir que os métodos realmente chegassem ao JIT. O inlining podia remover o ponto de interceptação, e imagens nativas de NGEN podiam levar o .NET Framework a usar código compilado previamente. Por isso, o processo instrumentado desativava os dois.
Com isso, a arquitetura final ficou assim:
- a DLL original permanece no disco
- o launcher valida sua assinatura
- o CLR carrega a assembly
- o método alvo chega ao JIT
- o profiler fornece um corpo composto
- o JIT compila esse corpo
- o UID alvo recebe o retorno experimental
- os demais UIDs seguem pelas instruções originais
Cada requisito passou a ser tratado no ponto certo do fluxo, sem tentar desfazer uma etapa que o sistema já havia concluído.
O mecanismo funcionava, mas ainda faltava validar o efeito
Com uma fixture x64, eu conseguia verificar se o CLR carregava o profiler, se SetILFunctionBody aceitava o corpo e se o JIT conseguia compilá-lo. Isso ainda não dizia se o Outplayed real tomaria outra decisão.
Separei a validação em quatro perguntas:
- o CLR carrega o profiler?
- a API aceita o corpo construído?
- o JIT compila e executa esse corpo?
- o aplicativo real muda de comportamento por causa dele?
Adicionei modos intermediários ao launcher: baseline sem instrumentação, bootstrap para carregar a infraestrutura, observe para registrar callbacks e neutral para fornecer corpos equivalentes aos originais. Só depois executei o modo que alterava as respostas.
Essa separação tornava as falhas mais fáceis de localizar. Se bootstrap não funcionasse, nenhum IL novo teria sido instalado. Se neutral falhasse, o problema estaria na instrumentação e não no plano 61.
O próprio ambiente impôs uma limitação. Em uma conta restrita de automação, o Overwolf encerrava antes dos métodos alvo com Failed to initialize CEF runtime. Precisei executar o teste completo na conta interativa usada pelo aplicativo.
Nessa sessão, os logs confirmaram que o Core real chegou ao callback e compilou o método substituído:
2026-09-08T21:09:08.275Z JIT target started function=0x7FFEC3916450 token=0x60030B72026-09-08T21:09:08.284Z premium SetILFunctionBody detailed=ok ids=ok rollback=ok2026-09-08T21:09:08.285Z Core JIT finished function=0x7FFEC3916450 token=0x60030B7 status=0x0Esse log permite uma conclusão bem delimitada: o Core chegou ao callback, aceitou os corpos fornecidos e concluiu a compilação do método detalhado. Sozinho, ele não prova todas as mudanças visuais nem registra, nesse trecho, uma execução independente do método de IDs.
O resultado alterou um caminho local, não o estado da conta
Na configuração, premium era somente o nome do modo de instrumentação:
mode = premiumapp = cghphpbjeabdkomiphingnegihoigeggcfphdofoplans = 61--app selecionava o UID que deveria seguir pelo caminho novo; --plans informava os IDs devolvidos pelos dois métodos locais. Para o Outplayed analisado, o valor observado era 61.
O profiler não criava uma assinatura Tebex nem alterava registros no servidor. Ele modificava duas respostas produzidas pelo Core para um UID específico, como resume a Figura 13.
flowchart TD
A[Servidor e Tebex] -->|Permanece igual| B[Estado da conta]
C[GetExtensionSubscriptions] -->|Resposta alterada para o UID alvo| E[Decisões locais]
D[GetExtensionSubscriptionsIds] -->|Resposta alterada para o UID alvo| E
Figura 13. A intervenção alterava respostas locais sem modificar o estado da conta no servidor.
Os logs de execução registraram que o profiler atuava somente no processo gerenciado Overwolf.exe e que os arquivos instalados permaneciam intactos. Ao iniciar o Overwolf normalmente, fora do ambiente de profiling, a instrumentação deixava de ser aplicada.
Na sessão que observei, o botão “Go Premium” desapareceu. Os anúncios não estavam visíveis e a opção de ocultar o layout do jogo ficou disponível. Ao mesmo tempo, a tela de plano continuou mostrando Outplayed Core - Free.
Os dois estados podem ser comparados diretamente na Figura 1.
O resultado faz sentido dentro da arquitetura que eu havia reconstruído. O provedor legado ainda influenciava parte do comportamento local, enquanto o estado da conta e os benefícios dependentes do servidor seguiam por outro caminho.
Isso não demonstra que o aplicativo inteiro tenha se tornado premium. Também não prova que cada mudança visual tenha sido causada exclusivamente pelos dois retornos. A ausência de anúncios pode depender de outras condições, uma única execução não mostra persistência e funcionalidades validadas no servidor ficaram fora do escopo.
A causa raiz estava no momento da intervenção
Comecei a investigação tratando a falha como um problema de compatibilidade entre o IL produzido e a versão atual do Core.
Essa linha de análise encontrou um bug real. A implementação emitia um float onde a versão analisada esperava um double. A fixture demonstrou que o IL corrigido funcionava. Ainda assim, o Overwolf recusava a DLL antes de executá-la.
O teste que mudou o rumo da investigação foi também o mais simples: abrir uma cópia limpa do Core com Cecil e gravá-la sem alterar método nenhum. O WinTrust rejeitou a cópia mesmo com 0 diferenças entre os 16.992 métodos comparados.
Ali ficaram claros dois problemas que até então pareciam um só:
O corpo de IL precisava ser atualizado.
O mecanismo usado para entregar esse conteúdo havia deixado de ser compatível com o sistema.
A abordagem em disco assumia que a DLL instalada podia ser substituída antes da inicialização. O launcher verificava a integridade assinada dessa mesma DLL antes de carregá-la. Corrigir o IL sem mudar o ponto de intervenção nunca seria suficiente.
A pergunta que destravou o problema foi esta:
Em que ponto o arquivo já foi aceito, mas o comportamento ainda não se tornou código nativo?
A resposta estava imediatamente antes do JIT. A DLL assinada continuava no disco. Quando um dos métodos alvo estava prestes a ser compilado, o profiler fornecia um corpo composto: o UID do Outplayed seguia pelo caminho experimental; os demais executavam as instruções originais incorporadas ao mesmo método.
No começo, eu tentava mudar o que estava no arquivo. A solução apareceu quando passei a investigar o momento da mudança. Ela precisava acontecer depois do WinTrust e antes do JIT.