Skip to content

v0.8.4

Choose a tag to compare

@BinFlip BinFlip released this 27 Jul 04:18
1cd43e0

Added

  • Memory optimization pass (compiler::MemoryOptimizationPass): store-to-load forwarding, redundant load elimination, and block-local dead store elimination, every rewrite gated on a Memory SSA alias proof. Registered in the deobfuscation pipeline's normalize phase and enabled by default; disable with PassConfig::memory_optimization = false. This reaches the field and array traffic obfuscators use to keep values out of SSA registers, which the register-level passes cannot see through
  • Field-sensitive points-to for CIL (CilTarget::field_member_index): the field's metadata token supplies the stable per-field cell identity Andersen's analysis keys on, so &o.a and &o.b no longer alias. An unresolved (null) field token falls back to the sound whole-object approximation
  • x86 segment overrides reach the IR: X86Memory gained a segment field, decoded from the instruction's explicit prefix, and fs:/gs:-qualified accesses now lower to LoadIndirect/StoreIndirect with a distinct address_space (257/256, following LLVM's numbering). Alias analysis treats the spaces as disjoint, so TEB/PEB and stack-cookie accesses stop colliding with flat memory at the same displacement. cs:/ds:/es:/ss: deliberately stay in the flat default — in flat user mode they share a base, and marking them would let alias analysis prove two names for one cell disjoint
  • Re-exports for analyssa's new alias machinery: analysis::{pointsto, address} modules plus MemorySsa, IndirectLocation, ArrayIndex, AliasResult, MemoryDefSite, MemoryPhiOperand, and MemorySsaStats
  • Cross-block value promotion before CFF restructuring (deobfuscation::passes::unflattening::spill): unflattening rewires the CFG, so the SSA has to be reconstructed afterwards — and reconstruction is a reaching-definition problem that can only be solved for values with a storage location. Arguments and locals have one; a stack temporary produced in one block and consumed in another exists only as an SSA name, and the sole record that two names denote the same value is the phi that merges them. Rewiring discards exactly that record, and for an edge the patch creates no phi ever described it. Every value crossing a block boundary is now promoted to a local slot before any terminator is touched — the classical "spill temporaries before restructuring" step — so rebuild can recover versions and phi placement for all of them by the ordinary algorithm. This is what makes .NET Reactor NecroBit samples reconstruct at all, and it is correct for any rewiring rather than only the shapes whose phis happen to survive
  • Zero-initialization for undefined SSA definitions (SsaConverter): a new construction phase materializes definitions for variables that had none, following ECMA-335 §I.12.3.2.2 — a typed zero Const for primitives and references, and LoadLocalAddr; InitObj; LoadLocal for value types. Previously such variables were registered with no defining instruction, so they survived initial construction but vanished the moment the variable table was rebuilt from real definitions
  • Emulation and CFF smoke tests (tests/emulation_smoke.rs): five samples whose deobfuscation depends on emulation or the unflattening tracer producing byte-identical results, each checked for semantic preservation against original.exe. The full packer suites take hours; this runs in seconds, so a change to the emulation layer or the tracer is validated against real output rather than only wall-clock time
  • Deobfuscation benchmark (benches/deobfuscation.rs): ConfuserEx and .NET Reactor groups plus a detection-only control, behind --features deobfuscation

Fixed

  • Taint-driven neutralization could produce IR with dangling reads: SentinelTaintRemovalPass and NeutralizationPass rewrote every tainted instruction to Nop and dropped every tainted phi, destroying definitions that surviving code still read. PhiTaintMode::NoPropagation makes phis taint barriers by design, so a phi routinely merges a tainted definition into code the analysis never marks. Both passes now shrink the removal set to a fixpoint (utils::retain_removable) — a candidate whose result still has a reader is kept rather than the removal widening into legitimate code. NeutralizationPass also excludes its protected DecryptedString constants from the candidate set rather than at rewrite time, so branch-target selection sees what is actually removed
  • AssemblyDependencyGraph::find_cycles reports participants, not a closed walk: following analyssa's switch to a single deterministic Tarjan pass, a self-dependency now names the assembly once rather than twice. The stale "modified DFS with three-color marking" documentation was corrected to match
  • CIL emulation fetched instructions in O(n) (emulation::engine::context::MethodCode): every executed instruction cloned the method's entire instruction vector and scanned it linearly for the current offset. A 3-million-instruction run performed 3.2 billion Instruction clones — about 1052 per step. Method bodies are now cached per token with an offset-to-index map, and the execution loop borrows the instruction instead of cloning it. Synthetic bodies bypass the cache, since ILGenerator can mutate them
  • Emulation rebuilt the assembly context on every instruction: loaded_assembly_context constructed a fresh EmulationContext and took an RwLock per executed instruction; contexts are now memoized per assembly index
  • optimize_locals split values it renumbered (SsaFunctionCilExt): renumbering a local moved its origin but left its rename group pointing at the old slot. rebuild_ssa groups variables by rename group while resolving argument/local representatives by origin, so the two views disagreed about which names denote the same local and the value ended up with no reaching definition. Both now move together
  • CFF tracer could escape its visit budget: entering an expression-switch false arm deliberately resets total_visits so each arm gets its own budget. On heavily nested methods that reset fired often enough to make the budget unbounded — a 348-block ConfuserEx method reached 3.3 million block visits against a 50,000 cap. A monotonic counter now bounds total work per trace without changing the per-arm semantics
  • CFF reconstruction produced invalid SSA after block cloning: cloned blocks duplicated every definition they contained, phi operands survived pointing at predecessors the patched CFG no longer has, and operands were left naming redirected blocks. Clones now allocate fresh variable ids (after state-tainted filtering, which keys on the original ids), phi operands are pruned against the patched predecessor sets and definedness, and redirected operands are resolved through a bounded hop limit
  • Cleanup deleted live types reachable only through another deletion candidate (#249, fix contributed in #248 by @agski331): find_unreferenced_types computed a single step of what is a transitive reachability problem. A candidate rescued partway through a pass never propagated that liveness onwards, so any cluster reachable solely through it was still read as isolated infrastructure — on reactor_virtualization that cut the assembly from 854 methods to 45, deleting a VM interpreter whose stubs remain live because nothing devirtualizes it. Reachability is now a worklist drain over a type-level call graph, in O(V+E) rather than O(depth × edges). CustomAttribute constructor types are seeded as roots before propagation rather than filtered afterwards, so the types those constructors call survive too, and liveness propagates from a nested type to its enclosing type — deleting an enclosing type cascades to its children through expand_type_tokens, which was dropping nested types that live code still called. The deletion set is sorted for a reproducible order
  • Technique cleanup ran whether or not the transformation it implies succeeded: a technique builds its cleanup request from detection findings alone, so it scheduled the decryptor, its infrastructure type, the initializer and the encrypted data for deletion even when no call site was reversed, leaving those call sites pointing at metadata that no longer exists and emptying the methods holding them. Removal now requires that nothing still calls the decryptor — a successful decryption rewrites its call site to the constant, so the absence of remaining callers is the evidence of reversal. Absence of recorded failures is not, since a decryptor that was never exercised has none either. Note that the caller check reads the SSA call graph, which covers only methods that converted successfully
  • ConfuserEx constant decryption missed builds that differ from stock 1.6.0 (#249): four defects, each masking the next. The blob index was matched against int32 alone, so builds emitting T Get<T>(uint32) yielded no decryptor at all — any integer width is now accepted, the surrounding constraints carrying the selectivity. stobj rejected reference types, though ECMA-335 §III.4.29 defines it over any typeTok and makes it equivalent to stind.ref for reference types, which is what stobj !!T becomes at T = string. ldelema hand-rolled its index match to I32/NativeInt while ldelem/stelem share a helper accepting every width, so a native uint index aborted emulation. And stfld through a pointer to a value-type array element replaced the whole element with the field's value instead of updating the field inside it, which is the path LZMA's bit-decoder struct arrays take
  • ConfuserEx LZMA blobs were rejected or misparsed (#249): the sniffer required the compressed payload to be smaller than its declared output, but LZMA expands small high-entropy input and the constants blob is XOR-encrypted before compression — a 44-byte blob compresses to 51 and was refused. The header was also modelled as 5 property bytes plus a 4-byte size, while builds calling the LZMA SDK's stream API write the standard 13-byte header with an 8-byte size; reading that as 9 bytes shifts the payload and corrupts the range coder. Both layouts are now attempted and held to the size each declares, and the size ceiling is documented as an allocation guard rather than a property of the format

Changed

  • Deobfuscating heavily flattened methods is roughly 25× faster, through changes that leave what the tracer computes unchanged:
    • Forks mark the evaluator instead of copying it, using analyssa 0.4.1's new checkpoint/rollback. A flattened method forks millions of times and the evaluator's state grows with everything the trace has learned, so copying it per fork was measured at 99% of tracer time. The same journal treatment is applied to the tracer's own visited-state set, whose sole writer only ever inserts
    • Per-block structural facts — dispatcher-target and foreign-dispatcher membership, constant-producer targets, and the overflow-dispatch-site predecessor walk — are computed once per trace rather than per block visit
    • The cross-scope local bridge indexes variables by local slot instead of scanning the whole variable table inside the per-instruction loop
    • PatchPlan keeps membership and redirect-target indexes beside its ordered vectors, replacing linear scans that ran once per block of every node in the trace tree
    • Trace nodes store their visited blocks inline (SmallVec); a 1200-block method produced 92 million nodes averaging about one block each, so the per-node heap allocation dominated the allocator
    • The per-node instruction log was removed: it recorded operand values nothing read, and its only consumer needed opcodes that are available from the SSA
  • Dependencies: bumped analyssa (0.3.0 → 0.4.1) and added smallvec (1.15)

Measured end-to-end on the packer samples, deobfuscating the whole assembly: ConfuserEx maximum 63.6 s → 2.0 s, and .NET Reactor necrobit — which previously failed to reconstruct at all — 385 s → 15.1 s. Part of that comes from analyssa 0.4.1 rather than from dotscope: alongside the evaluator's checkpoint/rollback, it batches the rebuild-mode substitution in eliminate_trivial_phis, which had been applying one whole-function scan per trivial phi. A rebuild produces trivial phis in proportion to the function, so the round was quadratic — on a 1200-block method it was the single largest cost in the pipeline.