专栏名称: 谷歌开发者
Google中国官方账号。汇集Android, Chrome, Angular等移动和网络开发技术、Google Play和AdMob等开发者成长、优化和变现平台。
目录
相关文章推荐
新浪科技  ·  【#再见爱人4收获各大榜单热搜超100个#】 ... ·  5 天前  
新浪科技  ·  【#新能源车换车还是换电池# ... ·  6 天前  
51好读  ›  专栏  ›  谷歌开发者

Android 中的 FORTIFY

谷歌开发者  · 公众号  · 科技媒体  · 2017-04-26 16:05

正文


FORTIFY 是 Android 自 2012 年中以来一直配备的一项重要的安全功能。去年初,在将默认的 C/C++ 编译器从 GCC 迁移为 Clang 后,我们投入大量时间和精力,确保 FORTIFY 在 Clang 中的质量与之前相当。为做到这一点,我们重新设计了某些关键的 FORTIFY 功能的工作方式,具体将在下文介绍。

在我们介绍全新 FORTIFY 的一些详情之前,我们来简单回顾一下 FORTIFY 的功能及其用法。



什么是 FORTIFY?

FORTIFY 是 C 标准库的扩展集,用于拦截对 memset、sprintf 和 open 和其他标准函数的错误使用。它有三大功能:

  • 如果在编译时 FORTIFY 检测到错误调用标准库函数,则在错误得到修复前将不允许编译您的代码。

  • 如果 FORTIFY 未获取足够的信息,或者如果确定代码是安全的,FORTIFY 将不会对任何信息进行编译。这意味着,当 FORTIFY 在找不到错误的上下文中使用时,它的运行时开销为 0。

  • 否则,FORTIFY 会进一步进行检查,动态确定可疑代码是否存在错误。如果检测到错误,FORTIFY 将输出部分调试信息并中止程序运行。


思考下例,它是 FORTIFY 在真实代码中捕获的一个错误:

struct Foo {
    int val;
    struct Foo *next;
};
void initFoo(struct Foo *f) {
    memset(&f, 0, sizeof(struct Foo));
}


FORTIFY 发现,我们错误地将 &f 作为 memset 的第一个参数进行传递,而实际上应为 f。通常,很难追踪此类错误,因为它表面上可能将 8 个字节的附加 0 写入任意的堆叠部分,而实际上对 *f 不进行任何操作。因此,取决于您的编译器优化设置、initFoo 的用法和您的项目的测试标准,此错误可能长时间被忽略。有了 FORTIFY,您会收到如下编译时错误:

/path/to/file.c: call to unavailable function 'memset': memset called with size bigger than buffer
    memset(&f, 0, sizeof(struct Foo));
    ^~~~~~


以下列函数为例,说明如何进行运行时检查:

// 2147483648 == pow(2, 31). Use sizeof so we get the nul terminator,
// as well.
#define MAX_INT_STR_SIZE sizeof("2147483648")
struct IntAsStr {
    char asStr[MAX_INT_STR_SIZE];
    int num;
};
void initAsStr(struct IntAsStr *ias) {
    sprintf(ias->asStr, "%d", ias->num);
}


此代码适用于所有正数。但是,当您传入 num <= -1000000 的 IntAsStr 时,sprintf 会将 MAX_INT_STR_SIZE+1 个字节写入到 ias->asStr 中。如果不使用 FORTIFY,此差一错误(结果会清除 num 中的一个字节)可能被静默忽略。而有了它,程序可以输出堆叠追踪信息、内存映射,并在中止运行时转储内核信息。

FORTIFY 还可以执行其他几项检查,例如确保对 open 的调用具有适当的参数,而它的主要用途是捕获上述与内存有关的错误。


但是,FORTIFY 并不能捕获当前与内存有关的 所有 错误。以下列代码为例:

__attribute__((noinline)) // Tell the compiler to never inline this function.
inline void intToStr(int i, char *asStr) { sprintf(asStr, “%d”, num); }


char *intToDupedStr(int i) {
    const int MAX_INT_STR_SIZE = sizeof(“2147483648”);
    char buf[MAX_INT_STR_SIZE];
    intToStr(i, buf);
    return strdup(buf);
}


由于 FORTIFY 根据缓冲区的类型及其分配位置(如果可见)确定缓冲区的大小,因此它无法捕获此错误。在本例中,FORTIFY 放弃检测是因为:

  • 我们无法确定此类指针指向的对象大小,因为 char * 可以指向不定数量的字节

  • FORTIFY 无法确定指针分配的位置,因为 asStr 可以指向任何对象。


如果您想知道为什么这里有非内联属性,那是因为,如果 intToStr 内联到 intToDupedStr 中,FORTIFY 可能可以捕获此错误。这是因为,编译器可以因此确定 asStr 指向同一个内存作为缓冲区,这是一个 sizeof(buf) 字节大小的内存区。



FORTIFY 的工作方式

FORTIFY 的工作原理是:在编译时截获对标准库函数的所有直接调用,然后将这些调用重定向至经过 FORTIFY 处理的特殊版本的上述库函数。每个库函数由发出运行时诊断的部分和发出编译时诊断的部分(如果适用)组成。下面是一个简化的示例,说明经过 FORTIFY 处理的 memset(源自 string.h)的运行时部分。实际的 FORTIFY 实现可能包含几个附加的优化或检查部分。


_FORTIFY_FUNCTION
inline void *memset(void *dest, int ch, size_t count) {
    size_t dest_size = __builtin_object_size(dest);
    if (dest_size == (size_t)-1)
        return __memset_real(dest, ch, count);
    return __memset_chk(dest, ch, count, dest_size);
}


在本例中:

  • _FORTIFY_FUNCTION 扩展到几个特定于编译器的属性,使对 memset 的所有直接调用均调用此特殊包装器。

  • __memset_real 用于跳过 FORTIFY,以调用“正常的”memset 函数。

  • __memset_chk 是经过 FORTIFY 处理的特殊 memset。如果 count > dest_size,__memset_chk 中止程序运行。否则,它会一直调用到 __memset_real 为止。

  • 因为 __builtin_object_size,奇迹发生了:它很像 size sizeof,但是它不会告诉您某个类型的大小,而是在编译时尝试计算出给定指针包含的字节数量。如果失败,它会返回 (size_t)-1。


__builtin_object_size 可能看上去比较粗略。编译器究竟如何能计算出某个未知指针指向多少个字节?其实……它不能。:)因此,_FORTIFY_FUNCTION 必须内联所有这些函数:内联 memset 调用也许可以使指针指向的分配(例如,本地变量和 malloc 的调用结果等等)可见。如果它可见,我们通常可以确定准确的 __builtin_object_size 结果。

编译时诊断位同样主要围绕 __builtin_object_size 进行。事实上,如果您的编译器能通过某种方式发出是否可以证明某个表达式为 true 的诊断,则您可以将此诊断添加到包装器中。利用特定于编译器的属性,在 GCC 和 Clang 上均可实现这一点,因此,添加诊断与添加正确的属性一样简单。



为什么不使用 Sanitize 呢?

如果您熟悉 C/C++ 内存检查工具的话,您可能在想,既然有了 Clang 的 AddressSanitizer 等工具,FORTIFY 还有何用处。Sanitizer 非常适用于捕获和跟踪与内存有关的错误,而且可以捕获许多 FORTIFY 所无法捕获的问题,但我们建议使用 FORTIFY,原因有两个:

  • 除了在代码运行时检查其是否存在错误以外,FORTIFY 还可以引发明显的编译时代码错误,而 Sanitizer 在出现问题时仅仅中止程序运行。尽管人们公认应尽早捕获问题,但我们还希望尽我们所能提供编译时错误。

  • FORTIFY 非常轻便,可应用于生产环境。对我们的代码启用 FORTIFY 后发现,最高 CPU 性能下降约 1.5%(平均下降 0.1%),几乎不产生内存开销,且二进制文件大小增幅很小。与之对比的是,Sanitizer 可能使代码运行速度下降一半以上,而且往往消耗大量的内存和存储空间。


有鉴于此,我们在 Android 生产版本中启用 FORTIFY,以减轻某些错误可能造成的损失。尤其是,FORTIFY 可以将潜在的远程代码执行错误转化为仅中止受破坏的应用的错误。重申一遍,Sanitizer 可以检测的错误数量超过 FORTIFY,因此我们绝对支持在开发/调试版本中使用 Sanitizer。但是,对提供给用户的二进制文件运行 Sanitizer,其成本过高,不宜在生产版本中启用它。



FORTIFY 重新设计

FORTIFY 的初始实现用到了 C89 中的几个技巧,并引入几个特定于 GCC 的属性和语言扩展。由于 Clang 无法模拟 GCC 的运行方式,无法完全支持初始 FORTIFY 实现,我们对 FORTIFY 进行了很大幅度的重新设计,使其在 Clang 中也能尽可能高效。特别是,Clang 风格的 FORTIFY 实现不仅支持某些函数重载(如果您使用 overloadable 属性,Clang 可以非常顺利地对 C 函数应用 C++ 重载规则),还可以充分利用一些特定于 Clang 的属性和语言扩展。

我们使用这一新的 FORTIFY 对数以亿计的代码行进行了测试,包括所有 Android 代码、所有 Chrome 操作系统代码(需要自行重新实现 FORTIFY)、我们的内部代码库以及许多常见的开放源代码项目。

此项测试表明,我们的方法破坏代码的方式层出不穷,比如:

template 
bool writeOutputFile(OpenFunc &&openFile, const char *data, size_t len) {}

bool writeOutputFile(const char *data, int len) {
    // Error: Can’t deduce type for the newly-overloaded `open` function.
    return writeOutputFile(&::open, data, len);
}


struct Foo { void *(*fn)(void *, const void *, size_t); }
void runFoo(struct Foo f) {
    // Error: Which overload of memcpy do we want to take the address of?
    if (f.fn == memcpy) {
        return;
    }
    // [snip]
}


还有一个开放源代码项目曾试图解析系统头文件(比如 stdio.h),以确定它包含哪些函数。添加 Clang FORTIFY 位会严重干扰解析器运行,导致其编译失败。


尽管作出上述大幅变更,但我们看到,破坏代码的情况非常少。例如,在编译 Chrome 操作系统时,不到 2% 的程序包出现轻微的编译时错误,只需修复几个文件即可解决这些错误。尽管能做到这一点已经“相当不错”,但它还不够理想,因此我们对方法进行了优化,进一步减少不兼容情况的发生。尽管其中部分迭代需要变更 Clang 的工作方式,但 Clang+LLVM 社区非常认同我们提议的调整和补充并予以大力支持:

  • 在 Clang 中添加 pass_object_size、

  • 在 Clang 中添加 alloc_size(在 LLVM 中添加对应的函数),以及

  • 其他各种增强/改进功能,例如启用在重载解析期间转化与 C 不兼容的指针。


最近,我们将它推广应用于 AOSP,且从 Android O 开始,Android 平台将受到 Clang FORTIFY 的保护。我们仍在对 NDK 进行最后的改进,开发者应该不久就会看到升级后的 FORTIFY 实现。此外,如前文所述,Chrome 操作系统现在也采用类似的 FORTIFY 实现,我们希望在接下来的几个月与开放源代码社区合作,将类似的实现* 引入 GNU C 库 glibc 中。


了解更多细节,查看文内所有链接,请点击文末“阅读原文”。


推荐阅读:

Android Studio 2.4 Preview 6发布,支持Java 8语言功能

Android O 中对设备标识符所做的变更

介绍Android原生开发工具包r14

TensorFlow使用者有奖征集


点击「阅读原文」,查看文内链接