ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

OP-TEE中RSA堆下越界写漏洞原理分析与防御实践

2026/8/30 7:34:37 拓冰建站 浏览量
OP-TEE中RSA堆下越界写漏洞原理分析与防御实践 Trustfall 这个案例把 RSA 计算过程中的堆下越界写heap underwrite带进了 OP-TEE 的 Secure World。研究它时最值得关注的不是某一条越界语句本身而是 TEE 内部大数运算的内存边界管理如何失效正常世界传入的参数一步步进入安全世界最终让 RSA 实现把数据写到了缓冲区起始地址之前。理解这条链路比背下一个漏洞描述更接近真实工程问题。这篇文章会沿着一条可复现的路线展开先说明 OP-TEE 的安全架构再解释 RSA 大数运算为什么容易产生 underwrite然后搭建 QEMU 环境把崩溃现象当成切入点分析根因、修复缺陷最后给出适合开发者使用的防御清单。读者可以是 TEE 应用开发者、系统软件工程师或者正在学习 ARM TrustZone 和 OP-TEE 的安全研究员。1. 先搞清楚 OP-TEE 的 Secure World 到底防什么1.1 OP-TEE 的基本分工OP-TEEOpen Portable Trusted Execution Environment是一个开源的 TEE 实现遵循 GlobalPlatform 规范运行在 ARM TrustZone 划分出的 Secure World 中。普通操作系统跑在 Normal World也就是通常说的 REERich Execution Environment比如 Linux敏感数据、密钥、可信计算逻辑跑在 Secure World。两个世界之间通过 SMCSecure Monitor Call指令切换由 EL3 的 monitor 代码完成上下文切换。OP-TEE 本身又可以分成几个关键部分组件所在位置作用OP-TEE OSSecure World 特权模式内核、调度、内存管理、系统调用入口OP-TEE ClientNormal World 用户态提供libteec库封装与 Secure World 通信的 APIOP-TEE Linux DriverNormal World 内核态提供/dev/tee0、/dev/teepriv0设备节点Trusted ApplicationTASecure World 用户态实现具体安全服务比如密钥管理、RSA 验签OP-TEE Test测试套件提供xtest等验证工具这里的核心隔离思想是即使 Normal World 的操作系统被攻破攻击者也不能直接读取 Secure World 的物理内存。普通世界和设备之间的大块正常内存可以被 Secure World 配置为不可访问TrustZone 的硬件权限位会拦截非法访问。但这个隔离并不等于 Secure World 内部的内存错误不会产生后果。OP-TEE OS 和 TA 仍然是普通 C 代码内核里也会用堆分配器管理内存TA 里也会执行 RSA、AES、哈希等算法。只要代码里存在越界读写就可能在 Secure World 内部破坏堆元数据、覆盖相邻对象甚至改变控制流。1.2 Secure World 的“安全”是边界安全不是代码安全很多初学者会把 TrustZone 理解为“只要代码跑在 Secure World它就是安全的”。这是误解。TrustZone 解决的是 Normal World 和 Secure World 之间的隔离不解决 Secure World 内部 C 代码的内存安全问题。攻击者并不需要一开始就拥有 Secure World 权限。在真实攻击模型中Normal World 中的一个恶意应用可以通过 OP-TEE Client API 去打开 TA 会话、向 TA 传入共享内存缓冲区、调用 TA 导出的命令。这些输入会经过 GP 规范定义的参数结构传递到 Secure World。如果 TA 或 OP-TEE OS 在解析这些输入时发生堆越界写那么一个普通用户态程序就可能成为破坏 Secure World 内存的触发源。Trustfall 这一类 RSA 堆 underwrite 之所以值得重视是因为 RSA 运算是 TA 里的常见功能签名验证、密钥协商、证书解析都会使用。只要涉及大数运算就会涉及可变长度缓冲区只要缓冲区长度计算错误就可能产生向前越界写。对于 Secure World 来说这意味着被隔离保护的密钥材料和执行环境都暴露在同一个内存破坏风险之下。在实际排查时建议把 Secure World 内部的代码都当成“面向不可信输入的服务端”来审查。调用方在 Normal World攻击者可以控制输入长度、填充内容、密钥参数这就足够让一个缺失边界检查的 RSA 实现走向崩溃。2. 理解 RSA 大数运算中的 heap underwrite2.1 RSA 计算为什么离不开临时堆缓冲区RSA 的核心运算是模幂比如验证签名时要计算m c^d mod n其中 n 可能是 2048 位甚至更高。C 语言没有原生大整数类型所以只能用数组表示大数每个数组元素通常是一个uint32_t或uint64_t称为一个字word。一个 2048 位的大数用 32 位字表示需要 64 个字用 64 位字表示需要 32 个。由于中间结果在乘法过程中可能产生进位实际缓冲区长度通常需要比密钥长度多一个或几个字也就是常说的 guard digit。许多实现里的大数结构类似下面这样typedef struct { uint32_t *dp; /* 数据数组 */ int used; /* 实际使用的字数 */ int alloc; /* 分配的字数 */ } mp_int;alloc表示dp指向的数组能容纳多少个uint32_tused表示当前这个数实际上占用多少字。每次运算前要调用类似mp_grow的函数确保alloc足够。这里的错误点非常多used和alloc单位不一致、字节数和字数换算错误、alloc少了进位位、复制时没有检查src-used dst-alloc。RSA 模幂里还会反复使用临时缓冲区。每次乘法、平方、约减都可能有中间结果。如果一个临时大数分配的长度比实际结果少函数在从高位向低位写入时就可能写出缓冲区起点之前。2.2 underwrite 到底写成什么样先明确一个术语。安全社区更常见的是“heap overflow”也就是向缓冲区结束地址之后写入而标题里的heap underwrite指的是向缓冲区起始地址之前写入。如果把堆块看作一段内存区间[buf, buf size)那么buf[-1] x是 underwritebuf[size] x是 overflow。两者都属于越界写但前者更容易被忽略因为代码里出现的往往是“负偏移”或者“有符号长度转换成无符号后向下溢出”。下面这一段是典型的错误模式。函数想把src拷贝到dst的低地址区域并且假设src的实际长度比dst短于是先计算前导空闲字数再移动指针void align_uint32_array(uint32_t *dst, size_t dst_words, const uint32_t *src, size_t src_words) { size_t skip dst_words - src_words; uint32_t *p dst skip; for (size_t i 0; i src_words; i) { p[i] src[i]; } }如果src_words dst_wordsdst_words - src_words会按照无符号整数下溢成一个极大的数。dst skip会远远超过dst的末尾后续写入变成高地址越界。真正会形成低地址越界的是另一种常见写法调用方先计算“前导零数量”这个值可能为负但被写成有符号整数后直接参与指针偏移int leading_zero_words (int)dst_words - (int)src_words; /* 如果 src_words 大于 dst_wordsleading_zero_words 是负数 */ uint32_t *p dst leading_zero_words; for (int i 0; i (int)src_words; i) { p[i] src[i]; /* p[0] 写到了 dst 起始地址之前 */ }这里如果src_words比dst_words大leading_zero_words是负数p指向dst起点之前。循环一开始就把数据写到安全区域之外这就是一个典型的 heap underwrite。由于i会逐渐增大后续写入可能先覆盖前一个堆块的数据再覆盖当前堆块的头部最后进入合法区域所以现场往往会非常混乱。在 RSA 代码里这种指针偏移可能不是直接写在 RSA 函数体中而是藏在“大数规范化”“字节串转大数”“大数转固定长度字节串”的工具函数里。问题往往不是 RSA 算法不懂而是长度边界没有传递到位。2.3 为什么 RSA 实现会成为 underwrite 的高发区RSA 运算很多地方都对长度敏感。第一密钥长度可以是 1024、2048、3072、4096 位同一个实现需要适配多种长度。长度不同分配的字数就不同任何#define固定长度或隐式默认长度都可能出错。第二外部输入是大端字节串内部大数通常使用小端字序。转换时要从字节长度换算成字长度例如in_bytes转换为in_words时常见公式是(in_bytes 3) / 4。如果某处直接使用in_bytes / 4长度为 3 字节的输入会被当成 0 字导致后续读取未初始化数据或写入位置错误。第三RSA 中间结果会有进位。模幂乘法中两个 N 字长度的大数相乘结果最多是 2N 字加上约减过程临时缓冲区偶尔会多出 1 个字。如果只按 N 分配写入时就会向高地址越界如果使用了从尾部向前的存储方式则可能变成向低地址越界。第四前导零的处理。大数运算结果可能实际位数比密钥短代码经常想去掉前导零或把结果补齐到固定长度。在“去掉前导零”和“按固定长度输出”之间做转换时如果跳过前导零的计数错误就会让后续指针偏移变成负数。2.4 RSA 的算法安全不等于实现安全很多安全扫描报告里会出现“目标主机支持 RSA 密钥交换”这类说明。它通常指的是 TLS 协商阶段允许使用 RSA 密钥交换偏向协议和配置层面的问题。但 RSA 还有另一层安全问题实现是否能在任意输入长度下保持正确的内存边界。哪怕 RSA 算法本身是安全的一个 TA 里的 RSA 验签函数仍然可能因为恶意构造的输入长度而崩溃。对于 TEE 来说这种实现层面的漏洞恰恰是最危险的因为它直接位于安全世界的可信执行代码中。Trustfall 就是把这两件事接在了一起RSA 作为入口算法堆内存边界错误作为具体漏洞模式。3. 搭建一个可以复现 OP-TEE RSA 内存问题的环境3.1 为什么选择 QEMU 而不是直接上板子OP-TEE 的内存错误在真实硬件上调试很痛苦secure world 崩溃可能导致系统挂死日志不全单步执行困难。QEMU 的virt平台可以模拟 ARMv8-A 的 TrustZone 布局OP-TEE 官方维护了一套适用于 QEMU 的构建脚本可以快速启动一个带 Secure World 和 Normal World 的完整环境。QEMU 环境的好处是构建和启动成本低不需要真实开发板。可以在崩溃前打快照反复复现同一输入。可以配置 GDB在 Secure World 代码中下断点。内存检测工具导致的性能下降可以接受。坏处是模拟器环境和真实硬件有一些差异比如 cache 行为、设备模型、时序等。因此分析阶段用 QEMU最终验证阶段如果条件允许还是应该回到真实硬件或厂商模拟环境再跑一遍。3.2 拉取 OP-TEE 源码并构建 QEMU 镜像OP-TEE 由多个仓库组成官方推荐使用repo工具管理。以qemu_v8配置为例步骤大致如下。注意命令会随版本变化执行前先看仓库 README。mkdir -p optee-workspace cd optee-workspace repo init -u https://github.com/OP-TEE/manifest.git -m qemu_v8.xml repo sync -j8 cd build make toolchains -j8 make runrepo sync会拉取 OP-TEE OS、optee_client、optee_test、Linux、U-Boot、QEMU 等组件。首次构建需要较长时间也需要能正常访问 GitHub 等代码仓库。如果网络条件有限可以先准备离线镜像再按照官方文档导入。make run会编译并启动 QEMU。启动后会看到 U-Boot 输出然后加载 Linux 内核。正常登录后系统里应该能看到 TEE 相关设备节点。检查设备节点ls -l /dev/tee0 /dev/teepriv0 cat /proc/version/dev/tee0是供用户态 CAClient Application使用的设备/dev/teepriv0用于驱动与 secure world 之间的管理操作。如果这两个节点存在说明 OP-TEE driver 已经注册成功。3.3 运行 xtest 验证 Secure World 基本正常OP-TEE 自带的测试套件是xtest在启动后的 Linux 命令行里可以直接运行xtest完整跑一遍需要几分钟。它会执行 GP 兼容性测试、TA 基本功能测试、密码学算法测试等。运行结束后会输出类似38083 subtests passed的统计具体数值取决于源码版本。如果只是验证 RSA 相关接口可以先只跑部分用例。例如xtest 4006不同版本用例编号不同建议先看xtest --help。这一步的目的是确认基线环境正常再开始修改代码或复现崩溃。3.4 为 Secure World 打开内存检测要定位 underwrite不能只靠肉眼读代码。最好在复现环境里打开 AddressSanitizer 类似的内存检测能力。OP-TEE 的构建系统提供多个 sanitizer 相关配置常见的有CFG_CORE_ASAN为 OP-TEE OS 核心代码加入 AddressSanitizer 支持。CFG_TA_ASAN为 Trusted Application 加入 AddressSanitizer 支持。CFG_CORE_SANITIZE_UNDEFINED开启未定义行为检测。在build目录里构建时可以临时加变量make run CFG_CORE_ASANy CFG_TA_ASANy如果当前版本不支持某个配置构建过程会打印错误或者直接忽略需要回到源码mk/config.mk确认实际配置名。打开 ASAN 后Secure World 里的越界读写会在崩溃前被拦截并打印更明确的调用栈和访问地址。除了 ASAN还可以提高 OP-TEE 的日志级别make run CFG_TEE_CORE_LOG_LEVEL4日志级别越高Secure World 打印的调试信息越详细。但要注意日志本身也会改变程序执行时序可能让一些内存错误不明显。所以复现时先用默认级别确认能崩溃再打开日志和 sanitizer 做定位。4. 从“RSA 测试崩溃”反推堆 underwrite4.1 先记录现象不要急着改代码当某个 RSA 测试用例崩溃时第一件事不是去翻 RSA 实现而是把现场信息完整记录下来。至少包括哪个测试用例崩溃输入是正常密钥还是异常长度密钥。密钥长度、输入数据长度、填充方式。xtest或 TA 返回的错误码。Secure World 是否打印 panic 日志。是否开启 ASAN如果开启ASAN 报告的读写方向和地址位置。一个典型的 Secure World panic 日志可能长这样E/TC:0 0 TA panicked with Panic: TEE_ERROR_SECURITY (0xffff000e) E/TC:0 0 Status of TA 12345678-... (0x0) is 0x3 D/TC:0 0 pc:0x... sp:0x... D/TC:0 0 rsa_verify:0x...如果打开 ASAN输出会更接近ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... at pc ... WRITE of size 4 at 0x... thread T0 #0 0x... in rsa_exptmod #1 0x... in rsa_verify 0x... is located 4 bytes before 16-byte region [0x...,0x...)这里的4 bytes before就是很明确的 underwrite 信号写入地址落在分配区域之前。如果日志中没有这一句只有普通 abort可以通过 GDB 查看当前寄存器中的地址与分配块地址的关系。4.2 把 panic 地址翻译成代码位置拿到 panic 地址后用符号化的方式定位到具体函数。QEMU 环境里可以将 OP-TEE OS 的 ELF 文件加载到 GDB。启动 QEMU 时加上 GDB 支持比如在 OP-TEE 的build目录里make run DEBUG1然后在另一个终端连接gdb-multiarch build/../out/arm-plat-virt/core/tee.elf target remote :1234GDB 断点可以下在rsa_exptmod、mp_add、mp_mul这些大数运算函数入口。崩溃后使用bt查看调用栈用info registers查看x0、x1等寄存器值再用x/8gx查看内存区域内容。这种分析方式对 underwrite 特别有效你可以在崩溃指令前几步观察指针是如何从合法区域移动到非法区域的。比如x0原本是缓冲区起始地址某条sub x0, x0, #4指令后x0变成了start - 4随后写内存触发了异常。4.3 排查链路从外层输入到内层索引定位 underwrite 时推荐按照下面的链路逐层检查。检查层级检查内容判断方法输入参数调用方传入的长度是否可信是否校验in_bytes key_size长度换算字节数是否按(bytes 3) / 4转为字数打印in_bytes、in_words分配长度临时缓冲区是否包含进位 guard word检查alloc是否比used大指针偏移偏移计算是否可能为负数对比src_words和dst_words循环边界写循环是否检查低地址边界GDB 单步看索引变化堆布局越界写入落到了哪个对象查看 ASAN 报告中的区域归属经常出现的情况是最外层参数没有校验导致内部长度换算产生了一个比缓冲区更大的值再通过无符号减法变成负偏移。因此第一优先检查输入长度是否被当作可信值直接参与运算。4.4 常见错误模式与对应现象下面的表格总结了 RSA 大数代码里容易被忽略的边界错误。错误模式现象根因处理建议字节转字时向下取整长度 1 到 3 的输入被当成 0 字用了in_bytes / 4而不是(in_bytes 3) / 4统一使用向上取整公式无符号减法下溢越界地址变成一个极大值size_t skip dst_words - src_words未先判断大小先比较再减法有符号负偏移写地址在缓冲区起始之前int skip为负数后参与指针运算检查skip 0缺少 guard word乘法进位后多写一个 word分配长度只覆盖密钥字长所有临时大数加 1 word前导零计数错误输出长度多一位或少一位去前导零与固定长度补齐逻辑不一致单独测试零结果和全零输入只测固定密钥长度65537 和随机指数行为不同测试用例没有覆盖不同指数长度使用随机指数、边界指数、最大长度这些错误在普通 C 项目里已经足够危险在 Secure World 中就更不能放过。任何一个都可能导致underwrite并且因为堆布局变化有时能复现有时又不能复现。4.5 Valgrind 在模拟器里的补充用法Valgrind 不能直接跟踪 QEMU 里的 Secure World因为 secure world 运行在 TrustZone 的另一侧普通进程的 Valgrind 工具看不到它的内存。更可行的方式是把出问题的 RSA 大数代码单独提取到宿主机写成普通 C 单元测试再用 Valgrind 和 AddressSanitizer 分析。比如把mp_int相关的工具函数拷贝到一个独立项目然后用如下命令编译gcc -g -O1 -fsanitizeaddress,undefined rsa_repro.c -o rsa_repro ./rsa_repro如果代码里有uninitialised value was created by a heap allocation这类输出说明越界读或未初始化读和堆分配有关。Valgrind 也会报告12345 Invalid write of size 4 12345 at 0x...: align_uint32_array 12345 Address 0x... is 4 bytes before a block of size 16 allocd地址在 block 之前就能确认是 underwrite。之所以推荐单独提取是因为 QEMU 环境调试成本高而 MPI 层代码通常不依赖硬件可以相对独立地做单元测试和 fuzz。5. 修复 underwrite 的正确姿势从单点补丁到系统性防御5.1 先修最小代码路径修复 underwrite 的第一步永远是让最靠近崩溃的路径不再越界。以 2.2 节的align_uint32_array为例修复的关键不是把size_t改成int而是在减法之前判断长度关系。int align_uint32_array(uint32_t *dst, size_t dst_words, const uint32_t *src, size_t src_words) { if (src_words dst_words) return -1; size_t skip dst_words - src_words; uint32_t *p dst skip; for (size_t i 0; i src_words; i) { p[i] src[i]; } return 0; }这样修改后调用方可以根据返回值返回TEE_ERROR_BAD_FORMAT或TEE_ERROR_BAD_PARAMETERS。对于 RSA 验签来说输入长度超过密钥长度本身就是一个非法状态不应该继续运算。这里有个容易踩的坑返回值本身经常被忽略。所以更稳妥的写法是让上层必须检查返回值或者在调试版本里直接断言if (src_words dst_words) { EMSG(RSA buffer overflow prevented: src%zu dst%zu, src_words, dst_words); return TEE_ERROR_BAD_FORMAT; }5.2 在 API 层加防御只修一个函数不够因为同一个长度换算模式可能出现在多个位置。OP-TEE 遵循 GlobalPlatform 的 TEE Internal API其中提供了TEE_BigInt系列大数接口比如从字节串转换大数可以使用TEE_BigIntConvertFromOctetString。这些 API 在内部管理缓冲区和长度能减少应用开发者直接操作uint32_t数组的机会。但内部实现仍然必须正确。建议在 MPI 库的入口统一增加边界检查分配大数时总是多分配一个 guard word。mp_grow里如果新长度小于已分配长度不能直接返回成功要确认传入的size是真实需要的字数。大数运算函数在访问dp前先断言used alloc。字节串转大数时如果input_len超过固定缓冲区容量直接返回错误。通过这样的统一约束后续新增的算法函数就不太容易重复同样的漏洞。5.3 通过 CI 和 fuzzing 防止回归修复之后需要把回归路径固化到 CI 里。即使 Trustfall 本身被修复也不能保证其他大数函数没有类似问题。建议的 CI 矩阵至少包括多个优化级别-O0、-O2、-O3。多个编译工具链GCC 和 Clang。开启 AddressSanitizer 和 UndefinedBehaviorSanitizer。不同的密钥长度1024、2048、3072、4096。边界输入空数据、1 字节、密钥字节数减 1、恰好密钥字节数、多 1 字节。零值大数、全0xFF大数、前导零大数。对于 fuzz可以把 RSA 验签函数包装成一个接收字节串的入口用 libFuzzer 或 AFL 去生成输入。即使不追求覆盖率也能发现长度换算相关的崩溃。关键在于让 fuzz 输入能控制密钥长度、数据长度和填充字段否则只会覆盖 happy path。5.4 生产环境需要额外关注的点开发环境修完越界后生产环境还有几件事要做。第一Secure World 的 panic 信息要能被正常世界收集。不能只把异常留在安全世界内部否则线上出现问题时无从排查。可以通过 OP-TEE 的 panic 通知机制上报或者定期抓取 secure world 日志。第二不要在生产环境关闭隔离。有些团队为了调试方便会把 TA 降级到普通权限或者打开内存转储。这会把 TrustZone 的安全性直接削弱线上环境必须保持默认安全配置。第三回滚方案要准备。任何补丁都可能引入新的问题尤其是涉及大数运算性能优化时长度检查和加法进位可能影响性能。发布前要在目标平台做性能对照保留回滚通道。6. 从 Trustfall 案例延伸出的工程结论6.1 对 TEE 开发者的建议