Post-compilation string obfuscation for Rust binaries. Encrypts all strings in .rodata (ELF) / .rdata (PE) so that strings reveals nothing about your program. Supports self-rekeying to change the binary's hash on demand and an embedded encrypted config store that persists across rekeys.
- Your binary links
obfrust-runtime, which embeds a 4KB metadata placeholder, a 4KB config placeholder, and a pre-maininit function - You compile normally with
cargo build --release - You run
obfrust patchon the compiled binary — this encrypts.rodatain-place and fills the metadata placeholder with key material - At runtime, the init function decrypts everything in memory before
main()runs - Optionally, the binary can re-key itself (generating a new key + re-encrypting on disk) to change its hash
- Optionally, the binary can store and retrieve encrypted config data that survives rekeys
The init-time decryption uses a pure-arithmetic XOR keystream (SplitMix64) that has zero dependencies on .rodata contents, avoiding the chicken-and-egg problem of needing library constants that are themselves encrypted.
# Cargo.toml
[dependencies]
obfrust-runtime = { path = "/path/to/obfrust/obfrust-runtime" }The runtime must be linked into your binary. At minimum, reference it so the linker doesn't strip it:
fn main() {
// Ensure obfrust-runtime is linked
let _ = obfrust_runtime::rekey;
// ... your code ...
}cargo build --release# From the obfrust workspace root:
cargo build --release -p obfrust# Output to a new file:
obfrust patch target/release/my-app -o target/release/my-app.obf
# Or patch in-place:
obfrust patch target/release/my-app --in-place# Should find zero sensitive strings:
strings target/release/my-app.obf | grep "my secret"
# Should run normally:
./target/release/my-app.obfRekeying generates a new random encryption key, re-encrypts all sections, and writes the binary back. This changes the binary's hash (SHA-256, etc.) without recompilation.
obfrust rekey target/release/my-app.obffn main() {
if should_rekey() {
match obfrust_runtime::rekey() {
Ok(()) => println!("rekeyed"),
Err(e) => eprintln!("rekey failed: {e}"),
}
}
}Self-rekey modifies the on-disk binary. The currently running process is unaffected — the new key takes effect on the next execution.
Patched binaries include a 4KB encrypted config region. You can store arbitrary data (up to ~4088 bytes) that is encrypted with the same key material as the obfuscated strings. Config data survives both self-rekey and external rekey — it is decrypted with the old key and re-encrypted with the new key automatically.
fn main() {
let license = b"PRO-2026-XYZ789";
match obfrust_runtime::write_config(license) {
Ok(()) => println!("config written"),
Err(e) => eprintln!("write failed: {e}"),
}
}fn main() {
match obfrust_runtime::read_config() {
Ok(Some(data)) => {
let text = String::from_utf8_lossy(&data);
println!("config: {text}");
}
Ok(None) => println!("no config stored"),
Err(e) => eprintln!("read failed: {e}"),
}
}Config is stored on disk in the binary's .obf_config section, encrypted with the same SplitMix64 XOR keystream used for .rodata. The write_config and read_config functions read/modify the on-disk binary (not in-memory), so changes take effect immediately for subsequent reads but don't affect the running process's memory layout.
obfrust info target/release/my-app.obfobfrust metadata found at file offset 0x63148
version: 1
binary_len: 535728 bytes
regions: 1
key_hash: b49c7176609231083630e463b8f5f89be09eb66950da224df916427d9160d6ce
nonce: [89, 85, 08, b9, 6c, 2b, 92, 18, e3, 12, b9, cf]
region[0]: file_offset=0x5de0 vaddr=0x5de0 size=0x6720 (26400 bytes)
config: 23 bytes at file offset 0x62148
obfrust patch <BINARY> [-o <OUTPUT>] [--in-place]
Encrypt all string-bearing sections and write metadata.
obfrust rekey <BINARY>
Re-key a patched binary with a new random key.
obfrust info <BINARY>
Display obfrust metadata (key hash, nonce, regions, config status).
| Feature | Linux (ELF) | Windows (PE) | macOS (Mach-O) |
|---|---|---|---|
| Patch binary | Yes | Yes | Planned |
| Init decryption | .init_array |
TLS callback | Planned |
| External rekey | Yes | Yes | Planned |
| Self-rekey | Yes | Rename trick | Planned |
| Config store | Yes | Yes | Planned |
- ELF: Only
.rodatais encrypted..data.rel.rois excluded because it contains relocated pointers (vtables, dispatch tables) that the dynamic linker patches before init runs. - PE:
.rdataand.dataare encrypted, with exclusion zones for PE data directories (import table, IAT, TLS, debug, exception, etc.). - macOS: Mach-O patching is not yet implemented. The architecture is designed for it (sections:
__TEXT,__cstring/__TEXT,__const, init:__DATA,__mod_init_func) but the code is a stub.
obfrust/ Workspace root
├── obfrust/ CLI patcher tool
├── obfrust-common/ Shared types: ObfMeta format, crypto helpers
├── obfrust-runtime/ Library linked by target binaries
└── test-binary/ Integration test binary
The patcher generates a random 32-byte key_material and 12-byte nonce. For each encrypted region, a deterministic XOR keystream is generated using SplitMix64 seeded from the key material, nonce, and region index. This is a stream cipher — output length equals input length, no padding.
SplitMix64 was chosen over ChaCha20 for the init-time path because the decryption runs before .rodata is available — crypto libraries that read constants from .rodata (ChaCha20's "expand 32-byte k", BLAKE3 initialization vectors) would crash.
Offset Size Field
0 4 magic ("OBFR")
4 4 version (1)
8 4 num_regions
12 4 binary_len low 32 bits
16 32 key_material
48 12 nonce
60 4 binary_len high 32 bits
64 N*24 regions: [(file_offset: u64, vaddr: u64, size: u64)]
Max 168 regions per binary.
Offset Size Field
0 4 magic ("OBFC")
4 4 data_len
8 N payload (up to 4088 bytes)
The entire 4096-byte block is encrypted using init_xor_region with region_index = u32::MAX to avoid collision with .rodata region indices. An all-zeros block means no config has been written.
This tool is designed to defeat static string analysis (strings, grep, regex scanning). It does NOT protect against:
- Dynamic analysis: attaching a debugger, setting breakpoints at the decryption return, or dumping process memory after init runs
- Determined reverse engineering: the key material is stored in the binary itself (in
.obf_meta), so an RE who understands the scheme can decrypt both strings and config manually
The encryption key is not stored as a raw ChaCha20 key — it goes through a BLAKE3 keyed-hash derivation layer that mixes the key material, nonce, and binary length. This makes automated key extraction harder but not impossible.
MIT