赛博杯新生赛 2024 - StackLogout 出题博客
去年4月份左右 pankas 转发了一篇推文,讲PHP通杀的,我一看原文,竟然是glibc组件的bug, 并且有cve编号。博客很详细,还更了整整3篇,不用调试就能看懂。
自从看到CVE-2024-2691的缓冲区溢出,我就一直想着考上一题,这次趁着新生赛,它来了!
出题思路
构思
我不打算考太难,于是就打算整一个栈迁移,相对堆来说还是好做的多的,但是又不能太简单, 于是我打算要先泄露信息,才能打栈迁移。我和 dbgbgtf 商量了一下,我俩都出栈迁移, 他的简单一点,就叫login,我呢刚好和他相反,叫logout。
我们的漏洞点是差不多的,我们俩都考缓冲区溢出,他的是Off by Null,我的是用cve溢出。
变换莫测的栈布局
我第一个写的就是有漏洞的函数,但是不同版本的gcc,不同的变量位置, 会导致相应的栈布局发生变化。正好创新实践的作业可选120行的汇编,于是我先写了一个c 源码作参考,然后开始写汇编:
1 |
|
为了熟悉熟悉多字节操作,我还在里面加了点SSE2指令,总之就是memset和memcpy的意思。 汇编代码有将近200行,在这里就不贴了,可以加入协会或等到仓库公开后, 在我们的仓库中找到。
GCC汇编优化命令
在gcc生成的汇编代码中,有许多以.开头的命令,在我的代码中就有许多。
.section .rodata.str1.8,"aMS",@progbits,1
: 生成一个段专门放对齐为8的字符串,最后会合并到.rodata
.p2align 4
: 等价于.align 2 ** 4
即.align 16
.equ canary, 8
: 声明一个常量.p2align 4,,10
: 当对齐到16字节边界的代价不超过10字节,则对齐
大部分命令是对齐,可以给现代cpu提供一些加速。例如在这系列博客中,提到了cpu会一次性加载16字节倍数的字节码,因此将字节码对齐到边界后,jump过来后需要加载的字节码减少了,适合放在热点代码处。
最后写完以后栈布局就是这样:
在泄露完信息后,通过cve在第一次输入时多写一个字节到toread
,造成足够长的长度做栈迁移,
然后第二次输入写掉前一个函数的rbp,等待上个函数返回执行栈迁移。
隐藏在ELF中的彩蛋
在ELF的注释段有我在汇编中插入的彩蛋哦
完善题目背景
既然这道题叫 StackLogout ,那么和stack_login对应的,我该整点退出操作,
同时在这些函数里要给选手保留泄露信息的机会。于是我顺理成章地想到了类shell操作,
手搓了一个"Pwn Shell"。并且在logout
时由于缓冲区没有初始化,留下了信息,
包含了libc、栈和canary。根据who
函数的逻辑,在函数执行完毕返回时,会将输入的内容复制回
logout
的buf
中,不带'\0'
,而打印缓冲区时使用%s
,于是造成信息泄露。
消失的leave
当我把main
写好,编译一看,logout
的leave
被优化没了。原来的计划是who
通过溢出改
rbp,logout
再做leave;ret
实现rop。
尽管我尝试开启-fno-omit-frame-pointer
,程序确实使用了rbp,不再将其作为临时寄存器,
但是离开函数时仍然没有使用leave
,而是rsp直接加了一个常数。
只有不开优化才能出现leave
,没办法了,给它编译时整个特例。并且,
由于之前设计的缓冲区大小是0x130,后期为了做起来简单调小了(不方便溢出多个字节),
因此也没什么地方能写canary,顺便把logout
的canary关了。
patchelf失败
题出完了,我想在本地patchelf以后试试,结果不行。我把ubuntu的libc拉下来,但是iconv_open
返回-1。我调了老半天,发现为了编码"ISO-2022-CN-EXT",需要加载其他库,而加载路径是写死的,
由于Arch Linux的默认库路径与ubuntu并不相同,因此无法打开扩展库,也因此无法进行字节转换。
一开始我想放在Roderick的容器上调,但是一旦apt upgrade
,libc库也会一同更新,
而这些扩展库同属于libc包。而且让新生用这个办法也未免太麻烦了一点。于是我在pwntools
里找其他的解决方案。我试着把程序放到容器中,然后ssh上去调试,结果打开的gdb是容器里的,
没法使用本地的。再次研究gdbserver,假设它的stdout
连接到pts/2
,然后在pts/3
中用gdb
连上去,它的输出仍然出现在pts/2
中!换言之,输入和输出和是不能通过gdb控制的。
于是我就想到了一个更优雅的办法:起一个容器,用xinetd分发gdbserver :1337 /home/ctf/pwn
,
这样然后用pwn.remote
连到xinetd获取程序的输入输出,再用gdb连到1337端口,打开调试,
如此实现了基于远程环境的调试,我也能直接把容器交给选手,方便选手的调试。
我的canary在哪里
题目出好了,我拿给 dbgbgtf 调试,结果他调试没有canary。这是怎么回事?我在本地尝试,
发现logout
中缓冲区上留下的canary是由pwnShell
中运行的strstr
留下的。在我的机子上,
strstr@PLT
实际运行了__strstr_generic
,但是 dbgbgtf 机子上却运行着__strstr_sse2_unaligned
,
而在这个函数中没有设置canary。我直接调试研究这样运行的原因,结果是与cpu特性有关。
我的cpu(R7 6800HS)没有Fast_Unaligned_Load
,因此使用了__strstr_generic
。
得,我直接让所有strstr
强制运行__strstr_generic
得了。
我强制让strstr
运行__strstr_generic
的方法是定义了如下全局变量:
static char *(* __strstr_generic)(const char *, const char *) = (void *)((size_t)puts + 0x2dc30);
我原先以为它会在运行时计算,结果在ld加载阶段就算好了,直接放到ro区域了,和别的GOT项一个待遇。
所以由于不同的libc库偏移不同,直接patchelf后运行大概率会挂掉,只能放在容器里调试。
题解
不需要在pwnShell
中做其他事,直接logout
。然后在who
中输入\xe0
并确认,以此在logout
中泄露
libc。类似的,参照上面的图,泄露出stack和canary。然后借助cve把toread
写成0x48
,在who
中再次输入,覆盖正确的canary并设置rbp。然后在logout
中确认,成功栈迁移并运行rop链。
需要注意的是,覆写rbp时不能留who
函数的栈帧地址,因为who
返回到logout
后还要做strchr
,
在这个过程中,复制的东西会被覆写掉。还记得who
退出前把缓冲区中的内容复制回logout
了吗?
借助这个功能,选择将栈迁移到logout
的缓冲区即可成功执行rop链。
EXPLOIT
1 | from pwn import * |
Overflow more!
可以注意到,原来的博客里说,可以溢出1-3字节,但是此刻我们只溢出了1字节,有没有办法能多溢出呢? 答案是有的。众所周知,中文utf-8占3字节,但是gb2312只占2字节稍微研究一下这个编码可知, 当字符集发生变换,从某个字面跳到某个未出现的字面时,会写入能溢出的4个控制字符, 我称这个写入控制字符的过程为 膨胀 。同时,如果转换很多中文字符,那么在转换过程中则会发生 收缩 。
假设输入缓冲区和输出缓冲区一致,如果在末尾加一个中文字符,则会溢出4(控制字符)-3(utf-8中文)个字节, 但是倘若我们先转换成中文,并加上大量一样的中文字,则会先膨胀再收缩,实现输出比输入短。 此时再在后面添加一个其他字面的中文,就可以实现3字节溢出。如下图所示,末尾的控制字符超过输入缓冲区 3字节,符合条件:
不难看到我在重新读入时toread & 0x1f8
,倘若溢出了2-3字节,则可以溢出写更多字节,变成ret2libc了。
不过由于who
在退出时还会做一个memcpy,将rbp - 0x40
位置的字节复制到rbp + 0x10
,
因此我们溢出的字节的结尾会被payload开头的一部分覆写,需要调整一下发送的payload。
我把patch贴在这里,接下来怎么打ret2libc留给读者思考。
1 | diff --git a/Pwn/StackLogout/StackLogout.py b/Pwn/StackLogout/StackLogout.py |
参考
- 标题: 赛博杯新生赛 2024 - StackLogout 出题博客
- 作者: RocketDev
- 创建于 : 2024-12-22 10:47:00
- 更新于 : 2024-12-22 22:44:00
- 链接: https://rocketma.dev/2024/12/22/StackLogout/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。