Todos os textos

Depois do WinTrust, antes do JIT: reescrevendo IL sem tocar na DLL

Como uma verificação de assinatura tornou inviável a reescrita em disco do Overwolf e levou a investigação para a substituição de IL em runtime.

0%0%
19 min restantes

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.

Tela do Outplayed antes da instrumentação, com o botão GO PREMIUM e anúncios na interface.

Tela do Outplayed depois da instrumentação, sem anúncios e com a opção de ocultar o layout do jogo disponível.

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:

C#: retorno do inteiro 7
int GetNumber()
{
return 7;
}

pode ser representado, de forma simplificada, assim:

IL: retorno do inteiro 7
ldc.i4.7
ret

Uma substituição simples podia reduzir o corpo de um método ao equivalente de return false:

Substituição direta do corpo de um método
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:

ClientCore.cs: gravação em disco
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:

  • GetExtensionSubscriptions
  • GetExtensionSubscriptionsIds

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 61 para 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:

Carga do preço: float no legado, double na fixture
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:

  1. o novo corpo de IL deveria ser aceito pelo runtime;
  2. o método detalhado deveria devolver o plano esperado;
  3. o método de IDs deveria incluir 61;
  4. 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:

Launcher: falha de verificação
Refusing to start - deployed assembly failed verification:
C:\Program Files (x86)\Overwolf\0.309.0.14\OverWolf.Client.Core.dll

Esse 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.

O launcher aceita a DLL assinada e rejeita a cópia regravada sem assinatura válida.

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:

  1. manter a DLL original, intacta e validamente assinada no disco;
  2. intervir antes que o método virasse o código nativo executado;
  3. 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.

A substituição chama o delegate salvo, que retorna à entrada redirecionada e provoca 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:

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.

O launcher verifica a DLL original no disco; em memória, o profiler fornece IL ao CLR antes da compilação JIT.

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:

InstallBody: transferência do novo corpo ao 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:

Comportamento condicional desejado
se o UID é o do Outplayed:
devolver o resultado experimental
senão:
executar o comportamento original

O 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.

IL: prefixo condicional e corpo original
ldarg.0
call get_UID
ldstr "identificador da extensão alvo"
call string::op_Equality
brfalse 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.

Um prefixo compara o UID com o alvo: se corresponde, devolve o retorno modificado; caso contrário, segue para o corpo original.

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:

Profiler.cpp: ajuste do 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:

  1. a DLL original permanece no disco
  2. o launcher valida sua assinatura
  3. o CLR carrega a assembly
  4. o método alvo chega ao JIT
  5. o profiler fornece um corpo composto
  6. o JIT compila esse corpo
  7. o UID alvo recebe o retorno experimental
  8. 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:

  1. o CLR carrega o profiler?
  2. a API aceita o corpo construído?
  3. o JIT compila e executa esse corpo?
  4. 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:

Core real: log de compilação
2026-09-08T21:09:08.275Z JIT target started function=0x7FFEC3916450 token=0x60030B7
2026-09-08T21:09:08.284Z premium SetILFunctionBody detailed=ok ids=ok rollback=ok
2026-09-08T21:09:08.285Z Core JIT finished function=0x7FFEC3916450 token=0x60030B7 status=0x0

Esse 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:

Configuração da instrumentação
mode = premium
app = cghphpbjeabdkomiphingnegihoigeggcfphdofo
plans = 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.

Imagem ampliada

100%