这次的比赛还是很有趣的,学到了许多。
一些地方写得比较简略,如果师傅们有看不懂的欢迎交流。
get-me
分析
shell 命令行注入。程序中有下面的危险命令执行:
1 | sprintf(s, "/usr/bin/python3 -c 'print(%s + %s)'", v4, v5); |
让单引号闭合,用 “;” 隔离,在 “)” 前加 “\“ 就好了。
exp
1 | from pwn import * |
real-bf
分析
虚拟机题,主要难点在于逆向。
程序实现了栈上 rsp 的移动,加减运算,以及输入输出 rsp 指向地址的值等功能。
漏洞点是 rsp 向高地址处的移动没有边界限制,因此可以影响到真实的栈。
题目没有开 NX。泄漏栈地址后,覆盖返回地址为栈上布置的 shellcode 的地址就行。
exp
1 | from pwn import * |
spraying
一般 CTF 中的 Linux Kernel 题的出题点是如何利用 Linux Kernel Module 中的漏洞进行提权。
入门可以看:Linux Kernel Pwn_0_kernel ROP与驱动调试 和 CTF-Wiki Pwn Kernel 基础知识
分析
boot.sh
解压题目附件后,先看看 qemu 的启动参数 vim ./boot.sh
:
1 | qemu-system-x86_64 -m 120M -kernel ./bzImage -initrd ./rootfs.img -append "console=ttyS0 root=/dev/ram rdinit=/sbin/init nokaslr" -cpu qemu64 -nographic -s |
没有开 smep 之类的保护,还把 kaslr 关了。(但跟后面解这题没啥关系)
etc/init.d/rcS
解压 rootfs.img 后,看一下 vim ./etc/init.d/rcS
:
1 |
|
忽略掉一些完全看不懂的东西,有几处需要注意:
cat /proc/kallsyms > /tmp/kallsyms
:这样我们可以从 /tmp/kallsyms 读取到一些 kernel 中函数的地址了,如 ·prepare_kernel_cred
、commit_cred
(这点也和解这题没啥关系)insmod /pax.ko
:将驱动模块加入到内核中。下一步我们就要研究这个驱动模块。setsid cttyhack setuidgid 1000 sh
:设置了 uid 和 gid。调试时可以把 1000 改成 0,这样查看一些用于调试的 kernel 信息。
pax.ko
从解压 rootfs.img 找到 pax.ko,拖入 ida 反编译,发现两个重要函数:
pax_ioctl
用户程序中调用 ioctl
时,内核中就会调用这个函数。看看源码:
1 | __int64 __fastcall pax_ioctl(file *file, unsigned int cmd, size_t arg) |
该函数的功能是接收从用户程序传入的地址和四字节值,将四字节值写入该地址。我们得到了一个内核中的任意地址写。
pax_open
用户程序中调用 open
时,内核中就会调用这个函数。看看源码:
1 | int __fastcall pax_open(inode *inode, file *file) |
函数打印了 EUID 的地址。结合前面的任意地址写,我们可以覆盖进程的 UID、EUID、GID 等,达成提权到 root。
接下来开始调试,确定一下该地址是否真的为 EUID 的地址。
调试
写一下详细的步骤:
提取出未压缩的 kernel:
./extract_vmlinux.sh ./bzImage > ./vmlinux
./boot.sh
启动 qemu,gdb ./vmlinux
启动 gdb看一看 pax 的 地址(需要 root 权限)
1
2/ # cat /proc/modules | grep pax
pax 16384 0 - Live 0xffffffffc0002000 (OE)添加 pax 的符号表以便调试
1
2
3
4pwndbg> add-symbol-file ./core/pax.ko 0xffffffffc0002000
add symbol table from file "./core/pax.ko" at
.text_addr = 0xffffffffc0002000
Reading symbols from ./core/pax.ko...下断点在
pax_open
上:1
2pwndbg> b pax_open
Breakpoint 1 at 0xffffffffc0002010: file /root/xss_pwn/core/pax.c, line 27.gdb 连接 qemu:
1
2
3pwndbg> target remote localhost:1234
Remote debugging using localhost:1234
0xffffffff81948ba6 in ?? ()qemu 内输入
cat /proc/pax
单步调试,找到
current_task
的地址1
2
3
4*RAX 0xffff8800056495c0 ◂— 0 // 这就是current_task的地址
...
0xffffffffc000201d <pax_open+13> mov rax, qword ptr gs:[0x15bc0]
► 0xffffffffc0002026 <pax_open+22> mov rbp, rspgdb 内打印
current_task
的 cred 的 euid 的地址:1
2pwndbg> p &(*(*(struct task_struct *)0xffff8800056495c0).cred).euid
$7 = (kuid_t *) 0xffff88000560ccd4gdb continue,查看 qemu 内打印的 euid 的地址:
1
2/ # cat /proc/pax
[ 24.690799] EUID: 0xffff88000560ccd4
两者相同,说明 open 得到的 EUID 的地址是正确的。
利用
写一个用户程序,打开 /proc/pax
设备,运行 dmesg 命令手动获取 EUID 的地址,然后多次调用 ioctl
覆盖 UID、EUID、GID 等为 0,最后查看 uid,发现为 0,getshell。
麻烦是程序如何上传到远程服务器。
上传
题目提供了一个 python 脚本来传输,原始版本如下:
1 | # -*- coding: UTF-8 -*- |
脚本先将 ELF 文件压缩,再 base64 编码,传输到远程。在远程解码、解压。
即使这么做了,还是有两个问题:
- 由于文件系统中没有 libc 库,我们只能将程序静态编译。而静态编译的 ELF 文件会很大。(就算经过了压缩和编码还是很大)
- 经过我测试
cat <<EOF > exp.gz.b64
一次只能传 0x1000 字节,文件太大的话,传着传着远程 qemu 就终止了。。。
核心问题在于 glibc 太大了,所以我 google 找到了一个体积较小的 libc:[diet libc - a libc optimized for small size](diet libc - a libc optimized for small size)
对比一下 glibc 和 diet libc 编译的 exp 文件体积,分别是 994K 和 31K,差距巨大。且后者在压缩和编码后仅有 0x414c 字节。
还需要提到的一点是,我们在远程的目录中没有写权限,但对 gen.sh、insert.sh 和 pax.ko 有读写执行权限,所以我们可以把解码和解压的结果写到这三个文件中。
exp
pwn
1 |
|
上传脚本
1 | # -*- coding: UTF-8 -*- |
效果
1 | size = 0x414c |
dice
非常有趣的一题,争取下次新生赛我也出一道这种题。
分析
程序实现了一个猜骰子数的小游戏,以及 robot 的一些逻辑。
逆向不难,但需要一些时间,要有一些耐心。主要有这些函数:
后面会重点分析一下其中的一些函数。
规则
程序开头就打印了规则:
Welcome to Liar’s Dice!
How many players total (4-10)? 10
The game works like this:
Each player starts the game with the same number of dice. At the beginning of each round, all players roll their dice.Each player keeps their dice values a secret. Players take turns placing “bets” about all the dice rolled for that round.
A bet consists of a die face (1-6) and the number of dice that player believes were rolled. Once a player places their bet,
the next player may decide to raise the bet, call the last player a liar, or say that the last bet was “spot on.”1) If the player chooses to raise the bet, they must either increase the number of dice, the die face value, or both. They may not decrease
2) If they believe the last bet was wrong, then all players reveal their dice. If the bet was valid, the challenger loses a die. Otherwise,
3) If they believe the last bet was exactly correct, and they are right, they are rewarded with an extra die.Once a player has no more dice, they are eliminated. The last player standing, wins.
Have fun! And remember, no cheating!
player 可以进行一下几种操作:
- Bet:一个 Bet 需要提供骰子面和对应骰子面数。Bet 要么骰子面比前一个大,要么对应骰子面数比前一个大,也可以两个都大。
- Liar:当前 player 认为前一个 Bet 是无效的(大于对应骰子面数),如果是,做出前一个 Bet 的 player 失去一个骰子,如果不是,当前 player 失去一个骰子。
- Spot on:当前 player 认为前一个 Bet 是正确的(等于对应骰子面数)如果是,当前 player 获得一个骰子,如果不是,当前 player 失去一个骰子。
在看完规则后,我们可以借助一个二维数组帮助理解:
现在假设该轮有 1 个 1,2 个 2,3 个 3,4 个 4,2 个 5,2 个6。
我们可以找出“安全区”(S)、“得分区”(O)和“失分区”(X):
face\num | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
1 | O | X | X | X | X |
2 | S | O | X | X | X |
3 | S | S | O | X | X |
4 | S | S | S | O | X |
5 | S | S | O | X | X |
6 | S | S | O | X | X |
Bet 可以看作是指针在该数组上的移动,而且只能向下或者向右移动。
这可以说是一个博弈的游戏,我们通过自己骰子的面、以及其它玩家的 Bet 来做抉择。
最终目的是成为唯一存活(最终唯一有骰子的人),那我们在游戏过程中就要尽量不丢失骰子(合理 Bet),尝试把别人的骰子搞掉(Liar)、以及获得骰子(Spot on)。
作弊的 robot
但这个游戏的 robot 很坏,它记录了所有骰子面的数量,看下面的函数:
然后它会用记录的数据作抉择:
具体看看它是怎么做抉择的:
太坏了!如果发现上一个玩家是用户的话,它就会尝试 spot on 和 lier,而对其它的 robot 无动于衷。但如果无法 spot 和 lier 的话,它又会非常保守,具体看 robot_default_bahave
:
这里截不完全,结合上面的二维数组讲解一下逻辑。
face\num | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
1 | O | X | X | X | X |
2 | S | O | X | X | X |
3 | S | S | O | X | X |
4 | S | S | S | O | X |
5 | S | S | O | X | X |
6 | S | S | O | X | X |
robot 会从上一个 Bet 的位置开始,对这个地图进行向右和向下的搜索,以确定当前的 Bet。先尝试向右一格,如果没碰到危险区,就在此处 Bet;否则向下不断搜索,直到碰到安全区,若一直没碰到安全区,则说明上一个 Bet 在得分区。
也就是说利用骰子面信息,robot 永远不会犯错。即使我们能获得和它相同的信息,我们也赢不了游戏。
漏洞
负数索引
在查询玩家骰子数时存在负数索引的漏洞:
也就是说我们可以读取任意地址的四字节值,从而泄漏一些游戏的信息,以及 getshell 需要的一些地址。
robot 逻辑和系统逻辑的不一致
我们前面知道 robot 会作弊,记录骰子面数。但系统在记录时采用了不同的做法:
dices_face_this_roll_for_judge
就是系统用来记录的每个 player 的骰子面的数组。这里系统的错误(或者说与 robot 的不一致)在于,默认 player 的骰子数固定为 5。当一个 player 的骰子数大于 5 时,其顺序号大于 5 的骰子面部分会被下一个 player 的骰子面覆盖。也就是说,掷骰子时最多只掷 5 个。这样的话,后面计算骰子面数也会出错。robot 的做法反而是正确的。
据此可以推测,随着游戏的进行,总会有 player 的骰子数越来越大。而 robot 利用与系统不一致的数据,做出的抉择出错的可能性就越大,我们就有可乘之机了。
栈溢出
当我们胜利后,有一个很明显的栈溢出:
1 | char s[520]; // [rsp+120h] [rbp-210h] BYREF |
利用
win the game
做法很简单:泄漏 seed 以生成一系列随机数,采用和系统一样的方法计算骰子面数,和 robot 一样的方法来做抉择。具体请看 exp。
getshell
需要泄漏一些值才能利用栈溢出,完成 getshell:
- canary:程序开了 canary 保护
- seed:为了预测接下来的骰子面
- libc 基址:为了执行 system(“/bin/sh”) 、以及泄漏 environ 变量的值
- 环境变量的起始地址:为了泄漏 canary
- heap 基址:为了计算偏移
泄漏链为 (heap 基址 和 libc 基址) –> (环境变量的起始地址) –> (canary 和 seed)。
问题在于要找到 heap 基址和 libc 基址,在 gdb 内查看 chunk:
经过调试后发现程序调用 fopen
、fread
、fclose
后产生了这个 chunk,其中就有 libc 地址和 heap 地址。
起初我以为调用 srand
后 libc 中会有一个全局静态变量存着 seed 的值,看源码后发现确实有,但在 srand
中被修改了。这样就只能泄漏栈上的 seed 值了。
我本来想泄漏 thread local storage 中的 canary 值的,既然都泄漏栈地址了,就在栈中找吧。
exp
pwn
我不太会 python,可能写得很啰嗦、难懂。
1 | from pwn import * |
生成随机数
1 |
|
效果
一开始 robot 和系统的计算结果还比较接近:
1 | Round 1 |
到后面就变得离谱了:
1 | Round 83 |