第四章:任意地址读(泄露)——用%x和%s撬开内存的嘴
格式化字符串漏洞最实用的地方,就是读内存。你想想看,一个printf就能把栈上、堆上、甚至任意地址的数据打出来,这简直是给攻击者开了个后门。我个人习惯把这种技术叫做“内存钓鱼”——你只需要把鱼钩(格式化字符串)甩出去,鱼(数据)自己就上钩了。
这一章我们聚焦在任意地址读。说白了,就是利用%x和%s这两个格式化占位符,把程序不想让你看到的东西——比如canary、PIE基址、libc地址——统统捞出来。
核心思想:格式化字符串的%n$x语法允许你直接访问第n个参数(在栈上)。如果你能把目标地址放到栈上,再用%s去读那个地址指向的内容,就能实现任意地址读。
4.1 栈上数据泄露:%x的妙用
先来个最简单的。假设程序里有这么一段代码:
char buf[100];
read(0, buf, 100);
printf(buf); // 漏洞就在这里
你输入%x.%x.%x.%x,printf就会把栈上前4个参数(其实是第1到第4个格式化参数位置)以十六进制打印出来。这些数据可能是局部变量、返回地址、甚至canary。
我记得第一次实战时,我输入了20个%x,结果终端刷出一大串十六进制数。当时我盯着屏幕看了半天,心想:这玩意儿真能看出门道?后来发现,只要你知道栈布局,就能从这些数里提取出关键信息。
小技巧:用%p代替%x会更清晰。%p会自动加0x前缀,而且指针宽度统一(32位4字节,64位8字节)。我一般调试时先用%p看个大概,再用%x精确控制输出格式。
4.2 定位格式化字符串在栈上的位置
要泄露任意地址,第一步是找到你的输入在栈上的偏移。怎么做?很简单:
- 输入
AAAA%p.%p.%p...(或者AAAA%x.%x.%x...) - 看哪个
%p输出了0x41414141(即AAAA的十六进制) - 那个位置就是你的输入在栈上的偏移
举个例子,假设你输入AAAA%6$p,输出是AAAA0x41414141,那偏移就是6。这意味着在printf的参数列表中,第6个参数位置正好是你的输入缓冲区起始地址。
注意:64位程序的前6个参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),从第7个开始才在栈上。所以偏移计算和32位不一样。我刚开始学的时候在这上面栽过跟头——在64位程序里用%7$p才找到输入,还以为是程序有问题。
4.3 泄露canary
Canary是栈保护机制的核心。它通常放在栈上局部变量和返回地址之间。如果能读到canary,就能在后续的栈溢出攻击中保持它的值不变,绕过检测。
怎么找canary?一般来说,canary的值以0x00结尾(因为它是8字节对齐,最低字节固定为0)。你在栈上扫描时,看到类似0x...00的值,大概率就是canary。
我曾经在调试一个CTF题目时,用%15$p读到一个值0x5f6d3c2b1a00,末尾是00,我立刻意识到这就是canary。后续的exploit里,我把它原样写回去,成功绕过了栈保护。
// 假设偏移是15,泄露canary
printf("%15$p"); // 输出: 0x5f6d3c2b1a00
4.4 泄露PIE基址
PIE(Position Independent Executable)让程序的代码段地址随机化。但如果你能泄露一个已知函数的地址,就能算出基址。
通常,栈上会保存返回地址,这个地址指向程序代码段。比如main函数的返回地址指向__libc_start_main,或者某个函数的返回地址指向程序自身的代码。
具体做法:
- 在栈上找到返回地址的位置(通常偏移比较固定)
- 用
%n$p读出这个地址 - 减去该函数在二进制文件中的偏移,得到PIE基址
举个例子,假设你读出返回地址是0x555555555234,而main+123的偏移是0x1234,那基址就是0x555555555234 - 0x1234 = 0x555555554000。
经验之谈:我一般先泄露一个__libc_start_main的返回地址,因为它几乎每个程序都有,而且偏移在libc里是固定的。这样既能算PIE基址,又能为后续泄露libc地址铺路。
4.5 泄露libc地址
泄露libc地址是getshell的关键。因为system、execve这些函数都在libc里。你需要知道libc的基址,才能计算这些函数的实际地址。
方法:
- 在栈上找到某个libc函数的GOT条目地址,或者某个libc函数的返回地址
- 用
%s去读那个地址指向的内容(即libc函数的实际地址) - 减去该函数在libc中的偏移,得到libc基址
这里要注意:%s会把地址当作字符串指针,读取直到遇到\x00。所以你要确保目标地址是可读的,而且%s读出来的数据是地址值(不是ASCII字符串)。
// 假设puts@GOT的地址在栈上偏移为10
// 构造payload: [puts@GOT地址] + %10$s
// 注意:地址要放在输入的开头,因为%10$s会从第10个参数位置读指针
char payload[20];
*(uint64_t*)payload = puts_got_addr; // 8字节地址
strcat(payload, "%10$s"); // 格式化字符串
printf(payload); // 输出: [puts的实际地址]
避坑指南:我曾经在泄露libc地址时,忘了地址里可能包含\x00字节(比如0x7f...00)。%s遇到\x00就停了,导致只读到部分地址。解决办法是:用%n$p读地址本身(如果地址在栈上),或者用%s读一个不含\x00的地址。
4.6 实战流程总结
下面这张图展示了任意地址读的完整流程:
4.7 三种泄露对比
| 泄露目标 | 特征 | 常用占位符 | 典型偏移位置 | 后续用途 |
|---|---|---|---|---|
| Canary | 末尾0x00,8字节对齐 | %p |
局部变量与返回地址之间 | 绕过栈保护,实现ROP |
| PIE基址 | 程序代码段地址(如0x55...或0x40...) | %p |
返回地址、函数指针 | 计算程序内函数/GOT地址 |
| libc地址 | 0x7f开头(64位)或0xf7开头(32位) | %s(读GOT)或%p(读返回地址) |
GOT条目、libc函数返回地址 | 计算system等函数地址 |
核心要点:任意地址读的本质是控制格式化字符串的参数位置。你只需要把目标地址放到栈上已知偏移的位置,然后用%n$s去读。偏移定位越准,泄露越稳。
好了,这一章的内容就到这里。记住:读内存是格式化字符串漏洞的“眼睛”,没有它,后续的写操作就是盲人摸象。多练几次偏移定位,你就能闭着眼睛把canary、PIE、libc全捞出来。