USTC Hackergame 2022 一游 & 部分题目 Writeup

又是一年 Hackergame,我这个菜鸡又来被虐了。

如果有读者想看完整且严谨的 Writeup,建议移步 官方 GitHub,本文章仅图一乐。

签到

一开始的思路是想办法修改时间限制,使得时限内也能签出 2022。但找了一圈发现超出了自己的知识范围,遂作罢。

之后随意点了一下提交,URL 中出现了一个 query string,value 和识别结果不能说相似度很高吧,只能说完全一样。这和去年的签到题有点相像,都是通过修改 URL 的 query string 来拿到正确的 token。

将 value 修改为 2022,根本不用画,就拿到了 token。

PS:2022 年的网页竟然还在用 Vue 2……

猫咪问答喵

灌注关注 taffy 喵,关注 taffy 谢谢喵。

一如既往是非 USTC 学生也能做,但出题人似乎在努力平衡校内校外人的难度。

1

直觉当然是前往中科大信安协会官网寻找信息,但即使动用了 Wayback Machine 也并没有什么收获(而且这两个组织竟然是分立的)。

后来发现只需 Google 一下,在 学校新闻网 上就可以找到是 2017 年 3 月。

2

虽然问的是演讲内容,但是完全不需要找 USTC 的资料。KDE 的程序 就那么多,可以直接枚举得到结果。枚举到 Kdenlive 发现答案正确,搜索发现在描述的场景下确实 有显示问题

3

找到 Firefox version history 页面,可以看到 “Firefox 12 is the final release to support Windows 2000 and Windows XP RTM & SP1.”

此外也可以直接搜索 “firefox drop support for windows 2000”,然后找到 Mozilla 的公告

4

开头先 Google “linux kernel argc zero”,找到 对应的 LWN 新闻。通读“towards a general fix” 部分,发现 Ariadne 提出了一个修复方案,而 Torvalds 本人是倾向于这个方案的。从这部分的链接进入 commit 的详情,发现修改的是 fs/exec.c,合入主线时的修改应该也修改了此文件。合入主线的日期肯定晚于 2022/1/26,即这个 commit 的日期。

然后找到 kernel.org 中 kernel/git/torvalds/linux.git(不要找 GitHub),找到 fs/exec.c 的修改 log,然后从上面的日期往后找。找到 这个 commit,标题中的 argv is empty 正好对应了 argc 为 0,上面也附有 Ariadne 的说明。填进去 Hash 为 dcd46d897adb70d63e025f175a00a89797d31a43,正好对了。

对 OS 熟悉的读者或许能够直接推断出 argv is empty 这另一种说法,用这个直接 Google 能更快找到答案。

5

这道题非常有迷惑性,很容易将研究导向错误的方向。但是,遵循着猫咪问答的传统——考察搜索技能,找到答案和密码学知识其实没有半毛钱关系。更何况,懂密码学也没法在这个方向搞出答案。

很容易将 MD5 直接使用 Google 搜索,但搜索框会识别一些以冒号开头的命令(如 site:cyp0633.icu),所以 Google 很容易将冒号分隔的词语各作为一个关键词而搜索。而将关键词使用双引号括起来能够禁止转义,或者使用 Google 高级搜索,使用” 与以下字词完全匹配”功能。

搜索结构中很容易找到 一段 SSH 日志(如果没有结果,删掉开头的 MD5:),从日志中可以找到一个 Host IP 地址 205.166.94.16,使用 SSH 连接,提示信息如下:

The authenticity of host ‘205.166.94.16 (205.166.94.16)‘can’t be established. ED25519 key fingerprint is MD5:e4:ff:65:d7:be:5d:c8:44:1d:89:6b:50:f5:50:a0:ce. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])?

正好是我们所要的 MD5(如果是 SHA256,使用 ssh -o FingerprintHash=md5)。使用 IP 访问网页,能够找到页面上一个域名 sdf.org,对应的就是这个 IP 地址。

综上,答案为 sdf.org。在 WHOIS 上查询,确实是 1996 年 10 月 12 日创建的。

6

此处百度搜索起来效率更高,Google 索引不到什么东西。

这题也有比较大的迷惑性。很容易我们可以找到 中科大网络信息中心网站,此处展示了中科大历年的网络方面通知文件。我们可以找到两个 “关于实行新的网络费用分担办法的通知”,一个发布于 2003 年另一个发布于 2010 年

但是,后者并没有针对网络通服务定价作出什么修改,也就是说 “20 元一月” 的定价并不是从此时开始的。找到另一个文件,提到“网络通自 2003 年 3 月 1 日起开通”,国际连接也是 20 元一月,所以本题答案为 2003-03-01。

家目录里的秘密

拿到压缩包,直接使用 VS Code 全目录搜索 flag,第一个 flag 就在. config/Code/User/History/2f23f721/DUGV.c 里。

第二个 flag 与 Rclone 有关,而这个压缩包内与 Rclone 有关的文件就是. config/rclone/rclone.conf。这里面保存了一份使用 Rclone 连接一个 FTP 服务器的配置文件,然而文件夹中并没有办法找到 example.com 具体指的是什么,加之题目上也说在压缩包里找 flag,就并不应该试图努力从远程服务器拿 flag。秘密应该在 pass 字段里。

联想到 FTP 连接是需要把密码还原出来的,所以这个 pass 应该是使用了对称式加密。在 一篇社区文章 里证实了我的想法,使用的是 AES 加密;而通过搜索 “decrypt rclone pass” 之后找到了另一篇社区文章,贴出了一段代码,能够将 pass 解密。按照帖子中的指引操作即可,解出来正好是 flag。

有意思的是,第二篇帖子在 10 月 20 日发布,不由得让人猜想这其中的关联性。

HeiLang

到了第一天晚上才做出来,咕咕咕了大半天,转发抽一人送一个 AirPods Pro(bushi)。

手撸了一个简单的 translator,将 HeiLang 转换为 Python。非常不优雅而且低效,但能用。

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
f=[]
out=open("getflag.py","w")
with open('./getflag.hei.py') as file:
    f=file.readlines()

for i in f:
    if i[:2]=='a[':
        eq=i.rfind('=') # 查找等号的位置
        result=int(i[eq+2:].strip()) # 获取数字
        i=i[2:eq-2]
        nums=i.split('|') # 分割数字
        for n in nums:
            index=int(n.strip()) # 获取下标
            out.write("a[{}] = {}\n".format(index, result))

    else:
        out.write(i) # 非赋值语句,直接写入

把它运行一遍,看看输出内容,你就知道它的工作原理了。

这段代码写得贼累,求赞求投币求转发,最重要的是点一个大大的关注,后面我忘了(逃

Xcaptcha

逻辑比较简单,请求题目就对 /xcaptcha 发送 GET 请求,请求时需提交 Cookie 以便于识别个人 Token。提交结果时,就对 /xcaptcha 发送 POST 请求。要带上刚刚 GET 请求的 Cookie,否则无法对应刚刚获取的题目。

刚开始使用浏览器 JavaScript 断点捕获了一套题目,做出来之后再通过 Postman 发送 POST 提交,却会提示超时,这意味着我们必须使用程序自动化提交。

考虑到得到的字符串中含有中文,而且还包含整个算式,我想了想,还是用 Python 比较舒服。程序如下:

python
 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
import requests
import re

url="http://202.38.93.111:10047/xcaptcha"
headers={
    "Cookie":r"your-cookie",
}

r=requests.get(url,headers=headers)
newCookie=r.headers["Set-Cookie"]

ans=[]

pattern=re.compile(r'<label for="captcha\d">.+<\/label>') # 匹配题目部分
captcha=pattern.findall(r.text)
for c in captcha:
    c=c[22:-14] # 去掉标签
    print(c)
    ans.append(eval(c))

data={
    "captcha1":ans[0],
    "captcha2":ans[1],
    "captcha3":ans[2],
}

headers["Cookie"]=newCookie
r=requests.post(url,headers=headers,data=data)
print(r.text)

然后只需要从输出的内容中将赤裸裸的 flag 粘进去就行了。

Headless browser and requests are all your friend

旅行照片 2.0

又到了和去年一样的社工环节,不同的是今年有 100 分可以直接读 EXIF 骗过来。太简单就不讲了。

邮政编码可不一定是中国大陆的,照片中出现了 “welcome to zozomarine stadium” 字样,搜索发现是在日本千叶,进一步,很容易推断出拍摄者身处 “アパホテル&リゾート〈東京ベイ幕張〉”。但是体育馆和酒店的邮编并不相同,所以答案为 2610021。

熟悉手机的人可以一眼看出机型是红米 Note 9 4G,而对于其他人,手机分辨率的突破口其实仍然是 EXIF。相机型号未给出准确值(可能用了谷歌相机之类的),但 SM6115 代表骁龙 662,再搭配小米品牌的条件就不难找到了,这是一块 2340*1080 的屏幕。

然后通过地图搜索机场,附近东京都市圈内的机场有成田和羽田两座。成田机场在球馆背侧,跑道大致南北向,所以通过的飞机不应该在图中;那剩下的就是羽田机场(IATA: HND)可能性最大。

飞机像是在起飞,所以起飞机场就是 HND。寻找数月之前的航班难度较大,而鉴于 5 月 14 日是周六,我们可以尝试搜索 10 月 22 日(同是周六)18 点 23 分之前起飞的航班。

方法很简单,一个一个往前试。最终试到了 NH683,由 HND 飞往 HIJ。

猜数字

研究了一下请求,通过对 /status 的 POST 提交结果,然后再对 /status 进行一次 GET 来获取服务器反馈。

结合题目描述的大数定律(我概率论真的不好),那确实是收敛于某个值?于是用 Python 写了个暴力猜数程序,试图找找规律。

python
 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
import requests

nums=[]

cookie={}
header={
    "Authorization":"Bearer your-token",
}

url="http://202.38.93.111:18000/state"

for i in range(0,100):
    min=0.0
    max=1.0
    resp=requests.get(url,headers=header,cookies=cookie)
    while(1):
        max=round(float(max),6)
        min=round(float(min),6)
        sendNum=round((max+min)/2.0,6)
        resp=requests.post(url,headers=header,cookies=cookie,data="<state><guess>{}</guess></state>".format(sendNum))
        print(" 尝试 {},max={},min={}".format(sendNum,max,min),end="")
        resp=requests.get(url,headers=header,cookies=cookie)
        print(resp)
        if resp.text.find("<talented>1</talented>")!=-1:
            print(" 已经达成一次猜出数字,正在退出")
        elif resp.text.find("less=\"false\"more=\"false\"")!=-1: # 猜对了
            nums.append(sendNum)
            print(" 猜对了,数字为:{}\n====================================".format(sendNum))
            break 
        elif resp.text.find("less=\"true\"more=\"false\"")!=-1: # 猜小了
            min=sendNum
            print(" 猜小了,数字为:{}".format(sendNum))
        elif resp.text.find("less=\"false\"more=\"true\"")!=-1: # 猜大了
            max=sendNum
            print(" 猜大了,数字为:{}".format(sendNum))
        elif resp.text.find("less=\"true\"more=\"true\"")!=-1:
            print(" 猜错了,数字为:{}".format(sendNum))
        else:
            nums.append(sendNum)
            print(" 未知错误,应该是猜对了:"+resp.text+"\n====================================")
            break
    print(" 当前已经猜对的数字有:{}".format(nums))
print(nums)

但并没有找到什么规律。

LaTeX 机器人

纯文本

一看到能够自由输入内容,就想到了某种注入。但阅读 Dockerfile,进行本地转换部分的命令似乎没有什么可以自由发挥的空间,就放弃了在 Shell 中注入的计划。

转念一想,LaTeX 语法是可以包含其他文本文件的,语法为 \input{filepath}。于是输入 \input{/flag2},弹出的信息就是 Flag…… 吗?

不完全是。LaTeX 自带转义,只需要在合适的地方加上花括号就行了,具体位置可以仿照之前的 Flag。

特殊字符混入

此题仍然可以使用 \input 解决,但是要小心两种控制字符的转义。LaTeX 的控制字符可以使用 \catcode 指令禁用(参考),不过此处只需要禁用 #_,剩下两个不必要。构建的字符串如下。

text
1
\catcode `\#=12\catcode `\_=12\input{/flag2}

那这题的难度在哪里呢?我想,应该是对 LaTeX 各种术语的熟悉吧,比如不知道 “控制字符”(control character) 就没法搜出对应的方法。

安全的在线测评

在看到题之后,就隐隐约约感觉到,当年打 NOIP 敢想却没条件做的事情——直接读答案输出,说不定就是这道题的正解。又想到成熟的 OJ,例如 青岛大学的 OJ,对这种情况都有相应的防范措施,而这个判题脚本又显得过于简单,应该八九不离十。

评测机的工作目录树大概是这个样子:

text
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.
├── data
│   ├── dynamic\*.in
│   ├── dynamic\*.out
│   ├── problem.txt
│   ├── static.in
│   └── static.out
├── online\_judge.py
├── README.md
└── temp
     ├── code.c
     └── temp\_bin

无法 AC 的题目

这里我们的目标是读取 ./data/static.out 的内容,然后输出。注意程序运行的目录并不是在 temp 里。很容易得到以下的代码:

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include<stdio.h>

int main(int argc, char *argv[])
{
    FILE *fp;
    char buf[100];
    fp = fopen("./data/static.out", "r");
    while (fgets(buf, 100, fp) != NULL)
        printf("%s", buf);
    fclose(fp);
    return 0;
}

当然这样只能读到静态数据的输出,后面的显然会 WA。

如果使用判题脚本测试时,正解也 RE,可以将 44 行改为 [path,],这是机器上没有 runner 账户造成的。

动态数据

一开始以为思路其实是差不多的,都是读答案。但为了判断用哪个答案对应的输出,还需要同时读取输入文件进行比对。但是生成的动态数据权限均为 700,这意味着评测机上 runner 账户运行的程序无法直接读取答案。

所以有了一份本地能过,云端却过不了的代码,仅供取笑:

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
62
63
64
65
66
67
68
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    FILE *fp;
    // read input from stdin to char static_in[]
    char buf[1000];
    memset(buf, 0, 1000 * sizeof(char));
    fgets(buf, 1000, stdin);
    // remove \n
    if (buf[strlen(buf) - 1] == '\n')
    {
        buf[strlen(buf) - 1] = '\0';
    }
    // read from ./data/static.in
    char static_in[1000];
    memset(static_in, 0, 1000 * sizeof(char));
    fp = fopen("./data/static.in", "r");
    fgets(static_in, 1000, fp);
    if(static_in[strlen(static_in) - 1] == '\n')
    {
        static_in[strlen(static_in) - 1] = '\0';
    }
    fclose(fp);

    // compare input with static.in
    if (strcmp(buf, static_in) == 0)
    { // static data
        // read from ./data/static.out (multi line) and print
        fp = fopen("./data/static.out", "r");
        while (fgets(buf, 1000, fp) != NULL)
        {
            printf("%s", buf);
        }
        fclose(fp);
        return 0;
    }

    char path[1000];
    memset(path, 0, 1000 * sizeof(char));
    for (int i = 0; i < 5; i++)
    {
        sprintf(path,"./data/dynamic%d.in", i);
        fp = fopen(path,"r");
        // read from fp and compare with buf
        memset(static_in, 0, 1000 * sizeof(char));
        fgets(static_in, 1000, fp);
        if(static_in[strlen(static_in) - 1] == '\n')
        {
            static_in[strlen(static_in) - 1] = '\0';
        }
        fclose(fp);

        if (strcmp(buf, static_in) == 0)
        { // dynamic data
            // read from ./data/dynamic.out (multi line) and print
            sprintf(path,"./data/dynamic%d.out", i);
            fp = fopen(path,"r");
            while (fgets(buf, 1000, fp) != NULL)
            {
                printf("%s", buf);
            }
            fclose(fp);
            return 0;
        }
    }
}

线路板

真没想到工训过了这么长时间,还能用得上这玩意。首先需要 下载一个 KiCad

打开 KiCad 的 Gerber 文件查看器,打开从网站上下载的 zip 文件,就可以看到 Gerber 文件的预览了。

Gerber

一开始当然不是上面那个样子的,可以在右侧边栏的第 6 层(Copper L1)上右键,选择隐藏其他层。

但是 Gerber 又不能通过编辑把过孔去掉,此时可以使用 “文件 - 导出到 PCB 编辑器”,然后再用 KiCad PCB 编辑器打开。运气好,打开顶部铜层就看到了 flag。

PCB

微积分计算小练习

经典的微积分题和微积分没关系。为了这道题我把 XSS 学了一遍。

首先从下载的代码入手,可以看到 bot.py 的 56 行处,将 flag 放进了 Selenium 的 Cookie。所以我们所要做的,就是在提交服务器运行代码将 Cookie 盗取出来。我们传入服务器的数据只有一个网址,且只能是做题网站的网址,所以需要用 XSS 脚本注入做题网站。

再观察提交 bot 代码可以看到,它在将 Cookie 设置之后,会打开我们提交的结果网址,然后找到用户名和分数两个字段,并显示在终端上。很令人惊喜啊,完全没有清洗过输入的数据。由于 Selenium 会将整个网页加载完成再执行下面的内容,而用户名部分会将我们插入的用户名作为 HTML 直接插入,我们有了在用户名部分执行外部 JavaScript 的能力。

XSS 注入有存储、反射和 DOM 型这几种。反射型就是在 URL 的 query string 后面附上攻击脚本,这个我试了,会报错。反射型插入 <script>,但是对于此处使用 innerHTML 的情况,JavaScript 并不会被执行。所以这里使用 DOM 型 XSS(其实或许没必要分这么清楚)。

一般来说有两种途径,使用 <img onerror="script"> 或者 <iframe>。前者的基本原理是设置一个完全不存在的 src,访问失败就会执行后面的 JavaScript。一开始我的思路是将 Cookie 作为 query string 发一个 GET 请求,然后自己开一个服务器去记录。但这个东西似乎对外网有限制,连不上。突然想到用户名文本仍然会显示出来,那么直接替换文字,让用户名的区域直接显示 Cookie 不就行了?

构造注入代码如下:

html
1
</p><img src="x"onerror="document.getElementById('cookie').innerText=document.cookie;"><p id="cookie">

直接将上面的代码输入名字输入框,提交页面就可以看到一个坏掉的图片图标,以及 Cookie。正好就是 flag。

很幸运的是设置 Cookie 的时候没有做 HTTP-Only,不然 JavaScript 操作起来就有难度了。

企鹅拼盘

我真的没想到过我能在 math 部分拿到分…… 虽然方法跟数学基本没什么关系就是了。

进入 TUI 界面后,点击 input 可以输入,再点击其他地方可以发送命令。

第一个 Level 只允许输入四个 bit,暴力枚举拿分。后面的不会了。

许可证:CC BY-SA 4.0
最后更新于 Jun 13, 2023 11:22 +0800