Address Sanitizer(投毒?投毒!)

本文最后更新于:2024年1月6日 早上

0x0: 写在所有之前

笔者在Fuzz101项目中,遇到了ASAN(Address Sanitizer)这个内存错误检测工具,感觉挺巧妙的。

这边就浅浅的记录一下

0x1: What is it?

ASAN(Address Sanitizer)是google开发的一个针对C/C++的高效率的内存错误检测工具

它可以检测以下类型的漏洞

  • stack buffer overflow
  • heap buffer overflow
  • global buffer overflow
  • use after free
  • use after return
  • use after scope
  • memory leak
  • initialization order bugs

同时,ASAN还支持x86、x68_64、mips、arm、powerpc等多种架构

可以说,好用的一批

那它是怎么做到的嘞

因为是内存检测工具,所以要做到全面的检查,就要对每次的内存读/写及其他操作进行监测

笔者刚开始的时候觉得ASAN可能是canary保护的plusplus版本,后来才知道ASAN通过一个编译器检测模块和一个劫持内存操作函数(例如malloc/free)的run-time库来对内存进行监测。

1
2
3
4
5
6
7
8
Before:
*address = ...; // or: ... = *address;

After:
if (IsPoisoned(address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // or: ... = *address;

Shadow Memory

影子内存,位于虚拟地址空间的中间(因为堆栈在两头),是用来记录主程序的内存是否可用的内存区域,这是ASAN的特有产物。

主程序的内存按照8字节对齐,而8个字节在影子内存中对应的区域为一个字节,所以主程序内存 :影子内存=8 :1。

影子内存无法在主程序中被读写,只有通过编译器相关代码才可访问

在每次对内存进行读写操作时,都会读取对应的影子内存检查其合法性

影子内存的计算公式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
64-bit
Shadow = (Mem >> 3) + 0x7fff8000;

[0x10007fff8000, 0x7fffffffffff] HighMem
[0x02008fff7000, 0x10007fff7fff] HighShadow
[0x00008fff7000, 0x02008fff6fff] ShadowGap
[0x00007fff8000, 0x00008fff6fff] LowShadow
[0x000000000000, 0x00007fff7fff] LowMem

32-bit
Shadow = (Mem >> 3) + 0x20000000;

[0x40000000, 0xffffffff] HighMem
[0x28000000, 0x3fffffff] HighShadow
[0x24000000, 0x27ffffff] ShadowGap
[0x20000000, 0x23ffffff] LowShadow
[0x00000000, 0x1fffffff] LowMem

ultra compact shadow
Shadow = (Mem >> 7) | kOffset;

Sanitizer 投毒

我觉得可能用大家都玩过的游戏——扫雷进行类比,会更好理解。

如果我们想要标记一个区域已经被使用,那么就在这块内存对应的Shadow Memory埋雷,一旦再次对此区域进行读写操作,就会触发那颗“雷“,从而造成crash。

1
2
3
4
shadow_address = MemToShadow(address);
if (ShadowIsPoisoned(shadow_address)) {
ReportError(address, kAccessSize, kIsWrite);
}

当然,这只是举个例子。不同的漏洞所对应的”扫雷“策略也不同。

每个影子内存对应的可能值有9个:

  • 当其对应的内存8个字节都未被投毒,value=0
  • 当其对应的内存8个字节都被投毒,value=负数
  • 当其对应得内存有k个字节被投毒,value=8-k
1
2
3
4
5
6
7
8
9
10
11
12
13
byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value) {
if (SlowPathCheck(shadow_value, address, kAccessSize)) {
ReportError(address, kAccessSize, kIsWrite);
}
}
// Check the cases where we access first k bytes of the qword
// and these k bytes are unpoisoned.
bool SlowPathCheck(shadow_value, address, kAccessSize) {
last_accessed_byte = (address & 7) + kAccessSize - 1;
return (last_accessed_byte >= shadow_value);
}

Report Error

ReportError 可以被实现为一个调用(这是默认方式),但还有一些其他稍微更有效率和/或更紧凑的解决方案。在某个时候,默认行为是:

将失败地址复制到 %rax(%eax)。
执行 ud2 指令(生成 SIGILL 信号)。
在 ud2 后的一个字节指令中编码访问类型和大小。总体上,这三条指令需要 5-6 字节的字节码。
也可以只使用单个指令(例如 ud2),但这将需要在运行时库中拥有一个完整的反汇编器(或其他一些技巧)。

实现

通过IDA进行分析可以很直观的看到ASAN的行为

小写一个demo

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <stdlib.h>

int main()
{
char stack1[0x10];

return stack1[0x10];
}

编译

1
gcc -g -fsanitize=address ./stack.c -o ./stack

img

这里面其实可以很清楚的看到mem memory和shadow memory之间的换算以及Report Error的调用

至于汇编层面的,笔者在这边贴出官方文档中的example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# long load8(long *a) { return *a; }
0000000000000030 <load8>:
30: 48 89 f8 mov %rdi,%rax
33: 48 c1 e8 03 shr $0x3,%rax
37: 80 b8 00 80 ff 7f 00 cmpb $0x0,0x7fff8000(%rax)
3e: 75 04 jne 44 <load8+0x14>
40: 48 8b 07 mov (%rdi),%rax <<<<<< original load
43: c3 retq
44: 52 push %rdx
45: e8 00 00 00 00 callq __asan_report_load8
# int load4(int *a) { return *a; }
0000000000000000 <load4>:
0: 48 89 f8 mov %rdi,%rax
3: 48 89 fa mov %rdi,%rdx
6: 48 c1 e8 03 shr $0x3,%rax
a: 83 e2 07 and $0x7,%edx
d: 0f b6 80 00 80 ff 7f movzbl 0x7fff8000(%rax),%eax
14: 83 c2 03 add $0x3,%edx
17: 38 c2 cmp %al,%dl
19: 7d 03 jge 1e <load4+0x1e>
1b: 8b 07 mov (%rdi),%eax <<<<<< original load
1d: c3 retq
1e: 84 c0 test %al,%al
20: 74 f9 je 1b <load4+0x1b>
22: 50 push %rax
23: e8 00 00 00 00 callq __asan_report_load4

0x2: 报错查看

md,写了半天,才发现github里有官方给的demo,草了

🤡🤡🤡🤡🤡🤡🤡🤡🤡🤡🤡

stack overflow

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>

int main()
{
char stack1[0x10];
char stack2[0x10];

puts("welcome to korey's test");
printf("pls input: ");
gets(stack1);
puts("done");

return stack2[0x10];
}

当输入的内容较少时,会正常出现stack overflow的报错

img

img

图一框一:

  • 漏洞类型——stack overflow
  • 报错地址,pc、sp、bp等寄存器的值
  • 在线程T0栈地址0x7ffff0291120进行read操作时,检测到错误
  • 错误代码位于stack.c的14行

图一框二

  • 漏洞地址0x7ffff0291120位于T0线程的栈中偏移80处,并指出漏洞存在的变量

图二

  • shadow memory 展示
  • 因为1字节的shadow memory对应8字节的mem memory,所以f1 f1 f1 f1f2 f2之间的区域为stack1对应的shadow memory,stack2同理
  • 因为造成漏洞的时return stack2[0x10];,所以第一个f3被标识,表示此处投毒发现漏洞

此图的下半部分则为各种符号代表的含义

因为demo中使用了get,尝试输入大量数据造成栈溢出

img

但好像并不能输出log信息

那就进gdb康康

可以看到在最后调用__asan_report时,函数参数已经指向了dirty data,那就说明原本正常运行时ASAN多开辟出来的栈空间被恶意操作时会导致功能crash

img

笔者后来又试试了只有gets函数的demo,发现就算用ASAN编译也只会报出stack smash

铸币笔者认为,ASAN更倾向于在编译的时候就标注出漏洞,当执行到这个漏洞时引发报错,就像在fuzz中,这可能算是个代码覆盖率的问题,执行到漏洞存在分支便crash。而对于gets这种依赖用户输入的危险函数,ASAN在编译的时候并不能精确定义其危险性,故并没有进行检测。

笔者的说法可能有失偏颇,各位大师傅们如果有什么想法可以在评论区留言呜呜呜。

heap overflow

和stack overflow类似,就不细讲了

demo

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>

int main()
{
char *heap = malloc(0x10);
char test = heap[0x10];

free(heap);
*heap = 0;
return 0;
}

ASAN_log

img

global buffer overflow

同上

demo:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <stdlib.h>

char global[0x10]={0};

int main()
{
return global[0x10];
}

但是ASAN_log很奇怪

img

特别是明明gloabl才0x10个字节大小,shadow memory前面那么大一块区域

且当我将当demo 换成return global[-1]时,直接显示正常了。

???

如果有师傅知道是什么情况能不能联系一下铸币笔者呜呜

跪谢

use after free

demo:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <stdlib.h>

int main()
{
char *heap = malloc(0x10);

free(heap);
return heap[0];
}

ASAN_log

img

memory leak

内存泄露其实就是内存分配后没有释放,导致内存空间中有数据残留

demo:

没有free掉堆块

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <stdlib.h>

int main()
{
char *heap = malloc(0x10);

return 0;
}

ASAN_log

img

stack use after scope

stack-use-after-scope指的是超出定义域外对局部变量操作

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>

volatile int *p = 0;

int main()
{
{
int test = 0;
p = &test;
}

*p = 1;
return 0;
}

先用volatile指定一下指针p每次操作前都重新读一下值

ASAN_log

img

stack use after return

和stack-use-after-scope类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>

char *p;

void func()
{
char buffer[0x10]={0};
p = &buffer[0];
}

int main()
{
func();
return p[0];
}

但是ASAN默认是不检测这个错误的,所以要使用ASAN_OPTIONS=detect_stack_use_after_return=1开启

ASAN_log

img

0x3: 一些小bug

一:

可以看到,针对溢出类型的漏洞时,ASAN在shadow memory ‘s left or right投毒的区域是有限的,若溢出的范围超出投毒的范围或者恰好落在别的可用内存对应的shadow memory,则不会报错

二:

众所周知,例如在x86_64进行堆块分配时,一个chunk的前8个字节是可以被前一chunk使用的,但这却会被ASAN检测出heap overflow

img

三:

当内存分配未8字节对齐时

img

可以看到这边有一个shadow memory为04,结合上文所提到的shadow memory的9钟可能值,可知此处有4个字节被投毒

0x4: 最后的最后

花了两天时间大致了解了一下ASAN,总的来说这个工具还是很好用的,虽然有些小问题,但这很大一部分原因是语言导致的。

其实官方的文档写的超级详细,各位大师傅如果想深入了解ASAN可以直接去google的仓库


Address Sanitizer(投毒?投毒!)
http://example.com/2023/08/06/ASAN/
作者
korey0sh1
发布于
2023年8月6日
许可协议