硬件辅助 AddressSanitizer 设计文档¶
此页面是 **硬件辅助 AddressSanitizer**(或 **HWASAN**)的设计文档,它与 AddressSanitizer 类似,但基于部分硬件辅助。
简介¶
AddressSanitizer 使用 1 字节标签(使用 *影子内存*)对应用程序内存的每 8 字节进行标记,使用 *红区* 来查找缓冲区溢出,并使用 *隔离区* 来查找使用后释放。红区、隔离区以及在较小程度上,影子内存,是 AddressSanitizer 内存开销的来源。有关详细信息,请参见 AddressSanitizer 论文。
AArch64 具有 地址标记(或顶部字节忽略,TBI),这是一个硬件特性,允许软件使用 64 位指针的 8 个最高有效位作为标签。HWASAN 使用 地址标记 来实现类似于 AddressSanitizer 的内存安全工具,但内存开销更小,并且精度保证略有不同(大部分情况下更好)。
英特尔的 线性地址掩码(LAM)也为 x86_64 提供地址标记,但目前在硬件中并不广泛可用。对于 x86_64,HWASAN 有一个使用页面别名的有限实现。
算法¶
每个堆/栈/全局内存对象都被强制对齐 TG 字节(TG 例如为 16 或 64)。我们称 TG 为 **标记粒度**。
对于每个这样的对象,会选择一个随机的 TS 位标签 T(TS 或标签大小例如为 4 或 8)
指向该对象的指针被标记为 T。
该对象的内存也被标记为 T(使用 TG=>1 的影子内存)
每个加载和存储都被检测,以读取内存标签并将其与指针标签进行比较,如果标签不匹配,则会引发异常。
有关此方法的更详细讨论,请参见 https://arxiv.org/pdf/1802.09517.pdf
短粒度¶
短粒度是大小介于 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:
stp x30, x20, [sp, #-16]!
adrp x20, :got:__hwasan_shadow // load shadow address from GOT into x20
ldr x20, [x20, :got_lo12:__hwasan_shadow]
bl __hwasan_check_x0_2_short_v2 // call outlined tag check
// (arguments: x0 = address, x20 = shadow base;
// "2" encodes the access type and size)
ldr w0, [x0] // inline load
ldp x30, x20, [sp], #16
ret
[...]
__hwasan_check_x0_2_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:
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 会使用不同的标签标记内存。
栈¶
通过将所有不可提升的 alloca 对齐到 TG 并通过函数前导和后导代码标记栈内存,对栈帧进行检测。
一个函数中不同 alloca 的标签 **不会** 被独立地生成;在具有 M 个 alloca 的函数中,这样做需要维护 M 个活动栈指针,从而显着增加寄存器压力。相反,我们在前导代码中生成一个唯一的基标签值,并将 alloca 编号为 M 的标签构建为 ReTag(BaseTag, M),其中 ReTag 可以像与常量 M 进行异或运算一样简单。
预计栈检测将是开销的主要来源,但它可以是可选的。
全局变量¶
HWASAN 检测代码中的大多数全局变量都被标记。这是使用以下机制完成的
每个全局变量的地址都有一个与之关联的静态标签。翻译单元中第一个定义的全局变量具有一个与之关联的伪随机标签,基于文件路径的哈希值。后续的全局标签从先前分配的标签递增。
全局变量的标签被添加到其地址在目标文件符号表中的符号地址中。这会导致在获取全局变量的地址时,该地址被标记。
当直接获取全局变量的地址(即,不是通过 GOT)时,需要使用特殊的指令序列将标签添加到地址,因为否则标签会将地址超出小型代码模型(AArch64 上为 4GB)。当通过 GOT 获取地址时,不需要进行任何更改,因为存储在 GOT 中的地址将包含标签。
为每个已标记的全局变量,会发出一个关联的
hwasan_globals
部分,它指示全局变量的地址、大小和标签。这些部分由链接器连接成一个单独的hwasan_globals
部分,该部分在加载二进制文件时由运行时(通过 ELF 注解)枚举,并相应地标记内存。
下面给出一个完整的示例
// 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(理想情况下)不使用标志来决定基于函数属性正在使用哪个传递,ASAN 或 HWASAN 正在被应用。
这确实意味着 HWASAN 的用户可能需要将新属性添加到已经使用旧属性的代码中。
与 AddressSanitizer 的比较¶
- HWASAN
比 AddressSanitizer 移植性更差,因为它依赖于硬件 地址标记(AArch64)。地址标记可以通过编译器检测模拟,但它将需要检测以在任何加载或存储之前删除标签,这在任何包含非检测代码的现实环境中都是不可行的。
如果目标代码将更高的指针位用于其他目的,则可能会存在兼容性问题。
可能需要在操作系统内核中进行更改(例如,Linux 似乎不喜欢从地址空间传递的已标记指针:https://www.kernel.org/doc/Documentation/arm64/tagged-pointers.txt)。
**不需要红区来检测缓冲区溢出**,但缓冲区溢出检测是概率性的,大约有 1/(2**TS) 的几率会漏掉错误(使用 4 和 8 位 TS 分别为 6.25% 或 0.39%)。
**不需要隔离区来检测堆使用后释放或栈使用后返回**。检测同样是概率性的。
预计 HWASAN 的内存开销将远小于 AddressSanitizer:影子内存的额外内存为 1/TG,以及由于 TG 对齐所有对象而产生的部分开销。
安全注意事项¶
HWASAN 是一种错误检测工具,其运行时不打算与生产可执行文件链接。虽然它可能对测试有用,但 HWASAN 的运行时并不是针对安全敏感约束进行开发的,可能会影响生成的执行文件的安全性。
支持的架构¶
HWASAN 依赖于 地址标记,它仅在 AArch64 上可用。对于其他 64 位架构,可以通过编译器检测在每次加载和存储之前删除地址标签,但这种变体将具有有限的可部署性,因为通常并非所有代码都经过了检测。
在 x86_64 上,HWASAN 利用页面别名将标签放置在用户空间地址位中。目前只支持堆标记。页面别名依赖于共享内存,这将导致如果应用程序调用 fork()
,则堆内存会在进程之间共享。因此,x86_64 实际上只对不进行 fork 的应用程序安全。
HWASAN 目前不支持 32 位架构,因为它们不支持 地址标记,并且地址空间太受限制,无法轻松实现页面别名。