Jitex - Parte 1 - Injetando IL e ASM

Modificando métodos em tempo de execução


Autor: Flavio

Publicado em: 12 de Janeiro de 2020

Nos últimos meses, resolvi estudar como funciona o CLR do .NET. Um dos meus principais objetivos, era poder fazer modificações no corpo do método em tempo de execução sem ter que mudar a fonte original (de forma bruta, sem modificar o .cs). Tentei fazer isto com o Detour, mas da forma que eu queria não era possível fazer com tipos genéricos, já que tipos/classes/métodos genéricos, são compilados sobre demanda… Não teve jeito, tive que estudar o JIT.

Grande parte dos conteúdos que encontrei nesta área, são voltados para o .NET Framework antes e logo depois do SP2 (caso não saiba, a atualização do Service Pack 2 do .NET Framework, mudou a estrutura interna drasticamente). Na época em que foram escritos, a maioria são informações obtidas através de engenharia reversa ou estudos em cima do ROTOR (um “mini” .NET Framework código aberto publicado pela Microsoft em 2006). Resumindo: grande parte estão desatualizados, mas continuam bastante úteis.

Gostaria de agradecer o membro xoofx. O artigo dele foi a base para este projeto e pode ser encontrado aqui: Writing a Managed JIT in C# with CoreCLR. A parte principal do projeto, foi em base deste post dele.

O resultado final deste artigo, permitirá:

  • Modificar o corpo IL do método (pré-compilação)
  • Modificar o código nativo do método (pós-compilação)
  • Monitorar todas as compilações do JIT no seu código
  • Entre outras coisas…

Github

O projeto pode ser encontrado aqui: Jitex

Como será feito

No Runtime (antigo CoreCLR) do .NET, o JIT foi inteiramente escrito em C/C++, o que nos permite uma comunicação mais “natural” entre .NET e código nativo através do P/Invoke. Quando uma aplicação .NET é executada, o módulo clrjit.dll (ou em algumas versões mscorjit.dll) é carregado, contendo todas as informações sobre o JIT. Métodos no .NET, são compilados sobre demanda, ou seja, somente quando são executados (exceto em casos com PrepareMethod). Quando executados, o JIT faz a compilação do MSIL para código nativo.

O que iremos fazer, é modificar a chamada do método de compilação do JIT.

É possível obter o JIT através do próprio clrjit.dll. Dentro deste módulo, há um método exposto chamado getJit que nos retorna uma instância do ICorJitCompiler:

extern "C" ICorJitCompiler* __stdcall getJit();

Esta interface, contém o método compileMethod, que é o responsável de fazer a compilação do MSIL para código nativo:

public:
    // compileMethod is the main routine to ask the JIT Compiler to create native code for a method. The
    // method to be compiled is passed in the 'info' parameter, and the code:ICorJitInfo is used to allow the
    // JIT to resolve tokens, and make any other callbacks needed to create the code. nativeEntry, and
    // nativeSizeOfCode are just for convenience because the JIT asks the EE for the memory to emit code into
    // (see code:ICorJitInfo.allocMem), so really the EE already knows where the method starts and how big
    // it is (in fact, it could be in more than one chunk).
    //
    // * In the 32 bit jit this is implemented by code:CILJit.compileMethod
    // * For the 64 bit jit this is implemented by code:PreJit.compileMethod
    //
    // Note: Obfuscators that are hacking the JIT depend on this method having __stdcall calling convention
    virtual CorJitResult __stdcall compileMethod (
            ICorJitInfo                 *comp,               /* IN */
            struct CORINFO_METHOD_INFO  *info,               /* IN */
            unsigned /* code:CorJitFlag */   flags,          /* IN */
            BYTE                        **nativeEntry,       /* OUT */
            ULONG                       *nativeSizeOfCode    /* OUT */
            ) = 0;

Este método é o nosso alvo.

Obtendo o JIT

Foi criada uma classe ManagedJit para fazer todo o controle do “nosso JIT”. Esta classe vai ser Singleton (afinal, não pode ter mais que um JIT em execução).

Para começar, temos que obter o endereço do ICorJitCompiler. Para isto, basta importamos a dll clrjit.dll apontando como entry o getJit:

[DllImport("clrjit.dll", CallingConvention = System.Runtime.InteropServices.CallingConvention.StdCall, SetLastError = true, EntryPoint = "getJit", BestFitMapping = true)]
private static extern IntPtr GetJit();

static ManagedJit() {}

Agora que nós obtemos o endereço do ICorJitCompiler, podemos ler a sua VTable, que é pequena e possui poucos métodos. Além desta, futuramente teremos que ler a VTable do CEEInfo. Para obtermos o endereço de um método, temos que saber o seu index na VTable. Este projeto está feito .NET Core 3.1 e os index estão para esta versão. Caso esteja utilizando uma versão do .NET Core diferente da 3.0 | 3.1, pode-se obter o index da seguinte forma:

  1. Acesse o ThunkInput.txt da sua versão (veja pelo histórico do git)
  2. Obtenha a lista de todos os métodos (FUNCTIONS)
  3. O index do método na VTable é a linha em que o método se encontra

Abaixo, os indexes de todos os métodos que vamos utilizar e todos os delegates para o P/Invoke:

internal static class VTable {
	public const int CompileMethod = 0;
	public const int GetModuleAssembly = 48;
	public const int GetAssemblyName = 49;
	public const int GetMethodDefFromMethod = 116;
}
internal static class Delegates { 
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
	public delegate IntPtr GetJitDelegate();

	/// <summary>
	/// Wrap delegate to compileMethod from ICorJitCompiler.
	/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corjit.h#L238"/>
	/// </summary>
	/// <param name="thisPtr">this parameter.</param>
	/// <param name="comp">(IN) - Pointer to ICorJitInfo.</param>
	/// <param name="info">(IN) - Pointer to CORINFO_METHOD_INFO.</param>
	/// <param name="flags">(IN) - Pointer to CorJitFlag.</param>
	/// <param name="nativeEntry">(OUT) - Pointer to NativeEntry.</param>
	/// <param name="nativeSizeOfCode">(OUT) - Size of NativeEntry.</param>
	[UnmanagedFunctionPointer(CallingConvention.StdCall)]
	public delegate int CompileMethodDelegate(IntPtr thisPtr, IntPtr comp, ref CORINFO_METHOD_INFO info, uint flags, out IntPtr nativeEntry, out int nativeSizeOfCode);

	/// <summary>
	/// Wrap delegate to getMethodDefFromMethodDelegate from ICorJitInfo.
	/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2910"/>
	/// </summary>
	/// <param name="thisPtr">this parameter.</param>
	/// <param name="hMethodHandle">(IN) - Pointer to method handle.</param>
	/// <return>Method token.</return>
	[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
	public delegate int GetMethodDefFromMethodDelegate(IntPtr thisPtr, IntPtr hMethodHandle);

	/// <summary>
	/// Wrap delegate to getModuleAssembly from ICorJitInfo.
	/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2391"/>
	/// </summary>
	/// <param name="thisPtr">this parameter.</param>
	/// <param name="moduleHandle">(IN) - Pointer to module handle.</param>
	/// <returns>Handle from assembly.</returns>
	[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
	public delegate IntPtr GetModuleAssemblyDelegate(IntPtr thisPtr, IntPtr moduleHandle);

	/// <summary>
	/// Wrap delegate to getNameAssembly from ICorJitInfo.
	/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2396"/>
	/// </summary>
	/// <param name="thisPtr">this parameter.</param>
	/// <param name="assemblyHandle">(IN) - Pointer to assembly handle.</param>
	/// <returns>Handle from assembly.</returns>
	[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
	public delegate IntPtr GetAssemblyName(IntPtr thisPtr, IntPtr assemblyHandle);
}

Devemos obter o endereço do compileMethod e um delegate para ele:

public class ManagedJit {
	private static readonly IntPtr JitVTable;
	private static readonly CompileMethodDelegate OriginalCompileMethod;
	private static readonly IntPtr OriginalCompiteMethodPtr;

	static ManagedJit() {
		//Obtém o endereço do JIT
		IntPtr jit = getJit();

		//Obtém a VTable
		JitVTable = Marshal.ReadIntPtr(jit);

		OriginalCompiteMethodPtr = Marshal.ReadIntPtr(JitVTable, IntPtr.Size * VTable.CompileMethod);
		OriginalCompileMethod = Marshal.GetDelegateForFunctionPointer < CompileMethodDelegate > (OriginalCompiteMethodPtr);
	}
}

Com o compileMethod em mãos, podemos criar o nosso, mas antes, precisamos replicar as estrutras e enums que o original usa. Elas podem ser localizadas aqui:

Structs

Enums

Vamos fazer uma implementação básica do nosso método. Primeiro, criamos uma classe para gerenciar a compilação:

    internal class CompileTls
    {
        public int EnterCount;
    }

Seguidamente, a implementação do nosso compileMethod:

        //...
        [ThreadStatic] 
        private static CompileTls _compileTls;
        private static bool _hookInstalled;
        //...
        

        /// <summary>
        /// Wrap delegate to compileMethod from ICorJitCompiler.
        /// </summary>
        /// <param name="thisPtr">this parameter.</param>
        /// <param name="comp">(IN) - Pointer to ICorJitInfo.</param>
        /// <param name="info">(IN) - Pointer to CORINFO_METHOD_INFO.</param>
        /// <param name="flags">(IN) - Pointer to CorJitFlag.</param>
        /// <param name="nativeEntry">(OUT) - Pointer to NativeEntry.</param>
        /// <param name="nativeSizeOfCode">(OUT) - Size of NativeEntry.</param>
        private int CompileMethod(IntPtr thisPtr, IntPtr comp, ref CORINFO_METHOD_INFO info, uint flags, out IntPtr nativeEntry, out int nativeSizeOfCode)
        {
            CompileTls compileEntry = _compileTls ??= new CompileTls();
            compileEntry.EnterCount++;

            try
            {
                if (!_hookInstalled)
                {
                    nativeEntry = IntPtr.Zero;
                    nativeSizeOfCode = 0;
                    return 0;
                }
                return OriginalCompileMethod(thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);
            }
            finally
            {
                compileEntry.EnterCount--;
            }
        }

Observe que ainda chamamos o compileMethodriginal, porque é ele quem conhece toda a implementação necessária de MSIL para código nativo. O nosso objetivo é manipular o que chega até nele, e não mudar ele em si.

Com o método implementado, podemos prepará-lo para escreve-lo na VTable.

Atualmente temos o seguinte cenário: o JIT está todo em C++ e tem que chamar o nosso método que está em C#, ou seja, uma chamada de código não-gerenciado para código gerenciado (Um P/Invoke reverso). Isto pode ser feito através de delegates, em que passamos o ponteiro do delegate para o C++ e quando ele for chamado, o JIT se encarrega de fazer a transação.

        //...
        private CompileMethodDelegate _customCompileMethod;
        private IntPtr _customCompiledMethodPtr;
        //...

        private ManagedJit()
        {
            if (OriginalCompileMethod == null) return;

            _customCompileMethod = CompileMethod;
            _customCompiledMethodPtr = Marshal.GetFunctionPointerForDelegate(_customCompileMethod);
        }

Se escrevermos o ponteiro do delegate na VTable da forma que está à cima, isto causará StackOverflow, pois entrará em loop infinito. Lembra que um método/delegate é compilado pelo JIT somente quando é chamado? Neste caso, o nosso compileMethod e o nosso delegate nunca foram chamados, logo nunca foram compilados. Quando o JIT for tentar executar o delegate, ele vai recorrer ao método compileMethod para compila-lo, mas o compileMethod é o nosso delegate, o que causará uma nova chamada para compilação, causando um loop infinito. Para poder fazer uma a compilação do delegate, temos que simular uma chamada do código nativo para o nosso delegate, forçando o JIT a compilar o delegate e o método. Isto pode ser feito com trampoline:

        /// <summary>
        /// Cria um trampoline 64 bits. 
        /// </summary>
        /// <param name="address"></param>
        /// <returns></returns>
        public static IntPtr AllocateTrampoline(IntPtr address)
        {
            IntPtr jmpNative = VirtualAlloc(IntPtr.Zero, TrampolineInstruction.Length, AllocationType.Commit, MemoryProtection.ExecuteReadWrite);
            Marshal.Copy(TrampolineInstruction, 0, jmpNative, TrampolineInstruction.Length);
            Marshal.WriteIntPtr(jmpNative, 2, address);
            return jmpNative;
        }

        /// <summary>
        /// Libera o trampoline da memória.
        /// </summary>
        /// <param name="address"></param>
        public static void FreeTrampoline(IntPtr address)
        {
            VirtualFree(address, new IntPtr(TrampolineInstruction.Length), FreeType.Release);
        }
        private ManagedJit()
        {
            if (OriginalCompileMethod == null) return;

            _customCompileMethod = CompileMethod;
            _customCompiledMethodPtr = Marshal.GetFunctionPointerForDelegate(_customCompileMethod);

            //Impedir loop infinito
            IntPtr trampolinePtr = AllocateTrampoline(_customCompiledMethodPtr);
            CompileMethodDelegate trampoline = Marshal.GetDelegateForFunctionPointer<CompileMethodDelegate>(trampolinePtr);

            CORINFO_METHOD_INFO emptyInfo = default;
            trampoline(IntPtr.Zero, IntPtr.Zero, ref emptyInfo, 0, out _, out _);

            FreeTrampoline(trampolinePtr);
            InstallCompileMethod(_customCompiledMethodPtr); 
            _hookInstalled = true;
        }
        
        private static void InstallCompileMethod(IntPtr compileMethodPtr)
        {
            VirtualProtect(JitVTable + VTable.CompileMethod, new IntPtr(IntPtr.Size), MemoryProtection.ReadWrite, out var oldFlags);
            Marshal.WriteIntPtr(JitVTable, VTable.CompileMethod, compileMethodPtr);
            VirtualProtect(JitVTable + VTable.CompileMethod, new IntPtr(IntPtr.Size), oldFlags, out _);
        }

Há formas de compilar tanto o delegate quanto um método através do PrepareMethod e do PrepareDelegate. Eu tentei fazer com estas duas, mas acabei levando StackOverflow em todas as minhas tentativas.

Dessa maneira, já é o suficiente para que o JIT chame o nosso compileMethod.

Basta terminarmos a implementação do Singleton e testarmos:

private static ManagedJit _instance;

public static ManagedJit GetInstance () {
    lock (JitLock) {
        return _instance ?? = new ManagedJit ();
    }
}
class Program {
    
    static void Main () {
        ManagedJit managedJit = ManagedJit.GetInstance ();
        int resultado = Somar (1, 1);
        Console.WriteLine (resultado);
        Console.ReadKey ();
    }

    public static int Somar (int num1, int num2) {
        return num1 + num2;
    }
}

O esperado é que não aconteça nada fora do comum e que o resultado seja 2. Isso é somente para testar se o hook foi feito corretamente. Após isto, podemos continuar com a implementação do compileMethod. De início, vamos tentar mudar somente o IL.

Modificando o IL

Para implementar o nosso compileMethod, é bom conhecer os parâmetros que são fornecidos:

  • comp: É um ponteiro para a implementação ICorJitInfo. Ela será útil para nós, para obtermos o token do método e o assembly em que ele está.
  • info: É a estrutura básica do método. Ela contém endereço do IL, tamanho do IL, assinatura do método, …
  • flags: São flags que o JIT usará para compilar o método
  • nativeEntry: Endereço do método já compilado
  • nativeSizeOfCode: Tamanho do código nativo

Vamos fazer com que seja disparado um delegate toda vez que o método está prestes a ser compilado. Este delegate vai nos informar se o método deve ou não ser modificado e caso sim, qual será o novo IL dele.

public class ReplaceInfo {
    
    public enum ReplaceMode {
        IL,
        ASM
    }

    public ReplaceInfo (ReplaceMode mode, byte[] body) {
        Mode = mode;
        Body = body;
    }

    public ReplaceMode Mode { get; }
    public byte[] Body { get; }
}
public class ManagedJit{
  //... 
  private static IntPtr _corJitInfoPtr = IntPtr.Zero;
  
  public delegate ReplaceInfo PreCompile(MethodBase method);

  public PreCompile OnPreCompile { get; set; }       
  //...
}

Vamos criar também um método para nos auxiliar nas chamadas dos métodos do ICorJitInfo:

private static TOut ExecuteCEEInfo<TDelegate, TOut, TValue> (TValue value, int ofset) {
    IntPtr delegatePtr = Marshal.ReadIntPtr (_corJitInfoPtr, IntPtr.Size * offset);
    Delegate delegateMethod = Marshal.GetDelegateForFunctionPointer (delegatePtr, typeof (TDelegate));
    return (TOut) delegateMethod.DynamicInvoke (_corJitInfoPtr, value);
}

Sobrescrever o IL não requer grande trabalho, basta apenas informar o endereço de onde se encontra as instruções e o tamanho. O maior problema, é obter o MethodBase do método que será compilado.

A forma que utilizei para se obter informações do método, foi a mesma utilizada pelo xoofx:

  1. Obter o handle do assembly
  2. Obter o assembly pelo nome
  3. Obter o MetadataToken do método
  4. Procurar nos módulos do assembly, o MetadataToken do método.

É um trabalho pesado, mas não encontrei nenhuma outra forma. Para evitar que isto que seja feito toda hora, efetuamos o cache do handle em um dicionário.

if (compileEntry.EnterCount == 1 && OnPreCompile != null) {
    if (_corJitInfoPtr == IntPtr.Zero) {
        //Obtém a VTable do ICorJitInfo
        _corJitInfoPtr = Marshal.ReadIntPtr (comp);
    }

    lock (JitLock) {
        IntPtr assemblyHandle = ExecuteCEEInfo<GetModuleAssemblyDelegate, IntPtr, IntPtr> (info.scope, VTable.GetModuleAssembly);

        if (!MapHandleToAssembly.TryGetValue (assemblyHandle, out assemblyFound)) {
            IntPtr assemblyNamePtr = ExecuteCEEInfo<GetAssemblyName, IntPtr, IntPtr> (assemblyHandle, VTable.GetAssemblyName);
            string assemblyName = Marshal.PtrToStringAnsi (assemblyNamePtr);
            assemblyFound = AppDomain.CurrentDomain.GetAssemblies ().FirstOrDefault (assembly => assembly.GetName ().Name == assemblyName);
            MapHandleToAssembly.TryAdd (assemblyHandle, assemblyFound);
        }

        if (assemblyFound != null) {
            int methodToken = ExecuteCEEInfo<GetMethodDefFromMethodDelegate, int, IntPtr> (info.ftn, VTable.GetMethodDefFromMethod);

            foreach (Module module in assemblyFound.Modules) {
                try {
                    MethodBase methodFound = module.ResolveMethod (methodToken);
                    replaceInfo = OnPreCompile (methodFound);
                } catch {
                    // ignored
                }
            }
        }
    }
}

//Verifica se devemos modificar o método ou não
if (replaceInfo != null) {
    int ilLength = 0;
    IntPtr ilAddress = IntPtr.Zero;

    if (replaceInfo.Mode == ReplaceInfo.ReplaceMode.IL) {
        ilLength = replaceInfo.Body.Length;
        unsafe {
            fixed (void * ptr = replaceInfo.Body) {
                ilAddress = new IntPtr (ptr);
            }
        }
    }

    info.ILCode = ilAddress;
    info.ILCodeSize = ilLength;
}

return OriginalCompileMethod (thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);

Um detalhe interessante é o info.ftn, que é um ponteiro para o MethodDesc do método. Caso queira saber mais sobre o método, deve buscar nele.

Agora basta informamos o delegate ao chamar o ManagedJit:

class Program {
    static void Main () {
        ManagedJit managedJit = ManagedJit.GetInstance ();
        managedJit.OnPreCompile = OnPreCompile;
        int resultado = Somar (1, 1);
        Console.WriteLine (resultado);
        Console.ReadKey ();
    }

    private static ReplaceInfo OnPreCompile (MethodBase method) {
        MethodInfo somarInfo = typeof (Program).GetMethod ("Somar");

        if (somarInfo.MetadataToken == method.MetadataToken) {
            MethodInfo somarAleatorioInfo = typeof (Program).GetMethod ("Calculo");
            byte[] newIL = somarAleatorioInfo.GetMethodBody ().GetILAsByteArray ();
            return new ReplaceInfo (ReplaceInfo.ReplaceMode.IL, newIL);
        }

        return null;
    }

    public static int Somar (int num1, int num2) {
        return num1 + num2;
    }

    public static int Calculo (int num1, int num2) {
        return num1 + 10 + num2 + 10;
    }
}

Assim, toda vez que um método está prestes a ser compilado, notificamos e esperamos se ele deve ser modificado ou não.

Criei um método chamado chamado Calculo, ele é quem eu quero que substitua o Somar.

Quando executarmos, o resultado será 22:

Agora que conseguimos modificar o IL, podemos também modificar o código nativo.

Modificando o código nativo

Para modificar o código nativo é um pouco mais complicado, pois o código a ser reescrito tem que ser menor ou igual o código nativo gerado pelo JIT. Uma alternativa fácil para “burlar” isto, é induzir o tamanho do código nativo gerado pelo JIT através do IL.

Na teoria, quanto maior fosse o tamanho do IL, maior seria o tamanho do código nativo gerado pelo JIT, afinal, é mais código a ser executado. Mas isto não é realidade no .NET. Possuímos dois modos: DEBUG e RELEASE. No modo DEBUG, o cenário acima é real, quanto maior o IL, maior será o tamanho do código nativo, afinal, estamos em ambiente de depuração, não faria sentido otimizar. Em modo RELEASE, o cenário é diferente, o código sofre transformações e otimizações, ou seja, instruções são transformadas para instruções menores, mais eficientes, ou até mesmo removidas. Isto é um grande problemas para nós, pois precisamos achar uma forma de aumentar o tamanho do IL de uma forma que o JIT simplesmente não remova ou reduza drasticamente. Após muita procura, descobri que é possível fazer isto com instruções bitwise.

Ao injetar instruções bitwise, mesmo que não utilizadas, o JIT não remove elas do código final gerado e não faz grandes transformações. Assim podemos manipular o tamanho do código nativo através do IL.

Operadores bitwise foi uma das poucas instruções que encontrei que o JIT não remove no modo RELEASE. Outra instruções CALL, funcionaram também, mas daria mais trabalho para montar.

if (replaceInfo.Mode == ReplaceInfo.ReplaceMode.IL) {
    unsafe {
        fixed (void * ptr = replaceInfo.Body) {
            ilAddress = new IntPtr (ptr);
        }
    }
} else {
    Span<byte> emptyBody;

    //Tamanho mínimo gerado pelo JIT no modo RELEASE.
    const int minSize = 13;

    if (replaceInfo.Body.Length > minSize && replaceInfo.Body.Length > 21) {
        //Calcula o tamanho final que o IL deve ter.
        int nextMax = replaceInfo.Body.Length + (3 - replaceInfo.Body.Length % 3);
        ilLength = 4 + 2 * ((nextMax - 21) / 3);

        if (ilLength % 2 != 0) {
            ilLength++;
        }
    } else {
        ilLength = 4;
    }

    unsafe {
        void * ptr = stackalloc byte[ilLength];
        emptyBody = new Span<byte> (ptr, ilLength);
        ilAddress = new IntPtr (ptr);
    }

    emptyBody[0] = (byte) OpCodes.Ldc_I4_1.Value;
    emptyBody[1] = (byte) OpCodes.Ldc_I4_1.Value;
    emptyBody[2] = (byte) OpCodes.And.Value;
    emptyBody[ ^ 1] = (byte) OpCodes.Ret.Value;

    for (int i = 3; i < emptyBody.Length - 2; i += 2) {
        emptyBody[i] = (byte) OpCodes.Ldc_I4_1.Value;
        emptyBody[i + 1] = (byte) OpCodes.And.Value;
    }
}

//Precisamos garantir que há espaço suficiente na stack
if (info.maxStack < 2) {
    info.maxStack = 2;
}

info.ILCode = ilAddress;
info.ILCodeSize = ilLength;

O valor 13 da variável minSize, é o menor tamanho possível que encontrei de um código nativo, ele corresponde há um método void vazio. Não sei se este é realmente o menor tamanho possível, mas nos meus testes foi o suficiente.

Depois que o método estiver compilado, basta sobrescrever:

int result = Compiler.CompileMethod (thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);

//ASM can be replaced just after method already compiled by jit.
if (replaceInfo?.Mode == ReplaceInfo.ReplaceMode.ASM) {
    Marshal.Copy (replaceInfo.Body, 0, nativeEntry, replaceInfo.Body.Length);
}

return result;

Abaixo esta em um exemplo de como funciona:

class Program {
    static void Main () {
        ManagedJit managedJit = ManagedJit.GetInstance ();
        managedJit.OnPreCompile = OnPreCompile;
        int resultado = Somar (1, 1);
        Console.WriteLine (resultado);
        Console.ReadKey ();
    }

    private static ReplaceInfo OnPreCompile (MethodBase method) {
        MethodInfo somarInfo = typeof (Program).GetMethod ("Somar");

        if (somarInfo.MetadataToken == method.MetadataToken) {
            //num1 + num2 + 1 + 2 + 3 + 4 + 5 + 6 + ....

            //01 d1                   add    ecx,edx
            //83 c1 0a                add    ecx,0x1
            //83 c1 14                add    ecx,0x2
            //83 c1 1e                add    ecx,0x3
            //83 c1 28                add    ecx,0x4
            //...
            //89 c8                   mov    eax,ecx
            //ff c0                   inc    eax
            //c3                      ret

            List<byte> newASM = new List<byte> { 0x01, 0xd1 };

            int count = 0;

            //Simula um bytecode de no mínimo 500 bytes (bem maior que o gerado originalmente)
            while (newASM.Count < 500) {
                newASM.Add (0x83);
                newASM.Add (0xc1);
                newASM.Add ((byte) ++count);
            }

            newASM.Add (0x89);
            newASM.Add (0xc8);
            newASM.Add (0xff);
            newASM.Add (0xc0);
            newASM.Add (0xc3);

            return new ReplaceInfo (ReplaceInfo.ReplaceMode.ASM, newASM.ToArray ());
        }

        return null;
    }

    public static int Somar (int num1, int num2) {
        return num1 + num2;
    }
} 

O resultado será 3880.

Com isso, você já pode modificar o corpo do seu método em tempo de execução, tanto por IL quando por código nativo. Para restaurar compileMethod original, basta chamar o InstallCompileMethod passando o ponteiro do CompileMethodOriginal.

Há algumas limitações ainda nesta abordagem:

  • O tamanho de variáveis a ser utilizado no IL (stloc.x , ldloc.x, …), tem que ser menor ou igual ao tamanho de variáveis do método original.
  • O maxstack também deve ser menor ou igual ao original.

Resolverei estas limitações em um próximo post.

Fontes

Writing a Managed JIT in C# with CoreCLR

.NET Internals and Native Compiling

.NET Internals and Code Injection

.NET CLR Injection: Modify IL Code during Run-time

ConfuserEX

CLR Analyzer


Imagem do post