fix: repair eight correctness defects found by a whole-tree audit - #227
Open
r0ny123 wants to merge 25 commits into
Open
fix: repair eight correctness defects found by a whole-tree audit#227r0ny123 wants to merge 25 commits into
r0ny123 wants to merge 25 commits into
Conversation
end-of-file-fixer appended a newline to an obfuscated fuzz corpus entry the first time one was committed, corrupting it rather than tidying it. The same hazard already applied to the XOR-obfuscated malware fixtures and to tests/fuzz_regressions/; only the ApiScout databases were excluded. The exclude now covers all four, while still linting the READMEs inside them.
Three distinct ways ElfFileLoader.getAbi and the Mach-O readers broke against current LIEF. getAbi caught lief.bad_file, and that attribute no longer exists in lief 0.17 - hasattr reports False - so the moment the handler was needed it would itself have raised AttributeError. It also read .name off identity_os_abi, but LIEF returns a plain int for an OS ABI it has no enum member for, and .name on an int is another AttributeError. Now probes for the name and catches ValueError, naming no LIEF symbol at all so it survives further drift. Deliberately not widened to `except Exception`: AttributeError and TypeError here are the defect classes the fuzzing oracle exists to surface. Separately, both Mach-O readers already handle an unknown CPU type, but LIEF's RuntimeWarning still propagated, so a consumer running under -W error saw a crash on a malformed header instead of the unsupported metadata the code returns. That also matters now the suite runs with filterwarnings=error.
Two defects on the SmdaReport.fromDict path, both invisible to the test suite and both reachable by any consumer importing a report - MCRIT included. Validation. fromDict read fields unconditionally, so a truncated report surfaced as a KeyError from somewhere mid-rebuild rather than the ValueError the surrounding validation uses, and nothing constrained the values that did arrive. The fuzzer found four separate ways through, each one narrower than the last, so this lands as a single boundary rather than a pile of point guards: required fields must be present; containers must be containers; addresses and sizes must be integers rather than any number, since struct.pack_into rejects a float outright; and every address - report scalars, xcfg keys, function offsets, block addresses - must fit the 64-bit space the synthesizers pack them into. The xcfg key was the subtle one. It becomes the function offset via int(function_addr) and had no sign check, so a negative key reached an ELF program header as p_vaddr = -14200832. It also slipped past the image-span bound, because that measures max - min and a negative floor under a positive ceiling still measured a legal 17 MB. The address check deliberately sits after the container-shape checks. Putting it first made every subtest in test_function_rejects_malformed_container_fields short-circuit on the null offset of a bare SmdaFunction, so they kept passing while no longer testing the field each one names - a regression only the diff-coverage gate surfaced. Nesting depth. SmdaFunction.fromDict passed _calculateNestingDepth() as a dict.get() default, and Python evaluates that whether or not the key is present, so every function on the cached path built a full dominator tree and discarded it. Now an explicit branch: verified 0 calls with the field present, 1 without.
src/smda/synthesis/ writes binary headers from report-derived integers and is documented as not hardened against adversarial input. The new fuzz target found three ways to reach struct.pack with a value it cannot encode. An inverted segment span. The synthetic section covering functions outside every known section took its floor from function offsets and its ceiling from function extent ends - independent quantities, aligned at different granularities (down to PAGE_SIZE, up to 16). Real binaries always have an extent end above the offset it started from; a report whose blocks sit below their own function offset does not, and struct.pack got -4080 for an unsigned field. The sweep found the same shape at four sites, MachoSynthesizer twice and ElfSynthesizer twice, of which only one had crashed; all four now share _syntheticSpan(). PeSynthesizer has the shape but is benign, its ceiling always clears the floor. Unbounded allocation. SmdaConfig.MAX_IMAGE_SIZE has been 100 MB all along and every loader enforces it, but the synthesizers - which build images rather than load them - never got the same guard, so functions 1 GB apart produced a 1 GB bytearray. Repeated across fuzzing iterations that is what walked RSS past libFuzzer's limit. The check goes in _resolveFunctionOffsets, the one path all three formats share. A derived entry point. base_addr and oep can each sit inside the address space while their sum does not, so ElfSynthesizer keeps its own guard: that value comes from the report indirectly, and the deserialization bound structurally cannot see it.
Not a synthesis bug despite the target that found it: escapeBinaryPtrRef is reached from SmdaReport.fromDict via getPicHash, so any consumer importing a report can hit it. The operand regex accepts a hex literal of any length. Both escapeBinaryPtrRef and escapeBinaryValue pack it as "<I" and fall back to "<Q" on struct.error, with nothing behind that second attempt, so a value at or above 2**64 raises out of the fallback itself. Both now return the sequence unescaped - a displacement that wide cannot appear in the instruction bytes, so there is nothing to wildcard. Masking the value to the pack width would read cleaner but changes pic_hash for reports that work today, and pic_hash is a compatibility surface.
SmdaConfig is class-attribute based, so API_COLLECTION_FILES was one dict shared by every instance in the process: one config registering an ApiScout database wrote through to the class and leaked into every other config alive alongside it. That is precisely SMDA's batch and service deployment shape. The package also installed no NullHandler, so a consumer that never configures logging got handler-of-last-resort output on stderr. tests/testPackageMetadata.py additionally pins the two hand-maintained version strings together - they are written into every report and drive the recalculate-on-import decisions, with nothing previously checking they agree.
Eight methods - addGapCandidate, addTailcallCandidate, addReferenceCandidate, addLanguageSpecCandidate, addPrologueCandidate, addSymbolCandidate, addExceptionCandidate and locateLangSpecCandidates - re-declared their own parent's body character for character. This is the copy-paste-between-arch-families shape that sibling_check.py exists to catch in a diff, sitting in the source instead. Identified by comparing parsed bodies, not by eye: six other overrides in the same class (__init__, init, nextGapCandidate, checkFunctionOverlap, ensureCandidate, locateCandidates) genuinely differ and are untouched. Dispatch is on self, so a parent body calling back into the subclass still reaches the intel override. Verified output-identical: report identity hashes, function counts, block counts and instruction counts across cutwail, asprox, bashlite, komplex, mirai_i386 and mirai_x64 are unchanged. Unifying the FunctionAnalysisState hierarchy is a real refactor and is deliberately not part of this.
A previous commit switched six writes in evaluate_runtime.py to explicit utf-8 and left every matching read alone, including the cache file it had just started writing as utf-8. On Windows those reads use cp1252 and fail on any non-ascii byte, which the emoji-bearing markdown these scripts emit guarantees. Swept the class rather than the instance: the remaining benchmark-script sites in both directions, SmdaReport's toFile/fromFile, BatchProcessor's per-sample report writes, WinApiResolver's ApiScout database read, and the fixture manifests the tests read. Report content is caller-supplied - filenames, symbol names - so none of it is safe to leave locale-dependent now the test matrix includes windows-latest. Note the reads matter more than the writes here: json.dump defaults to ensure_ascii=True, so SMDA's own report files are pure ascii. A report written by another tool, or the markdown these scripts emit, is not.
…s floor Three changes to the fuzzing setup that only make sense together. The oracle was permissive. ExceptionHandling's operational/non-operational split is the library contract - degrade gracefully for the caller - and fuzzing/targets.py added only RecursionError on top, so TypeError, AttributeError, KeyError, IndexError, struct.error and friends were swallowed. Those are precisely how most real Python defects present. The stricter oracle lives in fuzzing/, not in src/, so the library contract is untouched, with a narrow documented allowlist. A synthesis target. src/smda/synthesis/ is ~1.9k lines writing binary headers from report-derived integers, documented as unhardened, with 14 tests and no fuzzing. The target runs a fuzzer-built report through synthesizeBinary() for each format and asserts the bytes re-parse with LIEF - a synthesizer whose output no longer loads has failed at the one thing it is for. It found every synthesis and escaper bug in this branch. A committed corpus floor. The accumulating corpus lived only in the Actions cache, which evicts after 7 days without a hit, so a quiet fortnight silently reset coverage progress to bare seeds. tests/fuzz_corpus/ now holds a 212 KiB pruned floor, XOR-obfuscated with the same scheme as the malware fixtures because most entries derive from them; the cache stays the accumulating layer on top. tests/fuzz_regressions/ had a documented convention and zero entries. It now holds the reproducers for the bugs found here, each verified to bisect its fix rather than merely pass.
Three gates that were either absent or reporting success without checking. Coverage was configured but never measured: branch coverage and an exclude list existed, with no fail_under, no --cov anywhere in .github/, and ignore_errors silently swallowing measurement errors. Nobody knew the number. It is now collected on one matrix leg, published to the step summary, floored at 75 (measured 81), and gated on diff coverage - a global floor on 24.7k lines barely moves when a new module lands untested, which is the case that matters. The omit glob *lib* is narrowed to */lib/*. Tests ran on Linux only, across all four Python versions; Windows and macOS appeared only in a job that imports smda and prints versions. For a library that parses PE and Mach-O, platform breakage would ship unnoticed. Both now run the real suite on the 3.11 leg. They passed first time, which the utf-8 sweep in this branch is what made true. Supply chain: amannn/action-semantic-pull-request ran from a floating tag under pull_request_target, i.e. with write-capable context, while every other action was SHA-pinned. Pinned. A Security workflow adds pip-audit against the dependency set resolved from pyproject - not the runner's environment, which drags in unrelated tooling - and zizmor over the workflows, both on a weekly cron because a dependency CVE lands without anything here changing. zizmor's one finding is suppressed inline with a reason: that workflow never checks out or executes PR content. pip-audit immediately earned its place. The setuptools bound in the Makefile and README pinned contributors below the fix for PYSEC-2026-3447, which landed in 83.0.0; pyproject already allowed it. Aligned. SECURITY.md did not exist for a library whose entire purpose is parsing hostile input.
The perf gate failed only at a 50% slowdown, so four consecutive 15% regressions could land green. There was also no memory channel at all, despite the README documenting ~1.8 GB peak for a 3 MB binary and batch mode sizing its worker count by per-file peak - the resource most likely to break a downstream consumer was unmeasured. run_perf_check.py now records peak allocation via tracemalloc, in its own pass so tracing overhead cannot distort the timing channel beside it, and merge_results accumulates it across counterbalanced passes like the timing samples. compare_perf gates it at 10%: allocation totals barely vary run to run, so it can be far tighter than wall-clock. Lowering the time threshold to 30% then made the gate fire on its first real PR: komplex went 0.2148s -> 0.2829s, +31.7%. It was noise - the overall row moved -0.9%, three fixtures improved 6-11%, every report hash matched, and the new memory channel showed komplex allocating 10.3 MiB on both sides, +0.01%. A fixture doing a third more work moves its allocation; this one did not. The counterbalanced passes cancel drift, not per-sample variance on a 0.2s workload. So a fixture must now breach the percentage and lose at least 0.15s before it fails; below that it reports WARN (NOISE?). The overall row keeps no floor, so a real across-the-board regression still fails.
An installed smda handed consumers - MCRIT included - zero types despite CI type-checking the source. src/smda/py.typed now ships (verified present in the built wheel) and the public entry point in Disassembler.py is annotated explicitly. Most of the remaining surface is inferred rather than annotated; the model classes are a deliberate follow-up rather than a broad sweep. ty now also checks fuzzing/, profiling/ and .github/workflows/scripts/ - the last of which holds evaluate_runtime.py, 955 lines of statistics gating the perf benchmark that nothing but its own tests had ever checked. profiling/ needs an unresolved-import override because its imports live in the optional profile extra, which the lint job deliberately does not install. tests/ is left out on purpose: it carries ~187 diagnostics and needs its own pass. Ergonomics: filterwarnings=error, so a DeprecationWarning from lief or capstone on 3.14 stops passing silently; a Hypothesis profile with deadline=None, since the default 200ms wall-clock deadline against max_examples=500 fails correct examples on a loaded runner; and a slow marker on the corpus-driven modules, which turns a ~150s suite into a ~15s subset for iteration.
The release history had forked. README carried 87 entries and version_history.md carried 119, and they were not a current-vs-stale pair: 71 versions appeared only in README, 94 only in version_history, 15 in both. CHANGELOG.md merges them into 188 entries newest-first with README's wording winning on overlap, README keeps a pointer, and version_history.md is gone. AGENTS.md's release procedure is updated to match, since it directed editing the README section that no longer exists. Also in AGENTS.md: it claimed a Python 3.10 target while pyproject is 3.11, and told agents to run tests through .devcontainer/run.sh, which is git-ignored and absent. `make clean` never deleted anything - its regex has an unclosed group, so grep errored out every time, and it piped to unquoted xargs. Replaced with a find -exec that survives paths with spaces. requirements.txt was consumed by nothing, omitted hypothesis, ty and diff-cover, and could only drift further; removed in favour of the dev extra. And a stray acute accent sat alone on a line in the README.
mapBinary derived the header-copy length from the lowest PointerToRawData above a 0x200 floor, but tested it with a strict `>`. 0x200 is the most common PointerToRawData there is - it is what FileAlignment 0x200 produces for the first section - so that section never lowered min_raw_section_offset and the 0xFFFFFFFF sentinel survived. header_copy_len then collapsed to len(binary) and the entire raw file was copied to RVA 0. The per-section copies that follow only repair section-backed RVAs, so everything between SizeOfHeaders and the first section's virtual address kept raw file content where Windows maps zeros. The floor exists to reject raw_offset == 0 (BSS-style sections), which `>=` still does. Affects nearly every PE built with FileAlignment 0x200, including the bundled njrat and pe_export_label fixtures and PeSynthesizer's own output.
getCodeAreas built each executable area from section.virtual_size alone. The `% 0x1000` round-up is a no-op for 0, so a section with VirtualSize == 0 appended the degenerate area [start, start]. mergeCodeAreas preserves zero-width ranges, which leaves code_areas non-empty - and that is what decides the behaviour of BinaryInfo.isInCodeAreas and FunctionCandidateManager._passesCodeFilter. Both leave their "no code areas, assume the whole image is code" fallback and start filtering against a range where `area[0] <= addr < area[1]` is false for every address, so analysis reports status "ok" with zero functions. On PE the field is also simply the wrong one: the loader maps such a section by SizeOfRawData, and mapBinary already folds raw_size into the virtual extent. That asymmetry is what proves the defect is in the gate - the image maps correctly while the filter rejects all of it. The extent is now recovered the same way, and no area is emitted unless it has positive width. Swept the whole tree for the class; the remaining real siblings are fixed here too: * BinaryInfo.getSections (PE branch) yielded the same zero-width range, so no address resolved to the section's name in code_sections. * aarch64 FunctionCandidateManager._peExecutableSectionRanges is the gate-on-size variant - it dropped the section from the executable-range list outright, rejecting every pointer target inside it. * ElfFileLoader and MachoFileLoader getCodeAreas append the same degenerate area for a zero-size executable section or segment. Neither format has a SizeOfRawData analogue (sh_size is the size), so only the positive-width guard applies there. DelphiPythiaProvider already uses max(virtual_size, size), PeSynthesizer already handles the inverse case, and the ELF branch of the aarch64 helper gates on virtual_address rather than size - recorded as benign, unchanged.
addCodeRef keeps code_refs, code_refs_from and code_refs_to as multi-edge
structures, but jump_targets is a flat set with no refcount. removeCodeRef
withdrew a single (from, to) pair from the three edge maps and then deleted
addr_to from jump_targets unconditionally, discarding every other jump that
still pointed there.
Every live caller passes a fall-through pair, and fall-through refs are
always booked with by_jump=False - so addr_to can only be in jump_targets
because some other instruction jumped to it. The purge therefore destroys a
real edge in one hundred percent of the cases where it fires. getBlocks()
rebuilds potential_starts from {start_addr} | jump_targets, so the surviving
branch target stops being a block start and its block is merged into the
predecessor.
Tracking the jump edges themselves and releasing the target only once no
jump edge remains fixes it. Five instances were measured on the committed
osx.gimmick sample.
This is the architecture-neutral base, so it fires on both backends; the CIL
and Dalvik states carry their own copies of the same code and are fixed
alongside it. The AArch64 caller has no max_instruction_start precondition at
all, so nothing was limiting the damage there.
END_INS was ["ret"], so getBlocks() walked straight through throw, rethrow, endfinally and endfilter. The disassembler already treats all four as terminators - it calls setNextInstructionReachable(False) for each - but the block model kept its own, shorter list, and nothing reconciled them. It only misbehaves when the following instruction is absent from code_refs_to, and that is exactly a try, handler or filter entry: _seedExceptionHandlerBlocks adds those offsets to jump_targets only. The first break needs an outgoing ref the terminator never emits and the second needs the successor to be a known ref target, so neither fires. The walk falls through into the handler while the handler also opens its own block, the same instructions land in two blocks, and num_instructions and num_blocks are inflated - the duplicated instructions are hashed twice in getPicHashSequence and getOpcHashSequence. Both reachable shapes are ordinary compiler output. A try block ending in throw is plain csc output and puts njrat at 49 block-instructions over 44 distinct ones. A filter clause (C# `catch ... when (...)`) places the catch handler entry immediately after endfilter; adding endfinally and endfilter alone takes njrat from 8454 block-instructions to 8453.
Two ways a single crafted or truncated .NET module aborted the entire analysis instead of degrading. DnfileMethodBodyReader.__init__ resolves pe.get_offset_from_rva(row.Rva). That call is evaluated in the argument list of the CilMethodBody call, so it runs inside a try whose only handler is MethodBodyFormatError. pefile's PEFormatError derives straight from Exception and shares no ancestor with it, so the handler cannot catch it, and nothing validated row.Rva - the guard above checked only ImplFlags and Flags. One bad row ended the per-method loop and discarded every method after it. Rows with a zero Rva are now skipped like the other bodyless rows, and the handler matches the one in CilSymbolProvider.update, whose comment already notes this is trivially reachable from a crafted MethodDef row. read_dotnet_user_string called pe.net.user_strings.get() under a handler for UnicodeDecodeError alone, but dnfile initialises user_strings to None and only replaces it when a stream literally named "#US" maps to UserStringHeap. Renaming that stream is enough to raise AttributeError out of format_operand, through analyzeFunction, and out of analyzeBuffer. The metadata-table path in the same function already degrades to InvalidToken via getattr; the #US heap now does the same.
getJumpTargets used one table_base variable for two different addresses: the address entries are read from, and the anchor those entries' offsets are added to. The reverse scan runs high-address-first, so the REG+REG add branch sets table_base to the adr anchor and the load branch, which is visited afterwards because it precedes the add in program order, overwrites it with the table's own address. Entries were therefore read from the right place and rebased on the wrong one. On the committed Mach-O switch fixture every target is off by (table - anchor) = 0xE8: all sixteen real case bodies are lost and four words of table data are queued as basic blocks instead. Keeping the anchor separately fixes it, and the ldr-only relative form - where no anchor is ever recorded and offsets really are relative to the table - still falls back to table_base. The existing test only counted successors, so it passed throughout; it now asserts the sixteen case-body addresses.
disassembleUnmappedBuffer builds its loader with map_file=True, so binary_info.base_addr and binary_size describe the mapped image, but it went on to hand the raw file_content to _addStringsToReport and to store it as report.buffer. StringExtractor.read_bytes computes rva = va - base_addr and indexes that buffer, so image-VA arithmetic was applied to raw file offsets while isAddrWithinMemoryImage still validated against the mapped size. Addresses passed the range check and then read the wrong bytes. The persisted buffer had the same problem, which defeats the buffer[addr - base_addr] carving STORE_BUFFER exists for. disassembleFile already used the mapped image; both other entry points now match it.
LegacyDemangler.demangle counts elements with sanity_check() on the untrimmed string and only afterwards truncates at ".llvm.". When that annotation sits inside a length-covered segment and the remainder is all hex digits, the count outlives the text it was derived from: the extraction loop iterates past the end of the trimmed string and int() is handed an empty length prefix. The resulting ValueError is in neither RUST_DEMANGLE_ERRORS nor NON_OPERATIONAL_EXCEPTION_TYPES, so it escapes both is_rust_language_evidence and demangle_itanium_symbol. The live path is ElfSymbolProvider._formatSymbolName, reached from setBinaryInfo -> getExportedFunctions inside analyzeBuffer, which runs before any label-provider guard - so one malformed symbol name turned the whole run into an error report. Raising UnableToLegacyDemangle when no length prefix remains puts it back in the demangler's own error set, where the existing guards handle it.
str.isdigit() is True for every character with the Unicode digit property, including category No - RUMI DIGIT ONE, superscripts, circled digits - but int() only accepts category Nd. Every isdigit()-gated parse that then calls int() therefore raises ValueError on input the gate just accepted. That ValueError is in neither RUST_DEMANGLE_ERRORS nor NON_OPERATIONAL_EXCEPTION_TYPES, so it escapes is_rust_language_evidence and demangle_itanium_symbol exactly like the empty-length-prefix escape fixed in the previous commit - one symbol name aborts the entire run. Six sites across both demanglers shared the class: the legacy length-prefix scan and its sanity_check counterpart, and the v0 parser's hex_nibbles, digit_10 and digit_62. Mangled Rust symbols are ASCII by construction, so each gate becomes an explicit `in string.digits` test; both modules already imported string for the neighbouring hexdigits checks. Found by the existing hypothesis fuzz target, which reached "_ZN\U00010e60" once its example database explored that far - the targeted regression test for the previous fix could not have covered this, since the guard it added is for a different condition on the same line.
The diff-coverage gate requires 100% on changed lines under src/smda/**, and five branches introduced by the preceding commits had no test reaching them: * the CIL and Dalvik copies of the refcounted jump-edge removal - only the shared base was covered, which is precisely the asymmetry that let the same bug survive in three places to begin with; * the AArch64 jump-table anchor captured from the *second* add operand (`add Xd, Xindex, Xanchor`), the arm the committed fixture never takes; * the executable-range guard for a section whose VirtualSize and SizeOfRawData are both zero; * STORE_BUFFER on disassembleBuffer, where the stored buffer is the caller's own already-mapped bytes rather than a re-read image.
CI runs ty over src/smda, fuzzing, profiling and the workflow scripts in its Code Quality job, but no single command reproduced that locally, so the check was easy to skip until CI failed. The target runs the same four paths as the job, so a local run and CI cannot disagree; tests/ is excluded in both. Note that ty resolves imports from the discovered project environment (./.venv) rather than from $(PYTHON). A local venv carrying extras that CI does not install can therefore hide an unresolved-import error that fails the job, so pass --python at an environment without those extras to reproduce CI exactly.
The correctness fixes on this branch left twenty-one comment lines across src/, against a project convention of no comments in code. Most restated the line below them: "a zero-width area is never a useful bound" above `if section_end > section_start`, or "skip methods that do not have a method body" above a test for a missing Rva and IL flag. Six lines survive, each recording something the code genuinely cannot say on its own: the PE loader's rule that a zero VirtualSize section is mapped by its raw size, the ordering dependency that keeps each malformed report field reporting itself, why a legacy Rust element count can outlive its length prefix, and why a displacement or immediate wider than 64 bits needs no escaping. The VirtualSize rule was stated twice, in PeFileLoader and again in the AArch64 candidate manager; only the loader's copy remains, so the two cannot drift apart. What is gone throughout is rationale for why an edit was made, which belongs in history rather than in the source. No behavior changes. Full suite green.
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.
Summary
Eight fixes from a whole-tree correctness audit. Each one is either an analysis abort (a single malformed input turns the whole run into an error report) or a silent recall/CFG loss (analysis reports
okwhile producing wrong or missing output). Every commit carries a regression test that was verified to fail on the code before it.Three of the eight started as a single reported site and grew once the same root cause was swept across the tree; those commits fix the sibling occurrences in the same change and record the sites deliberately left alone.
What each commit fixes
fix(loaders)PE header copyraw_offset > 0x200skipped the single most commonPointerToRawData, so the sentinel survived and the whole raw file was copied to RVA 0fix(loaders)zeroVirtualSizecode_areasnon-empty, flippingisInCodeAreasinto a filter that rejects every address —okwith zero functionsfix(core)jump_targetspurgefix(cil)block terminatorsthrow/rethrow/endfinally/endfilterinto a separately seeded handler, inflating counts and double-hashing instructionsfix(cil)malformed rows /#USexceptclause and an unguardedNoneheap each discarded every remaining methodfix(aarch64)jump-table anchortable - anchorfix(core)mapped-image stringsfix(labels)legacy Rust symbolsint(''), whoseValueErrorescaped every guardEach commit message states the mechanism, why the guard that looked like it covered the case does not, and the measurement where one exists (for example the CIL block change takes the bundled njrat fixture from 8454 block instructions to 8453, and the AArch64 targets are off by exactly
0xE8on the committed Mach-O switch fixture).Sibling sweeps included
VirtualSize— also fixed inBinaryInfo.getSections, the AArch64 executable-range helper (which used the gate-on-size variant), and the ELF and Mach-OgetCodeAreas.DelphiPythiaProvider,PeSynthesizerand the ELF branch of the AArch64 helper were checked and left unchanged.jump_targetsrefcounting — the shared base plus the CIL and Dalvik copies of the same code.Validation
Test count moves from 939 to 944; the five new tests are the regression tests for the fixes above (the rest are additions to existing suites). No committed fixture baseline moved.
Each new or extended test was run against the pre-fix tree and confirmed to fail there, so none of them is vacuous.
Note on the diff
This branch is based on the branch of #226, which is still open against the same base. Until that one merges, this PR's file list shows both sets of changes — the eight commits above are the content of this PR, and the diff will shrink on its own once #226 lands.