Rusty matryoshka - a Rust crypter that does things its own way
TL;DR
Static analysis of a Rust crypter/loader:
- Embedded x64 DLL split into ~200 fragments interleaved between
.rdataand.data, PE headers stored apart withMZ/PE\0\0wiped - base64url with a shuffled alphabet (Fisher-Yates + splitmix64) and a junk char every 57
- splitmix-looking XOR keystream with non-standard constants I couldn’t find documented anywhere
- Custom bit-level LZ container
- Control-flow flattening + opaque predicates fed by TLS values, which Ghidra just gives up on
Chapter I - First look
The sample is a ~1 MB PE32+ GUI binary. Nothing fancy at first sight:
1 | $ file loader.exe |
- Linked with LLD,
.pdbpath named after a fake software brand (and a version resource for the same fake company) - ~90 imports, nothing scary: no
VirtualAlloc, noCreateRemoteThread…GetProcAddress/LoadLibraryAare there, so everything interesting is resolved at runtime - TLS callbacks present
.rdataand.databoth start with huge base64url-looking strings, glued together with… English words?
1 | ...here said then more different these all get\x0036iGHHmSVLeIro_i_WSW5c9LQ... |
That’s entropy padding: sprinkle boring words around the encoded data so the sections don’t look like pure random. Cute.
The first decent lead comes from Ghidra’s crypto constants scan: a function using 0x9E3779B97F4A7C15 (golden ratio, splitmix64) right next to a base64 alphabet. Let’s start there.
Chapter II - The string/payload decoder
Shuffled base64url
The alphabet A-Z a-z 0-9 - _ is built on the stack, then shuffled with a Fisher-Yates driven by a standard splitmix64. The seed is seed_param ^ global ^ 0x8ac8579f9dbfd643. The global itself is used to rebuild the 0xbf58476d1ce4e5b9 multiplier (global + 0x518d1e1edfd8e721), so you need the right global or nothing works.
1 | alpha = bytearray(b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_') |
The decoding loop has a funny detail: a counter goes up to 0x38 and then skips one char. So every 57th character is junk. In practice it’s almost always a -, a kind of disguised line break. My first decode attempt was misaligned by exactly that, and the stats gave it away:
1 | Counter(blob[1::57]).most_common(1) |
The XOR keystream
The base64 output is XORed with an 8-bytes-per-block keystream. It looks like splitmix, but neither the increment nor the mixer are the usual ones:
1 | def keystream_xor(buf, s): |
For strings, the initial state comes from a per-call-site key: (key ^ 0xda41c459bba8115e) + 0xac2af1e5a7631881. The compiler even special-cased key == 0xda41c459bba8115e, which made me waste some time thinking it was “the” key ¯\_(ツ)_/¯
Nice property for us: the mixer is invertible (xorshifts + odd multipliers). Guess 8 plaintext bytes → recover the state → recover the seed. Keep that in mind.
Bit-level LZ
No zlib, no LZ4: a home-made container, read bit by bit (LSB first inside a byte, values assembled MSB first):
1 | bit 0 : 1 = compressed, 0 = raw |
The size header turned out to be the best oracle of the whole analysis: wrong key → absurd size, good key → plausible size. First win:
1 | decode(blob_at(0x...), 0x12, 0x480ef8a85e72589d) |
Noice. The same routine (base64 → XOR → LZ) decodes every API/DLL/window class name, each with its own key, and caches the result in a Rust Once lazy cell.
Chapter III - Where is the payload?
The big strings are referenced… by nothing. Scanning the whole file for (ptr, len) pairs pointing inside them gives a table of ~200 slices jumping back and forth between .rdata and .data. Concatenate them in table order and you get one big encoded blob.
So, decode it with the routine above and done? Nope. Brute-forcing the key with known plaintext (MZ\x90\x00..., then the LLD DOS stub MZx\x00\x01..., raw and compressed modes, every plausible size) gave nothing.
Two reasons, found the hard way:
- There are actually two encoded strings: a small 3-fragment one and the big one. Each has its own 57-char alignment, and mixing them breaks everything.
- The payload is not decoded by the generic routine at all. It’s decoded inline in a huge flattened function, with its own XOR state.
Time to stop guessing and reverse properly.
Chapter IV - Flattening + TLS opaque predicates
Three functions (6 to 32 KB each) are fully flattened. Every block ends like this:
1 | lea rdx, [rip + X] |
and the conditions come from stuff like:
1 | mov rax, qword ptr [rsp + 0x40] ; value loaded from TLS earlier |
Ghidra doesn’t resolve these jumps, so a big chunk of the code isn’t even inside a function.
The values are thread-local statics. Part of them comes straight from the .tls template, and part is rewritten at startup by a small routine that decodes a constant table (add / sub / xor with rolling constants). Since everything is deterministic, everything is recoverable statically.
So I wrote a small value-set analysis on top of capstone: track sets of constants in registers / stack / TLS, read globals from the image, fork on unknown conditions, resolve movsxd [table+idx] + base; jmp, and log (ptr, len, key) every time the string decoder is called. No emulation, just abstract interpretation over the disassembly.
1 | site=0x...709 ptr=0x...4b6 len=0x12 key=0x6e6b48a30b89ca23 -> user32.dll |
Two bugs on my side worth sharing, because they cost me the most time:
- I first forgot one level of indirection on
gs:[0x58](TEB → TLS array → TLS block), so every TLS read returned 0 and every opaque predicate went the wrong way. - Even with the TLS template, things were off until I modelled the TLS init routine. Without it, a loop exit condition evaluated to a constant that the loop counter could never reach.
Once both were fixed, the CFG opened up and led straight to the inline decoder, with the XOR state as a plain movabs:
1 | movabs r11, 0x38c09f78dbf06d5f ; initial state, small blob |
Chapter V - Rebuilding the DLL
With that state, the small blob decompresses to 1024 bytes of PE headers… with MZ and PE\0\0 zeroed:
1 | 00000030: 0000 0000 0000 0000 0000 0000 4000 0000 ............@... |
Free gift: the section headers give the exact raw size of the sections. That’s the size field the big blob’s LZ header must contain. So, test every 64-bit immediate of .text as a direct XOR state and keep the one giving flag = 1 and that exact size:
1 | 0x...6ca 0x2ee4c3cacfb2abd2 flag 1 size 0x98600 <- the one |
Decompress, glue headers + sections, restore the two signatures (analysis copy only):
1 | $ file stage_rebuilt.bin |
And of course… the extracted DLL is packed with the same crypter. Matryoshka all the way down.
Chapter VI - Detection
The XOR constants are rare enough to be a decent anchor. Tested on two builds, zero hits on ~1000 system DLLs:
1 | rule Loader_Rust_SplitmixXor_BitLZ |
Caveat: nothing prevents the author from rolling these constants per build. The structural tricks are more robust anchors: 1-in-57 junk char, bit-level LZ, interleaved (ptr, len) fragment table, headers stored apart with wiped signatures, English filler.
Takeaways
- None of the building blocks are new, but the combination is neat, and I couldn’t find any public write-up of this scheme or its constants
- Brute force + known plaintext is great… until the payload doesn’t go through the function you’re attacking
- When opaque predicates depend on TLS, model the TLS init, or your static analysis will lie to you
- The LZ size header is a free oracle: use it