4. TEE中的权限模型:安全世界与普通世界的隔离
说到TEE的权限模型,我第一个想到的就是当年做第一个TEE项目时踩的那个坑。那时候我刚接触这个领域,以为安全世界和普通世界就是简单的「开关切换」——嗯,后来被现实狠狠教育了一顿。
其实TEE的权限模型,说白了就是解决一个问题:怎么让两个世界共存,又不让它们互相捣乱。你想想看,普通世界跑着你的Android或Linux,安全世界跑着支付、人脸识别这些敏感业务。如果权限没管好,普通世界的一个野指针就能把安全世界的内存给踩了,那还谈什么安全?
4.1 两个世界的硬件隔离机制
ARM TrustZone 是当前最主流的TEE实现方案。它的核心思路很简单——在硬件层面把SoC的所有资源分成两个「世界」:普通世界(Normal World)和安全世界(Secure World)。
怎么分的?靠一根物理信号线——NS位(Non-Secure bit)。这根线挂在总线上,所有访问内存和外设的请求都得过它这一关。
| 维度 | 普通世界(Normal World) | 安全世界(Secure World) |
|---|---|---|
| 操作系统 | Android / Linux / RTOS | 可信操作系统(如OP-TEE、QSEE) |
| 内存访问 | 只能访问非安全内存区域 | 可访问安全和非安全内存(需配置) |
| 外设访问 | 非安全外设 | 安全外设(如指纹传感器、安全键盘) |
| 中断处理 | 普通中断(IRQ) | 安全中断(FIQ) |
| 特权级别 | EL0/EL1(用户态/内核态) | EL0/EL1 + Monitor模式(EL3) |
我在项目中遇到过一个问题:某个外设的驱动在普通世界也能访问安全内存区域。查了半天,发现是硬件设计时NS位没连对。这种问题在芯片设计阶段就要卡住,不然后面软件怎么补都补不牢。
4.2 世界切换:Monitor模式的「看门人」角色
两个世界怎么切换?靠一个特殊的异常级别——Monitor模式(EL3)。你可以把它理解成一个「看门人」,所有跨世界的调用都得经过它。
切换流程大概是这样的:
- 普通世界发起调用:通过SMC(Secure Monitor Call)指令触发安全监控调用。
- 陷入Monitor模式:CPU保存普通世界的上下文,切换到Monitor模式。
- 安全检查:Monitor检查调用是否合法(比如参数范围、调用来源)。
- 切换到安全世界:恢复安全世界的上下文,开始执行安全逻辑。
- 返回:安全逻辑执行完毕,再通过Monitor切回普通世界。
这里有个关键点:Monitor模式的代码是唯一能直接操作两个世界上下文的。所以Monitor的代码必须极其精简、极其安全。我记得有一次做安全审计,发现Monitor里有个缓冲区溢出漏洞——那感觉就像发现自家大门的锁芯被人锯了一半。
4.3 TEE中的安全域(SP)与可信应用(TA)
安全世界内部也不是「铁板一块」。它里面还分了多个安全域(Secure Partition,SP),每个SP运行一个或多个可信应用(Trusted Application,TA)。
你可以这么理解:
- 安全世界 = 一个安保严密的园区
- 安全域(SP) = 园区里的独立办公楼
- 可信应用(TA) = 办公楼里的各个办公室
每个SP有自己的内存空间、堆栈和资源。SP之间默认是隔离的——一个SP的TA不能直接访问另一个SP的内存。这种设计是为了防止「一颗老鼠屎坏了一锅粥」:就算某个TA被攻破了,攻击者也拿不到其他TA的数据。
每个TA只拥有完成其功能所需的最小权限。比如一个支付TA,它只需要访问密钥存储区和网络接口,就不应该给它访问指纹传感器的权限。
4.4 TA的权限管理机制
TA的权限管理,我把它总结成三个层面:
4.4.1 静态权限:编译时声明
每个TA在编译时就要声明自己需要哪些权限。这有点像Android的Manifest文件。OP-TEE里用manifest.xml来声明:
<?xml version="1.0" encoding="utf-8"?>
<ta>
<uuid>12345678-9abc-def0-1234-56789abcdef0</uuid>
<name>payment_ta</name>
<version>1.0.0</version>
<permissions>
<permission>GP_IS_GET_KEY_STORAGE</permission>
<permission>GP_IS_CRYPTO_OPERATION</permission>
<permission>GP_IS_NETWORK_ACCESS</permission>
</permissions>
<memory>
<heap_size>0x10000</heap_size>
<stack_size>0x4000</stack_size>
</memory>
</ta>
这些权限在TA加载时由TEE内核检查。如果TA运行时试图调用一个未声明的服务,TEE内核会直接拒绝——我曾经见过一个开发团队在manifest里漏写了加密权限,结果TA在运行时调用加密接口直接返回错误,排查了两天才找到原因。
4.4.2 动态权限:运行时检查
静态权限只是「准入资格」,真正干活时还要过动态检查。TEE内核维护了一个权限表,每次TA调用系统服务时都会查表:
| TA UUID | 服务ID | 允许的操作 | 调用次数限制 |
|---|---|---|---|
| payment_ta | 0x0001(密钥存储) | 读、写 | 100次/分钟 |
| payment_ta | 0x0002(加密运算) | 加密、解密 | 无限制 |
| face_auth_ta | 0x0003(人脸比对) | 比对 | 10次/分钟 |
为什么要加调用次数限制?防止暴力破解。比如人脸比对接口,如果攻击者能无限次调用,就可以用穷举法试出匹配的人脸。我在一个金融项目中就遇到过这种攻击场景——虽然最终没成功,但那次之后我坚持所有敏感接口都要加频率限制。
4.4.3 安全上下文:调用链追踪
TA的权限还跟调用来源有关。一个TA被谁调用的?是普通世界的CA(Client Application)调用的,还是另一个TA调用的?这决定了TA是否信任这个请求。
OP-TEE里用tee_ta_session结构体来追踪调用链:
struct tee_ta_session {
TEE_UUID uuid; // TA的UUID
struct tee_ta_ctx *ctx; // TA上下文
uint32_t session_id; // 会话ID
uint32_t caller_id; // 调用者ID
uint32_t login_method; // 登录方式
void *user_data; // 用户数据
};
这里login_method很关键。它表示调用者是怎么登录的:
TEE_LOGIN_PUBLIC:公开调用,任何人都可以发起TEE_LOGIN_USER:用户身份调用,需要用户认证TEE_LOGIN_GROUP:组身份调用,需要组成员认证TEE_LOGIN_APPLICATION:特定应用调用,需要应用签名验证
我习惯在TA的入口函数里先检查login_method。比如一个管理密钥的TA,只允许TEE_LOGIN_APPLICATION方式调用——也就是说,只有特定签名的CA才能访问。这样就算普通世界有恶意应用,它连TA的门都进不来。
4.5 安全世界内部的隔离:SP之间的防火墙
前面说了SP之间默认隔离,但有时候SP之间确实需要通信。比如支付TA需要从密钥管理TA那里获取加密密钥。这时候怎么办?
TEE规范定义了SP之间的通信机制,但通信必须经过TEE内核的「防火墙」:
- SP A发起请求:调用
sp_request(),指定目标SP的ID和请求数据。 - TEE内核拦截:检查SP A是否有权限访问SP B的服务。
- 权限验证:查权限表,确认SP A在SP B的「白名单」里。
- 数据传递:TEE内核把数据从SP A的内存拷贝到SP B的内存(注意是拷贝,不是共享指针)。
- 返回结果:SP B处理完,TEE内核再把结果拷贝回去。
这里有个设计细节:数据必须拷贝,不能共享指针。为什么?因为如果共享指针,SP A就可以在SP B处理数据时偷偷修改它——这就是TOCTOU(Time-of-Check Time-of-Use)攻击。我见过一个安全评审报告,里面专门强调了这个问题,后来所有SP间通信都改成了深拷贝。
4.6 权限模型的整体架构
说了这么多,我们来画一张图把整个权限模型串起来:
这张图把整个权限模型串起来了。从普通世界的CA发起SMC调用,经过Monitor模式的检查,进入安全世界,再到SP内部的TA权限管理——每一层都在做权限控制。
最后说一句:权限模型不是「配好了就完事」。我在项目里见过太多「权限配好了就不管了」的案例,结果后来业务变了,权限没跟着更新,留下了安全隐患。建议每个迭代都做一次权限审计,看看有没有「僵尸权限」或者「过度授权」的情况。