try ai
科普
编辑
分享
反馈
  • 内存安全:硬件基础与软件应用

内存安全:硬件基础与软件应用

SciencePedia玻尔百科
核心要点
  • 硬件通过内存管理单元(MMU)在操作系统(监管者模式)和应用程序(用户模式)之间强制实施了基本的特权分离。
  • 虚拟内存和分页机制通过将虚拟地址转换为物理地址,并使用权限位(读、写、执行)进行细粒度控制,从而将进程相互隔离。
  • 可信执行环境(TEE)创建了硬件隔离的“安全世界”,即使在操作系统内核被攻破的情况下也能保护敏感数据。
  • 内存安全是一种纵深防御策略,它结合了硬件壁垒(MMU、IOMMU)、操作系统策略(进程隔离)和语言级安全(例如,在 Rust 或 Java 中)来创建一个稳健的系统。

引言

在现代计算中,无数程序同时运行,每个程序都要求自己的一块内存。如果没有严格的规则,这将导致混乱,应用程序会覆盖彼此的数据,甚至破坏核心操作系统。内存安全是施加秩序、建立壁垒和强制执行边界以确保稳定性和隐私的关键学科。但这些数字壁垒是如何构建的,规则又由谁来强制执行呢?本文通过探讨硬件和软件在创建安全内存环境方面的深度协作来回答这个基本问题。首先,在“原理与机制”部分,我们将剖析使安全成为可能的基础硬件特性,从 CPU 特权级别和内存管理单元(MMU)到可信执行环境(TEE)的隔离堡垒。然后,在“应用与跨学科联系”部分,我们将看到这些原理在实践中如何应用,塑造了从操作系统设计和进程沙箱到编译器理论和语言开发中的各种选择。读完本文,您将理解从硅芯片到编程语言的层层防御如何协同工作,以保护我们数字世界的完整性。

原理与机制

如果说计算机的内存是一片广阔、开放的土地,那么运行一个包含许多程序的现代操作系统,就像试图在这片土地上建造一个繁华的都市。这里有皇家政府(操作系统内核)、公共工程(共享库),以及无数的私人住宅和商铺(用户应用程序)。如果没有围墙和地界线,一个笨拙或恶意的市民可能会溜进皇家城堡,不小心推翻宝座,或者闯入邻居的房子,重新布置他们的家具。结果将是一片混乱。内存安全是计算机体系结构对土木工程问题的艺术与科学的回应:它关乎直接在硬件中构建墙壁、大门和锁,以创造秩序、隐私和稳定性。

两个领域:监管者与用户

我们建立的第一道也是最根本的墙,并非建在公民之间,而是建在公民与国王之间。处理器本身强制执行着严格的社会等级制度。它至少可以在两种模式或​​特权级别​​下运行。最高、最特权的级别是​​监管者模式​​(Supervisor Mode,也称为内核模式或 ring 0),专为操作系统的核心保留。其他所有东西——你的网页浏览器、文字处理器、游戏——都在权力小得多的​​用户模式​​(User Mode,或 ring 3)下运行。

在监管者模式下运行的代码可以做任何事情:配置硬件、管理内存、控制所有其他程序。而用户模式代码在设计上是被束缚的。但如果一个用户程序需要强大的内核提供服务,比如从磁盘读取文件,该怎么办?它不能直接跳转到内核的代码中——这就像一个平民试图传送到王座室一样。任何此类尝试从一开始就注定失败。硬件本身,通过​​内存管理单元(MMU)​​,扮演着警惕的宫殿守卫的角色。内存的每一页都被标记了其所需的特权级别。如果 CPU 在用户模式下试图从标记为“仅限监管者”的页面获取指令,MMU 会大喊“停下!”并触发一个故障。CPU 会立即停下,并将控制权转移到一个预先设定的异常处理程序,从而在入侵开始之前就阻止了它。

那么,用户程序如何提出合法请求呢?它必须通过一个官方的、戒备森严的大门。这些门被称为​​系统调用​​。应用程序执行一个特殊指令(如 syscall 或 int 0x80),这是一个进入内核的正式请求。这不是一次简单的跳转;这是一个由硬件精心编排的仪式。硬件会查询一个由内核维护的特殊列表,即门描述符表,该表指定了唯一有效的入口点。如果请求有效,硬件会原子地、作为一个单一不可分割的操作执行几个动作:它保存用户程序的当前状态(以便稍后返回),切换到一个干净且安全的内核栈,将特权级别提升到监管者模式,并且只有到那时才跳转到内核中那个精确的、被批准的地址。这种由硬件强制执行的严格协议确保了内核的完整性永不被破坏。你可以请求觐见国王,但你必须从正门进入并遵守宫殿的规则。

划分土地:分页的魔力

保护内核只完成了一半的任务。我们还需要保护公民彼此不受侵害。你的网页浏览器不应该能够读取你的密码管理器的内存。这是通过一种名为​​虚拟内存​​的美妙幻觉实现的。操作系统和 MMU 合谋让每个程序都相信它独占了整个内存空间。

这种幻觉背后的机制是​​分页​​(paging)。MMU 扮演着实时翻译官的角色。当一个程序使用一个地址——一个在其私有梦境世界中的“虚拟”地址时,MMU 会在一组称为​​页表​​(page tables)的翻译地图中查找它。这些表对每个进程都是唯一的,它们告诉 MMU 对应的“物理”地址在计算机实际 RAM 中的位置。

页表中的每个条目,即​​页表条目(PTE)​​,就像是为一个小的、固定大小的内存块(通常为 4 KiB4\,\mathrm{KiB}4KiB),即一个​​页​​(page),盖上的护照印章。这个印章包含了至关重要的安全信息:

  • 一个​​有效位(VVV)​​:此页当前是否在物理内存中?如果 V=0V=0V=0,任何试图接触它的行为都会触发​​页错误​​(page fault),这是一个将控制权交给操作系统来处理问题的陷阱(也许是从硬盘加载该页)。这是第一个也是最具决定性的检查。

  • ​​权限位(R,W,XR, W, XR,W,X)​​:如果页面有效,进程被允许做什么?它可以被允许​​读取(RRR)​​、​​写入(WWW)​​和/或​​执行(XXX)​​该页的内容。

这些简单的位是细粒度内存保护的基石。如果一个程序试图写入一个被标记为只读(W=0W=0W=0)的页面,MMU 会触发一个​​保护错误​​(protection fault),操作系统通常会终止这个违规的程序。这防止了无数的 bug 破坏数据。

更强大的是,​​NX 位(No-Execute)​​(也称为执行禁用或 XD)可以防止 CPU 从标记为数据的页面中获取指令。这是一个关键的防御措施,可以抵御一整类攻击,在这类攻击中,攻击者将恶意代码注入到程序的数据区域(如输入缓冲区),然后诱骗程序跳转到那里。有了 NX 位,硬件会直接拒绝。

重要的是要认识到这种硬件保护实际上在检查什么。它在内存访问期间检查地址。如果一条指令包含一个恰好与被禁止地址相同的数字,什么也不会发生;它只是一个数字。只有当程序试图在加载或存储操作中使用该数字作为地址时,MMU 的警报才会响起。

实用防御工事:绊线与城墙

有了这些工具,操作系统就可以成为一个聪明的防御架构师。最常见和最危险的编程错误之一是​​栈溢出​​(stack overflow),即一个函数调用自身次数过多,或分配了过多的本地数据,导致程序的栈增长超出了其指定区域。

为了防范这种情况,操作系统采用了一个简单而绝妙的技巧:​​保护页​​(guard page)。栈在内存中是向下增长的。因此,操作系统只需在栈分配空间的底部紧下方放置一个页面,并将其 PTE 标记为无效(V=0V=0V=0)。这是一条虚拟的绊线。当一个有 bug 的程序的栈多增长一个字节时,它就会触碰到保护页。MMU 会立即检测到对无效页面的访问并触发一个页错误。操作系统的故障处理程序被唤醒,检查故障地址,看到进程撞上了自己的栈保护,就确切地知道发生了什么。然后它可以安全地终止该进程,防止它接触到更远处的内存——那些内存可能属于另一个程序甚至内核本身。

这种细粒度的、页级别的保护是现代 CPU 的一个标志。在嵌入式设备中常见的更简单的系统可能会使用​​内存保护单元(MPU)​​。MPU 定义了少数几个大的、粗粒度的内存“区域”,这些区域具有统一的权限。这就像是建造几堵大的城墙,而不是在每栋房子周围都建上栅栏。虽然聊胜于无,但 MPU 可能无法在一个小数据缓冲区的末尾创建紧密的边界。一个溢出可能需要在一个大的、可写的区域内跨越数千字节的未使用空间,才能碰到受保护的边界(如果能碰到的话)。分页的优雅之处在于其粒度,它允许操作系统在需要的地方精确地建造墙壁。

内部堡垒:可信执行环境

到目前为止,我们的模型都假设了一个值得信赖的操作系统。但如果操作系统本身被攻破了呢?或者,如果我们根本不想把我们最敏感的秘密,比如加密密钥或私人数据,托付给它呢?这导致了我们思维方式的深刻转变:我们需要在计算机内部建立一个堡垒,一个连操作系统都无法进入的堡垒。这就是​​可信执行环境(TEE)​​背后的原理,例如 ARM TrustZone 和 Intel SGX。

支持 TEE 的硬件将处理器分为两个“世界”:常规操作系统和应用程序所在的正常、非安全世界,以及一个完全隔离的​​安全世界​​。处理器有一个特殊的位,我们称之为​​非安全(NSNSNS)位​​,它决定了当前哪个世界是活动的。但是,这两个世界如何能在同一硬件上共存而互不干扰呢?我们不能每次在它们之间切换时都清空整个缓存。

解决方案是另一个绝妙的硬件技巧:​​标记​​(tagging)。处理器缓存中的每一行,以及其翻译表(TLB)中的每一个条目,都增加了一个额外的标签,用于存储创建它的世界的 NSNSNS 位。当 CPU 在非安全世界(NS=1NS=1NS=1)中运行时,缓存硬件只会对同样是在 NS=1NS=1NS=1 时获取的数据报告“命中”。任何潜伏在缓存中的安全数据实际上都是不可见的。这两个世界共享相同的物理硬件,但它们看不到彼此的数据,就好像生活在平行的维度中一样。

在这个模型中,操作系统的角色被从根本上降级了。从在安全世界中运行的程序(一个“enclave”)的角度来看,操作系统只是另一个不受信任的用户空间进程。enclave 不能信任操作系统会公平地调度它,也不能信任操作系统在处理其 I/O 时不会窥探或篡改。任何离开 enclave 的数据都必须加密。但它可以无条件信任的是,硬件保证了其自身的内存是机密且防篡改的。硬件已成为内存保护的最终仲裁者,超越了操作系统。

宏观视角:硬件之墙与软件之锁

这段贯穿硬件机制的旅程引出了最后一个问题:如果我们拥有如此强大的硬件保护,我们就可以粗心大意地编写代码吗?或者反过来说,如果我们使用像 Rust 或 Java 这样的现代“安全”编程语言,我们就可以忽略硬件吗?对这两个问题的答案都是响亮的“不”。

语言级别的内存安全是一个强大的概念。一个受管理(managed)的运行时能确保你不能越界访问数组,并防止你使用指向已释放内存的指针。在像 C 这样的不安全语言中,像原地列表反转这样的复杂操作充满了潜在的指针错误,可能导致可被利用的内存损坏。而在一个受管理的语言中,这样的错误会被捕获并转化为一个安全的、受控的异常。

然而,这种软件级别的安全并不能替代硬件级别的隔离。一个内存安全的程序仍然可能包含导致无限循环的 bug,独占 CPU 直到操作系统介入抢占它。更重要的是,语言安全无法阻止一个恶意进程试图攻击另一个进程,也无法防范具有​​直接内存访问(DMA)​​能力的流氓外设。为此,你需要一个​​IOMMU​​——一个专为 I/O 设备设计的 MMU——将外设置于其自身的、由硬件强制执行的沙箱中。

归根结底,内存安全是一个纵深防御的故事。硬件提供了坚不可摧的、粗粒度的墙:内核与用户之间的分离,进程之间的隔离,以及 TEE 的堡垒。软件和语言设计则在这些墙内部提供了细粒度的锁和规则,管理数据结构和对象的行为。一个保护着王国和它的城市;另一个保护着每栋房子的内容。一个真正安全的系统需要两者兼备。

应用与跨学科联系

在探索了内存保护的基本原理之后,我们可能会觉得这只是一堆巧妙但抽象的软硬件技巧的集合。但事实远比这更美妙。这些原理并非孤立的奇技淫巧;它们是构建整个现代、可靠计算大厦的基石。它们是我们数字世界中无形的建筑师,不知疲倦地为亿万晶体管的潜在混乱施加秩序。

现在,让我们踏上一段旅程,看看这些基础思想如何开花结果,形成一幅丰富的应用织锦,连接起硬件设计、操作系统、编译器理论乃至数学证明的抽象之美等看似毫不相关的领域。我们将看到,内存安全不是一个单一的功能,而是一场由众多乐器合奏的宏大交响乐,所有乐器都在协同工作。

基石:硬件的谕令

我们的旅程从最底层,即硅片本身开始。在任何软件运行之前,处理器硬件就已定下第一批、不容商榷的土地法则。

想象一下,你正在为一个工业控制器设计一台简单的计算机。它有一个高度特权的系统部分——“机器模式”或 M-mode——用于配置硬件,还有一个特权较低的“监管者模式”或 S-mode,用于运行主控制逻辑。你必须绝对确保监管者逻辑中的一个 bug 不会意外地覆盖关键的 M-mode 配置数据。你如何建造这堵墙?

答案是一种名为​​物理内存保护(PMP)​​的硬件特性。处理器提供了一小组特殊寄存器,其作用就像内存的契约办公室。例如,你可以声明从 $0x00028000$ 到 $0x00029000$ 的内存范围是禁区:不可读、不可写、不可执行。因为 PMP 规则是在每次内存访问时由硬件评估的,所以它们形成了一道坚不可摧的屏障。处理器架构师可以仔细地分层这些规则,创建只读代码区域、私有数据区域和禁区,确保即使在最基本的层面上,系统的不同部分也能各行其道()。这是最原始的内存安全形式:在沙地上划线,即使是最强大的软件,未经硬件许可也无法逾越。

但现代系统面临着另一个更严峻的挑战:设备。你的显卡、网络适配器、磁盘控制器——它们本身就是强大的计算机。为了性能,它们通常需要将数据直接写入系统的主内存,这个过程称为直接内存访问(DMA)。一个有 bug 或恶意的设备驱动程序,原则上可以命令其硬件向任何地方写入数据,从而可能破坏核心操作系统内核。

为了守卫这一侧翼,硬件设计师为我们提供了​​输入输出内存管理单元(IOMMU)​​。你可以将 IOMMU 想象成一个坐在你的设备和主内存之间的警惕的海关官员。当一个设备试图对某个地址执行 DMA 操作时,IOMMU 会拦截该请求。它会在一个由可信的操作系统内核设置的特殊表中查找该地址,以查看该设备实际上被允许访问哪些物理内存(如果有的话)。这使得操作系统可以授予设备驱动程序仅访问一组特定、有限的内存缓冲区的权限,仅此而已。通过将 IOMMU 与谨慎的、基于能力的软件设计相结合,操作系统可以确保即使是一个被攻破的驱动程序也无法利用其设备逃离其指定的内存区域,从而维护整个系统的完整性()。

土地法则:操作系统的授权

以这些硬件原语为基础,操作系统(OS)可以开始其作为宏伟城市规划师的工作。操作系统最伟大的创造是​​进程​​:一种抽象,它给每个运行中的程序一种独占整台机器的错觉。

这不仅仅是为了方便;它是安全的基石。想象一下,你的网页浏览器需要运行第三方插件——一个广告拦截器、一个 PDF 查看器、一个密码管理器。这些插件由不同的人编写,其质量和可信度各不相同。你如何防止一个有 bug 的插件使整个浏览器崩溃,或者更糟地,一个恶意的插件读取密码管理器插件的数据?

操作系统提供了一个优雅的答案:在各自独立的进程中运行每个插件。通过这样做,操作系统利用硬件的内存管理单元为每个插件提供其自己的私有地址空间、自己的一组文件权限和自己的网络身份。从所有意图和目的来看,它们生活在独立的、有围墙的房子里。它们之间或与主浏览器的通信必须通过由操作系统管理的狭窄、受审计的通道进行。这种基于进程的沙箱提供了强大的隔离,并且通过使用其他操作系统特性,如控制组(cgroups),我们甚至可以限制行为不端的插件允许消耗的 CPU 时间或内存()。这种架构是现代应用安全的核心,从你浏览器的标签页到你手机上的应用程序。

但操作系统的工作并不总是那么直接。有时,安全目标会与性能目标直接冲突,迫使架构师采用极其精妙的设计。考虑一下​​写时复制(COW)​​——一种经典的性能优化——与​​硬件内存加密​​之间的相互作用。COW 允许操作系统避免昂贵的内存复制;当一个进程分叉时,父进程和子进程最初可以共享相同的物理内存页,映射为只读。只有当其中一个试图写入共享页时,操作系统才会介入,制作一个私有副本,然后让写入继续。

现在,在一个像使用 AMD 的安全加密虚拟化(SEV)这样的安全系统中,会发生什么?在这种系统中,每个虚拟机都用一个唯一的、受硬件保护的密钥来加密其内存。管理这些虚拟机的虚拟机监控程序(hypervisor)无法读取客户机的内存。那么,两个不同的虚拟机能否共享一个包含相同数据(例如,一个公共库文件)的物理页来节省内存呢?答案是否定的。因为每个虚拟机使用不同的密钥,相同的明文页在内存中会产生完全不同的密文。硬件无法同时用两个不同的密钥解密一个物理页。这个基本的密码学现实使得跨虚拟机的页面共享变得不可能()。这个例子完美地说明了内存安全不仅仅是一个附加层,而是一个深刻的架构原则,可以从根本上改变安全性与性能之间的权衡。

抄写员与学者:编译器、语言和逻辑

到目前为止,我们的安全来自“政府”——硬件和操作系统。但如果我们能将安全构建到我们程序的结构中呢?如果我们能用逻辑和语言来创建可证明安全的程序呢?

这将我们带入了编译器、编程语言和形式方法的世界。考虑编程中最古老、最常见的 bug 之一:空指针解引用。一个操作链表的算法总是在追踪指针。我们如何能确定它永远不会尝试跟随一个 null 指针,从而导致崩溃?

一种方法是借助纯粹的数理逻辑的力量。通过定义一个​​循环不变量​​——一个在每次循环迭代开始时都为真的属性——我们可以正式证明一个指针在被解引用时永远不会是 null。对于一个列表过滤算法,可以建立像“p≠nullp \neq \text{null}p=null and p→next=qp \to next = qp→next=q”这样的不变量。我们证明它在循环开始前为真,然后我们证明如果它在一次迭代前为真,那么循环的逻辑会使它在下一次迭代前也为真。一旦这个不变量被建立,$p$ 的非空性就不再是希望或测试的问题,而是一个数学上的确定性()。这是最纯粹形式的内存安全:由证明来保证。

虽然形式化证明很强大,但它们并不总是实用的。更常见的是,安全是编程语言和编译器共同努力的结果。许多语言,如 Java 或 Rust,承诺内存安全,部分原因是通过防止数组越界访问。实现这一点最简单的方法是让编译器在每次访问前插入一个动态检查:index >= length?但这可能会很慢。

这时,编译器就可以成为一个“抄写员和学者”。一个聪明的编译器可以使用静态分析来证明某些检查是不必要的。在一个从 i = 0 迭代到 n-1 的循环中,如果编译器能证明数组的长度至少为 n,那么循环内对 array[i] 的访问就保证是安全的。通过证明这一点,编译器可以安全地消除运行时检查,从而同时为我们提供安全和速度()。这是一种混合方法(),编译器证明它能证明的(静态强制),并为其余部分插入检查(动态强制)。

语言和架构的选择具有深远的安全影响。考虑​​unikernel​​,一种现代操作系统架构,其中整个应用程序连同必要的库和内核函数被编译成一个在单一地址空间中运行的单一程序。这种设计速度快、效率高,因为没有昂贵的特权级别转换。然而,这也意味着没有内部的内存保护墙。二十个组件中任何一个(比如说,一个 C 库)的单个内存损坏 bug 都可能让攻击者接管整个系统。在这个世界里,语言的选择成为一个关键的安全决策。通过用像 Rust 这样的内存安全语言编写尽可能多的系统部分,我们极大地降低了初始损坏发生的概率,从而在硬件边界缺失的地方提供了一种统计上的防御()。

内部圣殿:可信执行环境

我们已经建立了一个强大的堡垒,有硬件基础、操作系统法则和安全编程的文化。但如果立法者本身,即操作系统内核,被攻破了呢?恶意软件有时可以获得最高级别的特权,从那里,一切都不再安全。为了保护我们最珍贵的秘密——加密磁盘的主密钥、银行的认证令牌——我们需要一个地方来隐藏它们,这个地方即使对操作系统内核也是安全的。

这就是​​可信执行环境(TEE)​​的目的。TEE 就像一个由 CPU 硬件本身创建的保险库或内部圣殿。放置在 TEE(一个“enclave”)内部的代码和数据受到硬件级别的加密和访问控制的保护。即使是在 Ring 0 运行的操作系统内核也无法读取 enclave 内部的内存。

想象一下你登录了一个企业网络。你的计算机持有一个 Kerberos 票据,一个证明你身份的秘密令牌。如果在你的机器上运行的恶意软件窃取了这个票据,它就可以冒充你。如果恶意软件获得了管理权限,标准的进程隔离可能就不够了。解决方案是使用 TEE,例如由基于虚拟化的安全(VBS)启用的 TEE。Kerberos 票据存储在这个隔离的环境中,正常的操作系统及其所有进程都无法访问。当应用程序需要认证时,它向 TEE 发出一个安全调用,TEE 代表它使用票据,但从不将原始秘密暴露给不受信任的世界()。

这些内部圣殿的架构设计各不相同。Intel 的 SGX 将 enclave 创建为用户空间进程内的隔离区域,这意味着操作系统内核必须将请求委托给用户空间的辅助程序,这增加了开销。另一方面,ARM 的 TrustZone 将整个处理器划分为“正常世界”和“安全世界”,允许正常世界的内核通过一条特殊指令直接调用安全世界的内核()。两种方法都在努力解决同一个根本问题:如何在一个复杂的、不可信的系统中创建一个可信的空间,以及如何在不引入像缓存计时攻击这样具有破坏性的侧信道漏洞的情况下做到这一点。

这种将可变与可信分离的原则甚至出现在高性能语言运行时的设计中。​​写异或执行(W^X)​​策略是一种安全姿态,即内存页面可以是可写的或可执行的,但绝不能同时是两者。这可以防止一种经典攻击,即攻击者将恶意代码注入可写的数据缓冲区,然后诱骗程序执行它。对于一个即时(JIT)编译器来说,它本质上必须在运行时写入新的机器码然后执行它,W^X 提出了一个难题。解决方案通常是一个“trampoline(跳板)”系统:JIT 将可执行代码写入一个读写缓冲区,然后在跳转到它之前,将完成的代码复制到一个独立的、只读可执行的页面,从而在任何时候都严格遵守 W^X 规则()。

从硬件的 PMP 到操作系统的进程,从编译器的证明到 CPU 的 TEE,我们看到一个统一的纵深防御体系。每一层都提供一种不同类型的保证,它们共同构成我们每天依赖的稳健、安全的计算环境。其美妙之处不在于任何单一的机制,而在于所有机制的优雅合作。