16、BLE重放攻击:数据包捕获与重放、时间戳防御机制、序列号防御机制、挑战-响应认证

各位同学,今天我们来聊一个在蓝牙安全里非常经典,也特别容易被忽视的问题——重放攻击

说白了,重放攻击就是攻击者把之前合法设备发过的数据包,原封不动地再发一遍。你想想看,如果接收方没有做任何防重放处理,它就会以为这是一条新的合法指令,然后乖乖执行。我在项目中就遇到过这样的案例:一个智能门锁,攻击者抓到了开锁指令的数据包,然后反复重放,门就开了。嗯,这很可怕。

那么,BLE里怎么防重放?主要有三种手段:时间戳序列号挑战-响应认证。我们一个一个来看。

16.1 数据包捕获与重放:攻击者是怎么做到的?

先说说攻击者怎么捕获数据包。BLE通信是无线广播的,任何在信号范围内的设备都能收到。攻击者只需要一个支持BLE嗅探的硬件,比如nRF52840 DK或者Ubertooth One,配合Wireshark或者btlejack这类工具,就能把空中的数据包抓下来。

我个人的习惯是,在做安全测试时,先用btlejack扫描一下周围设备的BLE广播和连接数据。你会发现,很多设备的数据包结构非常简单,甚至明文传输。比如下面这个例子:

# 使用btlejack捕获BLE数据包
btlejack -f -c 37,38,39 -o capture.pcap

# 然后用Wireshark打开分析
wireshark capture.pcap

攻击者抓到数据包后,重放就更简单了。直接用btlejack的replay功能,或者自己写个脚本用hcitool发出去。比如:

# 重放一个捕获到的连接请求包
btlejack -r captured_packet.bin -c 38

你想想看,如果这个包是开锁指令、支付确认、或者医疗设备的给药指令,后果有多严重?

⚠️ 注意: 重放攻击不限于连接后的数据包。广播包、扫描请求、连接请求,只要没有防重放机制,都可以被重放。我在一个智能灯泡的项目里就发现,它的开关指令是广播包,没有任何保护,重放一次就开关一次。

16.2 时间戳防御机制:给数据包打上时间烙印

时间戳防御,说白了就是在每个数据包里加上当前的时间。接收方收到后,检查这个时间戳是否在允许的误差范围内。如果时间差太大,就认为是重放攻击,直接丢弃。

举个例子,假设设备A给设备B发指令,数据包里带上当前Unix时间戳:

{
  "command": "unlock",
  "timestamp": 1712345678
}

设备B收到后,检查自己的当前时间与这个时间戳的差值。如果差值小于5秒,就认为是合法指令;否则丢弃。

但这里有个坑——时间同步。两个设备的时间必须基本一致,否则会误判。我在项目中遇到过,一个智能手表和手机的时间差了10秒,结果所有指令都被当成重放丢弃了。后来我们加了一个时间同步协议,每次连接时先校准时间。

💡 小技巧: 时间戳防御适合那些对实时性要求不高的场景,比如门锁、灯控。但对于高频数据交换,比如音频流、传感器数据流,时间戳的精度可能不够,这时候序列号更合适。

16.3 序列号防御机制:递增的计数器

序列号防御,就是通信双方维护一个递增的计数器。每次发送数据包,序列号加1。接收方只接受序列号比上一次大的数据包。如果收到重复的序列号,直接丢弃。

这个机制在BLE的LL层(链路层)就已经实现了。每个连接事件都有一个递增的connEventCounter。但问题是,这个计数器只在连接期间有效。如果连接断开重连,计数器会重置。攻击者可以在重连后重放之前的数据包。

所以,应用层也需要自己维护序列号。比如:

// 发送方
uint32_t seq_num = 0;
void send_secure_command(uint8_t *cmd, uint32_t len) {
    uint8_t packet[len + 4];
    packet[0] = (seq_num >> 24) & 0xFF;
    packet[1] = (seq_num >> 16) & 0xFF;
    packet[2] = (seq_num >> 8) & 0xFF;
    packet[3] = seq_num & 0xFF;
    memcpy(packet + 4, cmd, len);
    // 发送packet
    seq_num++;
}

// 接收方
uint32_t last_seq = 0xFFFFFFFF; // 初始化为最大值
bool is_valid_packet(uint8_t *packet) {
    uint32_t seq = (packet[0] << 24) | (packet[1] << 16) | (packet[2] << 8) | packet[3];
    if (seq <= last_seq) {
        return false; // 重放或乱序
    }
    last_seq = seq;
    return true;
}

嗯,这里要注意:序列号不能回绕得太快。如果序列号是32位的,每秒发1000个包,也要136年才回绕一次,基本够用了。但如果是8位的,256个包就回绕了,攻击者很容易利用回绕来重放。

🔑 核心要点: 序列号防御的关键是持久化存储。设备重启后,序列号不能重置为0,否则攻击者可以在重启后重放旧包。我建议把序列号保存在NVM(非易失性存储器)里,每次启动时读取。

16.4 挑战-响应认证:双向验证的终极方案

时间戳和序列号都有各自的局限性。时间戳依赖时钟同步,序列号依赖持久化存储。有没有更通用的方案?有——挑战-响应认证

这个机制的原理很简单:

  1. 接收方(比如门锁)生成一个随机数,称为“挑战”(Challenge),发给发送方(比如手机)。
  2. 发送方用共享密钥对这个挑战进行加密或签名,生成“响应”(Response),发回给接收方。
  3. 接收方用同样的密钥验证响应是否正确。如果正确,说明发送方是合法的,并且这个响应只针对本次挑战,无法重放。

用流程图来表示就是:

挑战-响应认证流程 手机(发起方) 门锁(接收方) 生成随机挑战 C ① 挑战 C(随机数) 计算 R = HMAC(K, C) ② 响应 R 验证 R == HMAC(K, C) ③ 验证通过/拒绝 共享密钥 K(预置或协商)

你看,这个流程里,每次的挑战都是随机生成的,所以即使攻击者抓到了某次通信的响应,下次挑战变了,这个响应就无效了。这就是挑战-响应能防重放的根本原因。

在BLE里,挑战-响应认证通常用在配对过程中。比如BLE 4.2的LE Secure Connections就用了类似机制。但应用层也可以自己实现,比如:

// 门锁端:生成挑战并验证
void handle_challenge_request(uint8_t *challenge, uint8_t *response) {
    // 生成16字节随机挑战
    uint8_t challenge[16];
    generate_random(challenge, 16);
    
    // 发送挑战给手机
    send_to_phone(challenge, 16);
    
    // 等待手机返回响应
    uint8_t response[16];
    recv_from_phone(response, 16);
    
    // 用共享密钥计算期望的响应
    uint8_t expected[16];
    hmac_sha256(shared_key, challenge, 16, expected, 16);
    
    // 比较
    if (memcmp(response, expected, 16) == 0) {
        // 认证通过
        unlock_door();
    } else {
        // 认证失败
        reject();
    }
}
🔑 核心要点: 挑战-响应认证是目前最可靠的防重放手段之一。它不依赖时钟同步,也不依赖持久化计数器。但前提是共享密钥必须安全存储。如果密钥被泄露,整个机制就形同虚设。

16.5 三种防御机制的对比

最后,我用一个表格来总结这三种机制的优缺点:

防御机制 原理 优点 缺点 适用场景
时间戳 数据包携带时间,接收方检查时间差 实现简单,无需额外交互 需要时钟同步,误差窗口内可被重放 实时性要求不高的指令(门锁、灯控)
序列号 递增计数器,接收方只接受更大的序列号 无需时钟同步,实现简单 需要持久化存储,重启后可能重置 连接稳定的数据流(传感器、音频)
挑战-响应 接收方发随机挑战,发送方用密钥计算响应 最安全,不依赖时钟和持久化 需要额外交互,增加延迟 配对、认证、高安全场景

我个人建议,在实际项目中,不要只用一种机制。比如,可以用挑战-响应来做初始认证,认证通过后用序列号来保护后续的数据包。这样既保证了安全性,又兼顾了效率。

嗯,关于BLE重放攻击的防御,今天就讲到这里。记住一句话:任何没有防重放机制的BLE通信,都是不安全的。下次你在设计BLE应用时,一定要问自己一句:我的数据包能被重放吗?


公众号:蓝海资料掘金营,微信deep3321