PIE/ASLR

区别

ASLR 是什么?

ASLR 是 Linux操作系统 的功能选项,作用于程序(ELF)装入内存运行时。是一种 针对缓冲区溢出的安全保护技术,通过对加载地址的随机化 ,防止攻击者直接定位攻击代码位置,到达 阻止溢出攻击 的一种技术。

开启、关闭ASLR 查看当前系统ASLR的打开情况:

sudo cat /proc/sys/kernel/randomize_va_space

ASLR 有三个安全等级:

0: ASLR 关闭 1:随机化栈基地址(stack)、共享库(.so\libraries)、mmap 基地址 2:在1基础上,增加随机化堆基地址(chunk)

PIE 是什么?

PIE 是 gcc 编译器 的功能选项,作用于程序(ELF)编译过程中。是一个针对代码段( .text )、数据段( .data )、未初始化全局变量段( .bss )等固定地址的一个防护技术,如果程序开启了PIE保护的话,在_每次加载程序时都变换加载地址_,从而不能通过 ROPgadget 等一些工具来帮助解题。

开启 PIE: 在使用 gcc 编译时加入参数-fPIE。

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 (关闭 / 开启)

PIE 开启后会随机化代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss )的加载地址。

gcc中的-fPIC选项是针对某些特殊机型做了特殊处理,比如适合动态链接并能避免超出GOT大小限制之类的错误。

联系

这段偏理论,不关心的师傅可以直接跳过到下一部分

上述有一个明显的 缺陷 ,那就是PIE只是在编译的过程中赋予了ELF加载到内存时其加载基址随机化的功能,也就是说PIE编译出来的ELF如果_在ASLR=0的情况下,ELF的加载基址也是不会变的_。

为什么呢,因为一个是能力赋予,一个是真正使用能力。

这个是有历史原因的,ASLR刚开始设计的时候是作为操作系统功能提供的, 只考虑了当时技术背景 下executable加载后stack、heap、libraries的随机化功能,因而有多种绕过方式,这一时期的ASLR的定义也就成了“对stack、heap、libraries的随机化”。后来设计者就考虑executable的加载基址随机化以使得bypass失效,然而这一点在技术细节上 需要编译器来实现 ,因此gcc开始支持PIE选项,使得编译出来的executable像是一个 特殊的so ,可以被操作系统加载到随机化的内存地址(只是可以),因而到这一阶段你会发现ASLR的定义变成了“对stack、heap、libraries、executable base的随机化”。然而你要将PIE出来的executable理解为特殊的so的话,原来的定义还是可以自圆其说的。

所以这是ASLR 的三个级别变成了 :0, 不开启任何随机化;1, 开启stack、libraries [、executable base(special libraries -^-) if PIE is enabled while compiling] 的随机化;2,开启heap随机化。

因而,我们会发现PIE编译出来的executable如果ASLR=0的话,基址也是不会变的(有能力但没使用),如果ASLR=1的话,即使按照ASLR定义这个级别似乎不会对heap基址随机化,但是__由于executable的基址已经随机化了,所以heap的基址自然也就被随机化了_。有条件的师傅不妨自己尝试一下。

具体细节

关于程序是否在编译时开启了PIE,可以直接使用 checksec ,工具直接查看(简单粗暴的);

还有一种方法是把文件丢 IDA 里(或者直接使用 objdump ),看它的地址(实际上是偏移);

因为存在一个小小的特性:

partial write

partial write(部分写入)就是一种利用了PIE技术缺陷的bypass技术。由于内存的页载入机制,PIE的随机化只能影响到单个内存页。通常来说,一个内存页大小为0x1000,这就意味着不管地址怎么变,某条指令的后12位,3个十六进制数的地址是始终不变的。因此通过覆盖EIP的后8或16位 (按字节写入,每字节8位)就可以 快速爆破或者直接劫持EIP 。

简单来说就是不管程序加载基址怎么变化,偏移量和真实地址的 最后三位 都是一样的。

并且呢,由于PIE只是改变了程序运行时加载的基址,使用的_仍然是一块连续的内存空间_。

这是什么意思呢?

看看这个公式:

pie_base = 泄露的地址 - 泄露地址的偏移量

也就是说,如果可以泄露出程序运行时的某个地址,那么用它减去对应的偏移,得到PIE的基地址,就能通过它反过来算所有的地址。

看看例子

先简单的看看我们熟知的/bin/bash,在Linux系统上使用 ldd 可以查看对应文件的链接情况:

image-20250122134522200

可以看到 每次加载的基址都在变化 。

然后我们载编译两个ELF文件,用 IDA 打开:选择 options -> general ->勾上 auto comments 和 Line profixes(graph)

接着就可以看到函数的地址了,没开启PIE的情况下是这样的:

image-20250122135944686

因为是64位机子,程序的起始地址一般默认在0x0000000000400000。

而在32位操作系统中,程序的起始地址并没有一个固定的默认值,它取决于系统的内存布局和程序的链接方式。然而,出于历史原因和约定,许多32位程序可能会选择在0x8048000这个地址附近作为它们的加载基址;

这个地址是Linux系统中常见的默认加载地址,它为程序的.text段提供了一个足够高的基址,以便为栈留下足够的空间,同时避免与内核空间或其他程序发生冲突。但是,这只是一个惯例,并不是硬性规定。

接下来我们看看开启了pie保护的情况:

image-20250122140133808

很明显啊,这个看上去就不太像正常的地址,因为它只是一个相对于pie_base的一个 偏移 ;

但是也是能看出来它_仍然是连续的_,不会出现东边一块西边一块的情况

(当然,这种连续仅限于同一个代码段中,比如现在图片上的 .text)。

至于为什么一直在强调 连续 呢,学过计算机组成原理或者对程序在内存中装载的师傅应该有所了解:

程序单独占用进程中的虚拟内存空间,逆向出来的地址是逻辑地址,而不是物理地址。

几个相关的点:

  1. 虚拟地址空间:在IDA Pro中,看到的是程序在虚拟地址空间中的布局。这个空间在程序被加载到内存之前是连续的,并且与实际的物理内存布局 无关 。
  2. 重定位信息:PIE保护的程序包含了重定位信息,这些信息告诉操作系统如何在程序加载时将虚拟地址 映射 到物理地址。在反汇编视图中,这些重定位信息通常不会直接显示。
  3. 地址无关性:IDA Pro和其他反汇编工具解析的是程序的二进制文件,它们不关心程序在运行时实际加载到哪个物理地址。因此,它们显示的是程序在虚拟地址空间中的 逻辑布局 ,这通常是连续的。
  4. 运行时地址:只有在程序实际运行时,操作系统才会根据PIE和ASLR(Address Space Layout Randomization)的设置来确定程序各部分的实际物理地址。这些地址在每次程序运行时都可能不同。