Challenge Name: rev_kpwhy
Category: Reversing
Difficulty: Easy-Medium
Author: rvsmvs
Challenge Description
Here at BrunnerCorp, we value performance almost as much as we value employee satisfaction. That’s why we’ve rolled out KPIman, our new tracker that assigns every employee a productivity score. Suspiciously, exactly one employee ID ever hit a perfect 100%. Find our model employee and keep your job.
We are handed kpiman, a small dynamically-linked C binary that reads an “employee ID” and grades it. Exactly one badge scores 100% and prints a promotion code — that code is the flag. We need to recover the one ID the checker accepts.
Approach
Step 1 — Triage
$ file kpiman
ELF 64-bit LSB executable, x86-64, dynamically linked, ... not stripped
Not stripped, built from kpimeter.c. nm/strings reveal the whole cast:
calculateSynergy measureVelocity assessAlignment # the three checks
kpi_alpha kpi_beta kpi_gamma synergy_table # constant tables
"Productivity: 100%%. Finally, someone who gets it."
"Promotion code: %s"
"Productivity: 0%%. Wrong badge length."
The binary needs GLIBC_2.34 and won’t run on the local box, so this is a pure static solve.
Step 2 — Read main
main reads a line with fgets, strips the newline via strcspn, then requires strlen(buf) == 0x2c (44) — anything else prints “Wrong badge length.” It then calls the three checkers and sums their return values:
score = calculateSynergy(buf) + measureVelocity(buf) + assessAlignment(buf);
if (score == 3) { printf("Productivity: 100%%...\n"); printf("Promotion code: %s\n", buf); }
Each checker returns 1 only if every byte in its slice matches, else 0. So a perfect score means all three constraint sets are satisfied simultaneously, and the badge that satisfies them is printed back verbatim as the promotion code. The 44 bytes are split into three contiguous ranges: [0..14], [15..29], [30..43].
Step 3 — Invert each checker
calculateSynergy — bytes 0..14 (XOR with a per-index constant):
eax = i<<3 - i + 0x2a ; = 7*i + 42
cl = buf[i] ^ (al) ; masked to a byte
cmp cl, kpi_alpha[i]
⇒ buf[i] = kpi_alpha[i] ^ ((7*i + 42) & 0xff), directly solvable.
measureVelocity — bytes 15..29 (running-sum chain against an int32 array):
ecx = buf[i] + buf[i-1]
cmp ecx, kpi_beta[i-15] ; kpi_beta is a DWORD array (lea rax*4)
⇒ buf[i] = kpi_beta[i-15] - buf[i-1]. buf[14] is already fixed by stage 1, so the chain unrolls forward deterministically.
assessAlignment — bytes 30..43 (substitution table):
dl = synergy_table[buf[i]]
cmp dl, kpi_gamma[i-30]
⇒ we need synergy_table[buf[i]] == kpi_gamma[i-30], i.e. invert the 256-entry synergy_table. Picking the printable pre-image for each target byte resolves every position uniquely.
Step 4 — Pull the tables and solve
All four tables live in .rodata (vaddr 0x402000 → file offset 0x2000): kpi_alpha @ 0x402020 (15 bytes), kpi_beta @ 0x402040 (15×int32), kpi_gamma @ 0x402080 (14 bytes), synergy_table @ 0x4020a0 (256 bytes).
import struct
data = open("kpiman","rb").read()
off = lambda v: 0x2000 + (v - 0x402000)
alpha = data[off(0x402020):off(0x402020)+15]
beta = list(struct.unpack("<15i", data[off(0x402040):off(0x402040)+60]))
gamma = data[off(0x402080):off(0x402080)+14]
table = data[off(0x4020a0):off(0x4020a0)+256]
buf = [0]*44
for i in range(15): # calculateSynergy
buf[i] = alpha[i] ^ ((7*i + 42) & 0xff)
for i in range(15, 30): # measureVelocity (chained)
buf[i] = beta[i-15] - buf[i-1]
inv = {}
for idx, val in enumerate(table): # assessAlignment (inverse LUT)
inv.setdefault(val, []).append(idx)
for i in range(30, 44):
cands = inv[gamma[i-30]]
buf[i] = next(c for c in cands if 0x20 <= c < 0x7f)
print(bytes(buf).decode())
Re-implementing the three checks against the recovered badge confirms synergy=1, velocity=1, alignment=1 (sum 3) — the exact 100% branch.
Flag
brunner{y0ur_kp1s_ar3_n0t_l00king_gr8_buddy}
Reflections and Learnings
- When you can’t run it, invert it: the binary needed
GLIBC_2.34and wouldn’t run on the box, so the whole solve was static. Each checker was a bijection to reverse, not a wall to brute-force — recognising that turns “I can’t execute it” into a non-issue. - Decompose the constraint: three independent slices (
[0..14],[15..29],[30..43]) meant three small inversions — an XOR against7*i+42, a running-sum chain seeded by a byte stage 1 had already fixed, and a 256-entry LUT inverted by picking the printable pre-image. Solve the easy stage first; it often unlocks the next. - Reuse the offset lambda: the same
.rodatavaddr→file-offset trick from Go Go Decompile pulled all four constant tables directly out of the file. - Re-implement to verify: re-running the three checks on the recovered badge and seeing
synergy=1, velocity=1, alignment=1confirmed the exact 100% branch before trusting the promotion code.