PYTHON 解码 IP 层
- 引言
- 1.编写流量嗅探器
- 1.1 Windows 和 Linux 上的包嗅探
- 2.解码 IP 层
- 2.1 struct 库
- 3.编写 IP 解码器
- 4.解码 ICMP
- 5.总结
作者:高玉涵
时间:2023.7.12
环境:Windows 10 专业版 22H2,Python 3.10.4
引言
IP 是 TCP/IP 协议族中最为核心的协议。所有的 TCP、UDP、ICMP 及 IGMP 数据都以 IP 数据报格式传输。图 1-1 所示为典型的 IPv4 头结构。但里面的信息是以二进制形式封装的,许多刚开始接触 TCP/IP 的人(包括我)很难直接读懂。这里我们可以利用 PYTHON 这门简洁且强大的语言来捕获和解码网络数据包的 IP 头部分,结合原理学习网络基础知识,往往能达到事半功倍的效果。
1.编写流量嗅探器
网络上有如 Wireshark 这样现成的嗅控工具,但不管怎样,学会自己编写简单的嗅探器来浏览和解码流量仍然很有好处。你不仅能学到一些新的 PYTHON 技巧,更加深对底层网络实现的理解。这里我将使用原始 socket 来读/写原始 IP 头或 ICMP 头等底层信息。
1.1 Windows 和 Linux 上的包嗅探
在 Windows 和 Linux 上操作原始 socket 的步骤不太相同,但嗅探工具需要具备足够的灵活性以便部署到不同平台。考虑到这一点,我们在创建 socket 对象后会检测系统环境。如果是 Windows 系统,就需要通过 socket 输入/输出控制(IOCTL)机制来设定一些标志,启用网卡的混杂模式。IOCTL 是用户程序和系统内核组件通信的一种方式,更多细节可以参考[百度百科](ioctl_百度百科 (baidu.com))中的解释。
现在我们来写第一个例子——一个简单的原始 socket 嗅探器,它只会读取一个数据包,然后直接退出:
import socket
import os
# 监听的主机
HOST = '192.168.1.105'
def main():
# 创建原始 socket, 绑定公共接口
if os.name == 'nt':
socket_protocol = socket.IPPROTO_IP
else:
socket_protocol = socket.IPPROTO_ICMP
sniffer = socket.socket(socket.AF_INET, socket.SOCK_RAW,
socket_protocol)
sniffer.bind((HOST, 0))
# 捕获 IP 头
sniffer.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
# 开启混杂模式
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
# 读数据包
print(sniffer.recvfrom(65565))
# 关闭混杂模式
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)
if __name__=='__main__':
main()
先把 HOST 变量设定成本机 IP 地址,然后构建一个 socket 对象,传入嗅探网卡数据所需的参数。这里 Windows 和 Linux 的区别是,前者允许我们嗅探任何协议的所有流入数据,而后者强制我们指定一个协议来嗅探,这里指定的是 ICMP。注意,你需要拥有 Windows 的管理员权限或 Linux 的 root 权限才能启用网卡的混杂模式。启用混杂模式后,就能嗅探到流经网卡的所有数据包,包括那些不归我们接收的数据包。接着,修改 socket 的设置,让它抓包时包含 IP 头。下一步,判断程序是不是运行在 Windows 上,如果是,就额外发送一条 IOCTL 消息启用网卡的混杂模式。现在我们就做好嗅探数据的准备了。在本例中只输出了原始数据包的全部内容,没有实际解码里面的信息,因为目前我们只想确认核心代码都能正常工作。嗅探完一个数据包后,我们会再次检测现在是不是在 Windows 平台,关闭网卡的混杂模式,然后退出。
Windows 命令提示符窗口,运行以下命令:
python sniffer.py
(b'E\x00\x004\x88\x8c@\x00\x80\x06\x00\x00\xc0\xa8\x01i\xc0\xa8\x1f\xa5\xd5\x8d\x1e\x005\t\xed\xb7\x00\x00\x00\x00\x80\x02\xfa\xf0\xa2\x85\x00\x00\x02\x04\x05\xb4\x01\x03\x03\x08\x01\x01\x04\x02', ('192.168.1.105', 0))
你会看到类似于下面内容的乱七八糟的输出。说实话,嗅探一个数据包用处不大,所以我们添加一些新功能,并解码其中的信息。
2.解码 IP 层
如果你分析过网络实际的数据包,应该能明白为什么我们需要对数据解码。图 1-1 所示。我们需要解码整个 IP 头(除了可选参数部分),并提取协议类型、源 IP 地址和目的 IP 地址等信息。这就意味着要直接跟二进制数据打交道,因此我们要找出一套用 PYTHON 分割 IP 头各个数据段的方案。
在 PYTHON 中,要把外来的二进制数据分解成数据结构有不少办法。比如,你可以用 ctypes 库或 struct 库来定义所需的数据结构。 在网上浏览各种开源工具时,你会发现这两种方法各有不少项目在用。本节会使用 struct 库读取 IPv4 头,因为它更专注于操作二进制数据。
2.1 struct 库
struct 库提供了一些格式字符,用来定义二进制数据的结构。在下面的示例中,我们将定义一个 IP 类来存储 IP 头信息。
import ipaddress
import struct
class IP:
def __init__(self, buff=None):
header = struct.unpack('<BBHHHBBH4s4s', buff)
self.ver = header[0] >> 4
self.ih1 = header[0] & 0xF
self.tos = header[1]
self.len = header[2]
self.id = header[3]
self.offset = header[4]
self.ttl = header[5]
self.protocol_num = header[6]
self.sum = header[7]
self.src = header[8]
self.dst = header[9]
# 人类可读 IP 地址
self.src_address = ipaddress.ip_address(self.src)
self.dst_address = ipaddress.ip_address(self.dst)
# 协议常量
self.protocol_map = {1: "ICMP", 6: "TCP", 17: "UDP"}
第一个格式字符(示例中的 < )永远都是用来表示数据的字节序的。C 数据类型一般是按照设备中的原生格式和字节序来存储的。在这个例子中,我们使用的是 Windows 系统(x64架构),它使用的是小端序(little-endian)。在小端序设备上,低位字节会被放在较低的内存地址上,高位字节会被放在较高的内存地址上。
接下来的格式字符是用来表示 IP 头的各部分的。struct 库提供了若干格式字符。对于 IP 头来说,我们只需要用到的只有 B(1字节,unsigned char)、H(2字节,unsigned short)和 s(一个字节数组,数组长度需要另外指定,比如 4s 就代表为 4 字节的字节数组)。留意一下我们的格式字符串和图 1-1 中所示的 IP 头结构是如何一一对应的。
在 struct 库里,不存在对应 4 位二进制位组成的数据格式字符,所以我们需要额外做一些操作,把 ver 和 hdrlen 变量从 IP 头的第一个字节里提取出来。
对于 IP 头的第一个字节,我们只想取高位作为 ver 的值。取某字节高位的常规方法是将其向右位移 4 位,相当于在该字节的开头填 4 个 0,把其尾部的 4 位挤出去。这样我们就得到了原字节的高位值。这一行 PYTHON 代码基本上就是做了如下操作:
0 1 0 1 0 1 1 0 >> 4
----------------
0 0 0 0 0 1 0 1
我们想把低位(或者说原字节的最后 4 个二进制位)填进 hdrlen 里,取某个字节低位的常规方法是将其与数字 0xF(00001111)进行按位与运算。它利用了 0 AND 1 = 0 的特性(0 代表假,1 代表真)。想要 AND 表达式为真,表达式两边都必须为真。所以这个操作相当于删除前 4 个二进制位,因为任何数 AND 0 都得 0;它保持了最后 4 个二进制位不变,因为任何数 AND 1 还是原数字。所以,基本上这一行 PYTHON 代码做的就是如下操作:
0 1 0 1 0 1 1 0
AND 0 0 0 0 1 1 1 1
--------------------
0 0 0 0 0 1 1 0
解析 IP 头其实不需要你掌握太多位运算知识,但在这种需要位移操作的情况下,解析二进制数据需要费点心思。其他情况(比如解析 ICMP 消息)大多没这么麻烦:ICMP 消息里的每一个字段位数都是 8 的整数倍,struct 的格式字符位数也都是 8 的整数倍。在图 2-1 所示的 Echo Reply ICMP 消息结构中,你可以看到 ICMP 头的每个字段都可以用一个格式字符组合来表示(BBHHH)。
因此,解析 ICMP 头结构的办法非常简单,只要为前两个成员变量分配 1 字节,为后三个成员变量分配 2 字节就可以了。
class ICMP:
def __init__(self, buff):
header = struct.unpack('<BBHHH', buff)
self.type = header[0]
self.code = header[1]
self.sum = header[2]
self.id = header[3]
self.seq = header[4]
3.编写 IP 解码器
现在,把刚才设计的 IP 头解码代码写下来,文件名就叫 sniffer_ip_header_decode.py,文件内容如下所示:
import ipaddress
import os
import socket
import struct
import sys
class IP:
def __init__(self, buff=None):
header = struct.unpack('<BBHHHBBH4s4s', buff)
self.ver = header[0] >> 4
self.ih1 = header[0] & 0xF
self.tos = header[1]
self.len = header[2]
self.id = header[3]
self.offset = header[4]
self.ttl = header[5]
self.protocol_num = header[6]
self.sum = header[7]
self.src = header[8]
self.dst = header[9]
# 人类可读 IP 地址
self.src_address = ipaddress.ip_address(self.src)
self.dst_address = ipaddress.ip_address(self.dst)
# 常用服务映射到名字
self.protocol_map = {1: "ICMP", 6: "TCP", 17: "UDP"}
try:
self.protocol = self.protocol_map[self.protocol_num]
except Exception as e:
print('%s No protocol for %s' % (e, self.protocol_num))
self.protocol = str(self.protocol_num)
def sniff(host):
if os.name == 'nt':
socket_protocol = socket.IPPROTO_IP
else:
socket_protocol = socket.IPPROTO_ICMP
sniffer = socket.socket(socket.AF_INET, socket.SOCK_RAW,
socket_protocol)
sniffer.bind((host, 0))
sniffer.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
try:
while True:
raw_buffer = sniffer.recvfrom(65565)[0]
# 取前 20 个字节创建 IP 头
ip_header = IP(raw_buffer[0:20])
# 打印检测的 IP 和协议
print('Protocol: %s %s -> %s' % (ip_header.protocol,
ip_header.src_address,
ip_header.dst_address))
except KeyboardInterrupt:
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)
sys.exit()
if __name__ == '__main__':
if len(sys.argv) == 2:
host = sys.argv[1]
else:
host = '192.168.1.105'
sniff(host)
首先,我们写下刚才的 IP 类,它定义了一个 PYTHON 结构,可以把数据包的前 20 字节映射到一个便于读/写的 IP 头对象里。如你所见,我们辨识出的所有字段都和标准的 IP 头结构完美契合。然后整理数据,将其输出为人类可读的形式,展示目前的通信协议和通信双方的 IP 地址。用上了新打造的 IP 头结构后,我们把抓包的逻辑改成持续抓包和解析。每读入一个包,就将它的前 20 字节转换成 IP 头对象。接着,只需要把抓取的信息打印到屏幕上就可以了。
我们来测试刚才写的代码,看看能从原始数据包中提取出什么样的信息。建议在 Windows 设备上测试这些代码,因为这样就能同时看到 TCP、UDP 和 ICMP 等协议的数据,易于进行一些简便的测试(比如直接打开浏览器浏览网页)。如果你不得不使用 Linux 系统,那就做一次 ping 测试吧。
python sniffer_ip_header_decode.py
Protocol: UDP 192.168.1.104 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.1 -> 239.255.255.250
Protocol: UDP 192.168.1.104 -> 239.255.255.250
Protocol: TCP 192.168.1.105 -> 20.198.162.78
因为 Windows 是个挺“健谈”的系统,所以你很可能立即就看到了测试结果。接下来,我们要运用解码 IP 头的技术来解码 ICMP 消息。
4.解码 ICMP
现在我们已经可以完整解码数据包层的 IP 层,接下来还需要解码网络数据包触发的 ICMP 响应。不同的 ICMP 消息之间千差万别,但有三个字段是一定存在的:类型(type)、代码(code)和校验和(checksum)。类型和代码两个字段告诉接收者,接下来要接收的 ICMP 信息是什么类型的,也就指明了如何正确地解码里面的数据。
这里我们需要检查类型为3、代码为 3 的 ICMP 消息。类型为 3 表示目标不可达(Destination Unreachable),而代码为 3 表示导致目标不可达的具体原因是端口不可达(Port Unreachable)。图 4-1 展示的就是 Destination Unreachable 类型的 ICMP 消息结构。
可以看到,数据包开头的 8 个二进制位代表类型,其后的 8 个二进制位代表 ICMP 代码。这里注意一个有意思的细节:当一台主机发送出 ICMP 消息的时候,会把触发 ICMP 消息的原始数据包的 IP 头附在消息未尾。另外,为了确认这个 ICMP 消息是被我们触发的,还可以自定义 8 字节的特征数据放在 UDP 数据包的开头,然后与接收到的 ICMP 消息的最后 8 字节 进行对比。关于这个技巧常用于黑客扫描工具,本文我们只研究解码数据包,另,找机会再讨论。
文件名 sniffer_with_icmp.py,文件内容如下所示:
import ipaddress
import os
import socket
import struct
import sys
class IP:
def __init__(self, buff=None):
header = struct.unpack('<BBHHHBBH4s4s', buff)
self.ver = header[0] >> 4
self.ih1 = header[0] & 0xF
self.tos = header[1]
self.len = header[2]
self.id = header[3]
self.offset = header[4]
self.ttl = header[5]
self.protocol_num = header[6]
self.sum = header[7]
self.src = header[8]
self.dst = header[9]
# 人类可读 IP 地址
self.src_address = ipaddress.ip_address(self.src)
self.dst_address = ipaddress.ip_address(self.dst)
# 常用服务映射到名字
self.protocol_map = {1: "ICMP", 6: "TCP", 17: "UDP"}
try:
self.protocol = self.protocol_map[self.protocol_num]
except Exception as e:
print('%s No protocol for %s' % (e, self.protocol_num))
self.protocol = str(self.protocol_num)
class ICMP:
def __init__(self, buff):
header = struct.unpack('<BBHHH', buff)
self.type = header[0]
self.code = header[1]
self.sum = header[2]
self.id = header[3]
self.seq = header[4]
def sniff(host):
if os.name == 'nt':
socket_protocol = socket.IPPROTO_IP
else:
socket_protocol = socket.IPPROTO_ICMP
sniffer = socket.socket(socket.AF_INET, socket.SOCK_RAW,
socket_protocol)
sniffer.bind((host, 0))
sniffer.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
try:
while True:
raw_buffer = sniffer.recvfrom(65565)[0]
# 取前 20 个字节创建 IP 头
ip_header = IP(raw_buffer[0:20])
# ICMP
if ip_header.protocol == "ICMP":
print('Protocol: %s %s -> %s' % (ip_header.protocol,
ip_header.src_address,ip_header.dst_address))
print(f'Version:{ip_header.ver}')
print(f'Header Lenght:{ip_header.ih1} TTL:{ip_header.ttl}')
# 计算 ICMP 包开始位置
offset = ip_header.ih1 * 4
buf = raw_buffer[offset:offset + 8]
# 创建 ICMP 对象
icmp_header = ICMP(buf)
print('ICMP -> Type: %s Code: %s\n' %
(icmp_header.type, icmp_header.code))
except KeyboardInterrupt:
if os.name == 'nt':
sniffer.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)
sys.exit()
if __name__ == '__main__':
if len(sys.argv) == 2:
host = sys.argv[1]
else:
host = '192.168.1.105'
sniff(host)
这段简单的代码在之前的 IP 结构下方又创建了一个 ICMP 结构。在负责接收数据包的主循环中,我们会判断接收到的数据包是否为 ICMP 数据包,然后计算出 ICMP 数据在原始数据包中的偏移,最后将数据按照 ICMP 结构进行解析,输出其中的类型(type)和代码(code)字段。IP 头的长度是基于 IP 头中的 ih1字段计算的,该字段记录了 IP 头中有多少个 32 位(4字节)长的数据块。所以只需要将这个字段乘 4,就能计算出 IP 头的大小,以及数据包中下一个网络层(这里指 ICMP)开始的位置。
Windows 命令提示符窗口,运行以下命令:
python sniffer_with_icmp.py
另,打开一个命令行窗口执行 ping 命令:
ping www.baidu.com
输出结果:
Protocol: ICMP 192.168.1.105 -> 47.254.33.193
Version:4
Header Lenght:5 TTL:128
ICMP -> Type: 8 Code: 0
Protocol: ICMP 47.254.33.193 -> 192.168.1.105
Version:4
Header Lenght:5 TTL:50
ICMP -> Type: 0 Code: 0
Protocol: ICMP 192.168.1.105 -> 47.254.33.193
Version:4
Header Lenght:5 TTL:128
ICMP -> Type: 8 Code: 0
这表明 ping (ICMP Echo)响应数据被正确地接收并解码了。
5.总结
通过上述举例。我的初衷只是为了让原本乏味枯燥的,网络相关理论知识变的有趣,为我们接下来深入学习起到“抛转引玉”的作用。