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