格式化字符串漏洞利用教学:结合AES解密、非栈上格式串与循环写构造ROP链
1. 题目简介与环境
题目来源: 2024 CISCN比赛题目 anime
核心漏洞: 格式化字符串漏洞
特殊限制:
- AES解密层: 用户输入并非直接传递给
printf,而是先经过一次AES-ECB解密。 - 非栈上格式串: 解密后的格式化字符串数据存储在
.bss段(全局缓冲区),而非栈上。 - 有限次数: 程序只提供3次输入/触发漏洞的机会。
- 稳定利用条件: 必须将这3次有限输入扩展成一个能够循环多次写入的稳定利用链,最终构造ROP链。
目标: 在以上限制条件下,实现完整的利用,最终获得任意代码执行。
2. 题目核心逻辑分析
2.1 程序流程
1. 读取用户输入 -> `ciphertext_password` (密文缓冲区,.bss段)
2. AES_ECB_Decrypt(ciphertext_password, decrypted_password, key) -> 解密第一个16字节块
3. printf(decrypted_password) -> 触发格式化字符串漏洞
4. 循环/条件判断,限制最多执行三次步骤1-3。
2.2 关键限制与影响
- AES单块限制: 虽然
read可读入0x40字节,但只有前16字节会被解密并最终用于printf。因此,每次可用的格式化字符串明文长度上限为16字节。 - 已知密钥: AES密钥硬编码在程序中,为
0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE(小端拼接后)。这意味着攻击者可以本地计算任意明文对应的密文。 - 非栈上格式化: 漏洞触发时,格式串本身在
.bss段。但printf解析%n、%p等格式化占位符时,依然会从调用栈上获取对应的参数。因此,泄露和写入的对象是栈内存,而非.bss段。
3. 利用思路总览
由于只有3次机会和16字节的有效载荷长度,无法像传统格式化字符串漏洞那样逐步尝试偏移、泄露、写入。本题的利用必须精心设计,将三次输入串联成一个完整的攻击链。
核心利用链:
第1次输入 (泄露) -> 泄露libc基址、栈地址,为后续写入建立“坐标”。
第2次输入 (搭建跳板) -> 修改栈上的一个“指针槽”,使其指向我们想要写入的目标区域附近。
第3次输入 (建立循环写原语) -> 修改另一个栈槽或程序状态,将“三次机会”扩展为“可循环多次写”。
后续循环写入 -> 利用建立好的写原语,逐字节将ROP链写入栈上返回地址区域。
4. 详细利用步骤拆解
4.1 阶段一:信息泄露 (第一次输入)
目标: 获取libc基址和一个关键的栈地址。
方法: 在16字节的格式串中,精心构造泄露语句。
示例格式串 (明文): %15$p.%19$p 或类似组合。
%15$p: 泄露栈上第15个参数(假设为一个指向libc中某固定偏移的地址)。通过计算leaked_addr - 0x29d90可获得libc基址。%19$p: 泄露栈上第19个参数(假设为一个栈地址)。通过计算leaked_stack_addr - 0x100 - 0x18可定位到后续要覆盖的关键栈区域(例如保存返回地址的位置)。- 调试确认: 偏移
15和19需通过动态调试预先确定。
结果: 获得libc_base和stack_ret(目标返回地址在栈上的位置)。
4.2 阶段二:搭建“指针跳板” (第二次输入)
目标: 在栈上构造一个可控的“两级指针”写入机制。
原理: 格式化字符串的%n类写入,其目标地址来自于栈上对应的参数槽。我们可以先修改某个栈槽的值,让它指向我们真正想写的位置,然后再用另一个%n向这个“指针”所指向的地址写入数据。
操作:
- 选择“指针槽”: 假设选择第
19个参数槽(PTR_SLOT = 19),它目前存放着一个栈地址(在阶段一已泄露)。 - 修改指针指向: 使用
%hn(双字节写)修改这个地址的低两字节,使其指向stack_ret(目标返回地址)附近的一个位置,例如stack_ret - 0x2c。这一步是为后续的精细写入“架设桥梁”。- 格式串构造: 需要精确控制输出的字符数,使其等于目标地址的低两字节值,然后通过
%19$hn写入。
- 格式串构造: 需要精确控制输出的字符数,使其等于目标地址的低两字节值,然后通过
结果: 栈上第19号槽现在指向我们想要写入的目标区域附近。
4.3 阶段三:建立可循环写的原语 (第三次输入)
目标: 修改程序逻辑或状态,突破“3次输入”的限制,实现可重复进入漏洞触发流程。
方法 (文档中暗示的思路):
- 改条件/循环变量: 程序可能通过一个计数器(如栈上或全局变量)限制为3次。利用格式化字符串的写能力,修改这个计数器,使其不满足退出条件(例如,改为一个很大的数),从而让程序可以继续循环接受输入。
- 或建立稳定的“写针”: 结合阶段二搭建的指针,精细地设置另一个栈槽(例如第
49号槽,WRITE_SLOT = 49),使其与%49$hhn配合,能够向“指针槽”当前所指向的地址写入单字节。
结果: 攻击者获得了一个可以反复使用的、能够向任意地址(通过指针控制)写入任意单字节(通过输出长度控制)的原语。程序进入可循环输入状态。
4.4 阶段四:逐字节写入ROP链 (后续循环输入)
在获得了稳定的循环写原语后,可以开始向stack_ret处写入ROP链。
写入策略 (文档中详述的经典方法):
这是一个两步循环过程,对ROP链的每一个字节进行操作:
-
设置写入地址:
- 使用
%19$hn(双字节写)来设置“指针跳板”(即PTR_SLOT指向的值)。 - 通过精确控制
printf输出的字符数量n,使得n = (stack_ret + i) & 0xffff,其中i是当前要写入的字节在ROP链中的偏移。 - 执行
%19$hn,将n这个16位值写入PTR_SLOT所指向的地址(即修改了指针值)。此时,“写指针”就被设置到了stack_ret + i。
- 使用
-
写入单字节值:
- 现在需要向
stack_ret + i写入一个具体的字节值byte。 - 如果
byte == 0,直接使用%49$hhn,由于当前输出字符数为0,就会向WRITE_SLOT指向的地址(即stack_ret + i)写入0。 - 如果
byte != 0,先输出byte个字符(例如通过%{byte}c),再使用%49$hhn,将byte写入stack_ret + i。
- 现在需要向
循环: 对ROP链的每一个字节,重复上述“设置地址 -> 写入字节”的过程。虽然速度较慢,但稳定可靠。
4.5 ROP链构造
- 由于已获得
libc基址,可以构造system("/bin/sh")或execve系列的ROP链。 - 为什么不直接用
one_gadget: 文档指出,直接覆盖返回地址为one_gadget可能因寄存器环境不满足其严格约束而失败。通过ROP链可以更可控地设置参数,成功率更高。
5. 辅助工具与EXP编写要点
5.1 本地辅助脚本
需要一个脚本处理AES加密,核心功能包括:
pad_block(plain): 将明文格式串填充至16字节(PKCS#7等格式)。encrypt_block(plain, key): 使用题目相同的AES-ECB模式和密钥,将16字节明文加密为密文。mydecode(fmt_string): 封装函数,输入为明文字符串,输出为可直接发送给程序的密文字节流。
5.2 EXP结构框架
from pwn import *
import aes_helper # 自定义的AES辅助模块
context.binary = './anime'
context.log_level = 'debug'
def my_decode(fmt_str):
"""将明文格式串转换为可发送的AES密文"""
cipher = aes_helper.encrypt_block(pad(fmt_str), KEY)
return cipher
# 1. 连接
p = process('./anime')
# 2. 第一次输入 - 泄露
fmt1 = b'%15$p.%19$p' # 实际偏移需调试确定
p.send(my_decode(fmt1))
# ... 解析泄露,计算 libc_base, stack_ret ...
# 3. 第二次输入 - 修改指针跳板
# 构造使输出长度等于 (stack_ret - 0x2c) & 0xFFFF 的格式串
write_val = (stack_ret - 0x2c) & 0xFFFF
fmt2 = f'%{write_val}c%19$hn'.encode() # 注意长度可能超过16字节,需精简
# 可能需要用 %c 和 %hn 的巧妙组合来压缩长度
p.send(my_decode(fmt2))
# 4. 第三次输入 - 建立循环写/修改循环条件
# 例如,修改限制次数的变量
# 或构造 fmt3 来设置 %49$hhn 的写入目标
# ...
# 5. 循环写入ROP链
rop_chain = p64(pop_rdi) + p64(bin_sh) + p64(system) # 示例
for i, byte in enumerate(rop_chain):
# 设置地址: 使指针指向 stack_ret + i
addr_part = (stack_ret + i) & 0xFFFF
# 构造设置地址的格式串 (需考虑长度)
# 发送...
# 写入字节: 向 stack_ret + i 写入 byte
# 构造写入字节的格式串
# 发送...
p.interactive()
(注:以上为伪代码框架,实际EXP需根据调试确定的偏移、长度限制和循环条件进行精细构造和压缩)
6. 漏洞修复建议
文档中提及了修复方法:将不安全的printf(format)调用,替换为安全的printf("%s", format)或puts(format)。这样,用户输入将被视为普通字符串输出,而不会被解析为格式字符串,从而从根本上杜绝漏洞。
总结:本题是一道经典的、限制严格的格式化字符串漏洞综合题。其利用精髓在于利用有限的几次输入,通过修改栈上指针和程序状态,搭建出一个稳定的、可循环的任意地址写原语,并最终通过这个“慢速但稳定”的原语,逐字节布置ROP链,完成利用。它考察了攻击者对格式化字符串漏洞原理的深刻理解、在严格限制下的创造力以及对利用链的全局规划能力。