Skip to content

v0.9.0

Choose a tag to compare

@BinFlip BinFlip released this 15 Aug 15:24
· 5 commits to main since this release

A security and correctness release. dotscope parses, emulates and rewrites
hostile input, and this release closes the gap between what the resource limits
claimed to enforce and what they actually did, along with a set of
miscompilations in the SSA back end and layout defects in the PE writer.

Security

  • Resource limits are enforced before the work happens, not after. The
    managed-heap ceiling was checked once the object was already materialised;
    unmanaged allocation (localloc, AllocHGlobal, AllocCoTaskMem,
    VirtualAlloc) had no budget at all, and max_unmanaged_bytes and
    max_heap_objects were declared but never read. Allocation
    now runs through a reservation that must succeed first, in-place mutation is
    accounted, and forks inherit the ceiling instead of escaping it.
  • Unbounded and quadratic work on attacker input. Fixed in the inheritance
    walker (a cyclic extends graph caused an uncatchable native stack overflow),
    the x86 traversal (O(n²) to end of file), method-body decoding (disassembled
    past the declared code_size), exception-handler association (O(H²·B) at load
    time), DEFLATE/GZIP/LZMA expansion, and the signature parser (a blob could
    build a ~61 000-deep type whose recursive drop overflowed the stack).
  • Argument validation across the BCL hooks. Negative or oversized lengths
    reaching Marshal.Copy, Stream.SetLength, StringBuilder.set_Length,
    String.PadLeft/PadRight, the BinaryReader readers and the PBKDF2
    constructors reserved usize::MAX, ran multi-billion-iteration loops, or
    drove a ~4.3-billion-round KDF. They now reject the value and raise the .NET
    exception.
  • Emulator forks were not isolated. "Isolated" forks shared one mutable
    runtime state, AppDomain and synthetic-method map while running concurrently.
    Assembly.Load(byte[]) is now bounded by max_loaded_assemblies and
    max_loaded_assembly_bytes, and runtime-loaded assemblies parse with minimal
    validation rather than the full pipeline over hostile bytes.
  • Memory protection flags are enforced on read and write, faulting through a
    new catchable AccessViolationException, and region mappings are overlap-checked.
  • deny(unsafe_code) is enabled. One unsafe block remains, for the writer's
    output mapping, with a targeted allow and a SAFETY note.
  • SECURITY.md now states the supported version, the real EmulationLimits
    defaults and what is actually run. The previous text listed DoS protections as
    "ToDo" and claimed Valgrind testing that does not exist.

Fixed

  • Malformed table rows silently truncated a table. The row iterators
    reported a parse failure as end-of-iteration, and because the writer rebuilds
    tables by iterating them, an unreadable row became missing output rather
    than an error. Iterators now yield Result, get returns
    Result<Option<T>>, and MetadataTable::new validates and truncates to the
    declared extent.
  • MethodPtr, EventPtr and PropertyPtr tokens used the wrong table id,
    so any assembly carrying a *Ptr table lost its method-bearing types.
  • Three back-end miscompilations. Full inlining placed the return-value copy
    before the instruction defining it; switch and conditional-branch phi
    trampolines fell through into the next edge's copies. Critical edges are now
    split into real blocks by a dedicated out-of-SSA pass.
  • Handler SSA used a "last block wins" snapshot of try-scope definitions
    because the CIL CFG carried no exception edges. Real EH edges make handler
    entries ordinary join points.
  • Linear-scan allocation computed live intervals with no liveness solve, so
    a value live across a back edge could have its slot clobbered.
  • Four exception-unwind defects: the caller's finally ran against the
    grandparent frame, queued finally blocks were never drained once a catch was
    selected, a leave out of nested finallys spun on endfinally, and a filter
    returning zero terminated emulation instead of resuming the handler search.
  • PE writer layout. Heap offsets were computed twice from different inputs,
    so offsets baked into tables and IL disagreed with where data was written;
    heap index widths were inherited from the input and truncated above 0xFFFF;
    section SizeOfRawData came from the virtual extent; and the input's
    certificate directory offset was applied to the output, zeroing live .text
    before the checksum was computed over the damage. Output now writes to a
    temp file and renames.
  • Cleanup deleted live metadata: TypeRef liveness ignored ResolutionScope,
    and the opaque-field pass folded any static-to-instance load and deleted the
    owning type with no immutability precondition.
  • Byte-offset slicing of string literals panicked on multi-byte UTF-8;
    clippy::string_slice is now denied, which surfaced ten genuine sites.
  • The fuzz crash-corpus regression test passed on any checkout without the
    corpus, and CI ran cargo test --lib, so the integration tests never executed
    on Windows or macOS. Both are fixed, and the 72 crash artifacts are committed.
  • An array signature's rank was never bounded, and it was the only ceiling on
    the lower-bound count that follows it, so a declared rank of 0x400000 made that
    check permissive rather than protective and the dimension list grew to the
    declared count before any read could run out of input. This accounted for every
    out-of-memory artifact found by fuzzing.
  • Type-name validation rejected legitimate compiler-generated names. It
    matched a hand-written list of prefixes, so <Module>{GUID} failed on untouched
    input as well as on rewritten output; the closed angle bracket the C# compiler
    guarantees is the real invariant. Validation failures also reported only how
    many validators failed, discarding the messages saying why.
  • Cleanup deleted enclosing types whose nested types were still referenced,
    leaving a NestedClass row pointing at a TypeDef that no longer existed.
    Reachability now walks the nesting relation to a fixed point.
  • Reachability used the SSA call graph alone, so every method without SSA
    looked unreachable and the live set was under-approximated. SSA edges are now
    preferred where they exist and the static graph fills in where they do not.
  • Opaque static fields were only folded when every write came from a .cctor.
    Obfuscators route initialization through helpers, so those fields stayed opaque
    and their predicates survived. A write site now counts when every caller of the
    writing method is itself initialization-only; a method with no known caller is
    not admitted. .NET Reactor string samples go from 223 decryption failures to
    none.
  • Parameters removed with their method left dangling references behind them.
    Constant, FieldMarshal and CustomAttribute rows name a parameter through
    a coded index and are dropped by asking whether their parent was deleted, but a
    parameter discarded along with its method never entered that record — what had
    been deleted was the method. The rows outlived the parameters they named and
    the output failed raw validation with an out-of-range Param RID. Removed
    parameters are now cascaded to all three tables.
  • .NET Reactor NecroBit recovered nothing from full-protection binaries.
    Every encrypted body was lost on both such samples — 0 of 59 and 0 of 562 —
    while necrobit-only binaries were unaffected. The cause was not in the
    decryption: a protection that resolves VirtualProtect through
    LoadLibrary/GetProcAddress and calls it through a delegate never reached
    the hook that implements it, so the pages holding the method bodies stayed
    read-only and the write-back faulted on the first body. Both samples now
    restore every stub and validate.
  • A native function resolved at runtime never reached its hook. Hook matching
    required a declared P/Invoke, so any function obtained through GetProcAddress
    and invoked through Marshal.GetDelegateForFunctionPointer bypassed it — the
    delegate path answered from a small table of hardcoded return values instead,
    reporting success without performing the call's effect. Such calls now carry
    their arguments and dispatch through the ordinary hook path. LoadLibrary
    hands out a distinct handle per module so the resolved function can be matched
    against the library it came from.
  • A refused write was retried as a fresh mapping. Marshal's write path
    treated "mapped, but not writable" the same as "not mapped" and tried to
    materialise a window at the enclosing 64KB boundary. For an address inside a
    loaded image that is the image base, so the attempt collided with the image and
    reported an overlap — turning a recoverable permission error into a fatal one
    that named the wrong cause. The two cases are now distinguished.
  • A failed body-decryption transform caused cleanup to delete the code it
    could not decrypt.
    A technique fills its cleanup request during detection,
    before it knows whether the transform those deletions depend on will run. When
    a byte transform fails, the bodies it was meant to restore stay encrypted;
    such a method contributes no call edges, so everything it references reads as
    unreachable and the type-level sweep removes it. One .NET Reactor sample fell
    from 1181 methods to 87. Techniques now report what they could not restore
    (Technique::unrecovered_methods), cleanup protects those methods, withholds
    the failed technique's own request, and skips unreferenced-type removal for the
    run — the call graph cannot tell unreachable from undecrypted. The same sample
    now keeps 946 methods and validates.
  • Unflattening could emit a function that failed SSA validation, which
    aborted deobfuscation for the whole assembly rather than the method. Rewiring a
    dispatcher edge can skip a definition that a surviving block still reads; the
    guards that prevent this have gaps, so the rebuilt form is now checked and a
    method that cannot be rewired safely is left flattened.

Performance

  • EmValue drops from 200 to 104 bytes on x86-64 by boxing CilFlavor::FnPtr,
    halving every value in the interpreter. Pinned by a static assertion.
  • Method, CilType and Param no longer eagerly allocate 8–11 Arc<boxcar::Vec<_>>
    each; LazyList<T> defers to first use.
  • Type resolution: get_by_fullname no longer falls back to a linear scan of
    every registered type (reachable once per custom-attribute argument), and
    fullname() returns Arc<str> instead of allocating a fresh String at every
    call site.
  • Declaring-type lookups are indexed rather than brute-force scans over every
    type and member — they sat on the emulator's hottest paths.
  • Table loaders no longer take a shared Mutex once per row inside the rayon
    loop; an inherent try_for_each had been shadowing rayon's in every loader.
  • Handler SSA no longer rebuilds a version-stack snapshot per exception
    successor per block, and unmanaged access is a BTreeMap lookup rather than a
    linear region scan that allocated a Vec for a 1–8 byte read.

Changed

  • BREAKING: CFF unflattening resolves dispatcher edges from SSA instead of
    enumerating execution paths.
    The old tracer walked the method from entry and
    forked at every conditional, which is exponential in the number of branches and
    re-explored the whole method once per dispatcher. The state reaching a
    dispatcher is a phi whose operands are indexed by predecessor, so the value on
    each edge can simply be read; recovering it is linear in the number of edges.
    Encodings that derive each state from the previous one are resolved by a fixed
    point over states — one iteration per original block, not per path.

    On one .NET Reactor sample the tree cost 108.8 million nodes and 62 seconds
    across 40 dispatchers; the same work now takes 0.16. reactor_full drops from
    615 to 154 seconds, of which unflattening is 1.1. The tracer and the patch-plan
    reconstruction are deleted, roughly 3 400 lines net.

    Edges are only rewired when the answer is provable: the case index is obtained
    by evaluating the dispatcher's own switch operand rather than a reconstructed
    transform, arithmetic folds at the operand's width because state encodings rely
    on int32 wraparound, and a block is skippable only when everything it computes
    feeds the state machine and nothing else. An edge that cannot be resolved keeps
    routing through the dispatcher, so coverage degrades rather than correctness,
    and blocks holding a call, a store or a string are never removed on the strength
    of an analysis that is allowed to be incomplete.

    unflatten and unflatten_with_dispatchers no longer take a config or an
    assembly, CffReconstructionPass::new takes only the context, and the patch
    plan API is gone. UnflattenConfig and UnflatteningThresholds lose the knobs
    that drove path enumeration; the ones that remain are now actually applied by
    detection, which previously built a config and then ignored it.

  • The workspace declares rust-version = "1.95", and the minimal-features CI job
    is pinned to it — and extended with a default-feature workspace check — so the
    MSRV is verified rather than merely stated.

  • BREAKING: Error is #[non_exhaustive] and derives Clone. The previous
    hand-written Clone rewrote most variants into Error::Other(String),
    destroying the taxonomy for any caller that cloned.

  • BREAKING: MetadataTable::get returns Result<Option<T>> and the table
    iterators yield Result<T>.

  • BREAKING: CilType::fullname() returns Arc<str>; UserStrings::get
    returns an owned U16String; derive_pbkdf2_key returns Result and errors
    on an unavailable algorithm instead of silently substituting SHA-256 for SHA-1.

  • CaptureConfig gains max_items and max_total_bytes ceilings (10 000 and
    256 MB), and buffer capture is off under a default config, honouring the
    documented "no capture by default" contract. CaptureContext::new() sets it
    explicitly, so the "capture what is useful" constructor is unchanged.

  • Stale rustdoc # Errors contracts across the crate referenced Error variants
    that had been deleted. They are rewritten to what the code returns, and
    deny(rustdoc::broken_intra_doc_links) plus RUSTDOCFLAGS: -Dwarnings in CI
    keeps them accurate — RUSTFLAGS does not reach rustdoc, which is why the
    existing -Dwarnings never caught them.

  • Doc tests run under a concurrency cap: each fenced example is a whole-crate
    fat-LTO link, and one per core exhausts memory on a many-core machine.

  • Five new fuzz targets beside cilobject, covering the assembly view, the
    signature and custom-attribute blob parsers, method-body decode and bounded
    emulation.