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