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.


1. First contact

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:

  1. strcspn(...) == 0x2c forces the input to be 44 characters long (the initial segment before any delimiter must be exactly 44).

  2. 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.)

  3. 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:

  1. Load the unpacked binary’s segments into emulated memory.
  2. Put a candidate byte at buf[n].
  3. Start execution at f_n.
  4. 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.