v0.9.0
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, andmax_unmanaged_bytesand
max_heap_objectswere 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 cyclicextendsgraph caused an uncatchable native stack overflow),
the x86 traversal (O(n²) to end of file), method-body decoding (disassembled
past the declaredcode_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
reachingMarshal.Copy,Stream.SetLength,StringBuilder.set_Length,
String.PadLeft/PadRight, theBinaryReaderreaders and the PBKDF2
constructors reservedusize::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 bymax_loaded_assembliesand
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 catchableAccessViolationException, and region mappings are overlap-checked. deny(unsafe_code)is enabled. Oneunsafeblock remains, for the writer's
output mapping, with a targeted allow and a SAFETY note.SECURITY.mdnow states the supported version, the realEmulationLimits
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 yieldResult,getreturns
Result<Option<T>>, andMetadataTable::newvalidates and truncates to the
declared extent. MethodPtr,EventPtrandPropertyPtrtokens used the wrong table id,
so any assembly carrying a*Ptrtable 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
finallyran against the
grandparent frame, queuedfinallyblocks were never drained once a catch was
selected, aleaveout of nestedfinallys spun onendfinally, 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;
sectionSizeOfRawDatacame 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.Outputnow 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_sliceis now denied, which surfaced ten genuine sites. - The fuzz crash-corpus regression test passed on any checkout without the
corpus, and CI rancargo 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,FieldMarshalandCustomAttributerows 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-rangeParamRID. 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 resolvesVirtualProtectthrough
LoadLibrary/GetProcAddressand 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 throughGetProcAddress
and invoked throughMarshal.GetDelegateForFunctionPointerbypassed 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
EmValuedrops from 200 to 104 bytes on x86-64 by boxingCilFlavor::FnPtr,
halving every value in the interpreter. Pinned by a static assertion.Method,CilTypeandParamno longer eagerly allocate 8–11Arc<boxcar::Vec<_>>
each;LazyList<T>defers to first use.- Type resolution:
get_by_fullnameno longer falls back to a linear scan of
every registered type (reachable once per custom-attribute argument), and
fullname()returnsArc<str>instead of allocating a freshStringat 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
Mutexonce per row inside the rayon
loop; an inherenttry_for_eachhad 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 aBTreeMaplookup rather than a
linear region scan that allocated aVecfor 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_fulldrops 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.unflattenandunflatten_with_dispatchersno longer take a config or an
assembly,CffReconstructionPass::newtakes only the context, and the patch
plan API is gone.UnflattenConfigandUnflatteningThresholdslose 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:
Erroris#[non_exhaustive]and derivesClone. The previous
hand-writtenClonerewrote most variants intoError::Other(String),
destroying the taxonomy for any caller that cloned. -
BREAKING:
MetadataTable::getreturnsResult<Option<T>>and the table
iterators yieldResult<T>. -
BREAKING:
CilType::fullname()returnsArc<str>;UserStrings::get
returns an ownedU16String;derive_pbkdf2_keyreturnsResultand errors
on an unavailable algorithm instead of silently substituting SHA-256 for SHA-1. -
CaptureConfiggainsmax_itemsandmax_total_bytesceilings (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
# Errorscontracts across the crate referencedErrorvariants
that had been deleted. They are rewritten to what the code returns, and
deny(rustdoc::broken_intra_doc_links)plusRUSTDOCFLAGS: -Dwarningsin CI
keeps them accurate —RUSTFLAGSdoes not reach rustdoc, which is why the
existing-Dwarningsnever 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.