Pull to refresh

Инъекции MSIL кода в стороннюю сборку при помощи Mono.Cecil. Реализация принципов АОП в NET

Reading time 12 min
Views 19K

Введение


В данной статье я расскажу про то, как можно добавлять свой код в существующие .NET сборки и о том, как это связано с аспектно-ориентированным программированием. Статья будет сопровождаться работающими примерами, так как я считаю, что код — это лучший способ донести идею.

Многие .NET разработчики знают, что для доступа к объектам чужой сборки можно использовать Reflection. С помощью типов из System.Reflection мы можем получить доступ ко многим объектам .NET сборки, просмотреть их метаданные, и даже использовать те объекты, доступ к которым ограничен (например, private методы чужого класса). Но использование Reflection имеет свои ограничения и главная причина этому — данные, с котороми вы работаете через Reflection, все еще считаются кодом. Таким образом, вы, к примеру, можете получить CodeAccessSecurity exception, если сборка, к которой вы пытаетесь применить Reflection, запрещает это. По этой же причине Reflection работает довольно медленно. Но наиболее важным для данной статьи является то, что стандартный Reflection не позволяет изменять существующие сборки, только генерировать и сохранять новые.

Mono.Cecil


Качественно иной подход предлагает бесплатная библиотека с открытым исходным кодом Mono.Cecil. Главное отличие подхода Mono.Cecil от подхода Reflection в том, что данная библиотка работает с NET сборкой как с потоком байт. При загрузке сборки, Mono.Cecil разбирает PE заголовок, CLR заголовок, MSIL код классов и методов и т.д. работая напрямую с потоком байтов, представляющим сборку. Таким образом, с помощью данной библиотеки можно как угодно (в пределах предусмотренного) изменять имеющуюся сборку.

Скачать Mono.Cecil можно тут.

Сразу замечу, что модификация сторонней сборки, подписанной строгим именем, приведет к сбросу подписи со всеми вытекающими последствиями. После модификации сборку можно переподписать (тем же ключом, если у вас он есть, либо другим — если, например, необходимо положить сборку в GAC).

Небольшой пример


Сразу рассмотрим на примере использование возможностей Mono.Cecil. Предположим, у нас есть сторонняя сборка некоторого консольного приложения без исходного кода, в котором есть тип Program. У нас нет доступа к исходному коду, но мы хотим, чтобы данное приложение при вызове каждого метода выводило, на консоль некоторое сообщение. Для этого напишем собственное консольное приложение. В качестве аргумента при запуске будем передавать путь к целевому приложению:

using Mono.Cecil;
using Mono.Cecil.Cil;

class Program
{
  static void Main(string[] args)
  {
    if (args.Length == 0)
      return;
    string assemblyPath = args[0];
    // считываем сборку в формате Mono.Cecil
    var assembly = AssemblyDefinition.ReadAssembly(assemblyPath);
    // получаем метод Console.WriteLine, используя стандартные методы Reflection
    var writeLineMethod = typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) });
    // создаем ссылку на метод, полученный Reflection, для использования в Mono.Cecil
    var writeLineRef = assembly.MainModule.Import(writeLineMethod);
    foreach (var typeDef in assembly.MainModule.Types)
    {
      foreach (var method in typeDef.Methods)
      {
      // Для каждого метода в полученной сборке
      // Загружаем на стек строку "Inject!"
      method.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldstr, "Inject!"));
      // Вызываем метод Console.WriteLine, параметры он берет со стека - в данном случае строку "Injected".
      method.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Call, writeLineRef));
      }
    }
    assembly.Write(assemblyPath);
  }
}

При передаче пути к сторонней сборке нашему консольному приложению, оно добавит в начало каждого метода IL код, выводящий на консоль сообщение «Inject!», после чего сохранит измененную сборку. При запуске измененной сборки, каждый метод будет писать в консоль «Inject!».

Остановимся подробнее на вышеприведенном коде. Как известно, NET поддерживает множество языков программирования. Достигается это за счет того, что любой код на любом языке программирования компилируется в CIL — Common Intermediate Language — промежуточный язык. Почему промежуточный? Потому что после, код на CIL преобразуется в инструкции соответствующего процессора. Таким образом, код на любых языках компилируется в примерно одинаковый код CIL, засчет чего вы можете использовать, например, сборку на VB в вашем проекте на C#.

Таким образом, каждая сборка условно говоря представляет собой набор метаданных (которые, например, использует Reflection), и набор инструкций на CIL.

Я не буду останавливаться подробно на описании CIL, поскольку это не является темой данной статьи. Я ограничусь лишь тем, что будет важно для дальнейшего, а именно — некоторыми особенностями CIL-инструкций. Информацию о представлении метаданных и других инструкциях вы всегда можете найти в интернете.

Для начала рассмотрим часть кода из примера выше:
method.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldstr, "Inject!")); 
method.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Call, writeLineRef));

В данном коде, мы получили доступ к набору CIL-инструкций некоторого метода и добавляем свои. Набор инструкций CIL вы можете посмотреть здесь: . При прямой работе с CIL важное значение имеет стек. Мы можем помещать на стек некоторые данные и забирать их оттуда (по принципу работы стека). В примере выше, с помощью инструкции Ldstr мы помещаем строку «Inject!» на стек. Далее мы делаем вызов System.Console.WriteLine. Любой вызов метода обращается к стеку, чтобы получить необходимые аргументы. В данном случае System.Console.WriteLine нужен аргумент типа string, который мы и загрузили на стек. Инструкция call загружает аргументы с конца, таким образом, необходимо загружать значения аргументов на стек в обычном порядке. Таким образом, данная инструкция передаст управление методу System.Console.WriteLine c параметром типа string равным «Inject!». Данный набор инструкций эквивалентен:
System.Console.WriteLine("Injected!");


Поскольку Mono.Cecil воспринимает сборку как набор инструкций (байтов), то мы можем менять ее содержимое без каких либо ограничений. После добавления CIL кода мы сохраняем ее (как набор байтов) и получаем модифицированную сборку.

Реальное примение кодогенерации для реализации аспектно-ориентированного подхода



Рассмотрим применение указанного выше подхода к собственным сборкам. Очень часто нам хочется выполнять некоторый код при входе или выходе из какого-либо метода, причем иметь доступ к некоторым данным, описывающим метод, либо его контекст. Простейший пример — логгер. Если мы хотим записывать в лог вход и выход из каждого метода, то писать однообразный код в начале и конце каждого метода может быть весьма утомительно. Также, на мой взгляд, это несколько загрязняет код. Кроме того, мы не можем получить доступ к параметрам метода на стеке автоматически, и если мы хотим также записывать состояние параметров на входе — придется делать это вручную. Вторая известная проблема — это реализация INotifyPropertyChanged, который приходится прописывать каждому свойству вручную.

Рассмотрим другой подход. Для его тестирования создадим новое консольное приложение. Добавим класс:
[AttributeUsage(AttributeTargets.Method)]
 public class MethodInterceptionAttribute : Attribute
 {
   public virtual void OnEnter(System.Reflection.MethodBase method, Dictionary<string, object> parameters)
   {
     
   }

   public virtual void OnExit()
   {
     
   }
 }

Пользователь может унаследоваться от данного класса, переопределить метод OnEnter и применить унаследованный атрибут к любому методу. Наша цель — реализовать следующую возможность: при входе в метод, помеченный атрибутом типа MethodInterceptionAttribute вызывать OnEnter, куда передавать ссылку на метод и набор параметров этого метода в виде <имя_параметра: значение>.

Для экспериментов создадим два консольных приложения. Первое будет содержать определение атрибута и все методы, необходимые для инъекции кода в сторонние приложения. Второе приложение будет тестовым. Рассмотрим сначала короткий код тестового приложения:

class Program
  {
    static void Main(string[] args)
    {
      MethodToChange("Test");
    }

    [TestMethodInterception()]
    public static void MethodToChange(string text)
    {
      Console.ReadLine();
    }
  }

  public class TestMethodInterceptionAttribute : MethodInterceptionAttribute
  {
    public override void OnEnter(System.Reflection.MethodBase method, Dictionary<string, object> parameters)
    {
      Console.WriteLine("Entering method " + method.Name + "..." + Environment.NewLine);
      foreach (string paramName in parameters.Keys)
      {
        Console.WriteLine("Parameter " + paramName + " has value " + parameters[paramName] + Environment.NewLine);
      }
    }
  }

Это простое консольное приложение, которое вызывает метод MethodToChange с параметром text равным Test. Данный метод помечен атрибутом TestMethodInterceptionAttribute, унаследованным от MethodInterceptionAttribute. OnEnter переопределен для вывода на консоль информации о любых помеченных данным атрибутом методах. Без предварительно обработки, данное приложение при запуске вызовет Console.ReadLine и завершит работу.

Продолжим рассмотрение главного приложения (тоже консольного). Для демонстрации примера MSIL кода и для помощи в дальнейшей разработке напишем следующий вспомогательный метод:

static void DumpAssembly(string path, string methodName)
{
  System.IO.File.AppendAllText("dump.txt", "Dump started... " + Environment.NewLine);
  var assembly = AssemblyDefinition.ReadAssembly(path);
  foreach (var typeDef in assembly.MainModule.Types)
  {
    foreach (var method in typeDef.Methods)
    {
      if (String.IsNullOrEmpty(methodName) || method.Name == methodName)
      {
        System.IO.File.AppendAllText("dump.txt", "Method: " + method.ToString());
        System.IO.File.AppendAllText("dump.txt", Environment.NewLine);
        foreach (var instruction in method.Body.Instructions)
        {
          System.IO.File.AppendAllText("dump.txt", instruction.ToString() + Environment.NewLine);
        }
      }
    }
  }
}

Данный метод считывает имеющийся MSIL код у какого-либо метода сборки (или у всех) и записывает его в файл dump.txt. Чем это может быть полезно? Предположим, мы знаем, какой конкретно код мы хотим добавить в стороннюю сборку, но не хотим писать весь MSIL код с нуля. Тогда мы запишем этот код на C# в какой-либо свой метод и сделаем его дамп. После чего будет гораздо проще писать MSIL с помощью Mono.Cecil, уже имея готовый пример того, как он будет выглядеть (конечно, для просмотра MSIL кода сборок можно применять и другие, более удобные методы).

Рассмотрим, что же мы хотим получить в начале каждого метода (в виде С#):

var currentMethod = System.Reflection.MethodBase.GetCurrentMethod();
var attribute = (MethodInterceptionAttribute)Attribute.GetCustomAttribute(currentMethod, typeof(MethodInterceptionAttribute));
Dictionary<string, object> parameters = new Dictionary<string, object>();
// здесь нам нужно автоматически перебрать все параметры метода и записать их значения в parameters, чего используя стандартный С# сделать нельзя
attribute.OnEnter(currentMethod, parameters);

Часть дампа этого кода на MSIL:

IL_0000: nop
IL_0001: call System.Reflection.MethodBase System.Reflection.MethodBase::GetCurrentMethod()
IL_0006: ldtoken EmitExperiments.MethodInterceptionAttribute
IL_000b: call System.Type System.Type::GetTypeFromHandle(System.RuntimeTypeHandle)
IL_0010: call System.Attribute System.Attribute::GetCustomAttribute(System.Reflection.MemberInfo,System.Type)
IL_0015: castclass EmitExperiments.MethodInterceptionAttribute
IL_001a: stloc V_1
IL_001e: ldloc V_1
IL_0022: callvirt System.Void EmitExperiments.MethodInterceptionAttribute::OnEnter()
...

Далее я просто приведу полный код метода InjectToAssembly (с подробными комментариями), который добавит ко всем методам с MethodInterceptionAttribute указанной сборки необходимый код:

static void InjectToAssembly(string path)
    {
      var assembly = AssemblyDefinition.ReadAssembly(path);

      // ссылка на GetCurrentMethod()
      var getCurrentMethodRef = assembly.MainModule.Import(typeof(System.Reflection.MethodBase).GetMethod("GetCurrentMethod"));
      // ссылка на Attribute.GetCustomAttribute()
      var getCustomAttributeRef = assembly.MainModule.Import(typeof(System.Attribute).GetMethod("GetCustomAttribute", new Type[] { typeof(System.Reflection.MethodInfo), typeof(Type) }));
      // ссылка на Type.GetTypeFromHandle() - аналог typeof()
      var getTypeFromHandleRef = assembly.MainModule.Import(typeof(Type).GetMethod("GetTypeFromHandle"));
      // ссылка на тип MethodBase 
      var methodBaseRef = assembly.MainModule.Import(typeof(System.Reflection.MethodBase));
      // ссылка на тип MethodInterceptionAttribute 
      var interceptionAttributeRef = assembly.MainModule.Import(typeof(MethodInterceptionAttribute));
      // ссылка на MethodInterceptionAttribute.OnEnter
      var interceptionAttributeOnEnter = assembly.MainModule.Import(typeof(MethodInterceptionAttribute).GetMethod("OnEnter"));
      // ссылка на тип Dictionary<string,object>
      var dictionaryType = Type.GetType("System.Collections.Generic.Dictionary`2[System.String,System.Object]");
      var dictStringObjectRef = assembly.MainModule.Import(dictionaryType);
      var dictConstructorRef = assembly.MainModule.Import(dictionaryType.GetConstructor(Type.EmptyTypes));
      var dictMethodAddRef = assembly.MainModule.Import(dictionaryType.GetMethod("Add"));
      foreach (var typeDef in assembly.MainModule.Types)
      {
        foreach (var method in typeDef.Methods.Where(m => m.CustomAttributes.Where(
          attr => attr.AttributeType.Resolve().BaseType.Name == "MethodInterceptionAttribute").FirstOrDefault() != null))
        {
          var ilProc = method.Body.GetILProcessor();
          // необходимо установить InitLocals в true, так как если он находился в false (в методе изначально не было локальных переменных)
          // а теперь локальные переменные появятся - верификатор IL кода выдаст ошибку.
          method.Body.InitLocals = true; 
          // создаем три локальных переменных для attribute, currentMethod и parameters
          var attributeVariable = new VariableDefinition(interceptionAttributeRef);
          var currentMethodVar = new VariableDefinition(methodBaseRef);
          var parametersVariable = new VariableDefinition(dictStringObjectRef);
          ilProc.Body.Variables.Add(attributeVariable);
          ilProc.Body.Variables.Add(currentMethodVar);
          ilProc.Body.Variables.Add(parametersVariable);
          Instruction firstInstruction = ilProc.Body.Instructions[0];
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Nop));
          // получаем текущий метод
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Call, getCurrentMethodRef));
          // помещаем результат со стека в переменную currentMethodVar
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Stloc, currentMethodVar));
          // загружаем на стек ссылку на текущий метод
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldloc, currentMethodVar));
          // загружаем ссылку на тип MethodInterceptionAttribute
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldtoken, interceptionAttributeRef));
          // Вызываем GetTypeFromHandle (в него транслируется typeof()) - эквивалент typeof(MethodInterceptionAttribute)
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Call, getTypeFromHandleRef));
          // теперь у нас на стеке текущий метод и тип MethodInterceptionAttribute. Вызываем Attribute.GetCustomAttribute
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Call, getCustomAttributeRef));
          // приводим результат к типу MethodInterceptionAttribute
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Castclass, interceptionAttributeRef));
          // сохраняем в локальной переменной attributeVariable
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Stloc, attributeVariable));
          // создаем новый Dictionary<stirng, object>
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Newobj, dictConstructorRef));
          // помещаем в parametersVariable
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Stloc, parametersVariable));
          foreach (var argument in method.Parameters)
          {
            //для каждого аргумента метода
            // загружаем на стек наш Dictionary<string,object>
            ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldloc, parametersVariable));
            // загружаем имя аргумента
            ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldstr, argument.Name));
            // загружаем значение аргумента
            ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldarg, argument));
            // вызываем Dictionary.Add(string key, object value)
            ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Call, dictMethodAddRef));
          }
          // загружаем на стек сначала атрибут, потом параметры для вызова его метода OnEnter
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldloc, attributeVariable));
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldloc, currentMethodVar));
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldloc, parametersVariable));
          // вызываем OnEnter. На стеке должен быть объект, на котором вызывается OnEnter и параметры метода
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Callvirt, interceptionAttributeOnEnter));
        }
      }
      assembly.Write(path);
    }
  }

Не забудем также про метод Main нашего консольного приложения:

static void Main(string[] args)
{
  if (args.Length > 0)
  {
    string mode = args[0];
    string path = args[1];
    if (mode == "-dump")
    {
      string methodName = args.Length > 2 ? args[2] : String.Empty;
      DumpAssembly(path, methodName);
    }
    else if (mode == "-inject")
    {
      InjectToAssembly(args[1]);
    }
  }
}

Готово! Теперь, если запустить главное приложение с параметром -inject, передав ему путь к нашему тестовому приложению, код метода MethodToChange изменится следующим образом (получено с помощью Reflector):

[TestMethodInterception]
public static void MethodToChange(string text)
{
    MethodBase currentMethod = MethodBase.GetCurrentMethod();
    MethodInterceptionAttribute customAttribute = (MethodInterceptionAttribute) Attribute.GetCustomAttribute(currentMethod, typeof(MethodInterceptionAttribute));
    Dictionary<string, object> parameters = new Dictionary<string, object>();
    parameters.Add("text", text);
    customAttribute.OnEnter(currentMethod, parameters);
    Console.ReadLine();
}


Что и требовалось. Теперь, каждый метод, помеченный TestMethodInterception, будет перехвачен и каждый его вызов будет обработан без написания большого количества повторяющегося кода. Для автоматизации процесса, можно использовать Post-Build события в VS, таким образом, чтобы после успешного построения какого-либо проекта автоматически обрабатывать готовую сборку и внедрять код на основе атрибутов. Также можно создать атрибуты уровня класса или сборки, чтобы внедрить код сразу во все методы класса или сборки.

Данный подход является примером использования методик аспектно-ориентированного программирования в .NET. Я не буду останавливаться подробно на том, что такое АОП, в общих чертах об этом всегда можно почитать на википедии. Наиболее известной библиотекой, позвояющей использовать принципы АОП в .NET является PostSharp, который как раз и вдохновил меня на изучение возможностей инъекции кода в свои сборки для реализации подобной функциональности и соответственно написание этой статьи.

Использование АОП позволяет писать чистый и легко поддерживаемый код, в основном засчет того, что большая часть кода генерируется автоматически на основе аспектов.

В этой статье я попытался подробно описать, как можно добавлять код к существующим NET сборкам с помощью Mono.Cecil, а также как это может применятся с пользой для реализации принципов АОП в Net, надеюсь, многим это покажется интересным или окажется полезным.
Tags:
Hubs:
+41
Comments 14
Comments Comments 14

Articles