RTOS漏洞分析:从两个蓝牙漏洞出发
本文想讲两件事:
- 漏洞是怎么找到的;
- RTOS漏洞怎么复现。
本文分析两个我拿到CVE的真实Zephyr漏洞:
- CVE-2026-10641 / GHSA-wx5j-q6f2-59p3 :Bluetooth HFP HF
+CIND解析越界写 - CVE-2026-9263 / GHSA-6gvp-pmh8-fjh2:Bluetooth Controller ISOAL framed RX 时间偏移处理越界读
两者虽然一个是 host classic profile parser ,另一个是 controller ISO adaptation layer parser,但根子上都是同一种问题:
trusted code 解析 untrusted input 时,把"语义上必须满足的约束"误当成"格式上大致像样就行"。
而这类错误,在 RTOS 里大量存在,有的能造成危险,因为RTOS中对象紧凑、缓冲区贴得近、错误路径少、debug 配置和 release 配置差异大,一旦进入错误状态,很容易直接落成真实内存破坏或者真实信息泄露。
一、在 RTOS 里是怎么找洞的
我感觉RTOS里我能力范围内的漏洞基本集中在以下几类边界:
- userspace -> kernel
- 协议栈 / 控制器解析器 -> 内部状态机
- loader / image / descriptor / metadata parser
- 驱动层对长度、索引、offset、callback 的信任
本文两个漏洞都属于第二类:
- 上层 profile parser:
AT + 列表语法 -> 内部状态数组 - 底层 controller parser:
framed ISO PDU -> SDU 重组状态
我看这类代码时,会优先扫三种模式:
1. "循环递增索引 + 写数组"
典型形态:
c
while (has_next()) {
parse_one(...);
arr[index] = ...;
index++;
}
如果循环停止条件来自输入,而不是来自数组大小,这就是第一优先级。
2. "先做一个看起来合理的检查,后面再做语义性减法/偏移"
典型形态:
c
if (len >= header + field_len) {
...
payload_len = field_len - mandatory_prefix;
}
如果 field_len 在语义上必须 >= mandatory_prefix,但前面的检查没体现这个条件,后面就非常容易出现整数下溢,然后变成 OOB read / OOB write。
3. "协议语义要求某字段必然存在,但代码只检查了总长度,不检查语义分支"
比如:
- 某个 flag 为 0 时必须带额外 3 字节
- 某个 LLID/section type 下必须是某种 layout
- 某个 AT 子列表个数必须不超过固定上限
这类 bug 的共同点是:
代码做了"语法检查",但没做"语义检查"。
下面进入两个具体案例。
二、CVE-2026-10641:HFP HF +CIND 解析越界写
2.1 漏洞位置
文件:
subsys/bluetooth/host/classic/hfp_hf.csubsys/bluetooth/host/classic/hfp_hf_internal.hsubsys/bluetooth/host/classic/hfp_internal.h
核心代码如下:
c
static void cind_handle_values(struct at_client *hf_at, uint32_t index,
char *name, uint32_t min, uint32_t max)
{
struct bt_hfp_hf *hf = CONTAINER_OF(hf_at, struct bt_hfp_hf, at);
int i;
for (i = 0; i < ARRAY_SIZE(ag_ind); i++) {
if (strcmp(name, ag_ind[i].name) != 0) {
continue;
}
hf->ind_table[index] = i;
break;
}
}
int cind_handle(struct at_client *hf_at)
{
uint32_t index = 0U;
while (at_has_next_list(hf_at)) {
...
cind_handle_values(hf_at, index, name, min, max);
index++;
}
return 0;
}
对应结构体里:
c
int8_t ind_table[HF_MAX_AG_INDICATORS];
uint32_t hf_ind;
uint32_t ag_ind;
uint32_t ind_enable;
其中:
c
#define HF_MAX_AG_INDICATORS 20
也就是说,ind_table 只有 20 字节 ,但 index 完全由输入控制的列表个数驱动增长,没有任何上界检查。
2.2 这个洞是怎么发现的
这个漏洞其实非常"典型":
- 解析器代码看起来很干净;
name/min/max都做了正常解析;- 但真正危险的变量不是字符串,不是范围,而是 个数 ,列表元素的个数。
我扫这段代码时,关注点不是 strcmp(),而是:
c
while (at_has_next_list(hf_at))
这意味着循环次数由远端提供的 +CIND: 列表长度决定。接着又看到:
c
hf->ind_table[index] = i;
index++;
数组长度是静态 20,循环次数却来自输入,而且中间没有:
if (index >= ARRAY_SIZE(...)) break/return/error- 没有
min(list_count, HF_MAX_AG_INDICATORS) - 没有"只接受标准指标集合个数"的硬限制
这就已经足够判定它是一个高质量候选漏洞了。
换句话说,这不是"深挖数据流"才出来的洞,而是第一层 parser 审计就应该直接命中的模式。
2.3 为什么这是漏洞
关键在结构体布局。
ind_table 后面紧跟着:
hf_indag_indind_enable
因此一旦 index >= 20,写入就会落到数组外的相邻字段上。
我在真实 Zephyr qemu_x86_64/atom 环境里观察到的布局是:
text
LAYOUT ind_table=0x145905 hf_ind=0x14591c ag_ind=0x145920 ind_enable=0x145924
这说明当前编译布局下,ind_table[20] 到 hf_ind 之间有 3 字节 padding。这件事非常重要:
- 它说明漏洞一定存在;
- 但它也说明"具体打到哪个字段的哪个字节",会受编译器对齐影响。
这正是 RTOS 实战里经常被忽略的一点:
漏洞根因稳定存在,但"利用形态"要看目标架构、ABI、编译器对齐和结构体布局。
所以这里不能机械地说"第 21 个元素一定改写 hf_ind 第 0 字节",而应该先测量实际布局,再定制 payload。
2.4 利用链怎么构造
触发面
+CIND: 是 HFP HF 在 SLC 初始化中解析 AG 返回的一条 AT 响应。攻击者控制的对象不是本地 API 参数,而是:
- 远端 AG 返回的
+CIND:指标列表
也就是说,本质上是:
trusted Bluetooth host parser 解析 remote peer 提供的 AT 列表。
关键思路
PoC 不需要完整蓝牙链路,只要在 真实 Zephyr 里走到同一条解析路径即可。
因此我的 PoC 做法是:
- 构造一个真实的
struct bt_hfp_hf对象; - 注册
cind_resp作为响应处理函数; - 用
net_buf构造恶意\r\n+CIND: ...\r\n\r\nOK\r\n; - 调用
at_parse_input()进入真实解析逻辑; - 在解析前后观测相邻字段变化。
这里的重点不是"模拟蓝牙",而是:
- 进入的是Zephyr 原生 parser;
- 改写的是真实 Zephyr 结构体内存;
- 结果跑在 QEMU Zephyr 镜像 上,而不是宿主侧简化模型。
2.5 利用细节:为什么最后只改了 hf_ind 的最低字节
PoC 构造了超过 20 个指标项。前 20 个用于填满 ind_table,后续多出来的项继续越界写。
原始目标是把后面 4 次越界写分别写成已知值,观察是否能逐字节改写邻接字段。
但在 qemu_x86_64/atom 这个具体布局下:
index 20、21、22落在 padding 中;index 23才首次命中hf_ind的最低字节。
真实运行日志:
text
STATE before hf_ind=0x11223344 ag_ind=0x55667788 ind_enable=0x99aabbcc payload_len=448
STATE parse_err=0 after hf_ind=0x11223303 ag_ind=0x55667788 ind_enable=0x99aabbcc idx20-23=3,51,34,17
EXP_SUCCESS ghsa_wx5j_q6f2_59p3_hfp_hf_cind_oobwrite
这里最关键的观测值是:
hf_ind从0x11223344变成0x11223303
也就是最低字节从 0x44 被改成了 0x03。
这已经足够说明:
- 写入确实跨出了
ind_table; - 写入不是"无害写 padding"这么简单;
- 相邻真实状态字段被成功破坏。
这就是一个已打实的 Zephyr 原生 OOB write。
2.6 修复为什么是正确的
修复提交 cf7693a8261ae363c9cf46cfd51005486637173e 增加了:
c
if (index >= ARRAY_SIZE(hf->ind_table)) {
LOG_WRN("Invalid indicator index: %u", index);
return;
}
这个修复很朴素,但正中根因,因为漏洞本质就是:
- 输入驱动的列表个数
- 越过了内部固定长度数组
它不需要改 parser 框架,不需要改状态机,只要把"列表长度必须不超过本地表容量"这个语义约束补回去就够了。
三、CVE-2026-9263:ISOAL framed RX 时间偏移处理越界读
3.1 漏洞位置
文件:
subsys/bluetooth/controller/ll_sw/isoal.c
关键代码分成两段看。
第一段:segment header 检查
c
static isoal_sdu_status_t isoal_check_seg_header(struct pdu_iso_sdu_sh *seg_hdr,
uint8_t pdu_size_remaining)
{
if (!seg_hdr) {
return ISOAL_SDU_STATUS_ERRORS;
}
if (pdu_size_remaining >= PDU_ISO_SEG_HDR_SIZE &&
pdu_size_remaining >= PDU_ISO_SEG_HDR_SIZE + seg_hdr->len) {
return ISOAL_SDU_STATUS_VALID;
}
return ISOAL_SDU_STATUS_LOST_DATA;
}
第二段:framed consume 时处理 time offset
c
uint8_t length = seg_hdr->len;
...
if (!seg_hdr->sc) {
offset = offset + PDU_ISO_SEG_TIMEOFFSET_SIZE;
length = length - PDU_ISO_SEG_TIMEOFFSET_SIZE;
}
其中 PDU_ISO_SEG_TIMEOFFSET_SIZE 是 3。
3.2 这个漏洞是怎么被发现的
这是非常标准的"检查和消费语义不一致"漏洞。
parser 的语义约束
在 framed ISO PDU 里:
- 如果
sc = 0 - 那么 segment header 后面必须 跟着 3 字节
time_offset
也就是说,语义上必须满足:
c
seg_hdr->len >= 3
代码实际检查了什么
isoal_check_seg_header() 只检查:
c
pdu_size_remaining >= 2 + seg_hdr->len
这最多说明:
- "PDU 里有这么多被声明出来的 segment 长度"
但并没有说明:
- "当
sc=0时,这个长度里至少容得下 mandatory 的 3 字节 time_offset"
于是就出现了典型错位:
- 前面把
len=0/1/2判成"合法" - 后面又默认它一定
>=3 - 接着做
length - 3
如果 length 是 uint8_t,那么:
0 - 3 = 2531 - 3 = 2542 - 3 = 255
这时"剩余负载长度"不再是小负数,而是巨大的无符号值,后面复制逻辑自然就会失控。
我看到这一组代码时,几乎可以直接下结论:
这条路径不是 assert-only 问题,而是高概率能落成真实 OOB read。
3.3 为什么这里会变成真实信息泄露
这一点比第一个漏洞更有意思,因为它不是直接 crash,而是把相邻内存当成 SDU 负载发出去。
漏洞链条如下:
- 攻击者构造一个 framed RX PDU;
- 设置
sc = 0,让代码认为后面应该有 3 字节time_offset; - 但把
seg_hdr->len伪造成0; isoal_check_seg_header()因为只看2 + len,把它误判为有效;isoal_rx_framed_consume()执行:offset += 3length -= 3
length从0下溢成253;- 后续
isoal_rx_append_to_sdu()从pdu->payload + 5开始,复制 253 字节 到输出 SDU; - 这 253 字节里,前面一部分已经不属于声明的 PDU 负载,而是相邻内存内容。
所以这个漏洞最终形成的是:
controller 内部相邻内存 -> ISOAL 重组后的 SDU -> 上送到 host 的可见输出
这已经不是"越界读但没有外泄路径"的温和问题,而是有非常清晰的数据外流通道。
3.4 利用时最关键的一个实验设计:baseline 和 trigger 只差一个字节
这个漏洞的 PoC 设计,我刻意做成了"最小差异对照实验"。
baseline
seg_len = 3- 这是
sc=0时的最小合法值 - 理论上此时只有 time offset,没有实际 SDU 负载
- 因此结果应该是:不拷贝任何字节
trigger
- 只把
seg_len从3改成0 - 其他字段尽量不变
这样做的价值非常大,因为它把结论压缩成一句话:
漏洞不是复杂状态联动导致的;只要把语义上必须为 3 的字段改成 0,就能从"零拷贝"瞬间变成"253 字节越界读"。
这是一种非常强的漏洞证明方式。
3.5 真实 Zephyr 复现结果说明了什么
PoC 运行在 Zephyr native_sim/native/64,关键日志如下:
text
BASELINE_OK no bytes copied when seg_len=3
TRIGGER_STATS write_calls=1 total_written=253 emitted_count=2 frag=253 total=253
LEAK_PREFIX KERN_SECRET_ISOAL_TIMEOFFSET
EXP_SUCCESS leaked_prefix_len=28 first_marker=KERN_SECRET_ISOAL_TIMEOFFSET
这几行日志分别证明了四件事。
第一,正常边界条件是稳定的
seg_len=3 时没有复制任何数据,说明 baseline 没问题。
第二,触发后真的发生了大尺寸异常复制
total_written=253 说明不是"多读 1~2 字节"这种边缘问题,而是下溢之后的整块错误复制。
第三,复制出来的数据确实来自相邻内存
PoC 在 PDU 有效区域之后放了一个隐藏标记:
text
KERN_SECRET_ISOAL_TIMEOFFSET
结果这个前缀真实出现在输出 SDU 里。
第四,漏洞有明确外泄语义
这一步最重要。很多越界读只能证明"读到了不该读的数据",但不能证明"数据能出去"。
而这里,数据已经进入 Zephyr ISOAL 输出路径,变成了可观察的 SDU 内容,所以它是一个有外泄价值的 OOB read。
3.6 这个洞的发现过程,比数组越界更有代表性
第一个漏洞属于经典索引越界;第二个漏洞更能体现 parser 审计的核心技巧:
不要只看"有没有检查",要看"检查的条件"和"后续消费逻辑要求的语义"是不是一回事。
很多代码在代码评审里很容易过关,因为大家会下意识认为:
- "前面已经调用
check_*()了" - "既然进来了,后面减 3 没问题"
但真正危险的地方恰恰是:
check_*()只检查了"报文总长度够不够"- 后面消费逻辑却依赖"某个语义分支下 mandatory 字段一定存在"
这类 bug 在协议解析器、镜像解析器、descriptor parser、TLV parser、relocation parser 里都非常常见。
四、两个漏洞放在一起看,真正该学到什么
这两个漏洞一个在 Bluetooth host classic profile,一个在 Bluetooth controller ISOAL,看起来跨度很大,但漏洞发现逻辑几乎一模一样。
4.1 共性一:都不是"复杂算法错误",而是"局部信任错位"
CIND漏洞信任了"远端列表个数不会超过本地数组容量"ISOAL漏洞信任了"只要 segment 总长度自洽,就一定满足 time offset 语义"
都属于:
把来自外部输入的约束,错误地当成了内部不变量。
4.2 共性二:都很适合用"结构化最小 PoC"证明
CIND:只要构造超过 20 个指标项即可ISOAL:只要把seg_len从 3 改成 0 即可
这说明它们不是需要非常苛刻时序和复杂环境的"脆弱漏洞",而是根因非常干净。
4.3 共性三:都必须在真实 Zephyr 环境里验证,不能只做宿主模型
这是我这次复现里最在意的一点。
如果只在宿主上写一个小 C 程序模拟:
- 当然也能证明"这种代码模式危险";
- 但不能证明"Zephyr 里真能打到这条路径、真能改到相邻字段、真能把数据带出去"。
所以这次我坚持:
CVE-2026-10641用 QEMU Zephyr 复现真实 OOB writeCVE-2026-9263用 native_sim Zephyr 复现真实 OOB read + leak
这两种结果都明显强于 host-side model。
五、从利用角度看,这两个洞的实际价值
5.1 CVE-2026-10641 的利用价值
它的直接效果是:
- 远端 AT 响应驱动的内部状态结构体越界写
在我当前复现环境里,已经成功打到 hf_ind 的最低字节。进一步利用价值取决于:
- 目标架构下的结构体布局;
- 编译器 padding;
- HFP HF 状态机后续如何使用这些字段;
- 是否能结合别的逻辑路径,把状态破坏转化成更强的控制力。
所以这个洞的价值不只是"写坏一个字节",而是:
给了远端对 host profile 内部状态对象的越界写原语。
5.2 CVE-2026-9263 的利用价值
它的价值更直接:
- 可以把 controller 相邻内存带入输出 SDU
这意味着:
- 泄露的不只是随机垃圾;
- 理论上可能是之前的缓冲区内容、状态字段、未初始化数据、别的控制器内部对象片段;
- 如果 host 侧会继续处理这些 SDU,信息影响面还会扩大。
所以这是一个典型的:
低层控制器 parser 错误 -> 控制器内存外泄 -> 上层可见数据面
这类洞在协议栈里一向是高价值问题。
六、给审计者的几个实战建议
如果你也在看 RTOS / 嵌入式协议栈,我建议把下面这几条当成固定 checklist。
6.1 不要被"有检查函数"迷惑
看到 check_xxx() 不是结束,而是开始。要继续问:
- 它检查的是语法 还是语义?
- 它检查的条件是否覆盖了后续所有分支依赖?
6.2 看到输入驱动的 index++,默认当成高危点
尤其是这种模式:
c
while (parse_next()) {
table[index] = ...;
index++;
}
这类代码在驱动、协议、shell、loader、descriptor parser 里都值得优先怀疑。
6.3 对整数下溢要比整数上溢更敏感
很多人会盯着:
len + xoffset + size
但在 parser 里更容易出事的往往是:
len - mandatory_headerremaining - prefix
因为这些字段经常是 uint8_t 或 uint16_t,一下溢就会变成大值,后面的 copy 长度直接失控。
6.4 做 PoC 时尽量设计 baseline / trigger 对照
好的 PoC 不只是"能 crash / 能泄露",还应该尽量回答:
- 正常情况下应该发生什么?
- 只改一个关键字节后发生了什么?
这样写出来的漏洞证明最有说服力,也最容易说服维护者接受。
七、POC说明
本文不贴完整 PoC 代码,我仔细想了想文末不附完整POC需要的话私聊吧。
八、总结
把这篇文章压缩成一句话,GPT给我的回答是:
RTOS 漏洞挖掘里,最值得花时间的地方,不是"大而全地扫代码",而是抓住那些"trusted code 解析 untrusted data"的边界,然后盯死长度、索引、offset、mandatory field 和状态机语义。
这次两个 Zephyr 蓝牙漏洞,一个落成 真实越界写 ,一个落成 真实越界读并外泄,根因都不复杂,但都非常有代表性。
也正因为根因不复杂,它们才说明一件更重要的事:
真正高质量的 RTOS 漏洞,很多时候不是藏在非常深的技巧里,而是藏在那些"大家都觉得这段 parser 看起来挺正常"的地方。