加固 C/C++ 程序
在这个文章的开头部分,我们先讨论一下编码的机制,加固的概念,尤其是栈的加固技巧。这里解释的一些概念也将在下面的部分中使用到,所以你可能至少要阅读前几段。
可执行区域保护
如第一部分所述,渗透常常会通过向程序中注入代码的方法,来使数据结构溢出,比如char 寄存器。接下来执行的代码就会跳转到攻击者可以进一步使用利用的内存位置,例如请求ROOT权限的shell脚本,(因此常称作“shell code”)。注意,即使代码是保存在程序的数据内存空间中(栈或是堆),它仍然可以被执行。可执行区域保护通过将这些内存页标记为需要它的可执行文件来更改此操作。这在大多数现代处理器和大多数操作系统上都有支持。不同厂家的名称各不相同,但基本概念保持不变。
尤其是Linux系统中,解决的措施如下:
(1)当程序被加载到内存中,只有这些内存页中的代码才允许执行。加载器会识别ELF头部。
这些部分包含了一下代码:
1) .init 和 .fini:这是在初始化和清除进程是运行的代码;
2) .plt 和.plt.got: 用于访问位于其他共享库中的函数的Trampoline代码;
3) .text: 其他的,也就是程序中实现功能的代码;
(2)堆没有执行权限。
(3)ELF文件的可执行源代码和共享库也包含了GNU_STACK程序头部,也就意味着有执行栈内存页面的权限。默认情况下,不会设置执行标志,而堆栈只具有读/写权限。
有三个例外:
1)当链接到一个可执行文件或共享库,-z execstack连接器会生成一个明确的标志;
2) 至少有一个目标文件是由汇编程序生成的。在这种情况下,还不知道堆栈是否可以在没有执行权限的情况下进行映射。需要一个明确的声 明: .section .note.GNU-stack,"",@progbits;
3) 当你使用嵌套函数,有一个GNU C扩展(在GNU C++不可用);
可执行区域保护是很重要,幸运的是许多漏洞已经被处理了。接下来要做的是正确的gnu_stack设置,因此我们将重点放在这。
Linux使用最简化的GNU_STACK, 这就意味着你的共享库中的程序块只要有一个被标记为需要一个可执行栈,那么整个程序都会以这样的方式运行。即使是 dlopen()库也是一样的--内核将在运行时更改权限。!
从安全的角度,这样会很不方便:你必须非常小心,不能在配置中将库错误设置。不涉及汇编源程序情况下,当你自己把所有东西都建好,那么应该就没问题。可以从分布服务器加载的共享库应该始终正确配置此标志。也就是说,我建议做两个测试:
1. 对于所有的可执行代码和共享库,检查他们的输出readelf -l ,这gnu_stack入口只有RW设置标志。应该像这样的:
$ readelf -l libm.so.6
...
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
...
它不应该是这样(注意E(执行)现在出现在这个标志中)
$ readelf -l unprotected.so
...
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RWE 10
...
2. 在启动你的程序时可以测试一下,然后导入所有的动态链接库。检查在/proc/PID/maps(其中pid由运行程序的数字进程ID替换)路径下,[stack]是否正确映射。就如上面所提到的,此测试将排除某些库在运行时更改堆栈权限。运行的过程应该是这样:
$ cat /proc/11684/maps
...
7ffe3401a000-7ffe3403c000 rw-p 00000000 00:00 0 [stack]
...
而不是这样(注意:x(执行)现在存在):
$ cat /proc/11687/maps
...
7ffea187d000-7ffea189d000 rwxp 00000000 00:00 0 [stack]
...
注意:在JIT编码器中存在一个漏洞,需要在运行时生成代码。编程人员通常会注意将代码映射到只写/不可执行得内存空间,然后切换到只执行/写保护的内存空间。但一旦能够成功诱导JIT编译器生成代码时,这种做法也就失效了。
随机地址分配机制
通过预防可执行代码被注入到程序中,可以消除很大部分的攻击媒介。但是假如一些对攻击者十分有用的代码已经保存在程序中了要怎么办?例如,所有的程序都链接到libc中,这样可以通过调用system()来启动SHELL。攻击者只要能覆盖栈指针中的函数返回地址,让指针指向system()的地址。这是内存地址布局随机化的本质:分为许多内存映射尽可能使系统无法预知的。在现代Linux系统中,这些区域受到影响:
主程序中可执行的代码
共享库的代码
堆和栈
mmap_base
VDSO 文件
部分内核本身
ASLR是在Linux内核中用到的。一般是在64位系统中使用,因为在可用的地址空间中随机保存会使地址范围大得多,极大地降低了遍历攻击的机会。如果拥有ROOT权限可以禁用ASLR:
# echo 0 > /proc/sys/kernel/randomize_va_space
这对调试来说很有用,但是它不应该被永远禁用。. gdb本身会默认禁用ASLR,让接下来的调试对话框中得地址都是恒定的。
堆和栈的随机分布是自动的,所以在当前这样的系统不需要执行什么命令。 为了能够在随机地址中加载主可执行文件和共享库,它们的代码必须是位置无关的。这实现如下:
共享库
使用-fpic 或-fPIC编译。-fpic在一些结构中是使用一个范围有限的GOT(全局偏置表)。 当地址溢出时,连接器就会提醒你。这时你就要转换到-fPIC ,这可能会有一些开销。在X86系统中,这两者是没差别的;
链接到-shared,指定和编译期间使用的相同选项(-fpicor-fPIC).;
只要有一个文件不是以 -fpic/-fPIC生成的,链接就会失败。共享库必须是与地址无关,否则,不同库的指针就可能会指向相同的内存地址,产生冲突。
可执行文件
使用 -fpieor-fPIE编译( 和编译共享库相同的区别,见上面 );
链接到-shared,指定和编译期间使用的相同选项(-fpicor-fPIC).;
不幸的是,在一些比较热门的编译系统中,为链接到可执行文件或共享库中的源代码设置不同的编译选项是很麻烦的。所以,你在编译时还是会用-fpic/ -fPIC。唯一不同的是符号将会被覆盖,但这只会在你使用LD_PRELOAD,会有影响,因为只有这些程序会在主函数之前被加载。在至少有一个GCC编译器的情况下也推荐这样做。
下面是一个程序链接到共享库的完整例子,地址无关代码:
main.cpp
double pi();
int main()
{
return (int)pi();
}
shared.cpp
double pi()
{
return 3.14f;
}
Building with full ASLR support:
# Build and link shared library
$ g++ -c -fPIC shared.cpp -o shared.o
$ g++ -shared -fPIC shared.o -o libshared.so
# Build and link main executable
$ g++ -c -fPIE main.cpp -o main.o
$ g++ -pie -fPIE main.o -L. -lshared -o main
main 是一个可执行文件, 但因为完全被重定向了,所以它生成的文件和共享库一样。
$ file mainmain: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 3.0.0, BuildID[sha1]=9fabc139c49f30
为了对比, 没有-pie选项:
$ g++ main.o -L. -lshared -o main_no_pie$ file main_no_piemain_no_pie: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 3.0.0, BuildID[sha1]=8d7792
这有两篇著名的对ASLR攻击论文
Jump over ASLR: Attacking branch predictors to bypass ASLR
ASLR⊕Cache
也就是说,这些攻击比攻击未受保护的二进制文件要付出更多代价。ASLR的开销非常低,所以没有理由不采用。
有了gcc7,就多加了一个-static-pie 选项。这样的可执行文件,不依赖于其他共享库,并可以在任意地址加载。
本文由看雪翻译小组 南极小虾 编译,来源fireeye@Nick Harbour
转载请注明来自看雪社区
热门阅读
点击阅读原文/read,
更多干货等着你~