引言:
2023年ZJUCTF-Pwn方向部分题题解,包含5rop,welcome,nya,game,logger,drawer。考点基本在栈和数组溢出上。其中有些题目支持本地环境可以在本地复现出来,有些题目需要远程打不能复现。Pwn方向的题难度真的好大......有哪里写的不对的地方还请指出呜呜
1.1 5rop
用IDA反汇编,打开,一览无遗......
观察代码段,发现一处栈溢出:
只有一处栈溢出那应该是rop。但由于程序使用了sys_read(内核函数),没有可以利用的GOT和PLT,所以肯定不是常规的Rop。
再找一下程序中还有没有其他可以利用的地方,发现:
.text:0000000000401015 pop rax; retn
0x402000 /bin/sh
0x401012 syscall;ret
现在明确:
rax
可控,可以用来盛装系统调用号syscall
代码碎片可以利用(因其后有ret),配合rax可以触发任意系统调用/bin/sh
字符串存在,可以想办法把地址放进rdi里,配合syscall
getshell
目前的难点在于我们如何把/bin/sh
的地址放到rdi里。一想到控寄存器,就想到Srop。
在触发系统调用时,操作系统会将程序从用户态切换到内核态,需要将所有寄存器压入栈中以保存上下文,以便在结束系统调用时可以恢复程序的状态。
有没有什么联系?寄存器?栈?由于栈是可控的,所以我们可以通过对栈的排布,让操作系统在恢复上下文的时候将栈上指定的值pop到我们需要的寄存器中。
(图源CTF-wiki)
幸运的是pwntools集成了Srop的工具。
下面再来碎碎念一下,作为一名合格(不了一点)的Pwn人需要随时都能背出shellcode
1 | mov rax, 0x3b ; 系统调用号 |
利用15系统调用号触发Srop,payload如下:
1 | from pwn import * |
拿到shell
1.2 nya
丢进IDA反汇编,运行一下
分析了一下,程序的主逻辑就是循环25次,每次可以给人物不同的属性加点,根据输入数字的不同,每次加的点数随机,甚至还会有其他的属性被扣点。当每个属性的点数到999就可以拿到flag了。
但经过尝试,发现如果循规蹈矩,25次甚至连一个属性都加不满。
看左侧的函数栏,发现每次加点的多少其实是由不同的函数处理的。
根据加点的多少,从小到大分为:
success
great success
unbelievable success
unexpected
其中unexpected函数的加点是固定的——给每个属性都加6点。这点很重要,之后要用到。但是很显然,就算加6点,25次循环也不可能到999。
观察train函数,我们先输入一个lucky number,程序对该数字进行操作,最后用处理结果通过roll函数给属性加点。
roll函数如下:
rand随机数...又来?又对数据进行了处理,根据处理结果分不同的函数加点。通过尝试发现,加点最多的是unbelievable success
通过多次尝试,偶然触发了一次:
但是概率实在是太低了,按照正常的方法肯定不行。
所以,为了让每次都能触发usuccess,必须保证:
每次的v6都大于100--> 每次的train函数处理结果足够小
好好好,正片开始:
train的处理结果是一个16位的short int类型的数据,这里我们把它分为高八位和低八位(下面将该数据称为result)
readstr() 函数off by NULL
看readstr函数
很明显的off by null。程序规定输入字符串的长一定小于3,如果大于3,会在第4位处添加0x00字符截断。
观察train函数,发现读入的string与result的在栈上的地址相邻,也就是说,这里的off by null会将result的低八位覆盖为0
只要我们在Yes or NO 的时候输入YYYY就OK。
至于高八位,再次观察train函数:
只要保证v0为0,就可以让result高八位为0。
因为要求一个相对于0xFFFF的逆元,把v3爆破出来:
1 | while(1){ |
求出v3 = 18
好了,完事具备~
前几次我们要去触发usuccess
运行程序,见证奇迹:
再来几次,发现到最后没法把所有的属性同时变成999
这时候利用off by NULL 触发 unexpected success,成功get flag(本地环境无flag)
1.3 Welcome
扔进IDA反汇编
程序的大意的现在有一个长度为8的数组,现在指针指在数组的第一位,通过A,D
左右移动指针的位置,通过W,S
来使指针指向的数字加1或减一。其中backdoor函数是后门函数,我们只需利用一些手段让程序执行backdoor就好~
嗯,一打开就知道是经典的数组溢出的题型。我猜应该没有限制输入的下标叭,那岂不是可以任意修改栈地址和返回地址(笑)
+++
由于S数组布置在栈上,因为它没有限制下标,所以我们可以去任意修改S[]和S[]位置的数字。
这里还走了点弯路,由于本题开了地址随机化,我们只能去根据函数地址之间的偏移去对返回地址进行修改。一开始只想着从S数组往下修改了,即修改main函数的返回地址到backdoor,找了一半天libc_start_main_ret
,才想起来这个b东西是内核地址,libc
加载的基址都不知道,改毛线。
后来受学长的点拨,才回过味来,向下修改不行可以向上修改。
正片开始:
发现本题的输入可以是字符串,然后main函数调用process函数,按照从前往后的顺序按位处理字符。也就是说,当main函数调用process的时候,栈空间如下:
1 | | space for 'process' | |
所以,我们只需要让process自己改自己就好了。即移动指针,修改retaddr of 'process'为backdoor
明确,process的返回地址是在main函数中,call process
的后一条汇编指令的地址即0x1591
(这里所说的地址其实是相对start函数基址的偏移量)
backdoor的地址为0x1289
算偏移改地址就OK
EXP:
1 | from pwn import * |
get shell
1.4 game
可以说是上一题的考点+ROP。扔进IDA反汇编
本程序的大意就是跟对面下棋,赢了就会调用win_game的函数,输了就寄了。在win_game里让你输入一个字符串(名字)会有一个栈的溢出点。
很明显,本题先要把对面赢了,然后在win_game里ROP,获取shell。
运行下程序,好好好,直接往中间下是吧,这赢个鬼。
看下胜利的判断条件,发现有一个全局变量byte_4061
,其置1表示游戏胜利。并且我们发现,该棋盘其实是一个数组,下棋的过程实际上就是使数组下标为3 * row + column
的数字置1 。尝试下负数,发现可以,不会报错。那现在去找数组地址和byte_4061
地址的偏差,用welcome中的思路,把byte_4061
改成1就好了。
数组首地址为0x4074
,偏移19,拆解因数3 * 6 + 1 * 1
试运行,完全OK
好好好,下面就是利用栈溢出ROP了。由于开了地址随机化,我们不能找到start函数的基址,也不能直接调用puts等函数,所以需要ROP三次。
- 第一次,填满
win_game_retaddr
以上的栈空间,利用演员printf
顺势拉出win_game
函数的返回地址。通过该返回地址与地址偏移量,计算start基址与任意已调函数的GOT
与PLT
。 - 第二次,利用经典的ROP调用链,泄露libc中函数的地址,根据libc文件中函数地址的偏移,计算libc基址
- 第三次,利用libc基址和
one_gadget
,计算execve
函数地址,覆盖win_game
函数返回地址,get_shell。
EXP:
1 | from pwn import * |
1.5 logger
好烦的一道题,做了好久,最后还是在求助下完成的(虽然被标是trivial难度的....)
直接给了源码,好好好。是一个Json字符串解析的程序。
1 | ............(上面还有好多行) |
输入一个Json字符串,程序会分成员进行解析。如果检测无误,就会调用wrapper执行touch
指令去创建一个以title
中字符串为名的文件。在这之前,程序还会检测title
中的成分,保证不含有恶意字符。
1 | bool file_name_check(char *filename, int checklen) |
OK,目标明确——绕过检测,只要能执行touch **;cat flag
就行。
有一个切入点,file_name_check
是根据title的长度进行检测的,长度以外的东西检测不到,但同样被输入到cmd中。
看了下输入的函数,由于我们要输入标准格式的Json字符串,所以没办法用00截断。
很有意思的一点,这道题中xor_handler没有对数组进行memset,也就是说,我们上一次输入的数据,在下一次输入时仍然存在原地,即便先前输入的东西过不了check。
1 | void o_xor_handler(uint8_t *title, uint8_t *data, char *stackbuf) |
好嘞,那就没有任何问题了,直接写EXP打:
1 | from pwn import * |
需要注意的两点:
- 本题的输入函数是需要读满512个字节,所以需要对输入的payload进行00补全
- 这里的flag其实的以掩码形式存在的,在程序内会被转化成1,2,4,8等。之前没注意到,在这卡了好半天。
1 | switch ((get_out_mask(l_flag))) |
成功get shell:
1.6 drawer
做的很吐血的一道pwn题,还学了一下Docker容器。在本地没有办法模拟,就说一下思路叭。
开局直接给了docker的配置:
1 | | |
查看Docker文件,其实看不太懂,但大意就是会运行start.sh
而start.sh是程序的启动脚本。不管这些,看程序先:
菜单?堆题?再仔细看看,好像不是。
程序实现了一个简单的画画的功能。
- new函数:输入长和宽,在堆中分配一段空间,空间大小为长乘宽
- draw函数:在刚刚分配的空间内输入内容
- preview函数:用于输出刚才输入的内容
- save函数:输入一个1byte长度的字符,以它作为 名字打开或新建一个文件,并把刚才输入的内容写入。
save函数如下:
可以看到它在创建了一个8bytes的filename数组后并没有memset将其置0,而是直接输入。
OK,那就有思路了,我们可以利用栈上存留的脏数据,让filename=start.sh
,从而修改启动脚本中的代码。由于容器不会被销毁,则第一次运行之后的结果在下次运行时仍然保留。即下次运行时的启动脚本,就是我们修改之后的了。
至于怎么把脏数据布置到栈上嘛,当然要请另外几个函数帮忙啦~
正片开始:
首先,利用new函数和draw函数把我们需要的东西输进去。
(start.sh原本的内容+cat flag)
1 | content = "#!/bin/sh\ncat flag;\n/etc/init.d/xinetd start;\nsleep infinity;" |
在save函数里,filename的栈地址为rbp-0x10 。需要明确,main函数中调用的函数所创建的新的函数栈,rsp可能会有所变化但rbp一定不变。
在preview函数中有rbp-0x10地址的变量,并且可以输入,并且的long unsigend
类型。(long unsigned
类型数据是8bytes的)可以利用。
但要注意,由于我们输入的是一个整形数,所以我们需要将start.sh
字符串转化为整形数。(ASCII码值,小端规则)打进栈中。
1 | idx = int.from_bytes(b"start.sh", byteorder="little") |
接下来是save函数。
用scanf
函数输入字符串,会自动在它后面加一个0x00
用于截断,但是我们的栈都布置好了,它一截断破坏栈结构。
重点来了!在isoc99_scanf
的源码中,如果其收到了EOF
信号会直接跳出来,不做任何输入。所以,在这里我们直接把与容器的链接shutdown
,isoc99_scanf
会收到EOF
被跳过,如此一来我们就可以顺利写入啦~
再次nc
容器,就能直接把flag cat出来。
EXP:
1 | from pwn import * |
就这些了,剩下的就没做出来了呜呜呜,还是太菜,继续练~