clang之Hardware-assisted AddressSanitizer

2,076 阅读9分钟

Clang 12 documentation

Clang 12 documentation包含了一系列工具,如 AddressSanitizerThreadSanitizerLeakSanitizerLibTooling等。

  1. clang之AddressSanitizer
  2. clang之MemorySanitizer
  3. clang之LeakSanitizer
  4. clang之UndefinedBehaviorSanitizer
  5. clang之Hardware-assisted-AddressSanitizer
  6. clang之SafeStack
  7. clang之ShadowCallStack
  8. clang之ThreadSanitizer
  9. clang之Thread-Safety-Analysis
  10. clang之DataFlowSanitizer

这部分是对clang文档 Clang 12 documentation HardwareAssistedAddressSanitizerDesign 的翻译。仅供参考。

硬件支持的AddressSanitizer设计文档

这篇文章是硬件支持的 AddressSanitizer (即 HWASAN)的设计文档,一个类似 AddressSanitizer 的工具,但是基于部分硬件的支持。

介绍

AddressSanitizer 对每8个字节的内存,打上一个字节的tag(使用影子内存),使用 redzones 来发现缓冲溢出的问题,使用隔离区来发现 释放后使用(use-after-free)的问题。redzones、隔离区,在很小的程度上,影子内存是 AddressSanitizer 内存消耗的来源。详细内容见 AddressSanitizer 的文章。

AArch64架构有地址标记(Address Tagging),或者叫top-byte-ignore(TBI),是一个硬件特性,允许软件使用64位指针中的8个最有意义的bit位作为一个标记。HWASAN使用地址标记来实现内存安全工具,类似 AddressSanitizer,但是内存消耗较小,且会精度稍有不同,绝大部分情况下是更加精确的。

算法

  • 每一个堆、栈或全局的内存对象,都会强制以TG个字节进行内存对齐(例如TG为16或64)。这里TG被称为标记间隔。
  • 对每一个该对象,会选取一个随机的TS位的标记。(TS即标记大小,例如4或8)。
  • 指向对象的指针被标记为T。
  • 对象的内存也会被标记为T(使用一个 TG=>1 的影子内存)。
  • 每一次内存的加载和存储都会被编译器进行插桩,以读取内存标记,将其与指针标记进行比较。一旦标记不匹配,则会抛出异常。

关于这种实现的更加细节的讨论,请见 arxiv.org/pdf/1802.09…

短颗粒(Short granules)

短颗粒是尺寸介于1和TG-1字节之间的小颗粒。短颗粒的尺寸存储在影子内存中的短颗粒的标记通常存储的位置,而短颗粒的实际标记存储在其最后一个字节中。这意味着,为了验证一个指针的标记与内存标记是否一致,HWASAN必须要检查两种可能性:

  • 指针标记与影子内存中的内存标记相等,或者
  • 影子内存标记实际上是一个短颗粒的尺寸,加载的值在短颗粒的范围内,并且指针标记与短颗粒的最后一个字节相等。

介于1和TG-1之间的指针标记都是有可能的,且与其他任意标记一样。这意味着,内存中的这些标记有两种解释:全标记解释(指针标记介于1和TG-1之间,且短颗粒的最后一个字节是原数据)和短标记解释(指针标记存储在短颗粒中)。

当HWASAN检测到一个介于1和TG-1之间的内存标记的错误时,它会显示内存标记和短颗粒的最后一个字节。此时,就由用户自己来决定采用哪种方式了。

编译器插桩

内存访问

在大部分使用场景下,在内存访问之前,会先执行一个外联指令序列的调用操作,用于验证这些标记。该调用操作的代码尺寸和性能消耗,已经通过自定义的调用约定进行相应的减少了:

  • 保留大部分的寄存器,并且
  • 寄存器包含内存访问的地址、类型和大小。

当前,使用了如下的指令序列:

// int foo(int *a) { return *a; }
// clang -O2 --target=aarch64-linux-android30 -fsanitize=hwaddress -S -o - load.c
[...]
foo:
      str     x30, [sp, #-16]!
      adrp    x9, :got:__hwasan_shadow                // load shadow address from GOT into x9
      ldr     x9, [x9, :got_lo12:__hwasan_shadow]
      bl      __hwasan_check_x0_2_short               // call outlined tag check
                                                      // (arguments: x0 = address, x9 = shadow base;
                                                      // "2" encodes the access type and size)
      ldr     w0, [x0]                                // inline load
      ldr     x30, [sp], #16
      ret

[...]
__hwasan_check_x0_2_short:
      ubfx    x16, x0, #4, #52                        // shadow offset
      ldrb    w16, [x9, 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:
      stp     x0, x1, [sp, #-256]!                    // save original x0, x1 on stack (they will be overwritten)
      stp     x29, x30, [sp, #232]                    // create frame record
      mov     x1, #2                                  // set x1 to a constant indicating the type of failure
      adrp    x16, :got:__hwasan_tag_mismatch_v2      // call runtime function to save remaining registers and report error
      ldr     x16, [x16, :got_lo12:__hwasan_tag_mismatch_v2] // (load address from GOT to avoid potential register clobbers in delay load handler)
      br      x16

使用malloc操作对堆内存或指针进行标记。任何强制所有对象遵从TG的内存对齐的malloc都是如此。free操作会对内存进行不同的标记。

栈桢会被编译器插桩,以对所有的 non-promotable allocas 进行TG的内存对齐。且在函数的入口和结尾进行栈内存的标记。

在一个函数中对不同的分配(allocas)所做的标记并不是各自独立生成的。在一个包含M个allocas的函数中做标记,会要求维护M个存活的栈指针,这样会显著增加寄存器的压力。相反,我们在函数开头生成一个单独的基础标记值,将该标记构建为分配数字M作为ReTag(BaseTag, M),其中,ReTag可以简单地互斥。这里M也可以是常量。

英文原文:Tags for different allocas in one function are not generated independently; doing that in a function with M allocas would require maintaining M live stack pointers, significantly increasing register pressure. Instead we generate a single base tag value in the prologue, and build the tag for alloca number M as ReTag(BaseTag, M), where ReTag can be as simple as exclusive-or with constant M.

栈的插桩操作会带来极大的资源消耗,但却是可选的。

全局变量

在HWASAN插桩的代码中,绝大部分的全局变量都是被标记过的。使用下边的这些机制都是可行的:

  • 每一个全局变量的地址都有一个静态的标记与之关联。 转换单元中定义的第一个全局变量会有一个伪随机的标记与之关联,该标记基于文件路径的hash值计算得出。之后的全局变量标记会在之前的标记上进行递增。
  • 全局变量的标记会被加到目标文件的符号表中的变量所在的符号地址中。这导致了,当全局变量的地址被取出的时候,需要打上标记。
  • 当全局变量的地址被直接取出(例如,不是通过GOT)时,需要使用一个特殊的指令序列将标记加到地址上,因为如果不这样做的话标记就会取到小代码模型(在AArch64上,4GB)的外部地址。当通过GOT取出该地址时,不需要做任何改变,因为存放在GOT中的地址就已经包含了标记。
  • 对于每一个被打上标记的全局变量,会有一个关联的 hwasan_globals 段(hwasan_globals section)生成,其中会展示全局变量的地址、大小和标记。当二进制被加载的时候,链接器会将这些段串起来,得到一个单独的 hwasan_globals 段,当内存依次被打上标记的时候,通过runtime(ELF)对这些段进行枚举。 (英文原文 An associated hwasan_globals section is emitted for each tagged global, which indicates the address of the global, its size and its tag. These sections are concatenated by the linker into a single hwasan_globals section that is enumerated by the runtime (via an ELF note) when a binary is loaded and the memory is tagged accordingly.)

一个完整的例子如下:

// int x = 1; int *f() { return &x; }
// clang -O2 --target=aarch64-linux-android30 -fsanitize=hwaddress -S -o - global.c

[...]
f:
      adrp    x0, :pg_hi21_nc:x            // set bits 12-63 to upper bits of untagged address
      movk    x0, #:prel_g3:x+0x100000000  // set bits 48-63 to tag
      add     x0, x0, :lo12:x              // set bits 0-11 to lower bits of address
      ret

[...]
      .data
.Lx.hwasan:
      .word   1

      .globl  x
      .set x, .Lx.hwasan+0x2d00000000000000

[...]
      .section        .note.hwasan.globals,"aG",@note,hwasan.module_ctor,comdat
.Lhwasan.note:
      .word   8                            // namesz
      .word   8                            // descsz
      .word   3                            // NT_LLVM_HWASAN_GLOBALS
      .asciz  "LLVM\000\000\000"
      .word   __start_hwasan_globals-.Lhwasan.note
      .word   __stop_hwasan_globals-.Lhwasan.note

[...]
      .section        hwasan_globals,"ao",@progbits,.Lx.hwasan,unique,2
.Lx.hwasan.descriptor:
      .word   .Lx.hwasan-.Lx.hwasan.descriptor
      .word   0x2d000004                   // tag = 0x2d, size = 4

错误报告

错误是有HLT指令生成的,会通过信号处理函数来处理掉。

属性

HWASAN使用了它自己的LLVM 中间代码(IR)的属性 sanitize_hwaddress,以及一个匹配C函数的属性。一个替代方案是再使用ASAN的属性 sanitize_address。使用独立的编译器属性的原因在于:

  • 用户可能需要禁用ASAN,而不是HWASAN。反之亦然,因为这些工具都会有不同的权衡和兼容性问题。
  • 理想情况下,LLVM不会使用标记来决定使用哪个pass。至于会不会使用ASAN或HWASAN,是取决于函数的编译器属性。

这意味着,HWASAN的用户可能需要在已经使用了编译器属性的代码中添加新的编译器属性。

与AddressSanitizer比较

HWASAN

  • 相比 AddressSanitizer 难以移植,因为他会依赖硬件地址标记(AArch64)。地址标记可以使用编译器插桩来模仿,但是会要求插桩在每次加载或存储操作前都将标记移除,而这一点在任何包含有未插桩代码的真实环境中都是不可行的。
  • 如果因为一些其他目的,使得目标代码使用了高的指针bit位,则可能会有兼容性问题。
  • 可能需要操作系统内核的一些修改。(如Linux似乎不太接受从地址空间传递过来的被标记指针)见www.kernel.org/doc/Documen…
  • 不需要使用redzones来检测缓冲溢出, 但是缓冲溢出检测是概率性的,有大概 1/(2**TS) 的几率漏掉bug()。(使用4位和8位的TS时,分别是6.25%和0.39%)。
  • 不需要使用隔离区来检测 使用已释放的堆(heap-use-after-free) 或者 使用已经返回的栈(stack-use-after-return)。该检测似乎是概率性的。

HWASAN的内存消耗会比 AddressSanitizer 要小的多:1/TG的额外内存用于影子内存,以及使用TG内存对齐所有对象带来的一些损耗。

支持的架构

依赖地址标记的HWASAN,目前仅在AArch64架构中可用。对于其他的64位架构,可能会通过编译器插桩,在每次加载和存储操作之前,将地址标记移除掉。但是这也不是绝对的,因为并非所有的代码都会被插桩。

HWASAN的方案不适用于32位的架构。

相关工作