本文最近一次更新于 2 年 3 个月前,其中的内容很可能已经有所发展或是发生改变。
又是大半年没更新,或许我不得不承认,我写博客的热忱相比两年多前的确是在逐渐退却,思考了一下,原因或许可以归咎于如下:
- 在工作上能学到的东西、值得记录的东西变得越来越少,最近半年几乎都乏善可陈了;
- 在业余时间的精力分散给了其他事情,导致研究技术的时间变少了(业余时间的事情,后续有机会再详细讲讲)。
不过,最近在工作上遇到了一个有点意思的事,钻研了一下觉得值得写一篇文章来记录——于是屁颠屁颠地花了三个晚上时间完成了这 2022 年的第一篇博文(希望不要是唯一一篇博文)。
背景介绍
最近我们组要推出一个 DNS 防火墙产品——拦截黑名单域名并提供简单的查询分析功能。我主要是负责实现拦截功能,具体的工作是写一个插件让 CoreDNS 可以实现 DNS Sinkhole 的功能,也就是针对(特定的客户端)访问特定的域名返回错误的结果,同时将解析的结果输出到数据库保存下来以供后续分析。开发过程并不困难,反而是在开发完成后的测试阶段遇到了困难。
抽丝剥茧
如果只是对拦截功能的测试,还比较容易解决——无非是多写几个单元测试用例,可要对整个 CoreDNS 的解析、拦截功能进行测试就会麻烦不少:完整的 DNS 请求涉及到网络、IO、转发请求等各个方面,不再是通过测试用例就可以覆盖的。因此我最初的想法是在另一台服务器上把默认的 DNS 服务器改为运行 CoreDNS 的服务器地址,再在这台服务器上批量运行 gethostbyname
函数来发出域名的解析请求,每一条的解析请求背后都意味着一套完整的 DNS 解析流程,因此可以较好地覆盖 CoreDNS 解析、拦截功能的测试。
不过这却引来了另一个问题,之前提到过,我们还需要把 CoreDNS 解析的 DNS 记录保存下来供后续分析使用,如果所有的 DNS 请求来源都是在另一台设备上,那么所有 DNS 记录的源 IP 都会是同一个,虽然这对功能测试以及性能测试没有影响,但在产品的实际展示效果以及产品体验上会大打折扣。
因此,搞来一批 DNS 流量并想办法让流量流经 CoreDNS 的服务器并保持源 IP 不变,问题就能得到解决了。
声明:文中出现的所有 IP 协议谨代表 IPv4 协议,暂未进行 IPv6 协议测试
一些「头脑风暴」
思考了一阵,我冒出了一个有点不太靠谱想法:要是能把公司内网的 DNS 流量全都指向 CoreDNS 就好了——这对我们来说是最省事的,可显然是不符合实际的,先不说公司的其他部门会不会同意,我们是以测试 CoreDNS 为目的,而测试的过程是不稳定的:如果 CoreDNS 挂掉了那意味着全公司的员工都无法上网了…这损失我们显然承担不起。
于是我想到了另一个方法:既然不能改公司的原始 DNS 流量,那么我把原始流量通过 Tcpdump 导出成 Pcap 文件,然后把 Pcap 文件本身包含的 DNS 记录请求的地址改成 CoreDNS 服务器的地址,再通过类似 Tcpreplay 的手动重放不就可以了吗?
乍一看,这似乎是比较好的解决方法,然而在实际验证过程中发现此法也行不通:原因在于 Tcpreplay 只会把 Pcap 文件里的数据重放在对应的网卡上,而并不会实际在网卡上创建 TCP 连接。这也比较好理解,毕竟我在机器 A 上抓包与机器 B 通信数据生成的 Pcap 文件,再通过机器 C 上的 Tcpreplay 重放,这并不可能重新让机器 A 和机器 B 建立相同的连接。
伪造 DNS 请求
虽然 Tcpreplay 的方式最终被验证是行不通的,但也算给了我一些启发:DNS 是建立在传输层之上的协议,我可以自行构造一份 DNS 协议的报文,再通过传输层协议(通常是 UDP)发送到 CoreDNS 的 53 端口,这样应当就能解决 Tcpreplay 无法产生真实连接的问题。
我这里是使用的 Scapy 工具来解析出 Pcap 文件中的 DNS 协议,Scapy 可以调用 iPython 解释器用于调试,比较方便:
>>> records = rdpcap("./dns.pcap")
>>> records[0][DNS]
<DNS id="31895" qr="0" opcode="QUERY" aa="0" tc="0" rd="1" ra="0" z="0" ad="1" cd="0" rcode="ok" qdcount="1" ancount="0" nscount="0" arcount="1" qd="<DNSQR" qname="baidu.com." qtype="A" qclass="IN" |=""> an=None ns=None ar=<DNSRROPT rrname="." type="OPT" rclass="4096" extrcode="0" version="0" z="0" rdlen="None" |=""> |>
>>> records[0][DNS].qname
>>> UDPClientSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
>>> UDPClientSocket.sendto(raw(records[0][DNS]), ("127.0.0.1", 53))
38
>>> records[0][DNS].qd.qname = b'www.zhihu.com.' # 我们可以尝试修改 DNS 协议的 qname
>>> UDPClientSocket.sendto(raw(records[0][DNS]), ("127.0.0.1", 53))
42</DNSRROPT></DNS>
这时,在运行 CoreDNS 的终端就能看到两条日志输出了:
[INFO] 127.0.0.1:61730 - 31895 "A IN baidu.com. udp 38 false 4096" NOERROR qr,rd,ra 88 0.041027042s
[INFO] 127.0.0.1:61730 - 31895 "A IN www.zhihu.com. udp 42 false 4096" NOERROR qr,rd,ra 256 0.069857333s
这意味着 CoreDNS 能成功接收到我们伪造并发送的 DNS 解析请求(Yes!终于向着解决问题迈出了一步)。不过观察到 CoreDNS 的地址还是显示请求是来自 127.0.0.1——因为我们是通过 127.0.0.1 发起的连接,但我们的目标正是想伪造这个发起连接的地址,让它不能显示成实际发送 UDP 数据包的机器。不过这并不是 DNS 协议能够做到的事了,注意看上一个代码块,DNS 协议本身是不具备目标 IP、端口等连接信息的,这是更底层的协议做的事情。
因此我们需要尝试伪造更底层的协议。
更进一步——UDP 首部
这里我再次恶补了一下计算机网络的知识,因为之前对计算机网络协议的使用都是到传输层便戛然而止,更低的 IP 层以及数据链路层则完全没有接触过了。而为了构造更底层的协议,就不能直接使用系统的套接字了——而需要使用 Raw Socket(原始套接字)。
扫盲时间:无论我们创建流套接字(通常是 TCP 传输)或是数据报套接字(UDP 传输),都需要指定目的地址和目的端口,因为前者是网络层所需要确定的,后者是传输层所需要确定的;而我们创建的数据报或是流套接字并没有让我们去构造传输层以及网络层的首部,因为这些都由操作系统帮我们完成了。而我们现在想要自己构造网络层以及传输层的首部,因此便不能使用流套接字或数据包套接字了。
>>> RawSock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_UDP)
>>> records[0][UDP]
<UDP sport="59503" dport="domain" len="46" chksum="0x1b86" |="" ...=""> # dport 是 domain 意味着 53
>>> records[0][UDP].sport=10000 # 将 sport 改成 10000
>>> del records[0][UDP].chksum # UDP 首部有改变,chksum 也会变,直接删掉此字段
>>> RawSock.sendto(raw(records[0][UDP]), ('127.0.0.1', 0)) # 虽然我们删掉了 chksum,但 raw 函数会重新生成 chksum
46</UDP>
特别需要注意的是,因为我们构造的 UDP 首部本身是包含着 sport 和 dport 的,因此我们在发送的时候的目标端口直接填写 0 即可。
CoreDNS 成功打印出如下日志,这意味构建 UDP 的首部也搞定了,离成功更近了一步:
[INFO] 127.0.0.1:10000 - 31895 "A IN baidu.com. udp 38 false 4096" NOERROR qr,rd,ra 88 0.070468417s
更近两步——IP 首部
虽然使用原始套接字能让我们成功伪造 UDP 首部,即更改源端口为任意数,但其实源端口的改动并不重要。我需要改动的是源地址,而源地址的改动则涉及到了 IP 协议的首部。
花费了一番功夫,发现了 IP_HDRINCL
这个原始套接字的选项,当创建的原始套接字是 IPPROTO_UDP
或者是 IPPROTO_TCP
时,此选项值默认填充为 0,此时待发送的数据包在流经 IP 层时会自动加上 IP 的首部,而当手动设置了此选项为 1 或者创建的原始套接字的类型是 IPPROTO_RAW
的时候,则不会自动加上 IP 的首部,也就是需要在发送的数据包上手动构造 IP 首部:
以下测试代码只在 Linux 下测试过,macOS 无法通过测试。
>>> records[0][IP]
<IP version="4" ihl="5" tos="0x0" len="66" id="49309" flags="frag=0" ttl="64" proto="udp" chksum="0x9ec7" src="10.6.3.182" dst="10.6.3.133" |="" ...="">
>>> del records[0][IP].chksum # 因为 IP 的 payload 有所更改,因此 chksum 肯定有变化,可以直接删掉它
>>> records[0][IP].dst='10.6.3.33' # 更改 dst 为 CoreDNS 监听的地址, 但不能是 127.0.0.1
>>> RawIPSock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
>>> RawIPSock.sendto(raw(records[0][IP]), ('', 0)) # 因为包含了 IP 首部,因此目标 IP 地址也可不用填</IP>
之所以 IP 首部的 dst 字段不能填写 127.0.0.1 的地址,是因为 127.0.0.1 是本地回环的地址,本地回环的连接是不可能从其他的 IP 发起的,所以如果这里的 src 是 10.6.3.182 而 dst 是 127.0.0.1 的话,响应的数据包因为找不到发送方会直接被丢弃掉(可以用 tcpdump 来验证)!所以这里的 dst 需要填写本机的网络地址,如果是网络地址找不到发送方数据报会被发到网关,虽然到了网关也会被丢弃,但数据包已经从本机传输出去了(也能通过 tcpdump 来验证)
CoreDNS 成功打印出如下日志:
[INFO] 10.6.3.182:10000 - 31895 "A IN baidu.com. udp 38 false 4096" NOERROR qr,aa,rd 85 0.000099594s
IP 首部的构造也已经完成,无论通过 CoreDNS 的日志或是 tcpdump 均可以看到发送请求的 IP 的确是被我们篡改了。
问题已经基本得到解决了。
更进三步——Ethernet 首部
之所以还想着篡改 Ethernet 的首部,是因为只改 IP 首部还是有些限制:只能发送不经网卡的数据。这意味着我在解析 Pcap 之后只能发送到本机,也就是说我只能在运行 CoreDNS 的设备上进行解析 Pcap 测试,大多数情况下也不是什么大问题,可当进行压力或是性能测试的时候,解析 Pcap 也是会占用不少资源的,这样一来 CoreDNS 的性能测试就会有失偏颇。
很自然地,我想到了可以通过篡改 Ethernet 首部,把发送方挪到另一台机器上:
>>> EtherSock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW) # 注意第一个参数,是 AF_PACKET
>>> EtherSock.bind(('eth0', 0)) # 这表示我们通过哪个网卡发送数据包
>>> records[0].dst = 'xx:xx:xx:xx:xx:xx' # 目的机器的网卡的 mac 地址
>>> records[0].src = 'xx:xx:xx:xx:xx:xx' # 刚刚绑定的网卡的 mac 地址
>>> EtherSock.send(raw(records[0]))
不过此方法同样有限制:需要保证 Ethernet 的 src 和 dst 处于用一个局域网内,这两者的通信不能通过网关。
一旦需要通过网关,那么 Ethernet 的 dst 字段就不能是目的机器的 mac 地址了,而应该是网关的 mac 地址。而为了安全考虑,绝大部分的网关都会选择开启入口过滤, 即网关在收到 Ethernet 数据包的时候,会拆开 Ethernet 首部,去查看 IP 首部的 src 和 dst,如果查看到 src 不属于本网段,那么这个 DNS 请求会被直接丢弃。
在通过 Python 验证了此法的可行之后,我选择了使用 Golang 来实际写解析的程序,因为 Python 的性能实在太差——用 Scapy 打开一个一百多兆的 Pcap 文件结果把我电脑的内存占用完毕了还没有加载成功……
另外,出于兼容性考虑,但我在解析程序的发送端上没有选择使用原始套接字,而是使用了 gopacket——反正解析 Pcap 也是需要用到 gopacket,发送端也干脆用它,这样 macOS 上也可以运行了。
至此,本次 CoreDNS 测试所遇到的问题,总算都圆满解决了。
IP 欺骗攻击
问题已经解决了,不过在研究过程中还发现了一些可以值得说道的东西:既然可以通过构建 IP 首部来伪造请求的发送方,那么就意味着攻击方可以伪造成不属于本机的请求,而被攻击方也没办法通过把发送方 IP 加入黑名单来抵御——因为发送方 IP 可以任意变化(这种手法经常用在 DoS 攻击里)。
这听起来好像有点无赖,不过刚刚也提到了 IP 欺骗的两个弱点:
- 大部分的网关都开启了入口过滤,发送方的 IP 如果不属于该网关覆盖的范围内,那么是发不出去的;
- 即使发出去了,因为 src 地址的改变,响应包也不会回到发送方,这意味着 TCP 协议是可以天然防御 IP 欺骗的,因为 TCP 在握手的时候序列号是与远程机器协商的。
这两者都极大的限制了 IP 欺骗的使用场景,思来想去,DNS 协议似乎是最适合 IP 欺骗的攻击了……
最后,列出一些在本次研究过程中,遇到的一些好用的工具:
- GoPacket:Golang 实现的高性能的解析 Pcap 发送 Ethernet 数据包的工具
- Scapy:Python 实现的数据包处理攻击,是 gopacket 的超集,可直接发送 IP 层的数据包,易用性、可调试性秒杀 gopacket,性能被 gopacket 秒杀
- Tcpdump:网卡抓包工具
- Tcpreplay、Tcpliveplay:pcap 回放工具,前者只是回放数据包不创建连接,后者可以真实建立连接。