Post

Confusing .NET Decompilers: The Call OpCode

The call and callvirt opcodes are arguably two of the most commonly used operations in the Common Intermediate Language (CIL). Yet, they have some interesting properties that are often overlooked.

Here’s a question: Given two classes A and B, each defining a Foo method that simply returns its name as a string, can you predict the outcome of the following program? What does it output, and will it complete or throw an exception before it reaches the end?

Can you guess what the output is of this program?

The answer may surprise you, but only the last line of Main throws an exception, and before it does, the program outputs the following:

1
2
3
4
5
6
7
8
Z:\> original.exe
This is B::Foo()
This is A::Foo()
This is A::Foo()
This is B::Foo()

Unhandled Exception: System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.Main()

In this post we will dive deep into how we can construct code like this, mess around with decompilers and thus throw off reverse engineers. There are no JIT compiler hacks required, nor do we need any tricks to modify the code at runtime. All we need is some clever usage of the ordinary call opcode.

As a quick disclaimer: a lot of the things discussed here are very much not according to the official specification of .NET. I would therefore not recommended this for production use, as implementations of the runtime can (and will) change all the time.

A refresher on .NET’s invocation opcodes

The .NET specification defines 4 different types of opcodes that transfer control from one function to another:

OpCode Description
call Call a method by metadata token
callvirt Call a virtual method by metadata token, keeping overrides into account
calli Call a function by its entry point memory address
jmp Call a method by metadata token, but disregard the current stack frame

Typically, out of these four, you will only see instances of call and callvirt when disassembling an application written in C## or VB.NET. You may see one or two instances of calli if the binary was written in C++/CLI, or if it makes use of the niche delegate* syntax that C## 9.0 brought us. jmp is as of writing this post never really emitted by any compiler itself, and only appears when you manually craft some CIL code yourself. Thus, for our intents and purposes, we will mainly focus on the first two opcodes in this article. Besides, as we will see, there is enough fun stuff you can do with just these two seemingly innocent opcodes.

Example usage of call and callvirt

Let’s start with a simple example that illustrates the purpose of these two opcodes. Consider the following C## snippet that invokes a static method and an instance method:

1
2
3
4
public static void MyMethod(object x)
{
    Console.WriteLine(x.ToString());
}

This snippet might be compiled to something like the following CIL sequence:

1
2
3
4
5
6
7
.method public static hidebysig void MyMethod(object x)
{
    ldarg.0                                                         // x
    callvirt instance string [mscorlib] System.Object::ToString()   // .ToString()
    call void [mscorlib] System.Console::WriteLine(string)          // Console.WriteLine(...)
    ret
}

We start by pushing the value of parameter x onto the stack using ldarg.0. We then continue by invoking the Object::ToString() instance method. We use callvirt for this because ToString() is a virtual method that may or may not be overridden by the object that it is invoked on (in our case, the object stored in parameter x). By using callvirt, we tell the runtime to dynamically resolve and invoke the “latest” implementation of the method referenced in the operand of the instruction. This process is also known as virtual dispatch and is one of the fundamental operations that exists in an object-oriented language.

This is in contrast with the invocation of Console::WriteLine(string). In this case, since this method is a static method, it cannot be overridden by any other method nor is it attached to any other object or interface. Thus, we don’t need any virtual dispatch provided by callvirt and can just stick with a normal direct call instead.

Invoking virtual methods using call

So is callvirt always used for calling virtual methods and call only for static methods?

Not quite! If you have ever used the base keyword in C#, you will know that it is used to access the implementation of a base class. Consider the following example:

1
2
3
4
5
6
7
8
9
public class A 
{
    public virtual string Foo() => "This is A::Foo()";
}

public class B : A
{
    public override void Foo() => "This is B::Foo()\n" + base.Foo();
}

Here we have a class B that extends class A and overrides method Foo(). In the new implementation, we load some string ("This is B::Foo()\n"), and then concatenate it with the result of the original implementation of A::Foo(). Effectively, we are prepending a line to the original return value.

If we try to initialize a variable of type A with an instance of type B, and call Foo() on it:

1
2
3
4
5
public static void Main()
{
    A a = new B();
    Console.WriteLine(a.Foo());
}

We get our expected output:

1
2
3
Z:\> test.exe
This is B::Foo()
This is A::Foo()

If we inspect the CIL implementation of our little program in a disassembler, we can see that indeed B::Foo() is invoking the base method A::Foo() using the call opcode instead of callvirt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// void B::Foo()
.method public virtual hidebysig string Foo()
{
    // Push the new string
    ldstr "This is B::Foo()\n"

    // Call the original A::Foo() using the `call` opcode.
    ldarg.0
    call instance string A::Foo()

    // Concatenate results
    call string [mscorlib] System.String::Concat(string, string)

    // Return
    ret
}

Why? Because in this case, we always intend to call A::Foo(), the original implementation. There is no need for the runtime to try and look up any overridden method, and thus a normal direct call suffices. In fact, if you replace it with a callvirt, the runtime would try to look up the most recent version of A::Foo(), which is B::Foo() itself. It would be the equivalent of replacing base with the this keyword in C#, causing an infinite recursive loop, and a nice stack overflow exception!

1
2
3
4
Z:\> test-using-callvirt.exe

Process is terminated due to StackOverflowException.

Strategically using call over callvirt

Now that we have a basic grasp of how these two opcodes work, let’s have some fun. What we’re going to do in the next few snippets, is (ab)use this subtle difference between call and callvirt to throw off many decompilers and human reverse engineers.

Confusing decompilers

Let’s start easy, and look back one more time at our Main function. If we inspect its CIL representation in a disassembler, it may look like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.method public static void Main()
{
    .entrypoint
    .locals init (
        [0] class A
    )
    
    // A a = new B();
    newobj instance void B::.ctor()         
    stloc.0
    
    // Console.WriteLine(a.Foo());
    ldloc.0
    callvirt instance string A::Foo()
    call void [mscorlib] System.Console::WriteLine(string)

    ret
}

We create an object using newobj and store it in local 0 using stloc.0. We then load the variable again using ldloc.0, and call the virtual A::Foo() method on it with callvirt. Finally, we print the result to the standard output.

Opening it in a decompiler such as ILSpy, we get exactly our original C## code back:

Calling A::Foo() using callvirt.

What would happen if we change the callvirt to a call?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.method public static void Main()
{
    .entrypoint
    .locals init (
        [0] class A
    )

    // A a = new B();
    newobj instance void B::.ctor()
    stloc.0

    // Console.WriteLine(a.Foo());
    ldloc.0
    call instance string A::Foo() // <--- changed from `callvirt` to `call`    
    call void [mscorlib] System.Console::WriteLine(string)

    ret
}

We learned in the previous section that unlike callvirt, call does not try to dereference the parent object to look up the most recent implementation of A::Foo(). As such, A::Foo() will be called even though the value of variable a is actually of type B, and B overrides Foo(). And indeed, the output of the program changes:

1
2
Z:\> test.exe
This is A::Foo()

However, we get the following output in the decompiler:

Calling A::Foo() using call.

No difference from the original.

And that is because a construction like this does not exists in C#. In C#, whenever you make any call on an object, the compiler will always assume you want the latest implementation of a method unless you use the base keyword. But since you cannot use base outside of the class that is implementing the base method you want to call, the compiler will always emit a callvirt.

This has some interesting implications for all reverse engineers that only look at the C## decompiler output and disregard the raw CIL code (which is roughly 99% of all .NET reversers [citation needed]). If they copy the decompiled code into their editor of choice and hit compile, they will get different results:

1
2
3
4
5
6
7
8
Z:\> original.exe
This is A::Foo()

Z:\> cd decompiled

Z:\decompiled> dotnet run
This is B::Foo()
This is A::Foo()

You can use this to detect that a program was decompiled and recompiled, and silently let the decompiled program do something else than what the original program used to do. For instance, let’s change the implementation of our classes a bit:

1
2
3
4
5
6
7
8
9
public class A 
{
    public virtual string Foo() => "All good :)";
}

public class B : A
{
    public override string Foo() => "Hey I don't like being decompiled >:(";
}

If we run the program as-is, we get our expected result.

1
2
Z:\> original.exe
All good :)

But if we decompile, and recompile back, we get a different result:

1
2
3
4
Z:\> cd decompiled

Z:\decompiled> dotnet run
Hey I don't like being decompiled >:(

Pretty nifty!

Confusing System.Reflection

We’re not done yet.

This redirection is also happening if we try to call the A::Foo() method using System.Reflection. This is particularly interesting for people that write or use deobfuscators based on dynamic analysis, emulation, or de4dot’s generic deobfuscator.

Let’s for example consider the case where A::Foo() and B::Foo() are part of a string decryption routine in an obfuscated binary:

1
2
3
4
5
// Original code:
public static void Main() 
{
    Console.WriteLine("All good :)");
}
1
2
3
4
5
6
// Obfuscated code:
public static void Main() 
{
    A a = new B();
    Console.WriteLine(a.Foo());
}

A reverse engineer might consider using System.Reflection to emulate (parts of) the CIL method body, and dynamically resolve all obfuscated strings this way. A naive, dumbed-down implementation of such a CIL “emulator” that attempts to emulate the original behavior of our running example can be seen below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void Main()
{
    var assembly = Assembly.LoadFrom(@"Z:\original.exe");

    // Emulate the `newobj instance void B::.ctor()`.
    var bType = assembly.ManifestModule.GetType("B");
    var instance = Activator.CreateInstance(bType);

    // Emulate the `call instance string A::Foo()`.
    var method = assembly.ManifestModule.GetType("A").GetMethod("Foo");
    string result = method.Invoke(instance, null);

    // Print result.
    Console.WriteLine(result);
}

We first emulate the newobj instruction using Activator.CreateInstance, and then use MethodBase::Invoke to emulate the call opcode. This is a pretty typical implementation for many deobfuscators that are out there. And it makes sense because we are specifically looking up and invoking A::Foo() using the GetType and the GetMethod methods. However, when we run this…

1
2
Z:\reflection-test> dotnet run
Hey I don't like being decompiled >:(

… we get the output as if it were calling B::Foo(). This means that MethodBase::Invoke is a callvirt and not a call! Only if you are very deliberately calling the very specific A::Foo() method, using e.g., a DynamicMethod where you specifically emit a call instead of a callvirt, this will succeed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void Main()
{
    var assembly = Assembly.LoadFrom(@"Z:\original.exe");

    // Emulate the `newobj instance void B::.ctor()`.
    var bType = assembly.ManifestModule.GetType("B");
    var instance = Activator.CreateInstance(bType);

    // Emulate the `call instance string A::Foo()`.
    var method = assembly.ManifestModule.GetType("A").GetMethod("Foo");
    string result = Call(method, instance);

    // Print result.
    Console.WriteLine(result);
}

private static string Call(MethodInfo target, object instance)
{
    // Create a new proxy method with exactly the same return type and parameters.
    var proxy = new DynamicMethod("Proxy", 
        typeof(string),
        new[] { typeof(object) },
        typeof(Program).Module);

    // Manually emit the `call` opcode to be sure we do not do virtual-dispatch.
    var cil = proxy.GetILGenerator();
    cil.Emit(OpCodes.Ldarg_0);
    cil.Emit(OpCodes.Call, target);
    cil.Emit(OpCodes.Ret);

    // Call it
    var proxyDelegate = (Func<object, string>) proxy.CreateDelegate(typeof(Func<object, string>));
    return proxyDelegate(instance);
}
1
2
Z:\reflection-test> dotnet run
All good :)

Of course, this is much more work and error-prone, especially if the target method has many parameters and or generic types associated to it. Most generic string deobfuscators are based on System.Reflection and use the normal MethodBase::Invoke strategy [citation needed]. Consequently, with this trick, they all would produce the wrong results. The almighty de4dot does use a dynamic method for its generic string decrypter, but only supports static methods out of the box. Calls like the ones presented here would therefore not be considered, even if we explicitly define the string decrypter method token using the --strtok command-line parameter.

Null calls

Let’s make it even worse.

We learned that the this keyword translates to the ldarg.0 opcode in CIL. After all, it is just a reference to a parameter that is implicitly defined for all instance methods. However, we also learned that if we use call, then we do not do any virtual dispatch, and thus do not use the value stored in the this object to look up the function that we want to call.

If we don’t need the this pointer anymore anyways, why do we initialize it in the first place? What happens if we just replace the variable with null instead?

1
2
3
4
5
6
7
8
9
.method public static void Main()
{
    .entrypoint

    ldnull // <-- push null instead of an initialized object.
    call instance string A::Foo()
    call void [mscorlib] System.Console::WriteLine(string)
    ret
}

The decompiler output starts to get weird:

Calling A::Foo() using call with a null object.

Surely, from the looks of the decompiled code C#, this should throw a NullReferenceException

1
2
Z:\>original.exe
All good :)

Except it doesn’t, because there is no dereference happening at all with the call opcode, and thus the null reference is completely ignored. And indeed, if we try to decompile and recompile this program, the compiler inserts a callvirt again, which means the program will try to dereference null, and thus throw our expected NullReferenceException:

1
2
3
4
Z:\decompiled> dotnet run

Unhandled Exception: System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.Main()

System.Reflection breaks as well since MethodBase::Invoke expects a non-null this parameter when invoking instance methods.

1
2
3
4
5
6
Z:\reflection-test> dotnet run
Unhandled exception. System.Reflection.TargetException: Non-static method requires a target.
   at System.Reflection.MethodBase.ValidateInvokeTarget(Object target)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.MethodBase.Invoke(Object obj, Object[] parameters)
   at Program.Main() in Z:\Program.cs:line 17

To solve this, you would again need to create a new DynamicMethod to specifically emit the ldnull or ldarg.0 followed by the call opcodes to emulate this behavior properly.

Lessons learned and final words

Even though C## may present itself as a mostly type-safe language, CIL definitely is not. We have seen that we can abuse the subtle differences between call and callvirt to fool decompilers and reverse engineers. By cleverly specifying the right operands in our call instructions, we were able to trick the decompiler into outputting code that once recompiled yields different results from the original code. Furthermore, we also have seen some hints of how complicated making an accurate emulator for the CIL language really is, making the development of automatic deobfuscators a very complex task.

So far we only looked into the call opcode extensively. However, as it turns out, this is not the only opcode that has some interesting unintended implementation details. In a future post, we will be taking another deep dive and look at the inner workings of the callvirt opcode as well, and exploit some of the design choices that the .NET runtime team have made.

This post is licensed under CC BY 4.0 by the author.