Asan 简述
AddressSanitizer 后文均简称为ASan 是 Google 开源的一个用于进行内存检测的工具,包括但可能不限于 Heap buffer overflow, Stack buffer overflow, Global buffer overflow 等等。其支持检测的漏洞有:
- Heap-use-after-free
- Heap-buffer-overflow
- Stack-buffer-overflow
- Global-buffer-overflow
1.插桩和动态运行库
ASan 由两个主要部分构成,插桩和动态运行库( Run-time library ),插桩主要是针对在llvm编译器级别对访问内存的操作(store,load,alloca等),将它们进行处理。动态运行库主要提供一些运行时的复杂的功能(比如poison/unpoison shadow memory)以及将malloc,free等系统调用函数hook住。其实该算法的思路很简单,如果想防住Buffer Overflow漏洞,只需要在每块内存区域右端(或两端,能防overflow和underflow)加一块区域(RedZone),使RedZone的区域的影子内存(Shadow Memory)设置为不可写即可。
2.内存映射
AddressSanitizer保护的主要原理是对程序中的虚拟内存提供粗粒度的影子内存(每8个字节的内存对应一个字节的影子内存),为了减少overhead,就采用了直接内存映射策略,所采用的具体策略如下:Shadow=(Mem >> 3) + offset。每8个字节的内存对应一个字节的影子内存,影子内存中每个字节存取一个数字k,如果k=0,则表示该影子内存对应的8个字节的内存都能访问。
如果k在0到7之间,表示前k个字节可以访问,如果k为负数,不同的数字表示不同的错误(e.g. Stack buffer overflow, Heap buffer overflow)。具体的映射策略如下图所示。
-
64位
Shadow = (Mem >> 3) + 0x7fff8000; [0x10007fff8000, 0x7fffffffffff] HighMem [0x02008fff7000, 0x10007fff7fff] HighShadow [0x00008fff7000, 0x02008fff6fff] ShadowGap [0x00007fff8000, 0x00008fff6fff] LowShadow [0x000000000000, 0x00007fff7fff] LowMem
-
32位
Shadow = (Mem >> 3) + 0x20000000; [0x40000000, 0xffffffff] HighMem [0x28000000, 0x3fffffff] HighShadow [0x24000000, 0x27ffffff] ShadowGap [0x20000000, 0x23ffffff] LowShadow [0x00000000, 0x1fffffff] LowMem
显而易见的是,ASan 的检查很大一部分是基于影子内存中的flag值。假设如果全段影子内存的 flag 全为0,我们就可以完全无视掉ASan,而0ctf 的 babyaegis,正是给了一个写0的机会,给了我们一次对一个指针再次读写的机会。
- 对于会有内存操作的库函数,如strlen等,都会进行hook掉,动态的检测内存
- hook掉的malloc分配的策略大概可以描述如下,不同size分配的内存区域不同,但是地址会固定,如0x10字节大小的,一开始都会分配到0x602000000010这个地址
- 分配的每块内存的前面0x10个字节都会带有一些描述这块内存的信息,如size,使用状态
- free掉之后的内存正常情况是不会再次被分配的
程序分析
功能分析
程序的功能很简单,增删改查且保护全开:
1. Add note
2. Show note
3. Update note
4. Delete note
5. Exit
add_note
- size需要满足
v14 = read_int("Size: ");
if ( v14 < 16 || v14 > 1024 )
error("Size: ");
- 之后读入content
- 之后读入8字节的ID
我这里尝试添加一个note,其真实存储content的地址在0x602000000000起始的内存空间处:
pwndbg> x /60gx 0x602000000000
0x602000000000: 0x02ffffff00000002 0x0900000120000010
0x602000000010: 0x3131313131313131 0x000000002b673131
0x602000000020: 0x02ffffff00000000 0x2080000120000010
0x602000000030: 0x0000602000000010 0x0000555555668ab0
0x602000000040: 0x0000000000000000 0x0000000000000000
通过调试分析可以发现内存的布局大概是这样子的:
RDI 0x555556504cc0 (notes) —▸ 0x602000000030 —▸ 0x602000000010 ◂— '11111111'
0x555556504cc0 // note 数组
pwndbg> x /10gx 0x602000000030
0x602000000030: 0x0000602000000010 0x0000555555668ab0 // cfi_check
0x602000000040: 0x0000000000000000 0x0000000000000000
0x602000000050: 0x0000000000000000 0x0000000000000000
0x602000000060: 0x0000000000000000 0x0000000000000000
0x602000000070: 0x0000000000000000 0x0000000000000000
pwndbg> x /4gx 0x0000555555668ab0
0x555555668ab0 <cfi_check>: 0xccccccfffff25be9 0x0000000000841f0f
// 最终数组中第一元素的内存地址
pwndbg> x /60gx 0x602000000000
0x602000000000: 0x02ffffff00000002 0x0900000120000010
0x602000000010: 0x3131313131313131 0x000000002b673131
0x602000000020: 0x02ffffff00000000 0x2080000120000010
show_note
这个函数逻辑很简单,也是根据输入的下标去数组中寻找对应的地址,打印其content,再从content后边取出8个字节作为打印出来。
update_note
这个函数就是对note内容的更新:
unsigned __int64 update_note()
{
unsigned __int64 v0; // rdi
unsigned __int64 v1; // rdi
__int64 v2; // rbx
unsigned __int64 v3; // rsi
__int64 v4; // rax
unsigned __int64 v5; // rdi
__int64 (__fastcall **v6)(); // rdi
__int64 (__fastcall *v7)(); // rbx
unsigned __int64 v9; // [rsp+8h] [rbp-28h]
int v10; // [rsp+18h] [rbp-18h]
signed int v11; // [rsp+1Ch] [rbp-14h]
v0 = (unsigned __int64)"Index: ";
printf((unsigned __int64)"Index: ");
v11 = read_int("Index: ");
if ( v11 < 0 || v11 >= 10 )
goto LABEL_29;
v0 = (unsigned __int64)¬es + 8 * v11;
if ( *(_BYTE *)((v0 >> 3) + 0x7FFF8000) )
_asan_report_load8(v0);
if ( !*(_QWORD *)v0 )
LABEL_29:
error(v0);
v1 = (unsigned __int64)¬es + 8 * v11;
if ( *(_BYTE *)((v1 >> 3) + 0x7FFF8000) )
_asan_report_load8(v1);
v9 = *(_QWORD *)v1;
printf((unsigned __int64)"New Content: ");
if ( *(_BYTE *)((v9 >> 3) + 0x7FFF8000) )
_asan_report_load8(v9);
v2 = *(_QWORD *)v9;
if ( *(_BYTE *)((v9 >> 3) + 0x7FFF8000) )
_asan_report_load8(v9);
v3 = strlen(*(_QWORD *)v9) + 1;
v10 = read_until_nl_or_max(v2, v3);
printf((unsigned __int64)"New ID: ");
v4 = read_ul("New ID: ");
if ( *(_BYTE *)((v9 >> 3) + 0x7FFF8000) )
v4 = _asan_report_load8(v9);
v5 = v10 + *(_QWORD *)v9;
if ( *(_BYTE *)((v5 >> 3) + 0x7FFF8000) )
v4 = _asan_report_store8(v5);
*(_QWORD *)v5 = v4;
v6 = (__int64 (__fastcall **)())(v9 + 8);
if ( *(_BYTE *)(((v9 + 8) >> 3) + 0x7FFF8000) )
_asan_report_load8((unsigned __int64)v6);
v7 = *v6;
if ( *v6 != cfi_check )
{
_asan_handle_no_return(v6);
_ubsan_handle_cfi_check_fail_abort(&unk_34B100, v7);
}
((void (__fastcall *)(_QWORD, unsigned __int64))v7)((unsigned int)v11, v3);
puts("Update success!");
if ( *(_BYTE *)((v9 >> 3) + 0x7FFF8000) )
_asan_report_load8(v9);
if ( *(_QWORD *)v9 >> 44 != 6LL )
error(v9);
return __readfsqword(0x28u);
}
delete_note
这里一开始是选择index,然后拿出来,free掉。这里又是另外一个漏洞,free掉之后没有置0,不过因为用了Address Sanitizier,任何use after free都会退出,而且Address Sanitizier也会把那个地址修改为一个不可读写的地址
free(*(__sanitizer **)v3);
secret
这里首先是读取一个地址,然后会判断一下地址右移44位之后是否大于0,假如大于的话,会或运算上0x700000000000,之后会对这个地址写0/因为程序开了PIE,地址大于 0x500000000000,而堆地址是大于0x600000000000,两个都小于0x700000000000,因此是不能对程序中的变量和堆上的变量进行写0操作.唯一有可能的就是Shadow Memory,这里就是之前提到的,修改影子内存为0,bypass掉asan的检查。
unsigned __int64 secret()
{
_BYTE *v0; // rax
unsigned __int64 v2; // [rsp+0h] [rbp-10h]
if ( secret_enable )
{
printf((unsigned __int64)"Lucky Number: ");
v2 = read_ul("Lucky Number: ");
if ( v2 >> 44 )
v0 = (_BYTE *)(v2 | 0x700000000000LL);
else
v0 = (_BYTE *)v2;
*v0 = 0;
secret_enable = 0;
}
else
{
puts("No secret!");
}
return __readfsqword(0x28u);
}
漏洞利用
调试
首先查看下实际存储数据的位置以及其对应的影子内存。为0的时候代表8个字节都可以写
pwndbg> x /40gx 0x602000000000
0x602000000000: 0x02ffffff00000002 0x0900000120000010 // 前0x10字节是header
0x602000000010: 0x3131313131313131 0x0000000000000100
0x602000000020: 0x02ffffff00000000 0x2080000120000010
0x602000000030: 0x0000602000000010 0x0000555555668ab0
0x602000000040: 0x02ffffff00000002 0x0900000120000010
0x602000000050: 0x3131313131313131 0xffffffffffffff00
0x602000000060: 0x02ffffff000000ff 0x2080000120000010
0x602000000070: 0x0000602000000050 0x0000555555668ab0
0x602000000080: 0x02ffffff00000002 0x0900000120000010
0x602000000090: 0x0000000000000000 0xbe00000000000000
0x6020000000a0: 0x02ffffff00000002 0x2080000120000010
0x6020000000b0: 0x0000602000000090 0x0000555555668ab0
0x6020000000c0: 0x0000000000000000 0x0000000000000000
// 影子内存
pwndbg> x /40gx 0xc047fff8000
0xc047fff8000: 0x0000fafa0000fafa 0x0000fafa0000fafa
0xc047fff8010: 0x0000fafa0000fafa 0xfafafafafafafafa
// 释放note1后
pwndbg> x /40gx 0xc047fff8000
0xc047fff8000: 0xfdfdfafafdfdfafa 0xfafafafafafafafa
0xc047fff8010: 0xfafafafafafafafa 0xfafafafafafafafa
其中asan 的 header的结构如下
image 使用secret,我们可以吧影子内存中对应控制chunk的size字段的字节修改为0,这样的话就可以溢出到下一个chunk,并修改其size字段,这时候size字段也不能修改的太大,太大话会出发asan的内存回收机制,收回所有的内存空间。
修改size字段后再free,会发现修改size的所有影子内存都被修改,此时再创建note,其地址空间就会与第一个内存空间重合,也就有了任意读,可以leak出程序基址,libc基址。
利用
使用bss段的一个callback,_ZN11__sanitizerL20InternalDieCallbacksE,在update的时候,检测函数指针错误的时候会调用Die,Die又会调用这个callback,跳转到gets函数,这个时候就有一个ROP,之后ROP一下就能get shell。具体可以参考下面两位师傅的exp。
网友评论