How Sanitizer Interceptor Works

本文分析了 sanitizer 是如何做到替换 malloc, free, memcpy 这些库函数的实现的。即 sanitizer 中的 interceptor 机制。

我们在前面的文章中提到,所有的 Sanitizer 都由编译时插桩 (compile-time instrumentation) 和运行时库 (run-time library) 两部分组成。

那么 sanitizer 的运行时库中做了哪些事情呢?

以 ASan 为例:

ASan 运行时库中实际上不止替换了 malloc/free, new/delete 的函数实现,还替换了非常多的库函数的实现,如:memcpy, memmove, strcpy, strcat, pthread_create 等。

那么 sanitizer 是如何做到替换 malloc, free, memcpy 这些函数实现的呢?答案就是 sanitizer 中的 interceptor 机制。

本文以 ASan 为例,分析在 Linux x86_64 环境下 sanitizer interceptor 的实现原理。

Symbol interposition

在讲解 sanitizer interceptor 的实现原理之前,我们先来了解一下前置知识:symbol interposition。

首先我们考虑这样一个问题:如何在我们的应用程序中替换 libc 的 malloc 实现为我们自己实现的版本?

  1. 一个最简单的方式就是在我们的应用程序中定义一个同名的 malloc 函数

  2. 还有一种方式就是将我们的 malloc 函数实现在 libmymalloc.so 中,然后在运行我们的应用程序之前设置环境变量 LD_PRELOAD=/path/to/libmymalloc.so

那么为什么上述两种方式能生效呢?答案是 symbol interposition。

ELF specfication 在第五章 Program Loading and Dynamic Linking 中提到:

When resolving symbolic references, the dynamic linker examines the symbol tables with a breadth-first search. That is, it first looks at the symbol table of the executable program itself, then at the symbol tables of the DT_NEEDED entries (in order), and then at the second level DT_NEEDED entries, and so on.

动态链接器 (dynamic linker/loader) 在符号引用绑定 (binding symbol references) 时,以一种广度优先搜索的顺序来查找符号:executable, needed0.so, needed1.so, needed2.so, needed0_of_needed0.so, needed1_of_needed0.so, …

如果设置了 LD_PRELOAD,那么查找符号的顺序会变为:executable, preload0.so, preload1.so needed0.so, needed1.so, needed2.so, needed0_of_needed0.so, needed1_of_needed0.so, …

如果一个符号在多个组件(executable 或 shared object)中都存在定义,那么动态链接器会选择它所看到的第一个定义。

我们通过一个例子来理解该过程:

$ cat main.c
extern int W(), X();

int main() { return (W() + X()); }

$ cat W.c
extern int b();

int a() { return (1); }
int W() { return (a() - b()); }

$ cat w.c
int b() { return (2); }

$ cat X.c
extern int b();

int a() { return (3); }
int X() { return (a() - b()); }

$ cat x.c
int b() { return (4); }

$ gcc -o libw.so -shared w.c
$ gcc -o libW.so -shared W.c -L. -lw -Wl,-rpath=.
$ gcc -o libx.so -shared x.c
$ gcc -o libX.so -shared X.c -L. -lx -Wl,-rpath=.
$ gcc -o test-symbind main.c -L. -lW -lX -Wl,-rpath=.

该例子中可执行文件与动态库之间的依赖关系如下图所示:

按照我们前面所说,本例中动态链接器在进行符号引用绑定时,是按照广度优先搜索的顺序,即:test-symbind, libW.so, libX.so, libc.so, libw.so, libx.so 的顺序查找符号定义的。

动态链接器提供了环境变量 LD_DEBUG 来输出一些调试信息,我们可以通过设置环境变量 LD_DEBUG=“symbols:bindings” 看下 test-symbind 的 symbol binding 的过程:

$ LD_DEBUG="symbols:bindings" ./test-symbind
   1884890:        symbol=a;  lookup in file=./test-symbind [0]
   1884890:        symbol=a;  lookup in file=./libW.so [0]
   1884890:        binding file ./libW.so [0] to ./libW.so [0]: normal symbol `a'
   1884890:        symbol=b;  lookup in file=./test-symbind [0]
   1884890:        symbol=b;  lookup in file=./libW.so [0]
   1884890:        symbol=b;  lookup in file=./libX.so [0]
   1884890:        symbol=b;  lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
   1884890:        symbol=b;  lookup in file=./libw.so [0]
   1884890:        binding file ./libW.so [0] to ./libw.so [0]: normal symbol `b'
   1884890:        symbol=a;  lookup in file=./test-symbind [0]
   1884890:        symbol=a;  lookup in file=./libW.so [0]
   1884890:        binding file ./libX.so [0] to ./libW.so [0]: normal symbol `a'
   1884890:        symbol=b;  lookup in file=./test-symbind [0]
   1884890:        symbol=b;  lookup in file=./libW.so [0]
   1884890:        symbol=b;  lookup in file=./libX.so [0]
   1884890:        symbol=b;  lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
   1884890:        symbol=b;  lookup in file=./libw.so [0]
   1884890:        binding file ./libX.so [0] to ./libw.so [0]: normal symbol `b'

这样我们就理解为什么上述两种替换 malloc 的方式能生效了:


实际上 sanitizer 对于 malloc/free 等库函数的替换正是利用了 symbol interposition 这一特性。下面我们以 ASan 为例来验证一下。

考虑如下代码:

// test.cpp
#include <iostream>
int main() {
    std::cout << "Hello AddressSanitizer!\n";
}

我们首先看下 GCC 的行为。

使用 GCC 开启 ASan 编译 test.cpp ,g++ -fsanitize=address test.cpp -o test-gcc-asan 得到编译产物 test-gcc-asan。因为 GCC 默认会动态链接 ASan 运行时库,所以我们可以使用 objdump -p test-gcc-asan | grep NEEDED 查看 test-gcc-asan 依赖的动态库 (shared objects):

$ objdump -p test-gcc-asan | grep NEEDED
  NEEDED               libasan.so.5
  NEEDED               libstdc++.so.6
  NEEDED               libm.so.6
  NEEDED               libgcc_s.so.1
  NEEDED               libc.so.6

可以清楚的看到在 test-gcc-asan 依赖的动态库中 libasan.so 的顺序是在 libc.so 之前的。实际上链接时参数 -fsanitize=address 会使得 libasan.so 成为程序的第一个依赖库。

然后我们再通过环境变量 LD_DEBUG 看下 test-gcc-asan 的 symbol bindding 的过程:

$ LD_DEBUG="bindings" ./test-gcc-asan
   3309213:        binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]
   3309213:        binding file /lib64/ld-linux-x86-64.so.2 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]
   3309213:        binding file /usr/lib/x86_64-linux-gnu/libstdc++.so.6 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]

可以看到动态链接器将 libc.so, ld-linux-x86-64.so 和 libstdc++.so 中对 malloc 的引用都绑定到了 libasan.so 中的 malloc 实现。


下面我们看下 Clang,因为 Clang 默认是静态链接 ASan 运行时库,所以我们就不看 test-clang-asan 所依赖的动态库了,直接看 symbol binding 的过程:

$ clang++ -fsanitize=address test.cpp -o test-clang-asan
$ LD_DEBUG="bindings" ./test-clang-asan
   3313022:        binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]
   3313022:        binding file /lib64/ld-linux-x86-64.so.2 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]
   3313022:        binding file /usr/lib/x86_64-linux-gnu/libstdc++.so.6 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]

同样可以看到动态链接器将 libc.so, ld-linux-x86-64.so.2 和 libstdc++.so 中对 malloc 的引用都绑定到了 test-clang-asan 中的 malloc 实现(因为 ASan 运行时库 中实现了 malloc,并且 clang 将 ASan 运行时库静态链接到 test-clang-asan 中)。

Sanitizer interceptor

下面我们来在源码的角度,学习下 sanitizer interceptor 的实现。

阅读学习 LLVM 代码的一个非常有效的方式就是结合对应的测试代码来学习。

Sanitizer interceptor 存在一个测试文件 interception_linux_test.cpp,llvm-project/interception_linux_test.cpp at main · llvm/llvm-project · GitHub

#include "interception/interception.h"
#include "gtest/gtest.h"

static int InterceptorFunctionCalled;

DECLARE_REAL(int, isdigit, int);

INTERCEPTOR(int, isdigit, int d) {
  ++InterceptorFunctionCalled;
  return d >= '0' && d <= '9';
}

namespace __interception {

TEST(Interception, Basic) {
  EXPECT_TRUE(INTERCEPT_FUNCTION(isdigit));

  // After interception, the counter should be incremented.
  InterceptorFunctionCalled = 0;
  EXPECT_NE(0, isdigit('1'));
  EXPECT_EQ(1, InterceptorFunctionCalled);
  EXPECT_EQ(0, isdigit('a'));
  EXPECT_EQ(2, InterceptorFunctionCalled);

  // Calling the REAL function should not affect the counter.
  InterceptorFunctionCalled = 0;
  EXPECT_NE(0, REAL(isdigit)('1'));
  EXPECT_EQ(0, REAL(isdigit)('a'));
  EXPECT_EQ(0, InterceptorFunctionCalled);
}

}  // namespace __interception

这段测试代码基于 sanitizer 的 interceptor 机制替换了 isdigit 函数的实现,在我们实现的 isdigit 函数中,每次 isdigit 函数被调用时都将变量 InterceptorFunctionCalled 自增 1。然后通过检验变量 InterceptorFunctionCalled 的值来测试 interceptor 机制的实现是否正确,通过 REAL(isdigit) 来调用真正的 isdigit 函数实现。

上述测试文件 interception_linux_test.cpp 中实现替换 isdigit 函数的核心部分是如下代码片段:

INTERCEPTOR(int, isdigit, int d) {
  ++InterceptorFunctionCalled;
  return d >= '0' && d <= '9';
}

INTERCEPT_FUNCTION(isdigit);

DECLARE_REAL(int, isdigit, int);
REAL(isdigit)('1');

这部分代码在宏展开后的内容如下:

// INTERCEPTOR(int, isdigit, int d) 宏展开
typedef int (*isdigit_type)(int d);
namespace __interception { isdigit_type real_isdigit; }
extern "C" int isdigit(int d) __attribute__((weak, alias("__interceptor_isdigit"), visibility("default")));
extern "C" __attribute__((visibility("default"))) int __interceptor_isdigit(int d) {
  ++InterceptorFunctionCalled;
  return d >= '0' && d <= '9';
}

// INTERCEPT_FUNCTION(isdigit) 宏展开
::__interception::InterceptFunction(
    "isdigit",
    (::__interception::uptr *) & __interception::real_isdigit,
    (::__interception::uptr) & (isdigit),
    (::__interception::uptr) & __interceptor_isdigit);

// DECLARE_REAL(int, isdigit, int) 宏展开
typedef int (*isdigit_type)(int);
namespace __interception { extern isdigit_type real_isdigit; };

// REAL(isdigit)('1') 宏展开
__interception::real_isdigit('1');

P.S.

__attribute__((alias)) 很有意思:

Where a function is defined in the current translation unit, the alias call is replaced by a call to the function, and the alias is emitted alongside the original name. Where a function is not defined in the current translation unit, the alias call is replaced by a call to the real function. Where a function is defined as static, the function name is replaced by the alias name and the function is declared external if the alias name is declared external.

在 ASan runtime library 中 malloc 是 weak 符号,并且 malloc 和 __interceptor_malloc 实际指向同一个地址。

也就是说 extern "C" void *malloc(size_t size) __attribute__((weak, alias("__interceptor_malloc"), visibility("default"))); 使得在 ASan runtime library 中造了一个弱符号 malloc,然后指向的和 __interceptor_malloc 是同一个地址。

$ readelf -sW --dyn-syms $(clang -print-file-name=libclang_rt.asan-x86_64.a) | grep malloc
  ...
  99: 0000000000001150   606 FUNC    GLOBAL DEFAULT    3 __interceptor_malloc
  102: 0000000000001150   606 FUNC    WEAK   DEFAULT    3 malloc

$ readelf -sW --dyn-syms $(clang -print-file-name=libclang_rt.asan-x86_64.so) | grep malloc
  ...
  3008: 00000000000fd600   606 FUNC    WEAK   DEFAULT   12 malloc
  4519: 00000000000fd600   606 FUNC    GLOBAL DEFAULT   12 __interceptor_malloc

P.S.2

熟悉在 Linux 下 sanitizer interceptor 机制的底层原理后,就很容易明白使用 sanitizer 时遇到的一些问题或坑为什么会是这样的。例如:

References

  1. ELF interposition and -Bsymbolic | MaskRay

  2. dlsym(3) - Linux manual pagedlsym(3) - Linux manual page

  3. asan/tsan: weak interceptors · llvm/llvm-project@7fb7330 · GitHub