有关 MTU 和 MSS 的一切

最近因为遇到了一个和 MSS 有关的问题,所以花了很多时间学习相关的东西。过程中又发现网上有很多相关的内容是错误的,或者介绍的东西其实现实世界已经不用了。(感觉网络相关的知识经常有这种情况,介绍一种完全过时,现实世界已经不再是用的东西。比如我很久都搞不清 IP 分 Class 有什么意义,后来看到 RIP 才明白原来这古老的路由协议假设全世界的网络号都是 classful 的,让路由结构简单了很多。)我看到这些资料的时候有了无数的疑问,然后又花了很长时间,这些疑问现在基本上都有了一个合理的解释。这篇博客就来总结一下和 MTU, MSS 有关的内容。有可能也写不完“一切”,但是预料到会写的很长。所以你现在去冲一杯咖啡,我们从最简单的地方开始。

MTU

MTU 指的是二层协议里面的最大传输单元。

这是一个很简单的概念。但这是我最初的问题来源,不知道你看到这个之后是否也会有相同的疑惑:

  • 为什么需要 MTU?
  • 为什么是我看到过的 MTU 都是 1500?
  • 如果传输的数据(在二层我们叫做 Frame)超过了 MTU 会发生什么?
  • 那什么时候发送的数据会超过 MTU?

我们从最简单的说起……

为什么需要 MTU,以及它的大小为什么到处都是 1500bytes?

MTU 的存在很合理,Frame 不可能无限大,发送小的数据是可以的。所以就设定了一个最大值。我们在网卡上看到的 MTU 一般都是 1500bytes,要注意这个值指的是 Frame 内容的最大值,并不包括 Ethernet Frame 的 header 和 FCS。一个 Ether Frame 最大是 MTU + Header 14bytes + FCS 4 bytes = 1518 bytes.

那么为什么 MTU 都设置成 1500 呢?

MTU usually is 1500

可以说是历史原因。维基百科有这么一句话:

Larger MTU is associated with reduced overhead. Smaller MTU values can reduce network delay.

第一句话很好理解,更大的包,header 占据整个包的比例就更小,那么链路上更多的资源就花在了数据的传输上,协议消耗的 overhead 就会很小。

第二句话呢?

我觉得 Ethernet 最精髓的地方就在于,它和我们说话一样。如果两个人在交谈,突然同时说话了,会发生什么?两个人都是停下来,等一段时间,然后一个人又开始说了,那么另一个人要等他说完再说。

那么就意味着,更大的包就会让一个人占据链路的时间更长,所以总体上延迟就会变大。此外:

  • 更大的包意味着出错的概率更大,所以会增加重传的比例;
  • 重传的代价也更大,一大段数据里面如果有一个 bit 出错了,这一大段就会整个重传;
  • 以太网是分组交换网络,即存储,转发,在转发给下一跳之前,路由设备或者交换机要存储还没发完的数据,更大的 MTU 就对设备的性能有更高的要求,意味着更高的成本;

综上,1500 其实是一个 Trade Off。

其实,不同的 2 层协议有不同的 MTU:

不同传输媒介的 MTU

这就是为什么一般 MTU 都是 1500.

这里要提一下 Jumbo Frame,可以最大支持 9000 bytes,提高传输的速率。不过现实中基本上见不到,Internet 上更见不到。因为 Ethernet 是 2 层协议,负责点对点的传输,如果因特网上如果一个 Jombo Frame 要能从用户传到另一个用户或服务,这需要所有点对点设备都要支持才行。而现实的世界里,基本上网络上所有的路由,交换设备,端设备,路由器,设置的 MTU 都是 1500.

这就有了下一个问题:那如果超过了这个大小呢?

超过 MTU 的 Frame 会发生什么?

Drop. 这是最简单的处理方法。也是现实世界很多软件,硬件的处理方式。

但是显然这取决于软硬件的实现方式,比如 Cisco 的交换机就可以支持一个 Baby Giant feature(好可爱的名字): 交换机可以转发超过 1500 bytes ,但又不超过很多的 MTU。有些软件和设备支持类似 feature,有些不支持,大部分都会直接 Drop。

既然上文说到基本上所有的设备设置的 MTU 都是 1500,那么为什么还会出现超过 1500 的 MTU 呢?

什么时候发送的数据会超过 MTU?

最常见的是 VPN 和 overlay 网络。这种网络本质上就是将二层包再包一层,在底层互联网上建一个虚拟的二层网络。比如说 VXLan,它会在原来的 Ethernet Frame 基础上加一个 VXLan header,然后变成 UDP 包发出去。

VXLan 包结构,图片来源

这样,假设我们原来的 Ethernet Frame 里面的数据是 1500 bytes,经过 VXLan 包装之后,就变成了:1500 + 14(原来的 Ethernet Frame header) + 8(VXLan header) + 8(UDP Header) + 20 (IP Header) = 1550 bytes, 超过了 50 bytes. (原来的 Frame 里的 FCS 不在里面,因为网络处理过了。)

如果抓包,就像下面这样:

它是原来的 Ether II frame 变成了 UDP 的数据,被包起来了,又封装成 IP,Ether 发出去。

超过 MTU 的包大部分网络设备都会直接丢掉,所以我们就需要保证发送的数据不超过 MTU (上图是一个反例)。

如何保证发送的数据不超过 MTU?

很显然,我们需要分成多份发送。如果我们要让 2 层网络发送(意思就是包括 IP header 在内一共) 4000 bytes 的数据,那么就要分成 3 个 Etherframe 来发送:第一次发送 1500 bytes,第二次 1500 bytes,第三次 1000 bytes.

要让最终传给 2 层协议的 Frame 数据大小不超过 1500 bytes,就要保证上层协议每一层都没有超过这个大小。

拿最常用的 4 层协议 TCP 来说,如果 MTU 是 1500,那么 IP 层就要保证 IP 层的 Packet 数据不超过 1480 bytes (1500 bytes – 20 bytes IP header), 对于 TCP 来说,它要保证每一个 Segment 数据大小不超过 1460 bytes (1460 bytes – 20 TCP header).

那么 TCP 层要怎么知道 2 层的 MTU 是多少呢?

  • 网卡驱动知道 2 层的 MTU 是多少;
  • 3 层协议栈 IP 会问网卡驱动 MTU 是多少;
  • 4 层协议 TCP 会问 IP Max Datagram Data Size (MDDS) 是多少;

TCP 层的最大传输数据大小,就叫做 MSS (Maximum segment size).

对于 TCP 来说,我知道了自己这边的 MSS,但是其实并没有什么用,因为我作为接受端,收到的包大小取决于发送端,得让发送端知道自己的 MSS 才行。

所以 TCP 在握手的时候,会把自己的 MSS 宣告给对方。

MSS 通告

在 TCP 的握手阶段, SYN 包里面的 TCP option 字段中,会带有 MSS,如果不带的话,default 是 536. 对方也会把 MSS 发送过来,这时候两端会比较 MSS 值,都是选择一个最小的值作为传输的 MSS.

(博客显示的图片如果太小,可以点击图片放大查看)

实际应用场景是什么?拿上文我们提到的 VXLan 封装举例,VXLan 封装的这一端知道自己需要 50bytes 的 overhead 来封装 VXLan,那么它就可以告诉对方,自己能接受的最大的 MSS 是 1410bytes (1500bytes MTU – 20 IP headers – 20 UDP headers – 50 bytes VLan),对方发过来的 MSS 是 1460 bytes(1500 bytes – 20 bytes – 20 bytes). 然后两端都会用 1410 bytes 作为 TCP MSS 值,即保证发送的 4层 segment 都不会超过 1410 bytes.

这里就有一个疑问:为什么 MSS 两端都使用一个共同的值,而不是 A -> B 1410 bytes; B -> A 1460 bytes, 这样不是可以更高效吗?

这个问题的答案我找了好久,感觉很多地方说法不一,比如这里就说:

TCP MSS is an option in the TCP header that is used by the two ends of the connection independently to determine the maximum segment size that can be accepted by each host on this connection.

但是很多地方也说两边的 MSS 会一样。

这个我自己测试了一下,手动调整一端的 MTU,另一段不调整,发现两端发送数据都会比较小的值。

MTU 一段设置为了 800,另一段是 1500,在 TCP 握手阶段可以看到。

从 10.130.0.6 发送给 10.130.0.5  最大的包是 800.

从 10.130.0.5 到 10.130.0.6 也是 800.

为啥双方用一个共同的最小值,这个我没找到确凿的原因,我觉得理论上两端分别用 MSS 是可以的,就像 TCP 的 rwnd 一样。但是,在现实的网络上,A 发送 B 有限制,那么 B 发送到 A 很大可能也有一样的限制。所以两边会把这个 MSS 作为链路上某一个点的瓶颈。毕竟,每一端都只知道自己这部分网络的情况,最好是基于自己和对方综合的信息来做决策。

MSS 设置的方法

如果已知有明确的网络情况,可以调小自己的 MSS,设置的方法有 3 种

  1. iptables: iptables -I OUTPUT -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 48
  2. ip route: ip route change 192.168.11.0/24 dev ens33 proto kernel scope link src 192.168.11.111 metric 100 advmss 48
  3. 程序可以自己设置,本质上是自己往 TCP option 里写 MSS:

当然了,也可以直接调整网卡上的 MTU:ifconfig eth0 mtu 800 up. 这样 Kernel 的 TCP 栈在建立连接的时候会自动计算 MSS。(上文写过的这个过程)

我们这里说的都是 TCP 两端的设备如果清楚自己的网络情况的话,可以进行的一些设置。还有一些情况,比如说一些 VPN 和 overlay,端对此并不知晓,完全是中间的路由设备做的。中间设备需要预留 50bytes,有什么方法可以让两边都知道,发送的数据包要预留 50bytes 呢?

MSS Clamping

我们在一个 VPN 环境中测试一下,网络结构可以简单地理解为 [Client -> VPN] -> Server.

Client 端对 TCP SYN 抓包
Server 端对 TCP SYN 抓包

仔细观察 TCP 建立连接的过程,可以发现 Server 端抓包,发现 Server 发送给 Client 的 MSS 值是 1460 bytes,但是 Client 收到的时候变成了 1190 bytes.

这意味着,除了 TCP 的两端,中间的路由设备也可以做 MSS Clamping,影响两端选择 MSS 的过程,以确保网络中为其他协议的 overhead 预留出来了足够的空间。

以上说的都是,协议的每一层,都确保了自己递交给下一层协议的数据单元都没有超过下一层协议的最大长度。但是我们并不在一个完美的世界中,假设协议收到了超过最大数据单元的数据,会怎么做呢?

其实,每一层协议自己都会有机制,让自己发送的内容不超过下层协议能承载的最大内容(最大 PDU)。

We are not in a perfect world after all…

我们从下往上讲起。

Layer 2

二层协议一般都很简答,如果收到了超过 MTU 的包,一般会简单地 drop 掉,要依靠上层协议来保证发送的数据不超过 MTU。

但是也有协议可以支持拆分(Fragment),比如二层的 MLPPP

Layer 3

IP 层的处理就比较经典了,自己收到的是上层协议发给它的内容,然后要负责通过 2 层来发送出去,上层的内容是无法控制的,但是要控制自己发送到下层的内容。

所以 IP 支持一个 feature 叫做 IP Fragmentation.

如果 IP Packet 超过了 1500bytes,IP 协议会将这个 packet 的 data 段拆分成多个,每一个分别上 IP header,以及 fragment header 标志这是拆分成的第几段。 接受端等收到所有的 IP 分片之后,再组装成完整的数据。

我们可以通过 ping 来发送一个超过 MTU 1500 bytes 的数据。ping -s 2000 -I 172.16.42.21 172.16.42.22

抓包如下:

读者可以从这个链接下载抓包文件自行浏览

可以看到一个 ping 一共有 4 个 IP 包,2个完成 Echo 2 个完成 Reply. 其中 Echo request,第一个 IP 包总大小是 1500 bytes,除了 IP 包的 20bytes header,还剩下 1480 bytes 是 ICMP 的数据,第二个 IP 包里面有 528bytes 是 ICMP 数据,两个 IP 包带的数据一共 2008 bytes,是符合我们的预期的,8bytes 是 ICMP 的 header。

由此,可以发现 IP Fragmentation 其实是把上层的数据拆分到多个 IP 包里面,不管上层的数据是什么。说白了,第一个 frame 有 ICMP 的 header,第二个 ICMP 包没有。如果把承载 ICMP 协议换成 TCP 协议,我们就可以发现问题了:收到了 IP framented frame,是无法处理的,因为这个 IP 包的数据对于上层协议来说是不完整的,假设一个 IP 包被 fragment 成了 3 个 IP 包,我们就必须等到 3 个 IP 包全部到齐才可以处理。

所以说:IP Fragmentation is generally a BAD thing.

可能导致的问题有:

  1. 同上面提到的 MTU 为什么是 1500 一样的问题:假设拆分成了 3 个包,丢了一个包就相当于全丢了,丢包率直接变成(假设丢包率是 10%,那么3个包都不丢的概率就是 90%^3=72.9%)27%;
  2. 导致 TCP 乱序:现在网络很多设备都是针对 TCP 做优化的,比如,根据 TCP 的 port number 去 hash 到同一条路由上去,减少 TCP reorder 的概率。但是如果 IP fragmentation 发生的话,后续的 IP 包在路由器看来并不是 TCP 包,因为 TCP header 只在第一个 fragment 上才有,所以会导致 hash 失效,从而更容易发生 TCP 乱序;另外,对段会等齐所有的 fragment 到达才会交给上层,这也导致了延迟增加和乱序的发生;
  3. 产生一些比较难 debug 的问题;
  4. 不是所有系统都能处理 IP Fragmentation,比如 Google GCE

此外,IP Fragmentation 本身就存在一些攻击面(见文末),我猜这也是 GCE 关闭了 IP Fragmentation 的原因?

所以,在现实的世界中,我们几乎看不到 IP Fragmentation 的,要依靠上层协议保证传给 IP 层的数据大小不需要 fragment.

Layer 4

上文已经提到了 MSS。但是我们平时写应用程序的时候,从没有自己分过 Segment,这是因为 TCP 是面向数据流的,你有一个 socket 之后,尽管向里面写就可以了,Kernel 的协议栈会负责给你将数据拆成正好能放到 IP 包里的大小发出去。注意这里是拆成多个 TCP segment 发送,在 IP 层并没有拆开,每一个 IP 包里面都有 TCP 的 header。

Layer 3 的 IP Fragment 会导致这么多问题,我们宁愿这个包被丢弃,也不要分成多个包发送。

DF(Don’t fragment bit)

IP 协议的 header 中有一位 bit 叫做 DF,如果这个 bit 设置了,就是告诉中间的路由设备不要分片发送这个包,如果大于最大传输单元的大小,直接丢弃即可。丢弃这个包的设备会发回一个 ICMP 包,其中,type=3 Destination Unreachable, Code=4 Fragmentation required, and DF flag set. RFC 1191

用 tcpdump 我们可以这么抓 ICMP 包:tcpdump -s0 -p -ni eth0 'icmp and icmp[0] == 3 and icmp[1] == 4'

发送端收到这个错误,就需要降低自己的 MSS 重新发送,重复这个过程,直到 MSS 满足条件为止。这个过程就做 PMTUD, 中间路径上的 MTU 探测 RFC 4821.

在 IPv6 中,行为基本上是一样的,但是 IPv6 没有这个 DF flag,所有的 IPv6 包都禁止中间的路由设备进行分片,也就是说等同于 IPv6 包永远是 DF=1,遇到 MTU 太大丢包发回来的是 ICMPv6(Of course!)

这里有一个问题,就是如果一些中间设备,因为安全原因(下文会解释)禁用了 ICMP(ping), 这样可能导致的问题是,TCP 连接能建立成功,但是数据一直发不出去,造成黑洞连接 RFC 2923。解决这个问题的核心,是要区分丢包到底是中间链路造成的,还是 MTU 太大造成的。TCP over IPv4 感觉没有特别好的办法,最好是允许 type 3 的 ICMP 包。RFC 4821 提出了一种不依赖 ICMP 的 PMTUD 方法,本质是使用小包开始,逐渐增大大小直到达到 MTU 上线,和 TCP 拥塞控制有异曲同工之妙。

至于这个“安全原因”,就比较有意思了。举一个例子:如果攻击者知道服务端的地址,即使攻击者不在 Client – Server 的路由链路上,它也可以发送一个 ICMP 包告诉 Client MTU too large, 让这个连接的双方降低 MSS 从而降低性能。

还可能有另一个问题,有些 DC 可能用了 ECMP 技术,简单来说,一个 IP 后面有多个服务器,ECMP 会根据 TCP 端口,和 IP 来做 hash,这样可以根据 IP + Port 来保证路由到正确的 Server 上,即使 IP 一样。但是对于 ICMP 包来说就有问题了,ICMP error 包可能被路由到了错误的服务器上,导致 PMTUD 失败。Cloudflare 就遇到过这个问题

说到这里,基本上就讲完了最近看的资料和自己的实验的内容,因为是一边学习一边记录的笔记,所以可能有不正确的地方,还望指正。

下面说两个有意思的问题。

道理我都懂,但是我的抓的包怎么大??

如果你通过抓包去看一下 MSS 是否是有效的,里面每一个包的大小是否最大是 1500 bytes,你会怀疑人生。

明明协商的 MSS 1460,但是后面的数据居然有 1万多bytes的??在接收端抓包也一样。

这个叫 TSO,TCP Segment Offload.

上面我们讲为了不发生 IP Fragment, Kernel 协议栈要负责把 TCP 分成一个个不超过 MSS 的小包发送,这部分工作是简单重复并且计算量比较大的,很显然,适合网卡来做这个工作。

所以 TSO,就是网卡 Driver 告诉 Kernel,这个工作可以交给我,做拆包我一直可以的,我可以一直拆包的,于是,Kernel 就发大包到网卡,网卡完成大包拆小包。

但是对于抓包来说,我们看到的就是 Kernel 发送了大包,因为抓包过程是看不到后面网卡具体做了什么的。

图片来源

如果我们关闭 TSO 功能: ethtool -K eth0 tx off。然后再抓包,你就会发现抓到的每一个发送的包都是 1500 bytes 了。

但是即使你按照 1500bytes 发送,然后这时候去接收端抓包,会发现还是有大包。发送端发送的都是小包,为啥到接收端就成了大包呢?显然网卡可以对发送做 offload,也可以对接收做 offload,网卡会攒一些 TCP 包,然后合起来发送给 Kernel 的协议栈。

这减轻了 Kernel 的不少 CPU 负担,转移到了硬件上完成。但是……分析 Sequence number 就是一个 pain in the ass 了。

第二个是工作中遇到的问题,也是我看这些东西的起因。

我们的 SDN 网络有一种这样的路由:

一种奇怪的“三角路由”,其中 Router 会添加 50bytes 的额外 header,然后发现 Router 这里发生了丢包。

最后发现,原因是我们对 eth0 设置了 MTU = 1450,但是忘记设置 ip route,导致握手阶段的包从 eth1 出去了,eth1 的默认 MTU 是 1500,PC 发送的 MTU(MSS actually) 也是 1500,就导致双方一致认为 MTU=1500,MSS=1460. 但是实际上到 Rrouter 这里加了 50bytes 的 overhead,就造成了丢包。

前面提到 IP Fragment 有很多安全问题,这里列举了其中一些:

  1. IP fragment overlapped:攻击者精心设计了很多 IP 分片,它们互相重叠,理论上这种包是无法在网络上出现的。如果服务器收到这些分片,可能无法正确处理(IP 实现的 Bug),那么可能会崩溃;
  2. IP fragment overrun:攻击者通过 IP 分片的方式,发送的 IP 包组装之后超过了 65535,可能造成服务器崩溃(溢出);
  3. IP fragmentation buffer full:攻击者一直发送 IP 分片,more-fragments 一直设置为 true,导致服务器收到 IP 包的时候,只能存储在 buffer 中试图将它们组装起来,直到内存耗尽 (DDoS);
  4. 其他构造的无法正确组装的 IP 包。可能导致 DDoS,或者可能导致 IDS(入侵检测系统)无法正确组装并识别这些包,导致这些包绕过安全系统进入了服务器,最终构造出攻击。参考 Rose Fragmentation Attack
    1. IP fragment too many packets
    2. IP fragment incomplete packet
    3. IP Fragment Too Small

参考资料,文中嵌入的链接就不再单独列在这里了:

  1. Resolve IPv4 Fragmentation, MTU, MSS, and PMTUD Issues with GRE and IPsec
 

由一个子网掩码配置错误所想到的

今天线上发现了一个机器的子网掩码配置错了,其实问题比较简单,一开始没有想到会是这个问题,想了好久才想到这里。

我觉得网络有意思的地方就是在于这些网络协议要考虑的不仅仅是正常的情况,处理正常的情况很简单,而主要的设计其实都是针对不正常的情况如何处理的,比如处理网络中的错误、环路,错误的路由等等。但现实就是,各种各样奇怪的事情会发生,协议必须能处理好各种异常。

不妨拿这个问题来复习一下网络协议:如果子网掩码配置错了,那么两台机器能 ping 通吗?

这个问题看似比较简单,但是答案并不是一个简单的“是”或者“不是”,因为这分成好几种情况。

首先,我们明确这个子网掩码的作用,简单来说,就是判断目标地址和自己是否在同一个网络内

  • 如果是,就直接走二层网络便可达;
  • 如果不是,那么就应该将数据发给网关,让路由器去路由到目的地址,需要三层网络。

即,这个判断是发生在本地的,是一台机器判断应该将数据包的目标地址设置成网关的 MAC(不在同一个子网),还是目的 IP 的 MAC(在同一个子网下)。

对于其他的计算机来说,他们不知道你设置的子网是什么,对于他们发送给你的数据包来说,无论你的子网设置成什么,只要 IP 设置对了,都可以收到。子网的设置只对 egress 流量有影响,对 ingress 流量没有影响(不考虑 ingress 依赖 ARP 这些的话)。下文中会用 “我” 来表示本地的机器。

子网配置的比较大的情况

首先看子网配置的范围比正确范围大的情况,比如我的 IP 本来应该是 1.1.3.2/24, 但是错误的配置成了 1.1.3.2/16.

可以从不同的目标地址来分析影响:

  • 1.1.3.0/24 本来,这个范围和我是在同一个地址下面,现在由于我的子网配置的过大,那么现在,这个段还是跟我在同一个子网下面,所以我和它们之间的通讯丝毫不会收到影响;
  • 1.1.0.0/16 下面,不包含 1.1.3.0/24 的部分:本来,这部分不是跟我在同一个子网下面,现在由于我的子网配置过大,导致我认为它们跟我在一个子网下。那么,本来数据应该发往 router,现在由于我错误地认为这些目标和我在同一个子网,所以我现在会直接查询 ARP 表,通过 ARP 查到目的地址然后发出去。这显然是查询不到的,发出去的 ARP 也不会有响应的,故,这些 IP 是一定不可达

子网配置较小的情况

比如我的 IP 本来应该是 1.1.3.2/16, 但是错误的配置成了 1.1.3.2/24

如上,首先,1.1.3.0/24 这个网段,在错误之前和之后都是和我在同一个网段,所以不会收到影响,能够正确路由。

然后下面就是有意思的部分了。

假设现在要发送到的一个目的地址是 1.1.4.3, 正确的话,这应该是和我在同一个子网,但是现在我的子网变小了,我会认为它不和我在同一个网络内,需要经过路由器转发,所以我会将要发给 1.1.4.3 的数据包的目标 MAC 地址设置成路由器,并且发给路由器。

现在又出现了两种情况,第一种比较简单,是路由器的 IP 地址和我也因为错误变成了不同的网段,比如 1.1.2.1, 那么我会直接认为是 Unreachable,ping 失败。

第二种情况比较有意思:如果我因为阴差阳错,即使子网掩码配置小了,但是依然幸运地和路由器处在同一个网络内呢?

上面已经分析过,这个时候我是可以 ping 通路由器的:

那么我能 ping 通 1.1.4.3 这个地址吗?

让我们一步一步分析一下。

按照上面的逻辑,我会把去往 1.1.4.3 的包丢给路由器,对于路由器来说,它遇到了一个奇怪的事情:这个家伙来自 1.1.3.2/16(路由器视角,路由器的子网配置是正确的),然后要把这个包发送给 1.1.4.3/16, 这不是都在同一个网络 1.1.0.0/16 内吗?发给我干嘛,直接发给它呀!

这时候路由器会怎么做呢?

对于 ICMP 包来说,路由器还是会帮我把这个包转发到目的地址,即路由器通过这个 interface 收到我的包,一看,发现是目的同一个 network,它再从同一个 interface 发出去。

RFC 792 (Internet Protocol)

The gateway sends a redirect message to a host in the following situation.
A gateway, G1, receives an internet datagram from a host on a network
to which the gateway is attached. The gateway, G1, checks its routing
table and obtains the address of the next gateway, G2, on the route to
the datagram’s internet destination network, X. If G2 and the host
identified by the internet source address of the datagram are on the same
network, a redirect message is sent to the host. The redirect message
advises the host to send its traffic for network X directly to gateway
G2 as this is a shorter path to the destination. The gateway forwards
the original datagram’s data to its internet destination.

在路由器上抓包,也可以看到它产生的 Redirect message,里面有说,你应该直接去找 1.1.4.3 , 不要再来找我啦!

所以说,ping request 的包是可以发给目的地址的,只不过会多回复给发送这一个错误消息而已。

现在请求到了 ping reply 这边,reply 能成功发回去吗?

按理说前面提到过,对于其他人来说,正常发给我消息是没有问题的。但是这个回复有一个依赖,就是它要知道我的 MAC 地址,要知道我的 MAC 地址,就需要我来告诉它。

那么第二个有意思的问题就来了:如果我收到了一个 ARP 请求,和我不在同一个网段,但是询问的确实是我的 MAC 地址,我会回复吗?

这个是 Linux 配置参数,位置在 /proc/sys/net/ipv4/conf/eth0/arp_ignore 含义如下:

arp_ignore – INTEGER
Define different modes for sending replies in response to
received ARP requests that resolve local target IP addresses:
0 – (default): reply for any local target IP address, configured
on any interface
1 – reply only if the target IP address is local address
configured on the incoming interface
2 – reply only if the target IP address is local address
configured on the incoming interface and both with the
sender’s IP address are part from same subnet on this interface
3 – do not reply for local addresses configured with scope host,
only resolutions for global and link addresses are replied
4-7 – reserved
8 – do not reply for all local addresses

The max value from conf/{all,interface}/arp_ignore is used
when ARP request is received on the {interface}

因为默认值是 0, 所以是会回复的。通过 tcpdump 在 linux1 上抓包可以确认:

这时候 1.1.4.3 拿到了我的 MAC,就直接在二层把 ping 的 reply 发给我了。

如果在 Linux2 这台机器上,即 1.1.4.3 上,抓包,会发现一个有意思的现象:它从路由器 MAC 收到的 ping 包,回复给了另一个 MAC 地址。

这个奇怪的 ping 链路整体的数据流向如下:

所以说,这种情况下是可以 ping 通的。

 

针对进程设置路由规则

最近有一个需求是这样的:Linux 机器上有多个网络的 interface,想要让其中的一个程序使用 eth1 而不是默认的 interface,不影响其他程序。

在 Linux 上发送 TCP 数据,是通过 kernel 提供的 syscall 将数据送给 kernel,然后 kernel 负责最后的发送。而到了 kernel 这一层,TCP 就是根据五元组来确认的了:来源端口,目的端口,来源IP,目的IP,以及协议类型。所以就让“按照进程转发”这个需求变得有些复杂。

Linux 提供了 SO_MARK 可以给流量“打标签”,然后针对打了标签的流量设置 route. 我们的思路是这样的:

  1. 设置一个路由规则,将带了 0x10 mark 的流量通过 table 100 查找路由: ip rule add fwmark 0x10 table 100
  2. table 100 中只有一条 default 路由规则,就是通过 eth1 出去: ip route add default dev eth1 table 100 scope global
  3. 然后给特定程序使用 SO_MARK 打标签。这样,就可以命中我们想要的路由了。

如何给这个流量打标签,变得有趣了起来。

方法1:iptables

如果应用使用的流量有明显的特征,就可以用这种方法。比如我们的流量都是去往 3306 的,就可以针对这个端口设置一条规则:

但是话说回来,既然都知道端口了,可以直接把步骤 3 去掉,在步骤 1 让去往这个端口的流量直接命中 table 100(ip-route(8) 是支持端口的):

所以这个方法感觉比较鸡肋。而且这种只支持程序的流量必须有一个固定的模式,假如程序请求很多其他网站并且 TCP 无法识别,就麻烦了。

方法2:LD_PRELOAD 魔法

使用这种方式,我们可以不修改程序的代码而又让进程的流量全部带上标记。

第一次见到 LD_PRELOAD 是在 jvns 的博客上,简直是一个颠覆世界观的东西,它这么简单,又这么有效!

它的原理是这样:dynamic link 的程序是在启动的时候寻找应该从哪里 load 一个 symbol 的,通过设置 LD_PRELOAD 给进程,就可以覆盖动态链接的符号的查找顺序。

ldd 展示动态链接的库:

所以,当我们编译一个 .so 共享库,并且通过 LD_PRELOAD 设置成共享库的路径,那么在寻找符号的时候就会先寻找 .so 里面的,这样,我们就可以通过编写同名的函数来覆盖系统的函数。

如果你用过 proxychains ,那么你应该已经用过这个 feature 了。

proxychains 是一个代理软件,一般的软件使用代理都需要设置 http_proxy 等环境变量来告诉软件代理的地址。前提是这个软件支持读这些环境变量。但是,有了 Proxychains,你不需要软件自身支持,就可以让它走代理!原理就是,软件通过 proxychains 启动,而启动的时候,proxychains 会设置软件的 LD_PRELOAD 变量,然后去替换系统的 socket 函数,这样,任何调用都会走 proxychains 的代理!source code

所以,使用这种方法,我们只要替换程序的 socket 方法,在每次创建 socket 之后,调用 setsockopt(2) 去设置上 SO_MARK 就好了。

网上已经有一个程序 App-Route-Jail 做了这件事,它的使用方法如下:

1.clone 并且编译程序:

2.启动程序的时候带上 LD_PRELOAD 变量,要设置什么 mark,通过 MARK 环境变量传入

它的程序很简单,就是用一个新的 socket 函数,替换原来的 socket 函数:在每次创建 socket 之后,调用 setsockopt(2) 设置 mark.

等下,那既然 mark.so 里面调用的也是 socket(),怎么避免 linker 再去找一遍这个函数在哪里,然后找到了自己,造成无限循环呢?

mark.c 的代码中,是用 dysym(3) 去 load 的 socket() 这个符号:

RTLD_NEXT 就是让 dlsym 从下一个 .so 开始找:

RTLD_NEXT
Find the next occurrence of the desired symbol in the search order after the current object. This allows one to provide a wrapper around a function in another
shared object, so that, for example, the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the “real” func‐
tion provided in another shared object (or for that matter, the “next” definition of the function in cases where there are multiple layers of preloading).

所以这里用的就是 glibc 提供的 socket 函数了。

这个方法也有缺点,就是它只对 dynamic link 的程序有效,像 golang 这种语言默认是全部静态编译的,连 glibc 都不用,LD_PRELOAD 自然就无效了。这种情况我们就只能修改程序的代码,让它自己标记自己的流量了。

方法3:直接在 golang 代码中直接设置  fwmark

我们需要手动创建 Dialer 来进行 tcp 连接。并且给这个 Dialer 设置一个 Control:

 

感觉使用这种方式在 Chaos Engineering 方面也会很有用,我们一直苦于如何有效对 HTTP(S) 流量进行注入而不影响其他机器的进程。使用 SO_MARK 或许可以解决。


2023年3月1日更新:这篇文章发出之后,网友评论了更多有用和有趣的方法。

方法4:通过 network namespace 配置路由

最先是在 twitter 上看 @Sleepy93216599 介绍的。不过没尝试过,就不详细展开介绍了。

方法5:通过 iptables –uid-owner 匹配

iptables 居然支持直接按照 uid 来匹配,之前一直不知道这个。这样的话只要给进程分配一个单独的 user 来运行就好了。

此外,还支持 gid, pid, sid, cmd owner 来匹配:

–uid-owner userid
Matches if the packet was created by a process with the given effective user id.
–gid-owner groupid
Matches if the packet was created by a process with the given effective group id.
–pid-owner processid
Matches if the packet was created by a process with the given process id.
–sid-owner sessionid
Matches if the packet was created by a process in the given session group.
–cmd-owner name
Matches if the packet was created by a process with the given command name. (this option is present only if iptables was compiled under a kernel supporting this feature)

方法6:使用 ptrace(2) hook 系统调用 connect(2)

这个方法简直惊为天人。

本文评论里介绍了 gg 这个工具,可以用来 hook golang 写的程序,感觉很神奇,但是没看懂怎么实现的,只有 FAQ 里面提过一次 ptrace,然后灵光一闪发现可以用 ptrace 搞事情。

后来看到 graftcp 这个工具,文档和代码写的都很好,看的让人拍大腿。简单来说,就是程序启动的时候,通过 ptrace(2) 进行跟踪,然后当程序开始创建 tcp 的时候,拦截 connect(2) 调用,获取目标地址,然后给这个程序修改成自己的地址,欺骗程序,自己拿到了真正的地址。然后恢复执行。后面程序成功创建了 tcp 连接,但是却是到代理的 tcp 连接,而不是真正到目标地址的链接。这样,代理就在这之间工作了。而这对程序是无感的。

文档还提到一个有趣的点,就是为什么不直接去 hook write(2)read(2)呢? 原因有2:

  • 不方便拿到真正的目标地址(原来的方法 hook connect(2)) 是通过 PIPE 把地址告诉代理进程。
  • 去改写程序的 buffer 内存,会造成缓冲区溢出等错误。(而原来的方法直接修改 connect(2) 的参数是在寄存器,安全的多)。
  • 另外 ptrace 是有性能损耗的,直接hook一次,在连接创建阶段,显然要比每次传输数据都暂停一下,性能要高得多。
 

Tcpdump 从 TCP_option_address 中根据真实 IP 过滤

这几天做了一个集群的迁移,我们搭建了一个新的集群,然后更新了 DNS 让域名访问新的集群,准备给老集群下线。下线的时候发现仍然有一部分请求到了老集群。看来是它们用了长链接,并没有根据新的 DNS 记录将请求发给新的地址。找到这些发送请求的客户端是谁,费了一番功夫,这里记录一下。

我们的架构如下:

 

LVS 是一个四层代理,将请求转发到 Nginx,一个七层代理,然后转发给后端应用。

现在的情况是:

  1. Nginx 转发给后端的 real server,real server 是同一组,即,从后端 real server 这一层,不知道是谁还通过旧的集群进行访问;用户来源的名字写在了一个特殊的 header 中;
  2. 我们可以从 Nginx 的 access.log 找到这些请求的真实 IP,但是这是不够的,因为用户请求访问走了 NAT 进行了地址转换,我们仍然不知道这个 IP 是哪一个团队的;

但是我们有了 IP,有了 Header,只要通过 tcpdump -A host <ip> 就可以将用户请求的 HTTP 内容全部打印出来,也就包括 header,就能知道是谁了。

这里有一个问题,就是 Nginx 在 LVS 后面,我们在 Nginx 上面进行 TCP dump,看到的是 LVS 的地址,而不是用户的真实地址。

LVS 里面使用了一个模块,叫做 TOA, TCP_option_address. 即 LVS 进行转发的时候,会将用户的真实 IP 写在 TCP 的 option 字段中。如果要根据真实地址进行 tcpdump,我们要过滤的是这个字段而不是原生的 host 地址。

注意这个 TOA 只会在 SYN 握手阶段设置,然后双方会把这个信息记录在内存里面,后面的 TCP 通讯就不会一直带上这个信息了。所以,要找到用户的真实 IP,必须过滤 TCP 的 SYN 包。

现在需求就变成了:用 tcpdump 在 Nginx 上,filter 出来 IP 为 A 的请求的 HTTP header,以便根据 header 中的信息找到调用来源的团队,和他们沟通重建连接的问题。

通过阅读 TOA 的源代码可以发现,代码中还原用户真实的 IP 地址的方式:遍历 TCP Option 字段,直到找到 option code 为 254(TCP option 254 是一个实验字段,RFC3692-style Experiment 2 (also improperly used for shipping products), opsize 固定为 8 的 option,就读 16 bit load成端口,读 32 bit load 成 IP.

所以只要针对这个 tcp option filter 即可。

需要注意的是,tcpoption 对于 tcpdump 来说不被理解,所以需要转成 hex 来匹配。我在 xbin 上面放了几个工具:

匹配 IP 是 120.120.111.111 的话可以用下面的语句:

其中:

  • tcp[tcpflags] & tcp-syn != 0是取 SYN 的 flag 为 1,即只抓取 SYN 包;
  • tcp[24:4]=0x78786F6F 是匹配 tcp option 字段中的 IP

如果要匹配 port 的话,就偏移两位即可:

 

这样做其实还有一些问题:

  • 上面说过 tcp option 里面有 IP 只发生在握手阶段,所以这样是抓不到后续的数据的。没找到 tcpdump follow tcp stream 的方法,可能还是全 dump 下来,然后去 wireshark 里面过滤比较好。不过我们这里 SYNACK 的时候已经有 HTTP 请求内容了,所以只过滤握手第三个包也足够;
  • 还有一个问题是,看 TOA 的代码,它是遍历所有的 TCP option,根据 option Kind + option size 去查是不是自己的 port + IP 字段。也就是说,port + IP 未必一定是在 TCP option 的开头,偏移未必一定是 tcp[24:4]。一个解决方法是把可能的都 dump 下来,然后用 wireshark 过滤,wireshark 支持搜索全部 TCP 包内容包含某个 hex value。比如:tcp.options contains fe:08:78:78:6f:6f.
  • 因为我们要找到的是长连接的用户,所以这些连接我们是没有 SYN 包的。所以本文描述的方法其实对本文提出的问题……无效。现实中我们通过其他的方法找到用户了。这篇文章权当是看个热闹吧。
 

用 BPF 动态追踪 Python 程序

最近在学习 BPF,这是一种目前比较流行的动态追踪技术,简单来说,它允许我们在不中断目前正在运行的程序的情况下,插入一段代码随着程序一起执行。比如你想知道某一个函数每次 return 的值是什么,就可以写一段 BPF 程序,把每次 return 的值给打印出来;然后把这个函数 hook 到函数调用的地方,这样,程序在每次调用到这个函数的时候都会执行到我们的 BPF 程序。

这种不终止程序,能去观测程序的运行时的技术是很有用的。

credit: https://www.brendangregg.com/ebpf.html

它的原理大致上是:

  1. 找到要观测函数调用的地址(所以用 BPF 的时候我们通常需要带 debug 符号的 binary),将这个地址的指令换成 int3,即一个 break,把原来应该执行的指令保存起来;
  2. 当程序执行到这里的时候,不是直接去调用函数了,而是发现这里是一个 uprobe handler,然后去执行我们定义的 handler;
  3. 原来的程序继续执行;
  4. 当我们停止 probe 的时候,把原来保存的指令复原。

例子:这行程序可以把系统中所有执行的命令打印出来 bpftrace -e 'tracepoint:syscalls:sys_enter_execve { join(args->argv); }'. 原理就是,每次执行 execve 的时候都会执行到我们定义的 join (print).

bpftrace exmaple

bpftrace 是一个命令行程序,它的语法类似 awk,做的事情就是:将我们写的程序(这里的程序指的是 bpftrace 定义的语法,而不是 BPF 程序)编译成 BPF 程序,然后 attach 到相应的事件上。

通过上面的描述可以看出,使用这个方法来追踪程序所需要的必备条件是:

  1. Kernel 要支持 BPF;(4.19+);
  2. 有 binary 可以追踪;

像 Python 这种解释型语言,binary 其实是一个解释器,解释器里面才是运行的 Python 代码。如果按照上述方法,追踪的其实是解释器的代码而不是我们的 Python 应用的代码。

Python Interpreter

使用 USDT 追踪 Python 程序

一个解决方法是使用 USDT 追踪。

USDT 全称是 User-level statically defined tracing. 是在程序的用户态定义的追踪点。Python 解释器为我们提前定义好了一些追踪点(可以叫做 probe,或者 marker),这些追踪点默认是不激活的,所以对性能丝毫没有影响。要追踪的时候可以激活其中的几个追踪点,然后将其提供的 arguments 打印出来。

能够使用 USDT 有几个前提:

  1. Kernel 要支持 BPF;(4.19+); (其实使用 Dtrace 或者 systemtap 也可以)
  2. Python 编译的时候要打开 trace 的支持 (编译的时候有 –with-dtrace 选项),我的几个服务器中,Ubuntu 上面的 Python3.10 是开了的,Fedora 上面的 Python 3.10 貌似没有开。

使用 USDT 追踪 Python 程序

首先需要安装 bpftrace. 安装说明可以参考这里

通过这个命令可以列出 binary 中的 USDT markers: bpftrace -l 'usdt:/usr/bin/python3:*'

可以看到目前支持的 markers 不多,一共也就 8 个。

先拿一个来试一下:执行这个命令可以打印出来 Python 每次函数调用的时间、源代码的地址,函数名字,和行号:

bpftrace -e 'usdt:/usr/bin/python3.10:function__entry {time("%H:%M:%S"); printf(" line filename=%s, funcname=%s, lineno=%d\n", str(arg0), str(arg1), arg2);}' -p 15552

其中,-p 15552 是正在运行的 Python 程序的 pid,-e 后面跟的就是 bpftrace 的代码,很像 awk,第一个是 probe,{} 里面是代码,主要要两行,第一行打印出来时间,第二行打印出来 function__entry 这个 probe 提供的三个参数。(需要稍微注意的是,字符串参数需要用 bpftrace 内置的 str() 函数打印出来字符串,否则的话打印出来的是 char * 的地址;另外要注意虽然 Python 文档中说的这三个参数是 $arg1 $arg2 $arg3,但是实际打印的时候,应该使用的是 arg0, arg1, arg2.)

效果如下:

其他的几个 marker 作用是:

  • function__entry: 函数入口
  • function__return: 函数退出的时候
  • line: 每执行一行 Python 代码都会触发
  • gc__start和gc__done: gc 开始和结束的时候触发
  • import__find__load__start 和 import__find__load__done:import 开始和结束的时候触发
  • audit:sys.audit 调用的时候触发

可以看到,当前支持的并不多,而且从这几个 marker 可以看出,大部分的作用是让你知道 Python 解释器正在干啥,而不是 debug。如果遇到性能问题的话可以通过这个解决,但是如果遇到逻辑错误,帮不上太大的忙。比如你无法通过 usdt 看到某一个变量在某个时刻的值。

USDT 的原理

USDT 工作的原理和上文提到的 uprobe 差不多,激活的时候会改变原来执行的指令,插入 int3 去执行我们的 handler.

Seeing is believing.

我们可以通过一些工具来观察它执行的原理。

function__entry 为例,我们先找到它在 Binary 中的 marker, 在 .note.stapsdt 里面:

可以看到我们刚刚使用的 function__entry 的位置是 0x000000000005d839.

然后我们找到这个位置的指令。

`gdb -p 17577` 去 attach 到正在运行的这个 pid 上。然后运行 info proc mappings dump 出来地址,可以看到 Offset 0x0 的位置是 0x55bfcc88d000.

那么 function__entry 的位置应该是 0x000000000005d839 + 0x55bfcc88d000.

下面用 disas 命令可以 dump 出来这段地址的指令:

查找 0x55bfcc8ea839 这个地址的指令:

发现是一个 nop,即什么都不做。所以这就是为什么上文说,在 usdt 不开启的时候,对性能是完全没有损失的。只是多了个一个什么都不做的占位指令而已。

下面使用之前的命令去 attach 这个 usdt probe:

然后重新看一下 0x55bfcc8ea839 这个位置的指令:

可以看到这个位置的指令已经变成了 int3. 当程序(解释器)执行到这里的时候,kernel 就会执行用 usdt 提供的变量去执行我们的 BPF 程序。当 BPF probe 退出的时候,int3 会被恢复成 nop.

更多的可能?

在网上查询和 Python 有关的 BPF 内容大部分都是“如何通过 Python(BCC)来使用 BPF”,而不是“如何用 BPF 去 profile Python 代码”,可能在解释型语言方面用的不多吧。

USDT 有很大的局限,就是只能使用 Python 解释器定义的几种 probes, 目前能做到的基本上是看看解释器正在干嘛,而不能看到一些具体的变量的参数,解释器的状态等等。如果要做到更加精细的动态追踪,目前有两个想法:

  1. 用 uprobe 去追踪 Python 解释器。不过大部分都是 PyObject 指针,需要了解解释器的工作原理,比较复杂;
  2. 自定义更多的 USDT。通过 python-stapsdt 这个库可以在 Python 程序中插入更多的 USDT marker。但是我觉得意义也不大:我们要解决的问题,往往是不知道问题出在哪里了,你要解决问题的时候,又不能停止程序,再想起来要插入 marker,就晚了。换句话将,既然知道一个地方可能会出问题,可观测行非常重要,那么为什么不直接打印日志呢?