第四章 网络层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的步骤其实很简单:

  1. 从PDU头部提取IVI、SEQ、SRC、DST
  2. 计算TransportPDU的长度
  3. 按上面的格式拼接成13字节的Nonce
  4. 用这个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 解密流程总览

把上面的知识点串起来,网络层解密的完整流程是这样的:

  1. 从抓到的PDU中提取IVI和NID
  2. 根据NID找到对应的NetKey
  3. 根据IVI和已知的IV Index,确定完整的IV Index
  4. 从PDU中提取SEQ、SRC、DST、TransportPDU长度
  5. 生成Network Nonce
  6. 用NetKey和Nonce,通过AES-CCM解密TransportPDU
  7. 验证NetMIC,确保数据完整性

下面这张图可以帮你理清整个逻辑:

网络层PDU解密流程 原始PDU数据 步骤1:提取IVI(1bit)和NID(7bit) 步骤2:根据NID查找NetKey 步骤3:根据IVI确定完整IV Index 步骤4:提取SEQ、SRC、DST、长度 步骤5:生成Network Nonce 步骤6:AES-CCM解密并验证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生成规则,剩下的就是多练。找几个实际的抓包文件,自己写脚本跑一遍,比看十遍书都管用。