Back to Home

Injecting MSIL code into a third-party assembly using Mono.Cecil. Implementing AOP principles in NET

MSIL · IL · NET · C · AOP · Mono.Cecil · code generation

Injecting MSIL code into a third-party assembly using Mono.Cecil. Implementing AOP principles in NET

Introduction


In this article, I’ll talk about how you can add your code to existing .NET assemblies and how it relates to aspect-oriented programming. The article will be accompanied by working examples, since I believe that code is the best way to convey an idea.

Many .NET developers know that Reflection can be used to access objects of another assembly. Using types from System.Reflection, we can access many .NET objects in the assembly, view their metadata, and even use those objects that are restricted to access (for example, private methods of another class). But the use of Reflection has its limitations, and the main reason for this is that the data that you work with through Reflection is still considered code. Thus, for example, you can get a CodeAccessSecurity exception if the assembly to which you are trying to apply Reflection prohibits this. For the same reason, Reflection is pretty slow. But the most important thing for this article is that standard Reflection does not allow you to modify existing assemblies, only generate and save new ones.

Mono.Cecil


A completely different approach is offered by the free open source library Mono.Cecil. The main difference between the Mono.Cecil approach and the Reflection approach is that this library works with the NET assembly as a stream of bytes. When loading the assembly, Mono.Cecil parses the PE header, CLR header, MSIL code for classes and methods, etc. working directly with the byte stream representing the assembly. Thus, with the help of this library, you can modify the existing assembly as you wish (within the limits provided).

You can download Mono.Cecil here .

Immediately, I note that a modification of a third-party assembly signed with a strong name will lead to a reset of the signature with all the ensuing consequences. After modification, the assembly can be re-signed (with the same key, if you have one, or with another - if, for example, you need to put the assembly in the GAC).

A small example


Just look at an example using the capabilities of Mono.Cecil. Suppose we have a third-party assembly of some console application without source code, in which there is a type Program. We do not have access to the source code, but we want this application to output a message to the console when each method is called. To do this, we will write our own console application. As an argument at startup, we will pass the path to the target application:

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);
  }
}

When you transfer the path to a third-party assembly to our console application, it will add code to the beginning of each IL method that displays the message “Inject!” To the console, and then save the modified assembly. When you start a modified assembly, each method will write to the “Inject!” Console.

Let us dwell on the above code. As you know, NET supports many programming languages. This is achieved due to the fact that any code in any programming language is compiled into CIL - Common Intermediate Language - an intermediate language. Why in between? Because after, the CIL code is converted into instructions of the corresponding processor. Thus, the code in any languages ​​is compiled into approximately the same CIL code, due to which you can use, for example, the assembly on VB in your C # project.

Thus, each assembly conditionally speaking is a set of metadata (which, for example, uses Reflection), and a set of instructions in CIL.

I will not dwell on the description of CIL, since this is not the topic of this article. I will limit myself to what will be important for the future, namely, to some features of CIL instructions. You can always find information on the presentation of metadata and other instructions on the Internet.

To get started, consider the code part from the example above:
method.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldstr, "Inject!")); 
method.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Call, writeLineRef));

In this code, we got access to a set of CIL instructions of some method and add our own. You can see the CIL instruction set here:. When working directly with CIL, the stack is important. We can put some data on the stack and take it from there (according to the principle of the stack). In the example above, using the Ldstr statement, we put the line “Inject!” onto the stack. Next we make a call to System.Console.WriteLine. Any method call accesses the stack to get the necessary arguments. In this case, System.Console.WriteLine needs an argument of type string, which we loaded onto the stack. The call statement loads arguments from the end, so you need to load the argument values ​​onto the stack in the usual way. Thus, this instruction will transfer control to the System.Console.WriteLine method with a parameter of type string equal to “Inject!”.
System.Console.WriteLine("Injected!");


Since Mono.Cecil perceives the assembly as a set of instructions (bytes), we can change its contents without any restrictions. After adding the CIL code, we save it (as a set of bytes) and get a modified assembly.

The real application of code generation for implementing an aspect-oriented approach



Consider applying the above approach to your own assemblies. Very often, we want to execute some code when entering or exiting a method, and have access to some data describing the method, or its context. The simplest example is a logger. If we want to log the input and output of each method in the log, then writing monotonous code at the beginning and end of each method can be very tiring. Also, in my opinion, this is somewhat dirty code. In addition, we cannot access the parameters of the method on the stack automatically, and if we also want to record the state of parameters at the input, we will have to do this manually. The second known problem is the implementation of INotifyPropertyChanged, which has to be manually assigned to each property.

Consider a different approach. To test it, create a new console application. Add a class:
[AttributeUsage(AttributeTargets.Method)]
 public class MethodInterceptionAttribute : Attribute
 {
   public virtual void OnEnter(System.Reflection.MethodBase method, Dictionary parameters)
   {
   }
   public virtual void OnExit()
   {
   }
 }

The user can inherit from this class, override the OnEnter method, and apply the inherited attribute to any method. Our goal is to realize the following opportunity: when entering a method marked with an attribute of the MethodInterceptionAttribute type, call OnEnter, where to pass a reference to the method and a set of parameters of this method in the form <parameter name: value>.

For experiments, we will create two console applications. The first will contain an attribute definition and all the methods needed to inject the code into third-party applications. The second application will be test. First, consider the short code of the test application:

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 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);
      }
    }
  }

This is a simple console application that calls the MethodToChange method with the text parameter equal to Test. This method is marked with the TestMethodInterceptionAttribute attribute inherited from MethodInterceptionAttribute. OnEnter is redefined to display information on the console about any methods marked with this attribute. Without pre-processing, this application at startup will call Console.ReadLine and exit.

We continue to consider the main application (also console). To demonstrate an example of MSIL code and to assist in further development, we will write the following helper method:

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);
        }
      }
    }
  }
}

This method reads the existing MSIL code from a build method (or all) and writes it to dump.txt. How can this be useful? Suppose we know what specific code we want to add to a third-party assembly, but do not want to write all the MSIL code from scratch. Then we will write this code in C # to some of our methods and dump it. After that it will be much easier to write MSIL using Mono.Cecil, already having a ready-made example of how it will look (of course, other more convenient methods can be used to view the MSIL assembly code).

Consider what we want to get at the beginning of each method (in the form of C #):

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

Part of the dump of this code on MSIL: Next, I will simply give the full code of the InjectToAssembly method (with detailed comments), which will add the necessary code to all methods with the MethodInterceptionAttribute of the specified assembly:

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()
...



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
      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
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Newobj, dictConstructorRef));
          // помещаем в parametersVariable
          ilProc.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Stloc, parametersVariable));
          foreach (var argument in method.Parameters)
          {
            //для каждого аргумента метода
            // загружаем на стек наш Dictionary
            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);
    }
  }

Do not forget about the Main method of our console application:

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]);
    }
  }
}

Done! Now, if you run the main application with the -inject parameter, passing it the path to our test application, the code of the MethodToChange method will change as follows (obtained using Reflector):

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


Which was required. Now, each method marked with TestMethodInterception will be intercepted and each call will be processed without writing a lot of repeated code. To automate the process, you can use Post-Build events in VS, so that after the successful construction of a project, it automatically processes the finished assembly and embeds the code based on the attributes. You can also create class or assembly level attributes to embed code into all class or assembly methods at once.

This approach is an example of the use of aspect-oriented programming techniques in .NET. I will not dwell on what AOP is, in general terms you can always read about it on Wikipedia. The most famous library that allows you to use the principles of AOP in .NET is PostSharp , which inspired me to study the possibilities of injecting code into our assemblies to implement similar functionality and, accordingly, writing this article.

Using AOP allows you to write clean and easily maintained code, mainly because most of the code is automatically generated based on aspects.

In this article, I tried to describe in detail how you can add code to existing NET assemblies using Mono.Cecil, as well as how it can be used to implement AOP principles in Net, I hope many people find this interesting or useful.

Read Next