原文链接:深入分析HWASAN检测内存错误原理
导语:ASAN(AddressSanitizer) 是 C/C++开发者常用的内存错误检测工具,主要用于检测缓冲区溢出、访问已释放的内存等内存错误。 AArch64 上提供了 Top-Byte-Ingore 硬件特性,HWASan(HardWare-assisted AddressSanitizer) 就是利用 Top-Byte-Ignore 特性实现的增强版 ASan,与 ASAN 相比 HWASan 的内存开销更低,检测到的内存错误范围更大。因此在 AArch64 平台,建议使用 HWASAN。本篇文章将深入分析 HWASAN 检测内存错误的原理,帮助大家更好地理解和使用 HWASan 来排查程序中存在的疑难内存错误。
前言
在字节跳动,C++语言被广泛应用在各个业务中,由于C++语言的特性,导致 C++ 程序很容易出现内存问题。ASAN 等内存检测工具在字节跳动内部已经取得了可观的收益和效果(更多内容请查看:Sanitizer 在字节跳动 C C++ 业务中的实践),服务于60个业务线,近一年协助修复上百个内存缺陷。但是仍然有很大的提升空间,特别是在性能开销方面。随着 ARM 进入服务器芯片市场,ARM架构下的一些硬件特性可以用来缓解 ASAN 工具的性能问题,利用这些硬件特性研发的 HWASAN 检测工具在超大型 C++ 服务上的检测能力还有待确认。
为此,STE 团队对 HWASAN 进行了深入分析,并在字节跳动 C++ 核心服务上进行了落地验证。在落地 HWASAN 过程中,修复了 HWASAN 实现中的一些关键 bug,并对易用性进行了提升。相关 patch 已经贡献到LLVM开源社区(详情请查看文末链接)。本篇文章将深入分析 HWASAN 检测内存错误的原理,帮助大家更好地理解和使用 HWASan 来排查程序中存在的疑难内存错误。
概述
HWASAN: HardWare-assisted AddressSanitizer, a tool similar to AddressSanitizer, but based on partial hardware assistance and consumes much less memory.
这里所谓的 "partial hardware assistance" 就是指 AArch64 的 TBI (Top Byte Ignore) 特性。
TBI (Top Byte Ignore) feature of AArch64: bits [63:56] are ignored in address translation and can be used to store a tag.
以如下代码举例,Linux/AArch64 下将指针 x 的 top byte 设置为 0xfe,不影响程序执行:
// $ cat tbi.cpp
int main(int argc, char **argv) {
int * volatile x = (int *)malloc(sizeof(int));
*x = 666;
printf("address: %p, value: %d\n", x, *x);
x = reinterpret_cast<int*>(reinterpret_cast<uintptr_t>(x) | (0xfeULL << 56));
printf("address: %p, value: %d\n", x, *x);
free(x);
return 0;
}
// $ clang++ tbi.cpp && ./a.out
address: 0xaaab1845fe70, value: 666
address: 0xfe00aaab1845fe70, value: 666
AArch64 的 TBI 特性使得软件可以在 64-bit 虚拟地址的最高字节中存储任意数据,HWASAN 正是基于 TBI 这一特性设计并实现的内存错误检测工具。
举个例子,以下代码中存在 heap-buffer-overflow bug:
// cat test.c
#include <stdlib.h>
int main() {
int * volatile x = (int *)malloc(sizeof(int)*10);
x[10] = 0;
free(x);
}
使用 HWASAN 检测上述代码中的 heap-buffer-overflow bug:
$ clang -fuse-ld=lld -g -fsanitize=hwaddress ./test.c && ./a.out
==3581920==ERROR: HWAddressSanitizer: tag-mismatch on address 0xec2bfffe0028 at pc 0xaaad830db1a4
WRITE of size 4 at 0xec2bfffe0028 tags: 69/08(69) (ptr/mem) in thread T0
#0 0xaaad830db1a4 in main ./test.c:4:11
#1 0xfffd07350da0 in __libc_start_main libc-start.c:308:16
#2 0xaaad83090820 in _start (./a.out+0x40820)
[0xec2bfffe0000,0xec2bfffe0030) is a small allocated heap chunk; size: 48 offset: 40
Cause: heap-buffer-overflow
0xec2bfffe0028 is located 0 bytes after a 40-byte region [0xec2bfffe0000,0xec2bfffe0028)
allocated by thread T0 here:
#0 0xaaad83099248 in __sanitizer_malloc.part.13 llvm-project/compiler-rt/lib/hwasan/hwasan_allocation_functions.cpp:151:3
#1 0xaaad830db17c in main ./test.c:3:31
#2 0xfffd07350da0 in __libc_start_main libc-start.c:308:16
#3 0xaaad83090820 in _start (/a.out+0x40820)
Thread: T0 0xeffc00002000 stack: [0xffffc3a10000,0xffffc4210000) sz: 8388608 tls: [0xfffd076a5030,0xfffd076a5e70)
Memory tags around the buggy address (one tag corresponds to 16 bytes):
0xec2bfffdf800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdf900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdfa00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdfb00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdfc00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdfd00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdfe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffdff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0xec2bfffe0000: 69 69 [08] 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0400: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0500: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0600: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0700: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0xec2bfffe0800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Tags for short granules around the buggy address (one tag corresponds to 16 bytes):
0xec2bfffdff00: .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..
=>0xec2bfffe0000: .. .. [69] .. .. .. .. .. .. .. .. .. .. .. .. ..
0xec2bfffe0100: .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..
See https://clang.llvm.org/docs/HardwareAssistedAddressSanitizerDesign.html#short-granules for a description of short granule tags
Registers where the failure occurred (pc 0xaaad830db1a4):
x0 a100ffffc4201580 x1 6900ec2bfffe0028 x2 0000000000000000 x3 0000000000000000
x4 0000000000000020 x5 0000000000000000 x6 0000000000100000 x7 fffffffffff00005
x8 6900ec2bfffe0000 x9 6900ec2bfffe0000 x10 0030f15d14c79f97 x11 00ffffffffffffff
x12 00001f0d780b69d2 x13 0000000000000001 x14 0000ffffc4200b60 x15 0000000000000696
x16 0000aaad830a3540 x17 000000000000000b x18 0000000000000100 x19 0000aaad830db600
x20 0200effd00000000 x21 0000aaad830907f0 x22 0000000000000000 x23 0000000000000000
x24 0000000000000000 x25 0000000000000000 x26 0000000000000000 x27 0000000000000000
x28 0000000000000000 x29 0000ffffc4201590 x30 0000aaad830db1a8 sp 0000ffffc4201550
SUMMARY: HWAddressSanitizer: tag-mismatch ./test.c:4:11 in main
如上所示,HWASAN 与 ASAN 相比不管是用法 (-fsanitize=hwaddress
v.s. -fsanitize=address
) 还是检测到错误后的报告都很相似。
下面对比分析 ASAN 与 HWASAN 检测内存错误的技术原理:
ASAN (AddressSanitizer):
- 使用 shadow memory 技术,每 8-bytes 的 application memory 对应 1-byte 的 shadow memory。
- 使用 redzone 来检测 buffer-overflow。不管是栈内存还是堆内存,在申请内存时都会在原本内存的两侧额外申请一定大小的内存作为 redzone,一旦访问到了 redzone 则说明发生了缓冲区溢出。
- 使用 quarantine 检测 use-after-free。应用程序执行 delete 或 free 释放内存时,并不真正释放,而是放在一个暂存区 (quarantine) 中,一旦访问了位于 quarantine 中的内存则说明访问了已释放的内存。
- 每 1-byte 的 shadow memory 编码表示对应的 8-byte application memory 的信息,每次访问 application memory 之前 ASAN 都会检查对应的 shadow memory 判断本次内存访问是否合法。例如:shadow memory 的值为 0xfd 表示对应的 8-bytes application memory 是 freed memory,所以当访问的 application memory 其 shadow memory byte 为 0xfd 就说明此时访问的是已经释放的内存了即 use-after-free;shadow memory 为 0xfa 表示相应的 application memory 是堆内存的 redzone,所以当访问到的 appllcation memory 其 shadow memory 为 0xfa 就说明此时访问的是堆内存附近的 redzone 发生了堆缓冲区溢出错误即 heap-buffer-overflow
HWASAN (HardWare-assisted AddressSanitizer)
-
同样使用 shadow memory 技术,不过与 ASAN 不同的是:HWASAN 每 16-bytes 的 application memory 对应 1-byte 的 shadow memory。
-
不依赖 redzone 检测 buffer-overflow,不依赖 quarantine 检测 use-after-free,仅基于 TBI 特性就能检测 buffer-overflow 和 use-after-free。
-
举例说明 HWASAN 检测内存错误的原理
-
因为 HWASAN 会将每 16-bytes 的 application memory 都对应 1-byte 的 shadow tag,所以 HWASAN 会将申请的内存都对齐到 16-bytes,因此下图中 new char[20] 实际申请的内存是 32-bytes。
-
HWASAN 会生成一个随机 tag 保存在 operator new 返回的指针 p 的 top byte 中,同时会将 tag 保存在 p 指向的内存对应 shadow memory 中。
-
为了方便说明,下图中用不同的颜色表示不同的 tag,绿色表示 tag 0xa,蓝色表示 tag 0xb,紫色表示 tag 0xc。
-
检测 heap-buffer-overflow
- 假设 HWASAN 为
new char[20]
生成的 tag 为 0xa 即绿色,所以指针 p 的 top byte 为 0xa。在通过p[32]
访问内存时,HWASAN 会检查保存在指针 p 的 tag 与p[32]
指向的内存所对应的 shadow memory 中保存的 tag 是否一致。显然保存在指针 p 的 tag 是绿色 而p[32]
指向的内存所对应的 shadow memory 中保存的 tag 是蓝色,即 tag 是不匹配的,这说明访问p[32]
时存在内存错误。
- 假设 HWASAN 为
-
检测 use-after-free
- 假设 HWASAN 为
new char[20]
生成的 tag 为 0xa 即绿色,所以指针 p 的 top byte 为 0xa。执行delete[] p
释放内存时,HWASAN 将这块释放的内存 retag 为紫色,即将这块释放的内存对应的 shadow memory 从绿色修改为紫色。在通过p[0]
访问内存时,HWASAN 会检查保存在指针 p 的 tag 与p[0]
指向的内存所对应的 shadow memory 中保存的 tag 是否一致。显然保存在指针 p 的 tag 是绿色 而p[0]
指向的内存所对应的 shadow memory 中保存的 tag 是紫色,即 tag 是不匹配的,这说明访问p[0]
时存在内存错误。
- 假设 HWASAN 为
-
算法
- shadow memory:每 16-bytes 的 application memory 对应 1-byte 的 shadow memory。
- 每个 heap/stack/global 内存对象都被对齐到 16-bytes,这样每个 heap/stack/global 内存对象至少对应的 1-byte shadow memory。
- 为每一个 heap/stack/global 内存对象生成一个 1-byte 的随机 tag,将该随机 tag 保存到指向这些内存对象的指针的 top byte 中,同样将该随机 tag 保存到这些内存对象对应的 shadow memory 中。
- 在每一处内存读写之前插桩:比较保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 是否一致,如果不一致则报错。
实现
shadow mapping
HWASAN 与 ASAN 一样都使用了 shadow memory 技术。ASAN 默认使用 static shadow mapping,只有对 IOS 和 32-bit Android 平台才使用 dynamic shadow mapping。而 HWASAN 则总是使用 dynamic shadow mapping。
-
ASAN: static shadow mapping。在 llvm-project/compiler-rt/lib/asan/asan_mapping.h 中预定义了不同平台下 shadow memory 的布局:HighMem, HighShadow, ShadowGap, LowShadow, LowMem 的地址区间。
-
Linux/x86_64 下 ASAN 的 shadow mapping 如下所示:
// Typical shadow mapping on Linux/x86_64 with SHADOW_OFFSET == 0x00007fff8000: || `[0x10007fff8000, 0x7fffffffffff]` || HighMem || || `[0x02008fff7000, 0x10007fff7fff]` || HighShadow || || `[0x00008fff7000, 0x02008fff6fff]` || ShadowGap || || `[0x00007fff8000, 0x00008fff6fff]` || LowShadow || || `[0x000000000000, 0x00007fff7fff]` || LowMem ||
-
给定 application memory 地址
addr
,计算其对应的 shadow memory 地址的公式如下:
uptr MemToShadow(uptr addr) { return (addr >> 3) + 0x7fff8000; }
-
HWASAN: dynamic shadow mapping。根据 MaxUserVirtualAddress 计算 shadow memory 所需要的总大小 shadow_size,通过 mmap(shadow_size) 得到 shadow memory 区间,再具体划分 HighMem, HighShadow, ShadowGap, LowShadow, LowMem 的地址区间。
-
伪算法如下(未考虑对齐):
kHighMemEnd = GetMaxUserVirtualAddress();
shadow_size = MemToShadowSize(kHighMemEnd);
__hwasan_shadow_memory_dynamic_address = mmap(shadow_size);
// Place the low memory first.
kLowMemEnd = __hwasan_shadow_memory_dynamic_address - 1;
kLowMemStart = 0;
// Define the low shadow based on the already placed low memory.
kLowShadowEnd = MemToShadow(kLowMemEnd);
kLowShadowStart = __hwasan_shadow_memory_dynamic_address;
// High shadow takes whatever memory is left up there.
kHighShadowEnd = MemToShadow(kHighMemEnd);
kHighShadowStart = Max(kLowMemEnd, MemToShadow(kHighShadowEnd)) + 1;
// High memory starts where allocated shadow allows.
kHighMemStart = ShadowToMem(kHighShadowStart);
uptr MemToShadow(uptr untagged_addr) {
return (untagged_addr >> 4) + __hwasan_shadow_memory_dynamic_address;
}
uptr ShadowToMem(uptr shadow_addr) {
return (shadow_addr - __hwasan_shadow_memory_dynamic_address) << 4;
}
-
Linux/AArch64 下 HWASAN 的某种 shadow mapping 如下所示:
// Typical mapping on Linux/AArch64 // with dynamic shadow mapped: [0xefff00000000, 0xffff00000000]: || [0xffff00000000, 0xffffffffffff] || HighMem || || [0xfffef0000000, 0xfffeffffffff] || HighShadow || || [0xfefef0000000, 0xfffeefffffff] || ShadowGap || || [0xefff00000000, 0xfefeefffffff] || LowShadow || || [0x000000000000, 0xeffeffffffff] || LowMem ||
tagging
-
stack 内存对象:在编译时插桩阶段,HWASAN 会插入代码来实现对 stack 内存对象的 tagging,将生成的随机 tag 保存在 stack 内存对象指针的 top byte,同时将随机 tag 保存这些 stack 内存对象对应的 shadow memory 中。
-
每个 stack 内存对象的 tag 是通过
stack_base_tag ^ RetagMask(AllocaNo)
计算得到的。stack_base_tag
对于不同的 stack frame 是不同的值,RetagMask(AllocaNo)
的实现如下(AllocaNo 可以看作是 stack 内存对象的序号)。static unsigned RetagMask(unsigned AllocaNo) { // A list of 8-bit numbers that have at most one run of non-zero bits. // x = x ^ (mask << 56) can be encoded as a single armv8 instruction for these // masks. // The list does not include the value 255, which is used for UAR. // // Because we are more likely to use earlier elements of this list than later // ones, it is sorted in increasing order of probability of collision with a // mask allocated (temporally) nearby. The program that generated this list // can be found at: // https://github.com/google/sanitizers/blob/master/hwaddress-sanitizer/sort_masks.py static unsigned FastMasks[] = {0, 128, 64, 192, 32, 96, 224, 112, 240, 48, 16, 120, 248, 56, 24, 8, 124, 252, 60, 28, 12, 4, 126, 254, 62, 30, 14, 6, 2, 127, 63, 31, 15, 7, 3, 1}; return FastMasks[AllocaNo % std::size(FastMasks)]; }
-
heap 内存对象:随机 tag 是在运行时申请 heap 内存对象时由 HWASAN 的内存分配器生成的。
-
当程序调用 malloc 或者 operator new 申请内存时,HWASAN 的内存分配器会将随机生成的 tag 保存在 malloc 或者 operator new 返回的指针的 top byte 中,同时将随机 tag 保存在这些 heap 内存对象对应的 shadow memory 中。
-
当程序调用 free 或者 operator delete 释放内存时,HWASAN 的内存分配器会再次随机生成 tag ,将新生成的随机 tag 保存在正释放的 heap 内存对象对应的 shadow memory 中。
-
global 内存对象:对于每个编译单元内的 global 内存对象,在编译时插桩阶段都会生成对应的 tag,编译单元内的第一个 global 内存对象的 tag 是对当前编译单元文件路径计算 MD5 哈希值然后取第一个字节得到的,编译单元内的后续 global 内存对象的 tag 是基于之前 global 内存对象的 tag 递增得到的。
-
在编译时插桩阶段 HWASAN 会插入代码来将生成的随机 tag 保存在 global 内存对象指针的 top byte,而将随机 tag 保存到这些 global 内存对象对应的 shadow memory 中则是由 HWASAN runtime 在程序启动阶段做的。对于每一个 global 内存对象,HWASAN 在编译插桩阶段都会创建一个 descriptor 保存在 "hwasan_globals" section 中,descriptor 中保存了 global 内存对象的大小以及 tag 等信息,程序启动时 HWASAN runtime 会遍历 "hwasan_globals" section 中所有的 descriptor 来设置每个 global 内存对象对应的 shadow memory tag。
short granules
每个 heap/stack/global 内存对象都会被对齐到 16-bytes,heap/stack/global 内存对象原本大小记作 size,如果 size % 16 != 0
,那么就需要 padding,heap/stack/global 内存对象最后不足 16-bytes 的部分就被称为 short granule。此时会将 tag 存储到 padding 的最后一个字节,而 padding 所在的 16-bytes 内存对应的 1-byte shadow memory 中存储的则是 short granule size 即 size % 16
。
举例如下:
uint8_t buf\[20\];
uint8_t buf[20]
开启 HWASAN 后会变为:
uint8_t buf[32]; // 20-bytes aligned to 16-bytes -> 32-bytes
uint8_t tag = __hwasan_generate_tag();
buf[31] = tag;
*(char *)MemToShadow(buf) = tag;
*((char *)MemToShadow(buf)+1) = 20 % 16;
uint8_t *tagged_buf = reinterpret_cast<int8_t *>(
reinterpret_cast<uintptr_t>(buf) | (tag << 56));
// Replace all uses of `buf` with `tagged_buf`
uint_t buf[20]
的最后 4-bytes 就是 short granule,short granule size 即 20 % 16 = 4。
因为 short granules 的存在,所以在比较保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 是否一致时,需要考虑如下两种可能:
-
保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 相同。
-
保存在 shadow memory 中的 tag 实际上是 short granule size,保存在指针 top byte 的 tag 等于保存在指针指向的内存所在的 16-bytes 内存的最后一个字节的 tag。
为什么需要 short granules ?
考虑 uint8_t buf[20]
,假设代码中存在访问 buf[22]
导致的 buffer-overflow。因为 HWASAN 会将 heap/stack/global 内存对象对齐到 16-bytes,所以实际为 uint8_t buf[20]
申请的空间是 uint8_t buf[32]
。
如果没有 short granules,那么保存在 buf
指针 top byte 的 tag 为 0xa1,保存在 buf[22]
对应的 shadow memory 中的 tag 为 0xa1,尽管访问 buf[22]
时发生了 buffer-overflow,此时 HWASAN 也检测不到,因为保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 是否一致的。
有了 short granules,保存在 buf
指针 top byte 的 tag 为 0xa1,保存在 buf[22]
对应的 shadow memory 中的 tag 则是 short granule size 即 20 % 16 = 4。访问 buf[22]
时,HWASAN 发现保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 不一致,保存在 buf[22]
对应的 shadow memory 中的 tag 是 short granule size 为 4,这意味着 buf[22]
所在的 16-bytes 内存只有前 4-bytes 是可以合法访问的,而 buf[22]
访问的却是其所在 16-bytes 内存的第 7 个 byte,说明访问 buf[22]
时发生了 buffer-overflow!
hwasan check memaccess
本节说明 HWASAN 如何在每一处内存读写之前通过插桩来比较保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 是否一致的。
开启 HWASAN 后,HWAddressSanitizer instrumentation pass 会在 LLVM IR 层面进行插桩。默认情况下,HWASAN 在每一处内存读写之前添加对 llvm.hwasan.check.memaccess.shortgranules
intrinsic 的调用。该 llvm.hwasan.check.memaccess.shortgranules
intrinsic 会在生成汇编代码时转换为对相应函数的调用。
还是以如下代码为例说明:
// cat test.c #include <stdlib.h> int main() { int * volatile x = (int *)malloc(sizeof(int)*10); x\[10\] = 0; free(x); }
上述代码 clang -O1 -fsanitize=hwaddress test.c -S -emit-llvm
开启 HWASAN 生成的 LLVM IR 如下:
%5 = alloca ptr, align 8
%6 = call noalias ptr @malloc(i64 noundef 40)
store ptr %6, ptr %5, align 8
%7 = load volatile ptr, ptr %5, align 8
%8 = getelementptr inbounds i32, ptr %7, i64 10
call void @llvm.hwasan.check.memaccess.shortgranules(ptr %__hwasan_shadow_memory_dynamic_address, ptr %8, i32 18)
store i8 0, ptr %8, align 4
%9 = load volatile ptr, ptr %5, align 8
call void @free(ptr noundef %9)
llvm.hwasan.check.memaccess.shortgranules
intrinsic 有三个参数:
- __hwasan_shadow_memory_dynamic_address
- 本次 memory access 访问的内存地址
- 常数 AccessInfo,编码了本次 memory access 的相关信息。计算公式如下:
int64_t HWAddressSanitizer::getAccessInfo(bool IsWrite,
unsigned AccessSizeIndex) {
return (CompileKernel << HWASanAccessInfo::CompileKernelShift) |
(MatchAllTag.has_value() << HWASanAccessInfo::HasMatchAllShift) |
(MatchAllTag.value_or(0) << HWASanAccessInfo::MatchAllShift) |
(Recover << HWASanAccessInfo::RecoverShift) |
(IsWrite << HWASanAccessInfo::IsWriteShift) |
(AccessSizeIndex << HWASanAccessInfo::AccessSizeShift);
}
// Bit field positions for the accessinfo parameter to
// llvm.hwasan.check.memaccess. Shared between the pass and the backend. Bits
// 0-15 are also used by the runtime.
enum {
AccessSizeShift = 0, // 4 bits
IsWriteShift = 4,
RecoverShift = 5,
MatchAllShift = 16, // 8 bits
HasMatchAllShift = 24,
CompileKernelShift = 25,
};
- IsWrite 布尔值,0 表示本次 memory access 是读操作,1 表示本次 memory access 为 写操作。
- AccessSizeIndex 由
__builtin_ctz(AccessSize)
计算得到。AccessSize 为 1-byte, 2-bytes, 4-bytes, 8-bytes, 16-bytes 时,AccessSizeIndex 分别为 0, 1, 2, 3, 4。 - CompileKernel 布尔值,只有 HWASAN 用于 KernelHWAddressSanitizer 时 CompileKernel 值才为 1。
- MatchAllTag 类型为 uint8_t,MatchAllTag 的默认值为 -1,表示没有设置 MatchAllTag。可以通过编译时选项
-mllvm -hwasan-match-all-tag=-1
进行设置。当保存在指针 top byte 的 tag 值 和保存在 shadow memory 中的 tag 不匹配时说明 HWASAN 检测到的内存错误,如果设置了 MatchAllTag,那么 HWASAN 会忽略所有 pointer tag 为 MatchAllTag 时出现的 tag mismatch。 - Recover 布尔值,0 表示 HWASAN 检测到错误不再继续执行程序,1 表示 HWASAN 检测到错误后继续执行。Recover 默认为 0,在编译时添加参数
-fsanitize-recover=hwaddress
后 Recover 为 1。
上述例子 LLVM IR 中,调用 llvm.hwasan.check.memaccess.shortgranules
的第一参数就是 %__hwasan_shadow_memory_dynamic_address
,第二个参数是 %8
对应源码中的 x[10]
的地址,第三个参数是常数 18
表示本次内存访问是写入 4-byte 的操作。
llvm.hwasan.check.memaccess.shortgranules
intrinsic 在 AsmPrinter 阶段被转换为汇编代码。对于 AArch64 后端,相关的函数为 LowerHWASAN_CHECK_MEMACCESS()
, emitHwasanMemaccessSymbols()
,代码位于 llvm/lib/Target/AArch64/AArch64AsmPrinter.cpp。
llvm.hwasan.check.memaccess.shortgranules
intrinsic 会根据参数不同而生成不同的汇编函数。例如上述例子中 call void @llvm.hwasan.check.memaccess.shortgranules(ptr %__hwasan_shadow_memory_dynamic_address, ptr %8, i32 18)
生成的汇编符号/函数名为 __hwasan_check_x0_18_short_v2
:
- x0 表示本次 memory access 访问的内存地址保存在 X0 寄存器中
- 18 即本次 memory access 的 AccessInfo 的值
另外,AArch64AsmPrinter 在将 llvm.hwasan.check.memaccess.shortgranules
intrinsic 转换为汇编代码时,总是将 __hwasan_shadow_memory_dynamic_address 即 shadow base 保存在 X20 寄存器中。
__hwasan_check_x0_18_short_v2
完整的汇编代码如下:
__hwasan_check_x0_18_short_v2:
sbfx x16, x0, #4, #52 // shadow offset
ldrb w16, [x20, x16] // load shadow tag
cmp x16, x0, lsr #56 // extract address tag, compare with shadow tag
b.ne .Ltmp0 // jump to short tag handler on mismatch
.Ltmp1:
ret
.Ltmp0:
cmp w16, #15 // is this a short tag?
b.hi .Ltmp2 // if not, error
and x17, x0, #0xf // find the address's position in the short granule
add x17, x17, #3 // adjust to the position of the last byte loaded
cmp w16, w17 // check that position is in bounds
b.ls .Ltmp2 // if not, error
orr x16, x0, #0xf // compute address of last byte of granule
ldrb w16, [x16] // load tag from it
cmp x16, x0, lsr #56 // compare with pointer tag
b.eq .Ltmp1 // if matches, continue
.Ltmp2:
// save original x0, x1 on stack (they will be overwritten)
stp x0, x1, [sp, #-256]!
// create frame record
stp x29, x30, [sp, #232]
// set x1 to a constant indicating the type of failure
mov x1, #18
// call runtime function to save remaining registers and report error
adrp x16, :got:__hwasan_tag_mismatch_v2
// (load address from GOT to avoid potential register clobbers in delay load handler)
ldr x16, [x16, :got_lo12:__hwasan_tag_mismatch_v2]
br x16
前面内容提到,HWASAN 在编译插桩时,默认情况下是在每一处内存读写之前添加对 llvm.hwasan.check.memaccess.shortgranules
intrinsic 的调用,那非默认情况呢?
- 如果在编译时添加选项
-mllvm -hwasan-instrument-with-calls=true
,那么编译插桩时 HWASAN 在每一处内存读写之前添加的则是对__hwasan_[load|store][1|2|4|8|16|n]
的函数调用。 - 还是以本文开头的示例代码进行说明,
clang -O1 -fsanitize=hwaddress -mllvm -hwasan-instrument-with-calls=true test.c -S -emit-llvm
生成的 LLVM IR 如下:
%2 = alloca ptr, align 8
%3 = call noalias ptr @malloc(i64 noundef 40)
store volatile ptr %3, ptr %2, align 8
%4 = load volatile ptr, ptr %2, align 8
%5 = getelementptr inbounds i32, ptr %4, i64 10
%6 = ptrtoint ptr %5 to i64
call void @__hwasan_store4(i64 %6)
store i32 0, ptr %5, align 4
%7 = load volatile ptr, ptr %2, align 8
call void @free(ptr noundef %7)
__hwasan_[load|store][1|2|4|8|16|n]
函数由 HWASAN runtime 中实现,代码位于 compiler-rt/lib/hwasan/hwasan.cpp。根据本次 memory access 为读操作或写操作,调用___hwasan_load
或___hwasan_store
;根据本次 memory access 读写的内存大小(字节),调用相应的版本,如本次 memory acesss 是对 4-bytes 内存的写操作,HWASAN 插入的是就对___hwasan_store4
的调用。__hwasan_[load|store][1|2|4|8|16|n]
函数的实现几乎一致,下面给出___hwasan_store4
的代码实现:
void __hwasan_store4(uptr p) {
CheckAddress<ErrorAction::Abort, AccessType::Store, 2>(p);
}
template <ErrorAction EA, AccessType AT, unsigned LogSize>
__attribute__((always_inline, nodebug)) static void CheckAddress(uptr p) {
if (!InTaggableRegion(p))
return;
uptr ptr_raw = p & ~kAddressTagMask;
tag_t mem_tag = *(tag_t *)MemToShadow(ptr_raw);
if (UNLIKELY(!PossiblyShortTagMatches(mem_tag, p, 1 << LogSize))) {
SigTrap<EA, AT, LogSize>(p);
if (EA == ErrorAction::Abort)
__builtin_unreachable();
}
}
__attribute__((always_inline, nodebug)) static inline bool
PossiblyShortTagMatches(tag_t mem_tag, uptr ptr, uptr sz) {
DCHECK(IsAligned(ptr, kShadowAlignment));
tag_t ptr_tag = GetTagFromPointer(ptr);
if (ptr_tag == mem_tag)
return true;
if (mem_tag >= kShadowAlignment)
return false;
if ((ptr & (kShadowAlignment - 1)) + sz > mem_tag)
return false;
return *(u8 *)(ptr | (kShadowAlignment - 1)) == ptr_tag;
}
- 如果在编译时添加选项
-mllvm -hwasan-inline-all-checks=true
,那么编译插桩时 HWASAN 会直接在 LLVM IR 上实现检查保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 是否匹配的逻辑。 - 还是以本文开头的示例代码进行说明,
clang -O1 -fsanitize=hwaddress -mllvm -hwasan-inline-all-checks=true test.c -S -emit-llvm
生成的 LLVM IR 如下:
%5 = alloca ptr, align 8
%6 = call noalias ptr @malloc(i64 noundef 40)
store volatile ptr %6, ptr %5, align 8
%7 = load volatile ptr, ptr %5, align 8
%8 = getelementptr inbounds i8, ptr %7, i64 30
%9 = ptrtoint ptr %8 to i64
%10 = lshr i64 %9, 56
; extrac pointer tag
%11 = trunc i64 %10 to i8
%12 = and i64 %9, 72057594037927935
; shadow offset
%13 = lshr i64 %12, 4
; shadow base + shadow offset
%14 = getelementptr i8, ptr %4, i64 %13
; load shadow tag
%15 = load i8, ptr %14, align 1
; compare pointer tag with shadow tag
%16 = icmp ne i8 %11, %15
; jump to short tag handler on mismatch
br i1 %16, label %17, label %31
17: ; preds = %0
; is this a short tag?
%18 = icmp ugt i8 %15, 15
; if not, error
br i1 %18, label %19, label %20
19: ; preds = %25, %20, %17
call void asm sideeffect "brk #2320", "{x0}"(i64 %9)
unreachable
20: ; preds = %17
; find the address's position in the short granule
%21 = and i64 %9, 15
%22 = trunc i64 %21 to i8
; adjust to the position of the last byte loaded
%23 = add i8 %22, 3
; check that position is in bounds
%24 = icmp uge i8 %23, %15
; if not, error
br i1 %24, label %19, label %25
25: ; preds = %20
; compute address of last byte of granule
%26 = or i64 %12, 15
%27 = inttoptr i64 %26 to ptr
; load tag from it
%28 = load i8, ptr %27, align 1
; compare with pointer tag
%29 = icmp ne i8 %11, %28
; if mismatche, error
br i1 %29, label %19, label %30
30: ; preds = %25
br label %31
31: ; preds = %0, %30
store i8 0, ptr %8, align 4
%32 = load volatile ptr, ptr %5, align 8
call void @free(ptr noundef %32)
开源社区贡献
在实践 HWASAN 过程中,我们对 HWASAN 进行了一些改进和增强:修复了 HWASAN 生成报告时存在的多线程数据竞争问题,增强了多线程内存错误报告的可读性、修复使用 match-all-tag 时会产生误报的问题、并且扩展了 match-all-tag 的使用场景等。目前字节跳动 STE 团队已将相关补丁提交至 LLVM 社区,并合入社区主线,链接如下:
- https://reviews.llvm.org/D147215
- https://reviews.llvm.org/D147121
- https://reviews.llvm.org/D149252
- https://reviews.llvm.org/D148909
- https://reviews.llvm.org/D149580
- https://reviews.llvm.org/D149943
LLVM 作为一套提供编译器基础设施的开源项目,包含一系列模块化的编译器组件和工具链,欢迎更多对 LLVM 感兴趣的同学一起交流,共同推进社区的建设。
参考链接
- Hardware-assisted AddressSanitizer Design Documentation
- https://arxiv.org/pdf/1802.09517.pdf
- https://github.com/google/sanitizers/tree/master/hwaddress-sanitizer
更多精彩内容
深度解析C/C++之Strict Aliasing Rule
热门招聘
字节跳动STE团队诚邀您的加入!团队长期招聘,北京、上海、深圳、杭州、US、UK 均设岗位,以下为近期的招聘职位信息,有意向者可直接扫描海报二维码投递简历,期待与你早日相遇,在字节共赴星辰大海!若有问题可咨询小助手微信:sys_tech,岗位多多,快来砸简历吧!