南京大学 操作系统 (JYY) 学习笔记:动态链接的黑魔法与内存入侵 (Dynamic Linking)

发布时间:2026/7/29 20:00:43

南京大学 操作系统 (JYY) 学习笔记:动态链接的黑魔法与内存入侵 (Dynamic Linking) 写在前面这是本系列的第十一篇。在前几讲中我们已经知道进程从execve的初始状态开始可以通过mmap改变地址空间通过fork创建新进程。只要有 ELF 可执行文件把它的PT_LOAD数据段正确映射到内存里程序就能跑起来就像我们的 Funny Little Executable 实验。但本讲要面对一个更庞大的工程问题当开发者希望把库函数如 libc和应用程序“分离”开但又希望在运行时能随时调用库函数该怎么办课前补课静态链接 vs 动态链接静态链接库 (.a / .lib):就像是一个提前打包好的工具箱。当你写程序时编译器会把你需要的加法、减法等功能代码**直接死死地“拷贝”**到你的最终可执行文件ELF里。程序变大了但它完全独立不需要依赖外界。动态链接库 (.so / .dll):就好比是一个放在外面广场上的“共享工具箱”。程序里只留一张欠条符号表。当程序真正运行起来时操作系统再去找到这个共享库在运行时把它们拼在一起。就像租用工具随用随借。动态链接机制与为什么需要它我们熟悉的 Windows 下的.dll或者 Linux 下的.so(Shared Object)都是动态链接的产物。为什么要“拆解”应用程序早期的时候游戏如果需要用到d3dx9_xxx.dll里的功能就会把整个库静态链接进游戏本体。结果每个游戏都复制了一份庞大的图形库硬盘和内存瞬间爆炸。1. 实现运行库和应用代码分离 (应用间共享)每个 C 程序都需要glibc。如果使用动态链接整个操作系统内存里只需要一个 libc 的物理副本成百上千个进程同时共享它。运行库和应用程序可以独立升级互不干扰。2. 大型项目的工程分解比如庞大的 Android 源码改一行代码如果需要重新链接生成 2GB 的大文件程序员会疯掉的。拆分成libjvm.so,libart.so每次只编译更新那个.so文件即可。可以用ldd和file命令来观察rootLAPTOP-GT06V0GS:~# ldd /bin/lslinux-vdso.so.1(0x00007ffe950d8000)libc.so.6/lib/x86_64-linux-gnu/libc.so.6(0x00007f829a846000)... rootLAPTOP-GT06V0GS:~# file /lib/x86_64-linux-gnu/libselinux.so.1/lib/x86_64-linux-gnu/libselinux.so.1: ELF64-bit LSB shared object, dynamically linked...动态链接的阴暗面供应链攻击与依赖地狱任何技术都有代价。库的依赖本质上也是一种代码克隆甚至把风险也克隆了过来。xz-utils (liblzma) 投毒事件 (CVE-2024-3094):震惊全球的安全事件黑客 JiaT75 潜伏开源社区长达三年获取信任成为维护者后巧妙绕开 fuzz 测试向基础压缩库投毒。导致全球依赖这个.so的 Linux 系统的 SSHD 均面临后门风险。如果 Linux 全是静态链接的呢如果不用动态链接一旦libc出了安全漏洞你需要把全系统几万个应用程序全部重新编译链接一次这是不可能完成的任务。Dependency Hell (依赖地狱):A 依赖 B 的 v1 版本C 依赖 B 的 v2 版本而你的项目同时依赖 A 和 C。当这种依赖嵌套成网状时版本冲突和不兼容更新就会让人彻底崩溃。感谢动态链接库虽然有坑但如果没有它现代计算机世界早就崩塌了。揭秘底层引擎mmap 和虚拟内存一个极其狂野的实验我们构造一个包含 100MB 无用代码 (nop指令) 的巨型共享库libbloat.so。然后同时启动1000个动态链接了这个库的进程问题系统的物理内存会被消耗 100MB 还是 100GB如果是 100GB电脑瞬间死机宕机。答案只消耗 100MB共享库加载的真相 (没有魔法)你执行./a.out时第一条被执行的指令根本不在你的代码里它是操作系统通过读取 ELF 文件头的INTERP(Interpreter) 段去调用了动态链接器如/lib64/ld-linux-x86-64.so.2。链接器使用mmap系统调用把libc等共享库映射到内存中。核心魔法只读方式mmap同一个文件物理内存中只有一份真实的副本。1000 个进程的页表全都指向同一块物理内存。操作系统的内存 Tricks地址空间表面上看是“若干连续的内存段”实际全是操作系统用分页机制虚构出来的幻象延迟加载 (Lazy Allocation):不到万不得已触发 Page Fault绝不给进程分配真物理内存。写时复制 (Copy-on-Write, COW):fork()时父子进程共享内存。只有当某一方试图写入时操作系统才紧急复制一份Page fault 时写者复制一份。内存去重 (Memory Deduplication):操作系统在后台悄悄扫描内存如果发现有两页内容完全一样的只读页就悄悄合并它们内存压缩与 Swapping:扫描发现某些内存页很久没人用了Cold pages就把它压缩或者扔到硬盘交换区里。实现动态链接GOT 与 PLT 的诞生如果把动态链接交给你来设计你会怎么做方案1加载时重定位 (libc.o)把程序和库一起搬进内存加载时暴力修改所有引用了库函数的地址。缺点极慢链接器需要解析成千上万个根本不会被运行的符号并且修改代码会导致代码页无法被多个进程共享因为每个进程加载的基地址不同。方案2位置无关代码 (Position-Independent Code, PIC)这就对了我们需要映射同一个libc.so并且无论它被加载到哪个虚拟地址代码都必须能正常运行。A Layer of Indirection (引入一个间接层)难题main调用了.so库里的printf但printf在运行时的地址是未知的。我们该用什么汇编指令跳过去编译器的选择 1全部查表跳转。每次调用都多查一次内存表。对于极其高频的函数性能损失不可忍受。编译器的选择 2全部直接跳转。但 x86 的call指令只支持 4 字节的相对偏移而libc.so可能被映射到了离当前代码十万八千里远的高位地址偏移量超出了 32 位极限根本跳不过去伟大的发明PLT 与 GOT为了兼顾性能和位置无关计算机先驱们发明了组合拳GOT (Global Offset Table - 全局偏移表):这本质上是一个存放指针的数组放在.data数据段里。加载时动态链接器把真正的printf物理地址填进这个数组。PLT (Procedure Linkage Table - 过程链接表):在可执行文件的代码段里“合成”一段跳板代码蹦床printfplt: jmp *GOT_PRINTF_OFFSET(%rip)调用流程你的代码call printfplt$ \rightarrow $ 跳转到蹦床 $ \rightarrow $ 蹦床去 GOT 表里读取真正的绝对地址 $ \rightarrow $ 跳进libc.so对于全局变量extern int x怎么办不能用间接跳转指令了。编译器一旦开启了-fPIC就会强制为所有的外部变量增加一层间接寻址必须去 GOT 表里拿一遍指针再解引用。这必然带来一定的性能损耗。终极黑魔法LD_PRELOAD计算机世界里没有黑魔法所有的“外挂”都有迹可循。LD_PRELOAD是 Linux 动态链接器提供的一个环境变量。它允许你强行指定一个动态链接库使其在程序启动时被最高优先级加载。LD_PRELOAD./mylib.so ./a.out它是怎么实现拦截Hook的因为动态链接器在解析符号比如找malloc在哪时是按照顺序查找的。如果你的mylib.so里也写了一个叫malloc的函数链接器找到它之后就不会再往下找glibc里的malloc了这样你就在不修改原本a.out代码的情况下成功劫持并替换了它的系统底层函数。rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec11/ldpreload# ./test_programTesting malloc/free hooks...# 成功的拦截程序每一次申请内存都被我们预先加载的恶意/监控代码记录了下来Allocated100bytes at 0x55ba218c86b0 Allocated200bytes at 0x55ba218c8720Take-away Messages (总结)找到正确的思路我们就能在复杂的机制中找到主干在动态链接的例子里我们理解了为了实现位置无关和代码共享先驱们创造了 ELF 中的GOT(Global Offset Table) 和PLT(Procedure Linkage Table)。而正是这种动态寻址机制赋予了我们利用LD_PRELOAD进行函数 Hook 的极客能力。

相关新闻