
Recover what obfuscation hides in Rust binaries (strings, crates, library functions, flattened control flow) via static analysis + Unicorn emulation, without running them. For malware triage, protected-app audits. ELF/PE/Mach-O, x86-64/ARM64/32-bit.
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 · Quick start · Results · Real malware · Limitations
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.
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.)
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.
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 match arm 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. --json for a SIEM, --fail-on HIGH
for 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).
Beyond strings — config/C2 extraction, hash-obfuscated API-import resolution, unpacked
stage-2 carving, control-flow-flattening detection and reconstruction (x86-64 and AArch64,
validated on goldberg, OLLVM -fla and 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).
Code virtualization — detect_vm.py flags 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.
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 (no
cargo-auditable needed).
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: