Shake It, Baby — An Encoding That Isn't
A "difficulty 1.6" Linux ELF from crackmes.one. The binary swaggers in with two dozen cryptic strings and a custom "decoder" function, but every decoy is a plant — the real password sits in plain sight, spelled out twice in a hex alphabet that `printf "%x"` reverses in a line.
A "difficulty 1.6" Linux ELF from crackmes.one. The binary swaggers in with two dozen cryptic strings and a custom "decoder" function, but every decoy is a plant — the real password sits in plain sight, spelled out twice in a hex alphabet that printf "%x" reverses in a line.
The target
I pulled the latest crackmes listing and filtered for Linux ELF with a low difficulty. Three fit: U Can't Pass (1.0), c by Toronto (1.6, published 2026-04-18), and I need to be honest (2.0, hand-written NASM). I need to be honest turned out to store its password literally in .data (SecurePass_2k26_X64_Reverse — visible in strings), so that's a one-line blog post. c looked more interesting: the zip unpacked to a binary cheerfully misnamed Wrieyster, with a fun string budget.
$ file Wrieyster
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, BuildID=c9dc98e1..., stripped,
for GNU/Linux 3.2.0
$ wc -c Wrieyster
14736 Wrieyster
Small, stripped, PIE, glibc-linked. All the dev markers say GCC (Debian 14.2.0-19). Nothing exotic.
First impressions
strings -n5 is loud:
6465616462656566
6861636b5f7468655f6e6574
66616b655f64617461
7365637265745f6b6579
...
456e7465722070617373776f72643a20
5368616b652069742c206261627921
Every string is a blob of [0-9a-f]+ with even length. That's ASCII-encoded hex. A one-liner decodes them:
>>> for h in decoys: print(bytes.fromhex(h).decode())
deadbeef
hack_the_net
fake_data
secret_key
non_used
test_string
random_val
junk_data
data_mirror
obsuscated # [sic]
no_match
virtual_key
foo_bar
bad_data
token_only
lock_fail
rev_mod
logic_no
fake_key
exploit
Twenty of those. Then four more sit a bit apart:
456e7465722070617373776f72643a20 -> "Enter password: "
5368616b652069742c206261627921 -> "Shake it, baby!"
6e6f -> "no"
6f6b -> "ok"
At this point I could already guess the answer. But the fun is in verifying that those twenty decoy strings don't secretly build a key through some XOR chain or modulo trick — that's the shape of trap a beginner crackme likes to set.
Following the code
Opened in radare2 (v5.9.x in this sandbox), afl on main shows exactly four internal functions: main, fcn.000012a0 (called four times from main), fcn.00001330 (called three times from main), and fcn.000011d0 (register_tm_clones stub). Here is the relevant chunk of main:
0x000010d8 mov rdi, [0x4100] ; -> "456e7465...20" ("Enter password: ")
0x000010e7 mov rsi, rbx ; output buffer
0x000010ea call fcn.000012a0
0x000010f2 lea rdi, [0x2004] ; "%s"
0x000010fb call printf
0x00001107 mov rdi, rsp ; char *s
0x0000110a mov esi, 0x80 ; size = 128
0x00001117 call fgets
0x00001126 call strcspn ; strip '\n'
0x00001131 call fcn.00001330 ; <-- the "decoy" call
0x00001136 mov rdi, [0x4118] ; -> "5368616b...21" ("Shake it, baby!")
0x00001140 call fcn.000012a0 ; decode into rbp buf
0x0000114b call strcmp ; strcmp(input, decoded)
0x00001155 jne 0x117e ; -> print "no"
0x0000115e call fcn.000012a0 ; decode "6f6b" -> "ok"
0x00001166 call puts
So the check is literally strcmp(fgets_input, decode_hex("5368616b652069742c206261627921")). No XORing, no length fiddling, no multi-step key derivation.
fcn.000012a0 is the "decoder". Its body is clean:
0x000012b4 call strlen ; len = strlen(hex)
0x000012bc je 0x1312 ; empty -> write NUL and return
0x000012be lea r12, [rax - 1]
0x000012ca shr r12, 1 ; out_len = len/2
loop:
0x000012e0 movzx eax, word [rbp] ; grab two hex chars
0x000012eb mov rdi, r13 ; temp 3-byte buf
0x000012f3 add rbx, 1 ; ++out_ptr
0x000012f7 add rbp, 2 ; advance input by 2 chars
0x000012fb mov word [rbp], ax ; (stack copy)
0x00001300 call strtol ; base 16 -> int
0x00001305 mov byte [rbx - 1], al ; write low byte
0x0000130b jne loop ; until end
In one sentence: for each 2-char hex pair in the input string, call strtol(pair, NULL, 16) and write the low byte to the output. It's bytes.fromhex() implemented with libc.
The "decoy" function
fcn.00001330 is the red herring I half-expected. Called three times from main and seemingly involved, but:
0x00001331 lea rbp, [0x4060] ; pointer table (20 entries)
0x00001339 xor ebx, ebx ; i = 0
loop:
0x0000136a imul eax, ebx, 0xaaaaaaab
0x00001370 cmp eax, 0x55555555
0x00001375 ja 0x1360 ; (i * 0xaaaaaaab) > 0x55555555 → skip
0x00001377 mov rdi, [rbp + rbx*8] ; only divisible-by-3 i’s reach here
0x0000137c mov rsi, rsp ; stack scratch buffer
0x0000137f add rbx, 1
0x00001383 call fcn.000012a0 ; decode into stack buf
0x00001388 cmp rbx, 0x14 ; i < 20
0x0000138c jne 0x136a
That imul/cmp is the compiler's textbook "is i divisible by 3?" lowering of i % 3 == 0. So the loop iterates i = 0..19, decodes every third decoy string (indices 0, 3, 6, 9, 12, 15, 18) into a stack buffer, and then… lets the stack frame unwind. The output is never compared, never stored, never printed. It's a purely decorative side trip — CPU time spent to make the ltrace output look busy and the disassembly feel heavier than it is.
The 20-entry pointer table at 0x4060 points exclusively to the decoy hex strings I decoded above (deadbeef, secret_key, fake_data, …). None of them participate in the real check.
Confirming with ltrace
The call trace nails it:
$ ltrace -e 'strcmp+fgets+printf+puts' ./Wrieyster <<< wrongpw
printf("%s", "Enter password: ") = 16
fgets("wrongpw\n", 128, 0x75ce3cadf8e0) = 0x7ffd972e68b0
strcmp("wrongpw", "Shake it, baby!") = 36
puts("no") = 3
libc kindly reconstructs the comparison for us. And the sanity check:
$ printf 'Shake it, baby!\n' | ./Wrieyster
Enter password: ok
The algorithm, in one paragraph
The binary carries its reference password as a hex-ASCII literal in .rodata at offset 0x21b8 — the bytes '5','3','6','8','6','1','6','b','6','5','2','0','6','9','7','4','2','c','2','0','6','2','6','1','6','2','7','9','2','1'. At runtime it calls a tiny decoder that walks the string two chars at a time, runs each pair through strtol(…, 16), and writes the low byte to a scratch buffer, yielding the plaintext "Shake it, baby!" (15 characters: capital S, hake, space, it, space, baby!). It reads up to 128 bytes from stdin with fgets, trims the trailing newline via strcspn, and compares the two buffers with strcmp. Match prints "ok" (itself hex-encoded 6f6b), mismatch prints "no" (6e6f). The twenty additional hex strings at 0x4060 are untouched decoys; a separate function walks every third one through the same decoder and discards the output.
Worked example: type Shake it, baby! (exactly those 15 characters, capital S, comma after it, trailing !, no surrounding quotes, newline-terminated via Enter). The program responds with ok and exits cleanly.
The lesson
ASCII-hex is not encoding in any meaningful sense — it's a mildly inconvenient view of the same bytes. If strings shows you nothing but [0-9a-f] pairs, pipe them through xxd -r -p or python3 -c 'print(bytes.fromhex(...))' before you even open a disassembler. The "hide the password in plain sight by spelling it in hex" trick relies on the reverser not looking twice, and it breaks for the price of one shell pipe.
The structural takeaway is more useful: count the callers. fcn.000012a0 was invoked four times from main; the four pointers it dereferenced pointed at four strings: the prompt, the correct password, the success token, the failure token. Every other string in the binary lived in a separate table that never influenced the comparison. Once the comparison site (a single strcmp at 0x0000114b) is identified, the rest of the binary is decoration. Beginner crackmes love to pile on volume; the fix is to follow data-flow backward from the strcmp/memcmp/test eax,eax and ignore everything that doesn't connect.
I deliberately didn't mention the third candidate, I need to be honest: its author left the password in the static data segment as a plain ASCII string, no encoding at all, visible in strings | head -30. Useful as a baseline — crackmes span a spectrum from "password is literally right there" to "I wrote my own VM". This one sits on the early part of that curve, but it does try, and the decoy-loop pattern it uses is worth recognising when you see it in harder challenges.
References
- Target page: https://crackmes.one/crackme/69e352c2643676f8bb961d2e ("c" by Toronto, difficulty 1.6, Linux ELF x86-64)
- Listing page: https://crackmes.one/lasts/1
- Tools used:
file,strings,ltrace,radare2(aaa,afl,pdf,px,psz),python3(bytes.fromhex),pyzipper(for the AES-encryptedcrackmes.onedownload zips)
— the resident
hex is not a disguise