Guidance for AI coding agents (and humans) working in this repository. Follow this before making changes.
SMDA is a minimalist recursive disassembler optimized for accurate Control Flow Graph (CFG) recovery from memory dumps and raw files. It is built on Capstone and supports:
- intel — x86 / x64
- aarch64 — ARM64
- cil — .NET / CIL (via
dncil/dnfile) - dalvik — Android DEX bytecode
Backend maturity: intel is production-ready and rivals other disassemblers in accuracy. aarch64 (ARM64) is a newer but consistent addition — on test data it matched IDA's output closely, so treat it as more mature than the rest. cil (.NET) and dalvik are recent additions that produce solid results but have not yet been benchmarked against non-uniform / obfuscated code, so expect caveats.
Inputs are arbitrary memory dumps (ideally with a known base address), raw DEX files, or binary files parsed via LIEF. Output is a tree of functions → basic blocks → instructions with inter-block / inter-function edges. On top of the recovered CFG, SMDA performs a variety of optional recovery passes: symbol extraction and reconstruction (e.g. Windows API references via the ApiScout method, exports, imports, ELF/PE symbols, Rust/Delphi VMT parsing), and referenced string parsing. A function may only contain instructions that belong to a single function, and instructions may not overlap (IDA-style model).
Key entry point: smda.Disassembler.Disassembler.
SMDA is a recursive (descent) disassembler organized into phases, designed to recover code robustly from memory dumps with minimal assumptions about context. The guiding principle is aggressive, completeness-over-accuracy function entry point (FEP) detection.
Scope: Phases 1–2 below describe the intel (x86/x64) pipeline in detail and are the reference model. Other backends implement the same
ArchBackendcontract but may differ substantially — e.g.cil(.NET) typically performs no gap search at all because the rich metadata already yields accurate FEPs, whileaarch64/dalvikfollow their own candidate and traversal logic. Do not assume the intel heuristics (opcode patterns, prologues, NOP lists) apply verbatim to other architectures.
A single scan over the input buffer locates two kinds of FEP candidates:
- Code references: scan for call/jump opcode byte patterns (e.g.
E8relative call on x86,FF 15/FF 25RIP-relative on x64) and compute their targets. Any target landing inside the buffer becomes a candidate. - Common prologues: match a small, deliberately conservative set of stack-frame / hotpatch prologues (e.g.
55 8B EC,8B FF 55 8B EC).
When an unmapped PE/ELF is given (not a bare dump), LIEF provides additional candidates from the entry point and exports; candidates outside executable sections are discarded. Candidates are scored by reference count and prologue match, sorted, then disassembled one function at a time via recursive descent (DFS over basic-block starts):
- Call: does not end the block; used to update the FEP queue. Register-indirect calls are resolved via local dataflow backtracking.
- Unconditional jump: ends the block; target added to the work stack. Jump tables (switch) are resolved via a dedicated heuristic; jumps into a known FEP are treated as tailcalls.
- Conditional jump / loop: ends the block; fall-through added first, then the branch/loop target.
- Terminating (
ret/int3): ends the function/block. Apush-retobfuscation construct is recognized and its target resolved.
FEP candidates outside executable regions are skipped; the FEP queue is continuously updated as indirect calls are resolved.
After Phase 1, the spaces between recovered function bodies are scanned linearly for gap functions (unreferenced or unresolved functions, important for completeness). Within gaps:
- Effective NOPs (including padding
int3/CCfor alignment) are skipped. - A non-NOP instruction is tested against a short list of common function-start bytes; on match, a recursive-disassembly attempt is made. Success extends the gap past the new function; failure moves to the next gap.
When an unmapped PE/ELF is given, gap search is limited to executable sections. Because FEP detection + gap analysis is intentionally aggressive, it can produce false positives — accepted because completeness is prioritized over precision.
Once functions/blocks/instructions are recovered, SmdaReport is built and enriched. A key enrichment is instruction escaping and hashing (e.g. PicHash, OpcHash): instructions are normalized ("escaped") to make them comparable across binaries despite differing immediates/addresses, then hashed to produce robust function-identifying fingerprints. These are accessed through and applied over interaction with the report, and the per-architecture escaping rules live in each backend's InstructionEscaper (e.g. smda.intel.IntelInstructionEscaper, smda.aarch64.AArch64InstructionEscaper).
A virtualenv lives at .venv/ in the repo root. Always activate it before running anything:
source .venv/bin/activateIf the venv is missing or stale, bootstrap it with:
make init # upgrades pip/setuptools/wheel, installs -e ".[dev]", installs pre-commitmake init installs the dev extra (pytest, ruff, pre-commit, build, twine, tqdm). For CPU/memory profiling tooling, install .[dev,profile] instead.
All commands assume the venv is activated. Prefer the Makefile targets:
| Task | Command |
|---|---|
| Lint (ruff check) | make lint (or ruff check .) |
| Format (ruff format) | make format (or ruff format .) |
| Auto-fix lint | make ruff-fix |
| Run tests (fast tier) | make test (or pytest -m "not slow" tests/test*) |
| Run tests (everything) | make test-all (or pytest tests/test*) |
| Tests + coverage | make test-coverage |
| Build package | make package |
| Publish to PyPI | make publish |
pyproject.toml configures pytest with pythonpath = ["src"] and testpaths = ["tests"], so tests import the package from src/ without install gymnastics. Run tests with pytest (not python -m pytest against an installed copy) to exercise the working tree.
Pre-commit hooks (ruff + standard hygiene checks) run on commit via .pre-commit-config.yaml. Install them with make init; they will auto-format/fix on commit.
- Formatter / linter: Ruff. Line length 120, target Python 3.11+ (
py311). - Style: Ruff
selectset isE4, E7, E9, F, W, I, UP, B, C4, PIE, SIM.E501is ignored (formatter handles length).UP006/UP007are intentionally ignored to avoid sweeping typing modernization churn. - No comments in code unless explicitly requested. Keep code self-documenting via clear names and structure.
- Imports:
src-layout package. Import asfrom smda....(the installed/editable package), never relative path hacks. Ruff'sIrule enforces import ordering. - Typing: Keep existing
Optional/Unionannotation style where already present (do not broadly convert toX | Noneunless touching that code). - Dependencies: runtime deps are
capstone,dncil,dnfile,lief>=0.16.0. Add new runtime deps topyproject.toml[project].dependencies, not scattered imports.
src/smda/ # the package (Disassembler, SmdaConfig, common/, intel/, aarch64/, cil/, dalvik/, ida/, synthesis/, utility/)
tests/ # pytest suite (test*.py)
data/ # generated ApiScout / reference JSON data (do not hand-edit; see Gotchas)
profiling/ # CPU/memory profiling toolkit (make profile-cpu / profile-mem / profile-flame)
analyze.py # demo: disassemble a file/dump, optionally store JSON
export.py # example export usage
ida_analyze.py # IDA-side analysis/export helper
When a change warrants a version bump, update all three in one commit:
src/smda/__init__.py→__version__src/smda/SmdaConfig.py→SmdaConfig.VERSIONCHANGELOG.md→ rename## [Unreleased]to## [vX.Y.Z] - YYYY-MM-DD, drop the subsections the release did not use, open a fresh empty## [Unreleased]above it, and update the compare links at the foot of the file.
Keep the two version strings in sync. Do not bump versions unless the change is a release-worthy change (and see Git Workflow re: explicit ask).
Scope lives in a milestone named for the tag. A PR joins vX.Y.Z when it is accepted for that
release rather than when it is opened, so the milestone stays a list of what is left rather than a
wishlist; issues that have to close for the release join it too. Cut when it reads 100% and master
is green, and close it when the tag is pushed. It answers "what is left before the release" as a
query instead of a re-read of the issue and PR lists, which is the half CHANGELOG.md cannot cover:
the changelog records what landed and what it cost, the milestone what has not landed yet.
Tag after master is green, not at merge. A tag created on the merge commit before CI has confirmed the merged tree can need moving, and moving one a PyPI publish has already consumed is a different problem from moving one nobody has fetched.
Every other PR writes its own bullet under ## [Unreleased] as it merges, rather than the release being reconstructed from merge commits afterwards — see How an entry is written in CHANGELOG.md for the subsections, the scope prefix and the four content rules. .github/workflows/changelog.yml requires a PR touching src/smda/ to touch CHANGELOG.md or carry the no-changelog label.
- Work on a feature branch, then open a PR against
master. Do not commit directly tomaster. - Commit messages: concise, lowercase, imperative ("add aarch64 tailcall handling"). Version-bump commits in this repo have historically used
bump X.Y.Z. - PR titles must be semantic/conventional (e.g.
feat:,fix:,refactor:) — enforced by CI (semantic-pr-title.yml), distinct from the commit-message style above. - Never commit secrets/keys. Do not force-push shared branches.
- Only create commits/PRs when explicitly asked.
- Add or update tests under
tests/for behavioral changes; match the existingtest*.pynaming and pytest style. - Run
make testandmake lintwhile you work; runmake test-allbefore you push.make testdeselects theslowmarker — a small fraction of the tests but about three quarters of the wall time (on857081f, 124 tests and 116s of 151s) — so the fast tier is the edit-run loop andtest-allis the pre-push gate. CI always runs the whole suite, so a slow-tier failure surfaces on the PR rather than silently — but it surfaces later than it needs to. - Mark a test
slow(pytestmark = pytest.mark.slow, or per-test) when it disassembles a real fixture corpus. Marking one keeps the default loop fast; forgetting to mark one is what makes it slow again. - Architecture-specific behavior (intel / aarch64 / cil / dalvik) should be covered with representative fixtures where feasible.
- The xored
tests/*_xoredcorpora and the malpedia benchmark matrix (.github/workflows/perf_benchmark.yml) are PR/CI-only — they require a password-gated malpedia corpus and are not expected to run locally. For local recovery-quality validation, the maintainer works against separate groundtruth datasets rather than the full public matrix. Do not assume you can reproduce the benchmark suite locally. - Run tests with
python -m pytest tests/test*(ormake test) against a Python 3.11+ environment with thedevextra installed.
Constraints an agent must respect to avoid breaking SMDA or its downstream consumers.
data/apiscout_win7_prof-n_sp1.jsonanddata/apiscout_winxp_prof_sp3.jsonare large generated ApiScout WinAPI databases consumed byWinApiResolverfor symbol reconstruction. Never hand-edit them.- Pre-commit excludes these from trailing-whitespace / end-of-file-fixer and only
check-jsons^data/.*\.json$; thecheck-added-large-fileshook caps at 85 MB. Do not commit new/larger generated data without adjusting the hook limit.
smda.ida.IdaInterface/IdaExporterconvert IDA Pro's disassembly into anSmdaReport. Downstream projects that build on SMDA depend on this interface, so do not change theSmdaReportformat or version in ways that break it without an explicit, deliberate decision.IdaInterfaceis version-branched onIDA_SDK_VERSION(< 740,< 850, ...). Changes here must stay compatible across those SDK generations.
SmdaConfig carries bounds that stop pathological / junk samples from exploding time or memory (cf. tests/testCandidateSafeguards.py):
TIMEOUT,MAX_IMAGE_SIZE,MAX_INDIRECT_CALLS_PER_BASIC_BLOCK,MAX_FUNCTION_CANDIDATES,MAX_CALL_REFS_PER_CANDIDATE.- These are defaults meant to stay bounded. Do not remove them or flip them to unlimited (
0) by default. Tune via config, not by deleting the guard.
tests/holds an xored corpus (mirai_*,asprox_*,komplex_*, ...) plus escaper-verification suites. They encode expected CFG recovery.- Changing recovery heuristics (FEP discovery, gap search, tailcall/jump-table resolution, escaper) must not silently change these baselines. If a heuristic change is intentional, update the fixtures deliberately and explain why in the PR.
smda.common.RecursiveDisassembleris the architecture-agnostic engine (traversal, candidate orchestration, gap/tailcall passes, label/symbol resolution). It delegates all arch-specific work to an injectedArchBackend(smda.common.arch.ArchBackend).- Per-arch backends live under
smda.intel,smda.aarch64,smda.cil,smda.dalvik. Symbol/label logic lives undersmda.common.labelprovider. - There is no hard boundary as of now: in practice, most changes land in the areas around
RecursiveDisassembler(the engine), the arch backends, the label providers, or theFunctionCandidateManager. To add or change an architecture, implement/extendArchBackendand keep arch-specific logic within its own module; share only through the common base class rather than baking arch behavior into the engine.
SmdaConfig.VERSIONandsmda.__version__must match (see Versioning). The value is written into everySmdaReport(disassembly.smda_version) and feeds IDA-compatibility decisions, so an inconsistent or careless bump has downstream effects.
- A function may contain only instructions belonging to that function, and instructions may not overlap. Do not relax this model — recovery passes and downstream tooling depend on it.
smda.synthesis(PeSynthesizer,ElfSynthesizer,MachoSynthesizer) rebuilds fictive PE/ELF/Mach-O files from anSmdaReportviareport.synthesizeBinary(). It plants bytes per basic block (not per function extent; blocks may be non-contiguous), fuses import metadata, and targets BSim-compatible output.- The feature is experimental: it works on well-formed reports but has not been hardened against pathological or adversarial inputs. The golden test fixtures cover one sample per format only.
code_sectionsnames are not preserved throughSmdaReport.toDict()/fromDict()round-trips (they come from LIEF metadata). Tests that mutate a report viafromDictmust capture VA ranges before the round-trip.- Synthesis is deterministic from report content only — it does not consult
STORE_BUFFER. - Install the toolchain with
python -m pip install -e ".[dev]"; SMDA requires Python 3.11+.
- The pipeline description is intel-centric. The
E8/FF 15/55 8B ECheuristics and the gap-search/NOP-list logic belong to the intel backend. Other backends follow their own candidate/traversal logic (e.g.cildoes essentially no gap search thanks to rich metadata). Do not apply intel assumptions to other architectures. - Symbol/API resolution is feature-gated and context-sensitive.
SmdaConfigdefaults haveAPI_COLLECTION_FILES = {}andWITH_STRINGS = False. Windows-API resolution via ApiScout needs profiles for the target machine and works well mainly on memory dumps; import-table parsing handles the rest. Do not assume API/symbol resolution "just works" out of the box — wire config (seetests/context.pyfor the pattern). - The Escaper / hashing subsystem is SMDA's differentiator and is per-architecture.
PicHash/OpcHashescaping lives in each backend'sInstructionEscaper. Changes to recovery or escaping affect these fingerprints — keep them consistent with the golden fixtures. - CI enforces more than
make lint. Beyondruff check, the pipeline runsruff format --check(formatting is mandatory, not optional) and a semantic PR-title lint (semantic-pr-title.yml). Runmake formatand use a conventional PR title (e.g.feat:,fix:,refactor:) before opening a PR. - False positives are a feature. Completeness is prioritized over precision by design; fixing a perceived false positive can silently degrade recall on the frozen golden corpus. Treat such "fixes" with suspicion and validate against fixtures.