dotnet - 1-3-7/disrobe GitHub Wiki

.NET / CIL

disrobe parses .NET PE and CLR metadata, decompiles CIL to C#, F#, and VB pseudo-source, registers detection and routing for 23 protector families, and probes ReadyToRun and Native AOT images.

At a glance

Surface Support
Decompile In-house CIL disassembler and CIL-to-C#/F#/VB lowering, so the structural recovery is disrobe's own even when a rendering backend is used
Rendering backends ILSpy, dnSpy, dnSpyEx, de4dot via --backend
Images ReadyToRun (R2R) and Native AOT detection; dotnet native-aot exposes the library's name, type, method-boundary, and managed-signature recovery, graded on real Native AOT builds for PE and on hand-assembled ELF and Mach-O images that carry only a header and two metadata names; single-file bundles extracted member by member
Native boundaries dotnet interop lists every P/Invoke import (module, entry point or ordinal, character set, calling convention, SetLastError, exact spelling, best-fit and unmappable-character options, PreserveSig), every LibraryImport declaration with its library, entry point and string marshalling, every UnmanagedCallersOnly export with its entry point and calling conventions, and every COM import with its GUID; graded against System.Reflection.Metadata on a compiled sample
ConfuserEx2 / ConfuserEx 1.0.0 Anti-tamper, constants, control-flow flattening, reference proxies, renamed closure types and the injected anti-debug/anti-dump runtime are neutralised statically on real ConfuserEx2 1.6.0 and ConfuserEx 1.0.0 output; the recovered C# of the gauntlet program (all four 1.0.0 presets), of the behaviour suite at the minimum preset and of HelloAppLegacy compiles and prints what the unprotected build prints. The encrypted-resource layer is carved byte-exact but walled on the runtime key; flattening inside exception regions (the behaviour suite at normal, aggressive and maximum) and array-initializer constants are named residuals. Anti-tamper, anti-debug and anti-dump removals are also offered as an opt-in patch plan with per-patch evidence, original bytes and risk note
BitMono AntiILdasm/BitDotNet header corruption repaired, StringsEncryption decrypted, DotNetHook and CallToCalli indirection folded back to direct calls (registration tokens mapped through the writer's row layout, instance targets matched through the dummy's receiver parameter), AntiDebugBreakpoints timing checks and unreachable prefix junk removed, renamer output and compiler-generated closure names repaired; the recovered gauntlet and behaviour-suite programs of BitMono 0.41.1 and 0.45.0 (Minimal, Balanced and Maximum, the last of which no longer loads on CoreCLR) compile and print what the unprotected builds print. Header repair, timing checks and hook redirections are also offered as an opt-in patch plan
Eazfuscator.NET A generic XOR string decryptor emulated over the #US table, graded on a synthetic fixture rather than Eazfuscator.NET output; VM tier devirtualized at all 67 instructions against an in-repo EazVM virtualizer of our own, not the shipping product
KoiVM Devirtualized on a committed sample produced by the real KoiVM tool, all six virtualized bodies lifted back to CIL
SmartAssembly, .NET Reactor Embedded-assembly resource decompressed and encrypted-string table decrypted, graded against Roslyn-built fixtures we build to the published algorithm; no assembly produced by either product is committed
Obfuscar Dedicated in-house peeler: NameMaker odometer classification plus HideStrings recovery; the recovered gauntlet program and HelloAppLegacy compile and print what the unprotected builds print, with renamed members disambiguated for C# name lookup
ILProtector, MaxToCode Invoke-stub and zero-RVA structures enumerated on in-repo fixtures; native-keyed configurations remain report-only
Themida .NET, ArmDot Detected; no native-VM devirtualizer ships

Other registered families produce reports identifying the protector and the static recovery available for that input. Bodies that require an unavailable runtime key remain encrypted.

Commands

disrobe dotnet decompile App.dll --backend ilspy --out src/
disrobe dotnet decompile App.exe --backend dnspy-ex --out src/
disrobe dotnet decompile App.single-file.exe --out recovered/
disrobe dotnet decompile App.dll --backend de4dot --out src/
disrobe dotnet analyze App.dll
disrobe dotnet native-aot App.native.exe --out aot.json
disrobe dotnet interop App.dll             # P/Invoke imports, LibraryImport, native exports, COM imports
disrobe dotnet backends                  # report available .NET backends on PATH
disrobe auto App.exe --out recovered/     # static protector peel + in-house CIL-to-C#

decompile always runs the in-house CIL renderer. --backend ilspy|dnspy|dnspy-ex|de4dot also runs that external decompiler; a named backend that is not installed fails with an error. disrobe auto stays on the registered in-house pass and does not launch those backends. analyze reports the PE and CLR summary, protector detection, and whether ReadyToRun (R2R) or Native AOT is detected. Use dotnet native-aot for the detailed AOT report and recovered method bodies.

Native boundaries

dotnet interop reads the ImplMap table, the LibraryImportAttribute and UnmanagedCallersOnlyAttribute custom attributes and the types marked ComImport. Each P/Invoke import names the managed method (nested types joined with /), the native module and entry point as written (an ordinal such as #1 stays as written), the character set, the calling convention, and the SetLastError, ExactSpelling, BestFitMapping, ThrowOnUnmappableChar and PreserveSig settings. Methods that the LibraryImport source generator wrote appear twice: once as the declaration (library, entry point, string marshalling, SetLastError) and once as the generated __PInvoke stub that carries the actual import. UnmanagedCallersOnly methods are listed with their export name and calling conventions; NativeAOT publishes those with an entry point as native exports.

The grader in crates/disrobe-pass-dotnet/tests/interop_reference.rs reads corpus/dotnet/interop/Interop.dll, a sample built by the .NET SDK from corpus/dotnet/interop/src/Interop.cs (13 P/Invoke imports covering every character set and calling convention, 4 LibraryImport declarations, 3 exports, 3 COM imports), and compares every field with what System.Reflection.Metadata reports for the same file (corpus/dotnet/interop/reference/, output committed as reference.json). A changed import name, module name, GUID or export name must fail the comparison, and 400 corrupted copies must produce a report or a typed error.

Coverage and fidelity

Single-file bundles

A .NET single-file deployment packs application files into a host executable. A self-contained deployment can also carry native runtime components. The bundle reader (disrobe_binfmt::containers::dotnet_bundle) finds the bundle marker inside a PE, ELF or Mach-O host, reads the manifest, and returns each embedded member under its sanitized relative path. It inflates a deflate-compressed member and returns a stored member as it lies. Members are managed assemblies, native libraries, deps.json, runtimeconfig.json and symbol files. The reader also parses deps.json into a typed manifest rather than only carving it, so the runtime assembly list and the library table are readable as data.

disrobe extract writes the members to disk, and disrobe auto routes them onward with no dedicated flag: an embedded managed assembly reaches the CIL pass on its own. disrobe dotnet decompile accepts the same bundle as a direct input. It stages all recovered output beside the destination and publishes the directory only after extraction and every managed assembly decompilation succeed. members/ holds every embedded file. assemblies/<relative-path>/ holds the normal manifest and pseudo-source for each managed assembly. bundle.manifest.json records the bundle version, bundle ID, quota accounting, and managed assembly list. The command refuses a non-empty destination and refuses more than 512 managed assemblies before invoking any rendering backend.

The format defines exactly three manifest major versions, and the reader accepts those three and refuses any other by number. Major 1 is what .NET Core 3.x wrote, major 2 is .NET 5, and major 6 is .NET 6 and later. Major 1 has no deps or runtimeconfig block and records every entry as type Unknown. Compression exists only from major 6.

Coverage is graded against bundles the real .NET tooling produced, in corpus/binfmt/dotnet-single-file. Each extracted assembly is compared byte for byte with the assembly the compiler emitted before bundling. The committed set spans all three major versions, PE, ELF and Mach-O hosts, every one of the six entry types, and a bundle that mixes compressed and stored entries. A declared member size that runs past the buffer, a path that escapes the output directory, a duplicate path and a decompression bomb are each refused.

A universal (fat) Mach-O host is not supported. Its header-offset field is relative to the slice rather than to the file, so the reader sees an implausible version and refuses the file instead of reading the wrong offset.

Native AOT images

The AOT report recovers metadata names, type and method attribution, method boundaries, and pseudo-C bodies. That coverage is measured on x86_64 PE executables published by the .NET Native AOT toolchain. The ELF and Mach-O fixtures are hand-assembled: each is a small assembly source linked by ld.lld or ld64.lld into an image that holds a ReadyToRun header and two metadata names. On those two formats the tests grade header location, layout-profile selection, and name recovery only; no ELF or Mach-O image built by the .NET toolchain is committed, and type, method, and body recovery on them is unmeasured. The parser reads layout metadata from the image; recovery on aarch64, arm, and x86 remains outside the measured population.

Invoke-map attribution retains separate entrypoints for compiled generic instantiations of the same metadata method. Invalid or conflicting entries are reported individually, allowing other methods to retain their recovered mappings. The independent stack_trace_map supplies method addresses, names, generic arguments and hidden-frame flags for bodies represented in that map; its entries complement reflection attribution rather than replacing it. A recovered method can also carry a code range when the image supplies matching runtime function boundaries.

disrobe dotnet native-aot app.exe --resources recovered/ writes recovered manifest resources from the ReadyToRun index. The destination must be empty. Empty, absolute, and traversal resource names and duplicate output paths are refused before extraction. Paths must resolve under the selected destination, and files are created without replacement. File names preserve the full manifest name, such as Tidewater.Gauges.tide_table.json. An index with refused entries cannot be extracted; inspect its recovery report without --resources. Extraction is limited to 65,536 resources and 256 MiB total. With --json, JSON remains the only stdout output while resources are written.

The report's object_model carries the metadata type layouts and, for every constructor whose body the image's own maps bind to exactly one type, the constant initializers that body stores through this. Two maps bind a constructor: an invoke-map entry with an entrypoint (a reflection-invocable constructor; listed as owned_entrypoints in the metadata attribution, with the declaring MethodTable the FieldAccessMap ties to each type telling apart types that share one metadata Method record) or a stack-trace-map entry naming .ctor on a metadata type (a constructor never reflected over, whose Method record the compiler trimmed; such an initializer carries no method_record_offset). A constructor that is only enumerated through reflection has an invoke-map entry without an entrypoint and no stack-trace entry, so nothing binds it and nothing is reported. The unwind table supplies the body range in both cases; a range claimed by two identities is dropped. The initializer forms are: byte, word, dword and sign-extended qword immediates, 64-bit constants materialized in rax by the preceding instruction, and stores the compiler coalesced over adjacent fields, split back per field through the FieldAccessMap offsets. Each initializer names its field and type when the FieldAccessMap owns that offset. A pointer store fed by a lea of a frozen object is reported as a reference store, and when the object lies in the image's frozen-object section and points at the String MethodTable (component size two, element type class), carries a valid length, UTF-16 code units and the null terminator, its text is reported as a frozen string beside it. Stores inside bodies shared by several method records are not reported. The x86-64 forms are graded against a committed .NET 10 NativeAOT build whose source assigns 51 constants (corpus/dotnet/aot/BUILD.md).

string_accessors lists functions that hide string literals behind an index: a body that at least two call sites reach with an immediate in ecx (mov ecx, imm32 or xor ecx, ecx directly before the call) and that itself addresses at least two preinitialized frozen arrays through lea. Each accessor carries its call sites with their indexes and the arrays' element sizes and lengths, and literals holds the text each index yields: the accessor body is run in the bounded in-process x86-64 emulator with only its own pages executable, the image's data mapped read-only, a host that serves array allocation (a MethodTable with a component size in rcx, a length in rdx) from scratch memory and declines every other call, and a step budget; the single byte buffer the body filled is read back as UTF-8. An index whose run faults, exceeds the budget or allocates anything else is listed in decode_refusals with the reason. The grade is a committed build whose eight plaintexts are kept beside it in plain.txt and appear nowhere in the image (corpus/dotnet/aot/BUILD.md).

Obfuscator reversal

disrobe registers detection rules for 23 protector families. Recovery depth varies by protector and by what is statically present in the artifact. The per-family evidence below states what artifact is graded, what data is recovered, and where static recovery stops.

Detection and string decryption are separate claims, and the evidence behind the second one differs by family. String decryption evidence below states which families are graded on an assembly the protector itself produced and which are graded on a fixture we build to the published algorithm.

Reversed on a real committed sample (plaintext recovered from the artifact, plaintext-absent oracle):

  • ConfuserEx2: in-house recovery reverses the constants protection (the documented FOSS "Ki.Constants" block-XOR / LZMA-validated algorithm) on a real committed SampleConstants.confuserex2.dll, with a test whose fixture holds only ciphertext plus the real decryptor and asserts plaintext not present anywhere in it. The encrypted-resource layer is carved byte-exact but walled on the runtime key. Control-flow flattening is deflattened in-house: disrobe rebuilds the original control-flow graph from the while(true)/switch dispatcher and recovers the switch-key encoding. The deflattening evidence comes from two protector lines and differs in strength:

    • ConfuserEx 1.x (yck1509/ConfuserEx, ctrl flow protection): on the NormalPredicate sample all 6 flattened methods recover the clean build's control-flow graph with every opcode and operand in place; on the x86Predicate sample (the native decoder stub emulated through the in-house x86 interpreter) and the ExpressionPredicate sample (the inverse expression folded symbolically) all 8 methods each recover the clean control-flow graph.
    • ConfuserEx2 1.6.0: in the gauntlet sample the flattened Process method deflattens with no unresolved block, 11 of its 12 block signatures match the clean build, and every call-result pop survives. In HelloAppLegacy.confuserex2.dll every flattened method (the static constructor and the injected constant decoder) deflattens with no unresolved block and no edge to a missing block; that check has no clean baseline to compare against.

    Protected programs re-executed: disrobe dotnet decompile now runs an unprotect layer before the decompiler. For ConfuserEx it decrypts the anti-tamper section by emulating the injected initializer in a bounded CIL interpreter over a mapped copy of the image (the derived key covers only the first 64 bytes; the rest of the XOR stream self-synchronises from the ciphertext), inlines every constant-decoder call site from the decrypted LZMA pool (string and primitive constants; array initializers stay as named residuals), inlines the static reference-proxy stubs, rebuilds every flattened method into direct control flow, and drops the injected runtime (constants decoders, LZMA decoder, anti-debug thread, anti-dump, ConfusedByAttribute) from the output. The grader in tests/protector_reexec_strict.rs compiles the recovered C# with the local Roslyn csc (net48) or the dotnet SDK (net9.0) and runs it beside the unprotected program built from the authored source: the ConfuserEx2 gauntlet, the four ConfuserEx 1.0.0 gauntlet presets, the ConfuserEx 1.0.0 behaviour suite at the minimum preset, the BitMono 0.41.1 gauntlet, the six BitMono 0.45.0 builds (gauntlet and behaviour suite at Minimal, Balanced and Maximum), the Obfuscar gauntlet and both HelloAppLegacy builds print byte-identical output with the same exit code; EdgeCases.confuserex2.dll, EdgeCases.obfuscar.dll and the ConfuserEx 1.0.0 behaviour suite at normal, aggressive and maximum stay named residuals (flattening inside exception regions, decompiler refusals in the megafile). A mutation of one recovered literal turns the grader red. Every anti-analysis removal that would change the protected assembly itself (anti-tamper section, anti-debug and anti-dump initializers, AntiDebugBreakpoints spans, DotNetHook registrations and call sites, corrupted headers) is additionally reported as a neutralisation plan: each patch names its technique, the method token and IL offset it rests on, the file offset, the original and replacement bytes and a risk note; apply verifies the original bytes before every patch, declined patches leave the technique reported as detected, and the input file is never modified (tests/neutralization_plan.rs grades the plans on the real fixtures, the clean counterparts yield empty plans, and a changed original byte is refused by patch id). On the command line disrobe dotnet decompile prints the unprotect report (layers neutralised, named residuals) and the plan, and --neutralize ask|apply|skip decides what happens to it: ask confirms each patch on the terminal with y, n, all or none and is the default when stdin is a terminal, apply accepts every patch, skip only reports and is the default otherwise. Accepted patches are written to <stem>.neutralized.<ext> beside the outputs together with neutralization.json (the full plan, the applied, declined and refused patch ids, and the output path); the input file is left as it was.

    For de4dot's runtime-VM string handling, confirm de4dot is available with disrobe dotnet backends, then use disrobe dotnet decompile App.exe --backend de4dot. disrobe auto does not invoke that external backend.

In-assembly-decryptor recovery, graded by round-trip against the pre-encryption original. This list is grouped by how recovery works, not by who produced the sample, so a family in it also appears in String decryption evidence below: the round-trip proves the decoder inverts the encryption, and the table below states whether the encrypted input came from the protector's own tool or from a fixture built to its published algorithm.

  • Eazfuscator.NET: locates a static char[]/byte[] string-decryptor method and emulates its CIL over the encrypted #US literal table to recover the plaintext strings. The graded decryptor is a generic single-key XOR loop inside a synthetic PE that the test assembles itself; neither Eazfuscator.NET's own string algorithm nor its output is part of that grade, which is why the family also sits in the modelled row below. The VM tier is devirtualized against an in-repo EazVM virtualizer of our own: the committed assembly is encoded by that virtualizer, not the shipping Eazfuscator.NET product. disrobe reads the embedded resource, recovers the per-build opcode map from the in-assembly dispatch table by fingerprinting each handler, decrypts the position-keyed instruction stream, and lifts every virtualized method body back to CIL. It then applies width-checked MBA simplification to supported straight-line int32 expressions before grading the result against the clean DLL. The ordered instruction comparison resolves branch targets to instruction indexes, and 67 of 67 instructions match in sequence across six bodies (100%). A second gate evaluates the recovered bodies in the product's bounded CIL emulator with the arguments Main passes and requires the six values the clean assembly prints. It also checks both sides of every recovered branch against the C# source, and a mutation of each branch must change a result. No assembly runs. For the committed seeded build, the randomized opcode map is recovered from the assembly rather than read from its sidecar.
  • KoiVM (ConfuserEx VM): detects the #Koi stream and VMDispatcher markers, identifies dispatch handlers, decodes each method's instruction stream, and lifts all six virtualized bodies to CIL. The committed fixture was produced by the TheProxyRE KoiVM fork through its public Virtualizer API over a benign test program. Recovery is compared with the separately compiled KoiSample.clean.exe and manually derived operations: Add and Square recover fully, and aggregate structural recovery has a 75% CI floor. The unobfuscated control produces no KoiVM summary.
  • SmartAssembly (embedded assemblies): the mode-1 chunked raw-DEFLATE resource that carries a merged or embedded dependent assembly is decompressed back to the original assembly bytes, graded byte-for-byte against a committed Roslyn-built fixture. The sample recovers 1 / 1 embedded resource. The payload inside it is a real assembly; the mode-1 framing around it is built to the published algorithm, not taken from a SmartAssembly build. Non-mode-1 carriers are marked Unknown and malformed mode-1 is Rejected; neither produces a decoded value. String encryption is a separate axis (below).
  • .NET Reactor (encrypted-resource strings): the AES key and IV are read from the reachable encrypted-string resource and the string table is decrypted back to the original literals, graded against the runtime-validated originals of committed Roslyn-built fixtures carrying the .NET Reactor v4 static-string resource shape (astral-plane, embedded-nul, empty, and CJK strings all round-trip). No assembly produced by .NET Reactor is committed. An ambiguous or disconnected decoy key/IV tuple is Rejected as report-only, never guessed.
  • ILProtector / MaxToCode: classified on in-repo structural fixtures by Invoke-stub and zero-RVA method enumeration, runtime-resource and .mtc/.text1 section location, and container-framing parse. For native-keyed configurations, the managed assembly does not carry the per-method key used by the runtime loader, so the encrypted bodies remain report-only.
  • Obfuscar: dedicated in-house peeler (NameMaker odometer classification plus HideStrings recovery: the hidden ldstr literals are read back to their original bytes from the in-assembly FieldRVA carrier through the generated accessor, 15 / 15 on the gauntlet sample).

Babel, Dotfuscator (Pro), Goliath, DeepSea, and Agile.NET have detection and classification support. Babel reports the string-recovery prerequisite: a matching protected/plain sample or a verified decoder chain. The other paths report watermarks, identifier characteristics, and encrypted-resource details. They can also use the generic static decoder for supported pure transforms.

String decryption evidence

Which families disrobe decrypts strings for, and what each claim is graded against:

Evidence Families
Graded on an assembly the protector's own tool produced ConfuserEx2, Obfuscar, BitMono
Decoder implements the published algorithm, graded against a fixture built to it; no assembly from the product is committed SmartAssembly, Spices.Net, Skater, .NET Reactor, Eazfuscator.NET, CryptoObfuscator
Key is native-loader-resident, so recovery stops at detection Themida (.NET wrapper), ILProtector, MaxToCode

The second row tests algorithm implementations against known plaintexts encrypted by repository fixtures. Product versions and per-build variants are outside that comparison; the corpus contains no redistributable product-produced sample for those families. The Eazfuscator.NET entry is weaker than the others in that row: its graded decoder is a generic XOR decryptor on a synthetic fixture, not an implementation of the product's string algorithm.

crates/disrobe-pass-dotnet/src/protectors.rs carries this split as Protector::string_evidence, and the tables above are regenerated from it. cargo run -p xtask -- regen --check fails if a family claims a committed sample the tree does not carry, or claims one whose MANIFEST.toml does not record the tool that produced it, or sits in the modelled row without being published there.

Limits

Recovery stops at these boundaries:

  • Themida / .NET wrapper: native VM bodies are outside the current recovery scope; disrobe does not ship a native-VM devirtualizer.
  • ArmDot: detected and reported, but no static devirtualizer ships.
  • ILProtector / MaxToCode native-keyed configurations: when the per-method key is computed inside the native stub, the original CIL is not statically present.
  • Obfuscar renames: original identifiers require the separate Mapping.txt; the PE contains no name map. Recovered programs keep the renamed identifiers, with suffixes added only where C# name lookup would otherwise differ from CIL (a member named like its type, a field named like a method, a namespace named like a member).
  • ConfuserEx residuals: a flattened method whose dispatcher sits inside an exception region (ConfuserEx flattens each exception scope with its own dispatcher, and a loop that spans the protected region jumps between the two), a method with a user switch beside the dispatcher, and array-initializer constants are reported by name and left as they are; the megafile EdgeCases.confuserex2.dll hits all three and the ConfuserEx 1.0.0 behaviour suite hits the first at every preset above minimum.

Protector reports identify the missing key or handler stream when those bytes are unavailable in the artifact.

⚠️ **GitHub.com Fallback** ⚠️