strings sees nothing. FLOSS sees nothing. rust-deobf sees the C2.
rust-deobf recovers what obfuscated Rust binaries hide — C2 addresses, file paths, mutexes, registry keys, config — without ever running the sample.
Demo · Use it from an AI agent · Install / quick start · Results · Real malware · Limitations
⚡ 30-second demo
A stripped Rust program built the way malware ships (opt-level="z", fat LTO, panic=abort),
with its eight indicators encrypted by obfstr — the strings
sit in a struct, match arms, comparisons, and a branch that never runs. None is ever printed.
Rendered from the captured output of a real run (Linux x86-64, rustc 1.97): the text is verbatim, the colours and typing animation are added, the ~8 s emulation wait is sped up, and the output is trimmed to the decoded strings. The plain-text output below is the same run.
Plain-text output (copyable)
What the file shows you:
$ B=build/scatter_obfstr__elf__size-lto__strip
$ strings $B | grep -ciE 'northwind|10\.66|NW-5c1e9a|skip-update'
0
What rust-deobf recovers (static + Unicorn emulation; the binary is never executed):
$ python3 scripts/recover.py $B
binary: scatter_obfstr__elf__size-lto__strip format=ELF arch=x86_64
static x86-64 (recover_callsite): 0 strings decoders=9 seconds=0.2
emulation (recover_emulate): 8 strings functions=416/416 timed_out=False workers=1 seconds=6.8
== decoded strings ==
10.66.12.7:8443 [ip]
function 0xf20c (emulation)
C:\ProgramData\NorthwindSvc\state.db [path]
function 0xf20c (emulation)
Mozilla/5.0 (Windows NT 10.0; Win64; x64) NorthwindAgent/2.1
function 0xf20c (emulation)
--skip-update-check
function 0xf443 (emulation)
Software\Northwind\Agent\Settings [registry]
function 0xf51b (emulation)
Global\NW-5c1e9a [mutex]
function 0xf5a8 (emulation)
https://telemetry.northwind.example/api/v3/beacon [url]
function 0xfd58 (emulation)
nw-agent::stage=collect
function 0xfd58 (emulation)
8/8 in under 10 seconds, each string tagged and traced to the function that builds it.
Reproduce it with ONLY=scatter_obfstr PROFILES=size-lto FORMATS=elf bash scripts/build_all.sh
(output trimmed to the decoded strings). (The input is a benign benchmark
program; every planted string is listed in bench_src/manifest.json.)
🔥 Why this exists
Rust malware is rising, and its deobfuscation tooling is effectively absent. FLOSS — the standard open-source string deobfuscator — recovers nothing from the common Rust obfuscation crates:
| obfuscator | strings |
FLOSS | rust-deobf |
|---|---|---|---|
| litcrypt / litcrypt2 | 0/5 | 0/5 | 5/5 |
| obfstr (inlined XOR+ADD) | 0/5 | 0/5 | 5/5 |
| cryptify (default key) | 0/5 | 0/5 | 5/5 |
| muddy (ChaCha20-Poly1305 AEAD) | 0/5 | 0/5 | 5/5 |
| tight stack strings | 0/5 | 0/5 | 5/5 |
| hand-rolled XOR / ADD / SUB | 0/5 | 0/5 | 5/5 |
Canary benchmark, release builds, ELF and PE x86-64 and AArch64, stripped and not. Full table, including the one case that needs dynamic recovery (cryptify with a custom key), under Results.
On real samples: EDDIESTEALER (PE) — 32 obfuscated strings including its hidden C2 domain and URL path, where static tools and FLOSS recover 0. AmnesiaStealer (macOS Mach-O) — C2 exfil URL, crypto-clipper config, staging paths. More below.
✨ What's inside
-
String deobfuscation — static call-site recovery (seconds) + a Unicorn emulation engine with a real loader, a C runtime, path exploration and global-constructor replay. Recovers strings built only in one
matcharm or on a branch that never runs. -
One-screen SOC triage —
soc_triage.py: format/arch, Rust toolchain, crate capability fingerprint (networking / crypto / recon / persistence / staging), indicators grouped by type, and a calibrated LOW / MEDIUM / HIGH signal with its evidence.--jsonfor a SIEM,--fail-on HIGHfor scripted gating.--fast(metadata + literal indicators, no decoding) scales with binary size: under a second on a small sample, ~20 s on a 20 MB binary. -
Multi-arch, multi-format — x86-64, ARM64 and 32-bit x86 across ELF, PE and Mach-O.
-
Fits your workflow — FLOSS-compatible export (
--floss-json), headless Ghidra annotation (plate comments, bookmarks, renames), library-function identification by byte signature (lib_<name>in stripped binaries), batch mode over a directory (--jsonl). -
Static unpacking —
unpack.pyemulates the unpacking stub (UPX and UPX-like; PE and ELF, x86-64 / x86 / AArch64) to the original entry point, carves stage-2, and recovers the import table the stub rebuilds — without running the sample. Verified on UPX-packed PE32/PE32+ and AArch64 ELF (OEP = the entry of the binary before packing) and cross-checked against a live Frida dump (99% identical). -
Beyond strings — config/C2 extraction, hash-obfuscated API-import resolution, control-flow-flattening detection and reconstruction (x86-64 and AArch64, validated on goldberg, OLLVM
-flaand a real LLVM-18 flattening pass), MBA / opaque-predicate simplification with Z3, Rust trait-object vtable recovery (recover_types.py), and optimization-reversal (jump-table un-switching, unrolled-loop detection —premium/opt_reverse.py). -
Rust idioms in stripped binaries —
recover_idioms.pybrings back the format string of everyprintln!/format!/panic!site (bothfmt::Argumentsencodings: newer rustc's compact template and the older pieces array), marks bounds / overflow / unwrap panic paths as cold safety checks with theirfile:line:col, flags drop / dealloc calls and enum-like (Option/Result?) returns, and annotates all of it in Ghidra — no symbols needed (measured on rustc 1.97 and 1.99.1 builds for x86-64 / AArch64 / i386 ELF and x86-64 / i386 PE). -
Code virtualization —
detect_vm.pyflags VMProtect/Themida/Tigress-style bytecode VMs; the premium devirtualizer lifts them back. From a VM with randomized opcodes and encrypted operands, it reconstructs:return (((a0 > 1000) & (((((a0 * 31) + a1) ^ (a0 >> 3)) % 97) == 0)) ? (a1 + a0) : 0xffffffff);
— the function's exact source.
-
Safe by design — static + emulation by default: emulation runs functions of the binary under Unicorn, never the binary itself. Dynamic (Frida) recovery is opt-in and for isolated VMs only.
-
Async state machines —
recover_async.pyfinds everyasync fn/ async block's state machine in a stripped binary (x86-64 / AArch64, ELF / PE): where the state lives, state → resume target, each.await's inner poll (direct,dyn Futurevtable, or inlined) as a nesting graph, the coroutine's source line, and the async runtime (tokio, ...) — anchored on rustc's "async fnresumed after completion" panics. -
Rust metadata that survives
strip— rustc version, third-party crates with exact versions, and panic sites, rebuilt from the source paths the compiler leaves in every Rust binary (nocargo-auditableneeded).
🤖 Use it from an AI agent
A decompiler's string view, strings, FLOSS — anything that reads strings from the file — can only
show bytes that are in the file. An obfuscation crate makes sure the plaintext never is, so an AI
agent reading a decompiler sees the same nothing you do. rust-deobf gives it the facts instead.
It 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:
python3 scripts/soc_triage.py sample.bin --json # one-screen facts: toolchain, crates, IOCs, signal
python3 scripts/recover.py sample.bin --json # every decoded string + the function that builds it
scripts/ghidra_run.sh sample.bin # write them into the Ghidra decompiler as plate comments
python3 scripts/mcp_server.py # or serve recover/triage/metadata/detect as MCP tools
Prefer typed tools over CLI parsing? scripts/mcp_server.py exposes detect / metadata /
triage / recover as MCP tools over stdio (no third-party
package needed), so an agent calls them with a JSON schema and gets structured JSON back. And
scripts/export_rules.py turns the recovered facts into capa rules and YARA rules (the
Mandiant detection formats), for the obfuscation crates and from the recovered decoder idioms /
hash-obfuscated imports — --static-only emits the crate family ruleset for any corpus.
Annotate the decompiler the agent is reading (GhidraMCP / ida-pro-mcp)
Run rust-deobf's MCP server next to a decompiler MCP server and the agent can write the recovered
facts straight into the view it reads: decoded strings on the function that builds them and on each
decoder call site, lib_* names for identified std/crate functions, vtables and their methods,
panic Location records, toolchain/crates/triage at the entry point. Claude Code, e.g.:
claude mcp add rust-deobf -- python3 /path/to/rust-deobf/scripts/mcp_server.py
claude mcp add ghidra -- uvx --from git+https://github.com/LaurieWired/GhidraMCP ghidra-mcp \
--ghidra-server http://127.0.0.1:8080/
# or IDA: claude plugin install ida-pro-mcp@mrexodia (plugin at http://127.0.0.1:13337/mcp)
{"mcpServers": {
"rust-deobf": {"command": "python3", "args": ["/path/to/rust-deobf/scripts/mcp_server.py"]},
"ghidra": {"command": "uvx", "args": ["--from", "git+https://github.com/LaurieWired/GhidraMCP",
"ghidra-mcp", "--ghidra-server", "http://127.0.0.1:8080/"]}
}}
Two ways to apply them:
push_annotations(rust-deobf tool):{path, backend: "ghidra-mcp" | "ida-mcp", url}talks to the plugin's HTTP API directly. It is a dry run unlessdry_run: false; loopback URLs only.annotations(rust-deobf tool) returns the neutral list; the agent applies each entry with the decompiler's own tools (set_decompiler_comment/rename_function_by_addressin GhidraMCP,append_comments/renamein ida-pro-mcp). Passimage_base(GhidraMCPget_program_info, ida-pro-mcpserver_health) to getdecompiler_addressfilled in.
The same from a shell: python3 scripts/decompiler_bridge.py sample.bin --backend ghidra-mcp [--dry-run] (or --backend ida-mcp, or the default --backend json). What it guarantees:
- Addresses are mapped and checked. Ours are RVAs (PE) or file VAs (ELF/Mach-O); Ghidra usually rebases a PIE to 0x100000, IDA loads it at 0. The bridge reads the decompiler's image base, checks that the entry point lands on a function start, and refuses to write if it doesn't.
- It only writes into the right program. If the decompiler reports a sha256 that differs from
the file's, the push is refused unless you pass
--force. - Your comments are kept. The bridge reads the existing comment, rewrites only its own
[rust-deobf]lines (an outdated fact is replaced, not left behind) and keeps your text.- Ghidra: GhidraMCP can read end-of-line comments but not PRE comments, so EOL is the default.
The listing shows them; the decompiler shows them once its EOL-comment display option is on.
--ghidra-comment prewrites PRE comments (visible in the decompiler) but replaces whatever PRE comment is already at that address. Addresses outside any function (vtables, panic Locations) can't be read back, so they are skipped in EOL mode. - IDA: reading a comment needs ida-pro-mcp's
py_evaltool (enabled in the IDA plugin by default;idalib-mcp --unsafe). Without it the bridge falls back to appending lines and says that stale lines may remain.
- Ghidra: GhidraMCP can read end-of-line comments but not PRE comments, so EOL is the default.
The listing shows them; the decompiler shows them once its EOL-comment display option is on.
- Bookmarks. Ghidra has no bookmark API, so a bookmark becomes a
[rust-deobf] bookmark:line in the same comment. IDA bookmarks go only into a free slot or one the bridge set itself, never over yours. - Renames touch only
FUN_/sub_defaults or names the bridge gave earlier. - Never runs the sample.
Before trusting a new setup, check it against the real decompiler with
python3 scripts/decompiler_selfcheck.py sample.bin --backend ghidra-mcp [--write-test]. It
checks the address mapping on 20 functions spread over the binary and the program identity. With
--write-test it also writes one comment, reads it back and restores it. It prints PASS/FAIL per
check.
Ghidra without the GUI: GhidraMCP 2.0 can serve a program headless through PyGhidra. It needs Ghidra
12.1.4 and the GhidraMCP extension (installed in Ghidra, or --extension-jar). It picks a free port
and logs it:
export GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.4_PUBLIC # Kali's packaged Ghidra may be older
uvx --python 3.13 --from "ghidra-mcp[headless] @ git+https://github.com/LaurieWired/GhidraMCP@2.0" \
ghidra-mcp --headless --binary sample.bin --project ~/rd-ghidra --transport streamable-http \
--mcp-port 8081 2> ghidra-mcp.log & # add --extension-jar <GhidraMCP.jar> if not installed
until URL=$(sed -n 's|^INFO:ghidra_mcp.headless:Serving .* on \(http://127\.0\.0\.1:[0-9]*/\)$|\1|p' \
ghidra-mcp.log | tail -1) && [ -n "$URL" ]; do sleep 5; done # import + analysis
python3 scripts/decompiler_selfcheck.py sample.bin --backend ghidra-mcp --url "$URL" --write-test
python3 scripts/decompiler_bridge.py sample.bin --backend ghidra-mcp --url "$URL"
kill -INT %1 # --project saves on exit; open ~/rd-ghidra in the Ghidra GUI to look
Strings quoted in the annotations come from the analyzed binary: the agent should treat them as data, never as instructions.
A prompt that works well:
"Run
python3 scripts/soc_triage.py sample.bin --jsonandpython3 scripts/recover.py sample.bin --jsonfrom rust-deobf, then decompile the functions those strings came from and explain what they do. Treat rust-deobf's decoded strings as ground truth; label anything you infer as a hypothesis."
Want the LLM step built in? scripts/llm_triage.py hands only the recovered facts (never the
raw binary) to a local model (Ollama / vLLM via RUST_DEOBF_LLM_BASE — malware data stays on your
host), the Anthropic or OpenAI API, or prints the grounded prompt for offline use.
🚀 Quick start
Install
pipx install rust-deobf # isolated CLI install (recommended), or:
python3 -m pip install rust-deobf # into the current environment / venv
python3 -m pip install "rust-deobf[premium]" # the premium deobfuscation modules too
rust-deobf doctor # checks Python, dependencies, platform, optional tools
- From a clone instead: see From source below.
Python 3.10+ on Linux, macOS and Windows (x86-64 and ARM64). One command, rust-deobf, runs
every tool: rust-deobf list shows them all, rust-deobf <tool> --help its options, and each
subcommand behaves exactly like the matching scripts/<tool>.py.
rust-deobf recover path/to/binary # static + emulation (default), never runs it
rust-deobf triage path/to/binary # one-screen triage card (soc_triage)
rust-deobf recover path/to/binary --json # machine-readable, for agents and pipelines
rust-deobf mcp # MCP server over stdio
Install reference
| pip | pipx | |
|---|---|---|
| install | pip install rust-deobf |
pipx install rust-deobf |
| upgrade | pip install -U rust-deobf |
pipx upgrade rust-deobf |
| a given version | pip install rust-deobf==0.1.0 |
pipx install rust-deobf==0.1.0 --force |
| premium modules | pip install -U "rust-deobf[premium]" |
pipx install --force "rust-deobf[premium]" |
| dynamic (Frida) | pip install "rust-deobf[dynamic]" |
pipx inject rust-deobf frida frida-tools |
| check | rust-deobf --version / rust-deobf doctor |
same |
- Premium modules (
rust-deobf opaque|deflatten|cff|expr|devirt|lift-vm|opt-reverse; AGPL-3.0-or-later OR commercial, see License) are a separate distribution,rust-deobf-premium, always the same version as the core. Upgrade them together:pip install -U "rust-deobf[premium]"(a plainpip install -U rust-deobfleaves an older premium behind, andrust-deobfthen tells you to upgrade both). The core works without them. - Extras:
[dynamic]Frida (runs the sample: isolated VM only),[rules]yara-python + flare-capa to validate exported rules,[dev]pytest + build. - Windows on ARM: use the x64 Python build (it runs under emulation). Native ARM64 Python
has no
unicorn/capstone<6wheels, sopip installfails there while building them; install into an x64 Python instead (rust-deobf doctorchecks which one you are running). - Upgrades and data: the signature/data files (
data/*.json) ship inside the package and are replaced on upgrade. Per-user caches live under~/.cache/rust-deobf(Linux; macOS~/Library/Caches/rust-deobf, Windows%LOCALAPPDATA%\rust-deobf—rust-deobf doctorprints the path). Caches are disposable: each carries a format version, a cache from another format is ignored and rebuilt rather than misread, and deleting the directory is always safe. Release notes: CHANGELOG.md.
From source (clone and run, or develop)
git clone https://github.com/VAHPEM/rust-deobf.git
cd rust-deobf
python3 -m pip install -r requirements.txt # lief, capstone, unicorn, z3-solver, pyelftools, pyyaml
python3 scripts/recover.py path/to/binary # every tool runs straight from the clone
python3 scripts/soc_triage.py path/to/binary
python3 scripts/cli.py list # the `rust-deobf` command, without installing
python3 -m pip install -e . -e premium # or: an editable install of both distributions
The examples below use the clone form (python3 scripts/<tool>.py); with an install,
rust-deobf <subcommand> is the same (recover_libfuncs.py -> libfuncs, recover_types.py ->
types, soc_triage.py -> triage, ghidra_run.sh -> ghidra; rust-deobf list has the map).
Benchmark/evaluation tools that need the repository's bench_src/ and build/ (e.g.
evaluate_all.py, fuzz_recover.py, redblue_loop.py) are clone-only.
All options
python3 scripts/recover.py path/to/binary --floss-json out.json # FLOSS results document
python3 scripts/recover.py path/to/binary --emulate never # static passes only, seconds
python3 scripts/recover.py path/to/binary --time-budget 600 # big binaries: emulate longer
python3 scripts/recover.py path/to/binary --max-ram 2G # cap memory (fewer processes)
python3 scripts/recover.py path/to/binary --libfuncs # also name Rust std/crate functions
python3 scripts/recover.py samples/ --jsonl triage.jsonl # a whole directory
# name library functions in a stripped binary (needs a signature DB for its toolchain):
python3 scripts/recover_libfuncs.py --gen build/*release*nostrip # build the DB once per toolchain
python3 scripts/recover_libfuncs.py path/to/binary # identify; --json for records
python3 scripts/recover_libfuncs.py path/to/binary --propagate # + call-graph thunks & drop-glue
python3 scripts/gen_sigdb.py path/to/binary [--allow-download] # DB for THIS binary's rustc+target (std)
python3 scripts/gen_sigdb.py path/to/binary --build-crates # + its crates: runs their build scripts,
# so only inside the analysis VM
python3 scripts/recover_libfuncs.py path/to/binary --db auto # use it (falls back to the bundled DB)
# recover Rust trait-object vtables (dyn Trait dispatch) as Ghidra data types:
python3 scripts/recover_types.py path/to/binary # --json for records
# push strings + library names + vtables into Ghidra (headless, PyGhidra):
scripts/ghidra_run.sh path/to/binary
# AI-assisted read over the recovered facts only (local model, API, or --offline):
python3 scripts/llm_triage.py path/to/binary
# runtime-only decoders, inside an isolated analysis VM ONLY (pip install frida frida-tools)
# it EXECUTES the sample, so it refuses to start without the explicit opt-in flag
python3 scripts/recover_dynamic.py path/to/binary --i-understand-this-executes [--argv arg1 arg2 ...]
Exit codes (recover.py, soc_triage.py, llm_triage.py): 0 the analysis ran, 2 usage error,
3 unsupported or unparseable input (not an ELF/PE/Mach-O LIEF can read — e.g. a text file),
4 analysis error (an engine raised; the other engines still ran and their output is printed; in
directory 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
binary single-file mode supports (32-bit x86 included), and counts the files it skipped.
Emulation runs by default and is bounded by --time-budget (120 s): functions are tried in
decoder-score order, and the output says how many were emulated (functions=412/1671) and
whether the budget ran out. Memory is the other knob: a large binary is split across worker
processes (RUST_DEOBF_WORKERS, default min(4, CPUs)), each holding its own loaded image and
Unicorn instance — roughly 250 MB plus 45 MB per MB of .text, so ≈0.5 GB for gdb and ≈1.1 GB
for cargo. --max-ram 2G (or RUST_DEOBF_MAX_RAM_MB) caps the process count to fit that
budget, trading speed for memory; a budget below one process's footprint runs serially, slower,
and the output reports both the process count and the reduction.
📊 Results
Every number here is a test or a script in this repo, and the benchmark publishes the cases where the tool does not work too.
| 360 / 360 | exact strings on held-out shapes × malware build profiles (54 stripped binaries, ELF/PE/AArch64), 0 extra |
| 1360 / 1360 | across build profiles the benchmark never tuned for, 0 junk — see Build profiles nobody tuned for below for what that number means |
| 120 / 120 vs 0 | on Windows, stripped PE files: rust-deobf vs FLOSS 3.2.0, same machine, same files |
| 29 FP / 21 binaries | false positives on real unobfuscated C/C++, Go and Rust programs (bash, git, gdb, docker, cargo…) |
| 9 families | real Rust malware validated end-to-end (static + emulation, never executed) |
Held-out shapes × malware build profiles (scatter suite)
python scripts/eval_scatter.py — the default recover.py pipeline, exact matches out of 8;
(+n) = extra strings reported (false positives). plaintext is the same program with plain
literals: everything it reports is a false positive.
| 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 |
|---|---|---|---|---|---|---|---|---|---|
xor_custom |
8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 |
litcrypt |
8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 |
litcrypt2 |
8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 |
obfstr |
8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 |
cryptify |
8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 | 8/8 |
plaintext |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
360/360 exact strings and 0 extra across all 54 binaries (stripped builds); the code an
external review tested got 79/360. Every cell is a test in tests/test_scatter.py.
Canary benchmark (one program per crate, five printed strings)
| obfuscator | cipher | strings |
FLOSS | static | emulation | dynamic |
|---|---|---|---|---|---|---|
| xor_custom | hand-rolled XOR | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| add_custom | hand-rolled ADD | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| sub_custom | hand-rolled SUB | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| litcrypt | XOR, runtime key | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| litcrypt2 | XOR, stride-permuted key | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| obfstr | XOR+ADD, inlined | 0/5 | 0/5 | — | 5/5 | 5/5 |
| tight strings | stack buffer XORed in place | 0/5 | 0/5 | — | 5/5 | 5/5 |
| cryptify (default key) | XOR, crate-default key | 0/5 | 0/5 | 5/5 | 5/5 | 5/5 |
| cryptify (custom key) | key only in the build/run environment | 0/5 | 0/5 | —¹ | —¹ | 5/5¹ |
| muddy | ChaCha20-Poly1305 (AEAD) | 0/5 | 0/5 | — | 5/5 | 5/5 |
| obfuse² (default features) | 2–4 random XOR/ADD/SUB/ROT layers per string, runtime-derived keys, flattened state machine | 0/5 | n/m | — | 5/5 | n/m |
| obfuse_aes² | AES-256-GCM (AES-NI / soft, picked by CPUID) | 0/5 | n/m | — | 5/5 | n/m |
| obfuse_xor² | XOR through MBA expressions | 0/5 | n/m | — | 5/5 | n/m |
| obfany² | XorShift keystream XOR, read_volatile; obfstr!/obfwide!/obfnum!/obfstmt! |
0/5 | n/m | — | 5/5 | n/m |
Release builds, ELF and PE x86-64 and AArch64, stripped and not (32-bit PE: the emulation column
holds, the static engines are x86-64/AArch64 only); size-lto/speed-lto builds
of every crate are tested too. Static = reads the decoder's key and the call sites without
running anything; emulation = Unicorn, never runs the sample natively; dynamic = Frida in an
isolated VM.
¹ cryptify reads CRYPTIFY_KEY again at run time: with a custom key the sample only
decrypts when run with that variable set, and the key is not in the binary. recover_cryptify
detects the crate and says so.
² The 2026 crates (obfuse 1.0.1, obfany 0.1.5): 5/5 exact (no extra string) measured on ELF
x86-64 and ELF AArch64 (musl) release (stripped and not), size-lto and speed-lto (stripped), and on
a stripped static i686 musl release build, PE x86-64 and 32-bit PE (i686-pc-windows-gnu) release
(stripped and not), and i686 glibc (tests/test_32bit.py). obfuse_aes on 32-bit PE was 3/5 until
VALIDATION §73 (a software-AES callee cut off by the emulator's budget); 5/5 since, measured on
rustc 1.99.1 builds. size-lto/speed-lto were measured on ELF only; FLOSS /
dynamic recovery (n/m) not run on them. obfuse's default features name
aes-256-gcm, but with polymorphic on (the default) the macro never calls the AEAD -- the default
binary holds no AES instruction; obfuse_aes is the crate's real AES-256-GCM path (AES-NI on
x86-64; on AArch64 the aes crate's software AES, ~26k emulated instructions per string). obfuse
draws fresh random keys on every build: four rebuilds of obfuse and obfuse_aes recovered 5/5
each (the stray cipher-state strings two of them also showed are filtered since).
Build profiles nobody tuned for
The benchmark's own programs rebuilt with release settings the benchmark never uses, stripped
(CARGO_PROFILE_RELEASE_* overrides). Exact strings; +n = junk strings reported.
| set | profiles | formats | first measured | now |
|---|---|---|---|---|
| 1 | opt-level="s" + thin LTO + 16 CGU + panic=unwind; opt-level=1 |
ELF, PE | 338/340 +7 | 340/340 +0 |
| 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 |
| 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 |
Each 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.
Precision on real, unobfuscated binaries (21-binary corpus)
scripts/measure_fp.py --pipeline --corpus (static + emulation, 120 s budget): every decoded
string reported is a false positive. The corpus mixes C/C++, Go and Rust binaries, because the six
binaries the filters were developed against (bash, tar, xz, git, curl, ripgrep: 0) overstated
precision — Xvfb and a Go binary had 68 and 17 before the second review round.
| binary | language | .text |
functions emulated (120 s, 2 CPUs) | false positives |
|---|---|---|---|---|
| bash | C/C++ | 945 KB | 2343/2343 | 0 |
| tar | C/C++ | 292 KB | 607/607 | 0 |
| xz | C/C++ | 41 KB | 76/76 | 0 |
| git | C/C++ | 3,077 KB | 4473/4473 | 0 |
| curl | C/C++ | 107 KB | 121/121 | 0 |
| gdb | C/C++ | 5,693 KB | 2022/15760 (budget) | 0 |
| cmake | C/C++ | 9,318 KB | 4305/18830 (budget) | 1 |
| Xvfb | C/C++ | 1,435 KB | 5038/5038 | 1 |
| openssl | C/C++ | 415 KB | 535/535 | 0 |
| perl | C/C++ | 1,858 KB | 2240/2240 | 3 |
| python3.13 | C/C++ | 3,039 KB | 10767/11510 (budget) | 0 |
| vim | C/C++ | 3,110 KB | 5302/5302 | 7 |
| age | Go | 1,631 KB | 2353/2353 | 2 |
| gh | Go | 16,710 KB | 1718/13720 (budget) | 0 |
| docker | Go | 13,379 KB | 1498/35916 (budget) | 1 |
| go (toolchain) | Go | 6,351 KB | 2115/13835 (budget) | 2 |
| ripgrep (Debian) | Rust | 3,075 KB | 4947/8530 (budget) | 3 |
| rustfmt | Rust | 2,849 KB | 4545/4545 | 4 |
| cargo-fmt | Rust | 874 KB | 1280/1280 | 0 |
| cargo | Rust | 19,594 KB | 3528/43240 (budget) | 5 |
| rustdoc | Rust | 5,894 KB | 8531/8531 | 0 |
29 false positives on 21 binaries (C/C++ 12, Go 5, Rust 12); 11 have none. Not zero: what
is left is text the program assembles at run time from its own pieces in ways the filters do not
model (File/Glob.pm, lib/rustlib/src/rust/, a regex help line), symbol names with letters
inserted as well as changed (PyGIL`EmSure), and a few reshuffles. Each is reported as decoded,
so a result list on a non-obfuscated binary is not empty — but it is short, and the previous
code reported 68 on Xvfb alone. Coverage grows with CPUs and --time-budget, and so can noise.
The CI job on GitHub's runner (other versions of these programs) measured 32 on 20 binaries.
Second machine, second toolchain
Everything was re-run outside the development container, on the two ARM64 VMs of dual-vm-ai-agent:
- Kali Linux, rustc 1.99.1 (current stable): both suites rebuilt for PE, AArch64 musl and a
new 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). - Windows:
recover.pyon the 18 stripped scatter PE files recovers 120/120 strings with 0 extra; FLOSS 3.2.0 on the same machine and files decodes 0. - Development container, rustc 1.97: 289 tests pass.
The glibc build needed two emulator fixes — libgcc's outlined atomics (__aarch64_cas4_acq &
co.) and a write import that must report success — see VALIDATION.md §10b.
Third-party obfuscation crates: goldberg
bench_src/ is hand-rolled ciphers; obf_src/ is author-written programs obfuscated with the
crates a real analyst meets. goldberg applies code-flow
obfuscation plus string and integer encryption; recover.py recovers its encrypted strings
6/6 across ELF, PE and AArch64, release and size-lto (tests/test_obf.py, a CI job). The
strings are IOC-shaped and chosen not to collide with crate or metadata tokens, so the number
measures recovery rather than a coincidence of the literal text. More crates are planned
(rustfuscator, obfuse), goldberg first.
Built in the open: two rounds of external review
An external review reproduced the published numbers exactly (107/107 tests, all 144 binaries rebuilt with rustc 1.97) and found no cheating — but showed the tool was overfitted to the benchmark's shape. The fix was not a list of new patterns: the emulator was rebuilt into a general engine, and the benchmark gained the shapes and build profiles that exposed the problem, so it cannot silently overfit again.
What the reviews found, and what changed
Round 1 — generalisation
| reviewer's held-out finding | before | now |
|---|---|---|
litcrypt2, malware-style build (fat LTO, opt-level="z", panic=abort), ELF and PE |
0/8 | 8/8 |
litcrypt2, benchmark profile (opt-level=3) |
5/8 (decoded 8, text filter dropped 3) | 8/8 |
mutex name Global\… rejected by the "looks like text" filter |
dropped | kept, tagged [mutex] |
IOC with a prefix (heldout::10.13.37.42:8443) rejected |
dropped | kept, tagged [ip] |
obfstr, strings not printed (.to_string(), comparisons) |
0–2 full, rest truncated | 8/8 |
| emulation capped at 400 functions (bash: 400/1671) | capped | no cap; ordered by decoder score, bounded by time |
- scatter suite (
bench_src/scatter_*): 8 indicator-shaped strings — IP:port, Windows path, mutex, URL, user agent, argv flag, hive-less registry key, prefixed identifier — in a struct,matcharms, comparisons that are never printed, and a branch that never runs. - build profiles: every program is also built
size-lto(opt-level="z", fat LTO,codegen-units=1,panic=abort) andspeed-lto(opt-level=3, same LTO settings).
tests/test_scatter.py holds recall and precision on all of it in CI (one job per format).
Round 2 — what the first round still overfitted (VALIDATION.md §11)
| finding | before | now |
|---|---|---|
| build profiles nobody tuned for (3 sets, 256 binaries) | 98–99 % exact, +18 junk | 1360/1360, 0 junk |
| real binaries outside the development set: Xvfb (C) / age (Go) | 68 / 17 false positives | 1 / 2; 29 on a 21-binary corpus |
| 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 |
| gdb's 15,760 functions in the 120 s budget | 136 emulated | ≈2,000 (2 CPUs) |
A CI job now measures false positives on a C/C++, Go and Rust corpus found on the runner.
🦠 Real malware
Validated on nine real Rust-malware families by hash — static + emulation only, samples
never executed and never committed (only hashes and results, in gallery/results.jsonl):
- 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.
- AmnesiaStealer (macOS Mach-O) — the C2 exfil URL, crypto-clipper config, Safari-cookie / TCC-bypass staging paths and AppleScript for Telegram/Notes theft.
- SPICA (ColdRiver) — its registry persistence key.
- RustyAttr (Lazarus) — unpacked from a triple-packed zip→gz→cpio→app chain (and honestly no obfuscated strings: its evasion is extended-attribute script hiding, not string encryption).
- KrustyLoader — an honest non-hit: its config is encrypted at run time (cfb-mode), so static string deobfuscation correctly finds nothing; the metadata tier still recovers rustc 1.70.0, 18 crates and a capability profile.
gallery/manifest.json (built from
rust-malware-gallery) maps 126 sample
hashes across 67 Rust malware families to family, aliases and writeups, for evaluation and
hash → family attribution. Details: VALIDATION.md §12, §18.
🔬 How it works
recover.py runs everything that applies and merges the results:
flowchart LR
B["Rust binary<br/>ELF · PE · Mach-O<br/>x86-64 · ARM64 · x86"] --> S["Static call-site recovery<br/>key + ciphertext by dataflow"]
B --> E["Unicorn emulation<br/>runs functions, never the binary"]
B --> L["Stack strings +<br/>Rust literals"]
B --> M["Rust metadata<br/>rustc · crates · panic sites"]
S --> R["Merge, filter,<br/>tag indicators"]
E --> R
L --> R
M --> T["SOC triage card<br/>LOW / MEDIUM / HIGH"]
R --> T
R --> O1["--json / --jsonl"]
R --> O2["FLOSS results JSON"]
R --> O3["Ghidra plate comments"]
T --> O4["llm_triage.py<br/>(facts only, local or API)"]
| engine | what it does | speed |
|---|---|---|
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 |
Emulation (recover_emulate.py, Unicorn) |
runs functions of the binary (never the binary) and collects the plaintext the code builds | bounded by --time-budget |
| Stack strings / Rust literals | plain strings cut at their real (pointer, length) boundaries instead of strings' glued runs |
seconds |
Dynamic (recover_dynamic.py, Frida) |
runs the sample and diffs its memory — cipher-agnostic, isolated VM only | runtime |
Inside the emulation engine
-
Static call-site recovery is fast with exact attribution (decoder, call site, ciphertext), but only as good as its idiom recognisers: it recognises the benchmark's release idioms, not every build profile — that is what emulation is for.
-
Emulation is the general engine, x86-64, AArch64 and 32-bit x86, across ELF, PE and Mach-O. What makes it work on real Rust code:
- real calls: callees run up to 8 levels deep, so a call site runs its out-of-line decoder
(
lc!→decrypt_bytes→Iterator::next); each callee has an instruction budget and a callee that overruns it (a spinning lock, a loop over a fake length) or faults is abandoned and returns 0, with the caller's registers restored; - a loader: relocations applied (ELF
RELATIVE/GLOB_DAT/JUMP_SLOT, PE base relocations), every import pointed at a named trampoline,.bsszeroed, thread-local storage (fs/gs/TPIDR_EL0) set up, AArch64 outlined atomics switched to their LSE path, and the image's global constructors (ELF.init_array/.ctors, Mach-O__mod_init_func) replayed before the sweep so state a loader sets beforemain— e.g. an anti-emulation marker a constructor writes, which an isolated-function analyzer would miss and whose absence poisons a string key — is in place for every run; - a C runtime: allocators (
malloc,HeapAlloc,__rust_alloc, …) hand out separated zeroed blocks;memcpy/memmove/memset/memcmp/bcmp/strlen,brk/mmap, environment lookups andGetLastErrorbehave; in stripped static (musl) builds the runtime functions are found by FLIRT-style byte signatures (gen_runtime_sigs.py); - path exploration: decoder-like functions are re-run with forward branches forced the
other way, so strings built only in one
matcharm or on a branch that never runs are reached; - scheduling: a first pass runs every function once with a small budget (most decoders finish in a few thousand instructions), the second gives full budgets and explores; a run or callee still writing text when its budget ends gets more; allocator wrappers and import stubs run at any call depth; large binaries are split across processes;
- capture: every store is tracked with its age; written bytes are read back before calls and on returns, extended through neighbouring written bytes and cut where writes of different ages meet (a short string written over a long one), and the (pointer, length) pairs the code passes on give the exact end of a Rust string.
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.
- real calls: callees run up to 8 levels deep, so a call site runs its out-of-line decoder
(
Every recovered string says where it came from, indicator shapes are tagged, and --floss-json
writes a FLOSS results document so the strings appear in FLOSS next to the static ones: view it with
floss -l out.json (checked against FLOSS 3.2.0).
Triage, AI-assisted triage, VM detection and devirtualization
scripts/soc_triage.py <binary> prints a one-screen SOC triage card — format/arch, Rust
toolchain + panic-site count, a crate capability fingerprint (networking / crypto / recon /
persistence / staging), indicators grouped by tag with benign Rust std/compiler paths filtered out,
a decoded-string sample, and a calibrated LOW/MEDIUM/HIGH triage signal with its evidence (an
analyst aid, not a verdict). The signal needs obfuscation evidence to rise above LOW: decoded
strings (one indicator-shaped, or three or more that are not just formatted from the binary's plain
literals — fewer is within the engines' false-positive floor on big benign binaries), an
indicator-shaped stack-built string, or virtualized functions in a binary that does not link an
interpreter crate (regex engines, wasm runtimes, script VMs look exactly like a VM). Indicator-shaped
plain literals (an update URL, an index URL) are listed separately as literal indicators and never
scored, and the ubiquitous HTTP/TLS/hashing/OS-binding crates (tokio, hyper, rustls, ring, base64,
windows-sys) are shown in the fingerprint but add nothing — so unobfuscated tools like rustup,
uv or rg come out LOW. --fast skips every decoding pass (static call-site recovery, stack
strings, emulation, the virtualization scan) and keeps metadata + literal indicators; literal
splitting scales with size (measured: 0.6 s on a 0.4 MB canary, 6 s on rg 5 MB, 19 s on rustup
20 MB, 100 s on uv 66 MB), and its signal is always LOW with that caveat.
--json emits a record for a SIEM/case system; --fail-on MEDIUM|HIGH exits 1 at that level.
Static + emulation only; never executes the sample.
scripts/llm_triage.py <binary> hands only the deterministically recovered facts (the triage
card above — never the raw binary) to an LLM and asks for a grounded verdict, likely capabilities
tied to the evidence, candidate ATT&CK techniques, what is uncertain, and next steps. The
deterministic facts stay ground truth; the LLM note is a clearly labelled hypothesis, and the model
is instructed not to invent indicators. Backends auto-detect: a local OpenAI-compatible server
(RUST_DEOBF_LLM_BASE=http://localhost:11434/v1 for Ollama/vLLM — so malware data never leaves the
host; when it is set it wins over any ANTHROPIC_API_KEY/OPENAI_API_KEY in the environment
unless RUST_DEOBF_LLM=anthropic|openai says otherwise), the Anthropic or OpenAI APIs, or, with no
key, an offline mode that prints the grounded prompt to paste into any LLM plus a deterministic
rule-based note. Recovered strings are attacker-controlled text, so they are sent inside
<untrusted_evidence> tags, truncated to 200 characters each and capped per list, and the system
prompt tells the model to treat them as data, never as instructions.
scripts/detect_vm.py <binary> flags functions hidden behind a bytecode VM (code
virtualization) — the VMProtect/Themida/Tigress technique, and RustCloak's virt. It keys on the
interpreter's machine-code fingerprint: a threaded dispatch (many indirect branches through one
register) plus dense bytecode byte-fetches, and folds into the triage card as a signal. Precise on
x86-64 (ls/grep/openssl/bash clean; it does flag CPython's eval loop — correctly, that
genuinely is an interpreter); AArch64 detection is the next round.
premium/lift_vm.py <binary> (premium — AGPL/commercial) devirtualizes a detected VM
function: it finds the embedded bytecode blob (anchored by its code cross-reference, since the blobs
are packed in .rodata), decodes the register-machine instructions — including the control-flow
opcodes, so branches and loops come back as labelled jmp/jnz with the SSA PHI copies visible —
and prints the function's logic as a compact VM-assembly listing instead of a dispatch loop. It
decodes the width-tagged format too (load.i8 rD, [rA], store.i8 [rA], rB, add.i32). When the
VM's opcode bytes are randomized per build, the lifter recovers the opcode map by parsing
the dispatch jump table and classifying each handler by its native instruction (an idiv with the
remainder stored → SREM, a rip-relative pool load → CONST, …). When the operand bytes are also
encrypted per build, it recovers the XOR key from the interpreter and raises the result to C
pseudocode, resolving the constant pool and folding temporaries into one expression.
Control-flow bodies are raised from if/goto toward structured do/while + if where the flow is
reducible (an -O2-unrolled loop nest is left as if/goto). premium/devirt_emulate.py
recovers the VM's instruction set by running the interpreter under Unicorn and observing opcode
semantics instead of pattern-matching its native code, with a correctness gate that re-executes the
recovered program. x86-64; recovery is demonstrated end-to-end on a function, with full per-binary
coverage ongoing.
Ghidra integration. scripts/ghidra_run.sh <binary> runs the recovery and annotates a Ghidra
project headless: every decoding function gets a plate comment listing its recovered strings and a
bookmark, functions with default FUN_/SUB_ names are renamed after their strongest indicator, and
identified library functions get lib_<name> renames. It is a PyGhidra script (Ghidra 11.3+ dropped
Jython); verified headless on Ghidra 12.1.3. IDA and Binary Ninja exporters are planned next.
🚧 Honest limitations
This is research code, not a finished product. The ones that matter most:
- Emulation is not execution. Arguments are fake, so code that only decodes for specific inputs (a key from the network, a path that depends on the environment) can be missed.
- Large binaries are covered partly within the default 120 s budget (≈2,000 of gdb's 15,760
functions on 2 CPUs). The output reports coverage; raise
--time-budgetor add CPUs. - Precision is good, not perfect: 29 false positives on a 21-binary C/C++, Go and Rust corpus.
- 32-bit PE is emulated since the 2026-10 round: stdcall imports pop their arguments, function
starts come from
.eh_frame, and the canary benchmark (release, stripped and not) recovers every string on i686-pc-windows-gnu builds (build_all.sh FORMATS=pe32), obfuse_aes included since VALIDATION §73 (measured on rustc 1.99.1 builds); the scatter suites / LTO profiles are not measured there yet. Library-function identification on 32-bit PE is measured per-binary only (gen_sigdb), not with the bundled DB. - Runtime-keyed configs (custom-key cryptify, KrustyLoader's cfb-mode config) need dynamic recovery with that environment.
- Platforms: Linux (x86-64, ARM64) and Windows x64 are tested. On Windows-on-ARM use x64
Python — native ARM64 Python has no
unicorn/capstone<6wheels yet.
Full list
- Emulation is not execution. Path exploration flips branches one at a time inside the function it starts from, within budgets (8 runs per function, instruction budgets per run and per callee).
- Large binaries: emulation runs Unicorn with Python hooks: ≈1 M emulated instructions/s per
process on the benchmark binary (x86-64 Linux, Unicorn 2.1.4; ≈0.65 M before the Oct 2026 hook
optimisations). The time is the Python hooks, not the engine (
scripts/bench_emu.py). A first pass runs every function once with a small budget, a second explores decoder-like functions, and binaries with 2,000+ functions are split over processes (RUST_DEOBF_WORKERS).--max-ramtrades that speed back for memory on a small machine. - Static recovery is idiom-bound by design; it covers the release idioms of the benchmark
crates. Under
size-ltomost keyed decoders are recovered by emulation, not statically. - musl runtime signatures describe the musl that current rustc bundles; another version may
need
gen_runtime_sigs.pyre-run on an unstripped build. - Precision: what is left is run-time assembled text the filters do not model (see Results).
- 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 buffer are told from two buffers that merely share a prefix; the text/write-age heuristics remain only as the fallback where an address is unknown (VALIDATION.md §11).
- Real malware: on large real binaries a minority of emulation outputs are garbled std-internal
fragments. 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 foraarch64-apple-darwin(§15,tests/test_macho.py). 32-bit PE (i686-pc-windows-gnu) is emulated too: the Windows std path needed stdcall import cleanup,.eh_framefunction starts and write-protected import trampolines (it gave 0–2/5 before). FickleStealer is also packed, so it needs unpacking regardless of arch. The benchmark's obfuscation crates model one real pattern, not all of them. - Library-function identification (
recover_libfuncs.py) names std/crate functions by byte signature on ELF, PE and Mach-O (x86-64, AArch64, 32-bit x86); the bundled DBs are for one toolchain, rustc 1.97 (linked-build signatures for x86-64, plus prebuilt-std signatures for x86_64 Linux, aarch64 Linux and x86_64 windows-gnu inscripts/data/libfunc_sigs_std.json.gz, ~600 KB gzip), and are used only for a binary of that rustc (same minor): signatures of another rustc mislabel more than they name. For any other rustc / target / crate set build a DB per binary withgen_sigdb.pyand pick it with--db auto; generic-heavy labels flatten their type args.
Full matrix: RESULTS.md · reproducible metrics: METRICS.md · real-binary validation and the
generalisation work: VALIDATION.md (§10) · artifact tiers: ARTIFACT_TIERS.md.
🧪 Reproduce the benchmark
| task | environment | why |
|---|---|---|
| Static / emulation recovery | any host, no VM | reads the binary; emulation runs functions under Unicorn, never natively |
| Building the benchmark | any host with Rust (rustup) |
cross-compiles, does not run samples |
| Dynamic recovery | isolated analysis VM — required | it executes the target |
python3 bench_src/gen_projects.py # (re)generate bench_src from the canaries
ARM64=1 bash scripts/build_all.sh # all crates x {ELF, PE, AArch64} x profiles
FORMATS=pe bash scripts/build_all.sh # one format (CI runs one job per format)
FORMATS=pe32 bash scripts/build_all.sh # 32-bit PE (i686-pc-windows-gnu, i686 mingw-w64)
ARM64_GNU=1 bash scripts/build_all.sh # + AArch64 glibc (dynamic); cross linker
# aarch64-linux-gnu-gcc unless on AArch64
python3 -m pytest tests -q # recall and precision, canary + scatter
python3 scripts/eval_scatter.py # the scatter table above
python3 scripts/measure_fp.py --pipeline # false positives on real binaries
python3 scripts/fuzz_recover.py --time 300 # robustness fuzz: malformed ELF/PE/Mach-O
python3 scripts/redblue_loop.py --rounds 20 # RustCloak hides random strings, rust-deobf recovers
fuzz_recover.py fuzzes rust-deobf itself — it mutates the committed binary fixtures and feeds the
malformed results through the whole loader/emulator path, each input isolated with an rlimit and a
timeout, so a crafted binary that would hang or crash the analyzer in a SOC pipeline is caught as a
finding (and saved for --reproduce). It found and we fixed five such hangs/OOMs; see
VALIDATION.md §48. --atheris drives libFuzzer when the atheris package is
installed; otherwise a standalone mutation driver runs.
redblue_loop.py is the recovery-side fuzzer and the overfitting answer: each round generates a
random benign Rust program that plants known indicator strings (documentation placeholders only),
compiles it under a random RustCloak configuration (needs the RustCloak plugin + a matching nightly
rustc), and checks rust-deobf recovers every planted string — misses are saved as regression
fixtures. See VALIDATION.md §49.
Build details
build_all.sh profiles: debug, release, size-lto, speed-lto (canary suite) and
release, size-lto, speed-lto (scatter suite); PROFILES=... and ONLY=... narrow it,
SKIP_EXISTING=1 keeps what is built. Rust targets: x86_64-pc-windows-gnu (with the
mingw-w64 linker), aarch64-unknown-linux-musl (linked by the bundled rust-lld) and, with
ARM64_GNU=1, aarch64-unknown-linux-gnu. BUILD_DIR=... builds elsewhere and
RUST_DEOBF_BUILD=... points the tests at it, so two toolchains can be compared side by side.
FLOSS is only needed to reproduce the baseline column (scripts/run_floss.py).
Dynamic recovery must run inside a dedicated, isolated VM (Kali or Windows), never on your host: snapshot before detonation, revert after.
🛡️ Scope / ethics
Defensive reverse-engineering for malware triage. The benchmark and test inputs are benign
programs built from author-written sources (bench_src/, every planted string listed in
bench_src/manifest.json) — no payloads, no live indicators. The tooling reads and decodes binaries
and produces no offensive capability. Real-malware validation (gallery/) is static + emulation
only: samples are never executed and never committed — only their hashes and the results.
Run dynamic analysis solely inside an isolated VM, against samples you are authorised to analyse.
📜 License
Open-core. The core — everything outside premium/ — is under the Apache License 2.0 (see
LICENSE): a standard, OSI-approved permissive license, free for any use including
commercial. Modules under premium/ (VM devirtualization, control-flow de-flattening, MBA
simplification) are dual-licensed AGPL-3.0-or-later OR Commercial. See
LICENSING.md for the model and CLA.md for contributing.
The 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.
If rust-deobf recovered something your other tools missed, a ⭐ helps others find it.
Issues and PRs welcome — especially new obfuscation crates and Rust samples (by hash) that beat it.
Metadata
Release files for rust-deobf 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| rust_deobf-0.1.0.tar.gz | 1.2 MB | With provenance | Details for rust_deobf-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| rust_deobf-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for rust_deobf-0.1.0-py3-none-any.whl |
Total release size: 2.3 MB
Release files / rust_deobf-0.1.0.tar.gz
| Download URL | rust_deobf-0.1.0.tar.gz |
|---|---|
| Size | 1.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6ecc526101071dd8a1b966b10d2d0ba094e451615ae59c42d8f2139169ae750d
|
|
BLAKE2b-256 checksum How to use checksums |
ca90ec57047a96fbbdf3007623e50cae4057d53e254e876a18bdc603025181ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logRelease files / rust_deobf-0.1.0-py3-none-any.whl
| Download URL | rust_deobf-0.1.0-py3-none-any.whl |
|---|---|
| Size | 1.1 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
19d3690b719506b60feda7e2669d6a12dddbb8a9d80f988a8ec8d7917905e672
|
|
BLAKE2b-256 checksum How to use checksums |
0bed848587955801752f4fef02482cdd6b35027338ab6d1cdf0f2a41e0c81836
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logSecurity for rust-deobf 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for rust_deobf-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.