XDCTF PWN500 Writeup

XDCTF PWN500分,也是pwn的最后一题,和苏大神一起拿下了它。虽然说没有什么非常高深的pwn技巧,不过一环套一环的利用步骤还是值得学习与存档的。我就详细的介绍一下这题pwn500的利用思路。
PS:因为要带新手,所以大神可能认为废话较多,勿怪。文章最后贴出了二进制文件和idb文件。对照着读更容易理解。有空的话我也许还会将源代码逆向还原一下,更适合新手学习。(不要太期待,博主很懒)

首先我们还是要分析程序。这一次的代码相当之长,最坑的是还有2个结构体,逆向最怕遇到结构体了。再次感谢苏大神的idb,已经帮我标好结构体了。

源码审计,无论是汇编源码还是c源码,都是做pwn的基本功。这里我就不多说了。主要的操作有6种,2个show的操作看起来不会有什么问题,reg注册操作只有一次,也没什么漏洞。这里竟然还有个cheat的操作,不过也看不出什么漏洞。

果然最容易出问题的还是最复杂的exam和resit操作。值得一提,这个程序还是多线程的,fork一个子线程接受输入再写到文件里面,再由另外的主线程从新读回来。根据经验,这种费力不讨好的地方肯定有出题者留下的坑。所以反复的看了好几遍exam中的这段代码。

step1 栈溢出

仔细看了看几个函数,果然发现了程序的第一个缺口——栈溢出漏洞。

1
2
if ( !new_pid )
get_input_to_file_0(tmpfile, essay_len)

这里函数调用的时候,essay_len,也就是限制输入长度的变量,它的取值范围是(0,104),这个值可以在前面交互时控制,最大可能长度是104。然后我们跟到这个get_input_to_file_0函数中去看一看。

1
2
3
4
5
6
void __fastcall __noreturn get_input_to_file_0(FILE *file, unsigned int len)
{

setvbuf(file, 0LL, 1, 0LL);
get_input_to_file(file, len);
exit(0);
}

看来还要继续跟到get_input_to_file这个函数中去。值得一提的是,这个函数是由fork的子线程调用的,这个函数结束后,线程就结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__int64 __fastcall get_input_to_file(FILE *file, int len)
{

signed __int64 v2; // rcx@1
char *v3; // rdi@1
FILE *stream; // [sp+8h] [bp-68h]@1
char s[96]; // [sp+10h] [bp-60h]@1

stream = file;
v2 = 11LL;
v3 = s;
while ( v2 )
{
*v3 = 0LL;
v3 += 8;
--v2;
}
*v3 = 0;
flen = 0;
do
flen += get_input(&s[flen], len - flen);
while ( flen != len );
fputs(s, stream);
return 0LL;
}

稍微分析一下,可以发现len确实就是输入的长度,而且输入是写到栈上的。这种地方就一定要检查一下栈的大小。

char s[96]; // [sp+10h] [bp-60h]@1

缓冲区只有96个字节,读入却可以是104个,溢出了。
恩,有溢出就可以控制eip让程序任意跳转,可以构造rop链来完成getshell的任务,这一题也就这样嘛。
刚刚这么想到一半,发现问题了。怎么只溢出了8个字节啊,完全不够用啊!!!

1
2
3
4
-0000000000000068 stream          dq ?                    ; offset
-0000000000000060 str db 96 dup(?)
+0000000000000000 s db 8 dup(?)
+0000000000000008 r db 8 dup(?)

这个是栈里面的情况,str是我们输入的字符串储存的地方,因为是64位程序,寄存器都是8个字节的,我们栈溢出只能溢出8个字节,最多把ebp的值给覆盖了,然后因为字符串结尾加0的关系,返回地址的最低位覆盖成了0。除了让线程崩溃之外,并没什么鸟用。

虽然覆盖了rbp其实也能做很多事情了,我们知道,一般函数的末尾都是

1
2
leave
ret

这样的返回格式,一旦覆盖了栈中的rbp,调用这个函数的父函数的rbp就由我们控制了,这样我们可以间接控制父函数的局部变量,然后在父函数返回的时候,因为leave指令的关系,连rsp也受我们控制了。这样我们就完成了一次栈迁移,把栈迁移到受我们控制的区域,效果等同于栈溢出。

但是,这里我们是做不到了,首先我们可以控制的数据都是在堆上,开了aslr的情况下,堆地址是无法预测的。没什么地方可以让我们迁移栈。然后我刚才也说了,父函数直接结束了,迁移了也没什么用。这里出题者显然是不想让我们这么轻易的就控制eip的。

step2 Use After Free(UAF)

做题的思路到这里就断了,想了很久也没什么头绪,闲着无聊就动态的调试程序,理一理整个程序的流程。然后就调试出了这个东西。

Error in `./jwc’: double free or corruption (fasttop): 0x00000000013863c0
[1] 6840 abort (core dumped) ./jwc

瞬间精神一震,这程序总算是崩溃了。崩溃了就说明有漏洞。赶紧开启gdb跟踪一下为什么崩溃。调试半天总算搞清楚了double free的原因。首先刚才我们输入的过长字符串虽然没有利用的可能,不过确实让线程崩溃了。本应该写到磁盘文件中的输入数据其实会先被留在缓冲区中,然后线程崩溃了,这样的话其实没东西写到文件里,文件是空的。

1
2
while ( fread(ptr + flen, 1uLL, 1uLL, tmpfile) )
++flen;

正因为文件是空的,所以其实flen,结构体中记录实际从文件中读取数据大小的变量其实是0。这个时候如果我们调用reset函数free内存块的话

1
2
3
4
5
if ( pScore->flen )
{
pScore->pEssay = 0LL;
pScore->flen = 0;
}

因为flen其实是0,所以即使free了内存,指向内存的指针pEssay也不会清零。万恶的野指针终于出现了。再resit一次free内存,就可以看到程序崩溃,double free了。

既然有double free,那么剩下的就应该是伪造内存,触发unlink宏中的dword shoot漏洞了。然而并没有这么轻松。构造double free的条件我们凑不齐(具体条件是啥,wooyun知识库或者HDUISA的wiki,或者我的博客中都有反正都是我写的233)。首先,指向堆的内存也储存在堆中,我们没办法leak地址。然后,程序分配的内存大小小于0x80。所以内存块是按照fastbin的方式分配的,而这种分配方式的内存很难构造double free的漏洞。至此,思路又断了。

然后又是好几个小时的无聊调试,直到有一次调试的时候,另外一个科目的score_info结构体正好填入了我们野指针指向的已经被释放的Essay内存中。瞬间精神又是一震。不能double free我可以Use After Free啊,怎么一直就没有想到呢?

再仔细看看内存中的堆结构,发现虽然score_info结构体需要的大小只有32字节,程序却分配了104个字节给它。这明显是出题者挖的坑啊,强行让score_info和Essay的大小一样。这样在fastbin分配内存的时候,score_info结构就会恰好分配在已经释放的Essay上。然后程序很便利的留了个cheat函数,摆明了让你用野指针去覆盖score_info结构中数据,竟然看了这么久却没有发现Orz。

看看score_info结构中有些什么?一个指向内存指针,通过cheat可以修改任意内存中的数据。一个指向函数的指针,修改他,可以跳转到任意地方执行代码。还有2个控制长度的变量,修改他们可以各种溢出。这个结构体可真是个核弹级别的东西啊,一旦被覆盖,就有无数的利用方法。

step3 格式化字符串

这题简直是PWN技巧的全家桶啊。为了实现getShell,咱们使用格式化字符串漏洞来leak出libc的加载基址。(啥为什么要libc基址,去HDUISA的wiki或者我的博客上找找怎么ret to libc又是我写的23333
这里我们覆盖score_info结构体中的pEssay指针指向got表中的free函数,然后再一次调用cheat,把free函数的地址改成printf。这样,程序调用free函数释放内存块就从

free(pMem);

变成了

printf(pMem)

而内存块中的内容是我们可以控制的,又正好是格式化字符串参数。这样就可以产生格式化字符串漏洞了。(不知道格式化字符串的继续翻wiki或者是博客去,都有写)我们总共有3门课程,一门的内存产生野指针,一门的内存被覆盖然后利用。最后一门就用来填格式化字符串参数。因为是64位程序,所以有6个参数是通过寄存器传递的,然后为了读出栈中的___libc_start_main函数返回地址。我们准备的格式化串是%21$016llx这样就可以leak基址了。额,等等,为什么这个payload竟然及格了!!好吧,后面随便加一点东西让它不及格吧%21$016llx123456这样就可以顺利的调用”free”了。

step4 ret to libc

通过刚才我们拿到的基地址,我们可以顺利的计算出程序在运行的时候,system函数的地址。有了这个地址,我们就可以愉快的跳转到system了。重新创建一个新的内存块,然后填上/bin/sh。然后再一次使用我们指向got中free的指针,把它修改成指向system函数(free:为什么老是我!!)。再一次free一下,shell就到手了。

step5 完整的利用POC

利用的poc和二进制文件和ida的idb文件和markdown格式writeup全部打成压缩包。随意转载,只求注个出处Orz
XDCTF-PWN500.zip