前文:以下是我入门PWN的记录,欢迎各位前来观看,小弟领教!
[TOC]
继上一大章的基础学 习后,对PWN有了一定了解,知道一些专名词、一些保护机制的工作流程、汇编语言等,接下来的几章开始正式学习二进制的漏洞利用和原理。
栈是PWN比较常见的突破点
栈溢出原理
在上一大章的函数调用约定中,我们可以了解到函数的调用过程: 调用函数:只需要将rip压栈,即push rip,然后讲rip赋值为被调用函数的起始地址,这一操作被隐性的内置在call指令中。
被调用函数:push rbp; mov rbp rsp;sub rsp oxxx。即保存调用函数的rbp指针,将自己的rbp指针指向栈顶,然后开辟栈空间给自己用,此时rbp就变成了被调用函数的栈底。
函数返回:leave;ret;翻译过来就是:mov rsp rbp;pop rbp;pop rip;即恢复栈帧,返回调用函数的返回地址。
栈的作用为存储函数调用相关信息以及函数的局部变量。
这些局部变量通常为数组或者输入的缓冲区(buf)。而函数调用相关的信息,主要是返回地址和栈底指针(rbp)。
栈溢出
函数中的存储在找中的局部变量数组边界检查不严格发生越界写,造成用户输入覆盖到缓冲区外的数据内容。
由于栈中同时存在着与函数调用参数的相关信息,栈溢出可以导致控制流劫持。
来让我解释一下:
- 若用户输入长度超过数组容量,超出的部分会向上覆盖栈中更高地址的数据(因为栈向下生长,越界写会向高地址蔓延);
- 当覆盖到 “返回地址” 时,攻击者可以将其修改为任意地址(如 shellcode 地址、ROP gadget 地址等);
|
|
main函数调用b,b函数调用a。缓冲区溢出发生在a函数中。
buf的长度为80,但是却读入了200长度。(栈溢出)
分析程序运行至a时的栈帧、栈中存放buf和返回地址等等信息:
输入200长度造成栈溢出,超出的 120 字节会向上覆盖栈中更高地址的内容如ret(因为栈向下生长,越界写会向高地址蔓延)。
覆盖返回地址可以控制程序下一步执行的位置,而通过控制执行位置,攻击者可以间接实现 “修改任意地址” 的效果。
举例,攻击者构造输入:前 84 字节填充无关数据(覆盖buf和func_b()的 ebp),第 85~88 字节填入0x12345678(小端序可能需要反写为0x78563412);这时返回地址被覆盖成0x12345678,直接跳转到0x12345678(可能是攻击者的代码)。
那说起返回地址这一块,回忆一下:
调用函数:只需要将rip压栈,即push rip,然后讲rip赋值为被调用函数的起始地址,这一操作被隐性的内置在call指令中。
被调用函数:push rbp;mov rbp rsp; sub rsp oxxxx。即保存调用函数的rbp指针,将自己的rbp指针指向栈顶,然后开辟栈空间给自己用,此时rbp就 变成了被调用函数的栈底。
函数返回:leave;ret;翻译过来就是:mov rsp rbp;pop rbp;pop rip;即恢复栈帧,返回调用函数的返回地址。
栈溢出的核心是覆盖程序中 “会被用来决定下一步执行位置” 的数据,这些数据未必是ret
指令读取的 “返回地址”。如果栈中存在被jmp
、call
等指令使用的 “目标地址”(比如函数指针、跳转表项等),覆盖这些地址同样能实现控制流劫持。
具体来说:ret
、jmp
、call
的共性与差异:
ret
指令:从栈中读取 “返回地址” 并跳转(依赖栈中存储的地址)call 地址
指令:将当前指令的下一条地址压栈,然后跳转到 “地址”(若 “地址” 存储在栈中且可被覆盖,则call
的目标会被篡改)jmp 地址
指令:直接跳转到 “地址”(若 “地址” 存储在栈中且可被覆盖,则jmp
的目标会被篡改)
只要这些指令依赖的 “目标地址” 存储在栈中,且能被栈溢出覆盖,就能劫持控制流。ret
只是最常见的场景(因为函数调用的返回地址几乎必然在栈上),而jmp
/call
的目标若在栈上,同样可以被利用。
总之:栈溢出的原理就是栈中存储的局部变量数组发生溢出,覆盖了栈中的其他数据。将返回地址覆盖为我们期望的目标地址,即可劫持控制流。
栈溢出在CTF中的应用
一般来说,在CTF中的PWN,多数情况下我们需要让程序执行这一段代码:
system("/bin/sh")
也就是说在远程机器上开一个命令行终端,这样我们就可以通过命令行来控制目标机器。
通常来说,CTF比赛中只需要开启命令行后读flag(cat flag)。
基本栈溢出
如果程序中没有system这样的代码出现,怎么办?我们可以自己写shellcode!
**shellcode
**就是一段可以独立开启shell的一段汇编代码。
ret2shellcode的思路就是:
如果程序中存在让用户向一段长度足够的缓冲区中输入数据。我们向其中输入shellcode,将程序劫持到shellcode上即可。当然,这种也是理想情况。
ret2shellcode
是二进制漏洞利用中的一种常见技术,用于在存在栈溢出等漏洞的程序中获取系统的 shell 权限,从而执行任意命令。ret2shellcode
利用这个特性,将返回地址覆盖为一段精心构造的机器码(即 shellcode)的地址,从而返回到攻击者留下的shellcode进行劫持。
栈溢出案例:ret2shellcode
|
|
vul函数存在明显的栈溢出,可以劫持控制流到gen_shell函数、可以劫持控制流到global_buf。
局部变量buf[80]
仅分配 80 字节,但read(0, buf, 200)
读取 200 字节输入,超出的 120 字节会覆盖栈中更高地址的数据(包括ebp
和返回地址),属于典型的栈溢出漏洞。
方法一:生成payload脚本劫持
vul()函数中,buf[80]是局部变量,栈布局从低到高为:buf[0..79] → ebp(4 字节,32 位系统) → 返回地址(4 字节)。
覆盖返回地址需要先填充:80字节(buf) + 4字节(覆盖ebp),之后的 4 字节就是要写入的返回地址(即gen_shell的地址)。
构造攻击数据(以 Python 为例):
|
|
输入攻击数据后,vul()
函数执行return
时,返回地址已被覆盖为0x401120
,程序会跳转到gen_shell
函数,执行execv("/bin/sh", 0)
,成功获取 shell。
方法二:利用栈溢出跳转到shellcode
构造 payload 覆盖 vul()
函数的返回地址为 global_buf
的地址(即 shellcode 所在位置)……
ret2libc
有时候,我们需要调用一些系统函数,就比如说system或者execv等。程序中可能不会提供一些现成的函数。
如果我们拿到了libc中函数的地址,我们可以直接调用libc中的函数,只需要传递好参数,然后call即可。
如何传参?如何调用system(/bin/sh)
?
只需要将rdi设置为/bin/sh字符串地址,然后call system即可。
如何设置mov?
如果直接mov,然后call,那么就和ret2shellcode无异。
现在问题是,我们只有一个libc地址和/bin/sh字符串地址,以及一个栈溢出漏洞,怎么传递参数?
pop rdi ret + /bin/sh地址 + system
来个例子:有一个存在栈溢出的 64 位程序 vuln
-
有一个漏洞函数
vul()
,存在栈溢出(可覆盖返回地址); -
程序加载了 libc 库(必然包含
system
函数); -
我们通过信息泄露已经获取到:
system
函数在内存中的地址:0x7ffff7839410
"/bin/sh"
字符串在内存中的地址:0x7ffff79e5aaa
- 一个
pop rdi; ret
gadget 的地址:0x401273
(从程序中找到)
-
经调试,覆盖
vul()
函数返回地址需要先填充 120 字节(前 120 字节会覆盖局部变量和rbp
)。函数的第一个参数通过
rdi
寄存器传递。因此调用system("/bin/sh")
必须满足:rdi
寄存器中存放"/bin/sh"
字符串的地址(0x7ffff79e5aaa
);程序跳转到
system
函数的地址(0x7ffff7839410
)执行。
通过栈溢出构造 payload,最终让程序执行 system("/bin/sh")
,获取 shell。
用 Python 代码生成 payload(pwntools):
|
|
ret2libc
调用 system("/bin/sh")
的核心逻辑是:
用 pop rdi; ret
把栈中的 "/bin/sh"
地址 “搬” 到 rdi
寄存器,再跳转到 system
函数。
整个过程完全依赖栈溢出控制程序执行顺序,不需要程序中存在现成的 shell 函数,也不需要栈可执行,因此适用性极强。
在这里开始就需要刷点PWN题了,我比较推荐的是ctfshow,购买官网套餐或者去咸鱼上买网盘保存的ctfshow-pwn题也行,看自己预算,在这里我就会同步进行writeup_pwn的编写了。
ROP 前传
ROP(Return-Oriented Programming,返回导向编程)是一种高级漏洞利用技术,主要用于在内存保护机制(如 NX/DEP,即代码段不可执行)限制下,通过拼接程序中已有的代码片段(称为 “gadget”)来构造攻击逻辑,实现对程序的控制。
很多情况下,程序中我们能够利用的只有栈。也就是说,程序中没有一个可读可写可执行的区域让我们输入shellcode。同时,大多数题目也不会那么好心给你留一个后门函数直接执行system。那么这个时候,我们就要利用ROP。我们不能运行shellcode,也没有后门函数一步到位。我们可以利用程序中的一些指令片段,一点点拼接起来,拼成我们想要的样子。怎么拼?
拿system("“/bin/sh");举例:
我们要将rdi改成/bin/sh这个字符串的地址,然后call system。不能执行shellcode,怎么改?我们有栈(传递参数)! pop rdi ret + /bin/sh addr
不能shellcode,怎么call?我们有ret! 所以,我们构造的payload就是: padding + pop rdi; ret + /bin/sh + system
ROP就是搭积木,用一个个小小的片段来完成复杂的工作,基本只需要用到栈这些小小的积木,我们称之为gadget怎么找gadget呢?
ropper & ROPgadgets(自行安装)可以帮我们寻找
|
|
如果要找特定寄存器或者返回值,可以在后面加,比如图的指令:
来个例子:
read(0,buf,200);向buf上写数据(/bin/sh),buf地址已知;system(buf);即执行system("/bin/sh");
|
|
ROP传参:
|
|
很抽象?那我们可以看回这个例子:
看到里面的一些地址pop rdi;ret之类的,再看回上面的,你就能读懂是什么意思。
但是在实际情况下,并不一定有像之前举的例子一样的gadget供我们使用,有时候我们需要一个gadget,比如poprsi;ret,程序中不一定会有,这时候就需要调动我们的思维能力,曲线救国,通过别的方式来完成ROP。
通用ROP: 在64位程序中,函数的前6个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的gadgets。
这时候,我们可以利用x64下的libc_csu_init 中的gadgets。
这个函数是用来对libc 进行初始化操作的,而一般的程序都会调用libc函数,所以这个函数一定会存在。
本章的ROP介绍到这啦。
ROP++
通过之前的认识,我们发现ROP形式上把代码片段分散在程序代码中。ROP通过栈来维护整个链的逻辑。 但如果栈溢出字节不够怎么办?
思路一:one punch!
libc中有没有这么一段代码,直接就运行system(“/bin/sh”)呢?这样我们就不需要传递参数,直接将返回地址覆盖到那里就好了。 答案是:有,而且不止一个。这种一步到位的gadget,我们称之为one gadget。
看起来像梭哈的,很方便喔!但是又局限性的,
师傅,那我还学什么ROP,直接每次一步到位不就得了?想法总是好的,但是现实是很差强人意的。 onegadget通常有变量要求,比如栈中的某个偏移处值必须为0,rax必须为0。所以说,onegadget这个东西,比较玄学,不靠谱。
思路二:没条件创造条件
计算机是怎么知道栈的?答:通过rbprsp
ROP怎么维护逻辑的?答:通过一系列的pop和ret操作,依靠rsp。
溢出字节不够,rop写不完,rsp一条路走到黑,一顿popret操作后跑到了我们溢出控制区域外。
问题归纳总结:溢出字节不够只是原因,我们的目的是让rsp在我们能够控制的区域自由飞翔。
栈转移/栈迁移
只要控制rsp一直在我们可控制内容的区域,就可以完成rop。那么,如何控制RSP?其实就是mov rsp XXX 这种效果。有没有mov rsp rax;ret,或者pop rsp;ret这种gadgets呢?理论上应该有的,但是实际情况下极其少见。那么,除了push和pop,还有没有其他常见指令能对rsp值进行更改呢?
那就是leave ret,leave
指令的实际效果就是mov rsprbp; pop rbp;用于恢复栈帧,通常与ret连用。
这里我们可以看到,leave将rbp的值符给了rsp。
也就是说我们控制了rbp指针,就可以控制rsp。
栈转移第一种方法
pop rbp; ret+ leave; ret
rop的构成:
|
|
首先利用pop rbp ret来控制rbp指针的值为我们想要将栈转移到的地址,然后执行leave;ret来控制rsp指针。
我们计算一下总共溢出的字节,pop rbp; ret的地址为返回地址,后续的所需要的溢出字节数仅仅为16字节,如果算上溢出覆盖到的rbp指针和返回地址, 那么一共是32字节。然后栈转移到我们目标的地址上继续执行rop。
栈转移第二种方法
两次leave ret
rop构成:
|
|
这种方式与上一种方式大同小异,但是我们需要将栈上保存的rbp(也就是返回地址之前)改成迁移的地址。程序在第一次执行leave ret时,会将rbp搞到目标迁移地址上。再次执行leave ret时,效果就和之前一样了。
那么溢出字节仅仅需要8个字节,算上覆盖的rbp以及返回地址,一共需要24字节溢出。
如果我们溢出的函数本身结尾带有leave;ret,那么我们只需要溢出16字节,只需要覆盖到返回地址即可。
总之,栈转移通过将 rsp
指向可控区域,解决了以下问题:
- 原始栈空间不足,无法构造长 ROP 链;
- 利用堆或其他可写区域(如
.bss
段)作为新栈,绕过栈保护机制; - 串联多个分散的 gadget,实现复杂攻击逻辑(如调用
execve
或泄露 libc 地址)。
其本质是利用 leave
指令对 rbp
和 rsp
的关联关系,通过控制 rbp
间接 “劫持” 栈指针,是 ROP 进阶利用中的关键技术。
改写.got表getshell方法
我们思考一下,程序调用函数都是通过访问got表。 如果我们将.got表中原本存放比如说puts函数的地址,改成system函数的地址,这样程序想要调用puts时,实际调用的却是system,利用这种方式getshell,不是很好吗?
这种方式叫做got表劫持,是一种间接控制程序执行流的方式。
GOT的地址,在没开PIE的情况下,我们可以在IDA中查看,也可以用pwntools指令查询:elf.GOT[“puts”]
在我们劫持got表时,常见的有如下几种: puts(buf)、atoi(buf)、atol(buf)、free(buf)
free在当前阶段少见,在后续的堆会很常见。
总的来说,把这些第一个参数也就是rdi指针指向我们可以控制输入的区域的函数,改成system,这样,当我们程序执行到调用这些函数时,程序就被劫持到我们目标函数处了。
如何改写?
ROP: read (0, got, size);
达到任意内存地址写后,不能确定栈地址,考虑写got。
bss等段越界访问(负值)。
主要看程序逻辑,具体问题具体分析。
PWN栈溢出题的一般解题思路
直接控制执行流:shellcode &ROP
间接控制执行流:改写函数指针,如got表。
保护机制绕过:canary、ASLR、PIE
方法论
第一步、查看文件信息(checksec看架构、保护信息)
一般来说,栈题目都会或多或少关闭一些保护。 如果关了canary,那么大概率是栈题。如果开启了PIE,那么就需要找个地方泄漏地址信息。如果开启了 FULL RELRO,那么思路就不应该是改写got。
第二步、理清函数逻辑(如何执行?)
直接在linux中运行,丢进ida,静态分析。
首先把函数名、变量等等搞成自己看得懂的地步。如果有结构体,最好花时间建一个结构体,这样之后分析省事。
第三步、找到漏洞(栈?堆?gets函数、system、execv)
找漏洞是很考验经验的。
一般先分析一下危险函数,比如
gets
、read
、write
、free
、printf
等等。 很多PWN题目会自己封装一个input
函数,重点分析一下,看看有没有越界。 分析的时候也有技巧,通常我们要看看我们的输入数据会被程序如何处理。由于pwn多数是内存破坏漏洞,所以对于内存拷贝等函数要格外注意。
第四步、漏洞利用(写exp、payload)
基本上找到漏洞工作就完成了一半。 漏洞利用和程序本身的逻辑以及保护机制息息相关。通常来说,需要解决以下几个问题:
地址问题
我们编写EXP离不开函数地址以及gadgets地址。
开启了PIE和ASLR,我们就需要想办法泄漏地址。
泄漏方法一般就是让程序打印出来一些脏数据或者函数地址。
程序基地址、堆地址、Iibc基地址、栈地址。
canary和NX问题
如果确定是栈题,并且有canary,那么通常考虑泄漏或者修改canary。一般来说,NX都是开启的,没有开启就说明要你执行
shellcode
了。NX对于我们写ROP基本没影响,绕过方法也很简单:
mprotect
程序本身的限制
gadget种类少、输入字符有限制、栈溢出字节不够、具体情况具体分析。
完结
栈的讲解就到这,可能内容比较少,概念抽象些,也是怪小生基础不牢理解力不够强😄,还得区看看PWB题加强一下。