有限次非栈上格式化改条件扩充循环写
字数 3239
更新时间 2026-03-23 14:32:47

格式化字符串漏洞利用教学:结合AES解密、非栈上格式串与循环写构造ROP链

1. 题目简介与环境

题目来源: 2024 CISCN比赛题目 anime
核心漏洞: 格式化字符串漏洞
特殊限制:

  1. AES解密层: 用户输入并非直接传递给printf,而是先经过一次AES-ECB解密。
  2. 非栈上格式串: 解密后的格式化字符串数据存储在.bss段(全局缓冲区),而非栈上。
  3. 有限次数: 程序只提供3次输入/触发漏洞的机会。
  4. 稳定利用条件: 必须将这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 可定位到后续要覆盖的关键栈区域(例如保存返回地址的位置)。
  • 调试确认: 偏移1519需通过动态调试预先确定。

结果: 获得libc_basestack_ret(目标返回地址在栈上的位置)。

4.2 阶段二:搭建“指针跳板” (第二次输入)

目标: 在栈上构造一个可控的“两级指针”写入机制。
原理: 格式化字符串的%n类写入,其目标地址来自于栈上对应的参数槽。我们可以先修改某个栈槽的值,让它指向我们真正想写的位置,然后再用另一个%n向这个“指针”所指向的地址写入数据。

操作:

  1. 选择“指针槽”: 假设选择第19个参数槽(PTR_SLOT = 19),它目前存放着一个栈地址(在阶段一已泄露)。
  2. 修改指针指向: 使用%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链的每一个字节进行操作:

  1. 设置写入地址:

    • 使用%19$hn(双字节写)来设置“指针跳板”(即PTR_SLOT指向的值)。
    • 通过精确控制printf输出的字符数量n,使得n = (stack_ret + i) & 0xffff,其中i是当前要写入的字节在ROP链中的偏移。
    • 执行%19$hn,将n这个16位值写入PTR_SLOT所指向的地址(即修改了指针值)。此时,“写指针”就被设置到了stack_ret + i
  2. 写入单字节值:

    • 现在需要向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加密,核心功能包括:

  1. pad_block(plain): 将明文格式串填充至16字节(PKCS#7等格式)。
  2. encrypt_block(plain, key): 使用题目相同的AES-ECB模式和密钥,将16字节明文加密为密文。
  3. 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链,完成利用。它考察了攻击者对格式化字符串漏洞原理的深刻理解、在严格限制下的创造力以及对利用链的全局规划能力。

相似文章
相似文章
 全屏