引言
本文将记述我这两天关于 IP 包构造的各种折腾,利用的主要工具是 python 的 scapy 库。起因是我了解到关于 IP 可以携带源路由选项从而改变默认的路由路线,折腾的结果是把 IP header 的细节都搞清楚了,但最初目的没有实现,现在能想到的原因就是 ISP 默认关闭了关于源路由的 IP 选项的支持。OK,让我们开始读 RFC 791 吧。我将结合规范讨论 IP 头的填充,并对应分析每一个填充的 scapy 实现。
注意本文所讨论规范均为 IPv4。且我并没有读过多少中文资料,里面多数学术词汇的中文都是我自己猜的。。。
首先我们熟悉下 RFC 喜欢用的数据包的表示方式:
+--------+--------+--------+--------+
| A | B | C | D | E |
+--------+--------+--------+--------+
| F |
+--------+--------+--------+--------+
真实的数据包当然是一维的,写成二维只是为了方便表示。真实组装的数据包内容则为 ABCDEF 以此类推。这种表示每一行有 32 bit,也就是 4 byte,这恰好是一个 IP 地址所占的空间。每一个 IPv4 地址由四组占用 8 bit = 1 byte 的数字组成 (\(2^8-1=255\))。每两个 + 之间有八个 -,恰好对应了 byte 和 bit 的转换关系(有时候,也会用 - + 交替并用其中的 - 来标记 bit 位)。比如上图中 A 的大小为8bit=1字节,而 F 大小为32bit=4字节,有时为了方便也叫做一行大小。所有下面的内容都需是牢记1行=4字节=32bit这一对应关系。
IP header 必选项
下面我们看一下典型的 IPv4 header 的结构。
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Ver= 4 |IHL= 5 |Type of Service| Total Length = 21 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification = 111 |Flg=0| Fragment Offset = 0 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time = 123 | Protocol = 1 | header checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| source address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| destination address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+
在上面的 IP 包中,前五行(20字节)是 IP 的头信息,最后承载了1字节的数据部分(仅为演示,通常数据部分是 ICMP 或 TCP,UDP 等内容,长度要长的多)。Version: IP 头的前4个 bit 来标记 IP 协议的版本,这里自然是 4 = 0100。IHL: 之后的 4 个 bit 被用来标记 IP 头的长度 (Internet Header Length),需要注意的是这里的长度是以行(也就是每 4 个字节为单位的)。考虑到这一字段只有4个bit,那么最大值也就是 1111 = 15,也就是说每个包 IP 头最多只能有15行,也就是60字节。IP 包头的长度被 IHL 字段的空间限制住了。TOS: 之后就是占据一字节的服务类型字段,这一字段相当于数据包的优先级,可能在有些网络进行路由时,会考虑该优先级顺序进行路由转发。但是,正常的 ISP 大概都不遵守这个,不然简直随随便便就变高端客户了。这一字段8bit可以继续分解为以下几个部分:Precedence(3bit)+ Delay(1bit)+ Throughput(1bit)+ Reliability(1bit)+ 00(2bit)。最后两个bit没有启用。前三个bit描述了该数据包的优先级,其中 7=111 是网络控制数据包,优先级最高,000是正常(routine)数据包,优先级最低。其他1-6级别也都有名字,可以去 RFC 里查阅。中间3个bit描述了该数据包应该如何在低延迟高吞吐和高稳定性间达到平衡,设置成1则表示要求这方面表现更好。但由于这三个因素是矛盾的,无法同时达成,因此 RFC 建议最多设置一个bit值为1(极特殊情况可以设置两个为1)。不过这一 tos 字段在后续的 RFC 中规范几经更新,前六bit现在被称为 DSCP 字段,由 RFC 2474,RFC 3168,RFC 3260等规范定义。后两个bit被称为ECN字段,由 RFC 3168 定义。前者和以前的tos类似,给出数据包要求的优先级和性能指标,不过反正大多数商用网络并不按照该字段做QoS,这里就不详细介绍了(设置对了也不会提高优先级的),想了解详细设置的请参考wiki。而后两个bit可以定义的ECN字段,通过路由标记而非丢包的方式实现TCP传输窗口大小的控制,更加低碳非暴力。为了表示支持ECN,终端先将两个bit中的一个设置为1,如果中间有路由发生阻塞且该路由也支持ECN,则将该字段改写为11,终端收到后即知道传输线路上的阻塞情况,从而调整TCP的发包速度,更好地实现控制。Total Length: 这行最后两个字节是总长度,给出 IP 包的总长度,并以字节为单位,也就是说整个 IP 包总长最长为65535字节,当然正常使用的 IP 包长度远小于此,大约在一千多字节,更大的数据包阻塞传输,完整性保证和重传成本方面都是十分糟糕的。这几个字段都可以在scapy直接设置,分别由 version,ihl,tos,len直接设置。
ID: 第二行的前两个字节是 Identification 字段,同样可以取值从0到65535。同样的数值如果目的和源 IP 地址也相同,就意味着不同 IP 包是分片得来的。因此 ID 只要保证不出现相同地址对之间非分片的IP包也出现相同的ID即可。RFC并没有给出强制实现,因此这一实现和系统有关。有些系统,每发一个 IP 包就把 ID 加一,这就确保了较长一段时间里,不会出现不分片的包共享相同 ID,避免歧义。Flag: 紧随其后的三个bit可以设置一些和IP包分片有关的标记。第一个bit预留恒为0,不过在 RFC 3514 的年度愚人节玩笑中,这一位被描述为邪恶比特。据称该位设为1代表数据包动机不纯,当然这只是个玩笑,如果你真把它设为1,可能很多路由由于无法识别会拒绝转发。第二个bit是不要分片的标记(DF),该位设置为1时,所有转发的路由器均不可将此IP包分片。但如果该数据包大小超过了转发路由网卡的MTU允许值,那对不起,只能将该包丢弃,最多友情赞助一个ICMPError的数据包回来。第三个bit是更多分片的字段(MF),这一bit设置为1就表示该IP包之后还有其他分片待一起组装。Fragment Offset: 该行最后13个bit则是分片位移的字段,这一字段负责记录IP包分片之后的顺序,用作重组时用。这一偏移的单位是两行(8字节)。比如第一个分片包共有256字节的话,第二分片这一字段的值为32(第一分片这一字段总是0)。因此最大的分片偏移数是8191*8字节,这就决定了IP包即使分片,大小也不会超过这么多。这一行的字段在 scapy 中分别可用 id,flags,frag 设置。
TTL: 第三行的第一个字节是生存时间ttl字段,这一字段规定了一个数据包可以在网络上生存的秒数,当该数字变为0时,该包将被路由器丢弃并返回一个ICMPError包给发送方(有些路由器出于安全考虑静默丢弃ttl为0的数据包,并不返回ICMPError,这也是使用 traceroute 时收到一行*的原因)。由于一个字节最大数字为255,则生存时间最长为255秒。同时又规定每经过一个路由转发不管时间都是多少,都要将ttl至少减1,考虑到现代网络端到端的延迟无论如何也到不了一秒,因此该ttl值实际上就控制了可以被多少路由器转发这一指标。比如ttl=10就表示经过10个路由器转发后该包将丢弃。一般系统和 scapy 的默认值都是 ttl=64,除非九转十八弯,一般的端对端通讯大概都在30跳以内,traceroute 默认的测量跳数就是30。Protocol: 之后的一个字节是协议字段,用于指示 IP 包所承载的上层数据协议,IP协议号最初被 RFC 790 定义。典型的比如1代表ICMP,6代表TCP,17代表UDP等,详情可参考wiki。Checksum: 之后的两个字节16bit是checksum字段,用于保证IP包头的完整性和合法性。计算就是先将 checksum 字段设置为0,然后按照给定算法对IP头进行某种求和得出16bit的验证字段,scapy default 就是正确的checksum,想了解具体算法,请参考wiki。每当数据包经过路由转发后。ttl等字段的值被修改,因此每次转发checksum都要重新计算一次。一旦接受路由发现该值错误,将可能丢弃 IP 包。这行的字段分别可以用 scapy 的 ttl,proto 和 chksum 设置。
Src and Dst IP: 接下来两行8字节64bit就简单了,分别是发送和接受方的 IPv4 地址,每个 IPv4 地址的大小正好是4个字节。考虑到 IPv6 地址的大小是16字节,因此两个版本的 IP 协议无法兼容,同时该层的原有路由等硬件设施也无法兼容,这就是 IPv6 推广的问题所在。需要注意的是路由对地址可能的影响,这包括 NAT 设备对于源地址和目的地址的转换以及网关可能的对明显错误的源IP地址包的过滤。更严格的情况,网关似乎会检查 mac 与 IP 是否对应,从而进一步阻挡对局域网内部其他源 IP 的伪造,结合交换机的 mac 地址端口绑定(修改mac地址的包无法通过),最大限度保证了任何伪造源IP的包无法通过网关。两个地址在 scapy 里分别由 src 和 dst 指定(如果都不指定,二者默认为 127.0.0.1)。
IP header 可选项
以上就是所有 IP 的必选字段,共五行20字节,构成了最小的 IP 头。之后就是重头戏 IP 包头的可选项,这一部分 scapy 的构造稍显复杂,需要设定byte填充 IPOption。这些字段很不常用但却比较有趣,同时参考资料也比较少,下面我们仔细分析一下。首先要注意的是 IP 头长度必须是占据几个整行(4字节的整数倍),这也是为什么 IHL 字段可以用行做单位。因此由于 IP Option 的长度不固定,如果有用的数据结束后不是整行,该行剩余的部分需要被 0 填满。这其中第一个字节的8个0会被理解为选项列表结束位(EOL),如果之后还有0,就被简单的理解为填充(Padding),这种区别,没有什么意义,反正就是所有选项最后用0填满到整行就对了。这一过程 scapy 可以自己处理好,因此我们无需考虑添加 EOL 选项之类的。此外还可以用1个字节的00000001,作为选项之间的分割,这个字节可有可无,通常是用来满足强迫症把下一个选项放置在一行开头的,没什么实际意义。
其他选项都由一个字节的选项类型,一个字节的选项长度(以字节为单位)和可变字节数的选项数据组成。
安全选项:类型值130=10000010,这货通过一些值来设定所谓 IP 包的安全等级等,除了最初网络萌芽的美国国防部,不知道还有没有别人用,如果现在有其他利用方式还请告诉我,这里其构造和作用就先略过了。
流标记选项:类型值136=10001000,用于给每一个IP包一个两字节的流ID,当然很少有什么非得在 IP层实现流控制的必要,这里就略过了。
记录路由(record route)选项:第一个字节是类型值 7=00000111,第二个字节是以字节为单位的该选项的总长度。第三个字节是以字节为单位,以1为起点的指针,指向data部分的位置。该指针的最小值通常也是初始值是4。之后就是四字节为一单位的数据部分,发送时该字段全部设置为0。之后路由转发时,会将自己的 IP 按照指针指向的位置更新在数据部分,同时将指针加4,以此类推,可以想见途径的所有路由会依次将自己的 IP 地址写在数据部分,直到指针的值超过长度字段,此时所有数据区被写满了 IP 地址,之后的路由不再进行操作。这一过程很像 traceroute。 不过这里有两个不同。第一,这一方式实现的路由 IP 跟踪,最多只能设置 9 个路由,更远的将无法记录。原因很简单,还记得 IP 头最长只能有15行吗,本来的必选项就是5行,这一路由记录的选项又占据了一行。考虑到每个IP地址都占据一个整行,因此记录路由的方式最多能记录9个路由的IP。第二,这种方式有一个额外的好处,即,如果你ping的路由非常近的话,这一返回的 ICMP echo-reply 包的 IP 头将记载同一路由两侧网卡的 IP,这点要优于 traceroute 只能看到单侧 IP。也就是说这一选项在 ICMP reply 包返回的过程中依旧对路由有效,只要数据没有写满还可以继续写。对于这一选项,大部分路由还都支持的。
+--------+--------+--------+---------//--------+
|00000111| length | pointer| route data |
+--------+--------+--------+---------//--------+
下面我们看如何通过 scapy 构造该选项。最原始的办法就是按规范手动构造各字节。比如
IP(dst=some.ip,ttl=64,IPOption(b'\x07\x07\x04\x00\x00\x00\x00')/ICMP()
上面的数据包就表示了一个包含 record route 选项的ICMP echo包。其中第一个7是选项类型值,第二个7是该选项的总字节数,第三个4是默认的指针位置,之后四个0表示一个待记录的路由IP。通过这种方式,我们就可以构造出不同的记录路由选项。当然如果真要写36个\x00也挺麻烦的,于是我又封装了一下,可以直接使用函数构造,比如
IP(dst=some.ip,ttl=64,IPOption(record_route(9))/ICMP()
只需标记一下想要记录几个 IP 就好了,协议的实现细节就不需要操心了。当然正如刚才所说,设置的记录数据超过9个IP是会报错的。
时间戳(Internet Timestamp)选项:第一个字节类型值为 01000100=68。这一选项和记录路由很相似,只不过这次是可以记录在每个路由处的时间,同时也可以记录路由IP。第二个字节是以字节为单位的该选项总长度。第三个字节的指针,与记录路由选项类似,以字节为单位,以该选项起点为起点1,指示在数据部分应该更新的位置。最小的值是5。之后的4个bit overflow,用于当下面时间戳已经写满,指针大小超过长度字段时,下面的路由将依次对 overflow 字段加一,表示时间戳记录溢出。其初始值设为0000。最后4个bit,有三种flag可以设置,模式0=0000,下面将只记录4字节的时间戳,指针每次路由加盖时间戳并转发时加4。模式1=0001,同时记录时间戳和标记路由的IP,此时每个转发路由将写8个字节的内容。指针每次转发也会加8。模式3=0011,这种模式,路由IP项需要制定,当转发路由与指针所指的 IP 重合时,才会改下时间戳并移动指针加8,其他未指定IP的路由不写入时间。除了 flag =3 需要指定路由 IP 之外,其他所有 IP 和时间戳字段,发包时都默认填充 0。 需要额外注意的时,RFC 规定关于溢出字段本身溢出时,丢包并返回 ICMPError 的规定,如果设置只记录一个时间戳的话,也就是网上路由超过16跳,IP 包就会被退回(overflow字段最大为1111=15),这一规定的合理性有待考量。还需要注意的时,flag=1或3时,每个路由需要2行记录数据,考虑之前分析的 IP 包头长度不超过15行,那么最多记录4个路由的时间戳情况。
时间戳选项和上边的记录路由选项的现状是,互联网上大部分路由支持,但也有不小的一部分路由并不支持,而且似乎会静默丢包,表现就是携带相同的选项 ping 9.9.9.9 时有正确的回复,而 8.8.8.8 时则没有任何回复也没有ICMPError。
+--------+--------+--------+--------+
|01000100| length | pointer|oflw|flg|
+--------+--------+--------+--------+
| internet address |
+--------+--------+--------+--------+
| timestamp |
+--------+--------+--------+--------+
| . |
.
用 scapy 构造该选项和记录路由类似,可以根据对该选项结构的分析,直接手工写出对应的各个字节并放置于IPOption的选项中。同理,我还是进一步对其进行了封装,使得可以直接如下构造。封装函数为 timestamp(flag,hop,*specified_route_ip)
。变量分别为 flag,路由数目,和如果设置 flag=3时,标记路由 IP 的可选项。
IP(dst='8.8.8.8',ttl=64,options=IPOption(timestamp(1,2)))/ICMP()
IP(dst='8.8.8.8',ttl=64,options=IPOption(timestamp(3,1,'gate.way.ip')))/ICMP()
最后我们看一下,由于安全原因,似乎绝大部分路由器都不支持或关闭对应功能的两个选项,这也是最有趣的两个选项,和本文的动机,然而并没能实现效果。这两个选项非常相似,字段上格式完全相同,因此统一叙述。
源路由与记录路由选项(loose/strict source and record route):其中 lsrr 和 ssrr 的类型值分别为 131=10000011 和 138=10001001,第二个字节依旧是该选项以字节为单位的长度。第三个字节是指针,依旧是以选项头为起点1指向路由数据部分。而路由数据则有若干IP地址构成。考虑到 IP 头的长度限制,最多只能有9个IP。该选项理应的运行方式是,当某个路由发现自己 IP 和 IP头的目的IP重合时,则考察指针指向的路由IP,如果指针没有超出选项长度的字节数,则路由将指针所指的 IP 替换到目的 IP 中,并且将路由数据该位置改为记录自己的 IP(源路由转化为记录路由),同时指针前移4字节。换句话说,指针位置之前的数据为记录路由而之后的为还没有到达的源路由。当某个路由是目的地址且此时指针已经溢出时,则为最终的目的 IP。而 loose 和 strict 的区别在于 strict 中源路由指定的 IP 必须在目的 IP 路由可以直接到达的范围内(没有额外路由转发)。因此 ssrr 最多等价于 ttl=10,无法转发到更远的地方。
+--------+--------+--------+---------//--------+
|10000011| length | pointer| route data |
+--------+--------+--------+---------//--------+
通过对源路由选项的上述描述,很明显看到其可以改变 IP 包在互联网上路由路径的潜力,从而可以实现很多有趣的事情,比如对于真个网络路由情况的大规模扫描,或是可能的黑名单 IP 的巧妙绕路到达等。然而由于该选项强大的能力,造成了巨大的安全隐患,ISP 的路由似乎都不支持该选项,并没有通过这些选项实现以上构想。
再一次,scapy 里我又对该选项做了封装,使用只需要:
IP(dst='ip1',options=IPOption(lsrr('ip2','ip3')))/ICMP()
lsrr
和 ssrr
两个函数的变量均为指定的源路由 IP 列表。以上 ping 包表示数据包需要先到达 ip1 所在的路由,再由其转发到 ip2,再由 ip2 的路由转发到 ip3。如果换做 ssrr
函数,则需要 ip1,ip2 及 ip2,ip3 在同一子网。不过说了这么多也没什么卵用,虽然 wireshark 抓包识别正常,构造应该也没啥问题,但架不住路由关闭了该选项的支持。因此 IP 包最有趣的选项就是个残废。
以上所有对 IP option 在 scapy 设置里的封装函数,详见该 gist,其中安全选项我并没写,因为那货似乎更没卵用。总之 IP 这层的东西,运营商路由不支持,自己咋折腾都白搭,基础设施掌握在人家手里,不是写写代码就能解决的问题。
总结下想到的可能 IP 层通过构造有点用,部分路由支持的应用。
- 通过调整 ttl 来实现不同跳路由 IP 的记录和可能的某些触发 (比如 TCP RST)位置的精确定位
- 通过 record route 功能,可以获取最近几个出口路由两侧网卡的 IP 地址
- 只要你有耐心,随便调节各个字段,看看你家 ISP 对 RFC 规范的支持程度究竟如何。。。。
其他的明显更有趣的QoS标记,指导流量优先级,或是源路由改变数据包路由路径这类,明显绝大部分 ISP 并不支持。
EOF