第四章:任意地址读(泄露)——用%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 定位格式化字符串在栈上的位置

要泄露任意地址,第一步是找到你的输入在栈上的偏移。怎么做?很简单:

  1. 输入AAAA%p.%p.%p...(或者AAAA%x.%x.%x...)
  2. 看哪个%p输出了0x41414141(即AAAA的十六进制)
  3. 那个位置就是你的输入在栈上的偏移

举个例子,假设你输入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,或者某个函数的返回地址指向程序自身的代码。

具体做法:

  1. 在栈上找到返回地址的位置(通常偏移比较固定)
  2. 用%n$p读出这个地址
  3. 减去该函数在二进制文件中的偏移,得到PIE基址

举个例子,假设你读出返回地址是0x555555555234,而main+123的偏移是0x1234,那基址就是0x555555555234 - 0x1234 = 0x555555554000。

经验之谈:我一般先泄露一个__libc_start_main的返回地址,因为它几乎每个程序都有,而且偏移在libc里是固定的。这样既能算PIE基址,又能为后续泄露libc地址铺路。

4.5 泄露libc地址

泄露libc地址是getshell的关键。因为system、execve这些函数都在libc里。你需要知道libc的基址,才能计算这些函数的实际地址。

方法:

  1. 在栈上找到某个libc函数的GOT条目地址,或者某个libc函数的返回地址
  2. 用%s去读那个地址指向的内容(即libc函数的实际地址)
  3. 减去该函数在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 实战流程总结

下面这张图展示了任意地址读的完整流程:

任意地址读流程图 任意地址读核心流程 步骤1:定位输入偏移 步骤2:扫描栈上数据 识别目标数据特征 Canary 末尾0x00 PIE基址 返回地址偏移 libc地址 GOT/返回地址 计算基址,完成泄露 关键:偏移定位是基础,特征识别是核心,基址计算是目标

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全捞出来。