Skip to content
Suzu
6 min read

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

A reverse writeup for DG-Server, covering the custom verifier and DNSSEC-like chain.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Reverse - DG-Server

I started from scripts/dg-verify.py.

The verifier is not normal DNSSEC tooling. It sends a simple HTTP request to /dns-query, parses text lines, and verifies a custom DNSSEC-like chain by itself.

DEFAULT_PORT = 57573
ROOT_KSK_PUBLIC_B64 = "0EsttfsYDwuEXUQUP7wel2Ph323yfcRMv2lwAYbm50E="

It also has its own Ed25519 verifier in Python. The signed message is not a real DNS wire format:

def rrsig_message(zone: str, sig: Rrsig, serial: int, material: str) -> bytes:
    message = (
        f"RRSIG|owner={sig.owner}|type={sig.covered}|ttl=3600|"
        f"expire={sig.expiration}|inception={sig.inception}|keytag={sig.key_tag}|"
        f"signer={zone}|serial={serial}|rr={material}"
    )
    return message.encode()

One small thing looked strange: the verifier only allows A, NS, and MX.

if rrtype not in {"A", "NS", "MX"}:
    raise ValueError("dg-verify only supports A, NS, and MX")

If the flag is in TXT, the official verifier will not ask for it.

Normal check first:

python3 scripts/dg-verify.py @chals1.ais3.org:53573 www.curious.sleeping A

It verifies the chain:

. -> sleeping. -> curious.sleeping.

The final record is only:

OK www.curious.sleeping. A 67.67.67.67

No flag there.

The server binary is stripped and static:

file dg-server
checksec --file=dg-server
ELF 64-bit, statically linked, stripped
Partial RELRO
No canary found
NX enabled
No PIE
SHSTK enabled
IBT enabled

At first, the protections made me think maybe it could be a pwn-style bug. But the remote surface is HTTP DNS query, and there was no crash trigger from normal requests. No input length bug showed up from the verifier path.

GDB did not give much. Running the server locally just stopped early:

gdb -q -batch -ex 'file dg-server' -ex 'run' --args ./dg-server
dg.conf: No such file or directory

The shipped bundle does not include the zone config. I stopped trying to run the local service. Static reversing and remote queries were more useful.

The first failed idea was guessing names.

curl -s 'http://chals1.ais3.org:53573/dns-query?name=flag.curious.sleeping.&type=TXT'

It returned NXDOMAIN, but the answer included a new record type:

NXDOMAIN flag.curious.sleeping. authoritative-zone=curious.sleeping.
H46HSBFKHOSNE792RC9U2UQ3RFHKIUGI.curious.sleeping. NSEC6 2e 426 0009 73311337 H46HSBFKHOSNE79FQM2ND3Q3RFHKIUGI A RRSIG NSEC6

NSEC6 is not normal DNSSEC. I first thought maybe it is just NSEC3 with another name, but the fields did not match. Also the salt 73311337 looked too CTF.

Some other names gave more useful intervals:

curl -s 'http://chals1.ais3.org:53573/dns-query?name=random.curious.sleeping.&type=A'
H46HSBFKHOSNE7FP2CD05BFU13HKIUGI.curious.sleeping. NSEC6 2e 426 0009 73311337 H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI TXT RRSIG NSEC6

The TXT bitmap says the next hidden name has a TXT record. Guessing names was not enough anymore; the next hash had to be reversed.

Using the token as a label also helped:

curl -s 'http://chals1.ais3.org:53573/dns-query?name=ctfd_97d5460a319c6e1ded9e2341317697ecc809dc5a87015ab22b0d897fdb9528b3.curious.sleeping.&type=TXT'

It returned:

H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI.curious.sleeping. NSEC6 2e 426 0009 73311337 S6NPJID2K4SNE7AB754D34I8IK3E8TKJ TXT RRSIG NSEC6

S6NPJID2K4SNE7AB754D34I8IK3E8TKJ should hide a TXT owner.

I searched strings in the binary:

strings -a -t x dg-server | rg 'NSEC6|73311337|RRSIG\|owner|dns-query'

Useful strings:

3cb3b9 73311337
3cb480 RRSIG|owner=%s|type=%s|ttl=3600|expire=20300101000000|inception=20260513000000|keytag=%u|signer=%s|serial=%u|rr=%s
3cb5c8 %s NSEC6 %02x %02x %04x %s %s %s
3cb611 NSEC6
3cb6d0 NXDOMAIN %s authoritative-zone=%s

The binary uses printf format strings, but this was not a glibc printf exploit. The strings just marked the custom DNS code. No glibc/printf attack was needed here.

Radare2 xrefs and objdump gave the useful functions:

r2 -A -q -c 'axt @ 0x7cb5c8' dg-server
r2 -A -q -c 'axt @ 0x7cb3b9' dg-server

The important code areas:

0x4053ef  build NSEC6 zone key state
0x405a23  compute NSEC6 owner hash
0x405909  base32 encode 20 bytes
0x406d91  print NSEC6 record

The base32 alphabet is not RFC4648:

0123456789ABCDEFGHIJKLMNOPQRSTUV

I reimplemented the NSEC6 code in Python. The weird part is the curious.sleeping. zone key state. For this zone, the first 20 bytes become all ff.

ffffffffffffffffffffffffffffffffffffffff7226efeef666f4e87fef3efc136c57f3ec92de2b

That makes the hash reversible enough. The code ORs the name hash with the first 20 key bytes. Since those bytes are all ff, the hash part becomes all ff. After that it adds the first DNS label in wire style and mutates the 20-byte value.

After reversing the final mutate step, adding 1 gives the first DNS label.

I tested known hashes first:

H46HSBFKHOSNE792RC9U2UQ3RFHKIUGI -> www
H46HSBFKHOSNE79FQM2ND3Q3RFHKIUGI -> mail
H46HSBFKHOSNE7FP2CD05BFU13HKIUGI -> _dmarc
H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI -> status

That matched the remote records. Good enough.

Decoding the suspicious TXT hash:

S6NPJID2K4SNE7AB754D34I8IK3E8TKJ -> azft0azxct7utcyw

Full solve script:

#!/usr/bin/env python3
import socket
from urllib.parse import quote


HOST = "chals1.ais3.org"
PORT = 53573
ZONE = "curious.sleeping."
TOKEN = "ctfd_97d5460a319c6e1ded9e2341317697ecc809dc5a87015ab22b0d897fdb9528b3"

ALPH = "0123456789ABCDEFGHIJKLMNOPQRSTUV"
KEY40 = bytes.fromhex(
    "ffffffffffffffffffffffffffffffffffffffff"
    "7226efeef666f4e87fef3efc136c57f3ec92de2b"
)


def q(name, rrtype):
    path = f"/dns-query?name={quote(name, safe='')}&type={quote(rrtype, safe='')}"
    req = (
        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()

    with socket.create_connection((HOST, PORT), timeout=5) as s:
        s.sendall(req)
        data = b""
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            data += chunk

    text = data.decode(errors="replace")
    if "\r\n\r\n" in text:
        text = text.split("\r\n\r\n", 1)[1]
    return [line.strip() for line in text.splitlines() if line.strip()]


def b32dec(s):
    acc = 0
    bits = 0
    out = bytearray()
    for ch in s:
        acc = (acc << 5) | ALPH.index(ch)
        bits += 5
        while bits >= 8:
            bits -= 8
            out.append((acc >> bits) & 0xff)
    return bytes(out)


def inv_mutate(data):
    b = bytearray(data)
    rounds = (9 & 7) + 8
    for r in range(rounds - 1, -1, -1):
        for i in range(20):
            x = (b[i] - (17 * r + i)) & 0xff
            x ^= KEY40[20 + ((r + i) % 20)]
            b[i] = x

        first = b[0]
        for i in range(19):
            b[i] = b[i + 1]
        b[19] = first
    return bytes(b)


def add1(data):
    b = bytearray(data)
    carry = 1
    for i in range(19, -1, -1):
        s = b[i] + carry
        b[i] = s & 0xff
        carry = s >> 8
        if not carry:
            break
    return bytes(b)


def decode_label(nsec6_hash):
    raw = add1(inv_mutate(b32dec(nsec6_hash)))
    n = raw[0]
    return raw[1:1+n].decode()


def find_txt_hash(lines):
    for line in lines:
        parts = line.split()
        if len(parts) >= 8 and parts[1] == "NSEC6" and "TXT" in parts[7:]:
            return parts[6]
    raise RuntimeError("no TXT NSEC6 hash found")


probe_name = f"{TOKEN}.{ZONE}"
lines = q(probe_name, "TXT")
for line in lines:
    print(line)

hidden_hash = find_txt_hash(lines)
label = decode_label(hidden_hash)
print(f"[+] decoded label: {label}")

txt_name = f"{label}.{ZONE}"
txt_lines = q(txt_name, "TXT")
for line in txt_lines:
    print(line)

Run:

python3 solve_nsec6.py

It decodes the hidden label:

[+] decoded label: azft0azxct7utcyw

Manual query:

curl -s 'http://chals1.ais3.org:53573/dns-query?name=azft0azxct7utcyw.curious.sleeping.&type=TXT'

Record:

azft0azxct7utcyw.curious.sleeping. TXT "AIS3{w4lking_0n_D0H_z0n3--NSEC...NSEC6!_666~~~}"

Flag:

AIS3{w4lking_0n_D0H_z0n3--NSEC...NSEC6!_666~~~}

Share this page