ret2libc
原理
ret2libc的原理是利用栈溢出返回到动态链接库进行攻击
ret2libc 这种攻击方式主要针对动态链接
编译的程序,因为正常情况下无法在程序中找到像system()
、execve()
这种系统级函数。如果程序是动态链接生成的,在程序运行时会调用libc.so
(程序被装载时,动态链接器会将程序所有所需的动态链接库加载至进程空间,libc.so
就是其中最基本的一个),libc.so
是 Linux 下 C语言库中的运行库glibc
的动态链接版,并且libc.so
中包含了大量的可以利用的函数,包括 system()
、execve()
等系统级函数,还包括了/bin/sh
字符串,我们可以通过这些函数在内存中的地址覆盖掉返回地址来获得当前进程的控制权。
如此就只剩下两个问题:
- 在内存中找到
sysytem()
这种函数的地址; - 在内存中找到
"/bin/sh"
这个字符串的地址。
动态链接
动态链接是指在程序装载时,通过动态链接器将程序所需的所有动态链接库装载到进程空间中,并在程序运行时将它们链接在一起形成一个完整程序的过程。动态链接的主要目的是为了避免静态链接带来的内存和磁盘空间浪费,并支持模块化开发。
静态链接的缺点:
- 资源浪费:每个程序都将所有需要的库代码直接编译进程序中,导致内存和磁盘空间的浪费。
- 重编译问题:如果某个库发生了变化,所有使用该库的程序都需要重新编译,增加了维护成本。
动态链接的优点:
- 节省资源:多个程序可以共享同一个动态链接库,减少了内存和磁盘空间的消耗。
- 模块化开发:支持模块化开发,不同模块可以由不同的开发者或团队开发,库的更新只需替换动态库文件,而无需重新编译整个程序。
通过file
命令可以查看程序是动态链接还是静态链接
- 动态链接:
dynamically(动态),表明了程序为静态链接。
- 静态链接:
statically(静态),表明了程序为静态链接。
PLT&GOT
Linux 下的动态链接是通过 PLT 表和 GOT 表实现的。
PLT:
- PLT 是一个用于函数调用的表,它的每个条目对应一个函数。PLT的主要作用是处理动态链接库中的函数调用。
- 当程序第一次调用一个动态链接库时,实际上调用的是 PLT 中的一个“跳板”,该跳板会将控制权转移到 GOT 表中记录的实际地址。
通过objdump
工具观察程序 PLT 表:
GOT:
- GOT 是一个数据表,用于存储动态库函数的地址。
- 在程序执行时,GOT 表的条目会被 PLT 填充,以便后续的函数调用可以直接跳转到正确的地址。
通过 GDB 观察 GOT 表:
延迟绑定
延迟绑定指的是只有在程序实际调用到某个 libc
函数时,才会利用 PLT
进行地址解析,从而更新 GOT
表中的函数地址。换句话说,若某个函数尚未被调用,那么 GOT
表中的地址将不会是其实际地址。
这种情况下,如果需要泄漏 libc
地址,必须使用已调用过的函数来进行泄露。
我们通过gdb
调试一个程序来观察一下延迟绑定。
- 调用前
程序没有调用puts
函数前puts
函数的地址和其它函数的地址都是一样的,都是默认的占位值。
- 调用后
调用过一次puts
函数后可以看到puts
函数的 got 表地址发生了变化,因为它已经进行了重定位。
PLT 和 GOT 的工作流程
下面我们通过大佬的图来理解一下。
- 第一次函数调用:
- 当程序第一次调用
libc
中的函数时,控制权首先会跳转到 PLT 中对应的函数入口。 - PLT 中的对应函数是一个指向 GOT 表的间接跳转,这个跳转会将程序转移到 GOT 表对应地址。
- 当程序第一次调用
- 跳转到 GOT 表:
- PLT 中的函数会转到 GOT 表中的相应位置,这个位置保存了函数的实际地址。
- 重定位:
- 在第一次调用函数时,GOT 表是一个占位值,是 PLT 表跳转指令的下一条指令地址。
- 程序执行
push
命令将参数入栈,然后跳转到公共 PLT 表。 - 执行
pushl
命令将参数入栈,然后跳转到 GOT 表的第三项,它是一个重定位的函数。
- PLT 更新 GOT 表:
- 重定位函数(通常是动态链接器的一部分)会被调用,更新 GOT 表中的函数地址为实际地址。
- 返回并执行:
- 一旦 GOT 表中被更新为正确的地址,程序控制会返回到 PLT 的入口处,这时 PLT 可以直接跳转到正确的函数地址。
注意:这里 PLT 表的第一项没有给符号名,自动改成了离他最近的一项。
之后我们如果要对同一函数再次调用将直接通过更新后的 GOT 表条目跳转到正确的地址,从而提高效率。
ret2libc的几种情况
ret2libc
攻击通常有以下几种情况:
- 程序自身没有
system
函数和/bin/sh
字符串,但提供了libc.so
文件。 - 程序自身没有
system
函数和/bin/sh
字符串,也没有提供libc.so
文件。
针对这些情况,常见的解题思路如下:
- 利用栈溢出和
puts
函数泄露GOT
表中libc
函数的地址。 - 根据泄露的地址,利用最低的 12 位确定
libc
版本(即使程序有 ASLR 保护,最低 12 位通常不会变化)。 - 使用识别出的
libc
版本计算system
函数和/bin/sh
字符串在内存中的实际地址。
常用泄露函数:
printf
函数puts
函数write
函数read
函数
32位
[SWPUCTF 2023 秋季新生赛]ezlibc
拿到附件后发现多了一个libc-2.31.so
文件,这就是libc
库文件。
查保护
发现程序为32位,并且没有栈溢出(canary)和地址随机化保护(PIE)。
分析程序
优先运行程序,发现程序输出一串字符串之后进行了一次输入,之后再输出了一串字符串。
ida 打开分析程序
发现main调用了一个dofunc函数,进入查看
发现了输入函数read
这就是那个一次输入,而write函数输出到标准输出(标识符1)就是输出。
我们发现输入read
函数的输入限制长度过大,而接收变量只有12个字节所以必然产生了溢出。
我们通过pwndbg来测量溢出长度。
首先通过cyclic
命令构造一串字符串,参数为字符串指定长度,cyclic
命令用于构造特意构造的字符串。
在不指定参数的情况下cyclic
命令默认构造100个字符长度的字符串。
之后选中复制这串字符串,然后让程序运行起来。
在就是前面的一次输入,这时候将复制的字符串粘贴输入。
程序报错地址无效,这是因为我们的字符串覆盖掉了返回地址。
这个无效的地址,就是我们通过cyclic
命令构造的字符串其中覆盖掉返回地址的那部分。
我们可以通过cyclic -l
加上这个报错字符串来测量缓冲区到返回地址的长度。
可以看到,显示缓冲区到返回地址的长度为20。
而输入长度限制0x100字节,所以很明显程序存在栈溢出,而且溢出字节数很大。
利用思路
我们可以通过在程序输入前已经调用过的write
函数来泄露 libc 函数 got 表地址
然后通过接收 got 表地址,减去 libc 文件对应函数偏移来计算 libc 基地址。
然后计算sytem
函数和/bin/sh
字符串的地址。
内存实际函数地址 = 基地址 + 偏移地址
之后再通过栈溢出来进行 ret2libc 攻击。
因为 libc 库一般都有 PIE 保护,所以我们根据符号表获取到的函数地址只是偏移地址。
exp
1 |
|
64位
[CISCN 2019东北]PWN2
查保护
发现程序为64位,并且没有 canary 保护和 PIE 保护。
分析程序
首先运行程序,发现存在菜单选项,并且根据选项进行输入。
ida 打开分析
程序最上面输出了一个图标,之后输出了欢迎信息。
进入begin函数查看内容,发现这就是菜单信息函数。
程序根据输入的序号执行不同的选项。
进入encrypt函数查看,发现危险函数 gets,判断存在溢出。
查看ida字符串窗口,没有发现system函数和/bin/sh字符串,所以要进行ret2libc攻击。
利用思路
由于 64 位程序优先通过寄存器传参所以我们先要搜索程序的 gadget。
然后通过 gadget 构造 payload 来进行地址泄露。
首先发送序号选择第一个选项到达溢出函数,之后发送第一次 payload 拿到 puts 函数的 got 地址。
根据 got 地址计算 libc 基址,然后通过 libc 文件偏移计算system
地址和/bin/sh
字符串地址。
由于我们没有 libc 文件所以需要根据泄露的地址进行搜索,这里我们通过一个 python 库LibcSearcher
来进行搜索。
由于存在同样的函数地址的libc版本比较多,所以我们需要一个一个的去尝试。
通过输入序号选择相应版本的动态链接库。
亦或者我们可以通过在线的网站进行搜索,根据函数和泄露地址的后三位进行搜索。
同样可以看到搜索出了很多版本,我们也需要一个一个尝试。
exp
1 |
|
后言
参考链接:PWN入门(1-3-6-4)-基本ROP-ret2libc实战3(无system函数,无/bin/sh) (yuque.com)
参考链接:Basic-ROP (yuque.com)>