背景介绍
前段时间,我们学校组织了一场校内的以推广CTF赛事为主的网络安全比赛。当时其实并没有很想参加,不过在舍友的安利之下 ,我于第二天加入了内卷洪流之中。
不过出乎意料的,比赛的赛题设计的很有意思,也很适合我这种网络安全知识面比较广但是没做过实操的人。就这样我开启了我的网络安全修炼之路。
比赛过程中,我所用到的大部分的知识都属于现学现卖,最终也是取得了不错的成绩,于是,我也有了写博客的想法。希望能为后人种一种树。
本篇文章作为入门级Pwn赛题的讲解,只需读者掌握部分计算机学科基础知识即可。
本篇文章只关注入门的内容,如果你是高手可能会觉得很无聊。
Pwn是什么?
通常是要利用程序中的特定漏洞,构造特定字符串(也称payload)输入程序中,以达到控制目标主机的目的。
通常这种题目都相当直白,直接扔给你一份编译后的可执行程序,然后给你一个ip地址和端口,这个端口上跑的就是这个可执行程序,你要做的就是在本地分析出这个可执行程序中存在的漏洞,并且巧妙构造一份字符串,输入给这个端口,然后如果构造正确的话,就可以利用这个端口控制远程主机。
发现漏洞一般都是比较简单的事情,然而,想要利用这个漏洞控制远程主机,需要非常心灵手巧才行 。因此这类题上手难度较大,不过一旦成功往往会有一种巨大的成就感。
题目
逍遥作为艰苦的历程,其要义在于它经由对自身的出离而与异于自身的他者相遇,并与他者共同构成彼此相聚的整体,在此整体中确证自身且返回自身。《庄子》对逍遥的诗意言说方式本身,并不掩盖逍遥观念自身的过程本质。撇开了如此艰苦的历程,逍遥就会成为毫无内容的仅仅溢于言表的虚幻的托辞。逍遥必须回到其艰苦而丰富的展开过程,才是真实的。 ——郭美华
请把字符串交给庄子,设法让庄子在计算回文串时,在服务器上执行代码或命令,读取文件系统中某处存储的 Flag。
你可以 下载本题的程序
点击 “打开/下载题目” 将打开网页终端,你也可以通过命令 nc prob05.geekgame.pku.edu.cn 10005 手动连接到题目
PS: 上述指令其实就是和prob05.geekgame.pku.edu.cn的10005端口建立TCP连接,因为服务器可能关闭,所以上述IP端口可能失效
做出本题需要掌握的知识
程序安全保护信息与实验环境
IDA的简单使用——反汇编和反编译可执行文件
首先需要下载一个静态分析利器ida,这个软件可以对可执行文件进行反汇编,甚至可以反编译成比较容易看懂的C语言代码。
这里给出一个下载链接,读者可根据自身需要选择:
安装成功后,主要有两个程序,一个是IDA Pro 7.5 SP3,另一个是IDA Pro 7.5 SP3 x64,前者是用来分析32位可执行文件的,后者是用来分析64位可执行文件的。
一般先用IDA Pro 7.5 SP3 x64,它会提示你可执行文件是64位还是32位的,打开它,点击new,选择要分析的可执行文件,把本题的程序解压出来,选择pwn这个文件。
然后软件会叫你选择文件的加载方式,这里什么都不用改,直接点OK就行,注意这里IDA识别出来了这个可执行文件的类型是ELF64 for x86-64,ELF是Linux下主要的可执行文件的文件格式,x86-64则代表该可执行文件是64位的:
左边列出了该程序中所有的函数,包括程序内部定义的和链接外部的。我们选择main函数,先看看主函数:
右边是main函数反汇编的结果,为了舒服我们一般都再反编译成C语言,摁下F5
即可:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v5; // [rsp+Ch] [rbp-4h] BYREF
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
banner();
__isoc99_scanf("%d", &v5);
while ( v5-- )
run();
return 0;
}
双击banner
和run
可以看到banner
和run
子函数的代码:
可以看到banner这里给了一个提示,告诉你本题的程序其实是百练上一道上机题的解B:回文子串。
int banner()
{
printf("This is a solution for this problem: %s\n", "http://bailian.openjudge.cn/xlylx2019/B/");
return puts("PWN it!");
}
int run()
{
char s[104]; // [rsp+0h] [rbp-80h] BYREF
int v2; // [rsp+68h] [rbp-18h]
int k; // [rsp+6Ch] [rbp-14h]
int j; // [rsp+70h] [rbp-10h]
int i; // [rsp+74h] [rbp-Ch]
int v6; // [rsp+78h] [rbp-8h]
int v7; // [rsp+7Ch] [rbp-4h]
__isoc99_scanf("%s", s);
v2 = strlen(s);
v6 = 0;
for ( i = 0; i < v2; ++i )
{
for ( j = i + 1; j <= v2; ++j )
{
if ( (unsigned int)check(&s[i], (unsigned int)(j - i)) && v6 < j - i )
{
v6 = j - i;
v7 = i;
}
}
}
for ( k = v7; k < v7 + v6; ++k )
putchar(s[k]);
return putchar(10);
}
至此,我们利用IDA工具成功获取了可执行程序的反编译代码,可以对程序逻辑进行分析了,因为编译过程中,会抹去变量名、函数名、部分结构信息等,所以反编译后的代码看起来会和我们正常写的不一样。
细心的读者可能会发现题目给的另一个文件libc-2.31.so,这个文件的作用类似于Windows的dll文件,作为动态链接库向pwn提供库函数,接下来我们先讲解栈溢出漏洞的原理,这个文件将在之后的漏洞利用中登场。
函数调用栈与栈溢出漏洞
对于函数调用栈,比较细致的讲解可参考这篇文章: 手把手教你栈溢出从入门到放弃(上)。
这里以比较通俗的文字给读者一个函数调用栈的基本映像。我们在调用函数的时候,函数内部可能又去调用其他函数,然后再回过头来继续执行,我们把外部函数称为父函数,内部函数称为子函数,因为要确保子函数执行完毕后,我们能继续执行父函数,那么我们需要保存父函数的地址、父函数的局部变量的值等父函数的状态信息,这时候栈这种结构就派上了用场,因为函数的多级调用是一个先入后出的行为,即越先被调用的函数越后执行完毕,这和栈的性质是对应的。当我们要执行子函数时,就把父函数的状态信息保存在栈中,然后再栈中分配新的空间给子函数,待执行完毕之后pop掉子函数的状态恢复父函数的状态继续执行就行了。
因为接下来对于一些细节的讲解必须涉及到汇编,这里为不熟悉汇编的读者准备了一些讲解,可点开查看:
BP、SP和IP寄存器
寄存器主要是用来存储临时数据的,所谓临时就是值会经常发生改变。这里介绍一下和函数调用栈相关的特殊寄存器。
RBP、EBP、BP其实都是指向一个长度为64位的寄存器,只不过RBP代表这个寄存器的全部64位,EBP代表低32位,BP代表低16位,如果是32位操作系统,就没有RBP,只有EBP和BP。同理SP和IP也是这种规则。
RBP存储的可以理解成是当前执行函数状态的起始地址,RSP存储的是当前函数调用栈中最后一个元素的有效地址,也就是栈顶的位置。
RIP寄存器存储的是当前执行指令的地址,可以利用跳转指令来修改RIP的值,使其跳转到system或exec等函数执行一些可以入侵到目标主机的代码,比如说system("/bin/sh"),这也是Pwn题的最终目标。
PUSH和POP指令
PUSH指令就是将操作数压入栈中,具体就是先减小RSP的值,然后把操作数赋值到RSP指向的地址。例如PUSH EBP
。
POP指令就是将栈顶的元素弹出来,赋值给操作数。例如POP EBP
。
MOV指令
通常写作MOV DST, SRC
即将SRC的值赋值给DST,某些时候也会写成MOV SRC, DST
。
CALL、JMP和RET指令
CALL指令先把RIP的值压入栈中,然后跳转到操作数指向的地址。
JMP指令直接跳转到操作数指向的地址,即把操作数的值赋值给RIP。
RET指令从栈顶中取出地址赋值给RIP。
{/collapse-item}
接下来我们来用通俗的语言解释一次子函数调用的过程:
函数调用栈用一张图表示为(图片引用自手把手教你栈溢出从入门到放弃(上)):
所谓栈溢出,就是让局部变量的值覆盖掉Return Address,使得子函数返回的时候跳转到特定的地址执行一些危险代码。
那么,怎么局部变量的值覆盖掉Return Address呢?因为C语言不检查数组的索引是否越界,因此这一点经常被利用来实现栈溢出攻击。在本题中,我们注意到run函数中有一个__isoc99_scanf函数(其实就是scanf函数)读入数据到局部变量s上:
字符串读入的时候,是从低地址到高地址依次赋值的,因此只要输入的字符串够长,就可以覆盖掉Return Address:
ROP与栈溢出漏洞的利用
ROP中文名是面向返回的编程,顾名思义就是利用已有的动态链接库和可执行文件,提取出可以利用的指令片段(gadget),这些指令片段均以RET指令结尾,即用RET指令实现指令片段执行流的衔接(前面提到过RET会把栈顶的元素赋值给RIP),最后完成期待的恶意行为。
我们最终的目的是让程序执行一个可以控制shell的代码片段(gadget),那么怎么知道这种代码片段所在的位置呢?这里可以利用一个one_gadget工具,下载方法为:
sudo apt -y install ruby
sudo gem install one_gadget
用起来也很简单,pwn中是不可能有这种函数的,只能在libc-2.31.so中找:
one_gadget libc-2.31.so
然后工具会告诉你有哪些可以控制目标主机的代码片段,这些代码片段的位置,以及需要满足的条件:
这里我们就利用0xe6c7e这个位置的gadget,注意限制条件是:
- 对于r15,要么r15保存的这个地址指向0,要么r15本身是0。
- 对于r12,要么r12保存的这个地址指向0,要么r12本身是0.
为便于说明,下面我们称上面这个代码片段为gadget1。
有了控制目标主机的gadget,怎么才能跳转到它并满足上述限制条件呢,可分为两步:
- 因为目标主机可能开启了ASLR(即每次运行程序,栈的地址和动态链接库的地址随机变化),所以我们首先要设法得到gadget1在内存中的位置(上面那个0xe6c7e只是个相对地址),这里主要利用这样一个性质:.so文件中两个函数的相对地址(即两个地址的差)和加载到内存中的相对地址是一样的。
- 其次我们需要一段gadget来控制r15和r12寄存器的值。
对于第一步,我们需要调用.so文件中的puts函数,打印出某个函数在内存中的地址,并据此计算出相对地址,所以我们需要控制rdi寄存器的值(也就是控制puts函数的参数),在pwn程序中搜索寄存器的名称就能发现下面这一段代码:0x4013ba
后面这一段代码可以控制rbx、rbp、r12、r13、r14、r15寄存器的值,0x4013a6
则可以控制edi寄存器的值。
攻击载荷的巧妙构造
目前,我们需要的gadget都已经找齐,接下来就只需要巧妙的构造攻击载荷,让程序最终执行到gadget1。
具体来讲,首先需要控制程序,打印出.so中某个函数的地址,然后我们可以计算出函数加载到内存前后的相对位置,然后可以据此计算出当前内存中的gadget1的位置,然后再让程序跳转到这个地址。
Step1: 获得相对地址
下面这张图展示的攻击载荷将会使程序打印出当前内存中printf函数的地址:
程序会先跳转到0x4013ba
,然后将rbx的值修改为1,rbp的值修改为2,以此类推,然后程序会跳转到0x4013a6
,并修改edi的值,然后执行call指令,调用我们指定的puts函数,为了让程序不至于崩溃,我们最后控制它返回到了main函数。
用该地址减去.so文件中printf函数的地址,得到的就是函数加载到内存前后的相对地址。
Step2: 跳转到gadget1
和Step1类似,先跳转到0x4013ba
控制r12和r15寄存器的值,然后跳转到0xe6c7e
即可。
最后成功入侵系统的截图:
最后
pwn题是CTF比赛非常常见的题型,对于初学者来讲,因为解题涉及到太多需要实操的底层知识,上手难度会比较大,但实际上对于较强的选手来说,这类题型因为出题的套路不是很多,所以反而做起来比较容易。希望通过这篇文章能让读者对这类题型有个初步的认识。
评论 (0)