
Presented at Recon Montreal 2018
或者,为傻瓜与叛逆者准备的进阶霰弹枪解析
从前,亲爱的读者,在一个漆黑而暴风雨肆虐的夜晚,您忠实的作者偶然遇到了一种令人困惑且神秘的情况——在一个系统中,唯一阻碍芯片剥离(chip-off)内存分析与逆向的,是一种未知的、碎片化的专有文件系统,再加上因使用嵌入式 Java 而引入的压缩,使得现有的文件雕刻(file carving)工具效果大打折扣。
当时,也许有人通过手动拼接那些看起来能对上的块,配合一些丑陋的 Python 控制台操作和难看的临时 bash 脚本,拼凑出某种临时解决方案。它够用,但非常耗时。
尽管在硬件黑客的日常工作中,从芯片剥离分析中提取未压缩的普通数据是相当乏味的工作,但当压缩数据的碎片被以无理取闹、令人不快的方式随意丢弃得到处都是时,即使没有其他真正的提取保护措施,也会带来严重问题。
尤其是 Zip 文件(也就是 JAR 文件使用的基本格式)有一些有趣的地方。作为 International Journal of PoC||GTFO 长期以来的忠实读者,尤其关注 Ange Albertini 在文件格式特技方面的工作,我觉得一个 zip 文件内部有足够多关于该 zip 文件自身的数据,足以让我们出色地把它重新拼接起来。
好了,参考内容就说到这里,让我们进入技术细节。
首先,我们可能不知道文件系统的具体细节(而从我研究的视角来看,独立于我遇到问题的那个系统,我决定干脆不去关心它)。但我们对大多数文件系统的实现方式略知一二。特别是,我们知道它们往往以块(chunk)为单位写入。这些块有一个某种形式的最小尺寸,称为页(page),我们可以通过浏览转储并识别被写入的最小块大小来确定页大小。
其中一些块可能是连续的,另一些则不是,而且块何时连续并没有清晰的规律。
所有这一切都在说,我们要解决的问题是:如何对数据页重新排序,使它们能为我们提供要提取文件的合法(或足够接近的)映像。
Zip 文件的写入方式实现了一种反向层级结构。首先是压缩文件数据(包裹在描述它的本地文件头 local file header 中)。然后是中央目录(central directory,列出各本地文件头的偏移量),再然后是中央目录结束记录(end of central directory,除其他内容外,它描述了 zip 中存储的文件数量、中央目录的起始偏移量以及中央目录的大小)。
让我们把它倒过来,深入一点细节:
中央目录结束记录(End of Central Directory)告诉我们:
每条中央目录记录告诉我们:
每条本地文件记录告诉我们:
综上所述,我们得以重建文件的绝大部分内容。
首先,我们需要一个步骤来区分来自不同固件的数据。原因是所有偏移量都只在其各自的 zip 文件内有效——任何冲突都会导致 zip 流不匹配和损坏,而我们非常希望尽可能多地提取出未损坏的数据。此外,我们也希望有很好的保证,例如,我们在目标固件中诊断出的任何漏洞,影响的是我们通常看到运行的那个固件,而不是某个只是被闲置在一边的其他文件。
这里需要的解决方案是 kmeans 算法(又称“劳埃德算法”)。这个视频 很好地解释了它的工作原理。SciPy 已经有一个可用的好版本,但我必须识别/修补 唯一实现了该算法的聚类/分析 crate,才能让它在 Rust 实现中正常工作。幸运的是,我不必从头编写它。
搞定这一步后,事情就顺了。
我们可以利用许多特征来做这件事。Flags、Method 和 Version 字段都会根据用于压缩文件的 Zip 软件栈而变化。此外,头部带有时间戳,而且通常不太可能所有固件都在完全相同的时间被编译和压缩。
顺便说一句,值得注意的是,Zip 文件使用 MS-DOS 格式的时间戳, 它们是位打包的短整数,表示年-月-日和小时-分钟-两秒。 如果在将其用作分类数据之前没有将其转换为绝对标量值, 那么你很可能会把一年的差异看得和一秒的差异同等重要,这绝对不行!
我们将这些转换成一个欧几里得向量(Euclidean Vector,这是对 ℝ 中一个 n 维值数组,也就是浮点坐标的花哨说法,不过上面链接的视频可能是最直接的解释),然后聚类算法几乎为我们完成了其余所有工作,把所有解析出的头部收集到我们预期数量的桶中。
虽然我是用 相当粗略的 Python 脚本为这种方法做的原型,但在 PoC 中达到了大约 70-80% 的 JAR 内容恢复率后,我决定就此打住,转而用 Rust 实现一个快速版本。
Rust 有一个名为 nom 的 crate,它对于编写解析器验证器(parser-verifiers)来说绝对棒极了。这也是用 Rust 重写它的主要吸引力之一,顺便说一句。能够编写清晰且极其严格的解析器,使得这件事在某些方面比试图用 Python 处理所有这一切要容易得多(Python 通常要宽容得多,以至于有时很难确保你没有因草率处理错误失败而漏掉某个边界情况)。
如果出色的、快速且清晰的解析器让你感兴趣,去看看
除此之外,用 Python 运行这类分析本质上偏慢,而且它从未真正被期待成为“通往 PoC 的路径”之外的东西,这个 PoC 用于探索这种方法的可行性。
总之,说得够多了……
重建剩余块的方法包括:首先过滤剩余页以找出高熵候选;先填补较小的间隙(尽可能多地从搜索列表中排除页,因为在最坏情况下,测试这些页的排列是一项指数级时间任务,所以快速解决较简单的情况是优先事项,并且随着进展会指数级地简化我们的问题!)。
我们还可以通过寻找因页边界而无法解析的本地文件头实例来快速取胜(我们应该能够将它匹配到一个对齐方式相同的对应头部,至少在相似对齐是唯一且不与其他损坏伪影冲突的情况下是这样)。
我们如何检查缺失页的候选?好吧,我们的中央目录里就放着文件的 CRC32 校验和!与其对整个文件计算 CRC32,可能最好的方法是:对我们已经知道的块计算 CRC32(从间隙开始前,页末尾的数据向前计算;再从间隙之后的数据(或 deflate 流之后的 DataDescriptor 块)反向计算),并由此算出每一块缺失页我们应当预期的中间 CRC32。
本质上,每当我们选择解决最简单/最快的问题,我们都会通过消除干扰项(chaff)让更困难的问题显著变简单。这也是为什么使用香农熵(Shannon entropy)直接扔掉空页或几乎为空的页——我们并不能保证只会遇到高熵的 zip 页,但即使存在离群值,从一开始就避免处理这种复杂情况也会带来巨大的速度提升。
如果你想摆弄它,请安装 rust(推荐使用出色的 rustup nightly。然后:
$ git clone [repo]
...
$ cd zipdefrag
...
$ cargo build --release
你可以省略 release 标志以启用调试。
构建产物将位于 /target/{debug,release}
使用 cargo doc 构建文档(这个 crate 有大量文档。我喜欢写文档。)
目前 Rust 版本默认没有终端输出;如果你想运行 CLI 测试工具,需要设置环境变量 RUST_LOG=zipdefrag,它会启用详细终端日志,展示至今的分析过程。
一个快速、可移植的原生可执行文件(带 Python 钩子),用于解析来自未知文件系统的谜题式 zip 转储。
一个演示转储
当前 Rust 实现的性能很糟糕,因为在搜索对应的 LFH 块时存在浪费行为。我会修复这个问题并吸取教训。
当 JAR 中有许多文件明显大于页大小时,这种技术效果不佳。因为它依赖于大量利用 zip 文件固有的结构,数据密集的文件就是不太行。
方便的是,对于 J2ME midlet 来说,class 文件通常相当小,但打包在其中的大型二进制文件可能无法恢复。
另外,Python PoC 包含一些算术错误。