photo of cryptic character codes and magnifying glass on table top

Building a .Net byte code inspector

Lately, I have been experimenting with .Net byte code modifications and verifying if the resulting code reflected my changes.
For that, the most straightforward way to validate this was to look at the corresponding byte code.

After some initial research, I found that libraries are available that provide insight into a .dll or .exe file beyond the standard .Net Reflection mechanisms, specifically Mono.Cecil.

Getting Started

Create a new C# project on your computer:

$ dotnet new console -o ilspy

The console template does not come with a .gitignore file out of the box. Therefore, we need to create that too:

$ cd ilspy
$ dotnet new gitignore

Open the new project in your editor or IDE (I’m using VSCode):

$ code .

Let’s get started to build the project. In general, when the user calls ilspy, we accept a path to the assembly to inspect via command-line arguments. Let’s validate the user’s input first:

using System;

namespace ilspy
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("ilspy");
            if (args.Length < 1)
            {
                Console.Error.WriteLine("Expected Assembly path. Got: None");
                Console.Error.WriteLine("Usage: ilspy <assembly path>");
                return;
            }
        }
    }
}

If we don’t get the assembly path, there’s nothing more to do, and we exit the program. Now, let’s add Mono.Cecil and start building the first feature.

Run:

$ dotnet add package mono.cecil

Some house-keeping: add Mono.Cecil and these other namespaces to your imports:

using System;
using System.Linq;       //add this
using Mono.Cecil;       //add this
using Mono.Cecil.Rocks; //add this

Below the Main method, create ShowAllTypes:


private static void ShowAllTypes(ModuleDefinition module)
{
    //Returns a list of all classes that are present in the assembly
    var allTypes = module.GetTypes();

    foreach (var typeDefinition in allTypes)
    {
        Console.WriteLine($"{typeDefinition.Name}");
        //`typeDefinition.GetMethods()` returns a list of all methods for this class
        foreach (var methodDefinition in typeDefinition.GetMethods())
        {
            Console.WriteLine($"{typeDefinition}.{methodDefinition.Name}");
        }
    }
}

It accepts our assembly (that’s what ModuleDefinition holds) and output all its types and methods.

Back in Main, below the if statement, let’s start by loading the assembly and its types and then calling our new Method:


var assemblyPath = args[0];

//Read the assembly from disk
var module = ModuleDefinition.ReadModule(assemblyPath);
ShowAllTypes(module);

Now it’s time to test our creation. Let’s build our program first:

$ dotnet build

Once that is finished successfully, we inspect our first assembly:

$ dotnet run bin/Debug/net5.0/Mono.Cecil.dll
ilspy
<Module>
Consts
Disposable
Mono.Disposable.Owned
Mono.Disposable.NotOwned
Disposable`1
Mono.Disposable`1.Dispose
Empty`1
ArgumentNullOrEmptyException
...

In this case, we’re looking at Mono.Cecil. The list is rather long, but we can confirm it works.

Now, as a next step, we’d like to show the IL-Code for a specific method. For that, we need the user to tell us first what class and method they’d like to inspect.

Let’s enhance our program to accept another parameter. Update the usage output as follows:

if (args.Length < 1)
{
    Console.Error.WriteLine("Expected Assembly path. Got: None");
    Console.Error.WriteLine("Usage: ilspy <assembly path> [<class.method>]"); //Update this line
    return;
}

Next, we check if the user has provided us with another parameter:

var assemblyPath = args[0];
string className = null;  //add this line
string methodName = null; //add this line

//add this
//Let's check if the user provided us with a class and method name to inspect
if (args.Length > 1)
{
    //We require the user to use a `Class.Method` notation
    className = args[1].Split(".")[0];
    methodName = args[1].Split(".")[1];
}

Now, based on the user’s input, we need to decide if we need to show all types as before or a specific one:

var module = ModuleDefinition.ReadModule(assemblyPath);

//Add these lines
//Check if we received class and method name
if (String.IsNullOrEmpty(className) && string.IsNullOrEmpty(methodName))
{
    //If not, we just display all types as usual
    ShowAllTypes(module);
}
else
{
    //Show details for this specific type
    ShowSpecificType(module, className, methodName);
}

We introduced a new method, ShowSpecificType, which we haven’t declared yet. Let’s take care of that. Declare it below Main:

private static void ShowSpecificType(ModuleDefinition module, string className, string methodName)
{
    //We find the specific class by its name
    var classType = module.GetTypes().FirstOrDefault(t => t.Name == className);
    //Then, we extract the specific method from that class
    var methodType = classType.GetMethods().FirstOrDefault(m => m.Name == methodName);

    if (methodType != null)
    {
        //Print the method body's instructions
        foreach (var instruction in methodType.Body.Instructions)
        {
            Console.WriteLine($"{instruction}");
        }
    }
    else
    {
        Console.Error.WriteLine("Couldn't find method definition");
    }
}

This method is similar to the previous one we wrote before, as we’re loading the class and method type. What’s new, however, is that now we’re accessing the method’s Body and instructions.

Let’s have a look at how printing the byte-code looks like for one of Cecil’s types:

$ dotnet run bin/Debug/net5.0/Mono.Cecil.dll SymbolProvider.GetSymbolType
IL_0000: ldarg.1
IL_0001: call System.Type System.Type::GetType(System.String)
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldnull
IL_0009: call System.Boolean System.Type::op_Inequality(System.Type,System.Type)
IL_000e: brfalse.s IL_0012
IL_0010: ldloc.0
IL_0011: ret
...

This project is just a starting point for additional features and enhancements, such as checking if a method or class has specific attributes applied or converting byte code back into C#.

Source Code

Find the source code here

Leave a Reply