1694 words
8 minutes
TexSAW 2026 - Broken Quest - Reverse Engineering Writeup

Category: Reverse Engineering Flag: texsaw{1t_ju5t_work5_m0r3_l1k3_!t_d0e5nt_w0rk}

Challenge Description#

These quest flags are totally just broken! I can’t figure out how to complete the quest. Note: There’s two ways to do this.

Analysis#

The challenge binary is a 64-bit PIE ELF for x86-64, dynamically linked and not stripped, which immediately made this more of a symbol-reading exercise than a blind reversing grind. Running radare2’s function list showed all of the useful entry points up front: the menu handlers, turn_in, and the flag path in handle_flag.

r2 -A ./brokenquest -q -c 'afl' 2>/dev/null | rg 'main|handle|turn|reset|rotate|heat|gold|swing|swap|sand|reverse|quest|flag'
0000000000001209 T reset
0000000000001232 T rotate
0000000000001298 T increment
00000000000012c5 T add_sub
00000000000012fa T modulo
0000000000001362 T swap
0000000000001394 T bitshift
00000000000013cf T flip_sign
0000000000001404 T char_clamp
0000000000001425 T op0
000000000000144e T op1
00000000000014b2 T op2
00000000000014d6 T op3
00000000000014fc T op4
000000000000152f T op5
0000000000001553 T op6
0000000000001586 T op7
00000000000015aa T calc_val
0000000000001857 T transform
0000000000001904 T handle_flag
0000000000001edc T turn_in
0000000000001f44 T print_menu
0000000000001fe5 T main

The first important shortcut was in turn_in. Instead of trying to solve the whole menu puzzle by hand, I checked what success actually meant. The disassembly showed a memcmp over eight 32-bit integers followed by a call to handle_flag if the comparison succeeded, so the entire problem reduced to recovering the exact target array.

objdump -d -Mintel ./brokenquest --disassemble=turn_in
0000000000001edc <turn_in>:
    1ef8: ba 08 00 00 00        mov    edx,0x8
    1f03: e8 e8 f1 ff ff        call   10f0 <memcmp@plt>
    1f08: 85 c0                 test   eax,eax
    1f0a: 74 16                 je     1f22 <turn_in+0x46>
    1f38: e8 c7 f9 ff ff        call   1904 <handle_flag>

Looking at main made that target explicit. Right before the menu loop, the binary writes eight constants onto the stack: 2, 6, -4, 6, 0, 4, -3, 1. The same function also dispatches the eight menu actions, which confirms the intended route is to mutate the current quest state until it matches that array.

objdump -d -Mintel ./brokenquest --disassemble=main
0000000000001fe5 <main>:
    2013: c7 45 d0 02 00 00 00  mov DWORD PTR [rbp-0x30],0x2
    201a: c7 45 d4 06 00 00 00  mov DWORD PTR [rbp-0x2c],0x6
    2021: c7 45 d8 fc ff ff ff  mov DWORD PTR [rbp-0x28],0xfffffffc
    2028: c7 45 dc 06 00 00 00  mov DWORD PTR [rbp-0x24],0x6
    202f: c7 45 e0 00 00 00 00  mov DWORD PTR [rbp-0x20],0x0
    2036: c7 45 e4 04 00 00 00  mov DWORD PTR [rbp-0x1c],0x4
    203d: c7 45 e8 fd ff ff ff  mov DWORD PTR [rbp-0x18],0xfffffffd
    2044: c7 45 ec 01 00 00 00  mov DWORD PTR [rbp-0x14],0x1
    2179: e8 5e fd ff ff        call   1edc <turn_in>
    218a: e8 7a f0 ff ff        call   1209 <reset>
    2198: e8 95 f0 ff ff        call   1232 <rotate>
    21a6: e8 ed f0 ff ff        call   1298 <increment>
    21b4: e8 0c f1 ff ff        call   12c5 <add_sub>
    21c2: e8 33 f1 ff ff        call   12fa <modulo>
    21d0: e8 8d f1 ff ff        call   1362 <swap>
    21de: e8 b1 f1 ff ff        call   1394 <bitshift>
    21ec: e8 de f1 ff ff        call   13cf <flip_sign>

To understand the intended puzzle, I disassembled each action handler. That established the state machine: rotate shifts the eight integers, increment and add/sub tweak selected positions, modulo truncates state[0] / 5 and reduces state[6], swap exchanges slots 0 and 5, bitshift doubles state[1] and shifts state[7], and flip_sign negates slots 0 and 2. That was enough to see there probably really were two routes, just like the challenge note said: either solve the menu puzzle or bypass it by reversing the reward routine directly.

objdump -d -Mintel ./brokenquest --disassemble=rotate --disassemble=increment --disassemble=add_sub --disassemble=modulo --disassemble=swap --disassemble=bitshift --disassemble=flip_sign --disassemble=char_clamp
rotate: cyclic right rotate of all 8 ints
increment: state[0] += 1; state[4] += 1
add_sub: state[0] += 3; state[3] -= 2
modulo: state[0] = trunc(state[0]/5); state[6] = state[6] % 5
swap: swap state[0] and state[5]
bitshift: state[1] *= 2; state[7] = arithmetic_shift_right(state[7],1)
flip_sign: state[0] = -state[0]; state[2] = -state[2]
char_clamp: wrap to signed 8-bit style range

The actual flag generation lived in handle_flag, which pulls bytes from .rodata and feeds the recovered state through calc_val and transform. Dumping .rodata exposed the encoded byte material starting at 0x3058, and inspecting calc_val made the transformation pipeline reproducible in Python.

objdump -s -j .rodata ./brokenquest
0x3058 bytes:
a4 76 30 58 6b fd 60 67 06 7d 31 21 32 68 20 24
69 2c 87 5d 80 0e 3d 30 26 78 bd 9c 9e 89 77 a4
3f 6a 83 ca 8d 51 65 7b e7 65 ff 57 cd 51
objdump -d -Mintel ./brokenquest --disassemble=calc_val
calc_val starts with state[idx] as a byte, then for 7 rounds uses operation selectors from the permuted control array to call op0-op7 with the next state element as argument.

Operation summary:
op0(c,a) = wrap8(c + a + 0x30)
op1(c,a) = wrap8(c*a) if both nonzero else special-case fallback using a*a / c*a / 0
op2(c,a) = wrap8(c + 1)
op3(c,a) = wrap8(c + a)
op4(c,a) = wrap8(c % a) if a != 0 else c
op5(c,a) = wrap8(c - a)
op6(c,a) = wrap8(c << abs(a))
op7(c,a) = wrap8(c - a)

At that point the cleanest route was to reimplement the transformation exactly and feed it the target state from main. The script below uses the recovered permutations, segment lengths, key offsets, and operator behavior straight from the disassembly. Once that was in place, the binary’s reward logic collapsed into a deterministic decoder.

TARGET = (2, 6, -4, 6, 0, 4, -3, 1)
perm = [
    [5, 3, 0, 7, 1, 6, 4, 2],
    [3, 6, 2, 0, 1, 4, 5, 7],
    [4, 5, 1, 3, 6, 0, 2, 7],
    [3, 2, 5, 1, 4, 0, 6, 7],
    [7, 4, 6, 1, 0, 3, 2, 5],
    [4, 3, 0, 5, 6, 7, 1, 2],
    [6, 0, 3, 2, 1, 7, 4, 5],
    [5, 4, 2, 7, 3, 0, 6, 1],
]
key = bytes.fromhex('a47630586bfd6067067d312132682024692c875d800e3d302678bd9c9e8977a43f6a83ca8d51657be765ff57cd51')
lengths = [9, 5, 6, 5, 5, 3, 7, 6]
keyoffs = [0x25, 0x20, 0x1A, 0x15, 0x10, 0x0D, 0x06, 0x00]
outoffs = [0, 9, 14, 20, 25, 30, 33, 40]

def wrap8(x):
    x &= 0xFF
    return x - 256 if x >= 128 else x

def c_mod(a, b):
    return a - int(a / b) * b

def op0(c, a): return wrap8(c + a + 0x30)
def op1(c, a):
    if c != 0 and a != 0:
        return wrap8(c * a)
    if a != 0:
        return wrap8(a * a)
    if c != 0:
        return wrap8(c * a)
    return 0
def op2(c, a): return wrap8(c + 1)
def op3(c, a): return wrap8(c + a)
def op4(c, a): return wrap8(c_mod(c, a) if a != 0 else c)
def op5(c, a): return wrap8(c - a)
def op6(c, a): return wrap8(c << abs(a))
def op7(c, a): return wrap8(c - a)
ops = [op0, op1, op2, op3, op4, op5, op6, op7]

def calc_val(ctrl, state, idx):
    c = wrap8(state[idx])
    for j in range(7):
        pos = (idx + j) & 7
        opn = ctrl[pos]
        arg = state[(pos + 1) & 7]
        c = ops[opn](c, arg)
    return c & 0xFF

def flag_from_state(state):
    out = bytearray(46)
    for row, length, ko, oo in zip(perm, lengths, keyoffs, outoffs):
        st = [state[i] for i in row]
        for i in range(length):
            idx = row[i & 7]
            out[oo + i] = calc_val(row, st, idx) ^ key[ko + i]
    return bytes(out)

print(flag_from_state(TARGET).decode())
python solve.py
texsaw{1t_ju5t_work5_m0r3_l1k3_!t_d0e5nt_w0rk}

That already gave the flag, but the challenge note promised a second route, so I verified the in-binary path too. Breaking at turn_in and patching the eight integers at $rdi to the recovered target state made the original executable print the exact same reward string. That confirmed the reverse engineering was correct and demonstrated the alternate solve path without having to derive a full menu-action sequence.

python -c "open('/tmp/bq_input.txt','w').write('0\n')" && gdb -q -batch ./brokenquest -ex 'set debuginfod enabled off' -ex 'break turn_in' -ex 'run < /tmp/bq_input.txt' -ex 'set {int}($rdi+0x0)=2' -ex 'set {int}($rdi+0x4)=6' -ex 'set {int}($rdi+0x8)=-4' -ex 'set {int}($rdi+0xc)=6' -ex 'set {int}($rdi+0x10)=0' -ex 'set {int}($rdi+0x14)=4' -ex 'set {int}($rdi+0x18)=-3' -ex 'set {int}($rdi+0x1c)=1' -ex 'continue'
Breakpoint 1, 0x0000555555555ee8 in turn_in ()
Interact with objects to advance your quest. Set all quest objective flags and turn in the quest to get the real flag.
Current Values: [ 0	0	0	0	0	0	0	0	]
Choose an action [0-8] to (probably) advance your quest:
...
You turned in the quest!

Here's your reward: texsaw{1t_ju5t_work5_m0r3_l1k3_!t_d0e5nt_w0rk}
[Inferior 1 (process 360494) exited normally]

Solution#

The solve used a direct reimplementation of handle_flag after recovering the target state from main.

# solve.py
TARGET = (2, 6, -4, 6, 0, 4, -3, 1)
perm = [
    [5, 3, 0, 7, 1, 6, 4, 2],
    [3, 6, 2, 0, 1, 4, 5, 7],
    [4, 5, 1, 3, 6, 0, 2, 7],
    [3, 2, 5, 1, 4, 0, 6, 7],
    [7, 4, 6, 1, 0, 3, 2, 5],
    [4, 3, 0, 5, 6, 7, 1, 2],
    [6, 0, 3, 2, 1, 7, 4, 5],
    [5, 4, 2, 7, 3, 0, 6, 1],
]
key = bytes.fromhex('a47630586bfd6067067d312132682024692c875d800e3d302678bd9c9e8977a43f6a83ca8d51657be765ff57cd51')
lengths = [9, 5, 6, 5, 5, 3, 7, 6]
keyoffs = [0x25, 0x20, 0x1A, 0x15, 0x10, 0x0D, 0x06, 0x00]
outoffs = [0, 9, 14, 20, 25, 30, 33, 40]

def wrap8(x):
    x &= 0xFF
    return x - 256 if x >= 128 else x

def c_mod(a, b):
    return a - int(a / b) * b

def op0(c, a): return wrap8(c + a + 0x30)
def op1(c, a):
    if c != 0 and a != 0:
        return wrap8(c * a)
    if a != 0:
        return wrap8(a * a)
    if c != 0:
        return wrap8(c * a)
    return 0
def op2(c, a): return wrap8(c + 1)
def op3(c, a): return wrap8(c + a)
def op4(c, a): return wrap8(c_mod(c, a) if a != 0 else c)
def op5(c, a): return wrap8(c - a)
def op6(c, a): return wrap8(c << abs(a))
def op7(c, a): return wrap8(c - a)
ops = [op0, op1, op2, op3, op4, op5, op6, op7]

def calc_val(ctrl, state, idx):
    c = wrap8(state[idx])
    for j in range(7):
        pos = (idx + j) & 7
        opn = ctrl[pos]
        arg = state[(pos + 1) & 7]
        c = ops[opn](c, arg)
    return c & 0xFF

def flag_from_state(state):
    out = bytearray(46)
    for row, length, ko, oo in zip(perm, lengths, keyoffs, outoffs):
        st = [state[i] for i in row]
        for i in range(length):
            idx = row[i & 7]
            out[oo + i] = calc_val(row, st, idx) ^ key[ko + i]
    return bytes(out)

print(flag_from_state(TARGET).decode())
python solve.py
texsaw{1t_ju5t_work5_m0r3_l1k3_!t_d0e5nt_w0rk}
TexSAW 2026 - Broken Quest - Reverse Engineering Writeup
https://blog.rei.my.id/posts/134/texsaw-2026-broken-quest-reverse-engineering-writeup/
Author
Reidho Satria
Published at
2026-03-30
License
CC BY-NC-SA 4.0