RTOS漏洞分析(四):CVE-2026-13214

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 分支)
  • 引入:1a4f092net: 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 == 50ocpp.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_msgstrcpy 这不是巧合。

这是我在审计手写解析器时优先级最高的信号

当同一种"从解析结果拷到固定缓冲"的操作,在同一个文件里既有 strncpy 又有 strcpy 时,那个 strcpy 几乎必然是漏洞。 作者知道边界这回事(写别处用对了),只是在这一个分支偷了懒。

反过来也一样:如果你在 code review 里看到一个手写解析器"全部用 strcpy",那说明作者可能根本没有边界意识,整份文件都要重看。

3.3 一句话找洞法

把这一类洞压缩成一条可执行的扫描规则:

strcpy(dest, src),当 dest 是固定尺寸栈数组、且 src 是解析结果/输入缓冲指针(不带长度)时------必查。

配上三个快速判定:

  1. src 是不是 JSON/协议解析器返回的裸指针?(→ 长度只受报文大小约束)
  2. 调用链上游有没有给 dest 传容量?(→ 没有的话,拷贝处一定无界)
  3. 同一文件里同类拷贝用没用 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

关键信息:

  1. WRITE of size 1024 ------ 一次 strcpy 写 1024 字节,目标栈缓冲只有 16 字节;
  2. 栈布局显示 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

证据链:

  1. ocpp_init_ret=0 + server_ws_connected + server_boot_rsp_sent:设备通过官方 OCPP 路径完成了与 CS 的握手,栈上已建立起真实的 OCPP 会话状态;
  2. server_malicious_getconfig_sent key_len=1024:CS 下发 1024 字节 key 的恶意 GetConfiguration;
  3. @ compiler_stack_protect.c:40栈保护 canary 被破坏------不是 assert、不是逻辑错误,是实打实的栈溢出被 canary 抓到;
  4. 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 终止 */
}

这个修复为什么"正确"且"典型":

  1. CISTR50 - 1 :留 1 字节给 NUL,这是 strncpy 的正确用法------strncpy 本身在源超长时不补 NUL ,所以必须 - 1 再手动终止(part 2 的洞恰恰就是漏了这一步);
  2. 与兄弟 handler 对齐parse_changeconfig_msg 一直就是 strncpy(key, payload.val1, CISTR50) 这种形态------修复只是把 parse_getconfig_msg 拉回到同一个安全模式;
  3. 更彻底的做法(advisory 也提到)是让解析器拿到底层长度 或返回 -EMSGSIZE,但最低限度这行改动已经消灭了 RCE。

六、总结

这个 9.8 的意义,不只是"我的第一个 CRITICAL",而是它把整个系列的线索收束到了同一个点上:

RTOS 里真正的 RCE 级漏洞,往往不是复杂的逻辑错误,而是"一个该有界的地方没有界"------而它在代码里的样子,就是一行 strcpy 它之所以能从"一行偷懒"变成"远程代码执行",是因为四层信任(JSON 零拷贝指针、缺失的 size 参数、没下沉的 schema 常量、strcpy vs strncpy 的不一致)在同一行上同时失效。

而它和 part 2 的 CVE-2026-10848 放一起,又把"越界写"的严重度规律讲透了:

能写什么,比写了几字节更重要。 一个 '\0' 和一个 1024 字节的可控字符串,差的是 7.0 和 9.8。

相关推荐
亚古数据1 小时前
韩国公司法人登记事项证明书全解析:跨境合作的“企业身份证”
大数据·人工智能·安全
fīɡЙtīиɡ ℡1 小时前
LLM/Agent 安全实战核心要点总结
网络·数据库·安全
一条泥憨鱼1 小时前
【从0开始学习计算机网络】| DNS 污染、劫持与 CDN:你的网页是怎么被“掉包“的
学习·计算机网络·安全·http·https·dns
Fnetlink12 小时前
解析SDWAN供应商光联世纪口碑佳的原因
网络·人工智能·安全
网安小学生(兼顾数据库版)2 小时前
从固件上传到合规报告:艾体宝ONEKEY实现CRA合规的操作实录
网络·安全·web安全
Multipath7122 小时前
多链路聚合设备-应急指挥车实战通信保障方案
网络·5g·安全·智能路由器·实时音视频
网安蟹佬霸2 小时前
WebAssembly安全攻防实战:从WASM逆向到漏洞利用
前端·安全·自动化·区块链·智能合约·wasm
luo_guibin2 小时前
CTFHub-Gradient sky(2020-CSICTF-Misc)
安全·渗透
网安蟹佬霸13 小时前
WAF绕过技术实战:从规则检测到编码绕过全指南(2026最新版,附Payload集与自动化脚本)
运维·安全·网络安全·架构·自动化·网安