09th February 2024 / Document No. D24.102.XX
Prepared By: c4n0pus
Challenge Author(s): c4n0pus
Difficulty: Insane
Classification: Official
A hard Forensic challenge that features a new type of malware concealment/delivery called R2R (Ready To Run) Stomping. Players are given a Windows 10 hibernation image which will have to convert into a memory dump image by decompressing the memory pages. From there, players will need to examine the process list and identify the out-of-place running process. After enumerating the loaded dlls
they will have to deduce, using the PE flags and various inconsistencies between code and loaded libraries that this binary is an R2R binary and contains native code instead of just .NET IL code (Mixed Mode Assembly). Finally, after analyzing the native code using a shellcode emulator or a debugger they will find the backdoored user and the flag.
As the days for the final round of the game, draw near, rumors are beginning to spread that one faction in particular has rigged the final! In the meeting with your team, you discuss that if the game is indeed rigged, then there can be no victory here... Suddenly, one team player barged in carrying a Windows Laptop and said they found it in some back room in the game Architects' building just after the faction had left! As soon as you open it up it turns off due to a low battery! You explain to the rest of your team that the Legionnaires despise anything unethical and if you expose them and go to them with your evidence in hand, then you surely end up being their favorite up-and-coming faction. "Are you Ready To Run with the Wolves?!"
- .NET analysis
- Windows Internals
- Volatility basics
- Ready To Run (R2R) AOT
- R2R Stomping
- R2R binaries analysis
- Windows Modern Standby analysis
- Custom Windows Shellcode emulation
We are given a zip file containing a file named hiberfil.sys
. A quick search tells us that this file contains the memory contents of a Windows machine during a "Sleep" or "Hibernate" phase. Essentially this is a memory dump but if we try and examine it with Volatility it will not find anything.
Searching around we find a blog post by Felix Guyard detailing the intricacies of analyzing the Modern Standby version of such images. At the end of the post, Felix provides us with a link to a modified version of Volatility that has been patched with 2 new plugins in order to: (a) identify such an image and (b) convert said image to a raw memory dump that can be examined by Volatility
After cloning the branch we can run it specifying the plugin and the hiberfil.sys
:
$ python vol.py -f hiberfil.sys windows.hibernation.Info
Volatility 3 Framework 2.5.1
Progress: 100.00 PDB scanning finished
Variable Value
Signature b'HIBR'
PageSize 4096
Comment The hibernation file header signature is correct.
System Time 2024-02-08 22:43:33
FirstBootRestorePage 0x12
NumPagesForLoader 434546
After confirming that this is indeed a Hibernation image we then can use the Dump
plugin to convert it (Note that we know the computer is running Windows 10, for the version variable):
$ python vol.py -f hiberfil.sys windows.hibernation.Dump --version 0
Volatility 3 Framework 2.5.1
WARNING volatility3.framework.interfaces.automagic: Reads to this layer are slow, it's recommended to use the layerwriter plugin once to produce a raw file
Progress: 100.00 PDB scanning finished
Status
Progress: 3.17 Writing layer memory_layer
This will take some time, approximately 10 minutes to extract all pages in the 4GB, however since it's single-threaded, hopefully, it won't take much longer on a lower-end PC / VM.
After converting we will find memory_layer.raw
which will be our normal memory dump. Let's try running windows.pslist
on it:
$ vol -f memory_layer.raw windows.pslist
Volatility 3 Framework 2.5.2
Progress: 100.00 PDB scanning finished
PID PPID ImageFileName Offset(V) Threads Handles SessionId Wow64 CreateTime ExitTime File output
4 0 System 0xac8d56a93080 151 - N/A False 2024-02-08 22:29:58.000000 N/A Disabled
[...REDACTED...]
3368 6560 OneDriveSetup. 0xac8d5db5c080 0 - 2 True 2024-02-08 22:40:49.000000 2024-02-08 22:41:05.000000 Disabled
5328 740 svchost.exe 0xac8d5fb17080 4 - 0 False 2024-02-08 22:41:13.000000 N/A Disabled
6744 5292 SearchProtocol 0xac8d5ed8f080 9 - 0 False 2024-02-08 22:42:04.000000 N/A Disabled
5352 5292 SearchFilterHo 0xac8d5f529080 4 - 0 False 2024-02-08 22:42:04.000000 N/A Disabled
1372 740 svchost.exe 0xac8d5faa6080 8 - 2 False 2024-02-08 22:42:37.000000 N/A Disabled
4404 884 WmiPrvSE.exe 0xac8d5e780080 10 - 0 False 2024-02-08 22:43:13.000000 N/A Disabled
6348 6560 TheGame.exe 0xac8d5ef4e080 8 - 2 False 2024-02-08 22:43:28.000000 N/A Disabled
2460 6348 conhost.exe 0xac8d5f8e2080 7 - 2 False 2024-02-08 22:43:28.000000 N/A Disabled
2484 884 GameBar.exe 0xac8d5f9a50c0 17 - 2 False 2024-02-08 22:43:28.000000 N/A Disabled
[...REDACTED...]
Notice that we can run it with the latest version of Volatility rather than the patched one, now that we have converted it. Also, we see a process called TheGame.exe
running. Keeping up with the theme of the competition we suspect that this might be interesting, let's examine the dlls
it loads:
$ vol -f memory_layer.raw windows.dlllist --pid 6348
Volatility 3 Framework 2.5.2
Progress: 100.00 PDB scanning finished
PID Process Base Size Name Path LoadTime File output
6348 TheGame.exe 0x7ff6dc8d0000 0x29000 theGame.exe C:\Users\architect\Desktop\publish\theGame.exe
6348 TheGame.exe 0x7ff8b9890000 0x1f8000 ntdll.dll C:\Windows\SYSTEM32\ntdll.dll
6348 TheGame.exe 0x7ff8b7f20000 0xbf000 kernel32.dll C:\Windows\System32\kernel32.dll
6348 TheGame.exe 0x7ff8b7550000 0x2f6000 KERNELBASE.dll C:\Windows\System32\KERNELBASE.dll
6348 TheGame.exe 0x7ff8b46c0000 0x91000 apphelp.dll C:\Windows\SYSTEM32\apphelp.dll
6348 TheGame.exe 0x7ff8b8a70000 0x19d000 USER32.dll C:\Windows\System32\USER32.dll
6348 TheGame.exe 0x7ff8b7850000 0x22000 win32u.dll C:\Windows\System32\win32u.dll
6348 TheGame.exe 0x7ff8b8100000 0x2c000 GDI32.dll C:\Windows\System32\GDI32.dll
6348 TheGame.exe 0x7ff8b7430000 0x115000 gdi32full.dll C:\Windows\System32\gdi32full.dll
6348 TheGame.exe 0x7ff8b6fa0000 0x9d000 msvcp_win.dll C:\Windows\System32\msvcp_win.dll
6348 TheGame.exe 0x7ff8b7090000 0x100000 ucrtbase.dll C:\Windows\System32\ucrtbase.dll
6348 TheGame.exe 0x7ff8b8e30000 0x744000 SHELL32.dll C:\Windows\System32\SHELL32.dll
6348 TheGame.exe 0x7ff8b7ff0000 0xaf000 ADVAPI32.dll C:\Windows\System32\ADVAPI32.dll
6348 TheGame.exe 0x7ff8b9590000 0x9e000 msvcrt.dll C:\Windows\System32\msvcrt.dll
6348 TheGame.exe 0x7ff8b8390000 0x9c000 sechost.dll C:\Windows\System32\sechost.dll
6348 TheGame.exe 0x7ff8b8430000 0x126000 RPCRT4.dll C:\Windows\System32\RPCRT4.dll
6348 TheGame.exe 0x7ff8b8560000 0x30000 IMM32.DLL C:\Windows\System32\IMM32.DLL
6348 TheGame.exe 0x7ff8aaac0000 0x59000 hostfxr.dll C:\Users\architect\Desktop\publish\hostfxr.dll
6348 TheGame.exe 0x7ff8aaa50000 0x64000 hostpolicy.dll C:\Users\architect\Desktop\publish\hostpolicy.dll
6348 TheGame.exe 0x7ff8935c0000 0x4e9000 coreclr.dll C:\Users\architect\Desktop\publish\coreclr.dll
6348 TheGame.exe 0x7ff8b9720000 0x12a000 ole32.dll C:\Windows\System32\ole32.dll
6348 TheGame.exe 0x7ff8b86b0000 0x354000 combase.dll C:\Windows\System32\combase.dll
6348 TheGame.exe 0x7ff8b7970000 0xcd000 OLEAUT32.dll C:\Windows\System32\OLEAUT32.dll
6348 TheGame.exe 0x7ff8b73a0000 0x82000 bcryptPrimitives.dll C:\Windows\System32\bcryptPrimitives.dll
6348 TheGame.exe 0x7ff892000000 0xc8d000 System.Private.CoreLib.dll C:\Users\architect\Desktop\publish\System.Private.CoreLib.dll
6348 TheGame.exe 0x7ff8a6a70000 0x1b9000 clrjit.dll C:\Users\architect\Desktop\publish\clrjit.dll
6348 TheGame.exe 0x7ff8b57e0000 0x12000 kernel.appcore.dll C:\Windows\SYSTEM32\kernel.appcore.dll
6348 TheGame.exe 0x7ff8ab0a0000 0xa000 TheGame.dll C:\Users\architect\Desktop\publish\TheGame.dll
6348 TheGame.exe 0x1492ecd0000 0xe000 System.Runtime.dll C:\Users\architect\Desktop\publish\System.Runtime.dll
A lot of loaded libraries, and we see the path of the executable being in \Desktop\publish\theGame.exe
, so this is definitely not something built-in.
We see the coreclr.dll
library, and a quick search later we find the now archived Microsoft repository that quotes: "CoreCLR is the runtime for .NET Core. It includes the garbage collector, JIT compiler, primitive data types and low-level classes."
So this is a .NET
project, and judging by the number of libraries contained in the same path as the executable and the fact that the core runtime and the JIT compiler are provided we can deduce that this is a self-contained executable that ships the .NET runtime itself so it doesn't rely on the installation of a specific runtime version.
The common "convention" is that the project name is the executable as well, however, being cross-platform the exe serves just as a native loader for the theGame.dll
. Had this been a Linux system the native loader would have been an ELF
file and a Mach-O
for MacOS.
Let's dump the dlls
and examine them (to dump, just add the --dump
switch to the plugin command line). Running file
on them confirms that the executable is native and the dll
is a .NET
assembly:
$ file *
[...REDACTED...]
pid.6348.TheGame.dll.0x1492d28e8b0.0x7ff8ab0a0000.dmp: PE32+ executable (DLL) (console) x86-64 Mono/.Net assembly, for MS Windows, 3 sections
pid.6348.theGame.exe.0x1492d2725a0.0x7ff6dc8d0000.dmp: PE32+ executable (console) x86-64, for MS Windows, 7 sections
So evidently the file in question is theGame.dll
Let's analyze it using ILSpy
on Windows (more on 'why' later)
ILSpy
yields the following code:
private static void Main(string[] argv)
{
string[] array = new string[8] { "usp10.dll", "rasapi32.dll", "dwmapi.dll", "mpr.dll", "advpack.dll", "winspool.drv", "imm32.dll", "msimg32.dll" };
nint[] array2 = new nint[array.Length];
for (int i = 0; i < array.Length; i++)
{
nint num = LoadLibraryA(array[i]);
if (num == IntPtr.Zero)
{
Console.WriteLine("Unable to load " + array[i]);
}
array2[i] = num;
}
string s = "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCoNCg==";
byte[] array3 = Convert.FromBase64String(s);
string @string = Encoding.UTF8.GetString(array3);
Console.WriteLine("Initializing AMSI Interface!");
if (AmsiInitialize("Testing", out var amsiContextHandle) != 0)
{
Environment.Exit(-1);
}
int millisecondsTimeout = 600000;
Console.WriteLine("AMSI Connection Established!");
Console.WriteLine("Testing against known malicious sample!");
if (AmsiScanString(amsiContextHandle, @string, "TEST", IntPtr.Zero, out var result) != 0)
{
Environment.Exit(-2);
}
if (result == AmsiResult.AMSI_RESULT_DETECTED)
{
Console.WriteLine("Testing String finished!");
Console.WriteLine("Scanning malicious code: kernel32.dll");
}
byte[] array4 = File.ReadAllBytes("C:\\Windows\\system32\\kernel32.dll");
if (AmsiScanBuffer(amsiContextHandle, array4, array4.Length, "TEST", IntPtr.Zero, out result) != 0)
{
Environment.Exit(-3);
}
if (result == AmsiResult.AMSI_RESULT_NOT_DETECTED)
{
Console.WriteLine("Scanning finished, no threads found!");
}
try
{
Console.WriteLine("Clean library detected -> Backdooring!");
FileStream fileStream = File.OpenWrite("C:\\Windows\\system32\\kernel32.dll");
fileStream.Seek(fileStream.Length, SeekOrigin.Begin);
fileStream.Write(array3, 0, array3.Length);
}
catch
{
}
Console.WriteLine("Sleeping forever");
Thread.Sleep(millisecondsTimeout);
OpCodes.Nop.ToString();
[...REDACTED...]
}
If we analyze the code we find that it imports some functions from the amsi.dll
library and uses it to scan the EICAR string. And then supposedly backdoors, with said string the kernel32.dll
. This is impossible because you could not obtain a write lock on such an important system file even as Admin. And even if one did manage to get such a lock, kernel32.dll
is used on every process so it can't be overwritten while the system is running. Hence the try/catch
block, this is sure to fail!
Besides the Console.WriteLine()
's and the amsi.dll
function we find a large amount of OpCodes.Nop
instructions which effectively inject the NOP
IL instruction.
So the binary effectively does absolutely nothing, besides wasting space that is... So what gives? Let's go a few steps back and examine the first couple of lines.
string[] array = new string[8] { "usp10.dll", "rasapi32.dll", "dwmapi.dll", "mpr.dll", "advpack.dll", "winspool.drv", "imm32.dll", "msimg32.dll" };
nint[] array2 = new nint[array.Length];
for (int i = 0; i < array.Length; i++)
{
nint num = LoadLibraryA(array[i]);
if (num == IntPtr.Zero)
{
Console.WriteLine("Unable to load " + array[i]);
}
array2[i] = num;
}
It uses the LoadLibraryA
function to load the aforementioned dlls
and add their handles to array2
.
HOWEVER, if we look again at the loaded dlls
from before, we see no mention of these! Before you say "But the array2
variable is not used so it could be optimized out". Yes, that could be the case, but neither amsi.dll
is found and is used...
If we trust volatility that it shows us the correct output, then the other logical explanation would the libraries were never loaded, and that would mean that either the code is faulty, or the code never ran in the first place. We could test the code ourselves to see if that is valid but we suspect the malicious actor already tried that. But how on earth would the code not run, there are no checks!
Let's take a step back and examine what we have. We have a binary that supposedly loads a couple of libraries but it never actually does. In fact, we are not even sure if the code we see runs entirely! This discrepancy between "source code" and compiled code strongly hints at a technique called "Stomping". A technique predominately used in malicious office documents containing macros, where the compiled code is different from the macro code we observe when we open the document.
But is stomping a thing here? Turns out very much so!
Let's take a primer on some .NET internals.
When we write some C# code for a .NET project, that code will be compiled down to something called an IL (Intermediate Language) representation. Since .NET is cross-platform that same IL code will be run on top of the runtime on each system, kind of what Java does with its .class
extension making it universal and portable.
However, the caveat is that when the IL code needs to be run the JIT (Just In Time) compiler steps in and compiles the IL instructions into native code (depending on the platform). This little step might be too much for some speed-critical applications that require a fast startup time as well. So how can we optimize this?
Enter, AOT (Ahead Of Time) compilation! Unlike JIT, which compiles each instruction when needed, the AOT compiles the entire code base into a native binary! But this is not a native binary (per se) because we can see the decompilation.
Microsoft developed a form of AOT called R2R (ReadyToRun) which creates binaries of Mixed Mode Assemblies meaning they contain both IL code to be run on cross-platform machines and native code to be run on native machines!
A R2R binary is larger because they contain both types of instructions but it runs faster on native platforms because it bypasses the full usage of the JIT. And since this is an optimization step the native code matches the platform on the system being run on, it will take precedence over the IL code!
But how do we confirm all of this? And if so, how do we find that native code?
To confirm this we need to look into a couple of places using a plethora of tools:
- In
DnSpy
in thePE Flags
section we can take a look at theCor20
header:
We find the ManagedNativeHeader.VirtualAddress
is populated -> This means we have some native code present in the binary
- If we open it up in dotPeek it will detect the
R2R
header and denote to us that this is indeed an R2R executable and we can see the compiler version as well:
- We can also do this manually by examining the hex dump of the binary and by searching for
R2R
:
To sum up:
- We are dealing with an R2R binary
- The decompiled code we see is not the code that actually runs
- Where do we find said code?
This is a fairly new attack vector and you can read all about it in this blog post, which shows not only how to analyze it but also how to create such a stompted file!
There are 2 ways to stomp the binary:
- Compile malicious code and edit out the IL instructions
- In this case, the malicious native code is still present
- Compile normal decoy code and replace the native code (<-- Our case)
- The
NOP
instructions were there just to allocate enough space for the shellcode
- The
The reason why it is so prevalent is because tools like DnSpy, ILSpy, dotPeek
do not show the IL code disassembly by default! They infer C# code based on the IL instructions. And many of these tools can't even analyze R2R binaries, as per the blog post, the recommended tool to perform such analysis is ILSpy
! Also, there aren't many tools available to detect stomping like oletools
can for the macros. There exists R2RDump but being an optional tool for the dotnet
runtime, we need to compile it ourselves and that entails that we compile the whole dotnet
runtime...
If we had the first case of stomping, ILSpy
would've been able to reconstruct the real IL instructions based on the opcodes in the native code!
Since this is the latter though, let's open it up in ILSpy
and go from there:
We find the same code, unless, we select the ReadyToRun
option for disassembly output! Opening up a new tab switching that to IL
mode and putting them side by side finally settles everything!
On the left are the IL instruction and on the right we can see the native code (shellcode). We can definitely see the difference between the shellcode and the IL code, the first couple of 100 lines of the shellcode are just the mov
instruction, no address loading, no index increment for the loop, no call
instructions, no branching, etc.
We know the shellcode starts at 0x1DB0
and it finishes just before the first call to the bunch of NOPs at 0x33B0
:
Using a hex editor we can carve out the shellcode to analyze it further:
From here we can use a shellcode emulator like speakeazy to emulate the shellcode or embed it into a stub and debug it with x64dbg.
Keep in mind that the shellcode contains some checks about whether it's being debugged along some other things, so if you decide to go down to the reversing path, some instructions may need to be bypassed
Since this is a forensic challenge we will cover the emulation approach.
After installing speakeasy
we run it as follows, specifying the architecture and the raw
switch:
$ speakeasy -t shellcode -r -a amd64
* exec: shellcode
0x1d19: 'kernel32.LoadLibraryA("Advapi32.dll")' -> 0x78000000
0x1d6c: 'kernel32.IsDebuggerPresent()' -> 0x0
0x1f09: 'kernel32.GetComputerNameExA("ComputerNameNetBIOS", "speakeasy_host", 0xe)' -> 0x1
0x1f18: 'kernel32.lstrlenA("speakeasy_host")' -> 0xe
0x1f2b: 'kernel32.lstrlenA("ARCH_WORKSTATION-7")' -> 0x12
0x1f44: 'kernel32.Sleep(0x493e0)' -> None
* Finished emulating
On the first run, the shellcode seems to perform a check between the computer name and the expected value. Since it exits we assume that this is a terminal condition so we need to change our emulator's computer name to bypass the check!
Thankfully, speakeasy
provides us with a config file that specifies such values. After changing the hostname to what seems to be the expected value of ARCH_WORKSTATION-7
we re-run:
This time we need to provide the custom config using the -c default.json
:
$ speakeasy -t shellcode -r -a amd64 -c default.json
* exec: shellcode
0x1d19: 'kernel32.LoadLibraryA("Advapi32.dll")' -> 0x78000000
0x1d6c: 'kernel32.IsDebuggerPresent()' -> 0x0
0x1f09: 'kernel32.GetComputerNameExA("ComputerNameNetBIOS", "ARCH_WORKSTATION-7", 0x12)' -> 0x1
0x1f18: 'kernel32.lstrlenA("ARCH_WORKSTATION-7")' -> 0x12
0x1f2b: 'kernel32.lstrlenA("ARCH_WORKSTATION-7")' -> 0x12
0x1f8d: 'advapi32.OpenSCManagerA(0x0, 0x0, 0xf003f)' -> 0xa500
0x1faf: 'advapi32.OpenServiceA(0xa500, "Wecsvc", 0x20)' -> 0x2804
0x1fd1: 'advapi32.OpenServiceA(0xa500, "EventLog", 0x20)' -> 0x2808
0x1ff5: 'advapi32.ControlService(0x2804, 0x1, 0x1203e48)' -> 0x1
0x2018: 'advapi32.ControlService(0x2808, 0x1, 0x1203e50)' -> 0x1
0x204b: 'kernel32.WinExec("cmd /c "net user game-architect-02 HTB{REDACTED} /add /y && net localgroup administrators game-architect-02 /add"", 0x0)' -> 0x20
0x2054: 'kernel32.ExitProcess(0x0)' -> 0x0
0x2054: 'kernel32.ExitProcess(0x0)' -> 0x0
* Finished emulating
We see that the shellcode gets a handle on the Service Manager and uses it to then get a handle on the Wecsvc
and EventLog
Services. Then it stops them using the ConstrolService()
function and the 0x1
argument.
After killing the Event Log Service, the shellcode adds a backdoored user with the flag as the password!