生成一份“热闹”的pcap
0x00 背景
如读者所见,本站近期正在连载《Suricata 源码阅读》系列文章。为了执行一些测试,笔者需要构造出一份热闹的pcap:有很多个客户端、很多个服务器,网络里充斥着 HTTP 请求和响应。
一个最直观的思路是,我们直接写代码生成这样的 pcap 文件。然而,这条路线非常复杂,想造出以假乱真的 TCP 流量记录,绝非易事:握手过程、断开过程、把 HTTP 报文拆分到各个 segment……这不是我们短期内能做到的事。
那么,我们考虑“先记录再替换”的方案——使用一个客户机向一个服务器发出大量请求,我们监听这些流量,获得一个 pcap;在这个 pcap 的基础上,修改各个报文的 src ip 和 dst ip,以伪造出熙熙攘攘的网络。这个方案比前一个方案合理,因为我们无需自行生成 TCP 流。然而,它也面临一个问题:“修改 ip”说起来简单,做起来难。改动了 ip 之后,我们需要维护各层的 checksum;另外,由于我们必须保证“同一个 TCP 连接的各个报文分到相同的 fake ip”,所以我们必须在代码中维护 TCP 连接的状态,至少也要维护四元组。于是,这份代码的复杂程度与自行实现 NAT 也不相上下了。自制 NAT 的方法可以参考这篇文章,很有意思。
沿着这个思路继续想。既然我们要做的事情本质上就是 NAT,何不使用现成的 NAT 技术?通过 SNAT 修改 src ip,通过 DNAT 修改 dst ip。网络结构如下:
- 客户端 ip 是
5.0.0.1
,对它而言,就是单个客户机向多个服务器7.*.*.*
发 http 请求。 - 客户侧路由器执行 SNAT,把客户端的 ip 伪装成
4.*.*.*
,传递给中间路由器。 - 中间路由器只做转发。它观察到的流量就是
4.*.*.*
中的多个客户端正在疯狂请求7.*.*.*
中的多个服务端。 - 服务侧路由器执行 DNAT,把发往
7.*.*.*
的请求全部转给8.0.0.1
服务器。 - 服务端 ip 是
8.0.0.1
,对它而言,就是4.*.*.*
中的多个客户机向自己发送请求。
于是,我们在中间路由器上抓包,获得的就是 4.*.*.*
客户端与 7.*.*.*
服务端之间的通讯。有多个客户端 ip、多个服务端 ip,完美且自然地解决了需求。
0x01 配置虚拟网卡
图中共有四条链路,我们在 PVE 中建立四个虚拟交换机:客户到客户侧路由器、客户侧路由器到中间路由器、中间路由器到服务侧路由器、服务侧路由器到服务器。
接下来添加网卡,把虚拟机接入对应的链路。按照之前的经验,我们使用 vmxnet3 而不是 virtio。由于笔者不希望全程使用 vnc 终端,故这五台虚拟机还需要额外接入管理网络。例如,客户侧路由器配置如下:
现在,虚拟网卡已经可用:
0x02 配置 ip 和路由表
默认情况下,Linux 不进行报文转发。我们修改三台路由器的 /etc/sysctl.conf
,启用转发功能:
使用 sysctl -p /etc/sysctl.conf; systemctl restart networking
使之生效。
现在,我们修改 /etc/network/interfaces
文件,给各个虚拟机分配静态 ip,构建路由表:
# client,所有流量走客户侧路由器(5.0.0.5)
allow-hotplug enp6s19
iface enp6s19 inet static
address 5.0.0.1/24
up ip route add 0.0.0.0/0 via 5.0.0.5
# router-client,5.0.0.0/24 连 client,其余流量连中间路由器(2.1.1.2)
allow-hotplug enp6s19
iface enp6s19 inet static
address 5.0.0.5/24
allow-hotplug enp6s20
iface enp6s20 inet static
address 2.1.1.1/24
up ip route add 0.0.0.0/0 via 2.1.1.2
# router-middle 中间路由器
allow-hotplug enp6s19
iface enp6s19 inet static
address 2.1.1.2/24
up ip route add 4.0.0.0/8 via 2.1.1.1
up ip route add 5.0.0.0/8 via 2.1.1.1
allow-hotplug enp6s20
iface enp6s20 inet static
address 2.1.2.1/24
up ip route add 7.0.0.0/8 via 2.1.2.2
up ip route add 8.0.0.0/8 via 2.1.2.2
# router-server,8.0.0.0/24 连 server,其余流量连中间路由器(2.1.2.1)
allow-hotplug enp6s19
iface enp6s19 inet static
address 2.1.2.2/24
up ip route add 0.0.0.0/0 via 2.1.2.1
allow-hotplug enp6s20
iface enp6s20 inet static
address 8.0.0.5/24
# server,所有流量走服务侧路由器(8.0.0.5)
allow-hotplug enp6s19
iface enp6s19 inet static
address 8.0.0.1/24
up ip route add 0.0.0.0/0 via 8.0.0.5
终于,我们在客户端 5.0.0.1
能 ping 通服务器 8.0.0.1
。traceroute 结果如下:
root@client:~# traceroute -n 8.0.0.1
traceroute to 8.0.0.1 (8.0.0.1), 30 hops max, 60 byte packets
1 5.0.0.5 0.571 ms 0.466 ms 0.424 ms
2 2.1.1.2 0.760 ms 0.722 ms 0.741 ms
3 2.1.2.2 1.189 ms 1.201 ms 1.309 ms
4 8.0.0.1 1.758 ms 1.753 ms 1.566 ms
现在网络已通,我们剩余的任务是配置 SNAT 和 DNAT。
0x03 配置 NAT
先来配置 DNAT,把所有发往 7.*.*.*
的 http 请求交给 8.0.0.1
服务器处理。在服务侧路由器上配置:
iptables -t nat -A PREROUTING -p tcp -d 7.0.0.0/8 -j DNAT --to-destination 8.0.0.1
于是,我们在客户端上请求任意 7.*.*.*
网站,都会由 8.0.0.1
服务器处理:
root@client:~# curl 7.1.2.4:8000 | head -n 1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 433 100 433 0 0 101k 0 --:--:-- --:--:-- --:--:-- 105k
<!DOCTYPE HTML>
root@client:~# curl 7.3.3.12:8000 | head -n 1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 433 100 433 0 0 97085 0 --:--:-- --:--:-- --:--:-- 105k
<!DOCTYPE HTML>
对服务器而言,所有的请求都是从 5.0.0.1
发起的:
接下来,在客户侧路由器配置 SNAT:
iptables -t nat -A POSTROUTING -p tcp -s 5.0.0.1 -j SNAT --to-source 4.0.0.0-4.255.255.255
此时,若客户端按照不同的服务端地址发起请求,则服务端收到的客户地址也不一样:
在中间路由器抓包,能见到不同的客户端 ip、服务端 ip:
0x04 服务端和客户端程序
用 fastapi 写个假服务器。它只有一个 api,提供“一言(hitokoto)”服务。语句库在 github 上,我们随便找几条句子。代码如下:
from fastapi import FastAPI
import random
app = FastAPI()
sentences = [
'面对就好,去经历就好。',
'我的腿让我停下,可是心却不允许我那么做。',
'像平常的你一样引发奇迹吧。'
]
@app.get("/hitokoto")
def hitokoto():
return {"message": random.choice(sentences)}
用 uvicorn 运行:
uvicorn app:app --host 0.0.0.0 --port 8000
现在编写客户端。任意 7.0.0.0/8
内的 ip 都是合法的服务端 ip,每次生成一个服务端 ip,交给线程池去访问。代码如下:
import random
import itertools
import requests as rq
from concurrent.futures import ThreadPoolExecutor, wait, ALL_COMPLETED
def gen_server_ip():
return '7.{}.{}.{}'.format(*map(lambda _: random.randint(10, 240), range(3)))
def make_request():
r = rq.get(f'http://{gen_server_ip()}:8000/hitokoto')
# print(r.text)
pool = ThreadPoolExecutor()
tasks = [pool.submit(make_request) for _ in range(100)]
wait(tasks)
抓包结果:
文章开头提出的任务至此完成。