Challenge: Do you know the way? · CTF: DalCTF 2026 · Category: Reversing · Difficulty: Easy
TL;DR#
A UPX-packed Linux binary that asks “Do you know the way? Are there any symbols around to help you?”. Unpack it, notice it’s not stripped, and you find the whole validation laid out as 44 tiny per-character functions f_0 → f_1 → … → f_43. There’s a cute anti-dynamic-analysis trap (rand() % 44 that bails the program out mid-check), but since each function only validates a single byte, the cleanest solve is to emulate each function in isolation with Unicorn and brute-force one character at a time. The challenge’s own hint — “any symbols around to help you?” — is also a wink: the binary keeps its symbols, and the flag is literally symb0ls_4r3_4lw4ys_3xtr3m3ly_h3lpfu1.
We’re handed a single file, checker. As always, start with file and strings:
1
2
3
4
5
6
7
8
9
| $ file checker
checker: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
statically linked, no section header
$ strings -n 6 checker | grep -iE "upx|good|job|symbol"
Good job!
any symbols
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 4.24 Copyright (C) 1996-2024 the UPX Team. All Rights Reserved. $
|
Two immediate takeaways:
- It’s UPX-packed. The
$Info:/$Id: banners and the missing section header are the giveaway. The interesting code is compressed and won’t show up in a normal disassembly until we unpack. - There’s a
Good job! success string and a teasing reference to symbols.
2. Unpacking#
UPX ships its own unpacker, so this is a one-liner:
1
2
3
4
5
6
7
8
9
10
11
| $ cp checker checker.unpacked
$ upx -d checker.unpacked
File size Ratio Format Name
-------------------- ------ ----------- -----------
28155 <- 8372 29.74% linux/amd64 checker.unpacked
Unpacked 1 file.
$ file checker.unpacked
checker.unpacked: ELF 64-bit LSB pie executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=4914..., for GNU/Linux 3.2.0, not stripped
|
not stripped — that’s the hint paying off. Every function keeps its name, which makes the structure trivial to read.
1
2
3
| $ strings -n 5 checker.unpacked | grep -iE "good|way|symbol"
Good job!
Do you know the way? Are there any symbols around to help you?
|
3. Reading main#
Disassembling main (Intel syntax) shows the high-level flow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| ; --- prompt + read input ---
lea rax,[rip+0xe13] ; "Do you know the way? ..."
call puts
lea rax,[rbp-0x40]
mov esi,0x30
call fgets ; read up to 0x30 bytes into rbp-0x40
; --- length check ---
lea rdx,[rip+0xe0d] ; delimiter set
lea rax,[rbp-0x40]
call strcspn
cmp rax,0x2c ; segment length must be EXACTLY 44
jne .wrong
; --- the "random" trap ---
call time
call srand
call rand
... imul/shr/sar magic ... ; edx = rand() / 44
imul edx,edx,0x2c ; edx = (rand()/44) * 44
sub eax,edx ; eax = rand() % 44 -> r
mov [rbp-0x7c],eax ; store r
; --- copy loop with a booby trap ---
xor i,i
.loop:
mov cl, [input + i]
mov [buf + i], cl ; copy input[i] -> local buf[i]
cmp i, r
jne .next
mov eax,0
jmp .return ; <-- if i == r, BAIL OUT silently!
.next:
inc i
cmp i, 0x2b
jbe .loop
lea rax,[buf]
call f_0 ; the real check
|
Three things to unpack here:
strcspn(...) == 0x2c forces the input to be 44 characters long (the initial segment before any delimiter must be exactly 44).
The rand() % 44 trap. The program seeds with time(0) and picks a random index r ∈ [0, 43]. While copying your input into a local buffer, the moment the loop index i equals r, it silently returns without ever calling the validator.
Because r is always in [0, 43] and the loop runs i = 0 … 43, this trap fires on (almost) every single run. The effect: if you try to brute-force or trace the program dynamically, it keeps bailing out at a random point and you never reach the check. It’s a lightweight anti-dynamic-analysis gimmick. (It does nothing to stop static analysis — the validator code is right there.)
f_0 is where the real work happens, on a clean copy of the input.
4. The validation chain#
Looking at the symbol table, the binary defines 44 functions f_0 … f_43, plus two helpers rol8 and ror8:
1
2
3
4
5
6
7
8
| $ objdump -d checker.unpacked | grep -E "<f_[0-9]+>:|<rol8>|<ror8>"
0000...1209 <rol8>:
0000...123e <ror8>:
0000...1273 <f_0>:
0000...12cc <f_1>:
0000...1329 <f_2>:
...
0000...21cb <f_43>:
|
Each f_n follows the same template — validate exactly one character and tail-call the next:
1
2
3
4
5
6
7
8
| ; f_0 : checks input[0]
movzx eax, byte [rdi+0]
xor eax, 0x31
call rol8 ; rol8(x, 1)
add eax, 0x5d
cmp al, 0x07
jne .wrong ; -> puts("Wrong"), return
call f_1 ; success -> next char
|
1
2
3
4
5
6
7
8
| ; f_1 : checks input[1]
movzx eax, byte [rdi+1]
add eax, 0x68
call ror8 ; ror8(x, 2)
xor eax, 0xffffffb4
cmp al, 0xc6
jne .wrong
call f_2
|
1
2
3
4
5
6
7
8
9
10
| ; f_2 : checks input[2]
movzx edx, byte [rdi+2]
mov eax, edx
shl eax, 3
sub eax, edx ; eax = x*8 - x = x*7 (a "multiply" via shl/sub)
add eax, 0x5f
xor eax, 0x73
cmp al, 0x20
jne .wrong
call f_3
|
The exact operations differ from function to function — some rol, some ror, different rotate amounts, different add/sub/xor constants, occasional multiplies built from shl+sub — but the shape is always identical: take input[n], run a short reversible byte transform, compare against a constant, branch to either “Wrong” or the next link in the chain. Reach f_43’s success branch and it prints Good job!.
The rol8 helper is a plain 8-bit rotate-left:
1
2
| rol8(value, n):
return ((value << n) | (value >> (8 - n))) & 0xff
|
and ror8 is its mirror image.
5. Solving it#
The key observation: every f_n depends on exactly one byte, input[n], and on nothing else. The 44 constraints are completely independent. So there’s no need to invert 44 different transforms by hand — we can just brute-force each byte through the real code.
The anti-dynamic trap makes running the whole binary painful, but we can sidestep it entirely by emulating each f_n directly with the Unicorn engine. For each position n we:
- Load the unpacked binary’s segments into emulated memory.
- Put a candidate byte at
buf[n]. - Start execution at
f_n. - Watch the branch: did it reach
f_{n+1} (✅ correct byte) or puts("Wrong") (❌)?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| from unicorn import *
from unicorn.x86_const import *
import struct, subprocess, re
data = open('checker.unpacked', 'rb').read()
mu = Uc(UC_ARCH_X86, UC_MODE_64)
mu.mem_map(0, 0x40000)
# load PT_LOAD segments at their vaddrs (PIE base 0)
e_phoff = struct.unpack_from('<Q', data, 0x20)[0]
e_phentsz = struct.unpack_from('<H', data, 0x36)[0]
e_phnum = struct.unpack_from('<H', data, 0x38)[0]
for i in range(e_phnum):
off = e_phoff + i*e_phentsz
if struct.unpack_from('<I', data, off)[0] != 1: # PT_LOAD
continue
p_off = struct.unpack_from('<Q', data, off+8)[0]
p_vaddr = struct.unpack_from('<Q', data, off+16)[0]
p_fsz = struct.unpack_from('<Q', data, off+32)[0]
mu.mem_write(p_vaddr, data[p_off:p_off+p_fsz])
mu.mem_map(0x2f0000, 0x20000) # stack
mu.mem_map(0x350000, 0x1000) # input buffer
STACK, BUF, RET, PUTS_PLT = 0x300000, 0x350000, 0x30000, 0x10b0
# grab f_0..f_43 addresses straight from the symbols
out = subprocess.check_output(['objdump', '-d', 'checker.unpacked']).decode()
faddr = {int(m[1]): int(m[0], 16)
for m in re.findall(r'^([0-9a-f]+) <f_(\d+)>:', out, re.M)}
def test_char(n, c):
mu.mem_write(BUF, bytes([0x41]*64))
mu.mem_write(BUF+n, bytes([c]))
mu.mem_write(RET, b'\xf4') # hlt as fake return addr
mu.reg_write(UC_X86_REG_RSP, STACK-8)
mu.mem_write(STACK-8, struct.pack('<Q', RET))
mu.reg_write(UC_X86_REG_RDI, BUF)
res = {'ok': None}
def hook(uc, addr, size, _):
if addr == PUTS_PLT: # reached "Wrong"
res['ok'] = False; uc.emu_stop()
elif addr in faddr.values() and addr != faddr[n]:
res['ok'] = True; uc.emu_stop() # tail-called next f_
elif addr == RET:
uc.emu_stop()
h = mu.hook_add(UC_HOOK_CODE, hook)
try: mu.emu_start(faddr[n], 0, count=2000)
except UcError: pass
mu.hook_del(h)
return res['ok']
flag = ''
for n in range(44):
flag += next((chr(c) for c in range(0x20, 0x7f) if test_char(n, c)), '?')
print(flag)
|
This instantly recovers positions 0–42:
1
| dalctf{symb0ls_4r3_4lw4ys_3xtr3m3ly_h3lpfu1
|
The last character#
f_43 is the only function that doesn’t tail-call a successor — on success it prints Good job!, and both its branches go through puts, so the emulator hook can’t tell them apart. Easy to finish by hand:
1
2
3
4
| rol8(input[43] - 0x0e, 2) ^ 0x36 == 0x8b
=> rol8(x, 2) == 0x8b ^ 0x36 == 0xBD
=> x = ror(0xBD, 2) == 0x6F
=> input[43] = 0x6F + 0x0E = 0x7D = '}'
|
So the closing byte is }, giving a clean 44-character flag.
6. Verifying against the real binary#
To prove it end-to-end we still have to get past the rand() % 44 trap. Rather than fight the randomness, just neuter the bail-out with a one-byte patch: turn the conditional jne that skips the early-return into an unconditional jmp, so the copy loop always runs to completion and f_0 always gets called.
1
2
3
4
5
| data = bytearray(open('checker.unpacked', 'rb').read())
off = 0x232c # the `jne .next` guarding the early return
assert data[off:off+2] == b'\x75\x07'
data[off] = 0xeb # 75 (jne) -> eb (jmp)
open('checker.patched', 'wb').write(data)
|
1
2
3
| $ echo -n 'dalctf{symb0ls_4r3_4lw4ys_3xtr3m3ly_h3lpfu1}' | ./checker.patched
Do you know the way? Are there any symbols around to help you?
Good job!
|
🎉
Flag#
1
| dalctf{symb0ls_4r3_4lw4ys_3xtr3m3ly_h3lpfu1}
|
Takeaways#
- UPX is a speed bump, not a wall.
upx -d and you’re done. Always run file/strings first to spot the packer. - Read the prompt. “Are there any symbols around to help you?” was a literal hint — the binary is unstripped, and the flag rubs it in: symbols are always extremely helpful.
- Anti-debug ≠ anti-static. The
rand() % 44 trap only frustrates someone running/tracing the binary. The whole algorithm sits in plain sight statically, and a one-byte patch removes the trap entirely. - Independent constraints → emulate, don’t invert. When every check touches a single byte, you don’t need to reverse the math. Drop each function into Unicorn and brute-force one character at a time — 44 × 95 tries is nothing.