模糊测试 ASan 模式下有关堆的 ASan Redzone 和 Check 的源码解析
Brinmon 发表于 湖南 二进制安全 555浏览 · 2024-09-20 14:46

1. 引言

模糊测试与 ASan 的概述

模糊测试(Fuzz Testing)是一种自动化的软件测试技术,旨在通过向程序提供随机或异常输入来检测其漏洞与崩溃。它能够有效捕捉输入处理中的异常,特别是与内存相关的问题。模糊测试在寻找未知错误方面尤为有用,因为它无需预定义输入模式,通过生成各种异常数据,增加程序失效的可能性。

AddressSanitizer(ASan)是一种强大的内存错误检测工具,主要用于检测运行时的内存问题,如缓冲区溢出、内存越界、未初始化内存的使用以及内存泄漏。ASan 在模糊测试中至关重要,因为它可以捕获许多模糊测试过程中暴露出的内存错误,帮助开发者更快、更有效地定位潜在问题。

文章目的

本篇文章的目的是深入解析 ASan 模式下,堆内存的保护机制,尤其是 Redzone 和 Check 的相关源码。文章将通过对源码的详细解读,揭示 ASan 如何通过 Redzone 和 Check 机制标记和监控内存的使用状态,进而检测越界访问和内存泄漏等问题。
文章主要大纲:

  • 堆的 ASan Redzone 标记机制解析
  • 堆的ASan Check 机制解析

2. ASan 原理概述

ASan 的工作的简单案例

写了一个Makefile 定义了两种不同版本的内存错误检测程序的编译流程,分别是带有 ASAN 插桩的版本和普通版本。ASAN 插桩版本用于动态检测程序中的内存错误(如使用释放的内存、越界访问等),而未插桩版本则是用于对比执行的正常程序版本。源码可以直接让GPT生成这里就不给出了代码文件了,就给出makefile文件.
Makefile文件:

# 定义编译器和编译选项
CC = gcc
CFLAGS = -g -Wall
ASAN_FLAGS = -fsanitize=address

# 要编译的源文件
SRCS = use_after_free.c heap_out_of_bounds.c stack_out_of_bounds.c \
       global_out_of_bounds.c return_local_variable.c memory_leak.c

# 生成的可执行文件 (插桩和未插桩)
INSTRUMENTED_TARGETS = use_after_free_asan heap_out_of_bounds_asan  \
                        memory_leak_asan
NON_INSTRUMENTED_TARGETS = use_after_free heap_out_of_bounds  \
                            memory_leak

# 默认目标: 编译插桩和未插桩版本
all: instrumented non_instrumented

# 编译插桩版本
instrumented: $(INSTRUMENTED_TARGETS)

use_after_free_asan: use_after_free.c
    $(CC) $(CFLAGS) $(ASAN_FLAGS) -o $@ $<

heap_out_of_bounds_asan: heap_out_of_bounds.c
    $(CC) $(CFLAGS) $(ASAN_FLAGS) -o $@ $<

memory_leak_asan: memory_leak.c
    $(CC) $(CFLAGS) $(ASAN_FLAGS) -o $@ $<

# 编译未插桩版本
non_instrumented: $(NON_INSTRUMENTED_TARGETS)

use_after_free: use_after_free.c
    $(CC) $(CFLAGS) -o $@ $<

heap_out_of_bounds: heap_out_of_bounds.c
    $(CC) $(CFLAGS) -o $@ $<

memory_leak: memory_leak.c
    $(CC) $(CFLAGS) -o $@ $<

# 清理编译生成的文件
clean:
    rm -f $(INSTRUMENTED_TARGETS) $(NON_INSTRUMENTED_TARGETS)

.PHONY: all clean instrumented non_instrumented

UAF漏洞爆出(ERROR:Addresssanitizer:heap-use-after-free)

以asan模式手动编译的命令:

clang use_after_free.c -fsanitize=address -o use_after_free_asan
或者
gcc use_after_free.c -fsanitize=address -o use_after_free_asan

栈溢出漏洞爆出(ERROR:Addresssanitizer:stack-buffer-overflow)

以asan模式手动编译的命令:

clang stack_out_of_bounds.c -fsanitize=address -o stack_out_of_bounds
或者 
gcc stack_out_of_bounds.c -fsanitize=address -o stack_out_of_bounds

其他可以用的Asan模式编译选项

  1. Address Sanitizer(-fsanitize=address):用于检测运行时的内存错误,例如越界访问使用已释放的内存(UAF,Use-After-Free)、堆栈溢出双重释放、以及其他与内存管理相关的错误。
  2. Memory Sanitizer (-fsanitize=memory):主要用于检测未初始化内存的使用对已释放内存的操作。它可以帮助发现未初始化的变量或内存空间的读取,这是常见的、难以追踪的错误。
  3. UndefinedBehaviorSanitizer (-fsanitize=undefined):检测程序中潜在的未定义行为,例如整数溢出空指针解引用未对齐的内存访问等。C 和 C++ 中的许多行为在标准中是未定义的,此工具可以帮助识别这些问题。
  4. Thread Sanitizer (-fsanitize=thread):专门用于检测多线程程序中的数据竞争问题。它可以监视多个线程对同一变量或内存位置的并发访问,帮助识别和修复潜在的并发 bug,如数据竞争死锁
  5. Address Sanitizer with Leak Detection (-fsanitize=leak):AddressSanitizer 的一个扩展,用于检测内存泄漏。内存泄漏是指分配的内存没有被释放,从而导致内存的浪费,长时间运行会导致程序崩溃或资源耗尽。
  6. Coverage Sanitizer (-fsanitize=coverage):专为Linux 内核模块开发设计,用于检测内核中的内存错误。这是内核版本的 AddressSanitizer,检测内核模块中的内存越界、使用已释放内存等错误。
  7. Kernel Address Sanitizer (-fsanitize=kernel-address):针对 Linux 内核模块开发,用于检测内核中的内存错误。

ASan 的工作原理

1.ASAN 是用于动态检测内存错误的工具

ASAN 可以通过编译时的插桩(instrumentation)和运行时的动态检查,帮助开发者检测和调试内存相关的错误。它在编译期间会为每个内存分配和释放操作添加额外的代码,确保在运行过程中对内存的每次读写都经过 ASAN 的验证,从而发现内存问题。

2. ASAN 将数据区域分为两种:可访问区域和不可访问区域 (redzone)

在程序运行时,ASAN 会为每个内存块设置边界标记。每次内存分配时,ASAN 在正常的可用内存区域周围插入一些不可访问的区域,称为 redzone。这些区域主要用于检测越界访问。

  • 可访问区域 是正常分配的内存空间,供程序读取和写入数据。
  • 不可访问区域 (redzone) 是在内存块两侧添加的一些填充区域,作为缓冲区,防止越界访问。redzone 的存在可以捕获那些访问未分配或已经释放的内存操作。

    这些 redzone 通常不会引发程序立即崩溃,而是被 ASAN 用来记录和标记潜在的内存问题。这样,当发生越界访问时,ASAN 能够识别并报告这些问题,帮助开发者找到问题所在。

3. ASAN 影子内存 (Shadow Memory) 的作用

ASAN 引入了一种称为 影子内存 (shadow memory) 的技术,用于跟踪主内存的可访问性状态。影子内存和正常内存的比例是 1:8,即每 1 字节的影子内存可以描述 8 字节的正常内存。这意味着,影子内存中的每个字节对应主内存中的 8 个字节,用于标记这些字节是否可访问。

  • 如果主内存的某一部分是可访问的,影子内存中的相应位就会被标记为“可访问”。
  • 如果主内存的某一部分处于 redzone,影子内存中的相应位就会被标记为“不可访问”。
    在每次内存访问时,ASAN 会查询影子内存以判断该访问是否合法。如果访问的是不可访问区域,ASAN 会立即报告错误并提示开发者。

4. 影子内存的布局和工作机制

影子内存的具体实现与内存的映射有关。ASAN 会通过影子内存中的字节位信息来判断内存的状态:

  • 对于正常的 8 字节内存,影子内存中的值为 0,表示这 8 字节都是可访问的。
  • 如果某些字节不可访问(如处于 redzone 或者被释放),影子内存中的值会被设置为特定的标记。
  • ASAN 的检测代码会在每次内存访问时通过影子内存验证目标地址的状态,如果发现异常立即触发报错。

3. 堆的 ASan Redzone 标记机制解析

Redzone 的设置和取消

堆溢出案例分析

这是heap_out_of_bounds.c正常编译出来的IDA伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+4h] [rbp-Ch]
  int *arr; // [rsp+8h] [rbp-8h]

  arr = (int *)malloc(0x14uLL);
  for ( i = 0; i <= 5; ++i )
    arr[i] = i;
  free(arr);
  return 0;
}

这个代码实现的功能是向堆块内写入6个int整数,占用0x20个字节,但是堆块的大小只有0x14个字节存在堆溢出漏洞.正常情况下是不会导致程序奔溃的.

这是heap_out_of_bounds.c以asan模式编译后的IDA伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int *v3; // rcx
  int i; // [rsp+4h] [rbp-Ch]
  int *arr; // [rsp+8h] [rbp-8h]

  arr = malloc(0x14uLL);
  for ( i = 0; i <= 5; ++i )
  {
    v3 = &arr[i];
    //在进行赋值前检测内存的可访问性
    if ( *((v3 >> 3) + 0x7FFF8000) != 0 && (((4 * i + arr) & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000) )
      __asan_report_store4(&arr[i]);//发现不可以访问就报错
    *v3 = i;
  }
  free(arr);
  return 0;
}

这是堆溢出漏洞经过Asan模式插桩以后的样子,被插入了一段校验代码,每次在向堆写入数据时都会检查是否发生堆溢出.

我们这部分主要是讲解堆内存是如何被标记Redzone即不可访问的!这要引入一个之前提到的影子内存(Shadow Memory)概念,这片内存放了真实内存可以被访问的内存数.通过伪代码可以发现并未实现堆块内存的redzone设置,因为这些操作被隐藏在了malloc函数中,源码如下:

malloc申请堆块后标记内存可用区域

当程序通过 malloc 分配内存时,ASan 会相应地在影子内存中标记该内存区域为可用,并且设置两个 redzone 区域,来防止越界访问。

相关源码位置:
llvm-project\compiler-rt\lib\asan\asan_allocator.cpp
源码位置:llvm-project/compiler-rt/lib/asan/asan_allocator.cpp at main · llvm/llvm-project (github.com)

//源码:asan_allocator.cpp
...
static Allocator instance(LINKER_INITIALIZED);
...
void *asan_malloc(uptr size, BufferedStackTrace *stack) {
  return SetErrnoOnNull(instance.Allocate(size, 8, stack, FROM_MALLOC, true));
}
...

// 拦截器函数:替代 malloc 函数的实现
INTERCEPTOR(void*, malloc, uptr size) {
  if (DlsymAlloc::Use())
    return DlsymAlloc::Allocate(size);  // 使用自定义分配逻辑

  GET_STACK_TRACE_MALLOC;  // 获取堆栈信息
  return asan_malloc(size, &stack);  // 使用 ASan 的内存分配逻辑
}
...

首先我们使用的malloc函数不是原来glibc里面的函数了,而是Asan实现的asan_malloc函数!可以通过源码查找发现他具体进行的操作,继续往内部追寻就可以发现!

// -------------------- 分配/释放例程 ---------------
void *Allocate(uptr size, uptr alignment, BufferedStackTrace *stack,
               AllocType alloc_type, bool can_fill) {
  // 如果AddressSanitizer (ASan) 未初始化,则进行初始化
  // UNLIKELY 用于优化分支预测,表示该条件不太可能为真
  if (UNLIKELY(!AsanInited()))
    AsanInitFromRtl();  // 初始化 ASan 运行时库
  ...

  // 如果使用的是次分配器(from_primary为false)或影子内存尚未被污染(即影子内存值为0),则对内存进行标记(污染)
  // MEM_TO_SHADOW将分配的实际内存地址映射到影子内存地址
  if (!from_primary || *(u8 *)MEM_TO_SHADOW((uptr)allocated) == 0) {
    // 计算分配的用户区域结束地址,向上对齐到ASAN_SHADOW_GRANULARITY的倍数(通常为8字节)
    uptr tail_beg = RoundUpTo(user_end, ASAN_SHADOW_GRANULARITY);
    // 计算实际分配的内存块结束地址,包括分配器的管理开销
    uptr tail_end = alloc_beg + allocator.GetActuallyAllocatedSize(allocated);

    // 对分配的左侧redzone区域进行毒化,防止越界写入左侧内存区域
    PoisonShadow(alloc_beg, user_beg - alloc_beg, kAsanHeapLeftRedzoneMagic);
    // 对分配的右侧redzone区域进行毒化,防止越界写入右侧内存区域
    PoisonShadow(tail_beg, tail_end - tail_beg, kAsanHeapLeftRedzoneMagic);
  }

  // 计算对齐后的用户区域大小,向下对齐到ASAN_SHADOW_GRANULARITY的倍数
  uptr size_rounded_down_to_granularity = RoundDownTo(size, ASAN_SHADOW_GRANULARITY);
  // 如果对齐后的大小不为0,则将用户实际可用的内存标记为可访问状态(即影子内存设置为0)
  if (size_rounded_down_to_granularity)
    PoisonShadow(user_beg, size_rounded_down_to_granularity, 0);

  ...
  // 运行malloc钩子,通常用于调试或分析内存分配行为
  RunMallocHooks(res, size);

  // 返回分配的内存地址
  return res;
}

在上述代码中,PoisonShadow() 函数用于将影子内存设为特殊的“redzone”值,这些 redzone 区域不可访问,旨在保护已分配内存的边界。
其中kAsanHeapLeftRedzoneMagic的值是:const int kAsanHeapLeftRedzoneMagic = 0xfa;

UAF案例分析

这是use_after_free.c正常编译出来的IDA伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned int *ptr; // [rsp+8h] [rbp-8h]

  ptr = malloc(4uLL);
  *ptr = 42;
  free(ptr);
  printf("%d\n", *ptr);
  return 0;
}

单纯在堆块释放后依旧对堆内存进行输出也就是UAF漏洞!正常情况下是不会导致程序奔溃的.

这是use_after_free.c以asan模式编译出来的IDA伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned int *v3; // rax
  unsigned int *ptr; // [rsp+8h] [rbp-8h]

  v3 = malloc(4uLL);
  ptr = v3;

  // 检查影子内存
  if (*((v3 >> 3) + 0x7FFF8000) != 0 && ((v3 & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000)) {
    // 报告堆内存越界
    __asan_report_store4(v3);
  }

  *ptr = 42;
  free(ptr);  // 释放堆块

  // 检查访问已释放的堆块
  if (*((ptr >> 3) + 0x7FFF8000) != 0 && ((ptr & 7) + 3) >= *((ptr >> 3) + 0x7FFF8000)) {
    // 报告堆内存越界
    __asan_report_load4(ptr);
  }

  printf("%d\n", *ptr);
  return 0;
}

这段代码展示了一个简单的使用后释放(Use-After-Free, UAF)漏洞,其中堆块在释放后继续访问,导致Asan检测到错误从而报错。在每次对堆块内存进行操作时,都会进行影子内存检查.
可以发现Asan插桩的条件是对内存进行操作时就会针对性插桩,比如向堆块内存赋值时后就会进行Asan检测,在printf输出堆块内存时候也会对内存进行检测.

我们这部分主要是讲解堆内存的Redzone即不可访问标记是如何被取消的!这里的实现就涉及到了free函数了,源码如下:

free释放堆块后标记内存不可用区域

free和malloc同样被拦截器函数替代了原有函数的功能!

相关源码位置:
llvm-project\compiler-rt\lib\asan\asan_allocator.cpp
源码位置:llvm-project/compiler-rt/lib/asan/asan_allocator.cpp at main · llvm/llvm-project (github.com)

// 拦截器函数:替代 free 函数的实现
INTERCEPTOR(void, free, void *ptr) {
  // 如果指针来自自定义分配器,则使用自定义释放逻辑
  if (DlsymAlloc::PointerIsMine(ptr))
    return DlsymAlloc::Free(ptr);

  GET_STACK_TRACE_FREE;  // 获取堆栈信息
  asan_free(ptr, &stack, FROM_MALLOC);  // 使用 ASan 的内存释放逻辑
}
//源码:asan_allocator.cpp
...
static Allocator instance(LINKER_INITIALIZED);
...
void asan_free(void *ptr, BufferedStackTrace *stack, AllocType alloc_type) {
  instance.Deallocate(ptr, 0, 0, stack, alloc_type);
}
...

可以发现free函数最后都会调用instance.Deallocate(ptr, 0, 0, stack, alloc_type);继续往后看源码!

struct Allocator {
  // 最大允许的 malloc 分配大小
  static const uptr kMaxAllowedMallocSize = FIRST_32_SECOND_64(
      3UL << 30, 1ULL << 40);  // 32 位系统为 3GB,64 位系统为 1TB

  // ASan 内存分配器,用于实际执行内存分配和释放操作
  AsanAllocator allocator;
  // ASan 的隔离区,用于延迟释放内存以检测 UAF(Use After Free)
  AsanQuarantine quarantine;
  // 互斥锁,用于保护分配器的回退操作
  StaticSpinMutex fallback_mutex;
  // 分配器缓存,用于优化分配操作
  AllocatorCache fallback_allocator_cache;
  // 隔离区缓存,用于优化隔离区操作
  QuarantineCache fallback_quarantine_cache;

  // 用户定义的最大 malloc 分配大小
  uptr max_user_defined_malloc_size;
  ...
}

这是Allocator结构体的定义,里面存在一个变量隔离区缓存:QuarantineCache fallback_quarantine_cache;

void Deallocate(void *ptr, uptr delete_size, uptr delete_alignment,
                BufferedStackTrace *stack, AllocType alloc_type) {
...

  // 将内存块放入隔离区
  QuarantineChunk(m, ptr, stack);
}

当内存被释放时,ASan 并不会立即将内存归还给操作系统,而是将其放入一个隔离区(quarantine)中,以便更有效地检测 “use-after-free” 问题。QuarantineChunk() 函数会毒化释放后的内存区域,防止程序继续访问已经释放的内存,典型的影子内存毒化过程如下:

struct QuarantineCallback {
  // 构造函数,初始化缓存和堆栈跟踪
  QuarantineCallback(AllocatorCache *cache, BufferedStackTrace *stack)
      : cache_(cache), stack_(stack) {}

  // 在将内存块放入隔离区之前执行的操作
  void PreQuarantine(AsanChunk *m) const {
    // 填充内存块
    FillChunk(m);
    // 毒化内存区域,标记为已释放
    PoisonShadow(m->Beg(), RoundUpTo(m->UsedSize(), ASAN_SHADOW_GRANULARITY),
                 kAsanHeapFreeMagic);
  }
...
};

最后整体梳理一下,在这段代码中,PoisonShadow() 被调用来将影子内存标记为已释放状态,以 kAsanHeapFreeMagic 值毒化内存。这一过程确保了即使程序错误地访问已释放的内存,ASan 也能检测到并报错。

4. 堆的ASan Check 机制解析

对堆内存溢出和uaf的检测机制

在前面的堆溢出案例分析和UAF案例分析中都提供了一个Asan模式的插桩版本,这里被插入的代码就是堆的ASan Check,检测是否存在非法的内存操作.

这些插入的代码都是我们将要堆对对内存进行操作时就会插入的代码,比如输出堆块内存信息和对堆块内存进行赋值时,下面就是之前案例中频繁出现的伪代码插桩片段:

if ( *((v3 >> 3) + 0x7FFF8000) != 0 && (((4 * i + arr) & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000) )
      __asan_report_store4(&arr[i]);//发现不可以访问就报错

由于这个代码操作是对数组的每个单元进行赋值所以每次赋值到要进行检测.

// 检查影子内存
  if (*((v3 >> 3) + 0x7FFF8000) != 0 && ((v3 & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000)) {
    // 报告堆内存越界
    __asan_report_store4(v3);
  }

这里只需要操作一次所以也就只会检查一次.

主要解决的问题:

  1. 怎么知道一片空间有几个字节可以用?
  2. 如何才能知道此次内存操作了几个字节?
  3. 如何检测内存越界了? 解决了上面两个问题就可以得出结论比较一下两个获取的值如果可操作的字节数小于操作了的字节数,就说明越界了!
影子内存解析(怎么知道一片空间有几个字节可以用)

我们可以在插入的代码中发现一个特殊的值:0x7FFF8000,这个值就是影子内存的基地址,也就是影子内存的起始地址。这个值并不固定,但在之前的案例中,都是以 0x7FFF8000 作为起始地址。
从这地址开始就存放了所有内存的可访问性:

ASAN_SHADOW_START(影子内存):
0x7FFF8000 0x4
...
0x7FFF8200 0x3
0x7FFF8201 0xF1
0x7FFF8202 0xF1

Addr(正常内存):
0x1000 
0x1001 
0x1002 
0x1003 
0x1004 
0x1005
0x1006
0x1007

ASAN_SHADOW_START(影子内存)的不同值代表以下含义:

  • 0x00:对应的 8 字节内存全都可访问。
  • 0x01 ~ 0x07:表示前 n 个字节可访问(n 的取值为 1 到 7),其余字节不可访问。
  • 0xF1:红色区域(redzone),表示该内存块属于红色区域,通常用于检测栈或堆的越界访问。这种内存区域不可访问。

如果你想获取 0x10000x1007 这段内存的字节可访问性,可以通过 0x1000 / 8 得到一个影子内存的索引:shadow_idx = 512(即 0x200)。
通过影子内存的偏移地址 *(ASAN_SHADOW_START + shadow_idx),你可以得知对应的 8 个字节中,有 3 个字节是可访问的。
在之前的案例中,表达式 *((v3 >> 3) + 0x7FFF8000) 执行的正是这个操作:由于每 8 个字节的内存对应影子内存中的 1 个字节,因此使用 ptr >> 3,等同于除以 8。

汇编插桩机制解析(如何才能知道此次内存操作了几个字节)

再使用 -fsanitize=address这个编译选项的时候,编译器就会将目标代码插入程序中.

clang use_after_free.c -fsanitize=address -o use_after_free_asan

首先是解析代码会进行几个字节的操作就会插入相对于的汇编代码,比如之前案例中出现的((ptr & 7) + 3)就是表明要向堆块内写入4个字节,如果直接从未代码层面去理解的画会比较麻烦,所以接下来直接从汇编层代码去理解.

根据前面案例提供的伪代码,查看其汇编代码就会如下:

//  if ( *((ptr >> 3) + 0x7FFF8000) != 0 && ((ptr & 7) + 3) >= *((ptr >> 3) + 0x7FFF8000) )
//    __asan_report_load4(ptr);

.text:00000000000012B0 48 8B 45 F8                   mov     rax, [rbp+ptr]
.text:00000000000012B4 48 89 C2                      mov     rdx, rax
.text:00000000000012B7 48 C1 EA 03                   shr     rdx, 3
.text:00000000000012BB 48 81 C2 00 80 FF 7F          add     rdx, 7FFF8000h
.text:00000000000012C2 0F B6 12                      movzx   edx, byte ptr [rdx]
.text:00000000000012C5 84 D2                         test    dl, dl
.text:00000000000012C7 40 0F 95 C6                   setnz   sil
.text:00000000000012CB 48 89 C1                      mov     rcx, rax
.text:00000000000012CE 83 E1 07                      and     ecx, 7
.text:00000000000012D1 83 C1 03                      add     ecx, 3
.text:00000000000012D4 38 D1                         cmp     cl, dl
.text:00000000000012D6 0F 9D C2                      setnl   dl
.text:00000000000012D9 21 F2                         and     edx, esi
.text:00000000000012DB 84 D2                         test    dl, dl
.text:00000000000012DD 74 08                         jz      short loc_12E7
.text:00000000000012DD
.text:00000000000012DF 48 89 C7                      mov     rdi, rax
.text:00000000000012E2 E8 F9 FD FF FF                call    ___asan_report_load4

简单的识别这段代码的意思就是:

  1. 首先检查这段内存是否可访问:*((ptr >> 3) + 0x7FFF8000) != 0
  2. 在获取接下来要操作的字节数:((ptr & 7) + 3)
  3. 在获取影子内存中标记的可用字节数:*((ptr >> 3) + 0x7FFF8000)
  4. 所以总的来说就是判断此次内存操作的字节数是否超过允许操作的字节数
被插入程序的Asan汇编代码的源码位置

源码位置:https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/asan/asan_rtl_x86_64.S

#include "asan_mapping.h"  // 包含 AddressSanitizer (ASan) 的内存映射定义
#include "sanitizer_common/sanitizer_asm.h"  // 包含常见的 Sanitizer 汇编工具

// 如果是 x86_64 架构
#if defined(__x86_64__)
#include "sanitizer_common/sanitizer_platform.h"  // 包含平台相关的工具

// 指定汇编文件名称
.file "asan_rtl_x86_64.S"

// 定义宏,用于生成函数、返回标签、检查标签、失败标签的名称
#define NAME(n, reg, op, s, i) n##_##op##_##i##_##s##_##reg

// FNAME 是用于生成 ASan 检查函数名称的宏
#define FNAME(reg, op, s, i) NAME(__asan_check, reg, op, s, i)
// RLABEL 是用于生成返回标签的宏
#define RLABEL(reg, op, s, i) NAME(.return, reg, op, s, i)
// CLABEL 是用于生成检查标签的宏
#define CLABEL(reg, op, s, i) NAME(.check, reg, op, s, i)
// FLABEL 是用于生成失败标签的宏
#define FLABEL(reg, op, s, i) NAME(.fail, reg, op, s, i)

// BEGINF 定义一个函数的开始,它会生成相应的代码段、全局标签、隐藏标签、函数类型和开始指令
#define BEGINF(reg, op, s, i) \
.section .text.FNAME(reg, op, s, i),"ax",@progbits ;\
.globl  FNAME(reg, op, s, i) ;\
.hidden  FNAME(reg, op, s, i) ;\
ASM_TYPE_FUNCTION(FNAME(reg, op, s, i)) ;\
.cfi_startproc ;\
FNAME(reg, op, s, i): ;\

// ENDF 宏定义了函数的结束,它标记了调试信息的结束
#define ENDF .cfi_endproc ;\

// ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD 用于初始化内存访问检查
#define ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, s) \
        mov    %##reg,%r10 ;\  // 将寄存器值移动到 r10
        shr    $0x3,%r10 ;\  // 将 r10 右移 3 位
        .if ASAN_SHADOW_OFFSET_CONST < 0x80000000   ;\
        movsbl ASAN_SHADOW_OFFSET_CONST(%r10),%r10d ;\  // 进行 1 字节符号扩展并加载
        .else                                       ;\
        movabsq $ASAN_SHADOW_OFFSET_CONST,%r11      ;\  // 将常量加载到 r11 中
        movsbl (%r10,%r11),%r10d                    ;\  // 进行内存访问,加载偏移量
        .endif                                      ;\
        test   %r10d,%r10d ;\  // 测试 r10d 是否为 0
        jne    CLABEL(reg, op, s, add) ;\  // 如果不为 0,跳转到检查标签
RLABEL(reg, op, s, add): ;\  // 返回标签
        retq  ;\  // 返回指令

// 额外的内存访问检查,用于 1 字节的访问
#define ASAN_MEMORY_ACCESS_EXTRA_CHECK_1(reg, op, i) \
CLABEL(reg, op, 1, i): ;\
        mov    %##reg,%r11 ;\  // 将 reg 的值移动到 r11
        and    $0x7,%r11d ;\  // r11d 与 0x7 进行按位与操作
        cmp    %r10d,%r11d ;\  // 比较 r10d 和 r11d
        jl     RLABEL(reg, op, 1, i);\  // 如果 r11d 小于 r10d,跳转到返回标签
        mov    %##reg,%rdi ;\  // 将 reg 移动到 rdi
        jmp    __asan_report_##op##1_asm ;\  // 跳转到 ASan 错误报告函数

// 额外的内存访问检查,用于 2 字节的访问
#define ASAN_MEMORY_ACCESS_EXTRA_CHECK_2(reg, op, i) \
CLABEL(reg, op, 2, i): ;\
        mov    %##reg,%r11 ;\
        and    $0x7,%r11d ;\
        add    $0x1,%r11d ;\  // r11d 加 1
        cmp    %r10d,%r11d ;\
        jl     RLABEL(reg, op, 2, i);\
        mov    %##reg,%rdi ;\
        jmp    __asan_report_##op##2_asm ;\  // 跳转到 2 字节的 ASan 错误报告

// 额外的内存访问检查,用于 4 字节的访问
#define ASAN_MEMORY_ACCESS_EXTRA_CHECK_4(reg, op, i) \
CLABEL(reg, op, 4, i): ;\
        mov    %##reg,%r11 ;\
        and    $0x7,%r11d ;\
        add    $0x3,%r11d ;\  // r11d 加 3
        cmp    %r10d,%r11d ;\
        jl     RLABEL(reg, op, 4, i);\
        mov    %##reg,%rdi ;\
        jmp    __asan_report_##op##4_asm ;\  // 跳转到 4 字节的 ASan 错误报告

// 定义 1 字节的加载和存储回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_1(reg, op) \
BEGINF(reg, op, 1, add) ;\
        ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, 1) ;\
        ASAN_MEMORY_ACCESS_EXTRA_CHECK_1(reg, op, add) ;\
ENDF

// 定义 2 字节的加载和存储回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_2(reg, op) \
BEGINF(reg, op, 2, add) ;\
        ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, 2) ;\
        ASAN_MEMORY_ACCESS_EXTRA_CHECK_2(reg, op, add) ;\
ENDF

// 定义 4 字节的加载和存储回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, op) \
BEGINF(reg, op, 4, add) ;\
        ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, 4) ;\
        ASAN_MEMORY_ACCESS_EXTRA_CHECK_4(reg, op, add) ;\
ENDF

// 定义 8 字节的内存访问检查函数,不需要额外的检查
#define ASAN_MEMORY_ACCESS_CHECK_ADD(reg, op, s, c) \
        mov    %##reg,%r10 ;\
        shr    $0x3,%r10 ;\
        .if ASAN_SHADOW_OFFSET_CONST < 0x80000000  ;\
        ##c    $0x0,ASAN_SHADOW_OFFSET_CONST(%r10) ;\
        .else                                      ;\
        movabsq $ASAN_SHADOW_OFFSET_CONST,%r11     ;\
        ##c    $0x0,(%r10,%r11)                    ;\
        .endif                                     ;\
        jne    FLABEL(reg, op, s, add) ;\  // 如果检查不通过,跳转到失败标签
        retq  ;\  // 返回指令

// 失败处理函数,用于内存访问失败的情况
#define ASAN_MEMORY_ACCESS_FAIL(reg, op, s, i) \
FLABEL(reg, op, s, i): ;\
        mov    %##reg,%rdi ;\  // 将 reg 移动到 rdi
        jmp    __asan_report_##op##s##_asm;\  // 跳转到 ASan 错误报告函数

// 定义 8 字节的回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_8(reg, op) \
BEGINF(reg, op, 8, add) ;\
        ASAN_MEMORY_ACCESS_CHECK_ADD(reg, op, 8, cmpb) ;\
        ASAN_MEMORY_ACCESS_FAIL(reg, op, 8, add) ;\
ENDF

// 定义 16 字节的回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_16(reg, op) \
BEGINF(reg, op, 16, add) ;\
        ASAN_MEMORY_ACCESS_CHECK_ADD(reg, op, 16, cmpw) ;\
        ASAN_MEMORY_ACCESS_FAIL(reg, op, 16, add) ;\
ENDF

// 定义所有类型的加载和存储回调函数,包括 1 字节、2 字节、4 字节、8 字节、16 字节
#define ASAN_MEMORY_ACCESS_CALLBACKS_ADD(reg) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_1(reg, load) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_1(reg, store) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_2(reg, load) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_2(reg, store) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, load) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, store) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_8(reg, load) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_8(reg, store) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_16(reg, load) \
ASAN_MEMORY_ACCESS_CALLBACK_ADD_16(reg, store) \

// 为除 R10 和 R11 外的所有寄存器实例化内存访问回调函数
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RAX)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RBX)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RCX)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RDX)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RSI)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RDI)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(RBP)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R8)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R9)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R12)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R13)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R14)
ASAN_MEMORY_ACCESS_CALLBACKS_ADD(R15)

#endif

// 指定不可执行堆栈
NO_EXEC_STACK_DIRECTIVE

大概总结以下上面的汇编代码,就是根据操作的内存字节数不同插入不同的Check汇编代码!
列举出来重要的就是:

  • ASAN_MEMORY_ACCESS_CALLBACK_ADD_1(reg, op)
    注释: 定义 1 字节的加载和存储回调函数。
  • ASAN_MEMORY_ACCESS_CALLBACK_ADD_2(reg, op)
    注释: 定义 2 字节的加载和存储回调函数。
  • ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, op)
    注释: 定义 4 字节的加载和存储回调函数。
  • ASAN_MEMORY_ACCESS_CHECK_ADD(reg, op, s, c)
    注释: 定义 8 字节的内存访问检查函数,不需要额外检查。

之前我举出的案例都是操作4个字节的案例,所以插入的汇编代码就是:ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, op)

按照伪代码来对比一下:

// 定义 4 字节的加载和存储回调函数
#define ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, op) \
BEGINF(reg, op, 4, add) ;\
        ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, 4) ;\
        ASAN_MEMORY_ACCESS_EXTRA_CHECK_4(reg, op, add) ;\
ENDF

对应完整的IDA伪代码就是:

if ( *((v3 >> 3) + 0x7FFF8000) != 0 && (((4 * i + arr) & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000) )
      __asan_report_store4(&arr[i]);
// ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD 用于初始化内存访问检查
#define ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD(reg, op, s) \
        mov    %##reg,%r10 ;\  // 将寄存器值移动到 r10
        shr    $0x3,%r10 ;\  // 将 r10 右移 3 位
        .if ASAN_SHADOW_OFFSET_CONST < 0x80000000   ;\
        movsbl ASAN_SHADOW_OFFSET_CONST(%r10),%r10d ;\  // 进行 1 字节符号扩展并加载
        .else                                       ;\
        movabsq $ASAN_SHADOW_OFFSET_CONST,%r11      ;\  // 将常量加载到 r11 中
        movsbl (%r10,%r11),%r10d                    ;\  // 进行内存访问,加载偏移量
        .endif                                      ;\
        test   %r10d,%r10d ;\  // 测试 r10d 是否为 0
        jne    CLABEL(reg, op, s, add) ;\  // 如果不为 0,跳转到检查标签
RLABEL(reg, op, s, add): ;\  // 返回标签
        retq  ;\  // 返回指令

对应的伪代码:*((v3 >> 3) + 0x7FFF8000) != 0

// 额外的内存访问检查,用于 4 字节的访问
#define ASAN_MEMORY_ACCESS_EXTRA_CHECK_4(reg, op, i) \
CLABEL(reg, op, 4, i): ;\
        mov    %##reg,%r11 ;\
        and    $0x7,%r11d ;\
        add    $0x3,%r11d ;\  // r11d 加 3
        cmp    %r10d,%r11d ;\
        jl     RLABEL(reg, op, 4, i);\
        mov    %##reg,%rdi ;\
        jmp    __asan_report_##op##4_asm ;\  // 跳转到 4 字节的 ASan 错误报告

对应的伪代码:

((4 * i + arr) & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000) 
      __asan_report_store4(&arr[i]);

通过汇编代码检测4字节的内存访问。主要步骤包括:

  1. 定义4字节内存操作的回调函数,通过 ASAN_MEMORY_ACCESS_CALLBACK_ADD_4(reg, op) 插入对应的检查代码。
  2. 使用 ASAN_MEMORY_ACCESS_INITIAL_CHECK_ADD 进行初始内存访问检查,判断地址是否存在越界问题。
  3. 通过 ASAN_MEMORY_ACCESS_EXTRA_CHECK_4 进行额外检查,确保4字节操作的安全性。
  4. 如果检测到内存越界,则调用 __asan_report_store4 报告错误。

对堆内存泄漏的检测机制

以asan模式编译的memory_leak.c,ida伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  void *v3; // rax
  int *leak; // [rsp+8h] [rbp-8h]

  v3 = malloc(4uLL);
  leak = v3;
  if ( *((v3 >> 3) + 0x7FFF8000) != 0 && ((v3 & 7) + 3) >= *((v3 >> 3) + 0x7FFF8000) )
    __asan_report_store4(v3);
  *leak = 42;
  return 0;
}

示例中,__asan_report_store4(v3) 是 AddressSanitizer (ASan) 进行的一个内存访问检测,但这段代码本身并不会直接导致内存泄漏的检测。ASan 专门负责检测内存访问的错误,例如越界访问、未初始化的内存访问、双重释放等。

然而,检测堆内存泄漏(如你的程序中分配了内存却未释放的情况),并不是 ASan 本身完成的,而是由 ASan 与 LeakSanitizer (LSan) 共同合作实现的。LSan 是专门用于检测内存泄漏的工具,通常与 ASan 一起使用。
比如在程序运行快结束的时候就会爆出错误:

[AFL++ 405757f4e3c5] ~/work/Asan/Asan_mode # ./memory_leak_asan 

=================================================================
==83191==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 4 byte(s) in 1 object(s) allocated from:
    #0 0x7f15f5897887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x56541800e1be in main /root/work/Asan/memory_leak.c:5
    #2 0x7f15f55e3d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)

SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).

我们可以通过字符串来搜索源码知道堆内存泄漏检查的位置!
下面是添加注释以后的源码:llvm-project/compiler-rt/lib/asan/asan_memory_profile.cpp at main · llvm/llvm-project (github.com)

void Print(uptr top_percent, uptr max_number_of_contexts) {
...
    // 打印有关当前堆分配情况的概览信息,包括正在使用的内存量、碎片和其他上下文的统计信息。
    // `top_percent`:表示显示的前百分比的上下文。
    // `max_number_of_contexts`:最多显示的唯一上下文数。

    // 使用Printf输出当前内存分配的概览信息
    Printf("Live Heap Allocations: %zd bytes in %zd chunks; quarantined: "
           "%zd bytes in %zd chunks; %zd other chunks; total chunks: %zd; "
           "showing top %zd%% (at most %zd unique contexts)\n",
           // `total_allocated_user_size_`:总的用户分配的内存大小(以字节为单位)
           total_allocated_user_size_,
           // `total_allocated_count_`:分配的块数
           total_allocated_count_,
           // `total_quarantined_user_size_`:已隔离内存的总大小
           total_quarantined_user_size_,
           // `total_quarantined_count_`:已隔离的内存块数
           total_quarantined_count_,
           // `total_other_count_`:其他类型的内存块数量
           total_other_count_,
           // 所有内存块的总数(包括正在使用的、已隔离的和其他块)
           total_allocated_count_ + total_quarantined_count_ + total_other_count_,
           // 打印的前百分比上下文,如:前20%
           top_percent,
           // 显示的最大唯一上下文数量
           max_number_of_contexts);

    // 遍历分配记录,根据`max_number_of_contexts`限制显示的上下文数量
    for (uptr i = 0; i < Min(allocations_.size(), max_number_of_contexts); i++) {
        // `a` 代表当前迭代中的内存分配记录
        auto &a = allocations_[i];

        // 输出每个上下文中的内存分配信息,包括内存大小、占用百分比和分配块数
        Printf("%zd byte(s) (%zd%%) in %zd allocation(s)\n", 
               // 当前上下文中分配的总字节数
               a.total_size,
               // 当前上下文的内存占总分配内存的百分比
               a.total_size * 100 / total_allocated_user_size_,
               // 当前上下文的分配块数
               a.count);
    }
...
  }

这段代码的作用就是在程序结束后来检查释放存在已经申请的堆块是否都被释放,如果没有全部释放就爆出内存泄漏的漏洞!

具体来说,以下是 LSan 如何检测到堆内存泄漏的过程:

  1. 内存分配:程序中通过 malloc 分配内存,如你的示例代码中的 v3 = malloc(4uLL)。此时,LSan 记录下每次内存分配的堆栈信息和分配的内存块大小。
  2. 程序结束时扫描内存:在程序结束时,LSan 会扫描程序中的所有活跃内存块(尚未被释放的内存),并根据之前记录的内存分配情况进行匹配。
  3. 检查未释放的内存块:如果某些分配的内存块未被释放(即调用 free 之前内存块仍处于“活跃”状态),LSan 会将其标记为“泄漏的内存”。在你的例子中,malloc 分配的内存块从未通过 free 释放,因此被视为“泄漏”。
  4. 报告内存泄漏:在程序结束时,LSan 输出一个内存泄漏报告,列出未释放的内存块的详细信息,包括:
    • 分配的字节数
    • 分配发生的函数以及源代码的行号(例如报错中提到的 main 函数和行号 #1 0x56541800e1be in main /root/work/Asan/memory_leak.c:5
    • 以及具体的调用堆栈。

5.对ASan的总结

ASan原理概述

  • ASan的工作的简单案例:通过Makefile定义了带有ASAN插桩和普通版本的内存错误检测程序的编译流程,展示了UAF漏洞和栈溢出漏洞爆出的案例及相应的编译命令。
    • 其他可以用的Asan模式编译选项:包括Address Sanitizer、Memory Sanitizer、UndefinedBehaviorSanitizer、Thread Sanitizer、Address Sanitizer with Leak Detection、Coverage Sanitizer、Kernel Address Sanitizer。
    • ASan的工作原理:ASAN通过编译时的插桩和运行时的动态检查检测内存相关错误,将数据区域分为可访问区域和不可访问区域(redzone),并引入影子内存来跟踪主内存的可访问性状态。
      堆的ASan Redzone标记机制解析
  • Redzone的设置和取消:当程序通过malloc分配内存时,ASan会在影子内存中标记该内存区域为可用,并设置两个redzone区域来防止越界访问;当内存被释放时,ASan将其放入隔离区,并毒化释放后的内存区域,标记为已释放。
    堆的ASan Check机制解析
  • 对堆内存溢出和uaf的检测机制:在对堆内存进行操作时会插入代码进行检测,通过判断可操作的字节数与操作的字节数来检测内存越界,影子内存用于知道一片空间有几个字节可以用,汇编插桩机制用于知道此次内存操作了几个字节,被插入程序的Asan汇编代码的源码位置在https://github.com/llvm/llvm - project/blob/main/compiler - rt/lib/asan/asan_rtl_x86_64.S。
  • 对堆内存泄漏的检测机制:检测堆内存泄漏由ASan与LeakSanitizer(LSan)共同合作实现,LSan在程序结束时扫描内存,检查未释放的内存块并报告内存泄漏。

6. 参考文献

相关资料:

0 条评论
某人
表情
可输入 255