Cybersecurity

R2R stomping – are you ready to run? – Check Point Research

Research by: Jiri Vinopal

Highlights

  • Check Point Research (CPR) introduces a new method for running hidden implanted code in ReadyToRun (R2R) compiled .NET binaries, R2R stomping.
  • We explain the implementation of R2R stomping with a focus on its internals.
  • The resulting problems of the R2R stomping technique will affect the work of the reverse engineers and security researchers.
  • CPR details techniques and tools to reverse engineer R2R stomped Assemblies and possible ways of detection.
  • We did not find evidence of using the R2R stomping in the wild, but we can not fully exclude a chance of being already a part of some advanced arsenals.

Abstract

What if we told you that the reality you perceive with your very own eyes is not always what it seems? That the .NET code you witness executing within your beloved managed debugger, such as dnSpy/dnSpyEx, may not necessarily be the same code that operates outside of its bounds?

.NET application startup time and latency can be improved by compiling application assemblies as ReadyToRun (R2R) format files, which is a form of ahead-of-time (AOT) compilation. Binaries compiled this way contain similar native code to what JIT would produce, but they are larger because they contain both intermediate language (IL) code and the native version of the “same code”. Or at least, that’s what documentation says.

This research introduces a new method for running hidden implanted code in ReadyToRun (R2R) compiled .NET binaries. The method focuses on the possibility of altering R2R compiled binaries in such a way that the original IL code of the assembly differs from the pre-compiled native code, which is a part of the produced binary too. Because of the .NET optimization, the pre-compiled native code will be prioritized and run, ignoring the original IL code.

Furthermore, because of the debugging experience, the default optimization settings of managed debuggers such as dnSpy/dnSpyEx differ, resulting in different code execution comparing normal execution of the altered R2R compiled binary and execution in the context of the managed debugger.

This research will focus on the following:

  • Introduction to R2R stomping
  • Implementation of R2R stomping with an explanation of the internals
  • The resulting problems that will affect the work of the reverse engineer
  • Techniques and tools to reverse engineer R2R stomped Assemblies
  • Possible ways of detecting R2R stomping

Introduction to R2R stomping

Before we dive into the ReadyToRun compilation format of dotnet applications, a little recap about .NET, in general, is necessary.

The Dotnet framework, originally created by Microsoft, is an open-source, cross-platform environment to build many different types of applications. Specific programming and scripting languages run on top of the framework (C#, F#, VB.NET, PowerShell). When it was first introduced in 2002 as a “.NET Framework”, it was just a matter of Windows platform and closed-source. Two years later (2004), the first open-source, cross-platform version of “.NET Framework” was introduced, known as “Mono Project”. It took some time for Microsoft to react and bring its own open-source, cross-platform version – .NET Core (2016). This Microsoft solution evolved into its successor .NET (.NET 5 – 2020). As the ReadyToRun format of dotnet compilation was first introduced in .NET Core 3.0, the technique introduced in this research (R2R stomping) targets dotnet version from .NET Core 3.0+ to .NET 5+.

Usually, a regular .NET assembly only contains a managed code (also known as Intermediate Language, IL code, MSIL, CIL), which needs to be compiled and interpreted into its form of native code by the just-in-time compiler (JIT) after the application starts. As the usage of the dotnet environment to build many different types of applications is getting more and more popular, a lot of pressure has been put on the improvement regarding its latency and relatively slow application startup time caused by JIT.

Since JIT compilation is the main cause of slow startup time and speed of execution, logical solutions that help us target this problem are, in general, reducing the amount of code that needs to be JIT-compiled or avoiding JIT usage at all. Such solutions are coming up with different types of compilation formats for dotnet assemblies that generally use a form of ahead-of-time (AOT) compilation.

The main formats of AOT compilation:

  • NGEN – .NET Framework only, considered to be a little bit of a fragile solution
  • ReadyToRun – From .NET Core 3.0+, reducing the need for JIT by pre-compilation
  • Native AOT – From .NET 7+, full native format (PE + CPU code), no need of .NET runtime to be installed, no usage of JIT, no IL code and .NET metadata

Once the application assemblies are compiled in a ReadyToRun (R2R) format, a form of AOT, resulting binaries contain similar native code to what JIT would produce, but they are larger because they contain both intermediate language (IL) code and the native version of the “same code”. Because this format still depends on the original dotnet metadata of assembly, they are also a part of the produced binary.

So, in general, such binaries conform to CLI file format as described in ECMA-335 but enrich it with a “ManagedNativeHeader” pointing to a specific “READYTORUN_HEADER” followed by other structures needed for successful execution of pre-compiled native code. The signature field of “READYTORUN_HEADER” is always set to 0x00525452 (ASCII encoding for “RTR”). The signature can be used to distinguish ReadyToRun images from other CLI images with “ManagedNativeHeader” (e.g., NGen images).

Figure 1: The ReadyToRun header structure parsed in the dotPeek tool

The method “R2R stomping” focuses on the possibility of altering R2R compiled binaries in such a way that the original IL code of the assembly will differ from the pre-compiled native code, which is a part of the produced binary too. Because of the .NET optimization, the pre-compiled native code will be prioritized and run, ignoring the difference to the original IL code of such Assembly.

Furthermore, the default optimization settings of managed debuggers such as dnSpy/dnSpyEx differ (suppressing the JIT optimization), resulting in different code execution comparing normal execution of the altered R2R compiled binary and execution in the context of the managed debugger.

In general, the idea behind the R2R stomping is similar to an already well-known technique called VBA stomping, which affects the VBA code of MS Office products and has been abused by threat actors for a while.

Implementation of R2R stomping

As we already mentioned, the main idea behind the R2R stomping implementation is about modifying the original code of the compiled assembly in a way that the capability and behavior of the method´s IL code would differ from the pre-compiled native code.

Such modifications could be done in 2 ways:

  • Compile real – Replace with decoy (replacement of the compiled IL code, leaving the original pre-compiled code)
  • Compile decoy – Replace with real (replacement of the pre-compiled native code, leaving the original IL code)

During the implementation of R2R stomping, we need to keep in mind that either the original IL code or the pre-compiled native code we decide to preserve still depends on the original metadata of the dotnet assembly. In other words, we must be very careful not to change the metadata in a way that could later result in failure during the execution.

Despite the fact that we have chosen the Windows OS, x64, and .NET 6 as our targeted environment for implementation example detailed below, we were able to successfully test the R2R stomping method in a wide range of dotnet runtimes (supporting ReadyToRun), from .NET Core to .NET 7 across different architectures and OS platforms (Windows, Linux, macOS).

Noteworthy is that the R2R stomping could be further combined with different compilation settings, such as those producing dotnet bundle (single-file) or self-contained assembly. In the shown implementation, these compilation formats were omitted to simplify the explanation of R2R stomping, but once they are applied, they would harden the analysis of the produced file regarding R2R stomped methods.

Compile real – Replace with decoy

In this implementation, the target code for a replacement is the IL code of the produced assembly, leaving the pre-compiled native code intact. We will start with the creation of a new project in Visual Studio IDE, selecting C#, Console App, and building on top of .NET (in our case, .NET 6).

Figure 2: Visual Studio IDE – Creation of new C#, Console App, .NET 6 project

To build our non-self-contained, ReadyToRun application, we can directly specify the “PublishReadyToRun” flag to the dotnet publish command dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true --self-contained false.

Figure 3: Building the ReadyToRun application with the dotnet publish command

To demonstrate the modification of the IL code, we can simply replace the Process.Start("calc") method invocation and its appropriate IL code with nops instructions. To achieve this, we can choose either the GUI-based tool dnSpyEx or the programmatic way using libraries such as AsmResolver or dnlib. Whatever approach we choose, preserving as much from the original metadata and PE structure as possible is important not to strip the pre-compiled native code from the dotnet module.

DnSpyEx way:

Open the compiled ReadyToRun assembly in the dnSpyEx.

Figure 4: DnSpyEx – opening ReadyToRun assembly

Edit the IL instructions related to Process.Start("calc") method invocation – replace with nops.

Figure 5: Editing IL instructions in dnSpyEx

Save the patched module – preserve as much as possible and make sure the “Mixed-Mode Module” option is checked.

Figure 6: Saving the patched module in dnSpyEx

The newly created ReadyToRun stomped assembly will not reveal any evidence of code related to the creation of the calc process both in the IL view and decompiled view of C# code.

Figure 7: C# view and IL view of the ReadyToRun stomped assembly

But once we try to normally run our patched ReadyToRun assembly, either via its associated executable CompileReal_ReplaceDecoy_IL.exe located in the same folder or via issuing the dotnet CompileReal_ReplaceDecoy_IL.dll command from a command prompt, we can spot that our pre-compiled native code was executed, ignoring the difference to the patched IL code (process calc.exe started).

Figure 8: Triggering the execution of the pre-compiled native code

Programmatic way using dnlib:

Generally, the logic behind the programmatic way of patching is the same as in the case we already covered, using dnSpyEx. Because we need some simple solution that is able to preserve not only the original dotnet metadata but also the pre-compiled code and its related structures that are a part of PE, using dnlib is probably the most suitable solution. Dnlib provides a native writer and its appropriate options that are able to preserve everything we need.

Example usage of dnlib (via PowerShell) to patch the original ReadyToRun application could be seen below:

[Reflection.Assembly]::LoadFrom("C:dnlib.dll") | Out-Null
$original = "C:CompileReal_ReplaceDecoy_IL.dll"

$moduleDef = [dnlib.DotNet.ModuleDefMD]::Load($original)
$mainMethod = $moduleDef.Types.Methods.Where{$_.Name -like "Main"}[0]
$inst = $mainMethod.MethodBody.Instructions.Where{$_.Operand.FullName -like "*Process::Start*"}[0]
$instIndex = $mainMethod.MethodBody.Instructions.IndexOf($inst)
$nopInst = [dnlib.DotNet.Emit.Instruction]::Create([dnlib.DotNet.Emit.OpCodes]::Nop)

$mainMethod.MethodBody.Instructions[$instIndex-1] = $nopInst
$mainMethod.MethodBody.Instructions[$instIndex] = $nopInst
$mainMethod.MethodBody.Instructions[$instIndex+1] = $nopInst

$nativeModuleWriterOptions = [dnlib.DotNet.Writer.NativeModuleWriterOptions]::new($moduleDef, $true)
$nativeModuleWriterOptions.MetadataOptions.Flags = [dnlib.DotNet.Writer.MetadataFlags]::PreserveAll
$moduleDef.NativeWrite($original + "_patched.dll", $nativeModuleWriterOptions)

Compile decoy – Replace with real

In this implementation, the target code for a replacement is the pre-compiled native code of the produced assembly, leaving the IL code intact. We will start with the creation of a new project in Visual Studio IDE, selecting C#, Console App, and building on top of .NET (in our case, .NET 6).

Figure 9: Visual Studio IDE – Creation of new C#, Console App, .NET 6 project

Normally, despite the fact of being native, the pre-compiled code of the ReadyToRun application still depends on metadata of the dotnet assembly that needs to be resolved before the code starts executing.

This time, the subject of replacement is the pre-compiled native code, so one of the most suitable solutions could be to replace it with some memory-independent shellcode specific to the targeted OS platform and architecture.

Such an implanted native shellcode will make sure that we are not using any kind of metadata of our targeted dotnet assembly that cannot be resolved. To make our demonstration easy and clear, we could create a decoy C# code that will result in a pre-compiled native code being large enough to make our shellcode easily fit in. The resulting decoy IL code that will be a part of the produced R2R assembly could be further modified or replaced (we need it just to create space for the shellcode that will be implanted in place of the pre-compiled code).

Figure 10: Decoy C# code

To build our non-self-contained, ReadyToRun application, we can directly specify the “PublishReadyToRun” flag to the dotnet publish command dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true --self-contained false.

When we have built the ReadyToRun assembly, we need to locate the pre-compiled native code of the method Main() that is a part of this assembly and find out information about its size. There are more ways to accomplish this, but the most straightforward is to use a tool called R2RDump (more about this tool will be covered later).

Figure 11: R2RDump tool parsing the structures of ReadyToRun assembly

We can clearly see that, in this case, the pre-compiled code of the Main() method is located on the RVA address 0x00001890 with a size of 282 bytes.

A native disassembler like IDA could be used to find and extract 282 opcode bytes of the pre-compiled native code on RVA address 0x00001890. These opcode bytes will serve the purpose of pattern search during binary patching.

Figure 12: IDA disassembler used to extract the opcode bytes of pre-compiled native code

To generate an example of a memory-independent shellcode that will replace the pre-compiled native code of R2R assembly, MsfVenom (a Metasploit standalone payload generator) could be used. Issuing the command below will result in 282 bytes of 64-bit Windows shellcode with the purpose of spawning a new process, calc.exe.

.msfvenom.bat -p windows/x64/exec CMD=calc.exe -f raw --smallest --nopsled 6 -o calc.sc

Once we have both the opcode bytes pattern of the pre-compiled native code of the assembly and shellcode, we can use any tool to search for the pattern and perform the raw binary patching. We decided to use the 010 Editor.

Figure 13: Binary patching using 010 Editor

If we try to run our ReadyToRun stomped assembly, either via its associated executable CompileDecoy_ReplaceReal_SC.exe located in the same folder or via issuing the dotnet CompileDecoy_ReplaceReal_SC.dll command from a command prompt, we can spot that our shellcode implanted on the place of the original pre-compiled native code was executed, ignoring the difference to the original decoy IL code (process calc.exe started).

Figure 14: Triggering the execution of the implanted shellcode

Despite the fully manual way of the above-mentioned implementation, most of the steps could be automated with a programmatic approach.

Problems affecting reverse engineering

Usually, when it comes to the analysis of dotnet assembly, a significant part of researchers will stay on the level of IL code or interpreted decompiled C# code. To be honest, who would use a different tool than dnSpy/dnSpyEx?

When it comes to analysis or reverse engineering of R2R stomped assembly, one must go deeper; as we saw earlier in the implementation section, the shenanigans are on the level of native code.

The main problems we are dealing with could be summarized as follows:

  • We see a different code than the one that is executed (static analysis)
  • We debug a different code than the one that is executed out of debugger context (dynamic analysis)
  • Other compilation formats could be applied to complicate the analysis (complicating the analysis)

To cover the problems affecting the work of reverse engineers, we will use those examples of R2R stomped applications produced in the “Implementation of R2R stomping” section covered earlier.

Static analysis problems

Once we try to examine the IL code or the interpreted decompiled C# code of the R2R stomped assembly, we will not see any sign of strangeness at first sight.

For example, the R2R stomped program that was replacing/modifying the IL code and leaving the pre-compiled code intact (section “Compile real – Replace with decoy”) in dnSpyEx could be seen below.

Figure 15: C# view and IL view of the ReadyToRun stomped assembly (pre-compiled code intact)

One could say that the nops instructions look suspicious, but it is important to note that these nops instructions could be removed completely.

Those who are pretty aware of dotnet internals could say that the dotnet metadata related to referenced types are showing types that are not used by the IL code at all (they are still used by the pre-compiled native code that was left intact).

Figure 16: Reference types check of R2R stomped assembly (not used referenced types)

While that is a good point, in a much-complicated program where only one of the methods is a target for the R2R stomping, the not-used referenced types could be easily overlooked.

Also, what about the case shown in the section “Compile decoy – Replace with real”? In that case, we left the original IL code intact and replaced the pre-compiled native code with shellcode, so metadata related to referenced types are accurate.

Figure 17: R2R stomped assembly with accurate referenced types

Dynamic analysis problems – debugging

When it comes to debugging dotnet assembly, one could hardly imagine using a different tool than dnspy/dnSpyEx.

Once we try to run/debug our patched ReadyToRun application in dnSpyEx, we will reach a different code executing when compared with normal execution. This is because the default settings of dnSpyEx are suppressing the JIT optimization (to preserve the debugging experience), forcing JIT (Just-In-Time) compilation of the presented IL code, and omitting execution of the pre-compiled native code.

Figure 18: Default dnSpyEx settings – suppressing the JIT optimization

We can immediately notice that once we try to debug/run the R2R stomped application produced in the section “Compile decoy – Replace with real” (the original IL code intact, the pre-compiled native code replaced with shellcode) in dnSpyEx, the process calc.exe is not started.

Figure 19: R2R stomped assembly running in the context of dnSpyEx – forced JIT of the IL code

But once we try to run it out of the debugger context (normal execution), we can see that because of the .NET optimization, the shellcode (implanted in place of the original pre-compiled native code) was prioritized and executed.

Figure 20: Triggering the execution of the implanted shellcode – the normal execution

Because of the debugging experience, the suppression of JIT optimization is quite the expected setting. As a point of interest, we can replicate the behavior of dnSpyEx default settings, effectively turning off AOT optimization, in normal execution. This could be accomplished by setting our targeted process´s environment variable COMPlus_ReadyToRun=0.

The normal execution without and with setting the environment variable COMPlus_ReadyToRun=0 can be seen below.

Figure 21: Normal execution of R2R stomped assembly without and with the setting of “COMPlus_ReadyToRun=0”

Further complicating the analysis

Different compiler settings could be applied to complicate the analysis of the R2R stomped assembly, resulting in different compilation formats of the produced ReadyToRun application.

An example of such compiler settings could be a combination of dotnet bundle file format (single-file) and self-contained options.

These settings could result in one native executable (because of the single-file compiler option) that contains the dotnet assemblies in its overlay. In addition to our main module, a significant part of the dotnet assemblies could represent a targeted dotnet runtime that was bundled into the single-file format (because of the self-contained option).

When dealing with such a program, we are struggling with the same issues covered before but also with a problem of detecting this form of compilation and extraction of the assemblies from the overlay of the dotnet bundle (single-file).

Even though these compilation formats are out of the scope of this research (not directly related to R2R stomping), the extraction of dotnet assemblies from the dotnet bundle overlay (single-file) could be accomplished by using the appropriate tools that understand the dotnet bundle file format, either via GUI-based tools such as ILSpydotPeek or programmatic approach using AsmResolver.

Figure 22: Extraction of dotnet bundle in the dotPeek tool

The analysis and reverse engineering of R2R stomped assemblies require a different approach than the one we are used to going with when it comes to ordinary dotnet assembly. We need a different toolset to analyze the parts of ReadyToRun assembly related to AOT compilation and its result. Unfortunately, there is no “one-to-rule-them-all” solution, but several tools are helpful for particular tasks.

In general, these tasks can be divided into:

  • Parsing the ReadyToRun assembly structure (R2RDump, dotPeek)
  • Showing the IL code and interpreted decompiled C# code (ILSpy, dnSpyEx, dotPeek)
  • Locating and disassembling the pre-compiled native code (R2RDump, ILSpy)

To demonstrate the usage of a specific tool regarding a particular task, the R2R stomped application produced in the section “Compile decoy – Replace with real” (replacement of the pre-compiled native code, leaving the original IL code) was chosen.

Parsing the ReadyToRun assembly structure

Properly parsing the R2R assembly is crucial as related structures provide important information that helps with analysis and reverse engineering. An example of information we can obtain is a list of methods that were pre-compiled to their native form, enriched with details about location and size.

Among the most reliable tools that understand the R2R assembly structure, parse it, and can present this information meaningfully, are R2RDump and dotPeek.

R2RDump is a command-line utility part of the dotnet runtime source code available on its GitHub repository. This tool is not a part of the dotnet runtime installer, so if we need to get it, we must compile it on our own. The maintenance of this tool is regular, and because of that, it can provide the most comprehensive information about ReadyToRun assemblies. The available options for this tool are shown below.

Figure 23: Available options of the R2RDump tool

An example of R2RDump usage that provides information about the R2R header and content of each presented section:

Figure 24: Parsing R2R header and content of sections with the R2RDump tool

If one would prefer a GUI-based tool, dotPeek is the way to go with. Despite the fact it can not provide such detailed information compared with R2RDump, it could be considered a suitable alternative.

Showing the IL code and interpreted decompiled C# code

As we described earlier, with the abuse of R2R stomping, certain IL code or the pre-compiled native code is modified. To be able to see the IL code of such methods is another important part of the analysis.

Most of the researchers are already aware of tools like dnSpyEx, ILSpy, and dotPeek that have the ability to show the IL code and its reconstructed decompiled C# code. This task is probably the only one that is common when analyzing an ordinary dotnet assembly.

The engine from ILSpy is running under the hood of the dnSpyEx tool to reconstruct both the IL code and decompiled C# code. An example of both of these views side-by-side can be seen below.

Figure 25: IL and C# code views in the dnSpyEx tool

Locating and disassembling the pre-compiled native code

The last but most important part of the analysis regarding R2R stomping is being able to locate and see the disassembly of methods that were pre-compiled to their native form.

When it comes to this task, a limited number of tools can be used. Such tools need to understand the R2R assembly structure and must be able to properly parse it to use certain information that can later serve to locate and process the pre-compiled native code and present it in its disassembly form. The most useful tools that can be used to accomplish this task are R2RDump and ILSpy.

The R2RDump tool was already mentioned, but we did not cover its ability to reconstruct and present the disassembly of certain methods that were pre-compiled to their native form. An example of using this tool to do so can be seen below. (showing the disassembly of R2R stomped assembly, method Main)

Figure 26: Using the R2RDump to show the disassembly of the method “Main”

The ILSpy is one of the industry-changing tools regarding dotnet analysis. Not so known, but it also understands the R2R assembly format enough to be able to interpret the disassembly code of pre-compiled methods. By selecting a method that was pre-compiled to native code and switching the view to one named “ReadyToRun”, we can investigate the disassembly associated with the selected method.

Figure 27: Using the ILSpy to show the disassembly of the R2R stomped method

Detecting R2R stomping

Before we jump to possible ways of detecting the R2R stomping technique, we need to start with a general detection of the ReadyToRun form of compilation. Recognizing this kind of format with a manual or automated approach is a relatively easy task.

For the manual approach of R2R format detection, tools like dotPeek or ILSpy should be our first choice because they tell us immediately what we are dealing with. As they even understand the dotnet bundle file format, there is no problem if such an option was set during the compilation of the R2R application (they can extract the content of the bundle).

Figure 28: Detection of R2R assembly in the dotPeek tool

The ReadyToRun compiled binaries enrich the CLI file format with a “ManagedNativeHeader” pointing to a specific “READYTORUN_HEADER”. The signature field of “READYTORUN_HEADER” is always set to 0x00525452 (ASCII encoding for “RTR”). The RVA address and size of “ManagedNativeHeader” are a part of the .NET Directory. All these findings could be used to create an effective Yara rule that can be used for automated detection of the ReadyToRun dotnet format. An example of such a Yara rule can be seen below.

import "pe"

rule r2r_assembly
{
    meta:
        author = "jiriv"
        description = "Detects dotnet binary compiled as ReadyToRun - form of ahead-of-time (AOT) compilation"
    condition:
        // check if valid PE
        uint16(0) == 0x5a4d and uint16(uint32(0x3c)) == 0x4550 and
        // check if dotnet -> .NET Directory is present
        pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address != 0 and
        // check if ManagedNativeHeader exists -> ManagedNativeHeader RVA is not 0 inside .NET Directory
        uint32(pe.rva_to_offset(pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address) + 0x40) != 0 and
        // check if it is R2R -> RTR magic signature is present (0x00525452 == "RTR" in ascii)
        uint32(pe.rva_to_offset(uint32(pe.rva_to_offset(pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address) + 0x40))) == 0x00525452
}

Generally, the manual detection of R2R stomping is based on an investigation of the difference between the method´s IL code and its appropriate pre-compilated native code.

We mentioned earlier that no tool could be considered an “all-in-one” solution for analyzing and detecting R2R stomping, but ILSpy is very likely the closest one to that. ILSpy understands the R2R format and is able to show us the IL code, interpreted decompiled C# code, and even the disassembly of the pre-compiled native code. Furthermore, it can deal with other compilation formats such as bundle (single-file) and self-contained dotnet. With all of these capabilities, it became the main utility for manual detection and analysis of R2R stomping. Noteworthy is that even though the ILSpy engine runs under the hood of dnSpyEx, the above-mentioned features are not implemented.

An example of manual detection of R2R stomping using the ILSpy could be seen below, where we use the application produced in the section “Compile decoy – Replace with real” (replacement of the pre-compiled native code, leaving the original IL code) – implanted shellcode.

Figure 29: R2R stomping – implanted shellcode

With the side-by-side views, we can immediately notice that something is really wrong with the pre-compiled native code of the Main method. One would hardly imagine a situation where the pre-compiled code would result in something lacking a typical function prologue and even manipulating with PEB structure (Process Environment Block). We would expect something like the one shown below (the original, not stomped R2R assembly).

Figure 30: The original, not stomped R2R assembly

When it comes to manual detection of R2R stomping regarding our second example application produced in the section “Compile real – Replace with decoy” (replacement of the compiled IL code, leaving the original pre-compiled code), we can spot relatively easily the missing reference to Process.Start() method in IL and C# code view.

Figure 31: R2R stomping – patched IL code (pre-compiled intact)

Of course, the more complicated programs we have, the harder would be to reveal the R2R stomping technique. The manual approach will always be time-consuming, but in most cases, the most reliable way to reveal R2R assemblies affected by stomping.

If we try to automate the detection of R2R stomping, no simple and 100% reliable solution is ready for production. As we already saw, the logic behind the R2R stomping detection needs to cover more different scenarios. We covered the implanted shellcode and modified IL code with decoy instructions, but there is always space for other imagination.

One can hardly think about the implementation of such detection logic with just some signature-based solution, like Yara.

The most promising would be using a programmatic way with the help of libraries (e.g., dnlibAsmResolvericed) that understand the dotnet assembly structure, metadata, IL code and are also able to disassemble the pre-compiled native code. This would be as reliable as our implemented logic that would need to predicate how the resulted pre-compiled code of methods should look like and how should not across all different platforms and architectures.

This is an example case where the implementation of prevention would be a much more reliable and easy-to-implement solution. If we thought about some computed hash of the IL code and its pre-compiled code that would be added to the R2R assembly structure and verified upon execution by dotnet runtime, there would be no R2R stomping (until next time – R2R Hash stomping).

Conclusion

This research introduced a new method for running hidden implanted code in ReadyToRun (R2R) compiled .NET binaries, R2R stomping. We covered its implementation details, focusing on the internal processing of dotnet runtime and resulting problems that harden reverse engineering.

In the last sections, we introduced several tools and techniques that can be effective and useful for the analysis of R2R stomped applications and how to use them for detection.

Despite the fact that there is no static, automated detection mechanism to be ready for production yet, in case of implanting a malicious code via the R2R stomping technique, the behavioral-based detection should not be affected. R2R stomping could affect the work of researchers, but it is not an evasion technique. As for now, we did not find any evidence of using the R2R stomping in the wild, but we can not fully exclude a chance of being already a part of some advanced arsenals.

The subject of this research was responsibly reported to MSRC (Microsoft Security Response Center) in June 2023, but as it doesn’t cross any MSRC boundaries for spoofing or security feature bypasses, there will be no fix released.

Check Point customers remain protected from the malicious abuse of the technique described in this research. Check Point’s Threat Emulation provides comprehensive coverage of attack tactics, file types, and operating systems. The engine quickly quarantines and runs the files in a virtual sandbox environment, which imitates a standard operating system to discover malicious behavior at the exploit phase.

Check Point’s Harmony Endpoint provides comprehensive endpoint protection at the highest security level, crucial to avoid security breaches and data compromise. Behavioral Guard protections were developed and deployed to protect customers against the threats described in this research.

References

ReadyToRun File Format: https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-format.md

ReadyToRun Overview: https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-overview.md

ReadyToRun Compilation: https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run

Single-file deployment: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/

ILSpy: https://github.com/icsharpcode/ILSpy

DnSpy/dnSpyEx: https://github.com/dnSpyEx/dnSpy

R2RDump: https://github.com/dotnet/runtime/tree/main/src/coreclr/tools/r2rdump

DotPeek: https://www.jetbrains.com/decompiler/

NGEN: https://learn.microsoft.com/en-us/dotnet/framework/tools/ngen-exe-native-image-generator

Native AOT: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=net7

ECMA-335: https://www.ecma-international.org/publications-and-standards/standards/ecma-335/

Visual Studio IDE: https://visualstudio.microsoft.com/

AsmResolver: https://github.com/Washi1337/AsmResolver

Dnlib: https://github.com/0xd4d/dnlib

MsfVenom: https://docs.metasploit.com/docs/using-metasploit/basics/how-to-use-msfvenom.html

IDA (Hex-Rays): https://hex-rays.com/ida-pro/

YARA: https://github.com/VirusTotal/yara

Iced: https://github.com/icedland/iced