anti analysis - 1-3-7/disrobe GitHub Wiki
disrobe analyzes samples statically by default. It recognizes obfuscation and anti-analysis patterns, recovers the available code and data, and reports missing runtime keys or unsupported transformations.
Identification never trusts a single magic byte. A zeroed or flipped magic, renamed UPX0/UPX1 sections, or a corrupt UPX! marker is re-identified from internal self-consistency:
-
PE through
e_lfanewto the COFF and optional headers. - ELF / Mach-O by header offsets that close against the file length.
- ZIP by its end-of-central-directory anchor.
- DEX by section-offset consistency.
- Classfile by a constant-pool walk.
- wasm by the LEB section stream.
A real UPX executable with a flipped MZ and renamed sections still unpacks byte-identically, because the structural PackHeader (method id, self-consistent compressed and uncompressed lengths, plausible version) is the signal a scrambler cannot remove without breaking the stub's own ability to self-extract.
Malware often ships with a broken, expired, self-signed, or mismatched Authenticode signature to look trustworthy at a glance. For a signed PE, disrobe native identify verifies the signature end to end rather than trusting its presence: it recomputes the Authenticode hash and compares it to the claimed digest, walks the PKCS#7/CMS certificate chain to an embedded bundle of trusted code-signing roots, requires the code-signing extended key usage on the leaf, and cryptographically verifies any RFC 3161 timestamp before letting it extend validity. The result is a single verdict (Valid, HashMismatch, Expired, SelfSigned, UntrustedChain, WrongKeyUsage, and the rest), so a tampered .text, a forged timestamp, or a chain that does not reach a trusted root is surfaced instead of silently accepted. The native guide lists the signed-fixture and osslsigncode cross-checks behind it.
| Scheme | What disrobe does |
|---|---|
| Single-byte XOR stack strings | Recovers them with English-likeness key detection, on native via the in-house x86 emulator driving each decoder-shaped function. |
| Per-family keyed strings | Mirai, Dridex, and Trickbot keyed-string schemes decoded from their known transforms. |
| JVM string encryption | Emulates the in-class decrypt(String) / decrypt(int, String) method over the encrypted constants, running <clinit> for a static key or constructing the receiver for an instance key. |
| .NET constant decryption | ConfuserEx2 constants reversed on a real committed sample by emulating the in-assembly decryptor. |
| JS string-array rotation | The rotated string array is rebuilt and call sites inlined. |
Python exec/eval/compile payloads |
Unwrapped through base64/85/16/32 and zlib/lzma decode chains. |
Recovery stops when a required key exists only in a system property, environment variable, clock value, random source, or runtime table.
-
BlackObfuscator DEX flattening is recognized: the
String.hashCode()-keyed dispatcher and itsconst-stringblock names are used to annotate the recovered block order. The flattened method body remains in the emitted source. -
OLLVM-style control-flow flattening is deflattened on native, and the result is reported as a cover rather than as an equivalence.
disrobeenumerates every state the dispatcher can select out of the sample's own compare tree or jump table, resolves the state transition out of each case region by value-set analysis over the state variable, and reports which states the recovered program reaches. A state it cannot reach stays in the denominator and carries a named reason. Thedeobf.jsonsidecar publishes the whole cover: the state list, the recovered state-to-state edge set, the case regions with their block spans, and one reason per uncovered state. Itsfully_recoveredflag is true only when all three of these hold: every dispatcher state is reached, no reached state has an unresolved transition, and the recovered edge set passes its consistency check. It is not a claim that the recovered program and the flattened one agree on every input. Bogus control flow and instruction substitution are reversed on the same path. -
Proven-dead conditional arms are folded on the AArch64
native decompilepath by a symbolic devirtualizer that runs before structuring (on by default, disabled with--no-devirt). The fold is transactional: on any proof miss or budget exhaustion it reverts to the original function, so it replaces a construct only with a proven-equivalent one and adds no edges. - Obfuscator-planted out-of-range exception entries that poison the JVM control-flow graph are dropped before structuring.
- Flattened JS dispatchers are collapsed back to structured control flow.
The JVM, Dalvik, and CIL decoders tolerate broken StackMapTable, fake exception ranges, and illegal-but-verifiable bytecode. On native, jump-into-the-middle desync, overlapping instructions, and opaque predicates are resolved in-tree. A mixed-boolean-arithmetic simplifier, wired through the JS and WebAssembly decoders as well, collapses MBA expressions back to their algebraic form through a layered stack: linear signature solving, nonlinear reduction modulo the null-polynomial ideal, e-graph equality saturation over proven ring identities, bounded enumerative synthesis for opaque leaves, and permutation-polynomial inversion. Each layer is sound or abstains, and a rewrite is emitted only after equivalence is proven over the full bitvector domain, so an expression the stack cannot prove is left untouched rather than approximated.
Indirect dispatch is resolved before the SMT tier is ever consulted. A strided-interval value-set analysis reads the masked, compare-guarded, or position-independent index bound off the path constraints and enumerates the jump table to a concrete target set that over-approximates, and usually equals, the reachable targets. It abstains when the table is writable or the index is unbounded, and defers to the solver only for a disequality residual, rather than narrowing past what it can justify. The over-approximation property is unit-tested and graded against a real gcc-compiled switch; it is not proved for every input. The value-set tier carries no solver dependency and compiles without one.
Every SMT verdict the simplifier and the devirtualizer depend on is independently checked before it is trusted. A SAT verdict is re-evaluated against the model the solver returned; an UNSAT verdict is reconfirmed by BDD bit-blasting, or, for the multiply-heavy opaque predicates the bit-blaster cannot settle, by a finite-difference polynomial certificate. A verdict that fails its own check, or a solver that panics or exhausts its budget, degrades to abstain, so a solver bug the independent check catches costs a recovery rather than producing a wrong answer. The external differential runs against pinned Z3 4.16.0 in CI and includes a seeded wrong rewrite as its control. The same harness supports Bitwuzla for local runs, but CI does not provision it. A defect shared by both the solver and its checker is outside what the differential can rule out.
| Target | Status |
|---|---|
| Lua (IronBrew2 2.7.0) | Devirtualized in standard and MAX mode, graded by a real-lua execution differential. |
| Native generic VM |
disrobe native devirt locates the interpreter, fingerprints each handler's micro-op behaviorally through the in-tree x86 emulator, and lifts to a re-executable IR plus pseudo-code, validated end-to-end on a self-authored Tigress-shape VM (the recovered IR re-executes byte-identically from machine code alone). |
| VMProtect / Themida / Enigma front-ends | Detection and structural analysis based on published format research. Recovery of a machine-keyed handler stream requires its key. |
The PE overlay carve computes the true end of the executable image and isolates any trailing archive (gzip, xz, zstd, bzip2, tar, 7z, cab, rar) into its own segment, so padding cannot mask an appended payload.
ProGuard/R8 mappings are replayed into name-restoration.json with overload matching; the emitted Java names are unchanged. Go type and stdlib names are recovered from surviving pclntab/moduledata on stripped binaries, Rust/C++/Swift/Itanium symbols are demangled, and structure is recovered from DWARF. garble's hashed user names remain unresolved without the required naming information; the report retains the structure and metadata that can be recovered.
The table links each capability to its compiler, runtime, verifier, or repository test. Partial and Detect-only rows identify the remaining recovery limits.
| Capability | What it does | Grading oracle |
|---|---|---|
| Opaque-predicate fold | Folds OLLVM bogus-control-flow always-taken / always-dead branches to their constant outcome |
crates/disrobe-pass-native/tests/lifting/ollvm_passes.rs (OpaqueResult::AlwaysTaken, real classify_fla.bin and self-authored predicate) |
| Control-flow-flattening deflatten | Enumerates every state an OLLVM-flattened dispatcher can select, resolves the transition out of each case region, and places the reachable ones as a direct-edge program. Each state it does not reach is reported with a named reason instead of being dropped |
crates/disrobe-pass-native/tests/lifting/cff_dispatcher_cover.rs. Cover is 9 of the 9 dispatcher states across the two committed OLLVM *_fla.bin functions, with each denominator read back out of that sample's own compare tree rather than typed into the test. Separately a bounded stub_emu differential runs the flattened bytes and the re-emitted recovered bytes on 15 arguments per function and compares the return value against corpus/native/ollvm/probe_src.c, which is 60 executions under a 200000-step and 16 KiB-stack budget. A seeded missing state and a seeded wrong edge each turn one of those gates red |
| Verified MBA simplify | Collapses mixed-boolean-arithmetic back to algebraic form through the layered simplifier described above, then proves equivalence over the full bitvector domain before emitting | The acceptance gate records a proof at the expression's actual width through exhaustive enumeration where the domain is runnable, exact linear-column identity, BDD bit-blasting, or a finite-difference polynomial identity. A candidate is emitted only with one of those proven verdicts; otherwise the input is left untouched |
| OLLVM substitution undo | Lifts substituted arithmetic sequences (including shift-encoded carries and movzx/xchg-loaded narrow operands) back to the original operation, proven minimal |
ollvm_passes.rs (undo_ollvm_substitution, asserts changed && proven, simplified_nodes < original_nodes) |
| Jump-table + PIC switch recovery | Resolves register-indirect dispatch and position-independent switch tables to concrete case-to-target lists |
disrobe-pass-native deobf, graded by stub-emulator dispatch equivalence with clobbered-base and out-of-image counter-tests |
| Stack-string reconstruction | Drives each decoder-shaped function through the in-house x86 emulator to recover plaintext that only exists after the decoder runs |
crates/disrobe-pass-native/tests/oracles/stack_string_oracle.rs (gcc-compiled object, stub_emu CPU memory state) |
| ABI / calling-convention inference | Infers calling convention, argument count, and return value from liveness on stripped code |
crates/disrobe-pass-native/tests/oracles/abi_inference_oracle.rs (real clang-compiled prototypes, graded vs the source prototype) |
| Static type recovery | Recovers per-slot integer width and signedness from instruction semantics, splits a reused stack slot into distinct objects through region-typed memory-SSA and live-range analysis, and reconstructs struct, array, and union shape from access paths. Types resolved from a known library or OS prototype propagate backward into the caller's locals with library!function provenance; a slot with no sign signal, or an unresolved or conflicting call target, is reported unknown rather than guessed. native decompile emits the result as a types.json sidecar |
crates/disrobe-typerec graded against an unstripped sibling's DWARF on an O0 corpus: width and struct field offset/width recall 1.0, live-range splitting lifts signedness recall from 0.25 to 1.0 on slot-reuse cases, with mutation checks that reject seeded-wrong widths, signs, offsets, and merged or invented fields |
| Solver-free indirect-dispatch resolution | A strided-interval value-set analysis resolves masked, compare-guarded, and position-independent indirect jumps to a concrete target set that over-approximates the reachable targets, usually exactly. It abstains, or defers to the SMT tier on a disequality residual, rather than narrowing past what it can justify |
disrobe-mba jump-table VSA, unit-tested for the over-approximation property and graded against a real gcc-compiled switch (crates/disrobe-mba/tests/jumptable_compiler_oracle.rs, crates/disrobe-mba/src/jumptable/vsa.rs) |
| Copy-prop + branch-fold cleanup | Register copy-propagation and dead-store elimination over junk-shuffle blocks |
crates/disrobe-pass-native/tests/oracles/copyprop_oracle.rs (concrete re-execution, live register equal before and after across seeds) |
| Path-sensitive dead-code removal | Drops blocks unreachable under the resolved predicate constraints |
disrobe-pass-native deobf/pathsense.rs, applied only on a proven path constraint |
| Anti-disasm tolerance | Resolves jump-into-the-middle desync, overlapping instructions, and junk bytes; the JVM/Dalvik/CIL decoders tolerate broken StackMapTable and fake exception ranges |
Native desync is graded on authored byte sequences (crates/disrobe-pass-native/src/desync.rs unit tests, tests/lifting/desync_*.rs); no real obfuscator output exercises it yet. The bytecode decoders are graded on malformed-bytecode fixtures |
| Runtime-check neutralisation | Plans same-length patches for debugger, PEB, timing, trap, and hypervisor checks, applied only with consent | Execution of self-authored gcc-compiled programs: armed builds print debugged, neutralised copies print clean, the control plans nothing, and a tampered plan is refused (tests/oracles/anti_runtime_oracle.rs) |
| Emulated anti-analysis answers | Serves pinned rdtsc, cpuid, timer, debugger-API, and PEB answers, dispatches vectored handlers, and runs TLS callbacks inside the stub emulator |
A guarded authored stub unpacks to its original bytes and names every check it passed; inverted guards keep it from the entry point (tests/packers/anti_guarded_stub.rs) |
| Real OLLVM fold | Recovers pseudo-C for each function of the real ollvm 17 bcf, sub, and split ELFs in corpus/native/ollvm/real/, or refuses it with a named reason |
The recovered C is compiled with gcc and swept over 25 inputs against the authored source, and a fully recovered file must print what the original program prints. Four of the nine functions recover: sub (classify, sum_to from probe_src.c; classify from the obfush sample) and split (classify from probe_src.c). Every bcf function and the two remaining split functions are refused by name. The recovered set is pinned, and a reference that returns one more than the source must fail the sweep (tests/lifting/ollvm_real_fold.rs). No flattened (fla) ELF from the real tool is committed yet |
| noreturn propagation | Propagates non-returning calls so the disassembler stops decoding junk past a terminal call |
disrobe-pass-native flow analysis on the disassembled call graph |
| Generic VM devirt | Locates the interpreter, behaviorally fingerprints each handler through the x86 emulator, and lifts to re-executable IR plus pseudo-code |
crates/disrobe-pass-native/tests/oracles/vm_devirt_oracle.rs (clang-compiled synthetic VM, recovered IR re-executes byte-identically from machine code alone); Lua IronBrew2 2.7.0 graded by a real-lua execution differential |
| Source-to-sink taint tracking | Tracks values from configured source calls to sink calls through register, stack, and inter-procedural propagation over the normalized IR | On NIST SARD Juliet v1.3, the native path detects all 190 labeled CWE-78 flows in the C slice with gcc 16.2.0 at both -O2 and -O0, including the 15 that cross more than one call, with zero false positives. On the C++ slice it detects 40 of 50 at -O2 and 50 of 50 at -O0; the -O2 misses are std::list and std::map flows whose nodes are linked inside libstdc++, which the engine does not model. The grader uses Juliet's manifest.xml and Flow Variant headers. Wasm, JVM, Dalvik and CIL inputs reach the same engine; these figures cover native input only |
disrobe behavior and disrobe capabilities surface anti-debug, anti-VM, anti-sandbox, and timing checks, mapped to MITRE ATT&CK and MBC, with a confidence grade per technique. The core scan also names the instruction-level checks it can anchor to an address: a PEB.BeingDebugged or PEB.NtGlobalFlag read through fs:[0x30]/gs:[0x60], an int 2d trap, and the anti-dump family (a PEB.Ldr SizeOfImage overwrite, a write to the mapped PE header, and VirtualProtect, NtProtectVirtualMemory or ZwUnmapViewOfSection aimed at the image header). A packer marker is reported as overcome only when an unpack pass ran in the same chain and produced a PE, ELF, or Mach-O layer; otherwise the finding names the missing pass.
The x86 stub emulator that runs packer stubs answers each runtime check with a pinned "not debugged" value, so a guarded stub proceeds deterministically instead of halting. Every answer is recorded as a named, neutralised check anchored to the instruction or call site that asked, so nothing is served silently:
| Check | What the emulator answers |
|---|---|
rdtsc, rdtscp
|
A monotonic counter that starts at 0x1_0000_0000 and advances 0x40 per read |
cpuid |
A GenuineIntel vendor, leaf 1 with the hypervisor bit (ECX bit 31) clear, and zeros for the 0x4000_0000 hypervisor leaves |
IsDebuggerPresent, CheckRemoteDebuggerPresent
|
FALSE |
NtQueryInformationProcess |
ProcessDebugPort 0, ProcessDebugObjectHandle STATUS_PORT_NOT_SET, ProcessDebugFlags 1 |
NtSetInformationThread(ThreadHideFromDebugger) |
Success, with no effect |
OutputDebugStringA/W |
Swallowed |
GetTickCount, GetTickCount64, timeGetTime, QueryPerformanceCounter
|
Pinned counters that advance a fixed step per call |
PEB BeingDebugged, NtGlobalFlag, process heap Flags and ForceFlags
|
The values of an undebugged process; each read is reported at its instruction |
AddVectoredExceptionHandler |
Registers the handler; a fault or int 2d/int3/icebp trap dispatches vectored handlers before the SEH chain |
| TLS callbacks (PE32 and PE32+) | Run before the entry point with DLL_PROCESS_ATTACH
|
The bounds stay in force: at most 16 vectored handlers and a vectored nesting depth of 16, at most 16 TLS callbacks sharing one eighth of the step budget, at most 8 watched PEB and heap fields, and at most 256 recorded checks per run, with the overflow counted rather than stored. A cpuid or rdtsc loop ends at the step cap. A self-authored packer-style stub whose decrypt loop is guarded by IsDebuggerPresent, the PEB and heap flags, an rdtsc delta, the cpuid hypervisor bit, NtQueryInformationProcess, a GetTickCount delta, a vectored handler around int 2d, and a TLS callback unpacks to its original bytes under the emulator, and inverting any guard keeps it from reaching the entry point (crates/disrobe-pass-native/tests/packers/anti_guarded_stub.rs).
disrobe native decompile --neutralize ask|apply|skip turns the runtime checks it finds in a PE into a patch plan the defender can accept or decline. Each patch names its technique, the instruction address and pattern it anchors to, the file offset and length, the original and replacement bytes, a risk note naming what could break, and a stable id. ask shows each patch and reads y, n, a (all remaining) or q (none) from stdin; it is the default when stdin is a terminal. skip writes the plan and report and applies nothing. Without the flag, a run whose stdin is not a terminal skips: it applies nothing and writes no plan, so scripted runs never patch. apply accepts every patch. The input file is never modified: accepted patches go to <stem>.neutralized.<ext> in the output directory, beside neutralization.json, which lists the plan and every applied, declined, and skipped patch. Before writing a patch, disrobe checks that the original bytes at its offset still match and refuses the whole plan if any differ. A declined patch leaves its technique reported as detected.
The plan covers IsDebuggerPresent and CheckRemoteDebuggerPresent call results, PEB BeingDebugged and NtGlobalFlag reads, NtQueryInformationProcess debug-class queries, rdtsc, GetTickCount and QueryPerformanceCounter delta compares, int 2d and icebp traps, and cpuid hypervisor-bit tests. Each replacement has exactly the length of the instruction it replaces. A vectored-handler control flow or a TLS callback is reported as a named refusal and never patched, because removing it changes what the program does rather than what it observes.
The plan is graded by execution on self-authored C programs in corpus/native/anti-analysis/runtime/, one per check, compiled with gcc at test time. Where a program can trip its own check without a debugger (an -DARMED build writes the PEB flag, zeroes the timing threshold, or sleeps between reads), the armed build prints debugged, and its neutralised copy prints clean. The benign control, which uses timers and GetModuleFileNameA without a decision, gets an empty plan. A plan whose original bytes were altered is refused before any write, and a plan made for one build is refused on another (crates/disrobe-pass-native/tests/oracles/anti_runtime_oracle.rs). disrobe never executes the input on its default path; the programs executed by these tests are the authored fixtures and their patched copies.
With a matching pyarmor_runtime, the static path supports the variants listed in the Python guide. The 72-of-72 structural result covers the manifest's v8/v9 default-trial wrappers: each decodes to a complete header-anchored root CodeObject. That measurement does not grade source recovery, original .pyc identity, or semantic equivalence. Some v6/v7 variants require the opt-in, sandboxed dynamic-capture path. For ionCube, SourceGuardian, Zend Guard, ILProtector, and MaxToCode, a required key may reside in a native loader or live process outside the artifact. Disrobe reports the missing key and leaves that content unrecovered.
See the forensics and malware-safety posture for how the default static path stays safe on untrusted input.