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 mainThe 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_in0000000000001edc <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=main0000000000001fe5 <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_clamprotate: 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 rangeThe 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 ./brokenquest0x3058 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 51objdump -d -Mintel ./brokenquest --disassemble=calc_valcalc_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.pytexsaw{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.pytexsaw{1t_ju5t_work5_m0r3_l1k3_!t_d0e5nt_w0rk}