v0.8.4
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 withPassConfig::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.aand&o.bno longer alias. An unresolved (null) field token falls back to the sound whole-object approximation - x86 segment overrides reach the IR:
X86Memorygained asegmentfield, decoded from the instruction's explicit prefix, andfs:/gs:-qualified accesses now lower toLoadIndirect/StoreIndirectwith a distinctaddress_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 plusMemorySsa,IndirectLocation,ArrayIndex,AliasResult,MemoryDefSite,MemoryPhiOperand, andMemorySsaStats - 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 zeroConstfor primitives and references, andLoadLocalAddr; InitObj; LoadLocalfor 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 againstoriginal.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:
SentinelTaintRemovalPassandNeutralizationPassrewrote every tainted instruction toNopand dropped every tainted phi, destroying definitions that surviving code still read.PhiTaintMode::NoPropagationmakes 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.NeutralizationPassalso excludes its protectedDecryptedStringconstants from the candidate set rather than at rewrite time, so branch-target selection sees what is actually removed AssemblyDependencyGraph::find_cyclesreports 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 billionInstructionclones — 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, sinceILGeneratorcan mutate them - Emulation rebuilt the assembly context on every instruction:
loaded_assembly_contextconstructed a freshEmulationContextand took anRwLockper executed instruction; contexts are now memoized per assembly index optimize_localssplit values it renumbered (SsaFunctionCilExt): renumbering a local moved its origin but left its rename group pointing at the old slot.rebuild_ssagroups 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_visitsso 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_typescomputed 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 — onreactor_virtualizationthat 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, inO(V+E)rather thanO(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 throughexpand_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
int32alone, so builds emittingT Get<T>(uint32)yielded no decryptor at all — any integer width is now accepted, the surrounding constraints carrying the selectivity.stobjrejected reference types, though ECMA-335 §III.4.29 defines it over anytypeTokand makes it equivalent tostind.reffor reference types, which is whatstobj !!Tbecomes atT = string.ldelemahand-rolled its index match toI32/NativeIntwhileldelem/stelemshare a helper accepting every width, so anative uintindex aborted emulation. Andstfldthrough 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
PatchPlankeeps 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
- Forks mark the evaluator instead of copying it, using analyssa 0.4.1's new
- Dependencies: bumped
analyssa(0.3.0 → 0.4.1) and addedsmallvec(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.