{"slug": "rust-binary-deobfuscation-with-rust-deobf", "title": "Rust binary deobfuscation with rust-deobf", "summary": "A new open-source tool called rust-deobf recovers obfuscated strings from stripped Rust malware binaries without executing them, recovering 8 of 8 planted indicators in under 10 seconds on a benchmark sample where `strings` and FLOSS both returned 0. The tool combines static call-site recovery with Unicorn emulation and a real loader, and its published benchmark table shows 5/5 recovery against litcrypt, obfstr, cryptify, muddy (ChaCha20-Poly1305 AEAD), tight stack strings and hand-rolled XOR/ADD/SUB, versus 0/5 for `strings` and FLOSS on each. On real samples, rust-deobf recovered 32 obfuscated strings from EDDIESTEALER (PE), including its hidden C2 domain and URL path, and the C2 exfil URL, crypto-clipper config and staging paths from AmnesiaStealer (macOS Mach-O).", "body_md": "`strings` sees nothing. FLOSS sees nothing. rust-deobf sees the C2.\n\nrust-deobf recovers what obfuscated Rust binaries hide — C2 addresses, file paths, mutexes, registry\nkeys, config — **without ever running the sample**.\n\n[Demo](#-30-second-demo) · [Use it from an AI agent](#-use-it-from-an-ai-agent) ·\n[Quick start](#-quick-start) · [Results](#-results) · [Real malware](#-real-malware) ·\n[Limitations](#-honest-limitations)\n\nA stripped Rust program built the way malware ships (`opt-level=\"z\"`, fat LTO, `panic=abort`),\nwith its eight indicators encrypted by [`obfstr`](https://crates.io/crates/obfstr) — the strings\nsit in a struct, `match` arms, comparisons, and a branch that never runs. None is ever printed.\n\n<sub>Rendered from the captured output of a real run (Linux x86-64, rustc 1.97): the text is\nverbatim, the colours and typing animation are added, the ~8 s emulation wait is sped up, and the\noutput is trimmed to the decoded strings. The plain-text output below is the same run.</sub>\n\n## **Plain-text output (copyable)**\n\n**What the file shows you:**\n\n``` bash\n$ B=build/scatter_obfstr__elf__size-lto__strip\n$ strings $B | grep -ciE 'northwind|10\\.66|NW-5c1e9a|skip-update'\n0\n```\n\n**What rust-deobf recovers** (static + Unicorn emulation; the binary is never executed):\n\n``` bash\n$ python3 scripts/recover.py $B\nbinary: scatter_obfstr__elf__size-lto__strip   format=ELF   arch=x86_64\n  static x86-64 (recover_callsite): 0 strings  decoders=9  seconds=0.2\n  emulation (recover_emulate): 8 strings  functions=416/416  timed_out=False  workers=1  seconds=6.8\n\n== decoded strings ==\n  10.66.12.7:8443   [ip]\n      function 0xf20c  (emulation)\n  C:\\ProgramData\\NorthwindSvc\\state.db   [path]\n      function 0xf20c  (emulation)\n  Mozilla/5.0 (Windows NT 10.0; Win64; x64) NorthwindAgent/2.1\n      function 0xf20c  (emulation)\n  --skip-update-check\n      function 0xf443  (emulation)\n  Software\\Northwind\\Agent\\Settings   [registry]\n      function 0xf51b  (emulation)\n  Global\\NW-5c1e9a   [mutex]\n      function 0xf5a8  (emulation)\n  https://telemetry.northwind.example/api/v3/beacon   [url]\n      function 0xfd58  (emulation)\n  nw-agent::stage=collect\n      function 0xfd58  (emulation)\n```\n\n**8/8 in under 10 seconds**, each string tagged and traced to the function that builds it.\nReproduce it with `ONLY=scatter_obfstr PROFILES=size-lto FORMATS=elf bash scripts/build_all.sh`\n(output trimmed to the decoded strings). (The input is a benign benchmark\nprogram; every planted string is listed in `bench_src/manifest.json`.)\n\nRust malware is rising, and its deobfuscation tooling is effectively absent. **FLOSS** — the\nstandard open-source string deobfuscator — recovers nothing from the common Rust obfuscation\ncrates:\n\n| obfuscator | `strings` | FLOSS | **rust-deobf** | \n|---|---|---|---|\n| litcrypt / litcrypt2 | 0/5 | 0/5 | **5/5** | \n| obfstr (inlined XOR+ADD) | 0/5 | 0/5 | **5/5** | \n| cryptify (default key) | 0/5 | 0/5 | **5/5** | \n| muddy (ChaCha20-Poly1305 AEAD) | 0/5 | 0/5 | **5/5** | \n| tight stack strings | 0/5 | 0/5 | **5/5** | \n| hand-rolled XOR / ADD / SUB | 0/5 | 0/5 | **5/5** | \n\n<sub>Canary benchmark, release builds, ELF and PE x86-64 and AArch64, stripped and not. Full table,\nincluding the one case that needs dynamic recovery (cryptify with a custom key), under\n[Results](#-results).</sub>\n\nOn real samples: **EDDIESTEALER** (PE) — 32 obfuscated strings including its hidden C2 domain and\nURL path, where static tools and FLOSS recover 0. **AmnesiaStealer** (macOS Mach-O) — C2 exfil URL,\ncrypto-clipper config, staging paths. [More below](#-real-malware).\n\n- \n**String deobfuscation** — static call-site recovery (seconds) + a Unicorn emulation engine with\na real loader, a C runtime, path exploration and global-constructor replay. Recovers strings built\nonly in one`match` arm or on a branch that never runs.\n- \n**One-screen SOC triage** —`soc_triage.py` : format/arch, Rust toolchain, crate capability\nfingerprint (networking / crypto / recon / persistence / staging), indicators grouped by type, and\na calibrated LOW / MEDIUM / HIGH signal with its evidence.`--json` for a SIEM,`--fail-on HIGH` for scripted gating.`--fast` (metadata + literal indicators, no decoding) scales with binary\nsize: under a second on a small sample, ~20 s on a 20 MB binary.\n- \n**Multi-arch, multi-format** — x86-64, ARM64 and 32-bit x86 across ELF, PE and Mach-O.\n- \n**Fits your workflow** — FLOSS-compatible export (`--floss-json` ), headless Ghidra annotation\n(plate comments, bookmarks, renames), library-function identification by byte signature\n(`lib_<name>` in stripped binaries), batch mode over a directory (`--jsonl` ).\n- \n**Beyond strings** — config/C2 extraction, hash-obfuscated API-import resolution, unpacked\nstage-2 carving, control-flow-flattening detection**and reconstruction** (x86-64 and AArch64,\nvalidated on goldberg, OLLVM`-fla` and a real LLVM-18 flattening pass), MBA / opaque-predicate\nsimplification with Z3, Rust trait-object vtable recovery (`recover_types.py` ), and\noptimization-reversal (jump-table un-switching, unrolled-loop detection —`premium/opt_reverse.py` ).\n- \n**Code virtualization** —`detect_vm.py` flags VMProtect/Themida/Tigress-style bytecode VMs; the\npremium devirtualizer lifts them back. From a VM with**randomized opcodes and encrypted\noperands** , it reconstructs:\n\n```\nreturn (((a0 > 1000) & (((((a0 * 31) + a1) ^ (a0 >> 3)) % 97) == 0)) ? (a1 + a0) : 0xffffffff);\n```\n\n — the function's exact source.\n- \n**Safe by design** — static + emulation by default: emulation runs*functions* of the binary under\nUnicorn, never the binary itself. Dynamic (Frida) recovery is opt-in and for isolated VMs only.\n- \n**Rust metadata that survives `strip`** — rustc version, third-party crates with exact versions,\nand panic sites, rebuilt from the source paths the compiler leaves in every Rust binary (no`cargo-auditable` needed).\n\nA decompiler's string view, `strings`, FLOSS — anything that reads strings from the file — can only\nshow bytes that *are* in the file. An obfuscation crate makes sure the plaintext never is, so an AI\nagent reading a decompiler sees the same nothing you do. rust-deobf gives it the facts instead.\n\nIt is a CLI with JSON output, so any agent that can run shell commands (Claude Code, Codex, Cursor, …) can call it next to whatever decompiler tooling it already drives:\n\n```\npython3 scripts/soc_triage.py sample.bin --json      # one-screen facts: toolchain, crates, IOCs, signal\npython3 scripts/recover.py   sample.bin --json       # every decoded string + the function that builds it\nscripts/ghidra_run.sh sample.bin                     # write them into the Ghidra decompiler as plate comments\npython3 scripts/mcp_server.py                         # or serve recover/triage/metadata/detect as MCP tools\n```\n\nPrefer typed tools over CLI parsing? `scripts/mcp_server.py` exposes `detect` / `metadata` /\n`triage` / `recover` as [MCP](https://modelcontextprotocol.io) tools over stdio (no third-party\npackage needed), so an agent calls them with a JSON schema and gets structured JSON back. And\n`scripts/export_rules.py` turns the recovered facts into **capa** rules and **YARA** rules (the\nMandiant detection formats), for the obfuscation crates and from the recovered decoder idioms /\nhash-obfuscated imports — `--static-only` emits the crate family ruleset for any corpus.\n\nA prompt that works well:\n\n*\"Run `python3 scripts/soc_triage.py sample.bin --json` and `python3 scripts/recover.py sample.bin --json` from rust-deobf, then decompile the functions those strings came from and\nexplain what they do. Treat rust-deobf's decoded strings as ground truth; label anything you\ninfer as a hypothesis.\"*\n\nWant the LLM step built in? `scripts/llm_triage.py` hands **only the recovered facts** (never the\nraw binary) to a local model (Ollama / vLLM via `RUST_DEOBF_LLM_BASE` — malware data stays on your\nhost), the Anthropic or OpenAI API, or prints the grounded prompt for offline use.\n\n```\ngit clone https://github.com/VAHPEM/rust-deobf.git\ncd rust-deobf\npython3 -m pip install -r requirements.txt          # lief, capstone, unicorn, z3-solver\n\npython3 scripts/recover.py path/to/binary            # static + emulation (default), never runs it\npython3 scripts/soc_triage.py path/to/binary         # one-screen triage card\npython3 scripts/recover.py path/to/binary --json     # machine-readable, for agents and pipelines\n```\n\n## **All options**\n\n```\npython3 scripts/recover.py path/to/binary --floss-json out.json   # FLOSS results document\npython3 scripts/recover.py path/to/binary --emulate never         # static passes only, seconds\npython3 scripts/recover.py path/to/binary --time-budget 600       # big binaries: emulate longer\npython3 scripts/recover.py path/to/binary --max-ram 2G            # cap memory (fewer processes)\npython3 scripts/recover.py path/to/binary --libfuncs              # also name Rust std/crate functions\npython3 scripts/recover.py samples/ --jsonl triage.jsonl          # a whole directory\n\n# name library functions in a stripped binary (needs a signature DB for its toolchain):\npython3 scripts/recover_libfuncs.py --gen build/*release*nostrip   # build the DB once per toolchain\npython3 scripts/recover_libfuncs.py path/to/binary                 # identify; --json for records\npython3 scripts/recover_libfuncs.py path/to/binary --propagate     # + call-graph thunks & drop-glue\n# recover Rust trait-object vtables (dyn Trait dispatch) as Ghidra data types:\npython3 scripts/recover_types.py path/to/binary                    # --json for records\n# push strings + library names + vtables into Ghidra (headless, PyGhidra):\nscripts/ghidra_run.sh path/to/binary\n\n# AI-assisted read over the recovered facts only (local model, API, or --offline):\npython3 scripts/llm_triage.py path/to/binary\n\n# runtime-only decoders, inside an isolated analysis VM ONLY (pip install frida frida-tools)\n# it EXECUTES the sample, so it refuses to start without the explicit opt-in flag\npython3 scripts/recover_dynamic.py path/to/binary --i-understand-this-executes [--argv arg1 arg2 ...]\n```\n\nExit codes (`recover.py`, `soc_triage.py`, `llm_triage.py`): `0` the analysis ran, `2` usage error,\n`3` unsupported or unparseable input (not an ELF/PE/Mach-O LIEF can read — e.g. a text file),\n`4` analysis error (an engine raised; the other engines still ran and their output is printed; in\ndirectory mode, at least one file failed — the table's error column says which). `soc_triage.py --fail-on MEDIUM|HIGH` exits `1` when the signal reaches that level. Directory mode analyses every\nbinary single-file mode supports (32-bit x86 included), and counts the files it skipped.\n\nEmulation runs by default and is bounded by `--time-budget` (120 s): functions are tried in\ndecoder-score order, and the output says how many were emulated (`functions=412/1671`) and\nwhether the budget ran out. Memory is the other knob: a large binary is split across worker\nprocesses (`RUST_DEOBF_WORKERS`, default `min(4, CPUs)`), each holding its own loaded image and\nUnicorn instance — roughly 250 MB plus 45 MB per MB of `.text`, so ≈0.5 GB for gdb and ≈1.1 GB\nfor cargo. `--max-ram 2G` (or `RUST_DEOBF_MAX_RAM_MB`) caps the process count to fit that\nbudget, trading speed for memory; a budget below one process's footprint runs serially, slower,\nand the output reports both the process count and the reduction.\n\nEvery number here is a test or a script in this repo, and the benchmark publishes the cases where\nthe tool does **not** work too.\n\n| **360 / 360** | exact strings on held-out shapes × malware build profiles (54 stripped binaries, ELF/PE/AArch64), **0** extra | \n| **1360 / 1360** | across build profiles the benchmark never tuned for, 0 junk — see *Build profiles nobody tuned for* below for what that number means | \n| **120 / 120 vs 0** | on Windows, stripped PE files: rust-deobf vs FLOSS 3.2.0, same machine, same files | \n| **29 FP / 21 binaries** | false positives on real unobfuscated C/C++, Go and Rust programs (bash, git, gdb, docker, cargo…) | \n| **9 families** | real Rust malware validated end-to-end (static + emulation, never executed) | \n\n## **Held-out shapes × malware build profiles (scatter suite)**\n\n`python scripts/eval_scatter.py` — the default `recover.py` pipeline, exact matches out of 8;\n`(+n)` = extra strings reported (false positives). `plaintext` is the same program with plain\nliterals: everything it reports is a false positive.\n\n| scatter program | ELF x86-64 release | ELF x86-64 size-lto | ELF x86-64 speed-lto | PE x86-64 release | PE x86-64 size-lto | PE x86-64 speed-lto | ELF AArch64 release | ELF AArch64 size-lto | ELF AArch64 speed-lto | \n|---|---|---|---|---|---|---|---|---|---|\n| `xor_custom` | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | \n| `litcrypt` | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | \n| `litcrypt2` | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | \n| `obfstr` | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | \n| `cryptify` | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | **8/8** | \n| `plaintext` | **0** | **0** | **0** | **0** | **0** | **0** | **0** | **0** | **0** | \n\n**360/360** exact strings and **0** extra across all 54 binaries (stripped builds); the code an\nexternal review tested got **79/360**. Every cell is a test in `tests/test_scatter.py`.\n\n## **Canary benchmark (one program per crate, five printed strings)**\n\n| obfuscator | cipher | `strings` | FLOSS | **static** | **emulation** | **dynamic** | \n|---|---|---|---|---|---|---|\n| xor_custom | hand-rolled XOR | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| add_custom | hand-rolled ADD | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| sub_custom | hand-rolled SUB | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| litcrypt | XOR, runtime key | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| litcrypt2 | XOR, stride-permuted key | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| obfstr | XOR+ADD, inlined | 0/5 | 0/5 | — | **5/5** | **5/5** | \n| tight strings | stack buffer XORed in place | 0/5 | 0/5 | — | **5/5** | **5/5** | \n| cryptify (default key) | XOR, crate-default key | 0/5 | 0/5 | **5/5** | **5/5** | **5/5** | \n| cryptify (custom key) | key only in the build/run environment | 0/5 | 0/5 | —¹ | —¹ | **5/5** ¹ | \n| muddy | ChaCha20-Poly1305 (AEAD) | 0/5 | 0/5 | — | **5/5** | **5/5** | \n\nRelease builds, ELF and PE x86-64 and AArch64, stripped and not; `size-lto`/` speed-lto` builds\nof every crate are tested too. Static = reads the decoder's key and the call sites without\nrunning anything; emulation = Unicorn, never runs the sample natively; dynamic = Frida in an\nisolated VM.\n\n<sub>¹ cryptify reads `CRYPTIFY_KEY` again at run time: with a custom key the sample only\ndecrypts when run with that variable set, and the key is not in the binary. `recover_cryptify`\ndetects the crate and says so.</sub>\n\n## **Build profiles nobody tuned for**\n\nThe benchmark's own programs rebuilt with release settings the benchmark never uses, stripped\n(`CARGO_PROFILE_RELEASE_*` overrides). Exact strings; `+n` = junk strings reported.\n\n| set | profiles | formats | first measured | now | \n|---|---|---|---|---|\n| 1 | `opt-level=\"s\"` + thin LTO + 16 CGU +`panic=unwind` ;`opt-level=1` | ELF, PE | 338/340 +7 | **340/340** +0 | \n| 2 | `opt-level=2` , 1 CGU, no LTO;`opt-level=\"z\"` , no LTO,`panic=unwind` | ELF, PE, ARM64 musl | 500/510 +3 | **510/510** +0 | \n| 3 | `opt-level=3` , no LTO, 16 CGU,`panic=unwind` ;`opt-level=\"s\"` , fat LTO | ELF, PE, ARM64 glibc | 504/510 +8 | **510/510** +0 | \n\nEach set was built after the fixes for the previous one, measured once (\"first measured\": the honest held-out number, 98–99 % exact with a few junk strings), and then used to find and fix its failures — so the \"now\" column is no longer held-out. The next independent review will say how the current code does on what nobody has seen.\n\n## **Precision on real, unobfuscated binaries (21-binary corpus)**\n\n`scripts/measure_fp.py --pipeline --corpus` (static + emulation, 120 s budget): every decoded\nstring reported is a false positive. The corpus mixes C/C++, Go and Rust binaries, because the six\nbinaries the filters were developed against (bash, tar, xz, git, curl, ripgrep: 0) overstated\nprecision — Xvfb and a Go binary had 68 and 17 before the second review round.\n\n| binary | language | `.text` | functions emulated (120 s, 2 CPUs) | false positives | \n|---|---|---|---|---|\n| bash | C/C++ | 945 KB | 2343/2343 | **0** | \n| tar | C/C++ | 292 KB | 607/607 | **0** | \n| xz | C/C++ | 41 KB | 76/76 | **0** | \n| git | C/C++ | 3,077 KB | 4473/4473 | **0** | \n| curl | C/C++ | 107 KB | 121/121 | **0** | \n| gdb | C/C++ | 5,693 KB | 2022/15760 (budget) | **0** | \n| cmake | C/C++ | 9,318 KB | 4305/18830 (budget) | **1** | \n| Xvfb | C/C++ | 1,435 KB | 5038/5038 | **1** | \n| openssl | C/C++ | 415 KB | 535/535 | **0** | \n| perl | C/C++ | 1,858 KB | 2240/2240 | **3** | \n| python3.13 | C/C++ | 3,039 KB | 10767/11510 (budget) | **0** | \n| vim | C/C++ | 3,110 KB | 5302/5302 | **7** | \n| age | Go | 1,631 KB | 2353/2353 | **2** | \n| gh | Go | 16,710 KB | 1718/13720 (budget) | **0** | \n| docker | Go | 13,379 KB | 1498/35916 (budget) | **1** | \n| go (toolchain) | Go | 6,351 KB | 2115/13835 (budget) | **2** | \n| ripgrep (Debian) | Rust | 3,075 KB | 4947/8530 (budget) | **3** | \n| rustfmt | Rust | 2,849 KB | 4545/4545 | **4** | \n| cargo-fmt | Rust | 874 KB | 1280/1280 | **0** | \n| cargo | Rust | 19,594 KB | 3528/43240 (budget) | **5** | \n| rustdoc | Rust | 5,894 KB | 8531/8531 | **0** | \n\n**29 false positives on 21 binaries** (C/C++ 12, Go 5, Rust 12); 11 have none. Not zero: what\nis left is text the program assembles at run time from its own pieces in ways the filters do not\nmodel (`File/Glob.pm`, `lib/rustlib/src/rust/`, a regex help line), symbol names with letters\ninserted as well as changed (``PyGIL` EmSure``), and a few reshuffles. Each is reported as decoded,\nso a result list on a non-obfuscated binary is not empty — but it is short, and the previous\ncode reported 68 on Xvfb alone. Coverage grows with CPUs and `--time-budget`, and so can noise.\nThe CI job on GitHub's runner (other versions of these programs) measured 32 on 20 binaries.\n\n## **Second machine, second toolchain**\n\nEverything was re-run outside the development container, on the two ARM64 VMs of\n[dual-vm-ai-agent](https://github.com/VAHPEM/dual-vm-ai-agent):\n\n- **Kali Linux, rustc 1.99.1** (current stable): both suites rebuilt for PE, AArch64 musl and a\nnew target,**AArch64 glibc** (dynamically linked, the default Linux target), every profile.`pytest tests` : 232 passed, 0 failed (x86-64 ELF skipped on an AArch64 host).\n- **Windows** :`recover.py` on the 18 stripped scatter PE files recovers**120/120** strings\nwith 0 extra; FLOSS 3.2.0 on the same machine and files decodes**0** .\n- Development container, rustc 1.97: 289 tests pass.\n\nThe glibc build needed two emulator fixes — libgcc's outlined atomics (`__aarch64_cas4_acq` &\nco.) and a `write` import that must report success — see [VALIDATION.md §10b](https://github.com/VAHPEM/rust-deobf/blob/master/VALIDATION.md).\n\n## **Third-party obfuscation crates: goldberg**\n\n`bench_src/` is hand-rolled ciphers; `obf_src/` is author-written programs obfuscated with the\ncrates a real analyst meets. [**goldberg**](https://crates.io/crates/goldberg) applies code-flow\nobfuscation plus string and integer encryption; `recover.py` recovers its encrypted strings\n**6/6** across ELF, PE and AArch64, release and `size-lto` (`tests/test_obf.py`, a CI job). The\nstrings are IOC-shaped and chosen not to collide with crate or metadata tokens, so the number\nmeasures recovery rather than a coincidence of the literal text. More crates are planned\n(`rustfuscator`, `obfuse`), goldberg first.\n\nAn external review reproduced the published numbers exactly (107/107 tests, all 144 binaries\nrebuilt with rustc 1.97) and found no cheating — but showed the tool was **overfitted to the\nbenchmark's shape**. The fix was not a list of new patterns: the emulator was rebuilt into a general\nengine, and the benchmark gained the shapes and build profiles that exposed the problem, so it\ncannot silently overfit again.\n\n## **What the reviews found, and what changed**\n\n**Round 1 — generalisation**\n\n| reviewer's held-out finding | before | now | \n|---|---|---|\n| litcrypt2, malware-style build (fat LTO, `opt-level=\"z\"` ,`panic=abort` ), ELF and PE | **0/8** | **8/8** | \n| litcrypt2, benchmark profile ( `opt-level=3` ) | 5/8 (decoded 8, text filter dropped 3) | **8/8** | \n| mutex name `Global\\…` rejected by the \"looks like text\" filter | dropped | kept, tagged `[mutex]` | \n| IOC with a prefix ( `heldout::10.13.37.42:8443` ) rejected | dropped | kept, tagged `[ip]` | \n| obfstr, strings not printed ( `.to_string()` , comparisons) | 0–2 full, rest truncated | **8/8** | \n| emulation capped at 400 functions (bash: 400/1671) | capped | no cap; ordered by decoder score, bounded by time | \n\n- **scatter suite** (`bench_src/scatter_*` ): 8 indicator-shaped strings — IP:port, Windows path,\nmutex, URL, user agent, argv flag, hive-less registry key, prefixed identifier — in a struct,`match` arms, comparisons that are never printed, and a branch that never runs.\n- **build profiles** : every program is also built`size-lto` (`opt-level=\"z\"` , fat LTO,`codegen-units=1` ,`panic=abort` ) and`speed-lto` (`opt-level=3` , same LTO settings).\n\n`tests/test_scatter.py` holds recall **and precision** on all of it in CI (one job per format).\n\n**Round 2 — what the first round still overfitted** ([VALIDATION.md §11](https://github.com/VAHPEM/rust-deobf/blob/master/VALIDATION.md))\n\n| finding | before | now | \n|---|---|---|\n| build profiles nobody tuned for (3 sets, 256 binaries) | 98–99 % exact, +18 junk | **1360/1360** , 0 junk | \n| real binaries outside the development set: Xvfb (C) / age (Go) | 68 / 17 false positives | **1 / 2** ; 29 on a 21-binary corpus | \n| cargo, gdb, cmake | killed at 5.5–6 GB of memory | run: gdb 0.5 GB, cargo (19 MB `.text` ) ≈1.1 GB per process | \n| gdb's 15,760 functions in the 120 s budget | 136 emulated | ≈2,000 (2 CPUs) | \n\nA CI job now measures false positives on a C/C++, Go and Rust corpus found on the runner.\n\nValidated on **nine real Rust-malware families** by hash — static + emulation only, samples\n**never executed** and **never committed** (only hashes and results, in `gallery/results.jsonl`):\n\n- **EDDIESTEALER** (PE) —**32 obfuscated strings** , including the hidden**C2 domain and URL path** (published IOCs) and the Windows API names it hides. Static tools and FLOSS: 0.\n- **AmnesiaStealer** (macOS Mach-O) — the C2 exfil URL, crypto-clipper config, Safari-cookie /\nTCC-bypass staging paths and AppleScript for Telegram/Notes theft.\n- **SPICA** (ColdRiver) — its registry persistence key.\n- **RustyAttr** (Lazarus) — unpacked from a triple-packed zip→gz→cpio→app chain (and honestly no\nobfuscated strings: its evasion is extended-attribute script hiding, not string encryption).\n- **KrustyLoader** — an honest non-hit: its config is encrypted at run time (cfb-mode), so static\nstring deobfuscation correctly finds nothing; the metadata tier still recovers rustc 1.70.0, 18\ncrates and a capability profile.\n\n`gallery/manifest.json` (built from\n[rust-malware-gallery](https://github.com/decoderloop/rust-malware-gallery)) maps **126 sample\nhashes across 67 Rust malware families** to family, aliases and writeups, for evaluation and\nhash → family attribution. Details: [VALIDATION.md §12, §18](https://github.com/VAHPEM/rust-deobf/blob/master/VALIDATION.md).\n\n`recover.py` runs everything that applies and merges the results:\n\n```\nflowchart LR\n    B[\"Rust binary<br/>ELF · PE · Mach-O<br/>x86-64 · ARM64 · x86\"] --> S[\"Static call-site recovery<br/>key + ciphertext by dataflow\"]\n    B --> E[\"Unicorn emulation<br/>runs functions, never the binary\"]\n    B --> L[\"Stack strings +<br/>Rust literals\"]\n    B --> M[\"Rust metadata<br/>rustc · crates · panic sites\"]\n    S --> R[\"Merge, filter,<br/>tag indicators\"]\n    E --> R\n    L --> R\n    M --> T[\"SOC triage card<br/>LOW / MEDIUM / HIGH\"]\n    R --> T\n    R --> O1[\"--json / --jsonl\"]\n    R --> O2[\"FLOSS results JSON\"]\n    R --> O3[\"Ghidra plate comments\"]\n    T --> O4[\"llm_triage.py<br/>(facts only, local or API)\"]\n```\n\n| engine | what it does | speed | \n|---|---|---|\n| **Static call-site recovery** (`recover_callsite.py` ,`recover_arm64.py` ,`recover_cryptify.py` ) | finds the decode loop, resolves the key and each call's ciphertext with ABI-aware dataflow, decodes | seconds | \n| **Emulation** (`recover_emulate.py` , Unicorn) | runs *functions* of the binary (never the binary) and collects the plaintext the code builds | bounded by `--time-budget` | \n| **Stack strings / Rust literals** | plain strings cut at their real (pointer, length) boundaries instead of `strings` ' glued runs | seconds | \n| **Dynamic** (`recover_dynamic.py` , Frida) | runs the sample and diffs its memory — cipher-agnostic, **isolated VM only** | runtime | \n\n## **Inside the emulation engine**\n\n- \n**Static call-site recovery** is fast with exact attribution (decoder, call site, ciphertext), but\nonly as good as its idiom recognisers: it recognises the benchmark's release idioms, not every\nbuild profile — that is what emulation is for.\n- \n**Emulation** is the general engine, x86-64, AArch64 and 32-bit x86, across ELF, PE and Mach-O.\nWhat makes it work on real Rust code:\n  - *real calls* : callees run up to 8 levels deep, so a call site runs its out-of-line decoder\n(`lc!` →`decrypt_bytes` →`Iterator::next` ); each callee has an instruction budget and a\ncallee that overruns it (a spinning lock, a loop over a fake length) or faults is abandoned\nand returns 0, with the caller's registers restored;\n  - *a loader* : relocations applied (ELF`RELATIVE` /`GLOB_DAT` /`JUMP_SLOT` , PE base\nrelocations), every import pointed at a named trampoline,`.bss` zeroed, thread-local\nstorage (fs/gs/`TPIDR_EL0` ) set up, AArch64 outlined atomics switched to their LSE path,\nand the image's**global constructors** (ELF`.init_array` /`.ctors` , Mach-O`__mod_init_func` ) replayed before the sweep so state a loader sets before`main` — e.g. an\nanti-emulation marker a constructor writes, which an isolated-function analyzer would miss\nand whose absence poisons a string key — is in place for every run;\n  - *a C runtime* : allocators (`malloc` ,`HeapAlloc` ,`__rust_alloc` , …) hand out separated\nzeroed blocks;`memcpy` /`memmove` /`memset` /`memcmp` /`bcmp` /`strlen` ,`brk` /`mmap` , environment\nlookups and`GetLastError` behave; in stripped static (musl) builds the runtime functions are\nfound by FLIRT-style byte signatures (`gen_runtime_sigs.py` );\n  - *path exploration* : decoder-like functions are re-run with forward branches forced the\nother way, so strings built only in one`match` arm or on a branch that never runs are\nreached;\n  - *scheduling* : a first pass runs every function once with a small budget (most decoders\nfinish in a few thousand instructions), the second gives full budgets and explores; a run or\ncallee still writing text when its budget ends gets more; allocator wrappers and import stubs\nrun at any call depth; large binaries are split across processes;\n  - *capture* : every store is tracked with its age; written bytes are read back before calls\nand on returns, extended through neighbouring written bytes and cut where writes of\ndifferent ages meet (a short string written over a long one), and the (pointer, length)\npairs the code passes on give the exact end of a Rust string.\n Reported: text-like strings (including indicator shapes) that are not in the file, not stitched from static pieces, printf templates or static text with numbers formatted in, and not artefacts of a forced path or of out-of-order stores.\n\nEvery recovered string says where it came from, indicator shapes are tagged, and `--floss-json`\nwrites a FLOSS results document so the strings appear in FLOSS next to the static ones: view it with\n`floss -l out.json` (checked against FLOSS 3.2.0).\n\n## **Triage, AI-assisted triage, VM detection and devirtualization**\n\n**`scripts/soc_triage.py <binary>`** prints a one-screen SOC triage card — format/arch, Rust\ntoolchain + panic-site count, a crate capability fingerprint (networking / crypto / recon /\npersistence / staging), indicators grouped by tag with benign Rust std/compiler paths filtered out,\na decoded-string sample, and a calibrated LOW/MEDIUM/HIGH triage *signal* with its evidence (an\nanalyst aid, not a verdict). The signal needs **obfuscation evidence** to rise above LOW: decoded\nstrings (one indicator-shaped, or three or more that are not just formatted from the binary's plain\nliterals — fewer is within the engines' false-positive floor on big benign binaries), an\nindicator-shaped stack-built string, or virtualized functions in a binary that does not link an\ninterpreter crate (regex engines, wasm runtimes, script VMs look exactly like a VM). Indicator-shaped\n*plain literals* (an update URL, an index URL) are listed separately as literal indicators and never\nscored, and the ubiquitous HTTP/TLS/hashing/OS-binding crates (tokio, hyper, rustls, ring, base64,\nwindows-sys) are shown in the fingerprint but add nothing — so unobfuscated tools like `rustup`,\n`uv` or `rg` come out LOW. `--fast` skips every decoding pass (static call-site recovery, stack\nstrings, emulation, the virtualization scan) and keeps metadata + literal indicators; literal\nsplitting scales with size (measured: 0.6 s on a 0.4 MB canary, 6 s on `rg` 5 MB, 19 s on `rustup`\n20 MB, 100 s on `uv` 66 MB), and its signal is always LOW with that caveat.\n`--json` emits a record for a SIEM/case system; `--fail-on MEDIUM|HIGH` exits 1 at that level.\nStatic + emulation only; never executes the sample.\n\n**`scripts/llm_triage.py <binary>`** hands *only* the deterministically recovered facts (the triage\ncard above — never the raw binary) to an LLM and asks for a grounded verdict, likely capabilities\ntied to the evidence, candidate ATT&CK techniques, what is uncertain, and next steps. The\ndeterministic facts stay ground truth; the LLM note is a clearly labelled hypothesis, and the model\nis instructed not to invent indicators. Backends auto-detect: a **local** OpenAI-compatible server\n(`RUST_DEOBF_LLM_BASE=http://localhost:11434/v1` for Ollama/vLLM — so malware data never leaves the\nhost; when it is set it wins over any `ANTHROPIC_API_KEY`/` OPENAI_API_KEY` in the environment\nunless `RUST_DEOBF_LLM=anthropic|openai` says otherwise), the Anthropic or OpenAI APIs, or, with no\nkey, an **offline** mode that prints the grounded prompt to paste into any LLM plus a deterministic\nrule-based note. Recovered strings are attacker-controlled text, so they are sent inside\n`<untrusted_evidence>` tags, truncated to 200 characters each and capped per list, and the system\nprompt tells the model to treat them as data, never as instructions.\n\n**`scripts/detect_vm.py <binary>`** flags functions hidden behind a **bytecode VM (code\nvirtualization)** — the VMProtect/Themida/Tigress technique, and RustCloak's `virt`. It keys on the\ninterpreter's machine-code fingerprint: a *threaded dispatch* (many indirect branches through one\nregister) plus dense bytecode byte-fetches, and folds into the triage card as a signal. Precise on\nx86-64 (`ls`/` grep`/` openssl`/` bash` clean; it does flag CPython's eval loop — correctly, that\ngenuinely is an interpreter); AArch64 detection is the next round.\n\n**`premium/lift_vm.py <binary>`** (premium — AGPL/commercial) **devirtualizes** a detected VM\nfunction: it finds the embedded bytecode blob (anchored by its code cross-reference, since the blobs\nare packed in `.rodata`), decodes the register-machine instructions — including the control-flow\nopcodes, so branches and loops come back as labelled `jmp`/` jnz` with the SSA PHI copies visible —\nand prints the function's logic as a compact VM-assembly listing instead of a dispatch loop. It\ndecodes the width-tagged format too (`load.i8 rD, [rA]`, `store.i8 [rA], rB`, `add.i32`). When the\nVM's opcode bytes are **randomized per build**, the lifter **recovers the opcode map** by parsing\nthe dispatch jump table and classifying each handler by its native instruction (an `idiv` with the\nremainder stored → SREM, a rip-relative pool load → CONST, …). When the operand bytes are also\n**encrypted per build**, it recovers the XOR key from the interpreter and **raises the result to C\npseudocode**, resolving the constant pool and folding temporaries into one expression.\nControl-flow bodies are raised from `if/goto` toward structured `do/while` + `if` where the flow is\nreducible (an `-O2`-unrolled loop nest is left as `if/goto`). **` premium/devirt_emulate.py`**\nrecovers the VM's instruction set by *running* the interpreter under Unicorn and observing opcode\nsemantics instead of pattern-matching its native code, with a correctness gate that re-executes the\nrecovered program. x86-64; recovery is demonstrated end-to-end on a function, with full per-binary\ncoverage ongoing.\n\n**Ghidra integration.** `scripts/ghidra_run.sh <binary>` runs the recovery and annotates a Ghidra\nproject headless: every decoding function gets a plate comment listing its recovered strings and a\nbookmark, functions with default `FUN_`/` SUB_` names are renamed after their strongest indicator, and\nidentified library functions get `lib_<name>` renames. It is a PyGhidra script (Ghidra 11.3+ dropped\nJython); verified headless on Ghidra 12.1.3. IDA and Binary Ninja exporters are planned next.\n\nThis is research code, not a finished product. The ones that matter most:\n\n- **Emulation is not execution.** Arguments are fake, so code that only decodes for specific\ninputs (a key from the network, a path that depends on the environment) can be missed.\n- **Large binaries are covered partly** within the default 120 s budget (≈2,000 of gdb's 15,760\nfunctions on 2 CPUs). The output reports coverage; raise`--time-budget` or add CPUs.\n- **Precision is good, not perfect** : 29 false positives on a 21-binary C/C++, Go and Rust corpus.\n- **32-bit PE is still open** : emulation of the Windows std path is not yet reliable there, so PE32\nis routed to literal recovery only (32-bit x86 ELF and Mach-O are verified 5/5).\n- **Runtime-keyed configs** (custom-key cryptify, KrustyLoader's cfb-mode config) need dynamic\nrecovery with that environment.\n\n## **Full list**\n\n- **Emulation is not execution.** Path exploration flips branches one at a time inside the function\nit starts from, within budgets (8 runs per function, instruction budgets per run and per callee).\n- **Large binaries** : emulation runs Unicorn with Python hooks (≈30k instructions/s per process).\nA first pass runs every function once with a small budget, a second explores decoder-like\nfunctions, and binaries with 2,000+ functions are split over processes (`RUST_DEOBF_WORKERS` ).`--max-ram` trades that speed back for memory on a small machine.\n- **Static recovery is idiom-bound** by design; it covers the release idioms of the benchmark\ncrates. Under`size-lto` most keyed decoders are recovered by emulation, not statically.\n- **musl runtime signatures** describe the musl that current rustc bundles; another version may\nneed`gen_runtime_sigs.py` re-run on an unstripped build.\n- **Precision** : what is left is run-time assembled text the filters do not model (see Results).\n- **Overlap rules** : which of several overlapping views of a buffer is the string is settled by the**buffer address** each view was materialised at (recorded during emulation), so views of one\nbuffer are told from two buffers that merely share a prefix; the text/write-age heuristics remain\nonly as the fallback where an address is unknown (VALIDATION.md §11).\n- **Real malware** : on large real binaries a minority of emulation outputs are garbled std-internal\nfragments.**32-bit x86 ELF** and**Mach-O** (x86-64 and arm64) are fully emulated, verified at**5/5 on all six canary crates** for`i686-unknown-linux-gnu` (VALIDATION.md §14,`tests/test_32bit.py` ) and for`aarch64-apple-darwin` (§15,`tests/test_macho.py` ).**32-bit PE** :\nthe loader handles it (PE32`HIGHLOW` relocations, imagebase rebasing) but emulation of the\nWindows std path gives 0–2/5 plus false positives, so emulation is skipped with a note until it is\nverified 5/5 (VALIDATION.md §14). FickleStealer is also packed, so it needs unpacking regardless\nof arch. The benchmark's obfuscation crates model one real pattern, not all of them.\n- **Library-function identification** (`recover_libfuncs.py` ) names std/crate functions by byte\nsignature on ELF, PE and Mach-O (x86-64, AArch64; 32-bit x86 ELF); the bundled DB is for one\ntoolchain (rustc 1.97 x86-64) and must be regenerated (`--gen` ) for another rustc, architecture\nor format, and generic-heavy labels flatten their type args.\n\nFull matrix: `RESULTS.md` · reproducible metrics: `METRICS.md` · real-binary validation and the\ngeneralisation work: `VALIDATION.md` (§10) · artifact tiers: `ARTIFACT_TIERS.md`.\n\n| task | environment | why | \n|---|---|---|\n| **Static / emulation recovery** | any host, no VM | reads the binary; emulation runs functions under Unicorn, never natively | \n| **Building the benchmark** | any host with Rust ( `rustup` ) | cross-compiles, does not run samples | \n| **Dynamic recovery** | **isolated analysis VM — required** | it *executes* the target | \n\n```\npython3 bench_src/gen_projects.py                 # (re)generate bench_src from the canaries\nARM64=1 bash scripts/build_all.sh                 # all crates x {ELF, PE, AArch64} x profiles\nFORMATS=pe bash scripts/build_all.sh              # one format (CI runs one job per format)\nARM64_GNU=1 bash scripts/build_all.sh             # + AArch64 glibc (dynamic); cross linker\n                                                  # aarch64-linux-gnu-gcc unless on AArch64\npython3 -m pytest tests -q                        # recall and precision, canary + scatter\npython3 scripts/eval_scatter.py                   # the scatter table above\npython3 scripts/measure_fp.py --pipeline          # false positives on real binaries\npython3 scripts/fuzz_recover.py --time 300        # robustness fuzz: malformed ELF/PE/Mach-O\npython3 scripts/redblue_loop.py --rounds 20       # RustCloak hides random strings, rust-deobf recovers\n```\n\n`fuzz_recover.py` fuzzes rust-deobf *itself* — it mutates the committed binary fixtures and feeds the\nmalformed results through the whole loader/emulator path, each input isolated with an rlimit and a\ntimeout, so a crafted binary that would hang or crash the analyzer in a SOC pipeline is caught as a\nfinding (and saved for `--reproduce`). It found and we fixed five such hangs/OOMs; see\n[VALIDATION.md §48](https://github.com/VAHPEM/rust-deobf/blob/master/VALIDATION.md). `--atheris` drives libFuzzer when the `atheris` package is\ninstalled; otherwise a standalone mutation driver runs.\n\n`redblue_loop.py` is the recovery-side fuzzer and the overfitting answer: each round generates a\n*random* benign Rust program that plants known indicator strings (documentation placeholders only),\ncompiles it under a *random* RustCloak configuration (needs the RustCloak plugin + a matching nightly\n`rustc`), and checks rust-deobf recovers every planted string — misses are saved as regression\nfixtures. See [VALIDATION.md §49](https://github.com/VAHPEM/rust-deobf/blob/master/VALIDATION.md).\n\n## **Build details**\n\n`build_all.sh` profiles: `debug`, `release`, `size-lto`, `speed-lto` (canary suite) and\n`release`, `size-lto`, `speed-lto` (scatter suite); `PROFILES=...` and `ONLY=...` narrow it,\n`SKIP_EXISTING=1` keeps what is built. Rust targets: `x86_64-pc-windows-gnu` (with the\n`mingw-w64` linker), `aarch64-unknown-linux-musl` (linked by the bundled `rust-lld`) and, with\n`ARM64_GNU=1`, `aarch64-unknown-linux-gnu`. `BUILD_DIR=...` builds elsewhere and\n`RUST_DEOBF_BUILD=...` points the tests at it, so two toolchains can be compared side by side.\nFLOSS is only needed to reproduce the baseline column (`scripts/run_floss.py`).\n\nDynamic recovery must run inside a dedicated, isolated VM (Kali or Windows), never on your host: snapshot before detonation, revert after.\n\nDefensive reverse-engineering for malware triage. The benchmark and test inputs are **benign**\nprograms built from author-written sources (`bench_src/`, every planted string listed in\n`bench_src/manifest.json`) — no payloads, no live indicators. The tooling reads and decodes binaries\nand produces no offensive capability. Real-malware validation (`gallery/`) is **static + emulation\nonly**: samples are **never executed** and **never committed** — only their hashes and the results.\nRun dynamic analysis solely inside an isolated VM, against samples you are authorised to analyse.\n\n**Open-core.** The core — everything outside `premium/` — is under the Apache License 2.0 (see\n[`LICENSE`](https://github.com/VAHPEM/rust-deobf/blob/master/LICENSE)): a standard, OSI-approved permissive license, free for any use including\ncommercial. Modules under `premium/` (VM devirtualization, control-flow de-flattening, MBA\nsimplification) are dual-licensed **AGPL-3.0-or-later OR Commercial**. See\n[`LICENSING.md`](https://github.com/VAHPEM/rust-deobf/blob/master/LICENSING.md) for the model and [`CLA.md`](https://github.com/VAHPEM/rust-deobf/blob/master/CLA.md) for contributing.\n\nThe defensive-use guidance in this README (run only in an isolated VM, benign test inputs, samples referenced by hash) is project guidance for using the tool responsibly, not a license term.\n\n**If rust-deobf recovered something your other tools missed, a ⭐ helps others find it.**\n\nIssues and PRs welcome — especially new obfuscation crates and Rust samples (by hash) that beat it.", "url": "https://wpnews.pro/news/rust-binary-deobfuscation-with-rust-deobf", "canonical_source": "https://github.com/VAHPEM/rust-deobf", "published_at": "2026-10-26 10:47:32+00:00", "updated_at": "2026-10-26 11:14:31.939427+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "artificial-intelligence"], "entities": ["rust-deobf", "FLOSS", "Unicorn", "obfstr", "litcrypt", "cryptify", "muddy", "EDDIESTEALER"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/rust-binary-deobfuscation-with-rust-deobf", "markdown": "https://wpnews.pro/news/rust-binary-deobfuscation-with-rust-deobf.md", "text": "https://wpnews.pro/news/rust-binary-deobfuscation-with-rust-deobf.txt", "jsonld": "https://wpnews.pro/news/rust-binary-deobfuscation-with-rust-deobf.jsonld"}}