Release 0.9.0: security, correctness and performance hardening - #264
Merged
Conversation
Adds the 72 minimised crash artifacts. `test_load_path` now fails on a missing or empty sample instead of passing silently, so the regression suite can no longer go green on a checkout without the corpus. CI drops `--lib`, so the integration tests run on Windows and macOS too. Doc tests move to their own concurrency-capped target: each fenced example is a whole-crate fat-LTO link, and one per core exhausts memory. `.cargo/config.toml` applies the same cap to bare cargo invocations.
Five new targets beside `cilobject`: `assemblyview` (`CilAssemblyView::from_mem`), `signatures` and `customattributes` (blob parsers), `methodbody` (IL decode) and `emulation` (bounded execution). Each is seeded from the committed corpus. The emulation target runs far below production budgets -- 20k instructions, depth 32, 500 ms -- so genuinely unbounded work stands out instead of blending into normal execution time.
`MetadataTable::new` never checked `row_count * row_size` against the buffer, and the iterators reported a parse failure as end-of-iteration -- so a malformed row silently truncated a table, and the writer re-emitted it without the dropped rows. The constructor now validates and cuts the slice to the declared extent, `get` returns `Result<Option<T>>`, and both iterators yield `Result`. Also rejects `valid` bits outside the known table set, so the row-count and data-offset computations can no longer disagree; bounds coded-index rows against the target table and routes token composition through `Token::from_parts`; and deletes the inherent `TableParIterator::try_for_each`, which shadowed rayon's and locked a shared mutex once per row in every loader. Row constructors move to `LazyList` for their custom-attribute lists.
Signature nesting caps were per-construct and multiplied, so a blob could build a ~61 000-deep `TypeSignature` whose recursive drop or `Display` overflowed the native stack. The depth cap is now global, `Drop for TypeSignature` is iterative, a separate node budget bounds breadth, and the four recursive consumers are depth-bounded. Custom attributes: the tag-driven path applies the SZARRAY length ceiling it was missing, and named-argument name lengths are bounded against the remaining blob rather than driving `String::with_capacity` directly. `UserStrings::get` no longer builds a `&[u16]` from an arbitrarily aligned byte pointer; it copies and decodes little-endian explicitly, and returns an owned `U16String`.
…names Every Method, CilType and Param eagerly allocated 8-11 `Arc<boxcar::Vec<_>>` of mostly-unused atomic pointer array. `LazyList<T>` defers that to first use; `clone` materialises, since the lists it replaces were shared through `Arc`. `get_by_fullname` fell back to a linear `ends_with` scan of every registered type on every miss -- reachable per custom-attribute argument, so its cost is driven by the blob. Adds a nested-fullname suffix index, makes the redirect-sweep indices set-valued (ordered, so duplicate-name resolution stays deterministic), interns the source id, and returns `Arc<str>` from `fullname()` instead of allocating a fresh `String` at every call site. Adds method-to-type and field-to-type indices, replacing the full scans over every type and member that sat on the emulator's hottest paths, and a depth-capped base walk for the inheritance chain.
`InheritanceResolver::load` accepted a type extending itself, and a cyclic `extends` graph then drove unguarded base walks into an uncatchable native stack overflow or a hang. Self- and cyclic edges are rejected during resolution, and the validators that report circularity now see the edge instead of having it elided underneath them. Also tightens the raw structure and constraint validators (token, table, layout and generic-constraint checks) and the dependency-graph cycle reporting, which names participants rather than a closed walk.
`decode_method` disassembled to end of file rather than to the method's declared `code_size`, exception-handler association was O(H^2*B) in time and O(H*B) in memory at load time, and a `switch` operand's attacker-controlled case count drove `Vec::with_capacity` before a single target byte was read. The parser keeps its whole-file view -- offsets are file-absolute and shared across methods -- and takes a `max_offset` instead. Handler association uses `partition_point` plus a `HashSet`, switch case counts are bounded by the bytes remaining, block lookup is indexed rather than scanned per branch target, and the fat header's size-in-dwords field is validated. `BinaryReader::ReadChars` and the shared read helpers reject negative counts through `usize::try_from` rather than reserving `usize::MAX`.
The managed-heap limit was enforced after the object was fully materialised, so `newarr` and `Array.CreateInstance` allocated first and only then asked whether they were allowed to. A `HeapBudget`/`Reservation` pair makes the check precede the allocation, and `replace_object`, `list_add` and the stream writers route through it. In-place mutation now updates the accounted size, and a fork inherits the object ceiling rather than escaping it. Unmanaged allocation had no budget at all: `localloc`, `AllocHGlobal`, `AllocCoTaskMem` and `VirtualAlloc` committed real host pages while `max_unmanaged_bytes` went unread. It is now enforced before the region is constructed, and `FreeHGlobal`/`FreeCoTaskMem` actually release. Regions move from a `Vec` to a `BTreeMap` keyed by base, giving an overlap check on insert and a log-time lookup in place of a linear scan under an `RwLock` for every 1-8 byte access. Protection flags are enforced on read and write, faulting through a new catchable access-violation exception. `init_block` validates the destination before allocating and fills in chunks. Capture buffers are gated on config and bounded by item and byte ceilings.
A sweep across the hooks that take a caller-supplied length or count. Negative values are rejected with `usize::try_from` and surface as the .NET exception rather than a reservation of `usize::MAX` or a four-billion-iteration loop: `Marshal.Copy`, `Stream.SetLength`, `StringBuilder.set_Length`, `String.PadLeft`/`PadRight` and the `BinaryReader` readers. `Rfc2898DeriveBytes` and `PasswordDeriveBytes` clamp iteration count and key size instead of taking them verbatim, and a negative iteration count no longer casts to a ~4.3-billion-round KDF. `derive_pbkdf2_key` returns `Result` and errors when an algorithm is unavailable, rather than silently substituting SHA-256 for SHA-1. DEFLATE, GZIP and LZMA decompression is bounded with `Read::take`, closing the unbounded expansion reachable from both emulated IL and static deobfuscation. Shared ceilings live in a new `bcl::limits` module.
`unwind_propagating_exception` popped the caller's frame before running the caller's `finally`, so the finally IL executed against the grandparent frame. `pop_finally` now runs before `pop_frame`, and discarding queued entries checks the remaining call stack rather than keying on the popped method, which would have dropped a recursive method's still-live outer frames. `find_exception_handler` queued `finally` blocks that were never drained once a catch or filter was selected, so `pending_finally` grew without bound; they are drained on catch. `schedule_finally_blocks` attached the leave target to the wrong queue entry -- since the queue is LIFO, popping innermost-first requires pushing outermost-first and putting the target on the entry popped last, and fixing either half alone leaves the `endfinally` stall in place. A filter returning zero resumes the handler search instead of terminating emulation as an unhandled exception, and `endfilter` no longer loops forever.
"Isolated" forks shared one mutable `RuntimeState`, AppDomain and synthetic-method map while running concurrently on rayon. `ThreadContext::fork` now deep-copies that surface. Two exceptions are deliberate: `synthetic_method_counter` stays shared, because private counters would let two forks mint the same token for different bodies, and `native_functions` stays shared so a pointer captured in one fork is interpretable in another. `Assembly.Load(byte[])` grew two unbounded host-side collections and re-parsed attacker metadata per call. Two new limits bound it: `max_loaded_assemblies` for retention and `max_loaded_assembly_bytes` for a single payload before it reaches the parser. Runtime-loaded assemblies parse with minimal validation -- they are loaded for cross-assembly resolution, not accepted as trustworthy. `assembly_index` widens from `u8` to `u32`, removing the crate's only truncation escape. The PE loader cross-checks `SizeOfImage` against the section extents instead of allocating it verbatim. `CilFlavor::FnPtr` is boxed, taking `EmValue` from 200 to 104 bytes on x86-64, pinned by a static assertion.
Three miscompilations in the back end, all in how values cross block edges. Full inlining inserted the return-value copy before the instruction defining it. Switch and conditional-branch phi trampolines fell through into the next edge's phi stores, so the default path executed the first case's copies and the true path executed the false edge's. The structural fix is a dedicated out-of-SSA pass that splits critical edges into real blocks, so no emitter has to predict what another emits after it: a branch drops its `br` exactly when the entry it transfers to is next in emission order, and an edge block suppresses that elision by existing. Only multi-way terminators get edge blocks; single-successor edges are never critical. The CIL CFG now carries real exception edges, so handler entries are ordinary join points and get their phis from the normal dominance frontier. That deletes `handler_scope_defs`, whose "last block wins" snapshot of try-scope definitions was both wrong and the per-block rebuild cost. Linear-scan intervals come from an up-and-mark walk rather than no liveness solve at all, so a value live across a back edge keeps its slot. The dataflow framework was not reused deliberately: its live-in/live-out bitsets are `2*B*V` bits, and linear scan exists for the large methods where that explodes. Branch-condition inversion is gone rather than repaired -- `.un` means unordered for floats and unsigned for integers, so complementing without the operand type sent NaN comparisons down the wrong edge.
`x86_decode_traversal` was O(n^2) in decoded instructions with no cap, over a region running to end of file. The unsupported-instruction arm matched on mnemonic and caught only `Jmp`/`Call`, so `ret`, `iret` and indirect branches fell through past padding into whatever followed. Termination now uses `flow_control()`, which classifies every transfer class, and instruction and byte budgets bound the work. Phi placement was O(registers * blocks^2 * in-degree) over unbounded decoded input, and `is_reducible` recursed over the CFG; the latter is now an explicit stack with enter/exit markers, which keeps the analysis exact where a depth cap would have made it conservative.
…omic output Heap offsets were computed twice from different inputs, so the offsets baked into tables and IL disagreed with where the data was actually written. The cause was ordering inside the writer: the pre-pass ran before the RID remapper that populates the maps it needs, so it saw empty maps by construction. Moving the remapper above it makes both passes see identical inputs. Heap index widths are recomputed from the generated output instead of inherited from the input, so offsets above 0xFFFF are no longer truncated. Section sizes get a separate `raw_size` for bytes written, measured from the position delta, while `data_size` stays the virtual extent -- they only diverge for copy-as-is sections, which is the case that was broken. The input's certificate data-directory offset was applied to the output, whose layout differs, zeroing live `.text` and metadata before the checksum was computed over the damage. That zeroing is deleted. `Output` writes to a temp file and renames, rather than truncating the destination in place and deleting it on any failure. Cleanup liveness follows `TypeRef.ResolutionScope` to a fixed point, since nesting is transitive, and a method body whose native extent cannot be determined is now a hard error rather than a row pointing at unrelated bytes.
The opaque-field pass constant-folded any static-to-instance field load and deleted the owning type with no immutability precondition. Folding now requires `initonly` and no observed writes, and deletion requires that every static field the type declares was resolved as predicate infrastructure -- deletion is whole-type, so one unrelated field makes it a real type whose removal leaves call sites dangling. Byte-offset slicing of attacker-controlled string literals panicked on multi-byte UTF-8. `clippy::string_slice` is now denied, which surfaced ten genuine panic sites; the shared char-boundary-safe helper lives in `utils::text` because four of them are outside the renamer entirely. PBKDF2 iteration count and key size are clamped rather than taken verbatim from SSA constants. `EmulationConfig::timeout` is actually applied instead of every emulation inheriting the warmup timeout. Delegate proxy inlining accounts for the bound target object and the full invocation list.
`impl Clone for Error` rewrote most variants into `Error::Other(String)`, silently destroying the taxonomy for any caller that cloned. The two non-`Clone` payloads move behind an `Arc` so `Clone` can be derived, which removes the wildcard arm and with it the way a newly added variant could start flattening. `Error` is now `#[non_exhaustive]`. 107 rustdoc `# Errors` contracts across 25 files referenced variants that had been deleted. They are rewritten to what the code actually returns, and `deny(rustdoc::broken_intra_doc_links)` plus `RUSTDOCFLAGS: -Dwarnings` on the CI doc step keeps them tied to it -- `RUSTFLAGS` does not reach rustdoc, which is why the existing `-Dwarnings` never caught any of this. `deny(unsafe_code)` is enabled. The crate's inventory claimed two unsafe blocks that no longer exist; what remains is one, in the writer's output mapping, with a targeted allow and a SAFETY note. BREAKING: `Error` is `#[non_exhaustive]`; `MetadataTable::get` returns `Result<Option<T>>` and the table iterators yield `Result`; `CilType::fullname()` returns `Arc<str>`; `UserStrings::get` returns an owned `U16String`; `derive_pbkdf2_key` returns `Result`.
Bumps both workspace members and the README install snippet. `SECURITY.md` is rewritten to be falsifiable: it declared only 0.1.x supported, listed the DoS protections as "ToDo" and claimed Valgrind testing that is not run. It now states the supported version, the real `EmulationLimits` defaults, the actual fuzzing setup and the one remaining `unsafe` block.
The workspace compiled against whatever toolchain a contributor happened to have. `rust-version = "1.95"` records the floor the code actually needs, and the existing minimal-features job is pinned to it rather than stable so the declaration is checked instead of merely stated. That job ran only `--no-default-features`, which leaves the default-feature path unguarded, so it also runs a workspace check.
`AtomicUsize::fetch_update` is deprecated as of the declared MSRV; `try_update` is the same operation under its current name. Four call sites, no behaviour change.
An array shape's rank was read and never validated, and it was the only ceiling on the lower-bound count that follows it. A rank of 0x400000 therefore made the bound check permissive rather than protective, and the extension loop grew the dimension list to the declared count before any read could run out of input. Found by fuzzing, where it accounted for every out-of-memory artifact.
…al error
Type-name validation carried a hand-written list of compiler name prefixes and
rejected anything outside it, so a legitimate `<Module>{GUID}` failed — on
untouched input as well as on deobfuscated output. A name containing a closed
angle bracket is the actual invariant the C# compiler guarantees, and it does
not need extending per obfuscator.
The failure summary also reported only how many validators failed, discarding
the messages that said why.
Deleting a type whose nested type is still referenced leaves the NestedClass row pointing at a TypeDef that no longer exists, and the written assembly fails validation on a dangling token. Reachability now walks the nesting relation to a fixed point, so an enclosing type is retained whenever anything it contains is. Adds debug logging of the type and method sets cleanup decides on.
The SSA call graph only covers methods that have SSA built. Using it alone made every other method look unreachable, so reachability consumers under-approximated the live set. `build_effective_call_graph` prefers SSA edges where they exist and fills in from the static graph where they do not.
…zation A static field was treated as constant only when every write came from a `.cctor`. Obfuscators route initialization through helpers the `.cctor` calls, so those fields stayed opaque and their predicates survived. Write sites are now admitted when every caller of the writing method is itself initialization-only, computed as a fixed point over the call graph. Methods with no recorded callers are deliberately not admitted — an unknown caller is not an initialization-only one. This is what lets the .NET Reactor string samples decrypt: 223 failures to none.
… tracing paths Unflattening walked the method from entry and forked at every conditional to see which states showed up where, building a tree of execution paths. That is exponential in the number of branches, and it answered a question SSA already answers directly: the state reaching a dispatcher is a phi whose operands are indexed by predecessor, so the value arriving from a given block is exactly the operand that block contributes. Reading it is linear in the number of edges, and each dispatcher only reads its own rather than re-exploring the whole method. On one .NET Reactor sample the tree cost 108.8 million nodes and 62 seconds across 40 dispatchers; the same work is now 0.16 seconds. reactor_full drops from 615 to 154 seconds overall, of which unflattening is 1.1. The new resolver reads a state per edge, follows merges upward when several original edges meet before the jump, and where an encoding derives each state from the previous one runs a fixed point over states — one iteration per original block, not per path. Four rules keep partial results safe: - The case index comes from evaluating the dispatcher's own switch operand, not from a separately reconstructed transform that can model the encoding wrongly. - Arithmetic folds at the operand's width. State encodings rely on int32 wraparound; folding at 64 bits yields a value matching no case. - Skippability is decided by what a value is used for, not by its opcode: a greatest fixed point marks values that only ever feed the state machine, so an encoding's arithmetic can be bypassed while anything the program still observes cannot. - A block emptied by an earlier round is never a dispatch target. Rewiring control into a block with no terminator produced a function that could not be laid out, and later passes mangled the surrounding branch trying to make sense of it — silently dropping a live arm of an if/else. An edge whose state cannot be determined keeps routing through the dispatcher, so coverage degrades rather than correctness. Blocks that hold a call, a store or a string are never emptied on the strength of an analysis that is allowed to be incomplete. BREAKING: `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 with the tracer. `UnflattenConfig` and `UnflatteningThresholds` lose the knobs that drove path enumeration; those that remain are applied by detection, which previously built a config and then ignored it.
…atio The over-cleanup guard compared how many methods survived against a threshold. That correlates only loosely with the property it was standing in for — samples have been observed at 82% survival and still invalid — and it says nothing about which methods went. What cleanup must guarantee is that nothing is deleted out from under a reference that still names it, which is exactly what validation and round-trip already check.
Three call sites wrapped differently than rustfmt wants: the two try_update closures from the atomic refactor and one iterator chain in the unflattening resolver. Formatting only, no behaviour change.
Constant, FieldMarshal and CustomAttribute reach a parameter through a coded index, and each is dropped by asking DeletionContext whether its parent was deleted. A parameter discarded along with its method never enters that record — what was deleted is the method — so those rows outlived the parameters they named and the output failed raw validation with an out-of-range Param RID. remove_orphan_params now returns the RIDs it removed, following the shape already used for GenericParam and its constraints, and the three dependent tables match against them.
…invalid Rewiring a dispatcher edge can skip a definition that a surviving block still reads. The guards in resolve are what should prevent that; where one has a gap the result was a function whose uses no longer had reaching definitions, and the error propagated out of the pass and abandoned deobfuscation for every remaining method in the assembly. rebuild_ssa already proves the property, so its verdict is now acted on: a method whose rewired form fails validation keeps its original SSA. An unrecovered method is a gap in the analysis; an aborted run is a gap in all of it.
A native-stub build is an x86 executable carrying the .NET image as payload, so it is rejected at load and never reaches the point where deobfuscation could be measured. test_netreactor_samples_loadable already asserts that the load error is the expected one; asserting it again here only restated it as a failure.
libFuzzer defaults to 2048MB. Loading a real ~900KB assembly costs ~124MB, and the sanitizer the fuzz build links inflates that by roughly an order of magnitude; cilobject and emulation peak at 2156MB and 2322MB. The limit fired on whichever input happened to be running and wrote an oom- artifact that does not reproduce. A genuine runaway allocation still trips the raised limit.
…hooks Hook matching started from a MethodDef with an ImplMap, so a function resolved via GetProcAddress and invoked through Marshal.GetDelegateForFunctionPointer never reached its hook and was answered from a table of hardcoded return values. The delegate invoke now forwards its arguments through the ordinary hook path. Hooks match on a (dll, function) pair, so LoadLibrary hands out a distinct handle per module and GetProcAddress records the function against it. An unreadable GetProcAddress name now fails resolution instead of registering as "unknown".
Marshal's write path treated an AccessViolation the same as a missing mapping 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 instead of the permission error that stopped the write.
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, since the call graph cannot tell unreachable from undecrypted.
The test only checked that byte_transform returned Ok, which it does as long as it restored something — it could not tell a full recovery from none at all, and both full-protection samples silently recovered 0 of 59 and 0 of 562. The helper now counts restored bodies against the stub count, with one test per storage variant. Also records in the research notes what the emulator must provide for variant B: the dynamically resolved VirtualProtect whose return value steers the init's control flow.
BinFlip
marked this pull request as ready for review
August 15, 2026 13:01
The date is now the day the release branch actually lands.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prepares the 0.9.0 release. Not for merge yet — opened as a draft so CI runs
while the remaining verification finishes.
What this is
A security and correctness release. dotscope parses, emulates and rewrites
hostile input, and the bulk of this branch closes the gap between what the
resource limits claimed to enforce and what they actually did, plus a set of
miscompilations in the SSA back end and layout defects in the PE writer.
See
CHANGELOG.mdfor the full entry.Shape of the branch
17 commits, grouped by subsystem so each is reviewable on its own:
test(fuzz)×2fix(metadata)×4#~stream validation, blob parser bounds, cyclic inheritance, table rowResultsperf(metadata)fix(assembly)fix(emulation)×4fix(compiler)fix(x86)fix(cilassembly)fix(deobfuscation)refactor(error)!Clone+non_exhaustive, error docs,deny(unsafe_code)chore(release)SECURITY.mdThe diff is not cleanly separable by individual fix — several files carry two
changes at once (most visibly the table row-iterator migration and the
LazyListswap, which land in the same 138 files). Commits are therefore wholefiles grouped by subsystem, and only the branch tip is verified to build.
Breaking changes
Erroris#[non_exhaustive]and now derivesCloneMetadataTable::getreturnsResult<Option<T>>; table iterators yieldResult<T>CilType::fullname()returnsArc<str>UserStrings::getreturns an ownedU16Stringderive_pbkdf2_keyreturnsResultVerification status
cargo fmt --checkcargo clippy --workspace --features z3 --all-targets -- -D warningscargo test --workspace --release --features z3(running locally)Local runs resolve
analyssafrom a sibling checkout via a[patch.crates-io]outside this repo, so they are evidence about the working tree rather than about
what CI builds —
Cargo.lockpins the published 0.5.0.