Linux ELF文件的保护主要有四种:Canary、NX、PIE、RELRO

1.Canary

StackCanary表示栈的报警保护
在函数返回值之前添加的一串随机数(不超过机器字长)(也叫做cookie),末位为/x00(提供了覆盖最后一字节输出泄露Canary的可能),如果出现缓冲区溢出攻
击,覆盖内容覆盖到Canary处,就会改变原本该处的数值,当程序执行到此处时,会检查Canary值是否跟开始的值一样,如果不一样,程序会崩溃,从而达到保护返回地址的目的。
Pasted image 20250214194346

//GCC用法
gcc -o test test.c // 默认情况下,不开启Canary保护
gcc -fno-stack-protector -o test test.c //禁用栈保护
gcc -fstack-protector -o test test.c //启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码
gcc -fstack-protector-all -o test test.c //启用堆栈保护,为所有函数插入保护代码
-fno-stack-protector /-fstack-protector / -fstack-protector-all (关闭 / 开启 / 全开启)

2.DEP(DataExecutionPrevention)/NX(Non-executable)防护

NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。
正常在栈溢出时通过跳转指令跳转至shellcode,但是NX开启后CPU会对数据区域进行检查,当发现正常程序不执行,并跳转至其他地址后会抛出异常,接下来不会继续执行shellcode,而是去转入异常处理,处理后会禁止shellcode继续执行

gcc -o test test.c // 默认情况下,开启NX保护
gcc -z execstack -o test test.c // 禁用NX保护
gcc -z noexecstack -o test test.c // 开启NX保护
-z execstack / -z noexecstack (关闭 / 开启)

该防护的作用简单的说就是能写的地方不能执行,能执行的地方不能写。
shellcode 所填充的位置是在栈上,但是开启了 DEP 保护后栈上就没有执行的权限也就无法控制程序流程。

#undef _FORTIFY_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}

int main(int argc, char** argv) {
vulnerable_function();
write(STDOUT_FILENO, "Hello, World\n", 13);
}

不开启 DEP 保护编译:

gcc -fno-stack-protector -z execstack -o ret2lib ret2lib.c

查看权限命令:
cat /proc/[pid]/maps

程序运行在后台命令:
./ret2lib &

权限如下:Pasted image 20250125140328开启 DEP 保护编译:
gcc -fno-stack-protector -o ret2lib ret2lib.c

权限如下:
Pasted image 20250125140357
可以看到在开启DEP防护的情况下栈上面就没有执行权限了。

3.PIE(ASLR)

一般情况下NX(Windows平台上称为DEP)和地址空间分布随机化(ASLR)会同时工作。内存地址随机化机制有三种情况

0 - 表示关闭进程地址随机化
1 - 表示将mmap的基地址,栈基地址和.so地址随机化
2 - 表示在1的基础上增加heap的地址随机化

该保护能使每次运行的程序的地址都不同,防止根据固定地址来写exp执行攻击

可以防止Ret2libc方式针对DEP的攻击。ASLR和DEP配合使用,能有效阻止攻击者在堆栈上运行恶意代码

Linux下关闭PIE的命令:
sudo -s echo 0 > /proc/sys/kernel/randomize_va_space

gcc -o test test.c // 默认情况下,不开启PIE
gcc -fpie -pie -o test test.c // 开启PIE,此时强度为1
gcc -fPIE -pie -o test test.c // 开启PIE,此时为最高强度2
gcc -fpic -o test test.c // 开启PIC,此时强度为1,不会开启PIE
gcc -fPIC -o test test.c // 开启PIC,此时为最高强度2,不会开启PIE
-no-pie / -pie (关闭 / 开启)

背景知识

ASLR(Address Space Layout Randomization)在2005年被引入到Linux的内核 kernel 2.6.12 中,当然早在2004年就以patch的形式被引入。随着内存地址的随机化,使得响应的应用变得随机。这意味着同一应用多次执行所使用内存空间完全不同,也意味着简单的缓冲区溢出攻击无法达到目的。
GDB从版本7开始,第一次在Ubuntu 9.10(Karmic)上,被调试的程序可以被关闭ASLR(通过标记位ADDR_NO_RANDOMIZE )。

Ubuntu 9.10的版本控制ASLR的方法还不成熟,需要从源码层面确认是否可以关闭开启。

查看ASLR设置

查看当前操作系统的ASLR配置情况,两种命令

$ cat /proc/sys/kernel/randomize_va_space
2
$ sysctl -a --pattern randomize
kernel.randomize_va_space = 2

配置选项

  • 0 = 关闭
  • 1 = 半随机。共享库、栈、mmap() 以及 VDSO 将被随机化。(留坑,PIE会影响heap的随机化。。)
  • 2 = 全随机。除了1中所述,还有heap。
    后面会详细介绍ASLR的组成,不关心的同学可以简单理解为ASLR不是一个笼统的概念,而是要按模块单独实现的。当然,在攻防对抗的角度上,应为不是所有组件都会随机,所以我们就可以按图索骥,写出通用的shellcode调用系统库。

    查看地址空间随机效果

    使用ldd命令就可以观察到程序所依赖动态加载模块的地址空间,如下下图所示,被括号包裹。在shell中,运行两次相同的ldd命令,即可对比出前后地址的不同之处,当然,ASLR开启时才会变化:
    Pasted image 20250121141533
    ASLR开启时,动态库的加载地址不同

Pasted image 20250121141548
ASLR关闭时,动态库的加载地址相同

关闭ASLR

方法一: 手动修改randomize_va_space文件

诚如上面介绍的randomize_va_space文件的枚举值含义,设置的值不同,linux内核加载程序的地址空间的策略就会不同。比较简单明了。这里0代表关闭ASLR。

# echo 0 > /proc/sys/kernel/randomize_va_space

注意,这里是先进root权限,后执行。不要问为什么sudo echo 0 > /proc/sys/kernel/randomize_va_space为什么会报错

方法二: 使用sysctl控制ASLR
$ sysctl -w kernel.randomize_va_space=0

这是一种临时改变随机策略的方法,重启之后将恢复默认。如果需要永久保存配置,需要在配置文件 /etc/sysctl.conf 中增加这个选项。

方法三: 使用setarch控制单个程序的随机化

如果你想历史关闭单个程序的ASLR,使用setarch是很好的选择。setarch命令如其名,改变程序的运行架构环境,并可以自定义环境flag。

setarch `uname -m` -R ./your_program

-R参数代表关闭地址空间随机化(开启ADDR_NO_RANDOMIZE)

方法四: 在GDB场景下,使用set disable-randomization off

在调试特定程序时,可以通过set disable-randomization命令开启或者关闭地址空间随机化。默认是关闭随机化的,也就是on状态。
当然,这里开启,关闭和查看的方法看起来就比较正规了。
关闭ASLR:
set disable-randomization on
开启ASLR:
set disable-randomization off
查看ASLR状态:
show disable-randomization

ASLR与PIE的区别

首先,ASLR的是操作系统的功能选项,作用于executable(ELF)装入内存运行时,因而只能随机化stack、heap、libraries的基址;而PIE(Position Independent Executables)是编译器(gcc,..)功能选项(-fPIE),作用于excutable编译过程,可将其理解为特殊的PIC(so专用,Position Independent Code),加了PIE选项编译出来的ELF用file命令查看会显示其为so,其随机化了ELF装载内存的基址(代码段、plt、got、data等共同的基址)。
其次,ASLR早于PIE出现,所以有return-to-plt、got hijack、stack-pivot(bypass stack ransomize)等绕过ASLR的技术;而在ASLR+PIE之后,这些bypass技术就都失效了,只能借助其他的信息泄露漏洞泄露基址(常用libc基址)。
最后,ASLR有0/1/2三种级别,其中0表示ASLR未开启,1表示随机化stack、libraries,2还会随机化heap。

ASLR 不负责代码段以及数据段的随机化工作,这项工作由 PIE 负责。但是只有在开启 ASLR 之后,PIE 才会生效。

4.RELRO

Relocation Read-Only(RELRO)可以使程序某些部分成为只读的。它分为两种:Partial RELRO和Full RELRO,即:部分RELRO和完全RELRO。
部分RELRO是GCC的默认设置,几乎所有的二进制文件都至少使用部分RELRO。这样仅仅只能防止全局变量上的缓冲区溢出从而覆盖GOT。
完全RELRO使整个GOT只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。

在Linux系统安全领域数据可以写的存储区就会是攻击的目标,尤其是存储函数指针的区域。所以在安全防护的角度应尽量减少可写的存储区域

RELRO会设置符号重定向表格为只读或者程序启动时就解析并绑定所有动态符号,从而减少对GOT表的攻击。如果RELRO为Partial RELRO,就说明对GOT表具有写权限

主要用来保护重定位表段对应数据区域,默认可写
Partial RELRO: .got不可写,got.pIt可写
Full RELRO: .got和got.pIt不可写
got.plt可以简称为got表

//GCC用法
gcc -o test test.c // 默认情况下,是Partial RELRO
gcc -z norelro -o test test.c // 关闭,即No RELRO
gcc -z lazy -o test test.c // 部分开启,即Partial RELRO
gcc -z now -o test test.c // 全部开启
-z norelro / -z lazy / -z now (关闭 / 部分开启 / 完全开启)

编外:FORTIFY

fortify是轻微的检查,用于检查是否存在缓冲区溢出的错误。适用于程序采用大量的字符串或者内存操作函数,如:

>>> memcpy():
描述:void *memcpy(void *str1, const void *str2, size_t n)
从存储区str2复制n个字符到存储区str1
参数:str1 -- 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针
str2 -- 指向要复制的数据源,类型强制转换为 void* 指针
n -- 要被复制的字节数
返回值:该函数返回一个指向目标存储区 str1 的指针
---------------------------------------------------------------------------------------
>>> memset():
描述:void *memset(void *str, int c, size_t n)
复制字符 c(一个无符号字符)到参数 str 所指向的字符串的前 n 个字符
参数:str -- 指向要填充的内存块
c -- 要被设置的值。该值以 int 形式传递,但是函数在填充内存块时是使用该值的无符号字符形式
n -- 要被设置为该值的字节数
返回值:该值返回一个指向存储区 str 的指针
---------------------------------------------------------------------------------------
>>> strcpy():
描述:char *strcpy(char *dest, const char *src)
把 src 所指向的字符串复制到 dest,容易出现溢出
参数:dest -- 指向用于存储复制内容的目标数组
src -- 要复制的字符串
返回值:该函数返回一个指向最终的目标字符串 dest 的指针
--------------------------------------------------------------------------------------
>>> stpcpy():
描述:extern char *stpcpy(char *dest,char *src)
把src所指由NULL借宿的字符串复制到dest所指的数组中
说明:src和dest所指内存区域不可以重叠且dest必须有足够的空间来容纳src的字符串返回指向dest结尾处字符(NULL)的指针
返回值:
---------------------------------------------------------------------------------------
>>> strncpy():
描述:char *strncpy(char *dest, const char *src, size_t n)
把 src 所指向的字符串复制到 dest,最多复制 n 个字符。当 src 的长度小于 n 时,dest 的剩余部分将用空字节填充
参数:dest -- 指向用于存储复制内容的目标数组
src -- 要复制的字符串
n -- 要从源中复制的字符数
返回值:该函数返回最终复制的字符串
---------------------------------------------------------------------------------------
>>> strcat():
描述:char *strcat(char *dest, const char *src)
把 src 所指向的字符串追加到 dest 所指向的字符串的结尾
参数:dest -- 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串
src -- 指向要追加的字符串,该字符串不会覆盖目标字符串
返回值:
---------------------------------------------------------------------------------------
>>> strncat():
描述:char *strncat(char *dest, const char *src, size_t n)
把 src 所指向的字符串追加到 dest 所指向的字符串的结尾,直到 n 字符长度为止
参数:dest -- 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串,包括额外的空字符
src -- 要追加的字符串
n -- 要追加的最大字符数
返回值:该函数返回一个指向最终的目标字符串 dest 的指针
---------------------------------------------------------------------------------------
>>> sprintf():PHP
描述:sprintf(format,arg1,arg2,arg++)
arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推
参数:format -- 必需。规定字符串以及如何格式化其中的变量
arg1 -- 必需。规定插到 format 字符串中第一个 % 符号处的参
arg2 -- 可选。规定插到 format 字符串中第二个 % 符号处的参数
arg++ -- 可选。规定插到 format 字符串中第三、四等等 % 符号处的参数
返回值:返回已格式化的字符串
-----------------------------------------------------------------------
>>> snprintf():
描述:int snprintf ( char * str, size_t size, const char * format, ... )
设将可变参数(...)按照 format 格式化成字符串,并将字符串复制到 str 中,size 为要写入的字符的最大数目,超过 size 会被截断
参数:str -- 目标字符串
size -- 拷贝字节数(Bytes)如果格式化后的字符串长度大于 size
format -- 格式化成字符串
返回值:如果格式化后的字符串长度小于等于 size,则会把字符串全部复制到 str 中,并给其后添加一个字符串结束符 \0。 如果格式化后的字符串长度大于 size,超过 size 的部分会被截断,只将其中的 (size-1) 个字符复制到 str 中,并给其后添加一个字符串结束符 \0,返回值为欲写入的字符串长度
---------------------------------------------------------------------------------------
>>> vsprintf():PHP
描述:vsprintf(format,argarray)
sprintf() 不同,vsprintf() 中的参数位于数组中。数组元素将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的
参数:format -- 必需。规定字符串以及如何格式化其中的变量
argarray -- 必需。带有参数的一个数组,这些参数会被插到 format 字符串中的 % 符号处
返回值:以格式化字符串的形式返回数组值
---------------------------------------------------------------------------------------
>>> vsnprintf():
描述:int vsnprintf (char * s, size_t n, const char * format, va_list arg )
将格式化数据从可变参数列表写入大小缓冲区
如果在printf上使用格式,则使用相同的文本组成字符串,但使用由arg标识的变量参数列表中的元素而不是附加的函数参数,并将结果内容作为C字符串存储在s指向的缓冲区中 (以n为最大缓冲区容量来填充)。如果结果字符串的长度超过了n-1个字符,则剩余的字符将被丢弃并且不被存储,而是被计算为函数返回的值。在内部,函数从arg标识的列表中检索参数,就好像va_arg被使用了一样,因此arg的状态很可能被调用所改变。在任何情况下,arg都应该在调用之前的某个时刻由va_start初始化,并且在调用之后的某个时刻,预计会由va_end释放
参数:s -- 指向存储结果C字符串的缓冲区的指针,缓冲区应至少有n个字符的大小
n -- 在缓冲区中使用的最大字节数,生成的字符串的长度至多为n-1,为额外的终止空字符留下空,size_t是一个无符号整数类型
format -- 包含格式字符串的C字符串,其格式字符串与printf中的格式相同
arg -- 标识使用va_start初始化的变量参数列表的值
返回值:如果n足够大,则会写入的字符数,不包括终止空字符。如果发生编码错误,则返回负数。注意,只有当这个返回值是非负值且小于n时,字符串才被完全写入
---------------------------------------------------------------------------------------
>>> gets():
描述:char *gets(char *str)
从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定
参数:str -- 这是指向一个字符数组的指针,该数组存储了 C 字符串
返回值:如果成功,该函数返回 str。如果发生错误或者到达文件末尾时还未读取任何字符,则返回 NULL

//GCC用法
gcc -D_FORTIFY_SOURCE=1 仅仅只在编译时进行检查(尤其是#include <string.h>这种文件头)
gcc -D_FORTIFY_SOURCE=2 程序执行时也会进行检查(如果检查到缓冲区溢出,就会终止程序)

在-D_FORTIFY_SOURCE=2时,通过对数组大小来判断替换strcpy、memcpy、memset等函数名,从而达到防止缓冲区溢出的作用