Skip to content

Leçon 10.2 · Au-delà de l'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.

Cette leçon n’est disponible qu’en anglais pour le moment.

Objectifs

  • 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:

SignWhere to lookWhat you see
CLR Runtime HeaderData directory 14Non-zero RVA and a size of 72 bytes (IMAGE_COR20_HEADER)
mscoree importImport tableA single import: _CorExeMain (EXE) or _CorDllMain (DLL) from mscoree.dll
Tool verdictDetect 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:

StreamContentsAnalyst value
#~Metadata tables: TypeDef, MethodDef, Field, MemberRef, TypeRef, ManifestResource, Constant…The program's full structure
#StringsUTF-8 identifiers: type, method, field and namespace namesReadable names (unless renamed)
#US"User strings": every literal loaded by ldstr, in UTF-16URLs, paths, keys, encoded config
#GUIDGUIDs, including the module version ID (MVID)Clustering
#BlobSignatures, constant values, custom attribute argumentsConstants 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

ToolTypeUse it for
dnSpyExDecompiler, debugger, editor (Windows)Main workbench: read C#, set breakpoints in decompiled code, inspect locals, dump modules, patch methods
ILSpyDecompiler (cross-platform)Clean reading and exporting a whole assembly as a C# project; second opinion when dnSpyEx output looks odd
de4dotDeobfuscator (command line)Detecting and removing known obfuscators: renaming, string decryption, control-flow cleanup
dnfilePython libraryScripted triage and config extractors: tables, #US, resources, at scale
monodis / ildasmIL disassemblersRaw 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:

  1. de4dot, if it recognises the obfuscator, decrypts and inlines strings automatically.
  2. 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.
  3. Debugger: set a breakpoint on the decrypter's return in 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:

  1. 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.
  2. The #US heap for anything not encrypted — every literal loaded by the code is there, UTF-16 encoded.
  3. Static byte arrays: an array initialised from data is compiled to a call to RuntimeHelpers.InitializeArray with a field stored in the file itself (look for <PrivateImplementationDetails>).
  4. Resources, especially small ones with high entropy or a custom name.
  5. 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 Guid attribute Visual Studio writes into project templates) stays stable across builds of one project and is a good clustering and YARA pivot — YARA's dotnet module 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.

  1. Create config.txt:

    text
    interval=3600
    mode=demo
  2. 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());
            }
        }
    }
  3. Compile with Mono's C# compiler, embedding the resource under a logical name, and run it (install Mono with brew install mono or your distribution's mono-devel package):

    bash
    mcs -out:LabDemo.exe -resource:config.txt,LabDemo.config.txt LabDemo.cs
    mono LabDemo.exe
    file LabDemo.exe
    text
    decoded:  server=update.example.com
    resource: interval=3600
    mode=demo
    LabDemo.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
  4. Try plain strings first:

    bash
    strings LabDemo.exe | grep -E "LabDemo|Base64|interval|Settings|mscoree"
    text
    interval=3600
    LabDemo
    Settings
    FromBase64String
    LabDemo.config.txt
    LabDemo.exe
    mscoree.dll

    The identifiers come from #Strings (UTF-8) and the resource is stored in plain text, but the Base64 literal is missing: #US is UTF-16, which an ASCII-only strings pass skips. This is why Strings and Obfuscated Strings insists on searching both encodings.

  5. Disassemble to IL with monodis:

    bash
    monodis LabDemo.exe > LabDemo.il

    An 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:  ret

    Class, method and field names survive, and every framework call is spelled out in full. Note that the const value appears twice: once as the field's constant (Constant table, stored in #Blob) and once as an ldstr operand in #US, because the compiler copies constants into each use.

  6. Look at individual heaps and extract the resource:

    bash
    monodis --userstrings LabDemo.exe
    monodis --mresources LabDemo.exe && cat LabDemo.config.txt
    text
    User Strings heap contents
    00: ""
    01: "c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ=="
    4b: "LabDemo.config.txt"
    71: "decoded:  "
    87: "resource: "
    ...
    interval=3600
    mode=demo
  7. 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 pefile

    Save 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)
  8. Run it:

    bash
    ./venv/bin/python inspect_net.py LabDemo.exe
    text
    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=24

    Every recognition sign from the start of the lesson is here: a 72-byte CLR header, a lone _CorExeMain import, 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. Flags 0x1 means IL-only. The entry token 0x06000004 is MethodDef row 4 — Main, the fourth method in the listing. res_LabDemo.config.txt on disk now holds the resource.

  9. Decode the literal to confirm it:

    bash
    echo c2VydmVyPXVwZGF0ZS5leGFtcGxlLmNvbQ== | base64 -d
  10. On Windows (inside your lab VM), repeat the analysis in the GUIs. In dnSpyEx, use File → Open on LabDemo.exe, expand the assembly and the LabDemo namespace, and click Settings: the C# view shows the constant and both methods. Expand Resources, right-click LabDemo.config.txt and choose Save to extract it. Right-click DecodeServer and choose Analyze to see "Used By" (Main). Then set a breakpoint on the return line of DecodeServer (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.dll import (_CorExeMain or _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.