Debugging Dot Net binaries

This article was published on the 27th of June 2021.

Debugging malware is an efficient way to observe the program’s behaviour. When looking at binaries that are written for the Dot Net platform, one can decompile the intermediate language back to Visual Basic or C#. This article provides information how one can debug a sample, some useful tips when debugging, and several ways to handle (in-memory) dependencies.

More information about the Dot Net framework can be found here.

Table of contents

Used tooling

Different debuggers are available, such as dnSpy and dotPeek. In this article, dnSpy will be used. Features similar to those that are discussed in this article, are (likely) available in other tools.

Attaching the debugger

When debugging, one can attach the the debugger to the debuggee at different moments in time. Generally, one will load the sample via the debugger, thus attaching the debugger from the moment the process is running. It is also possible to attach a debugger to an already running process, which is useful when the program cannot be debugged from the start. Several ways to debug a sample via dnSpy are shown in the screenshot below.

One such case where a later attachment made it possible to debug a sample, was in bmphide, a challenge from FireEye’s sixth annual Flare-On challenge series. This program contains anti-debugging methods, which causes a crash when attaching the debugger from the start. If one were to wait a bit and attach the debugger at a later stage, the anti-debugging checks have completed, allowing the debugger to work as intended. In this specific case, several functions within the program were hooked, meaning the decompiled code does not reflect the in-memory situation, but jumps are still taken correctly when stepping into function calls.

Setting the program counter

As with other debuggers, such as x64dbg, it is possible to alter which instruction is executed next. Skipping function calls, or repeatedly executing the same function, might lead to unexpected behaviour, such as a crash. Crashes are not caused by the debugger, but rather when code is executed in an incorrect order. Skipping a function call that initialises a field, will cause a NullReferenceException when said object is used later on.

Changing the instructions that will be executed can be done by right clicking the target line and selecting Set Next Statement in dnSpy. Alternatively, one can use CTRL + SHIFT + F10 after the target line has been selected. The screenshot below shows this functionality. Note that in this image, the execution would skip line 13 and 14, and would execute line 15.

Handling dependencies

Malware often uses additional stages by loading Dot Net based executables or libraries. In practice, the code from the newly loaded dependency is merged into the current process’ AppDomain. As such, the newly loaded module also has access to the codebase of the module that loaded it. Not all malware samples tie modules together like this, leaving the analyst with two options: dumping a module or following the module in-memory.

Dumping modules

Malware often loads a new module via Assembly.Load, which has various overloads that require a different arguments, such as a byte array, or the name of an assembly as a string. In dnSpy, one can save the byte array to the disk, which can then be analysed. Saving the array to the disk whilst debugging is done by finding the byte array in unencrypted form, going to the Locals tab at the bottom, right clicking the byte array, and pressing Save. An arbitrary name can be given to the file, though it is common practice to avoid using functioning extensions to avoid accidental execution of a dumped sample. The screenshot below shows how to save a local variable during debugging.

The function that is called does not need to be the entry point of the binary, nor does there need to be an entry point (in the case of a dynamic link library, or DLL in short). One can load a DLL into a custom C# program, where the function that was loaded can be called using reflection. The code snippet below provides a rudimentary way to load a file and invoke a function. Changes may be required depending on the function’s specifics. The function in the example is a static void that requires a string as its sole argument.

byte[] raw = File.ReadAllBytes(@"C:\Repos\AppToInject\AppToInject\bin\Debug\AppToInject.exe");
Assembly assembly = Assembly.Load(raw);
 
Type type = assembly.GetType("AppToInject.Program");
type.InvokeMember("CallMeMaybe", BindingFlags.InvokeMethod, null, type, new object[] { @"I am passed via reflection!" });

The code above loads a file from the disk, which is then loaded as an Assembly. Malware generally obtains the required byte array in a different way, often decrypting it in-memory. For the sake of this example, the way the byte array is obtained is irrelevant. After the file is loaded into an Assembly object, the required type can be obtained, which is then instantiated. The CallMeMaybe function is then invoked, with a single argument. In this case, the called function simply prints the content, but the concept can be used in a variety of ways.

Alternatively, the used class (and respective function) might not be static. In such a case, it is required to create an instance of the used Type object, as can be seen in the altered example below.

byte[] raw = File.ReadAllBytes(@"C:\Repos\AppToInject\AppToInject\bin\Debug\AppToInject.exe");
Assembly assembly = Assembly.Load(raw);
 
Type type = assembly.GetType("AppToInject.Program");
object c = Activator.CreateInstance(type);
type.InvokeMember("CallMeMaybe", BindingFlags.InvokeMethod, null, c, new object[] { @"I am passed via reflection!" });

When the module is an executable that is started via the entry point, it is easy to debug, assuming no code from the previous stage is used. One can simply load the binary via the debugger and continue from there. In case code from the previous stage(s) is used (or when you have a DLL and do not want to use a loader of sorts), it is best to follow the module in-memory, as is discussed in the next section.

Following modules in-memory

Within dnSpy, its possible to place breakpoints on modules in memory. One can open the Module Breakpoints window via Debug->Windows->Module Breakpoints. In the newly opened window at the bottom of the screen, one can enter the name of the modules that the debugger should react to, one per line. It is possible to use the asterisk as a wildcard in the name segment. The filters allow the debugger to only break if the module meets specific conditions, such as loading the module or a specific AppDomain name. The screenshot below shows this functionality.

Additionally, or alternatively, one can open the Modules window via Debug->Windows->Modules, or by using CTRL+ALT+U. Note that this menu option is only visible whilst debugging. Once the Modules window opens at the bottom, it remains open after the debugging has finished as well. Double clicking a module of sorts within the Modules window will open them in the list view on the left. Placing a breakpoint somewhere within the newly listed module will cause the debugger to halt whenever the marked line is hit. The Modules window is shown in the screenshot below. Note that the unknown module only resides in-memory, as the fourth column also indicates.

For further clarification, the location of the menu items of both Module Breakpoints and Modules are shown in the screenshot below.

Combining the two methods that are mentioned above, one can break once any module is loaded. Once a malicious module is loaded, it can dumped to the disk, opened via the Module screen in dnSpy to follow it in-memory, and a breakpoint can be placed upon the function that will be called. The invoke related functions (such as InvokeMember or Invoke) and their respective objects, show which function is called, and what arguments are passed, if any. If you can not find the invoke function call, it is possible to put a breakpoint on any interesting function you find within the newly loaded module. Generally, the moment that the Invoke function is called follows briefly after the Load call, though this can differ per sample.

Conclusion

Debugging to observe the sample’s behaviour is useful, especially once you can dive into (and possibly dump) any loaded module that you encounter. Some samples launch a newly loaded module as the second stage, whereas others use modules to add features.

Diving deep into a sample consumes a lot of time, meaning correctly triaging a sample can save a lot of time. This also means that triaging a sample is the activity that is most often performed. As such, knowing how to debug samples efficiently will save a lot of time, both when deep diving and when triaging.


To contact me, you can e-mail me at [info][at][maxkersten][dot][nl], send me a PM on Reddit, or DM me on Twitter @Libranalysis.