第四章 网络层PDU解密:结构、IV Index与Seq Number
好,咱们进入正题。网络层PDU,说白了就是蓝牙Mesh数据包在空中的真实模样。你抓到的那些二进制数据,最终都要解析成这个结构才能看懂。我个人习惯把网络层PDU比作一个信封——外面写着收件地址(目的地址),里面装着你的信(传输层数据),信封上还盖了个邮戳(Seq Number)和防伪标识(IV Index)。
这一章,我们就来拆开这个信封,看看每一部分到底在干什么。
4.1 网络层PDU结构
先看整体结构。一个完整的网络层PDU长这样:
+--------+--------+--------+--------+--------+--------+--------+--------+
| IVI | NID | CTL | TTL | SEQ | SRC | DST | TransportPDU | NetMIC |
| (1bit) | (7bit) | (1bit) | (7bit) | (24bit)| (16bit)| (16bit)| (可变长度) | (32/64bit)|
+--------+--------+--------+--------+--------+--------+--------+--------+--------+
嗯,这里要注意,IVI和NID其实是组合在一起的。IVI占1位,NID占7位,合起来就是一个字节。CTL和TTL同理,CTL占1位,TTL占7位,又是一个字节。这样设计是为了节省空间——蓝牙Mesh对空中包长度非常敏感。
我来逐个解释每个字段:
| 字段 | 长度 | 说明 |
|---|---|---|
| IVI | 1 bit | IV Index的最低有效位,用于解密时选择正确的IV Index |
| NID | 7 bit | 网络标识符,告诉接收方用哪组密钥来解密 |
| CTL | 1 bit | 控制位,0=接入层消息,1=控制消息 |
| TTL | 7 bit | 生存时间,每转发一次减1,0表示不再转发 |
| SEQ | 24 bit | 序列号,每个消息唯一,用于防重放 |
| SRC | 16 bit | 源地址,发送节点的单播地址 |
| DST | 16 bit | 目的地址,可以是单播、组播或虚拟地址 |
| TransportPDU | 可变 | 传输层数据,实际载荷 |
| NetMIC | 32/64 bit | 网络层消息完整性校验码,CTL=0时32位,CTL=1时64位 |
我在项目中遇到过一个问题:抓包时看到NID是0x7F,但怎么都解密不了。后来发现是IVI和NID拼反了——IVI在最高位,不是最低位。这种位序问题,Wireshark有时候不会帮你纠正,得自己留意。
4.2 IV Index的作用
IV Index,全称是Initialization Vector Index,初始化向量索引。它是个32位的值,每个节点都维护着这个值。它的核心作用有两个:
- 参与加密:IV Index是Network Nonce的一部分,没有它,解密就无从谈起
- 网络同步:所有节点必须使用相同的IV Index,否则无法通信
IV Index会随着时间变化。蓝牙Mesh规定,每过大约192小时(8天),网络可以更新一次IV Index。更新时,网络会进入一个过渡期,新旧IV Index同时有效,确保所有节点都能平滑切换。
关键点:IV Index不是直接出现在PDU里的。PDU里只带了IVI(最低1位),接收方需要根据IVI和自己的IV Index数据库来推断完整的32位IV Index。这就是为什么抓包时,如果你不知道当前的IV Index,解密就会失败。
我记得有一次帮客户排查一个Mesh网络,所有节点都连上了,但就是发不了消息。抓包一看,IVI是0,但节点的IV Index却是0x12345678——最低位是0,按理说应该匹配。再仔细看,原来网络刚完成一次IV Index更新,有些节点还在用旧值,有些已经切到新值了。嗯,这就是典型的IV Index不同步问题。
4.3 Seq Number的作用
Seq Number,序列号,24位,取值范围0到16,777,215。每个节点发送消息时,Seq Number会递增。它的作用很明确:
- 防重放攻击:接收方会记录收到的最大Seq Number,小于等于这个值的消息直接丢弃
- 消息唯一性:同一个节点发出的消息,Seq Number不会重复
- 参与Nonce生成:Seq Number是Network Nonce的一部分
你想想看,如果没有Seq Number,攻击者只要抓到一个包,反复重放,整个网络就会乱套。Seq Number就是蓝牙Mesh的「时间戳」,确保每条消息都是新鲜的。
避坑指南:我曾经遇到过一个设备,它的Seq Number在重启后重置为0。结果呢?网络里其他节点收到的消息Seq Number比之前小,直接判定为重放攻击,全部丢弃。这个设备就成了「哑巴」,发啥都没人理。解决方案是:设备重启后,Seq Number应该从上次保存的值+1开始,而不是从0开始。
4.4 Network Nonce生成规则
好,重头戏来了。Network Nonce是网络层加密的核心输入。它的结构如下:
Network Nonce (13字节):
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| NonceType | IVI | SEQ | SRC | DST | TransportPDU长度 | PAD |
| (1字节) | (4位) | (24位) | (16位) | (16位) | (8位) | (2位) |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
NonceType固定为0x00,表示这是网络层的Nonce。IVI是IV Index的最低4位(注意,不是1位,是4位)。SEQ、SRC、DST都来自PDU头部。TransportPDU长度是传输层数据的字节数。PAD是填充位,固定为0。
生成Nonce的步骤其实很简单:
- 从PDU头部提取IVI、SEQ、SRC、DST
- 计算TransportPDU的长度
- 按上面的格式拼接成13字节的Nonce
- 用这个Nonce和网络密钥(NetKey)一起,通过AES-CCM算法解密TransportPDU和NetMIC
个人经验:我写解密脚本时,最容易出错的地方就是IVI的位数。PDU头部的IVI只有1位,但Nonce里需要4位。这4位怎么来?取当前IV Index的最低4位。如果IV Index是0x12345678,最低4位就是0x8。但PDU头部的IVI是0x0(因为0x8的最低位是0)。所以Nonce里的IVI是0x8,不是0x0。这个坑,我踩过不止一次。
4.5 解密流程总览
把上面的知识点串起来,网络层解密的完整流程是这样的:
- 从抓到的PDU中提取IVI和NID
- 根据NID找到对应的NetKey
- 根据IVI和已知的IV Index,确定完整的IV Index
- 从PDU中提取SEQ、SRC、DST、TransportPDU长度
- 生成Network Nonce
- 用NetKey和Nonce,通过AES-CCM解密TransportPDU
- 验证NetMIC,确保数据完整性
下面这张图可以帮你理清整个逻辑:
这张图把整个流程串起来了。你写脚本的时候,就按这个顺序来。每一步都有对应的输入和输出,逻辑非常清晰。
4.6 实战:用Python解密网络层PDU
光说不练假把式。下面是我写的一个简化版解密函数,你可以直接拿去用:
import struct
from Crypto.Cipher import AES
def decrypt_network_pdu(pdu, netkey, iv_index):
"""
解密网络层PDU
:param pdu: 原始PDU字节流
:param netkey: 网络密钥(16字节)
:param iv_index: 当前IV Index(32位整数)
:return: 解密后的传输层PDU,或None
"""
# 1. 提取IVI和NID
first_byte = pdu[0]
ivi = (first_byte >> 7) & 0x01
nid = first_byte & 0x7F
# 2. 提取CTL和TTL
second_byte = pdu[1]
ctl = (second_byte >> 7) & 0x01
ttl = second_byte & 0x7F
# 3. 提取SEQ(3字节)
seq = struct.unpack('>I', b'\x00' + pdu[2:5])[0]
# 4. 提取SRC(2字节)
src = struct.unpack('>H', pdu[5:7])[0]
# 5. 提取DST(2字节)
dst = struct.unpack('>H', pdu[7:9])[0]
# 6. 传输层PDU和NetMIC
transport_pdu = pdu[9:-4] if ctl == 0 else pdu[9:-8]
netmic = pdu[-4:] if ctl == 0 else pdu[-8:]
# 7. 生成Nonce
nonce = bytearray(13)
nonce[0] = 0x00 # NonceType
nonce[1] = (iv_index & 0x0F) << 4 # IVI(4位)
nonce[1:4] = struct.pack('>I', seq)[1:4] # SEQ(3字节)
nonce[4:6] = struct.pack('>H', src) # SRC
nonce[6:8] = struct.pack('>H', dst) # DST
nonce[8] = len(transport_pdu) # 传输层长度
nonce[9:13] = b'\x00\x00\x00\x00' # PAD
# 8. AES-CCM解密
# 注意:这里使用AES-CCM,nonce长度为13,tag长度根据CTL决定
tag_length = 4 if ctl == 0 else 8
cipher = AES.new(netkey, AES.MODE_CCM, nonce=bytes(nonce), mac_len=tag_length)
try:
decrypted = cipher.decrypt(transport_pdu)
# 验证MIC
cipher.verify(netmic)
return decrypted
except ValueError:
print("解密失败:MIC验证不通过")
return None
使用建议:这段代码假设你已经有了正确的NetKey和IV Index。实际抓包时,NetKey通常需要从配网过程中提取,IV Index则需要从Beacon或网络状态中获取。我在课程后面的章节会专门讲怎么从抓包中提取这些参数。
好了,网络层PDU的解密就讲到这里。你掌握了结构、IV Index、Seq Number和Nonce生成规则,剩下的就是多练。找几个实际的抓包文件,自己写脚本跑一遍,比看十遍书都管用。