100libs Hurt
100libs Hurt
Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: instruction_emulation, objdump_disassembly, per_byte_brute_force, single_byte_independence
Summary
Walkthrough of the CUSTOM Reversing 100libs Hurt. Task: an x86-64 PIE loads 100 stripped shared objects via dlopen/dlsym; each verifyN validates exactly one input byte with an affine+xor+multiply chain compared to a constant, and all must return 1 to
Description
100libs Hurt — make the binary print
YES.Files:
100libs_hurt.tar.gzcontaining100libs.elfandlibs/verify0.so..libs/verify99.so.
An x86-64 PIE reads a string and passes it to a check function which loads 100
stripped shared-object "plugins" and ANDs together their return values. The goal
is to find the input that makes every plugin return 1, so the program prints
YES.
Recon
The archive extracts to:
100libs.elf— ELF 64-bit LSB PIE, x86-64, not stripped.libs/verify0.so..libs/verify99.so— 100 stripped x86-64 shared objects.
Each verifyN.so exports a symbol verifyN.
Analysis — main and check
main (at 0x208e):
- Reads input with
__isoc99_scanf("%255s", buf)into a stack buffer. - Calls
check(buf). - If
checkreturns non-zero →puts("YES"), elseputs("no"). The strings"YES","no"and format"%255s"sit in.rodatanear0x2ba8.
check (at 0x7da) is a long unrolled sequence. For each N in 0..99:
handle = dlopen("libs/verifyN.so", RTLD_LAZY)
verifyN = dlsym(handle, "verifyN") ; stored in a global
ret = verifyN(0x539, input) ; 0x539 == 1337 is a decoy/unused arg
result &= ret ; result initialized to 1
So all 100 verify functions must return 1 for the program to print YES.
Analysis — each verifyN
Every verifyN inspects exactly one byte input[N]. It advances the input
pointer by N (addq $N, %rax) then loads a signed byte
(movzbl (%rax) / movsbl %al) and runs an obfuscated arithmetic chain of the
shape:
c = (signed char) input[N]
a = c + A ; addl imm (sometimes leal A(%rax),%edx, or subl $-0x80 == +0x80)
a = a * B ; imull imm (or shl/add combos: shl6+add = *65, shl6-sub = *63)
a = a ^ C ; xorl imm
a = a * D ; imull imm (or shift/add/lea combos)
return (a == TARGET) ? 1 : 0 ; cmpl against a per-library constant
Constants A, B, C, D, TARGET differ per library. Concrete examples:
- verify0 (
input[0]):d = c+0x2ad; a = d*65; a ^= 0x1226a; a = a*63; cmp 0x786936(65 = shl6+add,63 = shl6-sub) - verify1 (
input[1]):a = (c+0x2bf); a *= 0x2b5; a ^= 0x2cfc; a = a*13; cmp 0x71141b(13built as(a*3)*4 + a) - verify99 (
input[99]):a = (c+0x2b7); a *= 0x5b; a ^= 0x7fcd; a = a*45; cmp 0x37d859(45 = ((a*5)*8) + a)
Key insight: each library's return value depends only on ONE byte, which has only 256 possible values. So each character of the flag can be brute-forced independently — no cross-byte constraints, no need to invert the arithmetic.
Solution — emulate each verifyN and brute-force the byte
The libraries are x86-64 ELF but the solving host was arm64 macOS, so they could
not be dlopen'd natively. Instead, the tiny arithmetic of each verifyN was
emulated in Python directly from objdump output. For each library:
- Disassemble with
objdump -d libs/verifyN.so, extract the instruction body between the<verifyN>:label and the following_fini. - Parse the instructions and emulate a 32-bit
eax/edxmachine over all 256 candidate byte values. - Find the byte whose emulated result equals the
cmplTARGET — that isinput[N].
Instruction forms that had to be handled:
addl/subl/imull/xorl/shll/movl, leal imm(%rax),%reg,
leal (,%rax,k),%reg (multiply by k), addl %eax,%eax (doubling),
addl %edx,%eax, and subl $-0x80,%eax (which is +0x80).
A first run solved 90/100; the remaining 10 used the leal (,%rax,k)
multiply-by-8/4 form and the subl $-0x80 add form, which were then added to the
emulator, solving all 100. Concatenating the 100 recovered bytes yields a
100-character flag (one char per library).
python read-only • copy allowed#!/usr/bin/env python3 # Compact per-library emulator: brute-force each byte 0..255 against verifyN. import re, subprocess MASK = 0xFFFFFFFF def sx32(v): v &= MASK return v - (1 << 32) if v & 0x80000000 else v def disasm_body(path, name): out = subprocess.check_output(["objdump", "-d", path], text=True) lines, grab = [], False for ln in out.splitlines(): if re.search(rf"<{name}>:", ln): grab = True; continue if grab: if "<_fini>:" in ln or re.match(r"^[0-9a-f]+ <", ln): break m = re.match(r"\s*[0-9a-f]+:\s+(?:[0-9a-f]{2} )+\s*(\S.*)$", ln) if m: lines.append(m.group(1).strip()) return lines def run(body, c): # 32-bit machine: 'a' == eax, 'd' == edx. c is signed char input[N]. reg = {"eax": sx32(c) & MASK, "edx": 0} def imm(t): return int(t.replace("$", ""), 0) & MASK for ins in body: op, _, rest = ins.partition(" ") rest = rest.strip() args = [a.strip() for a in rest.split(",")] if rest else [] def R(x): return x.lstrip("%") if op == "movl": src, dst = args reg[R(dst)] = imm(src) if src.startswith("$") else reg[R(src)] elif op == "addl": src, dst = args v = imm(src) if src.startswith("$") else reg[R(src)] reg[R(dst)] = (reg[R(dst)] + v) & MASK elif op == "subl": # subl $-0x80 == +0x80 src, dst = args v = sx32(imm(src)) if src.startswith("$") else reg[R(src)] reg[R(dst)] = (reg[R(dst)] - v) & MASK elif op == "xorl": src, dst = args reg[R(dst)] ^= imm(src); reg[R(dst)] &= MASK elif op == "imull": src, dst = args reg[R(dst)] = (sx32(reg[R(dst)]) * sx32(imm(src))) & MASK elif op == "shll": src, dst = args reg[R(dst)] = (reg[R(dst)] << imm(src)) & MASK elif op == "leal": # leal A(%rax),%edx -> edx = eax + A # leal (,%rax,k),%edx -> edx = eax * k mem, dst = args m = re.match(r"(-?0x[0-9a-f]+)?\(%rax(?:,%rax,(\d+))?\)", mem) base = int(m.group(1), 16) if m.group(1) else 0 k = int(m.group(2)) if m.group(2) else 1 reg[R(dst)] = (reg["eax"] * (k if m.group(2) else 1) + base) & MASK # movzbl/movsbl already reflected in initial eax; cmpl handled by caller return reg["eax"] & MASK def target(body): for ins in body: m = re.match(r"cmpl?\s+\$(0x[0-9a-f]+),%eax", ins) if m: return int(m.group(1), 16) & MASK return None flag = [] for n in range(100): body = disasm_body(f"libs/verify{n}.so", f"verify{n}") tgt = target(body) for cand in range(256): if run(body, cand if cand < 128 else cand - 256) == tgt: flag.append(cand); break print(bytes(flag).decode())
Running the emulator recovers all 100 bytes:
spbctf{REDACTED}
Feeding this to the binary prints YES.
Key Takeaways / Lessons
- Mastering instruction_emulation, objdump_disassembly, per_byte_brute_force, single_byte_independence was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-02 • CUSTOM • 100libs Hurt • Difficulty: Medium