HackThisSite - Programming Mission 5

Challenge

Level 5 — Fix a corrupted file

Get this file HERE, reconstruct it and send the password as answer.

有人用 Windows 命令行 ftp 客户端下载了一个 bz2 压缩的 PNG,PNG 里有一个重要密码;他漏考虑了一件事,文件因此损坏。下载 corrupted.png.bz2,把它修好并提交里面的密码。限时 600 秒

Solution

  • 实例页 https://www.hackthissite.org/missions/prog/5/,文件链接是 /missions/prog/5/corrupted.png.bz2/
  • 文件每个实例都不一样(两次抓到的分别是 13075 / 13961 / 15232 字节,内容与密码都不同),所以必须取文件 → 修复 → 读密码 → 提交在一次 600 秒窗口内完成。
  • filebzip2 compressed data,但 bzip2 -t 失败 → 这就是题面说的损坏。
1
2
3
4
5
6
$ curl -sL -b '<mission-cookie>' 'https://www.hackthissite.org/missions/prog/5/corrupted.png.bz2/' -o corrupted.png.bz2
$ ls -l corrupted.png.bz2 && file corrupted.png.bz2
13075 corrupted.png.bz2
corrupted.png.bz2: bzip2 compressed data, block size = 900k
$ bzip2 -t corrupted.png.bz2
bzip2: corrupted.png.bz2: data integrity (CRC) error in data

题面提示用 Windows ftp 客户端下载、漏考虑的一件事,是没有切换到 binary 模式。FTP 的 ASCII type 面向文本传输,会在本地字符表示与标准 NVT-ASCII 表示之间转换,并按平台约定处理行尾;Windows 文档将它与按 one-byte units 传输的 binary type 区分。对本题的跨 Unix/Windows 路径,原始压缩流中作为 bare LF0x0A 被当作行尾并扩展为 CRLF,也就是插入 0x0D。压缩流没有行结构,0x0A 只是普通数据字节,插入一个字节会改变后续 bzip2 bitstream,进而使校验与解压失效。这里的 0x0A → 0x0D 0x0A 是本题具体传输路径的结果,实际转换还取决于发送端、接收端和平台的行尾处理规则;本题的 CR/LF 统计与后续 PNG CRC 对照支持这一判断。

证据就在字节统计里:

1
2
$ python3 -c "d=open('corrupted.png.bz2','rb').read(); print(len(d), d.count(b'\r'), d.count(b'\n'), d.count(b'\r\n'))"
13075 115 57 55
  • \r\n 多了 58 个 → 就是被插入的 CR;
  • 文件里 \r\r\n 出现 0 次 → 说明原本就是 \r\n 的位置没有被二次膨胀,插入只发生在原来的 0x0A 前;
  • 另外还有 2 处 \n\r(例如 ... 0d 0a 0d ...),说明原始位流里确实有少量天然的 \r\n 相邻(转换后转换结果呈现为 CRLF,但其中的 CR 是原始数据)。

所以修复不是简单 dos2unix要决定哪些 CRLF 里的 CR 是插入的、哪些是原始数据。只删掉一部分、猜错一个位置,解出来的即为无意义的垃圾数据。

观测到的 CRLF 对很少(50-60 个),而天然 CRLF 通常只有 0-2 个。于是直接枚举保留子集,用解压结果是否以 PNG 魔数开头来判定:

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

PNG_MAGIC = b"\x89PNG\r\n\x1a\n"

def crlf_positions(data):
"""每个 0x0D 0x0A 对的起始下标。"""
return [i for i in range(len(data) - 1)
if data[i] == 0x0D and data[i + 1] == 0x0A]

def rebuild(data, keep, positions):
"""删掉每一个 CRLF 对里的 CR,但 keep 里的下标保留 CR(=原始数据)。

注意不能用 data.replace(b'\\r\\n', b'\\n'):相邻的 CRLF 会互相影响,
必须逐对扫描。
"""
keep = set(keep)
out, i, j = bytearray(), 0, 0
while i < len(data):
if i + 1 < len(data) and data[i] == 0x0D and data[i + 1] == 0x0A:
if j in keep:
out.append(data[i])
out.append(data[i + 1])
i += 2
j += 1
else:
out.append(data[i])
i += 1
return bytes(out)

def repair(data, max_keep=3):
pos = crlf_positions(data)
for k in range(max_keep + 1):
for combo in itertools.combinations(range(len(pos)), k):
try:
plain = bz2.decompress(rebuild(data, combo, pos))
except Exception:
continue
if plain[:8] == PNG_MAGIC:
return rebuild(data, combo, pos), plain, [pos[i] for i in combo]
raise SystemExit("could not repair the archive")

真实的一次运行:

1
2
3
4
5
[1] downloaded 15232 bytes
[2] 15232 bytes, 58 CRLF pair(s); searching for the genuine ones
[3] keep=2 tried=1398 -> decompressed 17109 bytes, PNG signature ok
genuine CRLF byte offsets: [9429, 14250]
[4] PNG verified: 1750x115, chunks ['IHDR', 'bKGD', 'pHYs', 'vpAg', 'IDAT', 'IEND'], all CRCs ok

k=0(全删)和 k=1 都不行,k=2 在枚举到第 1398 个组合时命中(即这次的两个 CR 属于原始数据)。恢复出的 PNG 还能过 chunk CRC 校验,说明重建完全正确。

PNG 是 1750×115 的横条,密码用粗体无衬线字体画在浅色背景上。

注意:密码是每个实例一次生成的(同一账号换一次页面就是另一串)。

  • 不能用 replace(b'\r\n', b'\n'):相邻 CRLF 会互相影响,必须逐对扫描决定保留哪一个 CR。

Vulnerabilities

这道题的损坏是经典的真实世界传输事故:文本模式的 FTP 会把二进制文件当文本处理,静默插入 0x0D。防御上:二进制传输永远走 binary 模式(ftpbin,或直接 scp/curl),下载后校验哈希/魔数;对压缩包这类强结构数据,还应当用内容特征(魔数、CRC)而不是命令退出码来判断完整性。本例中解压器在数据已损坏时仍会输出字节流,只看退出码会误判。另外,密码以明文绘制在图片里再压缩交付,本身就是把答案交给客户端的做法。