栈溢出入门
前言:
栈溢出是PWN的一种最简单的漏洞,其最核心的点就是,程序在读取用户输入的时候没有对输入内容进行检测与限制,从而导致输入的数据写入内存时挤占了其它不属于该变量的内存位置,从而导致重要寄存器(如RBP、RIP)中记录的地址被破坏,导致函数执行完返回时发生错误(找不到这样一个地址),即发生段错误(Segment fault)。
但是同时,我们可以利用改写RBP、RIP寄存器值的这一特性,将程序跳转至我们期望的地方去
RBP与RIP:
ARM架构多用于手机CPU处理器以及小型设备处理器上,我们在CTF中写的PWN题一般都是基于X86架构的,所以在我们通过ARM初步了解汇编体制之后,我们的思维就要逐步向X86架构迁移了。
RBP与RIP是X86架构中的两个寄存器的名称,二者用途如下:
RIP寄存器相当于ARM架构中的PC寄存器,其实质上也是指向处于“取指”阶段的指令(即“执行”阶段指令的后两条),因为X86架构同样存在三步流水线机制,RIP寄存器与PC寄存器在作用上是基本等效的
RBP寄存器事实上就与ARM架构的FP寄存器等效,与之配合工作的还有RSP寄存器,其等效于ARM架构中的SP寄存器
实例演示:
对于程序:
其栈空间布局如下:
当程序运行时,压栈的顺序是:RIP、RBP、字符串"Hello World"、最后到变量V4
由于栈的布局为栈底为低地址、栈顶为高地址,所以向V4内存空间写入数据是,数据是“逐渐往高地址写入”的,这就导致了一个问题:
当我们使用像gets()这样的函数读取用户输入时,我们程序并没有对用户输入的数据长度进行检查,导致程序在读取用户的超长输入后,会试图将其完整地存入栈内,但是由于栈帧的大小是固定的,即V4的大小不可更改,在实际运行中程序会“强征”字符串"Hello World"所占的内存空间来存储这一超长数据,导致了字符串"Hello World"的内存空间被覆写,也就是说这一内存空间存储的不再是字符串"Hello World",而是用户超长输入的“溢出部分”。
当这个超长数据的长度超过了字符串"Hello World"和V4空间的总和时,程序会继续向栈顶“强征”空间来完整地存储读入的数据,这个时候就会覆盖掉RBP,甚至时RIP寄存器的值。
覆盖掉RBP寄存器的值由于X86架构中没有类似LR寄存器的寄存器,所以函数在尾声部分返回调用函数时会出现错误,这就导致程序会在该函数运行完试图返回时出错并报错;
覆盖掉RIP寄存器的值,即指向的下一条将要执行的指令的地址被改变,而CPU又总是根据RIP的值来定位并决定下一步执行的指令,更改了RIP的值后,RIP存储的就是一段错误的或者是不存在的内存地址,CPU在试图定位执行时会报错,当然我们也可以修改这里保存的地址为我们期望跳转到的地址(恶意语句地址),这就是最基本的栈溢出原理了。
在Ubuntu环境中,使用gdb-pwndbg file命令开始调试指定程序
先用r命令运行一次这个程序:
在IDA Pro中,按空格进入文本模式浏览整个程序,定位到main函数:
这里提点题外话:
红线标明的部分就是函数的序言部分,可以看到这里是对帧指针进行了一个压栈的操作、然后帧指针下移、栈指针下移
包括在这里:
这里是函数的尾声部分:
关于leave指令:
leave指令是mov esp, ebp和pop ebp的结合(EBP/ESP就是RBP/RSP),即将栈指针归位、将帧指针归位。
关于retn指令:
retn或ret指令用于从被调用函数返回调用函数, 它通过弹出栈中的返回地址来实现这一点。返回地址是在函数调用时由call指令压入栈中的,当retn或ret执行时,它会从栈中弹出这个返回地址,并将控制流转移回该地址,从而继续执行调用函数。
看到伪代码页面:
很清晰的逻辑,我们同时可以知道字符串"Hello World"和V4空间的总和,一共是88个字节(一个char型元素占一个字节),也就是说,大于88字节的输出就会造成栈溢出,以下是一个溢出示例:
侵占varC:
这里我们控制输出了一个“Hacked!”
侵占RBP:
可以很明显地看到提示:Segment fault,即段错误
可以看到RBP寄存器的内容变成了奇怪的东西,RIP寄存器暂时正常
侵占RBP和RIP:
这里可以看到RIP指向了一个错误的地址,追溯后是一个0x4012b,实质上是一个无效的东西
同样导致了页错误
利用思路与exp编写:
注意到一个名为backdoor的函数:
也就是说我们要跳到这个地址来getshell;
先使用pwndbg的cyclic命令生成指定长度的内存地址探测字符串:
cyclic 128
//生成长度为128的探测字符串,需要保证长度足以覆盖掉RBP和RIP
用r跑一下程序,输入这个字符串:
将RIP追溯的值使用命令:
cyclic -l 追溯地址
这里指示偏移量为88
用VS Code远程连接环境,编写脚本如下:
from pwn import *
sh = process('./easydemo')
sh.sendline(b'A'*88 + p64(0x40117E))
sh.interactive()
逐行解释如下:
- from pwn import *
这里是将pwn库中的所有内容导入
- sh = process('./easydemo')
这里利用process方法自动执行easydemo可执行文件(ELF)
- sh.sendline(b'A'*88 + p64(0x40117E))
这里是最重要的一句:
pwn库中定义了sendline和send两种方法来用于发送数据,区别在于sendline会在数据末尾自动回车而send不会
关于内容:
b'A'*88:以byte形式发送88个字符A,这里的88就是之前的cyclic -l执行结果返回的偏移量,表示88字节后的数据即为RIP寄存器的内存区域(这与我们预期的不一样,因为88个字节应该刚好到RBP寄存器而不是RIP寄存器,但是程序有时候就是这样的,所以还是要以cyclic的返回结果为唯一标准)
+ p64(0x40117E):p64方法用于用于把整型数据转换为二进制(不用去考虑小端序),参数0x40117E是system函数的主体部分的起始地址
这里应当尽量避免使用函数的序言部分的起始地址
- sh.interactive()
这里是进入交互式界面
运行结果如下:
出现红色$符号,说明脚本执行完了sh.interactive()语句,尝试执行ls命令,回显正常,即成功getshell
拓展内容:
一些命令:
x 检查内存信息(查看的是十六进制数据)
x/x 查看32位的十六进制数据
x/10x 查看10个32位的十六进制数据
x/gx 查看64位的十六进制数据
x/10gx 查看十个64位的十六进制数据
使用时,应确保程序没有执行完毕(可以下断点)
下断点的命令:
b *下断点的地址
运行至断点后执行指令c以继续运行
白框内为断点指令
该箭头指示当前程序运行到的地址
STACK部分会返回当前栈的相关信息
我们执行指令:
x/10gx $sp
即查看栈指针附近的10个64位十六进制数据
在脚本中写入:
context.log_level = 'debug'
这个语句时将日志等级更位debug(调试)级,会尽可能多地展示程序运行时的相关信息以方便我们进行后续的调试工作
这里将发送与接收的数据全部详细地罗列出来了
log.info [*]
log.success [+]
log.error [-]
log.warning [!]
这里是一种日志性的写法,会在回显中显示特定的信息
例如:
log.success('Successfully PWN')
回显:
一些命令操作技巧:
Ctrl+Z将当前耗时任务挂至后台运行
fg命令用来将一个在后台运行的进程调至前台继续运行。当你挂起一个正在前台运行的进程(例如,通过按下Ctrl+Z),它会进入后台,并且暂停执行。使用jobs命令可以查看所有后台作业。要恢复这个进程到前台,可以使用fg命令,后面跟上作业号(通常是在jobs命令输出的列表中找到的)
bg命令则用于将一个在前台运行的进程放到后台执行。当你正在前台运行一个进程,但需要临时做其他事情时,你可以使用Ctrl+Z将进程挂起,这样它会进入后台并暂停。如果你想让这个进程在后台继续执行,而不是暂停,可以使用bg命令,后面跟上同样的作业号