追踪一个数据包的SNAT流程
简介
本文分三部分:
- 第一部分:搭建一个SNAT环境,通过ping和tcpdump验证实验。
- 第二部分:以
五链为基础,从业务上分析SNAT的处理流程。 - 第三部分:分析内核源码(Linux 6.16-rc5),深入理解
conntrack基础上的SNAT实现逻辑。
本文详细分析一对ICMP数据包,ping request和ping reply,基于conntrack(在IP_CT_DIR_ORIGINAL和IP_CT_DIR_REPLY)两个方向的SNAT业务处理流程。其中,SNAT规则是通过nft下发到内核中的。
一. SNAT实验
1.2 网络拓扑
我们要用PC1的 10.0.3.1 通过 SNAT 路由器 RT1,ping 通RT2 192.168.206.1。

1.3 RT1 SNAT 配置
1 | ~ # nft add table ip mytable |
1.4 实验效果
1 | ~ # ip netns exec left ping 192.168.206.1 |
1.5 抓包分析
1.5.1 RT1 eth1 抓包
发出 10.0.3.1 –> 192.168.206.1 的 ICMP request
收到 192.168.206.1 –> 10.0.3.1 的 ICMP reply
1 | 02:14:28.306188 fa:64:6e:a0:54:c8 > 66:37:84:be:0e:9f, ethertype IPv4 (0x0800), length 98: 10.0.3.1 > 192.168.206.1: ICMP echo request, id 122, seq 0, length 64 |
1.5.2 RT1 eth0 抓包
10.0.3.1 –> 192.168.206.1 的 ICMP request的数据包被SNAT规则修改了源IP,变为 192.168.206.250 –> 192.168.206.1
收到RT2返回的 192.168.206.1 –> 192.168.206.250 的 ICMP reply的数据包被SNAT规则修改了目的IP,变为192.168.206.1 –> 10.0.3.1转发给PC1。
1 | 02:15:40.972860 52:54:00:12:34:56 > 7a:d7:5d:43:7d:3e, ethertype IPv4 (0x0800), length 98: 192.168.206.250 > 192.168.206.1: ICMP echo request, id 126, seq 0, length 64 |
二. 业务流程分析
本节聚焦RT1路由器,观察ICMP,在五链上,基于会话的业务处理。
2.1 基础知识复习
2.1.1 五链
比较基础的网络协议栈处理流程,所谓五链就是内核在处理数据包时埋下的五个HOOK点:
定义在<linux/netfilter.h>中
- NF_INET_PRE_ROUTING:查路由前经过的埋点
- NF_INET_LOCAL_IN:目的ip是本地地址要经过的埋点
- NF_INET_FORWARD:目的ip不是本地地址,开启转发后要经过的埋点
- NF_INET_LOCAL_OUT:由本地产生的数据包要经过的埋点
- NF_INET_POST_ROUTING:路由后要经过的埋点

2.1.2 会话
linux内核为了追踪数据流,业务处理,有一个称为会话(conntrack)的功能模块,一个会话描述了符合特定规则的一系列数据包的特征和状态。
- 特征:一对五元组
- 状态:例如TCP在握手、建立成功、分手期间的不同状态。
五元组就是一个数据包的第四层协议,源、目IP地址,源、目端口号。例如tcp src=192.168.206.100 dst=4.145.79.82 sport=49380 dport=443
而一个会话包括ORIGINAL方向和REPLY方向,每个方向一个五元组,一个会话有两个五元组。
可以通过sudo conntrack -L查询会话
1 | zrf@debian:$ sudo conntrack -L |
其中,第一对五元组是ORIGINAL方向,第二对五元组是REPLY方向。
2.2 ICMP request
本节我们分析RT1路由器的eth1接口收到来自PC1的10.0.3.1 -> 192.168.206.1的ICMP请求后,把源IP10.0.3.1通过SNAT规则转为192.168.206.250的业务处理逻辑。
从五链的转发顺序中,可以知道,我们会先进入PRE_ROUTING埋点,发现目的IP 192.168.206.1 不是本地地址后,查询路由,进入FORWARD埋点,最后通过POST_ROUTING埋点从eth0接口把数据包转发出去。
本小节也将追踪这三个埋点,看看netfiler框架是如何记录会话,SNAT转换的。
2.2.1 PRE_ROUTING
在PRE_ROUTINGHOOK点上,根据hook优先级,会先后进入以下模块:
NF_IP_PRI_CONNTRACK_DEFRAG = -400,NF_IP_PRI_CONNTRACK = -200,NF_IP_PRI_NAT_DST = -100,
我们重点关注NF_IP_PRI_CONNTRACK会话模块就可以。
skb进入会话模块后
首先创建一对五元组(ICMP没有端口)icmp ORIGINAL:src=10.0.3.1,dst=192.168.206.1 REPLY:src=192.168.206.1,dst=10.0.3.1
会话状态为ctinfo:NEW
下面的log是在NF_INET_PRE_ROUTING.(NF_IP_PRI_CONNTRACK+1)注册了一个hook函数,用以验证以上的分析。
1 | [ 3946.922715] ctinfo:NEW L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=10.0.3.1 |
2.2.2 FORWARD
查询路由,后发现需要进行转发,则进入NF_INET_FORWARD埋点。
通过log可以看到,查询路由期间,可以找到出接口outdev : eth0
1 | [ 3946.927980] NF-CORE: pf = AF_INET, hook = NF_INET_FORWARD, elem->pri = -225 verdict = NF_ACCEPT skb ffff888004d74100 |
2.2.3 POST_ROUTING
在POST_ROUTINGHOOK点上,根据hook优先级,会先后进入以下模块:
- NF_IP_PRI_NAT_SRC
- NF_IP_PRI_CONNTRACK_CONFIRM
可以看到,NF_IP_PRI_NAT_SRC就是SNAT,也就是我们在ORIGINAL方向最该关心的业务。
进入SNAT模块后,会根据我们通过nft add rule ip mytable postrouting meta nftrace set 1 ip saddr 10.0.3.0/24 oif "eth0" masquerade添加的规则,修改REPLY方向的五元组:
此前REPLY方向的五元组为:dir:1,src=192.168.206.1,dst=10.0.3.1
而通过SNAT模块后,REPLY方向的五元组为:dir:1,src=192.168.206.1,dst=192.168.206.250
也就是说,SNAT模块把REPLY方向五元组的dst修改为192.168.206.250。
最后,修改skb的源IP地址:
此前skb为:[ 3946.924458] 10.0.3.1 -> 192.168.206.1 protocol 1 id 16310
而通过SNAT模块后,skb为:[ 3946.935845] 192.168.206.250 -> 192.168.206.1 protocol 1 id 16310
1 | [ 3946.934240] ctinfo:NEW L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=192.168.206.250 |
2.3 ICMP reply
RT2收到ICMP request后,会给RT1一个192.168.206.1 > 192.168.206.250: ICMP echo reply。
eth0收到数据包后,依旧从PRE_ROUTING进入第一个埋点,
2.3.1 PRE_ROUTING
在PRE_ROUTINGHOOK点上,根据hook优先级,会先后进入以下模块:
NF_IP_PRI_CONNTRACK_DEFRAG = -400,NF_IP_PRI_CONNTRACK = -200,NF_IP_PRI_NAT_DST = -100,
重点关注NF_IP_PRI_CONNTRACK和NF_IP_PRI_NAT_DST。
skb进入会话模块后
尝试在数据库中查找是否已经有这条会话实例,本次查询应该是可以查到的,而且还可以知道,这次的数据包是REPLY方向,以下是在NF_INET_PRE_ROUTING.(NF_IP_PRI_CONNTRACK+1)上注册埋点,,可以看到,数据包上已经绑定了ct实例,且方向为REPLY:
1 | [ 3946.946929] ctinfo:REPLY L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=192.168.206.250 |
此时,数据包还是[ 3946.950536] 192.168.206.1 -> 192.168.206.250 protocol 1 id 43037
接下来进入NF_IP_PRI_NAT_DST的埋点:
此时会话存在,将会依据REPLY,修改目的地址,所以从这个埋点出来后,数据包已经变为了[ 3946.952435] 192.168.206.1 -> 10.0.3.1 protocol 1 id 43037
1 | [ 3946.950884] NF-CORE: pf = AF_INET, hook = NF_INET_PRE_ROUTING, elem->pri = -100 verdict = NF_ACCEPT skb ffff888004d74100 |
2.3.2 FORWARD
查询路由,后发现需要进行转发,则进入NF_INET_FORWARD埋点。
通过log可以看到,查询路由期间,可以找到出接口outdev : left-veth
1 | [ 3946.954591] NF-CORE: pf = AF_INET, hook = NF_INET_FORWARD, elem->pri = -225 verdict = NF_ACCEPT skb ffff888004d74100 |
2.3.3 POST_ROUTING
最终,数据包从left-veth口发出去。
三. 源码分析
本节开始,从代码实现的层面,来了解一下conntrack和SNAT。
分为两部分:
运行时会话上下文:conntrack的创建和确认。
应用规则:应用NAT规则,修改数据包(以nft举例)
3.1 会话的建立
在NF_INET_PRE_ROUTING.NF_IP_PRI_CONNTRACK埋点上,如果这个数据包没有绑定会话,将会创建一个新的会话实例,我们主要关心会话的方向和两个五元组:
1 | static int |
此时会话的信息如下:
1 | [ 3946.922715] ctinfo:NEW L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=10.0.3.1 |
两个五元组不必多说,我们再关注一下init_conntrack()函数创建ct时,与方向有关的信息与存放位置:
tunple是此时数据包src=10.0.3.1,dst=192.168.206.1的元组,通过nf_ct_invert_tuple()反转元组函数得到一个期望的响应元组repl_tuple:src=192.168.206.1,dst=10.0.3.1。
__nf_conntrack_alloc()分配内存,并为两个五元组分别赋值tunple和repl_tuple。
1 | static noinline struct nf_conntrack_tuple_hash * |
3.2 会话的匹配
继续分析init_conntrack(),请关注返回值&ct->tuplehash[IP_CT_DIR_ORIGINAL],这是一个基于IP_CT_DIR_ORIGINAL方向的哈希值,如果下次在此收到这样的数据包,将通过这个哈希值在IP_CT_DIR_ORIGINAL方向查找。
让我们回头再关注以下resolve_normal_ct()更多细节,在创建会话实例时,会尝试在IP_CT_DIR_ORIGINAL和IP_CT_DIR_REPLY两个方向查看是否存在会话,都没有的话,才会创建新的实例。
1 | static int |
3.3 会话的修改
我这里指的会话的修改,主要是针对会话中的两个五元组的修改,并不涉及到skb数据包的修改,此刻系统可能基于SNAT规则,修改会话中五元组的规则,在内核中,SNAT主要通过nf_nat_setup_info()修改会话。
我们以ctinfo:NEW L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=10.0.3.1为例,看下nf_nat_setup_info()是怎么修改规则的。
修改过程详见代码注释:
1 | unsigned int |
3.4 修改数据包
请注意,截至目前为止,所有的修改只是针对会话实例的修改,并没有真正修改数据包。
当这些会话规则就绪以后,将通过nf_nat_packet()来修改源IP:
1 | unsigned int nf_nat_packet(struct nf_conn *ct, |
3.5 REPLY 方向
第二节中,我们已经分析过,REPLY方向将在NF_INET_PRE_ROUTING.NF_IP_PRI_CONNTRACK埋点上,通过基于方向的哈希(第三节会话的匹配),匹配到ctinfo:REPLY L3:ipv4[2],L4:icmp:[1] dir:0,src=10.0.3.1,dst=192.168.206.1 dir:1,src=192.168.206.1,dst=192.168.206.250会话。
接来在,在NF_INET_PRE_ROUTING.NF_IP_PRI_NAT_DST埋点上,把REPLY方向的数据包src:192.168.206.1 dst:192.168.206.250修改dst,变为src:192.168.206.1 dst:10.0.3.1。
1 | nf_nat_ipv4_pre_routing() { |
3.6 SNAT规则的添加
有很多方法可以给内核添加NAT规则,例如iptables、nft、编写内核模块等。
本次实验SNAT规则是通过nft来下发的:
由于不同的前端有各自的配置方法,这里并不会详细分析nft的源码,只会介绍举例内核最近的一层配置业务逻辑。
1 | ~ # nft add table ip mytable |
回忆ORIGINAL方向时,ctinfo:NEW状态的ct实例,在经过NF_INET_POST_ROUTING.NF_IP_PRI_NAT_SRC埋点时,会依据nft对内核的配置,首先在nf_nat_setup_info()修改REPLY方向五元组中的dst,然后在nf_nat_packet()修改数据包的src。
也就是在这个埋点,执行了nft注册的SNAT规则。
我们看这个埋点的回调函数nf_nat_inet_fn:
1 | unsigned int |
针对nft前端来说,调用栈如下,可见,从nf_nat模块,进入到nft模块,再进入到nft_masq模块,最终调用nf_nat提供的接口函数:
1 | nf_nat_inet_fn() /* nf_nat_core.c:960 */ { |





