Lesson 10.2 · Beyond the EXE· 45 min
Analysing .NET Malware
How to recognise a .NET assembly, read its metadata and IL, pick the right decompiler, and get past obfuscators and in-memory stages.
Objectives
- Recognise a .NET assembly from its CLR header, mscoree import and Detect It Easy output
- Explain what the CLR metadata streams and IL contain and why managed code decompiles to near-source
- Choose between dnSpyEx, ILSpy, de4dot and dnfile for a given analysis task
- Get past name obfuscation, string encryption, control-flow obfuscation and in-memory Assembly.Load stages
- Locate configuration values in a .NET sample and record them in a report
A large share of commodity malware — remote access trojans, info-stealers, loaders, crypters — is written in C# or VB.NET. For an analyst this is mostly good news. A .NET program is not compiled to x86 instructions but to an intermediate bytecode, and it carries a detailed description of its own types and methods. With the right tool you read something very close to the original source, often in minutes.
The bad news is that malware authors know this too. Almost every .NET sample you meet in the wild has been through an obfuscator, and many are only a thin first stage that decrypts and loads the real payload in memory. This lesson shows how to recognise managed code, what the file actually contains, which tools to use, and how to get past the obstacles that stand between you and the configuration.
Recognising a .NET assembly
A .NET executable is still a PE file, so everything from PE Headers applies. Three signs tell you it is managed code:
| Sign | Where to look | What you see |
|---|---|---|
| CLR Runtime Header | Data directory 14 | Non-zero RVA and a size of 72 bytes (IMAGE_COR20_HEADER) |
| mscoree import | Import table | A single import: _CorExeMain (EXE) or _CorDllMain (DLL) from mscoree.dll |
| Tool verdict | Detect It Easy | "Library: .NET" with a framework version, often an obfuscator name too |
The import is the one Resources, Overlays and Other Hiding
Places warned about: the native
entry point is a stub that jumps to _CorExeMain,
which hands control to the Common Language Runtime (CLR). On modern Windows the
loader recognises the CLR header and does not even run the stub. Either way,
disassembling it teaches you nothing. A sample whose import table is just one
mscoree.dll function is not "suspiciously minimal" in the way a packed native
file would be — it is simply managed. Treat the usual import-based
triage as not applicable and switch tools.
Two variants deserve a note. Single-file bundles produced by modern .NET are a native host executable with managed assemblies appended; Detect It Easy reports them, and you must extract the embedded assemblies before decompiling (ILSpy can open such bundles). Native AOT builds are compiled all the way to machine code and contain no IL: they look and analyse like any native binary, and a .NET decompiler cannot help.
Tip: Detect It Easy's .NET verdict usually names the obfuscator or protector too (ConfuserEx, .NET Reactor, SmartAssembly, Eazfuscator and many others). Write that name down first: it decides your next hour.
What the file contains: metadata and IL
The CLR header points at two things: the metadata and the method bodies,
which are Common Intermediate Language (CIL or IL) bytecode. The metadata
root (signature BSJB) is followed by a handful of streams:
| Stream | Contents | Analyst value |
|---|---|---|
#~ | Metadata tables: TypeDef, MethodDef, Field, MemberRef, TypeRef, ManifestResource, Constant… | The program's full structure |
#Strings | UTF-8 identifiers: type, method, field and namespace names | Readable names (unless renamed) |
#US | "User strings": every literal loaded by ldstr, in UTF-16 | URLs, paths, keys, encoded config |
#GUID | GUIDs, including the module version ID (MVID) | Clustering |
#Blob | Signatures, constant values, custom attribute arguments | Constants and attributes such as the TypeLib GUID |
Metadata exists because the runtime needs it. The CLR verifies types, resolves
calls by name across assemblies, supports reflection and just-in-time (JIT)
compiles each method on first use. So every class, method signature, field and
call target stays in the file. Calls to the framework go through the
MemberRef table as System.Convert::FromBase64String or
System.Reflection.Assembly::Load — that table plays the role the import table
plays for native code, and it is far more precise.
IL itself is a stack machine: ldstr pushes a string, call pops arguments and
pushes a result, stloc stores into a local slot. Compilers barely optimise it,
because the JIT does that later. Together with complete type information, this
is why decompilers recover C# that is structurally
close to the source — the reverse of the situation described in Decompilers
and Their Limits, where types and signatures
had to be reconstructed by hand. What is lost is small: local variable names
(stored only in separate PDB files) and comments.
References inside IL are metadata tokens: a 32-bit value whose top byte
names the table and whose low 24 bits are the row. 0x06000004 is row 4 of
MethodDef (table 0x06); 0x70000001 is offset 1 in the #US heap. You will
see tokens in dnSpyEx, in de4dot options and in your notes.
Tools and when to use each
| Tool | Type | Use it for |
|---|---|---|
| dnSpyEx | Decompiler, debugger, editor (Windows) | Main workbench: read C#, set breakpoints in decompiled code, inspect locals, dump modules, patch methods |
| ILSpy | Decompiler (cross-platform) | Clean reading and exporting a whole assembly as a C# project; second opinion when dnSpyEx output looks odd |
| de4dot | Deobfuscator (command line) | Detecting and removing known obfuscators: renaming, string decryption, control-flow cleanup |
| dnfile | Python library | Scripted triage and config extractors: tables, #US, resources, at scale |
| monodis / ildasm | IL disassemblers | Raw IL when the decompiler hides or mangles something |
dnSpyEx is the maintained community fork of the original dnSpy, which was archived in 2020. Its debugger is the reason it dominates malware work: you debug at C# level, without source, and can break inside any method you can read. ILSpy's decompiler engine is also the one dnSpyEx builds on, so their output is similar; ILSpy tends to follow new C# language features first.
de4dot is archived and no longer updated, and it lags behind current versions of the commercial protectors. It is still the fastest win when it recognises the obfuscator, and forks exist for specific protectors. Run it on a copy and keep the original for hashing and reporting.
Tip: Never run a sample to "see what it does" in dnSpyEx outside your analysis lab. The debugger executes the real code. Set your breakpoints before pressing Start.
Obstacles and how analysts get past them
Name obfuscation
Renamers replace every identifier with meaningless, unprintable or
deliberately confusing names — Unicode look-alikes, names that differ only in
invisible characters, dozens of overloads called the same thing. The code still
runs because the CLR resolves by token, not by readable name. Framework calls
cannot be renamed, so MemberRef entries such as HttpWebRequest,
RegistryKey::SetValue or Assembly::Load remain your anchors. de4dot rewrites
the names into readable placeholders (Class3, method_7); from there you
rename as you understand, exactly as in native analysis.
String encryption
The obfuscator replaces each ldstr with a call such as
Class5.smethod_2(1842) that decrypts a string from an encrypted blob at run
time. The #US heap then holds nothing useful — the analogue of
XOR string encryption in native code.
Three ways through, in order of preference:
- de4dot, if it recognises the obfuscator, decrypts and inlines strings automatically.
- de4dot with a named decrypter: give it the decryption method's token
(
--strtyp delegate --strtok 0x06000123) and it calls that method for every call site. This executes sample code, so do it only in the lab. - Debugger: set a breakpoint on the decrypter's
returnin dnSpyEx and read the result in the Locals window, or break after the whole settings block has been decrypted.
Control-flow obfuscation
Method bodies are rewritten into a switch inside a loop driven by a state
variable, with opaque predicates and junk
branches — control-flow flattening
applied to IL. The decompiler still produces valid C#, but a 20-line method
becomes 200. de4dot undoes the common schemes. When it cannot, do not read the
state machine: debug through it and watch which calls actually happen.
Encrypted method bodies
Some protectors (ConfuserEx's anti-tamper mode is the classic example) encrypt the IL of every method and decrypt it in the module's static constructor before anything else runs. The decompiler shows empty or invalid bodies. Let the constructor run under the debugger, then dump the module from memory: the dumped file has decrypted bodies that decompile normally.
Resources holding further stages
.NET assemblies carry manifest resources, listed in the ManifestResource
table and read at run time with GetManifestResourceStream. Some are .resources
containers holding named entries (images, strings, byte arrays); others are raw
blobs. Loaders hide the next stage here — sometimes inside a bitmap's pixel
data — encrypted, compressed or both. Extract every resource, check its size
and entropy, and find the code that reads it by searching for the resource name
or for calls to GetManifestResourceStream and ResourceManager.
Payloads loaded in memory
The signature move of .NET loaders is Assembly.Load(byte[]): decrypt a byte
array, load it as an assembly without touching disk, find its entry point by
reflection and Invoke it. It is the managed cousin of reflective DLL
injection, and the next stage never
exists as a file you could hash.
The fix is to catch the bytes. In dnSpyEx, either set a breakpoint on
Assembly.Load and save the byte[] argument from the Locals window
(right-click, Save), or let the call complete and open Debug → Windows →
Modules: in-memory assemblies appear in the list and can be saved to disk with
a right-click. The dumped assembly is then a new sample — triage it from the
start. Some stages are chained three or four deep; repeat until you reach code
that does something other than load code.
Anti-analysis checks
Managed malware checks Debugger.IsAttached, calls IsDebuggerPresent through
P/Invoke, looks for sandbox artefacts or sleeps before acting. They are easy to
spot in decompiled code, and dnSpyEx lets you edit the method (right-click,
Edit Method (C#)), recompile it and save a patched copy. Anti-debugging
in general is covered in Module 9's Anti-Debugging lesson.
Tip: Identify before you fight. Detect It Easy's protector name, a quick
de4dot -d sample.exe(detect only) and a look at the first class names tell you whether a known cleaner exists. Hours spent hand-reversing a string decrypter that de4dot already supports are hours wasted.
Where configuration lives
In .NET samples the configuration — C2 hosts and ports, mutex name, install path, encryption keys, campaign or build ID — is usually concentrated in one place. Look in this order:
- A settings class. Many families keep a static class of fields assigned
in its static constructor (
.cctor). Values are often Base64 strings, sometimes AES-encrypted with a key that is itself a field of the same class; open-source RATs such as AsyncRAT follow this pattern. - The
#USheap for anything not encrypted — every literal loaded by the code is there, UTF-16 encoded. - Static byte arrays: an array initialised from data is compiled to a call
to
RuntimeHelpers.InitializeArraywith a field stored in the file itself (look for<PrivateImplementationDetails>). - Resources, especially small ones with high entropy or a custom name.
- Memory, when all of the above is encrypted: break after the decryption routine and read the values.
Once you know the pattern, write a small dnfile script that pulls the values statically; Module 8's lesson on extracting malware configurations builds full extractors on this idea.
What to put in the report
Add a .NET section to the triage report structure you already use:
- Runtime facts: .NET Framework or modern .NET, target architecture, whether it is a single-file bundle, mixed-mode or Native AOT.
- Protection: obfuscator or protector name and how you identified it; which cleaning steps you applied, with the tool version. Hash the cleaned and dumped files separately and label them — they are derived artefacts, not samples seen in the wild.
- Identifiers: MVID and TypeLib GUID. The MVID changes with each build, but
the TypeLib GUID (the
Guidattribute Visual Studio writes into project templates) stays stable across builds of one project and is a good clustering and YARA pivot — YARA'sdotnetmodule exposes both. - Stage chain: each loaded assembly, how it was stored (resource, array, download), how it was decrypted, and its hash.
- Configuration and IOCs, with the method token where each was decoded, so another analyst can reproduce it.
Lab: a config hidden two ways
You will build a harmless C# program that hides a demo configuration twice — a Base64 literal and an embedded resource — then find both without running a decompiler GUI. The outputs below are real, from Mono 6.14.1 on macOS with Python 3.14, dnfile 0.18.0 and pefile 2024.8.26. Other versions may differ slightly in layout.
-
Create
config.txt:text interval=3600 mode=demo -
Create
LabDemo.cs:text // LabDemo.cs: a harmless program that hides a demo config two ways using System; using System.IO; using System.Reflection; using System.Text; namespace LabDemo { static class Settings { // Base64 of "server=update.example.com" const string Encoded = "c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ=="; public static string DecodeServer() { byte[] raw = Convert.FromBase64String(Encoded); return Encoding.UTF8.GetString(raw); } public static string ReadEmbedded() { Assembly self = Assembly.GetExecutingAssembly(); using (Stream s = self.GetManifestResourceStream("LabDemo.config.txt")) using (StreamReader r = new StreamReader(s)) { return r.ReadToEnd(); } } } class Program { static void Main() { Console.WriteLine("decoded: " + Settings.DecodeServer()); Console.Write("resource: " + Settings.ReadEmbedded()); } } } -
Compile with Mono's C# compiler, embedding the resource under a logical name, and run it (install Mono with
brew install monoor your distribution'smono-develpackage):bash mcs -out:LabDemo.exe -resource:config.txt,LabDemo.config.txt LabDemo.cs mono LabDemo.exe file LabDemo.exetext decoded: server=update.example.com resource: interval=3600 mode=demo LabDemo.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows -
Try plain
stringsfirst:bash strings LabDemo.exe | grep -E "LabDemo|Base64|interval|Settings|mscoree"text interval=3600 LabDemo Settings FromBase64String LabDemo.config.txt LabDemo.exe mscoree.dllThe identifiers come from
#Strings(UTF-8) and the resource is stored in plain text, but the Base64 literal is missing:#USis UTF-16, which an ASCII-onlystringspass skips. This is why Strings and Obfuscated Strings insists on searching both encodings. -
Disassemble to IL with
monodis:bash monodis LabDemo.exe > LabDemo.ilAn excerpt of
DecodeServer:text .field private static literal string Encoded = "c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ==" ... IL_0000: ldstr "c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ==" IL_0005: call unsigned int8[] class [mscorlib]System.Convert::FromBase64String(string) IL_000a: stloc.0 IL_000b: call class [mscorlib]System.Text.Encoding class [mscorlib]System.Text.Encoding::get_UTF8() IL_0010: ldloc.0 IL_0011: callvirt instance string class [mscorlib]System.Text.Encoding::GetString(unsigned int8[]) IL_0016: retClass, method and field names survive, and every framework call is spelled out in full. Note that the
constvalue appears twice: once as the field's constant (Constanttable, stored in#Blob) and once as anldstroperand in#US, because the compiler copies constants into each use. -
Look at individual heaps and extract the resource:
bash monodis --userstrings LabDemo.exe monodis --mresources LabDemo.exe && cat LabDemo.config.txttext User Strings heap contents 00: "" 01: "c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ==" 4b: "LabDemo.config.txt" 71: "decoded: " 87: "resource: " ... interval=3600 mode=demo -
Script the same inspection in Python. Create a virtual environment so nothing is installed system-wide:
bash python3 -m venv venv ./venv/bin/pip install dnfile pefileSave
inspect_net.py:python import sys import dnfile import pefile path = sys.argv[1] # 1. Is it .NET? Check data directory 14 and the import table with pefile. pe = pefile.PE(path) clr = pe.OPTIONAL_HEADER.DATA_DIRECTORY[14] print(f"CLR header: rva=0x{clr.VirtualAddress:x} size={clr.Size}") for imp in pe.DIRECTORY_ENTRY_IMPORT: print("import:", imp.dll.decode(), [f.name.decode() for f in imp.imports]) # 2. Parse the CLR metadata with dnfile. dn = dnfile.dnPE(path) cor = dn.net.struct print(f"runtime {cor.MajorRuntimeVersion}.{cor.MinorRuntimeVersion}, " f"flags=0x{cor.Flags:x}, entry token=0x{cor.EntryPointTokenOrRva:08x}") print("metadata version:", dn.net.metadata.struct.Version.rstrip(b"\0").decode()) print("streams:", [s.decode() for s in dn.net.metadata.streams]) # 3. Types and methods from the TypeDef and MethodDef tables. for t in dn.net.mdtables.TypeDef: methods = [str(m.row.Name) for m in t.MethodList] print(f"type {t.TypeNamespace}.{t.TypeName}: {methods}") # 4. Referenced framework members (the .NET equivalent of imports). for m in dn.net.mdtables.MemberRef: parent = m.Class.row print(f"memberref {parent.TypeNamespace}.{parent.TypeName}::{m.Name}") # 5. The #US heap: string literals used by ldstr. us = dn.net.user_strings offset = 1 while offset < us.sizeof() and us.__data__[offset] != 0: # 0 = padding item = us.get(offset) if item is None: break if item.value: print(f"#US 0x{offset:x}: {item.value!r}") offset += item.raw_size # 6. Manifest resources, extracted to disk. for r in dn.net.resources: name = str(r.name) print(f"resource {name!r} public={r.public} size={len(r.data)}") with open("res_" + name, "wb") as f: f.write(r.data) -
Run it:
bash ./venv/bin/python inspect_net.py LabDemo.exetext CLR header: rva=0x2008 size=72 import: mscoree.dll ['_CorExeMain'] runtime 2.5, flags=0x1, entry token=0x06000004 metadata version: v4.0.30319 streams: ['#~', '#Strings', '#US', '#GUID', '#Blob'] type .<Module>: [] type LabDemo.Settings: ['DecodeServer', 'ReadEmbedded'] type LabDemo.Program: ['.ctor', 'Main'] memberref System.Convert::FromBase64String memberref System.Text.Encoding::get_UTF8 memberref System.Text.Encoding::GetString memberref System.Reflection.Assembly::GetExecutingAssembly memberref System.Reflection.Assembly::GetManifestResourceStream ... #US 0x1: 'c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ==' #US 0x4b: 'LabDemo.config.txt' #US 0x71: 'decoded: ' #US 0x87: 'resource: ' resource 'LabDemo.config.txt' public=True size=24Every recognition sign from the start of the lesson is here: a 72-byte CLR header, a lone
_CorExeMainimport, and the five metadata streams. The header's runtime version reads 2.5 for every modern assembly; the metadata version string (v4.0.30319) is the one that names the framework. Flags0x1means IL-only. The entry token0x06000004isMethodDefrow 4 —Main, the fourth method in the listing.res_LabDemo.config.txton disk now holds the resource. -
Decode the literal to confirm it:
bash echo c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ== | base64 -d -
On Windows (inside your lab VM), repeat the analysis in the GUIs. In dnSpyEx, use File → Open on
LabDemo.exe, expand the assembly and theLabDemonamespace, and clickSettings: the C# view shows the constant and both methods. Expand Resources, right-clickLabDemo.config.txtand choose Save to extract it. Right-clickDecodeServerand choose Analyze to see "Used By" (Main). Then set a breakpoint on thereturnline ofDecodeServer(F9), start debugging (F5) and read the decoded string in the Locals window. In ILSpy, open the same file, browse the same tree, and use File → Save Code on the assembly to export a C# project.
Questions to answer: Why did strings find FromBase64String but not the
Base64 literal? If the author had encrypted the literal with AES instead of
Base64, which MemberRef entries would betray it, and where would you set a
breakpoint to read the plaintext? Replace the const with a static readonly
field and rebuild: where does the literal now live in the IL, and why? How would
you change inspect_net.py to flag any resource whose first two bytes are
MZ?
Key takeaways
- A non-empty data directory 14 and a single
mscoree.dllimport (_CorExeMainor_CorDllMain) mean managed code: stop disassembling x86 and switch to .NET tools. - The CLR keeps full metadata — types, methods, fields, framework references,
string literals in
#US— which is why IL decompiles to near-source C#. - Use dnSpyEx as the workbench (decompile, debug, dump, patch), ILSpy for clean reading and export, de4dot to strip known obfuscators, and dnfile to script triage and extraction.
- Identify the obfuscator before fighting it; when static cleaning fails, run under the debugger and break after decryption.
Assembly.Load(byte[])stages never touch disk: catch the byte array or dump the module from the debugger, then triage each stage as a new sample.- Report the protector, the stage chain with hashes, the configuration with the method that decodes it, and stable identifiers such as the TypeLib GUID.