Инъекции CLR: замена методов во время выполнения

ОГЛАВЛЕНИЕ

Многие из нас, наверняка, были заинтересованы в том, как работает универсальный язык CLR. Одной из наиболее интересных вещей является динамический компилятор JIT (Just In Time Compiler). Мы рассмотрим то, как JIT компилирует MSIL и создадим утилиту, которая позволяет программным образом заменить любой метод (JIT) другим во время выполнения. Мы также создадим отладочную утилиту, которая прехватывает JIT-вызовы и выводит в консоль информацию о диагноcnbrt.

Компилятор JIT

Microsoft Intermediate Language или MSIL (более известен как Common Intermediate Language (CIL)) является языком низшего уровня, типа ассемблера. Все языки .NET компилируются в MSIL (с некоторыми исключениями C++/CLI). Процессоры не могут выполнять MSIL напрямую (может, для .NET в будущем появится технология типа ARM Jazelle). Компилятор JIT используется для преобразования MSIL в машинный код. Метод будет скомпилирован единожды компилятором JIT, а CLR сохранит (кэширует) машинный код, который произведет JIT, для последующих вызовов.

Процесс компиляции должен быть очень быстрым, поскольку он происходит во время выполнения. Поскольку MSIL является языком низшего уровня, его система кодов операций с легкостью преобразуется в машинную специфическую систему кодов операций. Сам процесс компиляции использует кое-что под названием JITStub. JITStub - это блок вспомогательного машинного кода, которым обладает каждый метод. JITstub в исходной версии содержит код, который вызывает JIT для метода. После того, как метод будет скомпилирован при помощи JIT, JITstub будет заменен кодом, который вызывает машинный код, созданный JIT .
000EFA60 E86488D979       call        79E882C9  // До JIT.  Вызов метода JIT
000EFA60 E97BC1CB00       jmp         00DABBE0 // После JIT.  Переход к ассемблерному коду, созданному JIT
Каждый класс имеет таблицу методов. Таблица методов имеет адреса всех JITStub для методов класса. Таблица метода используется во время компиляции JIT для разрешения вспомогательного кода JIT. Метод, который уже был откомпилирован JIT, не будет включен в эту таблицу, машинный код, созданный JIT, вызовет адрес вспомогательного кода напрямую. Далее мы привели выходную информацию таблицы методов из SOS. Колонка entry содержит адреса кодов.
MethodDesc Table
   Entry MethodDesc      JIT Name
79371278   7914b928   PreJIT System.Object.ToString()
7936b3b0   7914b930   PreJIT System.Object.Equals(System.Object)
7936b3d0   7914b948   PreJIT System.Object.GetHashCode()
793624d0   7914b950   PreJIT System.Object.Finalize()
000efa60   00d77ce0      JIT ReplaceExample.StaticClassB.A()
000efa70   00d77ce8     NONE ReplaceExample.StaticClassB.B()
Также есть возможность вызвать компилятор JIT из управляемого кода без вызова самого метода. Метод System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod заставит JIT скомпилировать метод.


Отладка

Отладка процесса JIT может оказаться довольно сложной. К счастью, существует множество хороших инструментов, которые вы можете использовать. Мы кратко расскажем о них далее.

Отладка при помощи SOS

SOS расшифровывается как Son of Strike. Это расширение отладки для CLR, которое вы можете использовать в Visual Studio или WinDbg. Это очень полезный инструмент, который поставляется с .NET Framework. Он может быть использован для отладки расширений .NET на станциях, которые не имеют установленного Visual Studio. Мы также может использовать его для нахождения и рассмотрения структур в памяти, используемых CLR, рассматривать сборку и IL и множество других вещей. Это может стать вашим основным инструментом отладки. SOS работает только тогда, когда активирована отладка вручную (unmanaged debugging). Для получения более подробной информации вам стоит посетить эту страницу.

Rotor

Rotor - это свободное программное обеспечение CLR (находящееся под лицензией Microsoft Shared Source), выпущенное компанией Майкрософт. Rotor - это не то же самое, что CLR, которое поставляется компанией Майкрософт, но это полноценный CLR. Кодов много, и, возможно, вам будет чересчур сложно найти то, что вы ищите. Мы будем использовать некоторые заголовки из Rotor для нашего регистратора JIT (JIT Logger).

Регистратор JITLogger

JITLogger - это инструмент, который регистрирует JIT- вызовы к консоли. Он может быть активирован или отключен. Далее следуют подписи JITLogger и некоторый примерный выходной результат.
public class JitLogger
{
    public static bool Enabled { get; set; }
    public static int JitCompileCount { get; }
}
Выходной результат:
JIT :   0xdd20a8 Program.StaticTests
JIT :   0xdd217b Program.TestStaticReplaceJited
JIT :   0x749c205c MethodUtil.ReplaceMethod
JIT :   0x749c2270 MethodUtil.MethodSignaturesEqual
JIT :   0x749c231c MethodUtil.GetMethodReturnType
JIT :   0x749c20e4 MethodUtil.GetMethodAddress
JIT :   0xdd23e6 StaticClassB.A


Инъекция метода

Мы хотим заменить код более простым - для этого у нас два метода, источник, назначение и необходимо заменить назначение источником. Далее следует подпись для нашего метода замены:
public static void ReplaceMethod(MethodBase source, MethodBase dest)

Инъекция IL

Изначально мы хотели заменить IL, но мы столкнулись с некоторыми проблемами. Кажется, CLR ведет себя по-разному в зависимости от метода сборки и наличия прикрепленного отладчика. Так же кэшируются вызовы JIT (вспомогательный код заменяется). Нам необходимо заставить CLR каким-то образом аннулировать кэш JIT. Это получилось выполнить, но только в режиме отладчика, и приходится сохранять какое-то состояние каждого метода для того, чтобы CLR мог повторно скомпилировать его при помощи JIT.

Также мы пробовали использовать принудительный метод, но и здесь мы встретили некоторые препятствия. Идея заключалась в программной замене методов из управляемого кода. Казалось, можно зацепить JIT, использовать RuntimeHelpers.PrepareMethod ,чтобы заставить метод быть скомпилированным JIT, а затем изменить структуру CORINFO_METHOD_INFO, которая передается в наш подключенный метод. Передача состояния между управляемым и автономными подключенными методами оказалась проблематичной. Если мы вызываем любой управляемый метод из подключенного, то у нас переполнение стека. В то же время деактивация JIT-кэша слишком сложна и, опять-таки, это возможно только в режиме отладки.

Другим подходом может быть использование автономных программных интерфейсов метаданных (API). Мы могли бы считать RVA- таблицы метаданных методов, использовать RVA и адрес основания модуля для нахождения IL-адреса в памяти, а затем переписать поверх него. Это тоже проблематично, потому что длина источника IL в битах должна быть меньше, чем пункт назначения. Тем более что есть нечто большее, чем просто IL - у нас также есть tiny или fat заголовок IL и, вероятно, структуры SEH и т.д. После того, как метод будет вызван, как только JITStub будет заменен, мы столкнемся с той же проблемой, что и в других методах.

После этого тупика с различными подходами мы решили попробовать отличный метод. Вместо замены IL мы заменим компонующий автокод, который выдает JIT. С таким подходом мы можем заботиться о деактивации кэширования, заголовках IL, SEH и т.д.

Инъекции после отладки JIT (Post JIT Injection)

Наш новый подход обеспечивает компиляцию как исходных, так и методов назначения, находит в памяти таблицу методов и заменяет адрес JITStub назначения адресом источника. Мы можем заменить метод неограниченное число раз и нам не нужно думать о кэшировании метода, но нам необходимо отыскать несколько вещей в памяти.

Мы будем использовать RuntimeTypeHandle и RuntimeMethodHandle для нахождения таблицы методов и расположения метода в памяти. RuntimeMethodHandle указывает на 8-битную структуру в памяти, называемую MethodDescription. Это тот же адрес, что и в колонке MethodDesc, при использовании команды SOS !DumpMT -MD. Структура содержит индекс метода в таблице методов. Затем мы можем использовать RuntimeTypeHandle для нахождения таблицы методов. Таблица методов начинается с 40 бит после адреса RuntimeTypeHandle.

Динамические методы работают по-другому. Мы не смогли найти документацию, но мы нашли адрес JITStub используя отладчик памяти. Динамический метод не раскрывает свой RuntimeMethodHandle , поэтому мы должны использовать Reflection, чтобы получить его. Мы нашли адрес JITStub через 24 бита после адреса дескриптора динамического метода.

public static IntPtr GetMethodAddress(MethodBase method)
{
    if ((method is DynamicMethod))
    {
        unsafe
        {
            byte* ptr = (byte*)GetDynamicMethodRuntimeHandle(method).ToPointer();
            if (IntPtr.Size == 8)
            {
                ulong* address = (ulong*)ptr;
                address += 6;
                return new IntPtr(address);
            }
            else
            {
                uint* address = (uint*)ptr;
                address += 6;
                return new IntPtr(address);
            }
        }
    }

    RuntimeHelpers.PrepareMethod(method.MethodHandle);

    unsafe
    {
        // некоторые dwords
        int skip = 10;

        // считывание  индекса метода.
        UInt64* location = (UInt64*)(method.MethodHandle.Value.ToPointer());
        int index = (int)(((*location) >> 32) & 0xFF);

        if (IntPtr.Size == 8)
        {
            // получение таблицы методов
            ulong* classStart = (ulong*)method.DeclaringType.TypeHandle.Value.ToPointer();
            ulong* address = classStart + index + skip;
            return new IntPtr(address);
        }
        else
        {
            // получение таблицы методов
            uint* classStart = (uint*)method.DeclaringType.TypeHandle.Value.ToPointer();
            uint* address = classStart + index + skip;
            return new IntPtr(address);
        }
    }
}

private static IntPtr GetDynamicMethodRuntimeHandle(MethodBase method)
{
    if (method is DynamicMethod)
    {
        FieldInfo fieldInfo = typeof(DynamicMethod).GetField("m_method",
                              BindingFlags.NonPublic|BindingFlags.Instance);
        return ((RuntimeMethodHandle)fieldInfo.GetValue(method)).Value;
    }
    return method.MethodHandle.Value;
}

После того, как мы получим расположение адресов JITStub, мы просто должны изменить значение. Наш метод замены продемонстрирован ниже:

public static void ReplaceMethod(IntPtr srcAdr, MethodBase dest)
{
    IntPtr destAdr = GetMethodAddress(dest);
    unsafe
    {
        if (IntPtr.Size == 8)
        {
            ulong* d = (ulong*)destAdr.ToPointer();
            *d = *((ulong*)srcAdr.ToPointer());
        }
        else
        {
            uint* d = (uint*)destAdr.ToPointer();
            *d = *((uint*)srcAdr.ToPointer());
        }
    }
}
public static void ReplaceMethod(MethodBase source, MethodBase dest)
{
    if (!MethodSignaturesEqual(source, dest))
    {
        throw new ArgumentException("Сигнатуры методов не совпадают.",
                                    "source");
    }
    ReplaceMethod(GetMethodAddress(source), dest);
}


Пример кода

В нашем образце кода коде мы будем использовать несколько вещей - мы заменим статический метод одного из классов статическим методом другого класса. Мы сделаем то же самое с экземпляром метода, а также заменим статический метод DynamicMethod. Некоторые из наших тестовых методов будут подключены к режиму Release. Нам понадобилось добавить атрибуты MethodImpl к некоторым методам чтобы предотвратить подключение.

Если вы пройдетесь по коду, то заметите, что Visual Studio просто обманут. После замены метода Visual Studio вступит в новый метод вместо старого метода.

Далее мы приведем результаты наших тестов:

Enabling JIT debugging.
        JIT :   0x10720a8 Program.StaticTests
        JIT :   0x107217b Program.TestStaticReplaceJited
Replacing StaticClassA.A() with StaticClassB.A()
        JIT :   0x71ac205c MethodUtil.ReplaceMethod
        JIT :   0x71ac2270 MethodUtil.MethodSignaturesEqual
        JIT :   0x71ac231c MethodUtil.GetMethodReturnType
        JIT :   0x71ac20e4 MethodUtil.GetMethodAddress
        JIT :   0x10723e6 StaticClassB.A
        JIT :   0x71ac2094 MethodUtil.ReplaceMethod
        JIT :   0x1072426 StaticClassA.A
Call StaticClassA.A() from a  method that has already been jited
StaticClassA.A
Call StaticClassA.A() from a  method that has not been jited
        JIT :   0x1072172 Program.TestStaticReplace
StaticClassB.A
        JIT :   0x1072190 Program.InstanceTests
        JIT :   0x1072284 Program.TestInstanceReplaceJited
Replacing InstanceClassA.A() with InstanceClassB.A()
        JIT :   0x10723c2 InstanceClassB.A
        JIT :   0x1072402 InstanceClassA.A
Call InstanceClassA.A() from a  method that has already been jited
        JIT :   0x107241e InstanceClassA..ctor
InstanceClassA.A
Call InstanceClassA.A() from a  method that has not been jited
        JIT :   0x1072268 Program.TestInstanceReplace
InstanceClassB.A
        JIT :   0x10722a0 Program.DynamicTests
        JIT :   0x1072344 Program.CreateTestMethod
Created new dynamic metbod StaticClassA.C
        JIT :   0x107232e Program.TestDynamicReplaceJited
Replacing StaticClassA.B() with dynamic StaticClassA.C()
        JIT :   0x71ac2210 MethodUtil.GetDynamicMethodRuntimeHandle
        JIT :   0x1072434 StaticClassA.B
Call StaticClassA.B() from a  method that has already been jited
StaticClassA.B
Call StaticClassA.B() from a  method that has not been jited
        JIT :   0x1072325 Program.TestDynamicReplace
        JIT :   0x10c318 DynamicClass.C
StaticClassA.C

Выводы

Практичность использования данного кода ограничена. Если вы хотите изменить библиотеку, и у вас нет доступа к исходному коду, и вы не хотите детранслировать, перекомпилировать или использовать редактор шестнадцатеричных файлов, то это, скорее всего, вам поможет. Можно создать некоторую библиотеку AOP , которая модифицирует существующие типы во время выполнения, вместо того, чтобы создавать оболочки или другие подходы во время сборки.

Существуют некоторые ограничения для данного кода. Заметим, что как только метод скомпилирован при помощи JIT, он не будет более ссылаться на адрес таблицы методов, который мы изменяем. Замена должна производиться до того, как вызываемый метод будет скомпилирован JIT. Вдобавок, это не было протестировано на станциях архитектуры x86-64. Сборки Zap или NGen также не работают.

Нам необходимо помнить, что мы напрямую манипулируем памятью CLR не рекомендуемыми способами. Этот код может не сработать с более новыми версиями .NET Framework. Работа выполнялась на .NET 3.5 и ОС Vista x86, поэтому код может не сработать на других компьютерах. Неплохо было бы написать класс, который смог бы определить свойства процессора и создать более оптимальную версию, которая будет использовать более совершенные технологии, такие как SIMD. .
Обновление

Распределение памяти было изменено в .NET версии 2.0 SP2, которую приходится установить при установке .NET версии 3.5 SP1. Код был обновлен таким образом, чтобы он определял структуру и действовал соответственно.

Скачать исходный код

Ziad Elmalki