HackerGame 2023 WriteUp

感想

今年 hackergame 圆满结束,很享受的一次旅程(同时也有点折磨 QAQ),hackergame 这一个星期 ddl 挺多的,所以本次名次并不高,校内 15 名,总排名 226,这篇博客记录的不仅仅是自己做出来的题目的 WP,同时也对官方题解和我感兴趣的题目做一个 log,学习多点知识。

WP 部分

HackerGame 启动

签到题,打开题目后让你大声喊出 hackergame 启动!要求准确率到达百分之 99.99%,这是不可能的,上传的时候发现他是在 url 传参的,改一下参数(simularity=99.999)就过了。

猫咪小测

今年猫咪小测一共有四道题:

1. 想要借阅世界图书出版公司出版的《A Classical Introduction To Modern Number Theory 2nd ed.》,应当前往中国科学技术大学西区图书馆的哪一层?(是一个非负整数)

答:这道题给了提示是一个非负整数,而且图书馆不会很高,直接 0-20 爆破就行了。

2. 今年 arXiv 网站的天体物理版块上有人发表了一篇关于「可观测宇宙中的鸡的密度上限」的论文,请问论文中作者计算出的鸡密度函数的上限为 10 的多少次方每立方秒差距?(是一个非负整数)

找学术论文这里用中文一定是不可行的,用 chatgpt 翻译成英语:“The upper limit of the density of chickens in the observable universe”,之后再谷歌搜索即可得到论文,答案是 23。

3. 为了支持 TCP BBR 拥塞控制算法,在编译 Linux 内核时应该配置好哪一条内核选项?(输入格式为 CONFIG_XXXXX)

问 chatgpt,答案为 CONFIG_TCP_CONG_BBR。

4. 🥒🥒🥒:「我……从没觉得写类型标注有意思过」。在一篇论文中,作者给出了能够让 Python 的类型检查器 MyPY mypy 陷入死循环的代码,并证明 Python 的类型检查和停机问题一样困难。请问这篇论文发表在今年的哪个学术会议上?(会议的大写英文简称,比如 ISCA、CCS、ICML。)

我的做法是直接爆破所有的和 python 有关的学术会议,最后爆破出来是 ECOOP。官方题解则是以”python type check mypy halting problem”为关键词搜索对应的学术论文。

更深更暗

进去后,提示我们要翻到最底下。

我们直接 bp 抓包,看源代码,发现 flag 是 token 的哈希加密值,直接在本地复现即可:

1
2
3
4
5
6
7
const CryptoJS = require('crypto-js');
let token =
'340:MEQCID15NFRrTG68kB61LStGU/dVdE7xfkJYGlzMvO+UEfRyAiBZn5rm5XJZ4RBT54m7Qdu/fLoKzne1MGfiXFaPbNKuhQ==';
let hash = CryptoJS.SHA256(`dEEper_@nd_d@rKer_${token}`).toString(
CryptoJS.enc.Hex
);
console.log(`flag{T1t@n_${hash.slice(0, 32)}}`);

旅行照片 3.0

一年一度的社工题,这里分享我的做法。

题目1-2: 1、你还记得与学长见面这天是哪一天吗?(格式:yyyy-mm-dd) 2、在学校该展厅展示的所有同种金色奖牌的得主中,出生最晚者获奖时所在的研s究所缩写是什么?

首先确认时间是在暑假发生,然后仔细观察图片,发现有:

谷歌一搜发现是学术会议 statphys28,一搜发现时间在 8 月 7 日-8 月 11 日举行,举行地点是东京,其实就已经确认了学长是在东京大学读书,时间一个很小的范围,把第二道题确认下来爆破。

第二道题给了一个奖牌,上面有个人名:M.KOSHIBA

搜索后发现是诺贝尔物理学奖获得者,这里就需要寻找东京大学诺贝尔物理学奖获得者最年轻的那位:

发现是梶田隆章,研究所的缩写为 ICRR,然后根据这个一起去爆破第一问的时间,确认答案为:

2023-8-10ICRR

题目3-4 3、帐篷中活动招募志愿者时用于收集报名信息的在线问卷的编号(以字母 S 开头后接数字)是多少? 4、学长购买自己的博物馆门票时,花费了多少日元?

对于第 3 题,谷歌对帐篷图片识图,发现是上野公园,谷歌搜索上野公园 2023 年 8 月 10 日的活动,看到有个梅酒节活动,里面有 staff 大募集,能拿到问卷编号。

对于第 4 题,上野公园的博物馆基本可以确定是东京国立博物馆,直接去搜他的官网,发现他是对大学生免费的,所以答案就是0

题目5-6 5、学长当天晚上需要在哪栋标志性建筑物的附近集合呢?(请用简体中文回答,四个汉字) 6、进站时,你在 JR 上野站中央检票口外看到「ボタン&カフリンクス」活动正在销售动物周边商品,该活动张贴的粉色背景海报上是什么动物(记作 A,两个汉字)? 在出站处附近建筑的屋顶广告牌上,每小时都会顽皮出现的那只 3D 动物是什么品种?(记作 B,三个汉字)?(格式:A-B)

对于第 5 题,我当时猜测集合地点可能是坐船的地点,然后把浅草那边所有的码头的枚举了都不对,然后仔细看看,发现“学长即将开始他的学术之旅”,就又回去看了看 statphys28,发现他是在晚上举行的,statphys28 的官网有谷歌地图定位:

进去后发现是在安田讲堂

对于第 6 题,直接在推特搜”ボタン&カフリンクス”

可以确定是个熊猫,第二小问就比较离谱了,我当时没注意看是出站附近,以为是上野站附近的广告牌有 3d 小动物,看了很久的街景都没发现有(心想这地方那么村,怎么会有超大的 3d 广告牌),后来没头绪了仔细看了一下,发现是出站,他的第三张图片是去了任天堂马里奥世界,在涩谷,那就很简单了,在涩谷站出站每小时都会顽皮出现的那只 3D 动物就是秋田犬

赛博井字棋

这道简单题卡了我挺久 QAQ,进去后抓包看他 script.js 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function setMove(x, y) {
if (board[x][y] != 0) {
return;
}
if (frozen) {
return;
}
let url = window.location.href;
let data = { x: x, y: y };
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
}).catch(errorHandler);
}

仔细思考发现,他这个下棋的判断逻辑是在前端就已经写好了,即他是通过前端组件对棋盘进行判断的,这样的话我们直接重放 api 传合法的参数,就能把 ai 下的棋子篡改成我们的棋子,获得 flag:

奶奶的睡前 flag 故事

这道题我居然没做出来,真的就是没看提示(谷歌亲儿子),我以为是 png 隐写,结果是 pixel 的漏洞,谷歌 pixel 自带的截图编辑工具截出来的图,我们是可以把它已经裁剪过的截图给复原的,用网站:
https://acropalypse.app

组委会模拟器

Web 编程题,这里需要注意的是,在发送报文的时候,不要等待获取了响应之后再发新的报文,发送了一个报文后马上要开一个新的线程去发下一个报文。

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
import requests
import time
import json
import re
import threading

# 设置 session
session = requests.Session()
session.cookies.set("session", "eyJ0b2tlbiI6IjM0MDpNRVFDSUQxNU5GUnJURzY4a0I2MUxTdEdVL2RWZEU3eGZrSllHbHpNdk8rVUVmUnlBaUJabjVybTVYSlo0UkJUNTRtN1FkdS9mTG9Lem5lMU1HZmlYRmFQYk5LdWhRPT0ifQ.ZT4eFw.eaokXgOWqIaaKVpIijCDrvuxRLo", domain="202.38.93.111")

# 获取消息
response = session.post("http://202.38.93.111:10021/api/getMessages")
data = response.json()

# 提取所有 hack[...] 格式的消息及其 delay
messages = []
for i, message in enumerate(data["messages"]):
text = message["text"]
delay = message["delay"]
if (match := re.search(r'hack\[\w+\]', text)):
messages.append((i, delay, match.group()))

# 对消息按 delay 排序
messages.sort(key=lambda x: x[1])

# 删除消息的函数
def delete_message(message):
response = session.post("http://202.38.93.111:10021/api/deleteMessage", json={"id": message[0]})
print("Deleted message:", message[2], "| Response:", response.text)

# 发送删除请求
for i in range(len(messages)):
if i > 0:
# 等待 delay[i] - delay[i-1] 时间
time.sleep(max(0,messages[i][1] - messages[i-1][1]))
# 开启新线程来发送删除请求
threading.Thread(target=delete_message, args=(messages[i],)).start()

# 等待最后一个消息被删除
time.sleep(max(0,3 - messages[-1][1]))

# 获取 flag
time.sleep(2) #等待所有线程运行完毕
response = session.post("http://202.38.93.111:10021/api/getflag")
print("Flag:", response.text}

跑完就能获取到 flag 了。

这里需要探讨一下官方的做法。

官方的做法是在浏览器里嵌入脚本:他这里的脚本就是每隔 100 毫秒查找出网页中包含 flag 的所有可点击的消息元素,然后点击它。

1
2
3
4
5
6
7
setInterval(
() =>
Array.from(document.querySelectorAll('.fakeqq-message__bubble'))
.filter((element) => element.innerHTML.indexOf('hack[') != -1)
.forEach((element) => element.click()),
100
);

“右键检查或按下 F12 键打开浏览器的开发者工具,选择「控制台」。将该脚本粘贴到控制台中,刷新页面(以重新开始题目)后按下回车执行脚本,就能自动撤回了。所有消息加载完毕后再稍等几秒,即可看到真正的 flag。”

满扫描电视,SSTV 图片传输,这里可以直接使用解码的脚本解码,跑出来直接就有 flag 了。

官方的解法使用解码软件,Linux 上的 QSSTV,然后使用 PipeWire 将播放器音频连接到 QSSTV 输入,解码获得到 flag。

JSON ⊂ YAML?

题目:

你知道吗?Hackergame 出题时,每道题都需要出题人用 YAML 格式写下题目的关键信息。然而,每年总有一些出题人在编写 YAML 文件时被复杂的语法规则弄得头疼不已。

这天小 Z 又找到小 W 说:「我昨天写 YAML 时,又花了半天研究 YAML 的规范,YAML 好难啊!」

小 W 惊讶道:「怎么会有人不会写 YAML 呢?只要你会写 JSON 就会写 YAML 呀,因为任何合法的 JSON 本身就是合法的 YAML。」

小 Z 听闻这番言论后当场表示怀疑,立刻说出了一个字符串,JSON 和 YAML 解析出的含义存在差异。小 W 研究了一番才发现,这是因为很多主流的 YAML 库仍然是 YAML 1.1 的,它没有这个性质。他不得不承认:「好吧,这个性质只适用于 YAML 1.2。」

小 Z 笑了:「别提 YAML 1.2 了,它遇到合法的 JSON 都有可能报错。」

这个题目很有意思,就是 JSON 和 YAML 文件合法性的检查,要找两个,分别是

  • JSON 和 YAML1.1
  • JSON 和 YAML1.2

JSON 和 YAML 都是一种序列化格式。对于 JSON 和 YAML1.1:

我们先来对比 JSON 和 YAML1.1 的格式,JSON 中的数字格式是严格额,不能用前导正号,前导 0,小数点后面必须有数字。而 YAML1.1 是很宽松的,并没有一个确定的规则来决定一个未标注类型的字符串应该被解释成扫描类型,对于科学计数法,它强调小数点是必须的,所以在这个题中,可以构建1e1来获得 flag,在 JSON 中它会被解释成 10,而在 YAML1.1 中就是 1e1 这个字符串,因为他没有小数点。

官方给的解释中,提到了两条规则:

  1. 是否有小数点
  2. 指数部分是否有正负号

这两条规则都可以导致 JSON 和 YAML 解释不一样。

对于 JSON 和 YAML1.2:

YAML1.2 中明确规定了在遇到重复的键时必须报错,所以这里绕过比较简单,就是构造两个键名一样的参数即可。

最后结果:

Alt text

Git? Git

题目:
「幸亏我发现了……」马老师长吁了一口气。

「马老师,发生甚么事了?」马老师的一位英国研究生问。

「刚刚一不小心,把 flag 提交到本地仓库里了。」马老师回答,「还好我发现了,撤销了这次提交,不然就惨了……」

「这样啊,那太好了。」研究生说。

马老师没想到的是,这位年轻人不讲武德,偷偷把他的本地仓库拷贝到了自己的电脑上,然后带出了实验室,想要一探究竟…

我的方法简单粗暴,就是写一段脚本遍历所有 git 对象,然后打印出来,看看长不长,不长的话直接在里面找 flag,在本地文件夹 touch 建立一个文件,用 vim 打开,复制脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

objects_dir=".git/objects"
for dir in $(ls $objects_dir); do
if [ "$dir" != "info" ] && [ "$dir" != "pack" ]; then
for file in $(ls $objects_dir/$dir); do
object_hash="$dir$file"
object_content=$(git cat-file -p $object_hash 2>/dev/null)
if [ ! -z "$object_content" ]; then
echo "Object $object_hash:"
echo "$object_content"
echo "----------------------------------------"
fi
done
fi
done

打印出来之后找到 flag。

官方题解是先通过 git reflog 查看完整操作历史,然后找到他最后一次的提交 hash,用 git reset回退到这次提交,查看 README.md 文件获得。

HTTP 集邮册

题目:
本题中,你可以向一个 nginx 服务器(对应的容器为默认配置下的 nginx:1.25.2-bookworm)发送 HTTP 请求。你需要获取到不同的 HTTP 响应状态码以获取 flag,其中:

获取第一个 flag 需要收集 5 种状态码;
获取第二个 flag 需要让 nginx 返回首行无状态码的响应(不计入收集的状态码中);
获取第三个 flag 需要收集 12 种状态码。
关于无状态码的判断逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
crlf = buf.find(b"\r\n")
if buf.strip() != b"":
try:
if crlf == -1:
raise ValueError("No CRLF found")
status_line = buf[:crlf]
http_version, status_code, reason_phrase = status_line.split(b" ", 2)
status_code = int(status_code)
except ValueError:
buf += "(无状态码)".encode()
status_code = None

先来说说第二个 flag 的获得方式,根据上面这个代码,它查找第一个\r\n换行符时会尝试解析状态行,如何让他不返回状态码呢,这里我做的时候就是 fuzz 出来的,一个个试,其实不知道他的原理,看了官方文档才知道,构造 payload:

GET /\r\n

一般的 HTTP 头是通过两个空格去分割三个字符串,如果剩下两个空格就会回退到 HTTP/0.9,上面这个 payload发送的就是 HTTP/0.9 请求,这个请求只支持 GET,响应就直接响应文件内容,这样的请求没有状态码。即可获得 flag

第一个 flag 和第三个 flag,我构造的 payload 和原理:

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
GET /50x.html HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: "64dbafc8-1f1"\r\n\r\n
返回 304,这里需要获取资源的 e-tag, 304代表文件在指定条件下没有修改过。

POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10000000\r\n\r\n
返回 413,超出Content的范围

GET / HTTP/1.1\r\nHost: localhost\r\nIf-Match: "12345"\r\n\r\n
返回 412,发送一个带有失败前提条件的请求,这里用If-Match尝试去匹配etag,匹配失败就会返回412

POST / HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 411,没有 Content-Length 头

PUT /index.html HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 405,PUT方法被禁止

GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 404

GET / HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 400,不符合HTTP格式要求

GET / HTTP/1.1\r\nHost: localhost\r\nExpect: 100-continue\r\n\r\n
返回 100,nginx 只支持 100-continue 的 expect 回带,这里会返回 100-continue,100-continue代表服务器希望客户端继续请求或者忽略

GET / HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 200

GET / HTTP/5.5\r\nHost: localhost\r\n\r\n
返回 505,不支持该 http 方法

GET /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... HTTP/1.1\r\nHost: localhost\r\n\r\n
返回 414,请求头太长

GET / HTTP/1.1\r\nHost: localhost\r\nRange: bytes=1000-500\r\n\r\n
返回 416,资源范围不合法

这里官方还提到了一种,他自己也漏了:

1
2
3
4
GET / HTTP/1.1\r\n
Transfer-Encoding: gzip\r\n
Host: example.com\r\n\r\n
返回501,代表服务器不支持该功能,nginx里只支持chunked分块encoding,Transfer-Encoding这里换成chunked以外的都行

Docker for Everyone

很简单的 Docker 提权,先简单讲一下 docker,如果一个用户被加入到 docker 的用户组,那么他们可以运行 docker 的命令而无需 sudo,这会引入安全问题,如果用户可以使用 docker 命令,那么他们实际上就拥有了访问主机上任意文件的能力,他们可以挂载主机上的任何目录到他的启动容器中。这道题中就算 flag 是软连接,也可以创建一个新的 docker 容器将 flag 指向的真实路径挂在为容器内的卷,操作如下:

1
2
3
4
5
6
# 使用 docker 创建一个新容器并挂载 /flag
# /flag 是我们想要读取的文件
# /mnt 是容器内部的挂载点
docker run -v /flag:/mnt/flag -it alpine /bin/sh
# 在容器内读取挂载的 /flag 文件
cat /mnt/flag

结果:

惜字如金 2.0

惜字如金化标准

惜字如金化指的是将一串文本中的部分字符删除,从而形成另一串文本的过程。该标准针对的是文本中所有由 52 个拉丁字母连续排布形成的序列,在下文中统称为「单词」。一个单词中除「AEIOUaeiou」外的 42 个字母被称作「辅音字母」。整个惜字如金化的过程按照以下两条原则对文本中的每个单词进行操作:

第一原则(又称 creat 原则):如单词最后一个字母为「e」或「E」,且该字母的上一个字母为辅音字母,则该字母予以删除。
第二原则(又称 referer 原则):如单词中存在一串全部由完全相同(忽略大小写)的辅音字母组成的子串,则该子串仅保留第一个字母。
容易证明惜字如金化操作是幂等的:惜字如金化多次和惜字如金化一次的结果相同。

题目源码

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
#!/usr/bin/python3

# Th siz of th fil may reduc after XZRJification

def check_equals(left, right):
# check whether left == right or not
if left != right: exit(1)

def get_cod_dict():
# prepar th cod dict
cod_dict = []
cod_dict += ['nymeh1niwemflcir}echaet']
cod_dict += ['a3g7}kidgojernoetlsup?h']
cod_dict += ['ulw!f5soadrhwnrsnstnoeq']
cod_dict += ['ct{l-findiehaai{oveatas']
cod_dict += ['ty9kxborszstguyd?!blm-p']
check_equals(set(len(s) for s in cod_dict), {24})
return ''.join(cod_dict)

def decrypt_data(input_codes):
# retriev th decrypted data
cod_dict = get_cod_dict()
output_chars = [cod_dict[c] for c in input_codes]
return ''.join(output_chars)

if __nam__ == '__main__':
# check som obvious things
check_equals('creat', 'cr' + 'at')
check_equals('referer', 'refer' + 'rer')
# check th flag
flag = decrypt_data([53, 41, 85, 109, 75, 1, 33, 48, 77, 90,
17, 118, 36, 25, 13, 89, 90, 3, 63, 25,
31, 77, 27, 60, 3, 118, 24, 62, 54, 61,
25, 63, 77, 36, 5, 32, 60, 67, 113, 28])
check_equals(flag.index('flag{'), 0)
check_equals(flag.index('}'), len(flag) - 1)
# print th flag
print(flag)

我的做法是做推理,他说到了前 5 个字符是 ‘flag{‘ ,最后一个字符是 ‘}’ ,我们找规律发现这边至少有 119 个字符,而这一对串里只有 115 个,说明惜字如金去掉了部分字符,根据这个惜字如金的标准,首先我们现在每一个串的末尾加上 e,跑一遍看看结果:

5laulyoufeepr3cvees3df7weparsn3sfr1gwn!}

这里可以看到字符’la’和字符’}’已经被凑出来了,然后就是一个个试了,我的方法就是在末尾添 e 删 e(因为存在 referer 标准,要添几个 e 都是不确定的),我最后试出来的是:

1
2
3
4
5
cod_dict += ['nymeh1niwemflcir}echaete']
cod_dict += ['a3g7}kidgojernoetlsup?hee']
cod_dict += ['ulw!f5soadrhwnrsnstnoeqe']
cod_dict += ['ct{l-findiehaai{oveatase']
cod_dict += ['ty9kxborszstguyd?!blm-pe']

结果为:
flag{yoe-ve-r3cover3d-7he-an5w3r-r1ght?}

但传上去发现是错的,我发现这个’yoe’有问题,改成’you’上传就正确了。

高频率星球

题目:
茫茫星系间,文明被分为不同的等级。每一个文明中都蕴藏了一种古老的力量 —— flag,被认为是其智慧的象征。

你在探索的过程中意外进入了一个封闭空间。这是一个由神秘的高频率星人控制着的星球。星球的中心竖立着一个巨大的三角形任务牌,上面刻着密文和挑战。

高频率星人的视觉输入频率极高,可以一目千行、过目不忘,他们的交流对地球人来说过于超前了。flag 被藏在了这段代码中,但是现在只有高频率星人在终端浏览代码的时候,使用 asciinema 录制的文件了,你能从中还原出代码吗?

大概就是还原 asciinema 录制文件,然后就跑还原出来的代码,
这里用命令:

1
asciinema cat asciinema_restore.rec > output.txt

跑出来之后发现很多干扰,所以用 sed 过滤一下:

1
asciinema cat asciinema_restore.rec | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | sed 's/\x1b.//g' | tr -d '\b' > output.txt

跑出来后发现还是有部分没去掉的,就人工去了,大概就是 find 然后替换这样子,还原出代码后跑这个代码就能获得到 flag 了:

总结

WP 就写到这里了,之后会好好研究每一道题,之后的题解会单独放出来,这里就把我写出来的题做一个总结。这次 HackerGame 很有意思,期待明年的!