RTOS漏洞分析:从两个"内核验证层"漏洞出发(三)
前两篇讲的都是协议解析 :一篇是蓝牙 host/controller 的上行解析(CVE-2026-10641、CVE-2026-9263),一篇是 OTA/充电协议的下行响应解析(CVE-2026-10849、CVE-2026-10848)。四个洞的共性都是"解析器信任了远端输入的格式/长度约束"。
这一篇换个完全不同的边界:讲讲我在userspace -> kernel 拿到的两个CVE。也就是用户线程通过系统调用进入内核时,内核侧那层**验证函数(z_vrfy_* / getsockopt 的 syscall verifier)**到底验证了什么。
- CVE-2026-9771 / GHSA-68cj-3hg4-5vpm :
flash_copy()系统调用缺少设备指针校验,允许用户态权限提升(CVSS 8.8 - High) - CVE-2026-8718 / GHSA-p3r6-mx6c-33gq :
TLS_DTLS_PEER_CID_VALUE的 getsockopt getter 忽略调用者缓冲区大小,越界写(CVSS 8.4 - High)
这两个洞可以归纳成同一句话:
内核验证层(syscall verifier)是 userspace 和 kernel 之间的"契约":验证函数负责把"用户传进来的参数"翻译成"内核可以信任的东西"。而这里存在的洞要么是验证函数忘了验证某些参数,要么是验证函数验证了、但后端实现根本没遵守。
前两篇的漏洞是"解析器信任",这一篇的漏洞是"验证层信任 "。展开讲,这两个洞正好是验证层契约的两种不同断裂方式:
- 验证不全 (flash_copy):
z_vrfy_flash_copy()只验证了输出缓冲,漏掉了对设备指针的校验 ,把未经k_object_validate的对象指针直接交给实现层去解引用函数指针; - 验证与实现脱节 (DTLS CID):verifier 严格按
optlen分配了内核 bounce buffer,后端 getter 却无视optlen,把完整的 32 字节 CID 直接写进去。
下面分开看。
一、CVE-2026-9771:flash_copy() 缺少设备指针校验
1.1 漏洞位置
drivers/flash/flash_util.c,两个函数:
c
/* 内核验证函数:userspace 的信任边界 */
int z_vrfy_flash_copy(const struct device *src_dev, off_t src_offset,
const struct device *dst_dev, off_t dst_offset,
off_t size, uint8_t *buf, size_t buf_size)
{
K_OOPS(K_SYSCALL_MEMORY_WRITE(buf, buf_size));
return z_impl_flash_copy(src_dev, src_offset, dst_dev, dst_offset,
size, buf, buf_size);
}
c
/* 实现函数:在超级用户模式下真正执行 flash 操作 */
int z_impl_flash_copy(const struct device *src_dev, off_t src_offset,
const struct device *dst_dev, off_t dst_offset,
off_t size, uint8_t *buf, size_t buf_size)
{
...
if (!device_is_ready(src_dev)) {
return -ENODEV;
}
if (!device_is_ready(dst_dev)) {
return -ENODEV;
}
write_size = flash_get_write_block_size(dst_dev);
...
for (uint32_t offs = 0, ...; offs < N; ...) {
if (bytes_left < write_size) {
const struct flash_driver_api *api =
(const struct flash_driver_api *)dst_dev->api;
const struct flash_parameters *params = api->get_parameters(dst_dev);
...
}
...
ret = flash_read(src_dev, src_offset + offs, buf, bytes_read);
...
ret = flash_write(dst_dev, dst_offset + offs, buf, bytes_read);
...
}
return 0;
}
flash_copy() 是 Zephyr 在 4.2 引入的"闪存片间拷贝"系统调用:把 src_dev 上的一段 flash 拷到 dst_dev 上,用一个用户提供的 buf 做中间缓冲。在 CONFIG_USERSPACE 构建里,z_vrfy_flash_copy() 就是它面向用户态的信任边界。
1.2 一个"半成品"的验证函数
看 z_vrfy_flash_copy() 的完整验证逻辑,非常简洁就一行:
c
K_OOPS(K_SYSCALL_MEMORY_WRITE(buf, buf_size));
它只检查了输出缓冲 buf------确认用户传入的缓冲确实是用户内存且可写。而两个最关键的参数,src_dev 和 dst_dev,一行校验都没有。
对比同文件里它的每一个"兄弟"系统调用(flash_read / flash_write / flash_erase / flash_get_parameters......),它们的 verifier 无一例外都先校验设备指针:
c
/* drivers/flash/flash_handlers.c */
static inline int z_vrfy_flash_read(const struct device *dev, off_t offset,
void *data, size_t len)
{
K_OOPS(K_SYSCALL_DRIVER_FLASH(dev, read)); /* <-- 有 */
K_OOPS(K_SYSCALL_MEMORY_WRITE(data, len));
return z_impl_flash_read((const struct device *)dev, offset,
(void *)data, len);
}
static inline int z_vrfy_flash_erase(const struct device *dev, off_t offset,
size_t size)
{
K_OOPS(K_SYSCALL_OBJ(dev, K_OBJ_DRIVER_FLASH)); /* <-- 有 */
return z_impl_flash_erase((const struct device *)dev, offset, size);
}
K_SYSCALL_DRIVER_FLASH(dev, op) 会展开成 K_SYSCALL_DRIVER_OP + K_SYSCALL_OBJ 的组合,作用有两个:
K_SYSCALL_OBJ(dev, K_OBJ_DRIVER_FLASH):调用k_object_validate(),确认这个指针是一个合法的 flash 内核对象 ,而且调用线程对该对象有权限;K_SYSCALL_DRIVER_OP:确认dev->api指向的 driver API 表里,对应的操作(read/write)确实存在。
flash_copy 偏偏跳过了这整个校验。
一个验证函数,把"缓冲"和"对象"两类参数分开了:缓冲验证了,对象一个都没验证。这就是"验证不全"。
1.3 为什么伪造 struct device 就能 LPE
要理解这个洞的严重性,先看 flash_read / flash_write 到底怎么调用驱动:
c
static inline int z_impl_flash_read(const struct device *dev, off_t offset,
void *data, size_t len)
{
return DEVICE_API_GET(flash, dev)->read(dev, offset, data, len);
}
#define DEVICE_API_GET(_class, _dev) \
((const struct Z_DEVICE_API_TYPE(_class) *)_dev->api)
也就是说,flash_read(dev, ...) 展开后是:
c
((const struct flash_driver_api *)dev->api)->read(dev, offset, data, len);
这是一次通过 dev->api 的函数指针间接调用 。在正常情况下,dev->api 指向内核里静态注册的 flash_driver_api 表,没问题。但在 z_impl_flash_copy 里,src_dev 和 dst_dev 是用户传进来的、未经任何校验的裸指针。
struct device 的布局(include/zephyr/device.h):
c
struct device {
const char *name;
const void *config;
/** Address of the API structure exposed by the device instance */
const void *api; /* <-- 函数指针表 */
struct device_state *state;
...
};
在 CONFIG_USERSPACE 下,用户线程完全可以 在自己的内存里伪造一个 struct device:自己分配一个对象,填上伪造的 api 指针,指向一块同样由用户控制的 flash_driver_api 结构体------里面的 read、write、get_parameters 全部是指向攻击者选定地址的函数指针。
然后把这两个伪造指针传给 flash_copy()。内核在超级用户模式下执行:
c
device_is_ready(src_dev); /* 读伪造的 state->initialized,用户可控 */
api->get_parameters(dst_dev); /* 用户可控函数指针 */
flash_read(src_dev, ...); /* ((flash_api*)src_dev->api)->read(...) ------ 用户可控 */
flash_write(dst_dev, ...); /* 用户可控函数指针 */
这就是标准的 userspace 逃逸原语:
非特权线程让内核在超级用户模式下,用基本可控的参数,调用一个攻击者选定的函数指针。
而且参数也不是完全不可控的:read/write 收到的 (dev, offset, data, len) 里,data 指向已校验的用户缓冲(内核 bounce),offset/len 由攻击者的 src_offset/size 控制。攻击者可以选一个"把内存写到任意地址"之类的现有内核函数(gadget),或者直接指向一个能改写权限状态的位置。
这就是 CVE-2026-9771 的标题所在:本地权限提升(LPE) 。CVSS 8.8(AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H,Scope=Changed),因为一个"flash 驱动子系统"里的洞可以越过组件边界,把内核完全拿下来。
1.4 两级利用
这个洞其实有两个利用级别,值得分开说。
第一级:混淆代理 / 权限绕过(有真实复现)
flash_copy() 的目的就是"copy between devices"。既然 verifier 不校验设备指针,那么任何用户线程都可以拿任何 flash 设备对象来做 read/write------即使它从未被授予该设备的访问权限。攻击者不需要伪造任何东西,只要知道(或能读到)一个 flash 设备对象的内核地址,把它传进去就能用。
这是一个标准的 Confused Deputy:受信任的内核,被一个权限不足的调用者指挥着去操作它没权限碰的设备。
第二级:伪造设备指针 -> 内核态任意函数调用(CVE 标题)
更进一步,用户可以不传真实设备,而是传一个伪造的 struct device ,把 api->read / api->write / api->get_parameters 指向攻击者选定的地址。因为第一级已经证明"指针未校验",第二级是它的直接推论------把"合法的指针"换成"伪造的指针",同一个漏洞就升级成任意内核函数指针调用 / LPE。
第一级证明"验证确实缺失",第二级把缺失变成代码执行。同一个缺失,两个后果。
1.5 真实 Zephyr 复现
复现跑在 Zephyr qemu_x86_64 ,CONFIG_USERSPACE=y(drivers/flash/flash_util.c 的同路径)。PoC 的流程:
- 内核线程(supervisor)初始化 flash 模拟器,在
SRC_OFF写入已知 pattern,DST_OFF初始为全0xff; - 创建用户线程,故意不把 flash 设备对象加入它的权限集;
- 用户线程调用
flash_copy(flash_dev, SRC_OFF, flash_dev, DST_OFF, ...); - 观察返回值和目标区域内容。
真实输出(flash-copy-device-perm-bypass-exp-zephyr,v4.4.0-373-g4f2e63556a0f):
text
*** Booting Zephyr OS build v4.4.0-373-g4f2e63556a0f ***
PoC: flash_copy() missing flash device permission checks
PREP_ERASE rc=0
PREP_WRITE rc=0
BASELINE_READ rc=0
BEFORE_DST ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
user: start
USER_FLASH_COPY rc=0
POST_READ rc=0
AFTER_DST a0 a1 a2 a3 a4 a5 a6 a7 b0 b1 b2 b3 b4 b5 b6 b7
EXP_SUCCESS user copied flash data without flash device permission
这四行证据链:
BEFORE_DST ff ff ff:目标区域初始是全0xff(已擦除);USER_FLASH_COPY rc=0:一个没有 flash 设备权限的用户线程 调用flash_copy()返回成功;AFTER_DST a0 a1 ... b7:目标区域变成了 supervisor 预先写入的 pattern;EXP_SUCCESS:判断条件user_rc == 0 && memcmp(check, pattern) == 0成立。
也就是说,未授权用户线程通过官方 flash_copy() 系统调用,真的把一个 flash 区域的数据拷贝到了另一个区域------权限模型被绕过了。这是第一级(混淆代理)在真实 Zephyr 里的铁证;第二级(伪造设备指针)是同一个缺失的直接升级。
1.6 修复:把漏掉的那两行补回去
修复(advisory 建议的形态)非常朴素------把兄弟 syscall 都在做的那两行校验补进 verifier:
c
int z_vrfy_flash_copy(const struct device *src_dev, off_t src_offset,
const struct device *dst_dev, off_t dst_offset,
off_t size, uint8_t *buf, size_t buf_size)
{
K_OOPS(K_SYSCALL_DRIVER_FLASH(src_dev, read));
K_OOPS(K_SYSCALL_DRIVER_FLASH(dst_dev, write));
K_OOPS(K_SYSCALL_MEMORY_WRITE(buf, buf_size));
return z_impl_flash_copy(src_dev, src_offset, dst_dev, dst_offset,
size, buf, buf_size);
}
K_SYSCALL_DRIVER_FLASH(src_dev, read) 一步完成两件事:k_object_validate 校验对象合法性 + 调用者权限,同时确认 driver API 里 read 存在。这样伪造指针在任何解引用之前就被拦下。
这个洞之所以值得记一笔,是它的"对照"价值:同文件的每一个兄弟 syscall 都做了校验,唯独新增的 flash_copy 漏了。这种"复制粘贴模板时少贴一行校验"的错误,在系统调用密集的边界上特别常见。
二、CVE-2026-8718:TLS_DTLS_PEER_CID_VALUE getter 越界写
2.1 漏洞位置
subsys/net/lib/sockets/sockets_tls.c,getsockopt 的 DTLS 对端 CID 取值函数:
c
static int tls_opt_dtls_peer_connection_id_value_get(struct tls_context *context,
void *optval,
net_socklen_t *optlen)
{
#if defined(CONFIG_MBEDTLS_SSL_DTLS_CONNECTION_ID)
struct tls_session_context *session_ctx;
int enabled = false;
int ret;
size_t optlen_local; /* <-- 未初始化 */
if (!context->is_initialized) {
return -ENOTCONN;
}
session_ctx = get_latest_session(context);
if (session_ctx == NULL) {
return -ENOTCONN;
}
ret = mbedtls_ssl_get_peer_cid(&session_ctx->ssl, &enabled,
optval, &optlen_local);
if (enabled) {
*optlen = optlen_local;
} else {
*optlen = 0;
}
return ret;
#else
return -ENOPROTOOPT;
#endif
}
再看它调的 mbedtls(modules/crypto/mbedtls/library/ssl_tls.c):
c
int mbedtls_ssl_get_peer_cid(mbedtls_ssl_context *ssl, int *enabled,
unsigned char peer_cid[MBEDTLS_SSL_CID_OUT_LEN_MAX],
size_t *peer_cid_len)
{
*enabled = MBEDTLS_SSL_CID_DISABLED;
...
if (ssl->transform_in->in_cid_len == 0 &&
ssl->transform_in->out_cid_len == 0) {
return 0;
}
if (peer_cid_len != NULL) {
*peer_cid_len = ssl->transform_in->out_cid_len; /* 只写、不读入 */
if (peer_cid != NULL) {
memcpy(peer_cid, ssl->transform_in->out_cid,
ssl->transform_in->out_cid_len); /* 无条件 memcpy 满 32 字节 */
}
}
*enabled = MBEDTLS_SSL_CID_ENABLED;
return 0;
}
以及 getsockopt 的 syscall verifier(subsys/net/lib/sockets/sockets.c,CONFIG_USERSPACE 时):
c
int z_vrfy_zsock_getsockopt(int sock, int level, int optname,
void *optval, net_socklen_t *optlen)
{
net_socklen_t kernel_optlen = *(net_socklen_t *)optlen;
void *kernel_optval;
if (K_SYSCALL_MEMORY_WRITE(optval, kernel_optlen)) {
errno = -EPERM;
return -1;
}
kernel_optval = k_usermode_alloc_from_copy((const void *)optval,
kernel_optlen); /* 分配恰好 kernel_optlen 字节 */
K_OOPS(!kernel_optval);
ret = z_impl_zsock_getsockopt(sock, level, optname,
kernel_optval, &kernel_optlen);
...
}
2.2 三段契约的错位
这个洞最好从"三段代码各自以为的契约"来看:
| 它以为的契约 | |
|---|---|
| getsockopt 协议(libc/POSIX) | 调用者传 optval(缓冲)+ optlen(缓冲大小 ),getter 必须写不超过 optlen 字节,并把实际写入长度写回 *optlen |
syscall verifier (z_vrfy_zsock_getsockopt) |
按 *optlen 分配了恰好这么多字节的内核 bounce buffer,信任后端不会越界写 |
mbedtls get_peer_cid |
peer_cid_len 参数是 out-only (只写长度、不读入当容量),memcpy 无条件写满 out_cid_len(最多 MBEDTLS_SSL_CID_OUT_LEN_MAX,默认 32 字节) |
Zephyr 的 getter 坐在中间,同时违反了三张契约:
- 它没有把调用者的
*optlen(缓冲容量)作为容量传给 mbedtls------它传的是一个未初始化的局部变量optlen_local; - 更关键的是,即使传了也没用,因为 mbedtls 的
get_peer_cid根本不读这个参数当容量 ,它无条件memcpy满out_cid_len字节; - 于是调用者的缓冲(或内核 bounce buffer)被完整写满 32 字节 ,而调用者只声明了
optlen字节。
一句话:verifier 按 optlen 建立了"缓冲只有这么大"的契约,后端却无视它直接写满。 这就是"验证与实现脱节"。
2.3 两个形态:内核态写越界 vs 用户态内核堆越界写
漏洞落成什么后果,取决于调用者是内核态还是用户态。
内核态调用者
直接 getsockopt(SOL_TLS, TLS_DTLS_PEER_CID_VALUE, optval, &optlen)。optval 是内核调用者的缓冲,实际只有 optlen 字节,但 mbedtls 写满 out_cid_len(peer 协商的 CID,最大 32 字节)→ 越过调用者缓冲写 31 字节。OOB write 进相邻内存。
用户态调用者(CONFIG_USERSPACE=y)
这里更有意思。看 verifier 的三行关键代码:
c
kernel_optlen = *(net_socklen_t *)optlen; /* 用户传的"缓冲大小" */
kernel_optval = k_usermode_alloc_from_copy(optval, kernel_optlen); /* 内核分配恰好 kernel_optlen 字节 */
ret = z_impl_zsock_getsockopt(..., kernel_optval, &kernel_optlen); /* 后端写满 32 字节 */
用户传入的 optlen 是几,内核就分配几字节的 bounce buffer。用户传 1 字节,内核就分配 1 字节------然后后端 getter 让 mbedtls 往里写满 32 字节。
非特权用户线程可以制造内核堆越界写 :溢出的是
k_usermode_alloc_from_copy->z_thread_malloc分配的内核线程堆(恰好optlen字节),溢出的数据是对端协商的 DTLS CID(最多 32 字节,攻击者可控制溢出条件------optlen传多小------但不能完全控制字节内容)。
这正好解释了 CVSS 为什么给 8.4 且 Scope=Changed (AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:H):一个"TLS 套接字选项"的洞,能越过组件边界去破坏内核堆。虽然溢出的字节内容由对端 CID 决定(不能完全自由选字节),但溢出本身是确定性的、可被任何本地低权限线程触发,落在完整性(I:H)和可用性(A:H)上。
2.4 未初始化 optlen_local 的角色
这里还有个细节值得单独说:getter 里
c
size_t optlen_local; /* 未初始化 */
...
mbedtls_ssl_get_peer_cid(..., optval, &optlen_local);
optlen_local 没初始化就传给 mbedtls 当 out 参数。对 mbedtls 而言无所谓(它根本不读入),但对审计者而言这是个强烈的信号:
- 一个"长度"变量从声明到使用,没有任何赋值路径;
- 唯一的赋值发生在 mbedtls 内部(
*peer_cid_len = out_cid_len); - Zephyr getter 从来没有用
*optlen(调用者给的容量)初始化过它。
正常写法应该是 optlen_local = *optlen; 先把手上的容量给 mbedtls,再用本地固定缓冲承接、最后按实际长度截断拷回。而这里的实现把"容量"这一信息整个丢掉了 。uninitialized 只是表象,真正的问题是容量信息根本没进入这条调用链。
2.5 复现:baseline 和 trigger 只差一个 optlen
这个洞的复现设计得特别干净:同一个 DTLS 会话、同一个 getsockopt,只改调用者的 optlen。
复现跑在 Zephyr qemu_x86_64 ,CONFIG_NET_LOOPBACK 上起一对 DTLS client/server,两端都开 TLS_DTLS_CID,完成握手后服务端调用 getsockopt(TLS_DTLS_PEER_CID_VALUE)。
baseline:optlen = 32(缓冲刚好够)
text
baseline ret=0 errno=0 optlen=32 expected_peer_len=32
baseline buffer:
0000: 2c ee 66 6b 30 a3 60 df 2f 13 99 dd ad f6 d2 1d
0016: f2 db 73 82 3b 8d 0e 22 b9 78 eb 56 82 45 fe 92
0032: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41
BASELINE_OK full-size buffer preserved canary
前 32 字节是 peer CID,第 32~47 字节的 canary(0x41)完好。
trigger:optlen = 1(缓冲只有 1 字节)
text
trigger ret=0 errno=0 optlen=32 expected_peer_len=32
trigger buffer:
0000: d7 b3 61 81 13 d3 69 2b c1 5d 7c b3 d3 ad d4 8f
0016: a6
EXP_SUCCESS trigger canary corrupted past 1-byte user buffer
关键读数:
trigger ret=0:getsockopt 返回成功------即使缓冲只有 1 字节,后端也没报错;optlen=32:写回的长度是 32------后端确实写了 32 字节;- 缓冲区 dump 显示 17 个字节(
cid[1]+canary[16])全部被 CID 数据覆盖,canary 的0x41一个都不剩; EXP_SUCCESS:断言optlen > sizeof(cid)且 canary 被破坏,成立。
两个构建只差 optlen 这一个变量:32 时 canary 完好,1 时 canary 被 31 字节 CID 冲掉。这把这个洞压缩成一句话:
getter 完全无视调用者声明的缓冲大小;
optlen只是"写回"的载体,不是"写入"的限制。
在 qemu_x86_64 上,这是用户进程内的 1 字节缓冲被 32 字节 CID 覆盖(内核态形态);在 CONFIG_USERSPACE 构建里,同一个 getsockopt 走 verifier 时,变成内核 bounce buffer(恰好 optlen 字节)被 32 字节 CID 覆盖------内核堆越界写。
2.6 修复:用固定本地缓冲承接,再按容量截断
advisory 给的修复方向很标准:getter 不能把调用者的缓冲直接交给 mbedtls ,必须先写进一个固定大小的本地缓冲,再按 *optlen 截断拷回,同时拒绝过小的 optlen:
c
unsigned char cid[MBEDTLS_SSL_CID_OUT_LEN_MAX];
size_t cid_len = sizeof(cid);
ret = mbedtls_ssl_get_peer_cid(&session_ctx->ssl, &enabled, cid, &cid_len);
if (ret != 0) {
return -EINVAL;
}
if (*optlen < cid_len) {
*optlen = 0; /* 或返回 -EINVAL:缓冲不够 */
return -EINVAL;
}
memcpy(optval, cid, cid_len); /* 现在拷贝被 *optlen 约束 */
*optlen = cid_len;
关键改动是两点:本地固定缓冲承接 mbedtls 的"写满" ,以及回拷时以 *optlen 为上限 。这样无论 mbedtls 写多少,落到调用者内存里的都不会超过它声明的大小。(修复在 4.4.1 及之后,涉及 main/v4.4/v4.3/v3.7 多个分支的 backport。)
这个洞的教训是:当你的代码把一个带"容量"语义的参数交给一个"只写不读容量"的库函数时,容量信息必须在你自己这一层被强制成边界。 库函数的签名不会替你兜底。
三、两个洞放在一起:验证层契约的两种断裂
3.1 验证不全 vs 验证与实现脱节
| CVE-2026-9771(flash_copy) | CVE-2026-8718(DTLS CID) | |
|---|---|---|
| 边界 | syscall verifier(z_vrfy_*) |
getsockopt verifier(z_vrfy_zsock_getsockopt) |
| 断裂方式 | 验证不全:verifier 漏校验设备指针 | 验证与实现脱节:verifier 校验/分配了,后端没遵守 |
| 用户可控的"量" | 伪造 struct device + 函数指针 |
把 optlen 传得多小 |
| 后果 | 内核态任意函数调用 -> LPE | 内核 bounce buffer 越界写 -> 内存破坏 |
| 触发前提 | CONFIG_USERSPACE |
CONFIG_MBEDTLS_SSL_DTLS_CONNECTION_ID(+ 用户态时还要 CONFIG_USERSPACE) |
两个洞都在回答同一个问题:验证层建立的"参数契约",在哪个环节断了?
- flash_copy:契约根本没建立 ------
src_dev/dst_dev这两个"必须校验"的参数,verifier 一行都没写; - DTLS CID:契约建立了但没兑现 ------verifier 认真按
optlen分配了 bounce buffer,后端实现直接绕过了它。
前一种错在"少做了",后一种错在"做了但不一致"。两种都是审计时最容易被"看起来有验证"骗过去的形态:flash_copy 看起来验证了缓冲(有 K_SYSCALL_MEMORY_WRITE),DTLS 看起来验证了 optlen(有 bounce buffer)------但真正的边界都在更深一层。
3.2 与前两篇的对照:解析器信任 -> 验证层信任
前两篇的四个洞,攻击面都是"远端输入 "(蓝牙包 / 服务器响应),信任的是"格式/长度约束的充分性"。这一篇的两个洞,攻击面是"本地调用者",信任的是"验证层把用户参数翻译成了可信任的内核对象/大小"。
但底层的思维方式完全一致------找一个"代码以为成立、实际未必成立"的边界假设:
| 前两篇(解析器信任) | 这一篇(验证层信任) | |
|---|---|---|
| 信任的对象 | 远端消息的格式/长度 | 验证函数的完整性/一致性 |
| 典型假设 | "strncpy 会补 NUL"、"分配尺寸够用" |
"设备指针一定校验过"、"后端一定遵守 optlen" |
| 突破方式 | 构造恶意报文 | 传伪造指针 / 传小 optlen |
| 触发者 | 远端攻击者 | 本地低权限线程 |
对 RTOS 来说,这篇的两个洞尤其关键:CONFIG_USERSPACE 是 Zephyr 面向安全部署的核心机制,而这两个洞都在 userspace 逃逸的命门上(syscall verifier)。它们的共同教训是:
验证层不是"写了
K_SYSCALL_*宏就算数",而是"每个进入内核的指针都必须过k_object_validate,每个带容量语义的参数都必须被后端真正遵守"。
3.3 为什么这两个洞都是 Scope=Changed
flash_copy 是 S:C,DTLS 是 S:C------这两个洞都是"跨组件边界"的:flash 驱动子系统 / TLS 套接字子系统里的洞,能破坏内核整体。
这正是"验证层漏洞"和"解析器漏洞"的差异之一:解析器漏洞往往止步于"这个子系统崩了/漏了",而验证层漏洞天然具有越权语义 ------因为它发生在权限转换的瞬间。审计 RTOS 的 userspace 边界时,z_vrfy_* 里任何一行缺失的校验,都应该按"可能等于 LPE"来对待。
四、从利用角度看,这两个洞的实际价值
4.1 CVE-2026-9771(flash_copy):本地权限提升
- 第一级(混淆代理) :任何用户线程可对任意 flash 设备做读/写/擦除------绕过设备的权限模型,即使该线程从未被授予设备对象。在真实
qemu_x86_64上已验证。 - 第二级(LPE) :伪造
struct device,把dev->api->read/write/get_parameters指向攻击者选定地址,让内核在超级用户模式下调用------越出 userspace 沙箱,任意 flash 读写、内核崩溃、潜在代码执行。 - 价值:对暴露本地用户接口的 Zephyr 设备(受管设备、车载/工业设备),一个低权限线程能直接打穿内核。CVSS 8.8,且 advisory 提示已有利用活动迹象。
4.2 CVE-2026-8718(DTLS CID):内核堆越界写
- 内核态形态 :调用者缓冲被 32 字节 CID 覆盖(
optlen小于 CID 长度时)。 - 用户态形态 :verifier 的 bounce buffer 只有
optlen字节,后端写满 32 字节------内核堆越界写 。溢出字节是 peer 协商的 CID,攻击者可自由控制"溢出多少"(传多小optlen),但不能完全控制字节内容。 - 价值 :本地低权限线程可触发确定性的内核堆破坏。字节内容虽不完全可控,但落在堆元数据/相邻分配上,按堆布局可能升级为代码执行。CVSS 8.4,Scope=Changed。触发前提是
CONFIG_MBEDTLS_SSL_DTLS_CONNECTION_ID+ 建立过带 CID 的 DTLS 会话------对启用 DTLS CID 的 IoT/车联网设备是可远程/本地达成的。
POC说明
本文不贴完整 PoC 代码,需要的话私聊我吧。复现侧的做法提一句:
- flash_copy :
zephyr-vuln-repro/flash-copy-device-perm-bypass-exp-zephyr/(真实 qemu_x86_64,CONFIG_USERSPACE=y;用户线程未授权 flash 设备仍flash_copy()成功并改写目标区域)。 - DTLS CID :
zephyr-vuln-repro/dtls-peer-cid-value-smallbuf-oob-exp-zephyr/(真实 qemu_x86_64,loopback 上 DTLS client/server 完成 CID 握手后调TLS_DTLS_PEER_CID_VALUE;build_qemu_x86_64_baseline.sh用 32 字节缓冲、..._trigger.sh用 1 字节缓冲,canary 证明越界)。