rea-reverse

file 命令认错文件类型的那些情况:JAR、APK、DOCX 与残缺文件

发布者 rea-reverse更新于 约 11 分钟读完
本页目录

结论先行:file 只看文件开头附近几个固定偏移。对 ELF、Mach-O、PE、WebAssembly、SQLite、PNG、PDF 来说这就够了,这几类我们测的两个工具全部认对。但遇到靠 ZIP 内容来定义的格式(APK、IPA、不是用 jar 打的 JAR、重新打包过的 DOCX)、签名数据(比如 iOS 描述文件)和残缺文件时,它就不灵了:残缺文件的头还在,file 无从知道后面缺了一截。xxd 看到 PK\x03\x04 之后,就该去列 ZIP 条目;真正要靠得住的结论,得用对应格式的解析器打开来确认。

魔数对照表、每种格式该用什么工具打开,在用魔数识别文件类型那篇里讲过了。这篇只讲“认错了”或者“说得含糊”的情况,依据是 25 个样本和两个识别工具:file 5.41,以及本站的文件类型识别工具。

测试样本

25 个文件都是 2026-10-20 在 macOS 27.0.1 上现做的,或者取自本站自己的测试夹具,然后统一复制成 s01 … s25,不留扩展名:

样本 是什么 怎么做的
s01 普通 ZIP,一个文本文件 zip 3.0
s02 JAR javac --release 11 + jar cfe(Temurin 17.0.18)
s03 APK 本站 APK 测试夹具(test/apk/fixture.apk)
s04 DOCX textutil -convert docx
s05 IPA 合成的 iOS App,用 zip -r 打包(来自描述文件那篇)
s06 gzip,54 字节 对 25 字节的文本 gzip -9
s07 ELF x86-64 站内夹具
s08 Mach-O arm64 clang -O2
s09 PE32+ x64 站内夹具
s10 通用 Mach-O 站内夹具
s11 Java class s02 里的 Hello.class
s12 WebAssembly 站内夹具(clang.wasm)
s13 SQLite sqlite3,一张表
s14 PNG 32 × 32 站内样本
s15 PDF cupsfilter 转换文本文件
s16 iOS 描述文件,DER openssl cms -sign -nodetach(合成)
s17 同上,BER 不定长形式 openssl cms -sign -stream
s18 s02 的前一半 head -c 373
s19 s14 的前 200 字节 head -c 200
s20 s06 去掉最后 8 字节 head -c 46
s21 s02 解开后用 zip -r 重新打包
s22 s04 解开后用 zip -r . 重新打包
s23 hi\n 的 gzip,完整,23 字节 gzip -n
s24 一个 43,771 字节 gzip 的前一半 seq 1 20000 | gzip -9 后 head -c
s25 s05 用 zip -X 重新打包

版本:file-5.41,用 macOS 自带的 /usr/share/file/magic;xxd 为 macOS 自带;OpenSSL 4.0.3。本站识别工具的代码是在 Node.js 22.22.3 下从本站仓库直接调用的,和页面 Web Worker 调的是同一个 identify() 函数。

不认识的文件按不可信处理:只识别,不运行。文件如果来自别人的系统,先确认你有权分析。参见 REA 的 DISCLAIMER.md。

两边一致的部分

有 11 个样本没什么可说的。ELF、Mach-O(单架构和通用)、PE32+、Java class、WebAssembly、SQLite、PNG、PDF、gzip 和普通 ZIP,两个工具结论一致,细节也一致:x86-64、arm64、PE32+ ... x86-64、version 55.0 (Java SE 11)、32 x 32、version 1.3。通用 Mach-O 和 Java class 共用 CA FE BA BE 开头,两边都分对了:接下来四个字节在 s10 里是 00 00 00 02(两个架构),在 s11 里是 00 00 00 37(class 版本 55)。

就算在这一组里,file --mime-type 也没有描述行那么靠谱。WebAssembly 模块的描述行认对了,MIME 类型却是 application/octet-stream;一个带 main 方法的普通 class 文件,MIME 类型是 application/x-java-applet。如果你按 MIME 类型分流上传文件,最好先把自己关心的格式逐个跑一遍 file --mime-type。

file 认错或说不清的地方

APK 和 IPA 都成了普通 ZIP

$ file s03 s05
s03: Zip archive data, at least v0.0 to extract, compression method=deflate
s05: Zip archive data, at least v1.0 to extract, compression method=store

我们又对本站仓库里全部 19 个 APK 和 AAB 夹具跑了一遍 file,结果全是 Zip archive data。它的魔数库里没有 APK 规则。iOS 倒是有一条,翻 /usr/share/file/magic/archive 能找到:

# iOS App
>(26.s+30)	leshort	!0xcafe
>>26		string	!\x8\0\0\0mimetype
>>>30		string	Payload/
>>>>38		search/64       .app/   iOS App

要求第一个条目名是 Payload/,并且从偏移 38 开始的 64 字节内出现 .app/。我们的 IPA 第一个条目确实是 Payload/,但 Info-ZIP 的 zip 会给每个本地文件头加 28 字节的扩展字段(UT 时间戳和 ux 属主 ID),第二个条目的 Payload/Demo.app/ 就被挤出了这个窗口。用 zip -X(不写扩展字段)重新打包同一个 App,就认出来了:

$ file s25
s25: iOS App Zip archive data, at least v1.0 to extract, compression method=store
$ file --mime-type s25
s25: application/x-ios-app

也就是说,file 5.41 能不能认出 IPA,取决于是什么 zip 工具打的包。

不是用 jar 打出来的 JAR

$ file s02 s21
s02: Java archive data (JAR)
s21: Zip archive data, at least v1.0 to extract, compression method=store

两个文件里都是同样的 META-INF/MANIFEST.MF 和 Hello.class,java -jar s21 也正常输出 hi。JAR 规则 >(26.s+30) leshort 0xcafe 找的是第一个条目上 ID 为 0xCAFE 的扩展字段,jar 工具会写这个字段:

$ xxd -l 48 hello.jar
00000000: 504b 0304 1400 0808 0800 268f 545d 0000  PK........&.T]..
00000010: 0000 0000 0000 0000 0000 0900 0400 4d45  ..............ME
00000020: 5441 2d49 4e46 2ffe ca00 0003 0050 4b07  TA-INF/......PK.

META-INF/ 后面紧跟的 fe ca 00 00 就是这个标记。Gradle、Maven、zip 没有义务写它,所以一个完全合法的 JAR,file 经常认不出来。

重新打包的 DOCX 丢了名字,也丢了 MIME 类型

$ file s04 s22
s04: Microsoft Word 2007+
s22: Microsoft OOXML
$ file --mime-type s04 s22
s04: application/vnd.openxmlformats-officedocument.wordprocessingml.document
s22: application/octet-stream

/usr/share/file/magic/msooxml 里的规则先检查第一个条目是不是 [Content_Types].xml、_rels/.rels 或 docProps,然后只看第三、第四个条目的名字里有没有 word/、xl/、ppt/。textutil 写出的顺序是 [Content_Types].xml、_rels/.rels、word/_rels/document.xml.rels,第三个就命中了。我们用 zip -r . 重新打的包也以 [Content_Types].xml 开头,但多了目录条目,紧接着是 docProps/,word/ 排到了第 8 个。描述行退成了 “Microsoft OOXML”,MIME 类型退成了 application/octet-stream。

签名过的 iOS 描述文件是 “data”

$ file s16 s17
s16: data
s17: data

字节是 30 82 0e af 06 09 2a 86 48 86 f7 0d 01 07 02:一个 DER SEQUENCE,后面跟着 signedData 的 OID。file 5.41 没有针对裸 CMS 结构的规则。怎么解码见描述文件那篇。

半个 JAR 照样是 JAR

$ file s18
s18: Java archive data (JAR)
$ unzip -l s18
  End-of-central-directory signature not found.  Either this file is not
  a zipfile, ...

file 读的是第一个本地文件头,这部分还在;所有解压工具都要用的中央目录在丢掉的那一半里。只剩 200 字节的 PNG 也一样:file s19 输出 PNG image data, 32 x 32, 8-bit/color RGBA, non-interlaced,和完整图片一字不差,因为宽高就在第一个块里。

完整的 gzip 报 “truncated”,坏的反而不报

这是最出乎意料的一处。两个 gzip 文件:

$ gzip -t s23 && echo OK
OK
$ gzip -t s24
gzip: s24: unexpected end of file
$ file s23 s24
s23: gzip compressed data, from Unix, truncated
s24: gzip compressed data, last modified: Tue Oct 20 10:02:02 2026, max compression, from Unix, original size modulo 2^32 3994873712

完整的文件被标成 truncated,坏的没有,而且 file 给它报了一个接近 4 GB 的“原始大小”,实际输入只有 108,894 字节。/usr/share/file/magic/compress 里的规则能解释这两点:

>>-0	offset		>48
>>>-4	ulelong		x		\b, original size modulo 2^32 %u
>>-0	offset		<48		\b, truncated

“truncated” 的意思只是文件不足 48 字节,“原始大小”则是文件最后 4 个字节,不管那里是什么。我们 23 字节的 hi\n 压缩包是完整的,只是短;截断文件的最后 4 个字节是压缩数据。前面的截断样本 s20 恰好 46 字节,file 也报了 truncated,说对了纯属巧合。要检查压缩文件是否完整,用 gzip -t(或 xz -t、zstd -t)。

本站识别工具认错或说不清的地方

本站的文件类型识别工具处理 ZIP 的方式不同:它读文件末尾的中央目录,按条目列表命名格式(APK 看 AndroidManifest.xml 加 classes.dex 或 resources.arsc,IPA 看 Payload/*.app/,JAR 看 META-INF/MANIFEST.MF 或 .class 文件,DOCX 看 word/)。所以 s03、s05、s21、s22、s25 这几个 file 认不出的,它都认对了。第一轮测试里,14 个 APK 夹具它全部认成 apk,5 个 AAB 夹具里 3 个认成 aab,都是高置信度;剩下两个 AAB 见下文。不同 zip 工具不会改变中央目录的布局,害 file 漏掉 IPA 的那些扩展字段在这里没有影响。

不过 2026-10-20 第一轮测试时,它也有自己的问题。我们当天把这些问题修了,再用修好的 identify() 重跑了相关样本,环境同样是 Node.js 22.22.3。第二轮用的描述文件、gzip 文件和 IPA 是按第一轮的同样命令(openssl cms、gzip -9、zip -r)重新生成的,所以大小差几个字节;AAB、PNG 和 DOCX 用的是同一份输入。下面两轮结果都列出来。

描述文件。 第一轮里,s16 被认成 DER-encoded ASN.1 (certificate, key, PKCS#7 / #12),中等置信度,证据只有一个字节(30)。说得没错,但太笼统。s17 是以 30 80 开头的 BER 形式,结果是 Unknown binary data,低置信度,还附了一条说明:Protocol Buffers 这类无模式格式没有魔数。这个提示把人往错误的方向引,文件在偏移 4 处明明有一个 signedData OID。同站的 iOS 描述文件查看器读这两个文件都没问题,可识别工具当时不会把你带过去。

现在识别工具会按 CMS 结构去解析这两种编码,用的是描述文件查看器同一套 DER 读取代码(它支持 BER 的不定长编码);如果被签名的内容是带 ProvisionedDevices、ProvisionsAllDevices、TeamIdentifier 或 Entitlements 字段的 plist,就报为描述文件,并给出查看器的链接。第二轮结果:

s16: iOS provisioning profile (CMS-signed plist)  high  encoding DER, 1 certificate
s17: iOS provisioning profile (CMS-signed plist)  high  encoding BER, indefinite length, 1 certificate

两份都不再带 protobuf 提示。同样用 openssl cms -sign 签一段纯文本,现在得到的是 CMS / PKCS#7 SignedData,高置信度,内容类型 1.2.840.113549.1.7.1。以 30 80 开头、但按 CMS 解析不了的文件,仍然是 Unknown binary data。

两个 AAB 被认成 JAR。 第一轮里,sig-bundle.aab 和 sig-bundle-tampered.aab 的结果是 jar,高置信度。列一下内容就明白了:里面有 META-INF/MANIFEST.MF、JAR 签名、base/manifest/AndroidManifest.xml 和 base/dex/classes.dex,但没有 BundleConfig.pb,而识别工具当时判断 AAB 要求有这个文件。这两个是为测试签名校验手工做的文件,bundletool 或 Gradle 生成的 AAB 一定带 BundleConfig.pb。即便如此,一个缺了这个文件的已签名 bundle 被报成 JAR,而不是“AAB,不完整”。

现在只要有 base/manifest/AndroidManifest.xml 这样的模块清单,再加上一个模块目录(base/dex/、res/、lib/、assets/ 或 root/),就会认成 aab;缺 BundleConfig.pb 时只给中等置信度,并写明这一点。第二轮里这两个文件都是 aab,中等,flags: no BundleConfig.pb;另外三个 AAB 夹具仍是 aab,高。一个本来会认成 JAR、但里面任何位置有 AndroidManifest.xml 的 ZIP,也不再给高置信度。

残缺文件。 半个 JAR 的结果降成了 ZIP archive,中等置信度,附说明“中央目录读不到”,并提示看到了 META-INF/ 条目。这比 file 信心满满的 “JAR” 更诚实。但第一轮里,截断的 PNG(s19)和截断的 gzip(s20、s24),它给出的仍是和完整文件一样的高置信度 PNG image、gzip,因为没有任何检查去看文件结尾。

现在 PNG 必须以 IEND 块结尾;不超过 8 MiB 的 gzip 会在 Worker 里用浏览器的 DecompressionStream 解压一遍(结果直接丢弃),看压缩流能不能走到结尾。第二轮结果:

s19(939 字节里的前 200):       PNG image  medium  备注:文件结尾不是这种格式应有的结尾
s20(52 字节里的前 44):         gzip       medium  同一条备注
s24(43,779 字节里的前 21,889): gzip       medium  同一条备注
s14、s06、s23,以及截出 s24 的那个 43,779 字节完整 gzip:high,没有备注

文件末尾被追加了别的数据时也会出现这条备注,有些文件是故意这样做的,所以把它理解成“该查一下”,而不是“一定坏了”。

重跑 gzip 样本时又发现一个漏判。Node.js 的 zlib.gzipSync 生成的 46 字节 gzip 被认成了 Unknown binary data:这个 Node.js 里的 zlib(1.3.1)在 macOS 上会在 OS 字节写 19,而识别工具只接受 RFC 1952 列出的 0 到 13。Python 3.14.7 的 gzip.compress 写的是 255(“未知”),也被拒了。现在这两个值都能识别,分别显示为 macOS 和 unknown。

Office 和 IPA 的 MIME 类型。 第一轮里,DOCX 和 IPA 它报的 MIME 都是 application/zip;file 对完整的 DOCX 给的是具体的 OOXML 类型,对 s25 给的是 application/x-ios-app。格式名是对的,MIME 字段给的却是容器的类型。第二轮里,s04 给出的是 application/vnd.openxmlformats-officedocument.wordprocessingml.document,IPA 给出的是 application/x-ios-app,和 file 对 s25 的结果一致。XLSX 和 PPTX 也按同样方式给出各自的 OOXML 类型。

拿到一个陌生文件该怎么做

  1. 看前 16 个字节:xxd -l 16。容器类型一眼可见;可执行文件、图片、数据库通常到这一步就有答案了。

  2. 看到 PK\x03\x04,就列条目:unzip -l 或 zipinfo -1,或者把文件拖进识别工具,它从中央目录做同样的事。APK、IPA、JAR、Office 之间的区分别指望 file,它的答案取决于包是怎么打的。

  3. 看到 30 82 或 30 80,往签名或编码数据上想:证书、PKCS#12、CMS 签名、描述文件。openssl asn1parse 能直接报出外层 OID。对 s16,file 没做到,本站识别工具也是上面那次修复之后才做到:

    $ openssl asn1parse -inform DER -in s16 | head -3
        0:d=0  hl=4 l=3759 cons: SEQUENCE
        4:d=1  hl=2 l=   9 prim: OBJECT            :pkcs7-signedData
       15:d=1  hl=4 l=3744 cons: cont [ 0 ]
  4. 相信压缩包或归档之前先测一下:gzip -t、unzip -t、xz -t。file 不检查完整性。本站识别工具现在会把不超过 8 MiB 的 gzip 解压到结尾(顺带校验了其中保存的 CRC-32),并检查 PNG 是否以 IEND 结尾,但不校验 PNG 每个块的 CRC。其他格式都不做完整性检查。

  5. 用对应格式的解析器打开。 文件头只说明它“应该是”什么,文件后面缺了东西,解析器会报错。

REA 怎么判断 ZIP 子类型

REA(morluto 的开源项目)的制品盘点走的是反方向。已发布的 [email protected] 包里,dist/domain/zipPackageFormat.js 先从字节检查 ZIP 签名,然后只看文件名决定包类型:.ipa、.apk、.msix、.appx、.hap、.hsp,其余一律是 zip。源码注释写的是 “deliberately extension-only”。这是我们从 npm pack 下来的包里读到的,没有运行。用 REA 时,先给包加上正确的扩展名再跑 inspect-artifact;像本文这种没扩展名的文件,先按上面的步骤认出来。

这次测试的局限

25 个文件是为了测 ZIP 子类型和残缺文件专门挑的,数量不大,不能当覆盖率评测。我们测的是 macOS 自带的 file 5.41,新版 file 可能已经有 APK 或 CMS 规则,这里没有验证。另外我们的 IPA 和描述文件是合成的,Xcode 导出的真实包打包方式可能不同,而 s05 和 s25 已经说明,打包方式恰恰会改变 file 的答案。

下一步