引言:
2025 SJTU CTF PWN方向题解,包含GuessMaster,ret2vmcode,TheLampSecret,ezshellcode四道赛题
1 GuessMaster
看程序是一个随机数预测,一百次预测成功之后就会给两次溢出机会。
程序开了canary,time(0LL)
就是拿当前时间戳,只要网速够快就可以保证本地和远端时间戳同步,加上offset
就可以得到随机数种子。
这个offset
虽然在IDA里看是0xa
,但是在start里面好像会对这个变量做一堆莫名其妙的变化。GDB动调一下发现最后的offset是0x6e
程序还给了个后门函数,那就直接随机数预测+canary泄露+ret2text一把梭:
1 | import ctypes |
2 ret2vmcode
一个opcode的题目,给了很多操作原语,先阅读理解一下。
一个操作原语是9个字节,第一个字节表明这是什么操作,然后剩下八个字节前四个代表一个操作寄存器,后四个代表一个操作寄存器,而且程序的寄存器只有4个可用:
把所有代码给Claude 3.7 sonnet
,让他给我阅读理解一下。
这个程序一共分配0x300
字节大小的虚拟内存空间,并且表明前0X100
字节是指令区,后0x200
字节是数据区。
可以通过操作原语修改vm内存区的任意地址内存,这里并没有任何限制:
发现程序还提供了一个syscall
的操作原语:
很给的ORW
,所以本题的目的就是通过ORW
来获取flag
的内容。
但在main函数中对输入的字符有限制,不能出现0xf9
, 对应sys操作的标识符。
没关系,从store
和load
中可以看到程序并没有对指令操作的地址有任何限制,也就是我们可以通过前面的指令去修改后面指令的标识符,来绕过这个检测。
思路可行,exp如下:
1 | from pwn import * |
需要注意本题只能输入0x100
个字节,所有orw,在r之后,后两个寄存器不用变,直接改第一个寄存器即可完成w,节省空间。
本地运行题目,获取flag:
3 TheLampSecret
一个堆题,但又不那么堆。程序大意是一个许愿的功能,在init
里会分配两个堆块,一个是许愿池,一个是标记许愿池里每0x40
(一个愿望)的使用情况。
wish_count << 6
表明了每个愿望的大小就是0x40
字节。
程序提供了几个功能,许愿,查看愿望,编辑愿望,删除愿望,重置愿望还有一个thank tomorin
make
view
edit
delete
都没什么洞,主要还是reset
和thank
先看thank
, 是白给的一个栈溢出,但程序里没有提供后门函数,而且开了PIE,想要溢出还要泄露canary
。
在reset
里可以重置愿望池子,这里有一个很明显的负数溢出的漏洞。当我们输入wish_count
是0时,会绕过wish_count > 3d
的检测,下面会把wish_count - 1
,就变成了一个很大的无符号整形数。接下来由于这个count
特别大,最终分配的新的愿望池子的size
就是0x114514
虽然但是,老子一开始是按照我0xffffffff
的count
分配的,你私自把size
改小没通知count
,导致这里间接出发了一个很严重的越界访问读写的漏洞。
从之前几个函数的操作中可以得知,他们对于愿望池的内存访问都是根据wish_count
来的,所以我现在可以越界访问堆块外的数据。
由于分配的堆块是0x114514
大小,是一个很大的大堆块,会分配在一个高地址区间,这个地址区间有两个更高地址的邻居:
TLS
-> 泄露canary__rtld_global
-> 打exit_hook
一开始想泄露canary,但是看了下view_wish
, %s
输出我泄露个蛋。
看Dockerfile
, ubuntu:16.04
果断打exit_hook
1 | FROM ubuntu:16.04 |
打
exit_hook
的触发条件是程序通过exit
显式退出,或者程序通过main
函数正常返回,在libc 2.23 - 2.26
有效需要能写到
ld
加载段,可以有两种修改_rtld_global
的方式,任选其一:
- 改
rtld_lock_default_lock_recursive
或rtld_lock_default_unlock_recursive
为system
,_dl_load_lock
为/bin/sh\x00
1
2
3
4
5
6 0x7fb213ac5dca <_dl_fini+106> lea rdi, [rip + 0x1cb97] <_rtld_global+2312>
► 0x7fb213ac5dd1 <_dl_fini+113> call qword ptr [rip + 0x1d191] <execvpe+638>
rdi: 0x7fb213ae2968 (_rtld_global+2312) ◂— 0x0
rsi: 0x0
rdx: 0x7fb213ac5d60 (_dl_fini) ◂— endbr64
rcx: 0x1
- 改
rtld_lock_default_lock_recursive
或rtld_lock_default_unlock_recursive
为 one_gadget
1
2
3
4
5 ► 0x7f83b9c36dd1 <_dl_fini+113> call qword ptr [rip + 0x1d191] <rtld_lock_default_lock_recursive>
rdi: 0x7f83b9c53968 (_rtld_global+2312) ◂— 0x0
rsi: 0x0
rdx: 0x7f83b9c36d60 (_dl_fini) ◂— endbr64
rcx: 0x1三者在
_rtld_global
中的偏移如下:
1
2
3
4
5 ld_base = libc_base + 0xffffffff
_rtld_global = ld_base + ld.sym['_rtld_global']
_dl_rtld_lock_recursive = _rtld_global + 0xf08
_dl_rtld_unlock_recursive = _rtld_global + 0xf10
_dl_load_lock = _rtld_global + 0x908
我们还需要泄露一个libc基地址,make
里面是使用memcpy
把buf
里面的数据拷贝到愿望池中的,但在这之前并没有对buf
进行清空操作,在view
输出的时候就会把栈上的脏数据也一起拉出来。只要获得一个libc
段的地址, 再减去偏移,就可以获得基地址。
本题的exp如下:
1 | from pwn import * |
本地运行获取shell
:
4 ezShellcode
手搓不可见字符shellcode 比手搓可见字符简单多了。
可以输入0x100
个字节,strlen
要求不能有\x00
字符,不然会被截断,下面还有一个check
,函数里面的意思就是保证shellcode里不能有[A-Za-z0-9]
这题跟一般题目还有一个不一样的点就是,他会把输入的东西进行一个转换,转换之后的才是要执行的shellcode
。
直接把转换逻辑丢给AI让他帮我逆了,看起来就是取相邻的两个字节,进行一个类似BCD码的转化(不懂啊不懂啊)。
明确以下几条规则:
- x86/64下大多数对32位寄存器的操作不产生可见字符
- 在跳转指令中
jg, jge
等无符号数比较不产生可见字符 jmp qword ptr
指令不产生可见字符(除非偏移的数值中产生可见字符)mov dword ptr []
指令不产生可见字符(除非偏移的数值中产生可见字符)- 针对32位寄存器的立即数操作,立即数会被对齐到32位,如果位数不够会产生
\x00
syscall
是\0xf \0x5
,没有可见字符
本题思路如下:
- 第一次输入
shellcode
由于会被校验,所以直接拿shell
不现实,这里一般思路是获取栈上在call rax
时被压进去的返回地址,跳转到read
函数再来一次华丽的输入。 - 由于本题机制的原因,假如我们第一次返回到了
read
函数重新输入,这时候输入的地址是我们第一次mmap
的地址,但read
之后会在进行一次mmap
, 这时候v8
会被刷新为一个新的地址,那么存入new v8
的数据,就是栈上的buf
。注意这个buf
, 等我们第二次mmap
的时候,buf
里存储的仍然是我们第一次输入的shellcode
, 最终会call new v8
, 再次运行一遍我们第一次输入的shellcode
。 - 所以我们需要让一份
shellcode
, 在两次运行时,执行不同的功能:- 第一次运行,获取栈上保存的返回地址,跳转到
read
函数,进行old v8
内存的覆写,并将old v8
指针保存在栈上,设置计数器 - 第二次运行,检查计数器,如果已经被执行过,获取第一次运行时保存在栈上的
old v8
, 跳转过去,getshell
- 第一次运行,获取栈上保存的返回地址,跳转到
栈真是个好东西,虽然程序在运行时会在函数里call 来call去,但是我
rbp
岿然不动,这里面还涉及到很多动调的细节,比较考验耐心就是了。
本题exp
如下:
1 | from pwn import * |
运行exp
,获取shell: