Post

Lesson 9.3: Rust crackme lab

Lesson 9.3: Rust crackme lab

Rust binaries are annoying to read because the compiler inlines a lot, flattens iterator chains into flat loops, and adds panic code everywhere. But that panic code also helps you find things. This lesson combines 9.1 and 9.2 into a real case, which is getting the password out of a Rust crackme.

The lab for this lesson is at the end of the post. Try it yourself first, the walkthrough below is the path.

Step 0: confirm it’s Rust

Before opening the disassembler, run a quick triage. strings crackme | grep -iE "rustc|\.rs|panicked" almost always returns something, such as a rustc version string, .rs file paths embedded in panic messages, and mangled symbols like _ZN or _R. That tells you it’s Rust and not C.

Detect It Easy recognizes it too, but I still run strings because it also gives me the reference strings to xref in a moment.

Step 1: use panic strings and strings as landmarks

This is the biggest difference when reversing Rust compared to C. In C you start from main. In Rust, the real main is wrapped in lang_start and a layer of closures, so going straight in is tiring. Work backwards from strings instead.

This crackme prints Nope. on failure and Correct! on success. Open the Strings window, find Nope., press xref. It takes you straight to the failure branch of the check function, and right above it is the comparison loop. You skipped the entire Rust runtime without reading any of it.

If the binary isn’t stripped, it’s even easier, because demangled symbols (with rustfilt or let IDA/Ghidra do it) show the function name check plainly.

Step 2: find the constant array

In the check function, the decompiler shows the input being compared against a fixed block of bytes in .rodata:

1
6e 4a 49 4b 5f 0a 45 5a 72 42 44 10

Twelve bytes. And right at the top of the function there’s a length check. If the length of the input string isn’t 12, it returns false immediately. So the password is exactly 12 characters long. Knowing the length first gets you halfway there.

From lesson 9.2 we know that a Rust String/str is a pointer plus a length, not null-terminated. The constant array is also just a contiguous block of bytes. Don’t expect a 00 separator like in C strings.

Step 3: read the transformation

The iterator part in the original source may be an .enumerate().map(...) chain, but after the compiler inlines it you only see a flat loop. Don’t try to rebuild the iterator syntax, just work out what it does for each character at position i:

1
2
3
enc = (input[i] + i) & 0xFF      ; add the index
enc = enc ^ 0x3C                 ; XOR with a constant
if enc != EXPECTED[i] -> Nope

There are two simple operations, which are to add the index then XOR. Both are reversible.

Step 4: keygen instead of guessing

Since the check can be inverted, don’t brute force, just compute it. Reverse it with input[i] = (EXPECTED[i] ^ 0x3C) - i.

1
2
3
EXPECTED = [0x6e,0x4a,0x49,0x4b,0x5f,0x0a,0x45,0x5a,0x72,0x42,0x44,0x10]
pw = "".join(chr(((b ^ 0x3C) - i) & 0xFF) for i, b in enumerate(EXPECTED))
print(pw)   # Rust_1s_Fun!

Run the crackme again with that password:

1
2
./crackme 'Rust_1s_Fun!'
Correct! Flag: RE{Rust_1s_Fun!}

Done. The algorithm checks out in Python, since encrypting forward gives the constant array and inverting gives the password. The full details are in the “Show solution” block.

Practicing Rust

Most beginners avoid Rust because the decompiler gives confusing inlined code. There are only a few tricks though. Use panic strings and strings to locate things, look at the length of the constant block to know the input length, and invert the check instead of reading every line of the optimized assembly. Those three habits solve most beginner to intermediate Rust crackmes.

Lab

LAB 9.3Download the source files for this lab

The goal is to recognize a Rust binary, work your way to the check function through panic strings and symbols, read the algorithm that transforms the password, and then reverse it to find a valid key. You don’t patch anything, you understand it and write a keygen. The source is main.rs, and the optimized build, which looks more like a real binary (inlined iterators, merged functions), is:

1
rustc -O main.rs -o crackme

If you don’t have Rust yet, install it through rustup (https://rustup.rs). While you’re still learning, you can build without optimization to make it easier to read:

1
rustc main.rs -o crackme_debug

Try a wrong password first with ./crackme WRONG_PASSWORD, which prints Nope..

Use Detect It Easy or strings to confirm it’s a Rust binary (look for strings related to rustc, panic, and .rs paths). Open it in Ghidra or IDA and use the panic strings and symbol names (if not stripped) to find the check function. Find the 12-byte constant array in .rodata (the EXPECTED array), then read the transformation applied to each input character before the comparison. Write a snippet of Python (or Rust) that reverses it to recover the password from the constant array, run ./crackme <password>, and confirm you get the Correct! line.

Some hints. A Rust String/&str stores a pointer plus a length and isn’t null-terminated, and the constant array is also a contiguous block of bytes in .rodata. The password length is the length of the EXPECTED array, which is the first thing to find. The transformation is reversible (add the index, then XOR), so reversing it means XOR first, then subtract the index. Do it yourself before opening the solution.

Show solution

To recognize a Rust binary, run strings crackme | grep -iE "rustc|\.rs|panicked". A Rust binary always leaves behind a compiler version string (for example rustc 1.xx), the .rs source file paths inside panic messages, and symbol mangling of the _ZN... (legacy) or _R... (v0) style. That’s enough to know you’re holding Rust and not C/C++.

There are two good landmarks for finding the check function. One is panic strings. If the code has unwrap/expect or formatting, the .rs path and line numbers show up in .rodata, and an xref back leads near the relevant function. The other is symbols. If the binary isn’t stripped, look for a symbol containing check or main. If it is stripped, use the panic strings and the strings Usage:, Nope. and Correct! as landmarks and xref them. In this crackme, an xref from the Nope. string leads straight to the failure branch of check, and right above it is the comparison loop.

The 12-byte constant array in .rodata is:

1
6e 4a 49 4b 5f 0a 45 5a 72 42 44 10

After you strip away the inlined iterator, the loop does one thing for each character at position i:

1
2
3
enc = (input[i] + i) & 0xFF
enc = enc ^ 0x3C
compare enc with EXPECTED[i]

The length must be exactly 12 (the array length), checked right at the start of the function.

The transformation is reversible. Inverting it gives input[i] = (EXPECTED[i] ^ 0x3C) - i:

1
2
3
EXPECTED = [0x6e,0x4a,0x49,0x4b,0x5f,0x0a,0x45,0x5a,0x72,0x42,0x44,0x10]
pw = "".join(chr(((b ^ 0x3C) - i) & 0xFF) for i, b in enumerate(EXPECTED))
print(pw)

The result is:

1
Rust_1s_Fun!

To confirm:

1
2
./crackme 'Rust_1s_Fun!'
Correct! Flag: RE{Rust_1s_Fun!}

As a check on the algorithm in main.rs, encoding the password Rust_1s_Fun! gives the EXPECTED array above, and reversing the EXPECTED array gives back Rust_1s_Fun!.

What to take from this is that with Rust, panic strings and .rs paths are the best reference points, so use them before diving into assembly. The length of the constant block tells you the length of the password. And when the check is reversible, write a keygen (reverse it) instead of brute forcing or patching.

Key takeaways

Confirm Rust with strings, looking for the rustc version, .rs paths, and _ZN/_R mangling. Go from strings (Nope., Correct!) and panic strings backwards into the check function, and don’t struggle with the wrapped main. The length of the constant block in .rodata is often exactly the password length.

Iterator chains get inlined into flat loops, so read what they do instead of rebuilding the syntax. If the check is reversible, write a keygen and don’t brute force.

This post is licensed under CC BY 4.0 by the author.