羊城杯 2024 pwn writeup
去年就知道这个比赛很卷,没想到今年更卷。某某战队距离比赛结束还有40分钟时排名第一,比赛结束时排第二十一,真的逆天。
这次比赛学长不是在实习就是去帮别的战队打,到头来pwn全都只能我一个人来打,真的好累喵😇。还好题目不是很难,一共五题pwn,前4题很快就打完了,最后一题巨抽象,本地不同的打法都通了,远程死活不同,真是让人道心破碎捏。
pstack
这是本次比赛的签到题,主逻辑如下:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
可以看到这里存在0x10
字节的溢出。由于只能覆盖rbp
和ret
,所以需要栈迁移,然后ret2libc
,这里我栈迁移了2次,第一次用来泄露地址,第二次用来getshell。可是不知道是不是环境的问题,我本地打system("/bin/sh")
死活都不通,打execve("/bin/sh",0,0)
发现栈迁移后溢出的字节不够我写rop链,最后只能使用libc上的gadget来使其满足其中一个one_gadget
来getshell😇
exp:
1 | from pwn import * |
TravelGraph
这是本次比赛唯一的堆风水题,其题目大概的意思是让我们输入路径,然后有个函数叫Dijkstra
用来计算最短路劲,这里先看堆块是申请的
可以看见堆块的大小和我们选用的交通工具有关系,能申请的堆块大小有0x520/0x530/0x540
free函数中存在十分明显的UAF
漏洞
edit函数只能够使用一次,而且在使用前需要满足edit_flag2
变量的值为true
,这个变量的值可以通过Dijkstra
函数计算当前城市距离guangzhou
的距离是否大于2000
来改变
可以由于add函数对路径的长度有限制,所以正常情况下这个条件是无法满足的
求解思路为利用堆风水合理构造堆块获取一次edit
机会,然后largebin attack
打_IO_list_all
。由于这里开启了沙箱,所以我们使用orw
读出flag。这题的栈地址远程和本地有 8
字节的偏差,逆天
exp:
1 | from pwn import * |
httpd
这题实现了一个http服务器,其功能是对get请求的路径进行访问,如果路径以及文件合法就会打印出文件的内容
如上图所示,程序对我们输入的路径进行了十分严格的过滤,我们无法直接获取/flag
的内容,因为会给过滤掉,可是我注意到了下面有个popen
函数
函数的定义如下:
1 |
|
函数说明:
popen()函数通过创建一个管道,调用fork()产生一个子进程,执行一个shell以运行命令来开启一个进程。这个管道必须由pclose()函数关闭,而不是fclose()函数。pclose()函数关闭标准I/O流,等待命令执行结束,然后返回shell的终止状态。如果shell不能被执行,则pclose()返回的终止状态与shell已执行exit一样。
type参数只能是读或者写中的一种,得到的返回值(标准I/O流)也具有和type相应的只读或只写类型。如果type是”r”则文件指针连接到command的标准输出;如果type是”w”则文件指针连接到command的标准输入。
command参数是一个指向以NULL结束的shell命令字符串的指针。这行命令将被传到bin/sh并使用-c标志,shell将执行这个命令。
popen()的返回值是个标准I/O流,必须由pclose来终止。前面提到这个流是单向的(只能用于读或写)。向这个流写内容相当于写入该命令的标准输入,命令的标准输出和调用popen()的进程相同;与之相反的,从流中读数据相当于读取命令的标准输出,命令的标准输入和调用popen()的进程相同。
函数作用:
popen函数允许一个程序将另外一个程序作为新进程来启动,并可以传递数据或者通过它接受数据。
其内部实现为调用 fork 产生一个子进程,执行一个 shell, 以运行命令来开启一个进程,这个进程必须由 pclose() 函数关闭。
这么说popen
函数就类似于system
函数可以让我们进行任意命令执行,可是程序对我们输入的路径还进行了第二层过滤
1 | _BOOL4 __cdecl sub_1F74(const char *a1) |
可以看到我们不能直接使用/bin/sh
来起shell了,而且我们也不能直接cat flag
,因为这中间有空格,会导致程序判断我们的get请求格式错误,所以我们可以先将/flag的内容保存到当前目录下的tmp文件中,然后再读取该tmp文件来获取flag
转移flag:
1 | path = 'cat</flag>tmp' |
读取flag:
1 | path = './tmp' |
logger
这道题目打的是C++
中的异常处理,存在一个很明显的栈溢出,可是开启了canary
,需要用异常处理来绕过
这里我一开始想的是打 CHOP
,可是附件里并没有给各种依赖,然后我发现了下面这个东东
好家伙,这不是直接送我个system
函数吗。经过调试,这个地方执行时rdi
的值一直为0x4040A0
这个地方存储了一个用来报错的字符串,我们注意到上面有一个数组,该程序存在一个该数组的越界写
当索引为8时我们就能在0x4040A0
上写数据,我们可以直接写/bin/sh
,然后利用catch
中的system
来直接getshell
1 | from pwn import * |
sandbox(after competition)
这题是最抽象的,本地通可是远程不通😰😰😰😰😰😰
典型的菜单题,申请的堆块大小只访问了0x500-0x1000
,delete
函数存在明显的 UAF
所以直接larginbin attack
打_IO_list_all
即可,可是这只是恶梦的开始,这题开启了沙箱
可以看到程序吧open
和openat
给ban了,于是我马上想到了openat2
,shellcode
如下:
1 | shellcode = asm(""" |
在本地执行 shellcode
后可以马上获取到flag,可是远程不行,当时我认为出题者把flag的文件名给改了,所以我写了以下 shellcode
来获取当前目录下的所有文件名:
1 | shellcode = asm(""" |
在本地测试是没问题的,可是到远程就依然什么都没有,后面发现远程的 kernel
版本是 5.4
,而 openat2
系统调用是在 kernel 5.6
才引入的,所以这种方法作废
然后我想到了 io_uring
,可是依然是本地能获取flag,远程无法获取flag,那大概率就是不知道flag的路径和文件名的问题了,于是比赛中就没有做出来……
赛后再重新仔细研究了一下题目,发现 seccomp
禁用系统调用的时候并没有直接 return KILL
,而是 return TRACE
,然后我在项目 The Linux Kernel documentation
上找到了对于该返回值的描述:
SECCOMP_RET_TRACE:
When returned, this value will cause the kernel to attempt to notify a ptrace()-based tracer prior to executing the system call. If there is no tracer present, -ENOSYS is returned to userland and the system call is not executed.
A tracer will be notified if it requests PTRACE_O_TRACESECCOMP using ptrace(PTRACE_SETOPTIONS). The tracer will be notified of a PTRACE_EVENT_SECCOMP and the SECCOMP_RET_DATA portion of the BPF program return value will be available to the tracer via PTRACE_GETEVENTMSG.
The tracer can skip the system call by changing the syscall number to -1. Alternatively, the tracer can change the system call requested by changing the system call to a valid syscall number. If the tracer asks to skip the system call, then the system call will appear to return the value that the tracer puts in the return value register.
The seccomp check will not be run again after the tracer is notified. (This means that seccomp-based sandboxes MUST NOT allow use of ptrace, even of other sandboxed processes, without extreme care; ptracers can use this mechanism to escape.)
也就是说我们有办法对 seccomp
进行逃逸,其具体做法为:使用 fork
开一个子进程,子进程需要 ptrace(PTRACE_TRACEME, 0, 0,0);
来允许自己被父进程追踪,父进程需使用 ptrace(PTRACE_ATTACH, pid, 0, 0);
来追踪子进程。然后父进程在 wait()
阻塞等待子进程发起系统调用。一旦捕捉到,则子进程阻塞,父进程继续运行,此时需用 ptrace(PTRACE_0_SUSPEND_SEECOMP, pid, 0, PTRACE_0_TRACESECCOMP);
将被 TRACE
系统的调用改为允许运行,然后 ptrace(PTRACE_SCONT);
来恢复子进程的系统调用执行。由于我们不知道 flag
的路径和文件名是什么,所以直接使用 execve
来拿 shell
, exp
如下:
1 | from pwn import * |
运行后即可稳定拿 shell
,效果如下:
可是这个 shell
并不能使用 ls、cat
这些指令,只能使用 cd、pwd、echo
这种比较基本的,而且 echo
的功能还不全,下面给出一些可以用来平替的脚本:
1 | # ls |
效果如下:
那为什么 ls、cat
等指令无法使用呢?这里以 ls
为例解释一下:
ls命令的实现可以分为以下几个步骤:
打开目录:首先,需要打开要列出文件的目录。可以使用
open()
系统调用来打开目录,并获得一个目录文件描述符。读取目录项:通过
readdir()
系统调用,可以从打开的目录中读取目录项。readdir()
会返回一个指向目录项结构体的指针。通过循环调用readdir()
,可以逐个读取目录中的文件。过滤隐藏文件:在读取目录项之后,需要对目录项进行过滤。Linux中的隐藏文件以.开头,可以通过判断目录项的名字的第一个字符是否为.来过滤隐藏文件。
输出目录项信息:读取到一个目录项之后,可以通过目录项结构体中的字段获取文件的属性信息,比如文件名、大小、修改时间等。可以使用
printf()
函数将这些信息输出到终端。关闭目录:使用
closedir()
系统调用来关闭打开的目录,释放资源。
可以看到执行 ls
命令需要使用 open
系统调用,可是我们拿到的 shell
依然处于沙箱的环境中,open
系统调用给禁止使用,这也意味着我们无法使用 ls
命令