Skip to content

Release 0.9.0: security, correctness and performance hardening - #264

Merged
BinFlip merged 39 commits into
mainfrom
release/0.9.0
Aug 15, 2026
Merged

Release 0.9.0: security, correctness and performance hardening#264
BinFlip merged 39 commits into
mainfrom
release/0.9.0

Conversation

@BinFlip

@BinFlip BinFlip commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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.md for the full entry.

Shape of the branch

17 commits, grouped by subsystem so each is reviewable on its own:

test(fuzz) ×2 crash corpus + regression harness, CI coverage, five new fuzz targets
fix(metadata) ×4 #~ stream validation, blob parser bounds, cyclic inheritance, table row Results
perf(metadata) lazy entity lists, registry indices, interned fullnames
fix(assembly) method-body decoding bounds
fix(emulation) ×4 heap/address-space budgets, BCL argument validation, exception unwinding, fork isolation
fix(compiler) phi placement, edge copies, live intervals
fix(x86) traversal termination and decode budgets
fix(cilassembly) heap layout, section sizes, atomic output
fix(deobfuscation) constant-folding preconditions, UTF-8-safe truncation
refactor(error)! Clone + non_exhaustive, error docs, deny(unsafe_code)
chore(release) version bump, changelog, SECURITY.md

The diff is not cleanly separable by individual fix — several files carry two
changes at once (most visibly the table row-iterator migration and the
LazyList swap, which land in the same 138 files). Commits are therefore whole
files grouped by subsystem, and only the branch tip is verified to build.

Breaking changes

  • Error is #[non_exhaustive] and now derives Clone
  • MetadataTable::get returns Result<Option<T>>; table iterators yield Result<T>
  • CilType::fullname() returns Arc<str>
  • UserStrings::get returns an owned U16String
  • derive_pbkdf2_key returns Result

Verification status

  • cargo fmt --check
  • cargo clippy --workspace --features z3 --all-targets -- -D warnings
  • cargo test --workspace --release --features z3 (running locally)
  • extended fuzzing, ~15 min per target

Local runs resolve analyssa from 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.lock pins the published 0.5.0.

BinFlip added 17 commits August 12, 2026 20:07
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.
BinFlip added 12 commits August 13, 2026 20:20
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
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.
@BinFlip
BinFlip merged commit cdce114 into main Aug 15, 2026
7 checks passed
@BinFlip
BinFlip deleted the release/0.9.0 branch August 15, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant