HWASAN Internals

本文深入分析了 HWASAN (HardWare-assisted AddressSanitizer) 检测内存错误的原理。

概述

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):

HWASAN (HardWare-assisted AddressSanitizer)

算法

实现

shadow mapping

HWASAN 与 ASAN 一样都使用了 shadow memory 技术。ASAN 默认使用 static shadow mapping,只有对 IOS 和 32-bit Android 平台才使用 dynamic shadow mapping。而 HWASAN 则总是使用 dynamic shadow mapping。

tagging

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 是否一致时,需要考虑如下两种可能:

  1. 保存在指针 top byte 的 tag 和保存在 shadow memory 中的 tag 相同。

  2. 保存在 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 有三个参数:

  1. __hwasan_shadow_memory_dynamic_address

  2. 本次 memory access 访问的内存地址

  3. 常数 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

另外,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 的调用,那非默认情况呢?

参考链接