← Back to Writeups
CUSTOMMediumReversing

100libs Hurt

XESXOR9/2/20266 min read
#reversing#custom#medium#instruction_emulation#objdump_disassembly#per_byte_brute_force#single_byte_independence

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.gz containing 100libs.elf and libs/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 check returns non-zero → puts("YES"), else puts("no"). The strings "YES", "no" and format "%255s" sit in .rodata near 0x2ba8.

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 (13 built 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:

  1. Disassemble with objdump -d libs/verifyN.so, extract the instruction body between the <verifyN>: label and the following _fini.
  2. Parse the instructions and emulate a 32-bit eax/edx machine over all 256 candidate byte values.
  3. Find the byte whose emulated result equals the cmpl TARGET — that is input[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
#!/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