庄子的回文——从零开始的入门级64位ROP
标签搜索

庄子的回文——从零开始的入门级64位ROP

libero
2021-06-15 / 0 评论 / 1,132 阅读 / 正在检测是否收录...

背景介绍

前段时间,我们学校组织了一场校内的以推广CTF赛事为主的网络安全比赛。当时其实并没有很想参加,不过在舍友的安利之下 表情,我于第二天加入了内卷洪流之中。
不过出乎意料的,比赛的赛题设计的很有意思,也很适合我这种网络安全知识面比较广但是没做过实操的人。就这样我开启了我的网络安全修炼之路。
比赛过程中,我所用到的大部分的知识都属于现学现卖,最终也是取得了不错的成绩,于是,我也有了写博客的想法。希望能为后人种一种树。

本篇文章作为入门级Pwn赛题的讲解,只需读者掌握部分计算机学科基础知识即可。
本篇文章只关注入门的内容,如果你是高手可能会觉得很无聊。

Pwn是什么?

通常是要利用程序中的特定漏洞,构造特定字符串(也称payload)输入程序中,以达到控制目标主机的目的。

通常这种题目都相当直白,直接扔给你一份编译后的可执行程序,然后给你一个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函数

右边是main函数反汇编的结果,为了舒服我们一般都再反编译成C语言,摁下F5即可:
反汇编后的main函数

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;
}

双击bannerrun可以看到bannerrun子函数的代码:
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掉子函数的状态恢复父函数的状态继续执行就行了。
因为接下来对于一些细节的讲解必须涉及到汇编,这里为不熟悉汇编的读者准备了一些讲解,可点开查看:

接下来我们来用通俗的语言解释一次子函数调用的过程:

函数调用栈用一张图表示为(图片引用自手把手教你栈溢出从入门到放弃(上)):
函数调用栈

所谓栈溢出,就是让局部变量的值覆盖掉Return Address,使得子函数返回的时候跳转到特定的地址执行一些危险代码。
那么,怎么局部变量的值覆盖掉Return Address呢?因为C语言不检查数组的索引是否越界,因此这一点经常被利用来实现栈溢出攻击。在本题中,我们注意到run函数中有一个__isoc99_scanf函数(其实就是scanf函数)读入数据到局部变量s上:
利用scanf函数来实现栈溢出攻击
字符串读入的时候,是从低地址到高地址依次赋值的,因此只要输入的字符串够长,就可以覆盖掉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

然后工具会告诉你有哪些可以控制目标主机的代码片段,这些代码片段的位置,以及需要满足的条件:
利用one_gadget工具发现目标代码

这里我们就利用0xe6c7e这个位置的gadget,注意限制条件是:

  1. 对于r15,要么r15保存的这个地址指向0,要么r15本身是0。
  2. 对于r12,要么r12保存的这个地址指向0,要么r12本身是0.

为便于说明,下面我们称上面这个代码片段为gadget1

有了控制目标主机的gadget,怎么才能跳转到它并满足上述限制条件呢,可分为两步:

  1. 因为目标主机可能开启了ASLR(即每次运行程序,栈的地址和动态链接库的地址随机变化),所以我们首先要设法得到gadget1在内存中的位置(上面那个0xe6c7e只是个相对地址),这里主要利用这样一个性质:.so文件中两个函数的相对地址(即两个地址的差)和加载到内存中的相对地址是一样的。
  2. 其次我们需要一段gadget来控制r15和r12寄存器的值。

对于第一步,我们需要调用.so文件中的puts函数,打印出某个函数在内存中的地址,并据此计算出相对地址,所以我们需要控制rdi寄存器的值(也就是控制puts函数的参数),在pwn程序中搜索寄存器的名称就能发现下面这一段代码:
能够控制rdi、r15和r12寄存器的代码片段
0x4013ba后面这一段代码可以控制rbx、rbp、r12、r13、r14、r15寄存器的值,0x4013a6则可以控制edi寄存器的值。

攻击载荷的巧妙构造

目前,我们需要的gadget都已经找齐,接下来就只需要巧妙的构造攻击载荷,让程序最终执行到gadget1
具体来讲,首先需要控制程序,打印出.so中某个函数的地址,然后我们可以计算出函数加载到内存前后的相对位置,然后可以据此计算出当前内存中的gadget1的位置,然后再让程序跳转到这个地址。

Step1: 获得相对地址

下面这张图展示的攻击载荷将会使程序打印出当前内存中printf函数的地址:
打印printf函数地址
程序会先跳转到0x4013ba,然后将rbx的值修改为1,rbp的值修改为2,以此类推,然后程序会跳转到0x4013a6,并修改edi的值,然后执行call指令,调用我们指定的puts函数,为了让程序不至于崩溃,我们最后控制它返回到了main函数。
用该地址减去.so文件中printf函数的地址,得到的就是函数加载到内存前后的相对地址。

Step2: 跳转到gadget1

和Step1类似,先跳转到0x4013ba控制r12和r15寄存器的值,然后跳转到0xe6c7e即可。
跳转到指定gadget
最后成功入侵系统的截图:
成功入侵系统

最后

pwn题是CTF比赛非常常见的题型,对于初学者来讲,因为解题涉及到太多需要实操的底层知识,上手难度会比较大,但实际上对于较强的选手来说,这类题型因为出题的套路不是很多,所以反而做起来比较容易。希望通过这篇文章能让读者对这类题型有个初步的认识。

4

评论 (0)

取消