
现代软件建立在模块化和效率的基础之上,并严重依赖共享库为无数应用程序提供通用功能。然而,在现代安全实践面前,这种模型带来了一个根本性的挑战。当地址空间布局随机化(ASLR)等安全机制在每次程序启动时都将一段单一的、共享的代码放置在一个全新的、不可预测的内存地址时,这段代码如何能正确运行?任何在运行时修改代码的幼稚尝试都会同时破坏效率和安全性,并违反关键的“写异或执行”()原则。本文将通过剖析动态链接核心的精妙解决方案来阐述此问题。
读者将踏上一段旅程,探索使现代软件成为可能的复杂间接寻址之舞。“原理与机制”一章将首先揭示全局偏移表(GOT)的核心概念,解释它如何提供一个间接层,将不可变的代码与可变的数据分离开来。我们将探讨位置无关代码(PIC)和过程链接表(PLT)如何与 GOT 协同工作,在运行时解析数据和函数地址。随后,“应用与跨学科联系”一章将拓宽视野,揭示这一核心机制如何成为连接操作系统、编译器设计、高性能计算以及网络安全攻击者与防御者之间持续斗争的关键环节。
想象一下,你是一位城市规划师,身处一个整个区域都可能在一夜之间被搬走的世界。你刚建好了一座美丽的中央图书馆。你该如何编写前往图书馆的路线指南?如果你印制的指示牌上写着“图书馆在主街 123 号”,那么一旦城市网格发生变动,这些指示牌就会变得毫无用处。你将需要为每一种可能的城市布局都准备一套不同的指示牌。一个更聪明的做法是在每个区的入口处放置一张可更新的大地图。区内的指示牌只需简单地写上“前往区入口处的地图”。每天晚上,只有这张地图本身会被更新,标上图书馆新的绝对地址。
这正是现代操作系统所面临的挑战,而它们设计的精妙解决方案是软件运行方式的基石。这里的“区”就是程序,而“图书馆”则是共享代码库——一套通用的函数集合,比如被成千上万个应用程序使用的标准 C 库。出于安全考虑,现代操作系统采用了地址空间布局随机化(ASLR)技术,这就像每次程序启动时都移动一次城市网格。共享库对于每个程序都会被加载到不同的虚拟内存地址。那么,共享库自身的代码(它在编译时远不知道自己将被加载到何处)如何能正确地找到自己的函数和数据呢?
最直接的想法就是直接修复那些“指示牌”。当操作系统为一个程序加载库时,一个名为动态加载器的特殊软件可以扫描库的机器码,并手动将每个硬编码的地址“修补”成该程序独有布局下的正确地址。这个过程称为代码重定位(text relocation)。
乍一看,这似乎可行。但这是一个糟糕的主意,原因有二,且都十分深刻。
首先,它破坏了我们最初想要实现的“共享”目的。为了修补代码,操作系统必须为每个程序创建一个私有的、可写的副本。如果十个程序使用同一个库,你现在就会有十个几乎相同的副本占用物理内存,每个副本中修补的地址略有不同。这种被称为写时复制(copy-on-write)的机制意味着内存成本会急剧膨胀。对于 个进程,你将为这些补丁付出 个额外副本的代价,而不是共享一个副本。使用共享库带来的内存节省优势荡然无存。
其次,也是更关键的一点,这是一个巨大的安全漏洞。现代计算机架构强制执行一项严格的安全策略,称为写异或执行()。一个内存区域可以是可写的,或者是可执行的,但绝不能同时两者皆是。这项策略是防御那些试图注入并运行恶意代码的攻击的有力武器。要执行代码重定位,我们就需要使代码段本身可写,从而打破这一基本的安全屏障。事实上,在任何强制执行 的系统上,加载需要代码重定位的库将会直接失败。
我们那个幼稚的方案既低效又不安全。我们需要一个更精妙、更优美的解决方案。
有一句名言常被认为是 David Wheeler 说的:“计算机科学中的所有问题都可以通过增加一个间接层来解决。” 我们共享库问题的解决方案正是这一原则的杰作。
我们不直接修补代码,而是将不可变的纯代码与它所需的可变的、特定的地址分离开来。代码被保存在一个只读、可执行的区段,可以被所有进程真正地共享。而地址则被收集到一个特殊的、每个进程独有的“地图”中,这个“地图”位于一个私有的、可写的数据区段。这个地图就是全局偏移表(GOT)。
可以这样理解:共享代码包含的是相对方向(“我需要的变量在地图的第三个槽位”),而 GOT 就是地图本身,存放着绝对地址(“第三个槽位指向内存地址 0x7f8c12345678”)。每个进程都获得一份私有的 GOT 副本,但它们都共享同一个物理代码副本。当程序启动时,动态加载器的唯一工作就是根据随机化的内存布局,用正确的地址填充该进程的私有 GOT。共享代码则保持原样,纯净且安全。
这就引出了一个新问题:共享代码如何知道去哪里找到它自己的私有 GOT?毕竟,GOT 在每个进程中的地址也是不同的。
答案在于另一项架构上的精妙设计:程序计数器相对寻址。代码中的一条指令可以被写成“到距离我现在位置 500 字节的地方去”,而不是“到绝对地址 X 去”。当编译器和链接器构建共享库时,它们知道任何给定指令与该库 GOT 之间的固定距离。这个相对偏移量被固化到代码中。
无论操作系统将库放置在内存的哪个位置,这个相对距离都保持不变。一条想要找到 GOT 基地址的指令,只需将一个固定的偏移量加到它自己的地址(即程序计数器 PC 的值)上即可。这就是位置无关代码(PIC)的精髓。
让我们看看具体过程。一条位于地址 0x400100 的指令可能需要找到 GOT 的基地址,加载器已将其放置在 0x600000。编译器知道当指令执行时,PC 已经前进到 0x400104,于是计算出所需的偏移量:0x600000 - 0x400104 = 0x1FFEFC。它将这个偏移量直接嵌入到指令中。在运行时,CPU 只需计算 0x400104 + 0x1FFEFC 就能得到正确的 GOT 地址 0x600000。一旦这个基地址被存入寄存器,访问第三个条目就变得轻而易举:(因为在 64 位系统上地址是 8 字节)。
在程序启动时,动态加载器通过处理一个重定位条目列表来填充 GOT,它根据程序的加载地址计算出符号和指针的最终地址,并将它们写入相应的 GOT 槽位中。
访问全局数据的问题现在解决了。但是,如何调用另一个共享库中的函数,比如无处不在的 printf 呢?
一种方法是让加载器在启动时找到 printf 的地址并将其放入 GOT。然后代码会执行一个像 call [address_from_GOT] 这样的间接调用。这种方式完全可行,被称为立即绑定。然而,一个大型应用程序可能链接了数百个函数,但在一次典型运行中只使用了其中几个。在启动时查找所有这些函数的地址会显著减慢程序的启动时间。
为了解决这个问题,动态加载器采用了一种非常巧妙的技巧,称为延迟绑定。其思想很简单:在函数第一次被调用之前,不去费力查找它的地址。这个过程由 GOT 的一个搭档——过程链接表(PLT)——来协调。
PLT 是一小段可执行的代码“存根”或“跳板”的集合,与其余代码一样,它位于只读、共享的代码段中。当你的代码调用 printf 时,它被编译成一个到 printf@plt 存根的相对调用。在第一次调用时,会发生以下情况:
printf@plt 存根。printf 的 GOT 条目此时并不指向真正的 printf 函数。相反,它指向 PLT 存根内部的下一条指令。printf 的一个标识符压入栈中,并跳转到动态加载器内部一个特殊的解析器例程。printf 的真实地址。printf 的 GOT 条目,用新找到的真实地址覆盖旧值。printf 函数,函数得以执行。下一次你的代码调用 printf 时,它会再次跳转到 printf@plt 存根。但这一次,存根通过 GOT 的间接跳转会找到 printf 的真实地址。它会直接跳转到那里,完全绕过缓慢的解析器。高开销的查找只在需要时执行一次。PLT 和 GOT 之间这种复杂的协作是一种优美的优化,它将工作推迟到绝对必要时才执行。
这个精妙的间接系统不仅关乎性能和内存效率,它还是现代系统安全的支柱。通过启用 PIE(位置无关可执行文件),GOT/PLT 机制允许主可执行文件本身被加载到随机地址,这使得攻击者更难预测内存布局。
然而,延迟绑定机制存在一个微妙的安全代价。因为 GOT 必须在运行时被修补,所以它在程序的整个执行期间都必须保持可写状态。一个聪明的攻击者如果找到了其他漏洞(如缓冲区溢出),就可能覆写一个 GOT 条目,从而劫持合法的函数调用,并将其重定向到恶意代码。
为了应对这种威胁,我们可以选择牺牲延迟绑定的启动性能来换取更高的安全性。通过设置环境变量(LD_BIND_NOW=1)或使用特殊的链接器标志,我们可以指示动态加载器使用立即绑定。它会在启动时解析所有符号,一旦 GOT 被完全填充,就可以请求操作系统将整个 GOT 设为只读。这项策略被称为完全重定位只读(Full RELRO)。
这使得系统安全性大大提高。整个机制由硬件的内存管理单元(MMU)支持。启用 RELRO 后,GOT 所在页表的页表项的“写入”权限位会被清除。任何后续试图写入 GOT 的行为——无论是来自攻击者还是程序错误——都会立即触发硬件保护错误,操作系统将终止该进程。这是一个典型的工程权衡:一个更慢、更安全的启动,还是一个更快、但略微脆弱的启动。
优化的空间甚至可以更细粒度。从代码到 PLT 存根的额外跳转给每次外部函数调用增加了一点微小的开销。对于紧凑循环中的性能关键代码,即使这点开销也很重要。一些编译器提供了绕过 PLT 的选项,它会生成直接从 GOT 加载函数地址并调用的指令,每次调用能节省几个 CPU 周期,代价是代码体积略微增大。
从 CPU 指令和内存页的底层细节,到安全与效率的高层目标,全局偏移表不仅仅是一个数据结构。它是一个复杂而优美系统的关键,是编译器、链接器、操作系统和硬件之间的一场无声之舞,它们协同工作,使我们的软件能够安全、高效、无缝地运行。
在上一章中,我们剖析了全局偏移表(GOT)这套精美的机制。我们看到它不仅仅是一个数据结构,更是一场复杂表演——一场“间接之舞”——中的核心编舞者。正是这场舞蹈,使得我们的软件能够实现模块化、高效率和安全性。程序无需预先知道其组件将位于内存的何处;它可以在运行时动态确定,由 GOT 指挥执行流程。
现在,我们将探索这场舞蹈发生在何处。我们会发现,GOT 并非孤立的编译器知识点,而是一个连接着广阔且看似毫不相干领域的关键:操作系统架构、现代编程语言的实现、软件防御者与攻击者之间的持续对抗,以及高性能代码的优化。让我们登上舞台,见证这一精妙机制的深远影响。
想象一下,你在写一封信,但不知道收件人的最终地址。你不能直接在信封上写地址。相反,你可能会写:“请投递至中央邮局名录中‘Jones’名下的地址。”这正是现代编译器用来创建位置无关代码(PIC)的策略。
在现代操作系统中,一种名为地址空间布局随机化(ASLR)的安全特性会有意在程序每次运行时将其自身及其共享库加载到随机的内存位置。这挫败了那些依赖于知晓代码或数据确切位置的攻击。但如果一个程序不知道自己组件的位置,它又如何能正常运行呢?
这就是 GOT 最根本的作用。考虑访问一个全局数组 A。编译器不能将 A 的绝对地址硬编码到程序的指令中。相反,它生成的代码大意是:“首先,查阅全局偏移表以找到 A 的真实基地址。然后,计算我们所需元素的偏移量,并将其加到该基地址上。”动态链接器——这个将程序加载到内存中的实体——负责在 A 的随机位置确定后,用正确的基地址填充 GOT。
元素 A[i] 的最终地址计算变成了一个优美的、两步走的运行时过程:(从 GOT 获取的基地址) + (索引 * 元素大小)。代码本身对其在内存中的绝对位置一无所知;它只知道通往 GOT 的相对路径,而 GOT 则指向那片“应许之地”。这种简单的间接寻址是共享库和安全的现代可执行文件的基石。
GOT 表演的主要舞台是动态链接。在这里,它与过程链接表(PLT)合作,后者是一系列充当函数调用跳板的小代码存根。当你的程序调用像 printf 这样的外部函数时,它不会直接跳转到 printf。相反,它会跳转到 PLT 中的 printf 存根。然后这个存根执行关键的一步:跳转到 GOT 中 printf 条目所列出的地址。
这种间接性使得共享 C 库中的单个 printf 实现能够被数百个程序同时使用。但这种灵活性并非完全没有代价。仔细分析(考虑缓存性能和分支预测等因素)会发现,以这种方式进行的每次调用都会产生一个虽小但可测量的开销。与直接的静态链接调用相比,PLT/GOT 调用涉及一次额外的内存访问(从 GOT 读取地址)和一个间接分支,这可能稍慢一些,也更难被处理器预测。在高性能计算领域,这些纳秒级的延迟会累积起来。
因此,我们面临一个经典的工程权衡:灵活性与原始速度。我们能两全其美吗?答案是链接时优化(LTO)。启用了 LTO 的链接器可以一次性分析整个共享库,像一个聪明的效率专家一样行事。它可能会发现某个函数,比如 internal_helper,只在同一个库内部被调用。这是一个没有外部调用者的“内部”函数。在这种情况下,链接器可以将该函数声明为“隐藏”的,并将其所有内部调用重写为快速的直接跳转,完全绕过 GOT 和 PLT。这种优化从“舞蹈”中删除了不必要的步骤,通过消除那些对于外部灵活性并非必需的间接寻址,减小了最终的二进制文件大小并加快了执行速度。
由 GOT 编排的间接之舞是如此基础,以至于它构成了许多现代编程语言特性赖以构建的基底。
考虑虚方法调用,这是面向对象编程(OOP)的基石。当你在一个对象上调用虚方法时,程序首先查看对象内部,找到一个指向其类的虚方法表(VMT)的指针。然后,它在该表中查找正确的函数指针并跳转到该函数。这已经是一个两步的间接过程:对象 -> VMT -> 函数。现在,如果对象是由一个共享库中的代码创建的,但它继承的虚函数却定义在另一个共享库中,会发生什么呢?系统巧妙地在上面又加了一层间接。VMT 条目不会直接指向函数,而是会指向调用库中该函数的 PLT 存根。调用路径变成了一个令人眼花缭乱但完全合乎逻辑的链条:对象 -> VMT -> PLT 存根 -> GOT -> 最终函数。每一层抽象都为这个指针链增加了一个环节。
这一原则也延伸到了函数式编程的概念中。“闭包”是一个强大的特性,它将一个函数与其“环境”——即其所需来自周围作用域的变量——捆绑在一起。从本质上讲,闭包是一个包含代码指针和环境指针的数据结构。当你调用一个可能在不同库中创建的闭包时,你实际上是通过一个函数指针进行间接调用。底层的系统机制(通常涉及使用 GOT 的、称为“thunks”的特殊代码存根)使这成为可能,确保了调用是位置无关的,并且像其他任何调用一样进行动态链接。
任何依赖于一个可变的、受信任的地址表的机制,都必然会吸引安全研究人员和恶意行为者的注意。GOT 的灵活性也是其潜在的弱点。
最著名的攻击是 GOT 劫持(GOT Poisoning)。在其默认的“延迟绑定”模式下,GOT 在运行时是可写的,以便动态链接器可以在函数首次调用时填入其地址。如果攻击者发现了一个允许他们向任意内存位置写入数据的漏洞,他们就可以将目标对准 GOT。通过用自己恶意代码的地址覆写例如 printf 的条目,他们就可以劫持程序的控制流。下一次程序无辜地调用 printf 时,它就会在不知不觉中直接跳入攻击者的陷阱。这种概念性的攻击可以被建模,以理解其毁灭性的潜力。
幸运的是,防御手段和攻击手法一样巧妙。一种名为只读重定位(RELRO)的安全特性会指示动态链接器在完成其初始工作后将 GOT 设为只读。这彻底杜绝了 GOT 劫持。然而,其代价是需要“立即绑定”——所有符号必须在加载时解析,牺牲了延迟绑定的启动性能优势。这是一个操作系统设计者必须做出的典型的安全与性能权衡的例子。
GOT 在实现防御措施方面也扮演着角色。栈金丝雀(stack canary)是一个放置在栈上用以检测缓冲区溢出的值,它通常是一个存储在全局变量(例如 __stack_chk_guard)中的随机数。对于一个位置无关的程序来说,要找到这个关键的安全变量,它当然必须在 GOT 中查找其地址。
最后,动态链接器的行为,以 GOT 为其“账本”,创造了一种强大的机制,称为符号介入(symbol interposition)。通过设置像 [LD_PRELOAD](/sciencepedia/feynman/keyword/ld_preload) 这样的环境变量,你可以强制链接器首先加载你自己的共享库。如果你的库提供了一个与标准库中函数同名的函数(例如,你写了你自己的 malloc),链接器就会将所有对 malloc 的调用解析到你的版本,并将其地址写入 GOT。这对于调试和性能分析来说是一个不可或缺的工具。然而,这是一把双刃剑,因为恶意软件可以利用完全相同的技术来“钩取”系统函数,并秘密地监控或改变程序的行为。
凭借我们对 PLT 和 GOT 的理解,我们现在可以戴上侦探的帽子,从外部分析一个已编译的程序。想象一位逆向工程师或反编译器在检查一个二进制文件时遇到了指令 call 0x400560。这个地址本身是毫无意义的。
然而,侦探知道这个地址位于文件的 PLT 区段内。通过计算它相对于 PLT 起始位置的偏移,他们可以确定其槽位索引——比如说,索引 3。然后,他们转向另一条证据:包含重定位条目的 .rela.plt 区段。在该表中查找索引 3,就会发现与该槽位关联的符号名称:printf。最后,通过知道 C 库在运行时加载到内存的哪个位置,他们可以计算出 printf 的绝对地址,并明确指出正在调用的是哪个函数。这个揭示层层间接关系的过程是软件分析中的一项基本技能,而 PLT、GOT 和重定位表之间定义明确的“舞蹈”结构使之成为可能。
以全局偏移表为编舞者的间接之舞,是现代软件的一个统一原则。正是这个简单而深刻的思想,使得我们的程序能够安全地加载到内存的任何位置,通过库高效地共享代码,支持现代语言的强大抽象,并且能够被分析和调试。它证明了当一个简单的机制以优雅和精确的方式应用于整个软件栈时所能产生的优美。