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 秒窗口内完成。
file报bzip2 compressed data,但bzip2 -t失败 → 这就是题面说的损坏。
$ 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.bz213075 corrupted.png.bz2corrupted.png.bz2: bzip2 compressed data, block size = 900k$ bzip2 -t corrupted.png.bz2bzip2: 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 LF 的 0x0A 被当作行尾并扩展为 CRLF,也就是插入 0x0D。压缩流没有行结构,0x0A 只是普通数据字节,插入一个字节会改变后续 bzip2 bitstream,进而使校验与解压失效。这里的 0x0A → 0x0D 0x0A 是本题具体传输路径的结果,实际转换还取决于发送端、接收端和平台的行尾处理规则;本题的 CR/LF 统计与后续 PNG CRC 对照支持这一判断。
证据就在字节统计里:
$ 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 魔数开头来判定:
import bz2import 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:13 collapsed lines
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] 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 okk=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 模式(ftp 里 bin,或直接 scp/curl),下载后校验哈希/魔数;对压缩包这类强结构数据,还应当用内容特征(魔数、CRC)而不是命令退出码来判断完整性。本例中解压器在数据已损坏时仍会输出字节流,只看退出码会误判。另外,密码以明文绘制在图片里再压缩交付,本身就是把答案交给客户端的做法。