生成一份“热闹”的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

💡
iptables 教程可参考这篇文章。另外,dog250 的大作也非常有启发性。

先来配置 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)

抓包结果:

文章开头提出的任务至此完成。