1. ret2libc1

0x01 利用思路

 ret2libc这种攻击方式主要是针对 动态链接(Dynamic linking) 编译的程序,因为正常情况下是无法在程序中找到像 system() 、execve() 这种系统级函数(如果程序中直接包含了这种函数就可以直接控制返回地址指向他们,而不用通过这种麻烦的方式)。因为程序是动态链接生成的,所以在程序运行时会调用 libc.so (程序被装载时,动态链接器会将程序所有所需的动态链接库加载至进程空间,libc.so 就是其中最基本的一个)libc.so 是 linux 下 C 语言库中的运行库glibc 的动态链接版,并且 libc.so 中包含了大量的可以利用的函数,包括 system() 、execve() 等系统级函数,我们可以通过找到这些函数在内存中的地址覆盖掉返回地址来获得当前进程的控制权。通常情况下,我们会选择执行 system("/bin/sh") 来打开 shell, 如此就只剩下两个问题:
 1 . 找到system()函数的地址;
 2 . 在内存中找到/bin/sh这个字符串的地址

0x02 动态链接(Dynamic linking)

动态链接 是指在程序装载时通过 动态链接器 将程序所需的所有 动态链接库(Dynamic linking library) 装载至进程空间中( 程序按照模块拆分成各个相对独立的部分),当程序运行时才将他们链接在一起形成一个完整程序的过程。它诞生的最主要的的原因就是 静态链接 太过于浪费内存和磁盘的空间,并且现在的软件开发都是模块化开发,不同的模块都是由不同的厂家开发,在 静态链接 的情况下,一旦其中某一模块发生改变就会导致整个软件都需要重新编译,而通过 动态链接 的方式就推迟这个链接过程到了程序运行时进行。这样做有以下几点好处:

1、节省内存、磁盘空间

例如磁盘中有两个程序,p1、p2,且他们两个都包含 lib.o 这个模块,在 静态链接 的情况下他们在链接输出可执行文件时都会包含 lib.o 这个模块,这就造成了磁盘空间的浪费。当这两个程序运行时,内存中同样也就包含了这两个相同的模块,这也就使得内存空间被浪费。当系统中包含大量类似 lib.o 这种被多个程序共享的模块时,也就会造成很大空间的浪费。在 动态链接 的情况下,运行 p1 ,当系统发现需要用到 lib.o ,就会接着加载 lib.o 。这时我们运行 p2 ,就不需要重新加载 lib.o 了,因为此时 lib.o 已经在内存中了,系统仅需将两者链接起来,此时内存中就只有一个 lib.o 节省了内存空间。

2、程序更新更简单

比如程序 p1 所使用的 lib.o 是由第三方提供的,等到第三方更新、或者为 lib.o 打补丁的时候,p1 就需要拿到第三方最新更新的 lib.o ,重新链接后在将其发布给用户。程序依赖的模块越多,就越发显得不方便,毕竟都是从网络上获取新资源。在 动态链接 的情况下,第三方更新 lib.o 后,理论上只需要覆盖掉原有的 lib.o ,就不必重新链接整个程序,在程序下一次运行时,新版本的目标文件就会自动装载到内存并且链接起来,就完成了升级的目标。

3、增强程序扩展性和兼容性

动态链接 的程序在运行时可以动态地选择加载各种模块,也就是我们常常使用的插件。软件的开发商开发某个产品时会按照一定的规则制定好程序的接口,其他开发者就可以通过这种接口来编写符合要求的动态链接文件,以此来实现程序功能的扩展。增强兼容性是表现在 动态链接 的程序对不同平台的依赖差异性降低,比如对某个函数的实现机制不同,如果是 静态链接 的程序会为不同平台发布不同的版本,而在 动态链接 的情况下,只要不同的平台都能提供一个动态链接库包含该函数且接口相同,就只需用一个版本了。

总而言之,动态链接 的程序在运行时会根据自己所依赖的 动态链接库 ,通过 动态链接器 将他们加载至内存中,并在此时将他们链接成一个完整的程序。Linux 系统中,ELF 动态链接文件被称为 动态共享对象(Dynamic Shared Objects) , 简称 共享对象 一般都是以 “.so” 为扩展名的文件;在 windows 系统中就是常常软件报错缺少 xxx.dll 文件。

0x03 全局偏移表 GOT(Global offect Table)

1. 装载时重定位

了解完动态链接后,会有一个问题:共享对象在被装在时,如何确定其在内存中的地址?
这是就需要利用装载时重定位的思想,即 共享对象(SO文件) 里的函数/变量地址不是“写死”的,而是记录“基于基址的偏移量”;当SO被加载到实际内存地址后,动态链接器(ld-linux.so)会根据实际地址,修正所有对这些函数/变量的引用,让它们指向正确的内存地址。

通俗比喻
你开了一家连锁便利店(SO),原本计划把店开在 A 街 1 号(假设的基地址),货架编号(函数 / 变量地址)都是 “基于 A 街 1 号” 编的(比如 “收银台:A 街 1 号 + 5 米”)。但实际你把店开到了 B 街 10 号(实际装载地址),这时候需要在开业前(装载时)把所有货架编号修正为 “B 街 10 号 + 5 米”—— 这个 “修正编号” 的过程,就是装载时重定位

2.地址无关代码(PIC)

但随之而来的问题是:指令部分无法在多个进程之间共享,这时候就需要地址无关代码

指令段(.text)是 SO 的核心代码(比如函数逻辑),系统默认把它设为只读(read-only)PIC(位置无关代码)的核心巧思就是把 “需要动态修改的地址信息” 从只读的指令段(.text)剥离到可写的数据段(比如 GOT/.DATA)**,既保证指令段能被所有进程共享,又让每个进程有自己的数据段副本(存各自的实际地址),最终实现 “代码共享、数据独用”,完美解决了 “任意地址装载” 和 “节省空间” 的双重需求

PIC 的 “分离” 实现:用 GOT 表集中管理可修改地址
PIC 把所有需要动态重定位的 “地址引用”(比如全局变量、外部函数)都集中放到全局偏移表(GOT,Global Offset Table) ——GOT 属于数据段(.got/.got.plt),是可写的,且每个进程加载 SO 时,会有自己的 GOT 副本。

x86 汇编中call指令的相对偏移寻址原理

main()函数中调用 fun()函数 ,指令为:

![[Screenshot 2026-03-06 093557.png]]

  1. 偏移量计算逻辑

    call指令的偏移是相对于下一条指令地址计算的,而不是当前指令地址:

    1. 计算下一条指令地址:

      0x11f9+5 = 0x11fe

      call指令本身占 5 字节:1 字节操作码 + 4 字节偏移)

    2. 计算偏移量:

      偏移=目标地址−下一条指令地址 = 0x11b9−0x11fe = −69

    3. 将 - 69 转换为 32 位补码:

      −69 = 0xffffffbb

    4. 小端存储:将0xffffffbb按字节倒序存储,得到bb ff ff ff,与机器码一致。

  2. 指令与地址信息

    • 当前call指令地址:0x11f9
    • 指令机器码:e8 bb ff ff ff
    • 目标函数fun()地址:0x000011b9
    • e8call指令的操作码,后面 4 个字节bb ff ff ff相对偏移量
  3. 为什么用相对偏移?

    • 位置无关性:偏移量是相对的,无论程序被加载到内存的哪个地址,call指令都能正确跳转到目标函数。
    • 代码共享:指令段(.text)不需要修改,多个进程可以共享同一份代码副本,节省内存。
    • 安全特性:支持 ASLR(地址空间布局随机化),有效防范针对固定地址的攻击。