KPWhy
- Category: rev
- Difficulty: Medium
kpiman (“BrunnerCorp KPIman v3.1”) reads an employee ID from stdin and runs it through three checks. Pass all three and it prints a promotion code — the flag.
Solution:
1. Map the checks
main() reads a line into a buffer, strips the newline, and rejects anything that isn’t exactly 44 (0x2c) bytes. It then calls three validators and requires their return values to sum to 3:
if (strlen(&buf) == 0x2c) {
int32_t rax_7 = calculateSynergy(&buf);
int32_t rax_8 = measureVelocity(&buf);
int32_t rax_12 = assessAlignment(&buf) + rax_7 + rax_8;
if (rax_12 == 3)
printf("Promotion code: %s\n", &buf);
}
Each validator loops over its byte range and breaks out to return 0 on the first mismatch — if the loop runs to completion instead, it falls through to return 1. So the fallthrough path is success, not the break path — easy to get backwards.
| Function | Bytes checked | Check type |
|---|---|---|
calculateSynergy |
buf[0..14] (15 bytes) |
XOR against kpi_alpha[] with a per-index key |
measureVelocity |
buf[15..29] (15 bytes) |
adjacent-byte sum against kpi_beta[] |
assessAlignment |
buf[30..43] (14 bytes) |
substitution through synergy_table[] against kpi_gamma[] |
Each stage covers a disjoint slice, so all three can be solved independently and concatenated.
2. Invert stage 1 — XOR
buf[i] ^ ((i << 3) - i + 0x2a) == kpi_alpha[i]
XOR is its own inverse: buf[i] = kpi_alpha[i] ^ ((i << 3) - i + 0x2a).
3. Invert stage 2 — additive chain
buf[i] + buf[i-1] == kpi_beta[i-15] // mod 256
Seeded by buf[14] from stage 1, solved forward one byte at a time: buf[i] = kpi_beta[i-15] - buf[i-1] (mod 256).
4. Invert stage 3 — substitution table
synergy_table[buf[i]] == kpi_gamma[i-30]
Built the inverse mapping (output byte -> input byte) by scanning synergy_table once. It turned out to be injective across the 14 needed targets — exactly one candidate per position, no branching needed.
5. Extract constants and solve
Pulled kpi_alpha, kpi_beta, kpi_gamma, and synergy_table from objdump -s -j .rodata kpiman, then ran all three inversions:
kpi_alpha = bytes.fromhex("48434d5128282620" "1b590505" "21eefc")[:15]
kpi_beta = [0xa1,0xa4,0xd2,0xc0,0xd3,0xa5,0x92,0xcd,
0x9e,0xa4,0xd3,0xcb,0x9c,0x60,0x9b]
kpi_gamma = bytes.fromhex("d3c06bee6bea6dee5d08dcdc8390")
synergy_table = bytes.fromhex("<256 bytes from .rodata>")
buf = bytearray(44)
# Stage 1: XOR inversion
for i in range(15):
key = ((i << 3) - i + 0x2a) & 0xFF
buf[i] = kpi_alpha[i] ^ key
# Stage 2: additive chain
for i in range(15, 30):
buf[i] = (kpi_beta[i - 15] - buf[i - 1]) & 0xFF
# Stage 3: table inversion
inv = {}
for x in range(256):
inv.setdefault(synergy_table[x], []).append(x)
for i in range(30, 44):
buf[i] = inv[kpi_gamma[i - 30]][0]
print(bytes(buf).decode())
brunner{y0ur_kp1s_ar3_n0t_l00king_gr8_buddy}
6. Bonus — the angr route
Manual inversion was faster, but this is also a legitimate angr target with two real gotchas:
fgetsmust be hooked correctly. ASimProcedurewriting the 44 symbolic bytes needs to also leave the\nand a trailingNUL, matching what realfgetsdoes — skip the NUL andstrlen/strcspnrun into uninitialized memory, so thestrlen(buf) == 0x2cgate fails deterministically before the solver gets a chance.- Path explosion. 44 chained per-byte comparisons across three loops means angr forks ~2^44 states by default.
angr.exploration_techniques.Veritesting()merges branches back at each block boundary — without it the process gets OOM-killed before reaching the target.
import angr, claripy
proj = angr.Project("./kpiman", auto_load_libs=False)
BUF_LEN = 44
sym_bytes = [claripy.BVS(f"b{i}", 8) for i in range(BUF_LEN)]
class FakeFgets(angr.SimProcedure):
def run(self, s, size, stream):
for i, b in enumerate(sym_bytes):
self.state.memory.store(s + i, b)
self.state.memory.store(s + BUF_LEN, claripy.BVV(0x0A, 8)) # '\n'
self.state.memory.store(s + BUF_LEN + 1, claripy.BVV(0x00, 8)) # NUL
return s
class FakeStrcspn(angr.SimProcedure):
def run(self, s, reject):
return BUF_LEN # the newline we placed is always the first match
proj.hook_symbol("fgets", FakeFgets())
proj.hook_symbol("strcspn", FakeStrcspn())
state = proj.factory.entry_state(add_options={
angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS,
})
for b in sym_bytes:
state.solver.add(b >= 0x20, b <= 0x7E) # printable ASCII only
simgr = proj.factory.simulation_manager(state)
simgr.use_technique(angr.exploration_techniques.Veritesting())
FOUND_ADDR = 0x4013d3 # "Promotion code: %s\n" call site
AVOID_ADDRS = [0x401369, 0x401419] # the two failure printf sites
simgr.explore(find=FOUND_ADDR, avoid=AVOID_ADDRS)
found = simgr.found[0]
print("Flag:", found.solver.eval(claripy.Concat(*sym_bytes), cast_to=bytes))
With both fixes in place this recovers the same flag directly via simgr.found — worth reaching for whenever synergy_table-style checks turn out not to be a clean bijection, where manual inversion would need to branch on multiple candidates per position.
Flag: brunner{y0ur_kp1s_ar3_n0t_l00king_gr8_buddy}