RTOS漏洞分析:从两个"远端响应解析"漏洞出发(四)
标题沿用系列格式,但这一篇只讲一个洞------它是这个系列里的第一个 CRITICAL,也是我拿到的第一个 9.8:
- CVE-2026-13214 / GHSA-fqhf-6v24-4px2 :OCPP GetConfiguration key 解析栈溢出(CVSS 9.8 - Critical,
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) - 受影响:Zephyr
>= 4.3.0, < 4.4.2,修复在 4.4.2(afbf880,main#111242,另 backport 到 v4.4/v4.3 分支) - 引入:
1a4f092(net: ocpp: Add a support for Open Charge Point Protocol(OCPP v1.6) stack)
而且,这个洞和前两篇有很深的血缘:CVE-2026-10848(part 2 的 OCPP 洞)和 CVE-2026-13214(这一篇)在同一个子系统 subsys/net/lib/ocpp 里,甚至都是手写解析器里的"字符串拷贝"问题 。part 2 是外层 WAMP 字符串提取的 strncpy 忘了补 NUL(7.0,越界读+反射),这一篇是内层 GetConfiguration 解析的 strcpy 完全没有界(9.8,栈溢出→RCE)。
同一个子系统,两层不同的字符串拷贝错误,7.0 和 9.8 的差距,正好能说明"越界写里,写什么字节"到底有多重要。
这封信想重点讲的不只是这个洞本身,而是用户点名的那个问题------"相关漏洞可能如何存在":这一类的 bug 是怎么被写出来的、为什么几层检查全漏了、以及怎么一眼认出它。
一、CVE-2026-13214:OCPP GetConfiguration key 解析栈溢出
1.1 漏洞位置:三段代码,一条链
第一段:解析器(subsys/net/lib/ocpp/ocpp_j.c)
c
static int parse_getconfig_msg(char *json, char *key)
{
int ret;
struct json_obj_descr descr[] = {
JSON_OBJ_DESCR_ARRAY_NAMED(struct json_getconfig_payload, "key",
key, 1, key_len, JSON_TOK_STRING),
};
struct json_getconfig_payload payload = { 0 };
ret = json_obj_parse(json, strlen(json), descr,
ARRAY_SIZE(descr), &payload);
if (ret < 0) {
return ret;
}
/* key is optional so return success*/
if (payload.key[0] != NULL) {
strcpy(key, payload.key[0]); /* <-- 无界拷贝 */
}
return 0;
}
第二段:调度器(subsys/net/lib/ocpp/ocpp.c)
c
case PDU_GET_CONFIGURATION:
char skey[CISTR50]; /* 固定 50 字节栈缓冲 */
memset(skey, 0, sizeof(skey));
ret = fn(ui->recv_buf, skey); /* recv_buf = 网络收到的 JSON,skey = 目标 */
if (ret != 0) {
break;
}
if (*skey != 0) {
enum ocpp_key key;
key = ocpp_key_to_cfg(skey);
ocpp_get_configuration(key, ctx, uid);
}
...
其中 CISTR50 定义在 include/zephyr/net/ocpp.h:
c
#define CISTR50 50
第三段:字符串来源(Zephyr JSON 解析器)
json_obj_parse() 对 JSON_TOK_STRING 类型不做拷贝 ------它把 payload.key[0] 直接设成指向输入缓冲里那段字符串的指针 。这里的输入缓冲就是 ui->recv_buf,即 WebSocket 收到的整条消息。
所以 payload.key[0] 的长度只受一条约束:报文大小 (CONFIG_OCPP_RECV_BUFFER_SIZE,默认 2048)。
1.2 触发链:从中央系统的 WebSocket 到栈
OCPP 1.6 里,充电桩(Charge Point)主动连到中央系统(Central System)的 WebSocket 端点。整条链是:
text
ocpp_wsreader() # 读 WebSocket 的 reader 线程
-> ocpp_receive_from_server() # websocket_recv_msg -> ui->recv_buf
-> ocpp_process_server_msg() # 分发:按 WAMP 帧类型查 PDU 表
-> ctx->pfn[PDU_GET_CONFIGURATION] # = parse_getconfig_msg()
-> json_obj_parse() # payload.key[0] = recv_buf 内指针
-> strcpy(skey, payload.key[0]) # 无界 -> 覆盖 skey[50] 之后的栈
ocpp_process_server_msg() 跑在 ocpp_wsreader() 这个线程 的栈上(默认栈 CONFIG_OCPP_WSREADER_THREAD_STACKSIZE = 4096 字节)。skey 是它的局部变量。溢出直接覆盖这个线程的栈。
恶意帧长这样(复现里的实际报文,key 是 1024 个 K):
csharp
[2,"srv-getcfg-1","GetConfiguration",{"key":["KKKKKKKK...1024 字节..."]}]
一个控制着中央系统端点、或者在明文 ws:// 上做中间人的攻击者,只要发这一条帧,就能让设备 OCPP reader 线程的栈上多出最多 2048 字节的攻击者可控数据。
二、为什么它是 9.8
2.1 远程、零权限、零交互
看 CVSS 前一半:AV:N / AC:L / PR:N / UI:N。
- 网络可达:OCPP 默认走 WebSocket,攻击者不接触设备,只要让设备连到恶意 CS(或劫持明文连接);
- 低复杂度 :不需要猜长度、不需要绕过检查------根本没有长度检查;
- 零权限、零交互:OCPP 没有对 CS 的认证(见 part 2 引用的 OCPP 代码,握手直接过)。
这是一个"局域网里一个恶意充电桩运营商的服务器,或者一个能嗅探 ws:// 流量的中间人"就能打的问题。
2.2 可控的溢出字节:这是 9.8 和 7.0 的分水岭
这一点必须和 part 2 的 CVE-2026-10848 放一起看。同样是 OCPP、同样是手写解析器、同样是越界写:
| part 2:CVE-2026-10848 | 本篇:CVE-2026-13214 | |
|---|---|---|
| 函数 | extract_string_field(外层 WAMP) |
parse_getconfig_msg(内层 JSON key) |
| 拷贝 | strncpy(out_buf, token+1, outlen-1) 忘了 NUL |
strcpy(key, payload.key[0]) 完全无界 |
| 越界写内容 | 一个 '\0'(写超 1 字节,机会性) |
最多 2048 字节攻击者选择的数据(确定性) |
| 溢出数据可控性 | 不可控(值固定是 0) | 完全可控(key 字符串) |
| 后果 | 栈越界读 + 反射(信息泄露) | 栈上任意长度覆盖 → RCE |
| CVSS | 7.0(AC:H) | 9.8(AC:L) |
同样两个洞,"写什么字节"决定了严重度差了两个量级:
- part 2 的越界写是一个固定值
'\0',只能"碰运气"地破坏栈上恰好是"的位置,且不能选内容 → 利用成本高(AC:H),主要价值变成"读 + 反射"; - 这一篇的越界写是攻击者挑的字符串,1024 字节全是可控字节 → 栈上直接铺满攻击者数据,覆盖返回地址/保存的帧指针/局部变量都行。
在"越界写"的利用价值评估里,能写什么,比写了几字节更重要。可控字节的栈溢出,几乎是 RCE 的同义词。
2.3 两个结局:canary 抓现行 vs 无 canary 直接拿下
复现构建开了 CONFIG_STACK_CANARIES_STRONG=y,所以溢出后被 canary 抓到:
text
server_malicious_getconfig_sent key_len=1024
@ WEST_TOPDIR/zephyr/kernel/compiler_stack_protect.c:40
[00:00:02.120,000] <err> os: >>> ZEPHYR FATAL ERROR 2: Stack overflow on CPU 0
[00:00:02.120,000] <err> os: Current thread: 0x8092b74 (unknown)
[00:00:02.120,000] <err> os: Halting system
如果目标构建没有开 canary / MPU 保护 (RTOS 上很常见------默认配置为了省 flash/性能经常关),同样的 1024 字节可控数据就直接落在返回地址上。所以 advisory 的结论是:至少 DoS,很可能 RCE,取决于构建加固。
2.4 为什么 S:U 而不是 S:C
这一篇是 S:U(Scope=Unchanged),而 part 3 的两个洞是 S:C。因为溢出的目标局限在OCPP reader 线程的栈上,属于"协议子系统内部"------不像 part 3 那样越界写穿到内核堆/其他组件。但 C/I/A 全是 High:远程打崩整个设备(A)、改写设备内存/状态(I)、泄露/控制设备行为(C)。
三、这类漏洞是怎么"存在"的
用户点名要讲的:这种洞是怎么被写出来的,为什么几层检查全漏了。 这一节是这个系列里我觉得最有价值的部分。
3.1 四层信任错位,层层叠加
这个 bug 不是"某一行写错了",而是四层独立的信任错位在同一个拷贝点上叠加,任何一层拦住了都不会有 RCE。
错位一:JSON 解析器"零拷贝"返回指针------长度信息在源头就丢了
Zephyr 的 json_obj_parse 出于性能,对字符串不做拷贝,返回的是指向输入缓冲的裸指针 。这对解析器本身是对的(省内存),但它的副作用是:下游拿到的 char* 不带长度,也无法从接口上得知它有多长。
也就是说,从 parse_getconfig_msg 的视角,payload.key[0] 是一个"无限长"的字符串------它的边界信息(报文里实际多长)在解析层就被丢弃了。
错位二:调度器声明固定缓冲,却没把"容量"传给解析器
skey[CISTR50] 是 50 字节,但 parse_getconfig_msg(char *json, char *key) 的签名里没有 size 参数 。调用者知道目标有多大,被调用者不知道。这是标准的"missing destination size"设计缺陷------函数签名没有携带容量信息,拷贝处就不可能做边界判断。
错位三:schema 里的"50"只是个类型常量,不是运行时约束
CISTR50 == 50 在 ocpp.h 里定义,它描述的是"OCPP 配置 key 最长 50 字节"这个协议 schema 约束 。但它在整个拷贝路径里只是类型定义里的一个数字,从没有被作为拷贝上界强制执行。schema 约束没有下沉到运行时,就等于不存在。
错位四:拷贝函数选错------strcpy vs strncpy
strcpy 连"目标有界"这个 C 语言最基本的假设都不建立。它存在的唯一理由是"作者图省事"。
这四层任何一层成立,都不会有 RCE。 但它们在同一行 strcpy 上全数失效了。
3.2 最强审计信号:同文件里的 strcpy vs strncpy 不一致
这一节是我觉得最实用的部分。看 ocpp_j.c 里同一个文件、同一种用途的几个拷贝:
c
/* parse_getconfig_msg ------ 有洞 */
strcpy(key, payload.key[0]); /* 无界 */
/* parse_changeconfig_msg ------ 没洞 */
strncpy(key, payload.val1, CISTR50); /* 有界 */
strncpy(val, payload.val2, CISTR500); /* 有界 */
/* parse_remote_start_txn_msg ------ 没洞 */
strncpy(idtag, payload.val2, CISTR50); /* 有界 */
/* parse_idtag_info ------ 没洞 */
strncpy(idtag_info->p_idtag, parsed.json_id_tag_info.parent_id_tag,
sizeof(idtag_info->p_idtag)); /* 有界 */
同一个文件里,四个 handler 用 strncpy 加边界,唯独 parse_getconfig_msg 用 strcpy。 这不是巧合。
这是我在审计手写解析器时优先级最高的信号:
当同一种"从解析结果拷到固定缓冲"的操作,在同一个文件里既有
strncpy又有strcpy时,那个strcpy几乎必然是漏洞。 作者知道边界这回事(写别处用对了),只是在这一个分支偷了懒。
反过来也一样:如果你在 code review 里看到一个手写解析器"全部用 strcpy",那说明作者可能根本没有边界意识,整份文件都要重看。
3.3 一句话找洞法
把这一类洞压缩成一条可执行的扫描规则:
strcpy(dest, src),当dest是固定尺寸栈数组、且src是解析结果/输入缓冲指针(不带长度)时------必查。
配上三个快速判定:
src是不是 JSON/协议解析器返回的裸指针?(→ 长度只受报文大小约束)- 调用链上游有没有给
dest传容量?(→ 没有的话,拷贝处一定无界) - 同一文件里同类拷贝用没用
strncpy?(→ 用了,那这个strcpy就是例外,重点审)
3.4 和 part 2 是同一族:手写解析器的字符串拷贝
把这四篇连起来看,part 2 和 part 4 在方法论上是同一件事的两种形态:
- part 2 的
extract_string_field:用了strncpy,忘了补 NUL → 越界读 + 反射; - 这一篇的
parse_getconfig_msg:干脆用strcpy,连界都没有 → 栈溢出。
同一个子系统、相邻的两层(外层 WAMP 提取 / 内层 JSON 解析)、同一类"手写字符串拷贝",两次都因为"字符串拷贝的边界契约"出事。 这强烈地说明:手写解析器里的每一处字符串拷贝,都是一次需要单独验证的安全契约;只要用错了函数、漏了一个 NUL、少传一个 size,就是一类洞。
四、真实 Zephyr 复现
4.1 宿主 ASan:WRITE of size 1024
先把 parse_getconfig_msg 抽出来做宿主最小复现(ocpp-getconfig-stackof/,ASan 构建),构造 payload.key[0] = 1023 字节攻击者字符串:
text
Calling parse_getconfig_msg() with key buffer size=16, attacker_len=1023
ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd6cd6c8e0
WRITE of size 1024 at 0x7ffd6cd6c8e0 thread T0
#0 ... in __interceptor_strcpy
#1 ... in parse_getconfig_msg repro_ocpp_getconfig_stackof.c:74
#2 ... in main repro_ocpp_getconfig_stackof.c:94
关键信息:
WRITE of size 1024------ 一次strcpy写 1024 字节,目标栈缓冲只有 16 字节;- 栈布局显示
key在[32,48)、json在[64,82),访问偏移 48 正好冲进相邻的json变量。
4.2 真实 Zephyr 官方路径:canary 抓现行
真正有说服力的是 native_sim 官方路径 复现(ocpp-getconfig-key-strcpy-oobwrite-exp-zephyr/):不碰私有头、不走 host 模型,用 Zephyr 自己的 http_server_start + websocket_* + ocpp_init 起一个进程内 CS,走完完整的 BootNotification 握手后,再发那条恶意 GetConfiguration:
text
*** Booting Zephyr OS build v4.4.0-373-g4f2e63556a0f ***
OCPP official-path GetConfiguration overflow Zephyr EXP
http_server_start_ret=0
server_probe_ret=0
ocpp_init_ret=0
server_ws_connected sock=4
server_boot_rsp_sent
server_malicious_getconfig_sent key_len=1024
@ WEST_TOPDIR/zephyr/kernel/compiler_stack_protect.c:40
[00:00:02.120,000] <err> os: >>> ZEPHYR FATAL ERROR 2: Stack overflow on CPU 0
[00:00:02.120,000] <err> os: Current thread: 0x8092b74 (unknown)
[00:00:02.120,000] <err> os: Halting system
Exiting due to fatal error
证据链:
ocpp_init_ret=0+server_ws_connected+server_boot_rsp_sent:设备通过官方 OCPP 路径完成了与 CS 的握手,栈上已建立起真实的 OCPP 会话状态;server_malicious_getconfig_sent key_len=1024:CS 下发 1024 字节 key 的恶意 GetConfiguration;@ compiler_stack_protect.c:40:栈保护 canary 被破坏------不是 assert、不是逻辑错误,是实打实的栈溢出被 canary 抓到;ZEPHYR FATAL ERROR 2: Stack overflow:崩溃在 OCPP reader 线程上。
复现构建特意开了 CONFIG_STACK_CANARIES_STRONG=y,正好证明:在真实 Zephyr 官方消息路径上,一次远程下发的 GetConfiguration 就能打穿 reader 线程的栈。 关掉 canary(RTOS 产品里的常见选择),同一个报文就是直接控制返回地址。
五、修复:与兄弟 handler 对齐
修复(afbf880,4.4.2)非常朴素------把 strcpy 换成同文件其他 handler 已经在用的 strncpy + 显式 NUL:
c
if (payload.key[0] != NULL) {
strncpy(key, payload.key[0], CISTR50 - 1); /* 最多拷 49 字节 */
key[CISTR50 - 1] = '\0'; /* 显式 NUL 终止 */
}
这个修复为什么"正确"且"典型":
CISTR50 - 1:留 1 字节给 NUL,这是strncpy的正确用法------strncpy本身在源超长时不补 NUL ,所以必须- 1再手动终止(part 2 的洞恰恰就是漏了这一步);- 与兄弟 handler 对齐 :
parse_changeconfig_msg一直就是strncpy(key, payload.val1, CISTR50)这种形态------修复只是把parse_getconfig_msg拉回到同一个安全模式; - 更彻底的做法(advisory 也提到)是让解析器拿到底层长度 或返回
-EMSGSIZE,但最低限度这行改动已经消灭了 RCE。
六、总结
这个 9.8 的意义,不只是"我的第一个 CRITICAL",而是它把整个系列的线索收束到了同一个点上:
RTOS 里真正的 RCE 级漏洞,往往不是复杂的逻辑错误,而是"一个该有界的地方没有界"------而它在代码里的样子,就是一行
strcpy。 它之所以能从"一行偷懒"变成"远程代码执行",是因为四层信任(JSON 零拷贝指针、缺失的 size 参数、没下沉的 schema 常量、strcpyvsstrncpy的不一致)在同一行上同时失效。
而它和 part 2 的 CVE-2026-10848 放一起,又把"越界写"的严重度规律讲透了:
能写什么,比写了几字节更重要。 一个
'\0'和一个 1024 字节的可控字符串,差的是 7.0 和 9.8。