1. 文件头识别到底是什么时候用的
1.1 我什么时候被这件事难住过
最早被“根据16进制头文件识文件类型”这件事卡住,是在接手一批没有扩展名的备份文件时。目录里躺着几十个 20MB 到 200MB 的.bin文件,命名全是backup_20250101_001.bin这种,客户只给了需求:判断每个文件实际是什么格式,然后导入对应系统。我一开始试过根据文件名猜,结果猜错三个,其中一个其实是 ZIP 压缩包,被当成数据库备份导入,直接把后续流程带崩了。后来用十六进制编辑器打开文件头,看到50 4B 03 04,马上对应到 ZIP 魔数,再用文件尾特征一确认,问题当场解决。
另一个常见场景更现实:网站上传接口只校验扩展名,有人传了一个伪装成.jpg的可执行文件,前端显示“上传成功”,后端也存下来了。这类问题用文件系统看根本看不出异常,必须把文件当二进制流打开,看开头几个字节到底是不是图片魔数,才能定性。所以“16进制头文件识文件类型”不是冷门知识,而是排查问题、数据恢复、安全分析、导出归档时的一线刚需。
说人话版本:文件头识别就是不看扩展名,直接打开二进制文件看前几个字节,用固定字节特征判断真实格式。扩展名是“标签”,文件头才是“身份证”。这篇文章会把常见格式的魔数整理成表,再给你一套可以直接跑的 Python 脚本,同时把偏移量、大小端、容器嵌套这些容易踩坑的地方一起说清楚。
1.2 “魔数”为什么能当身份证
文件格式设计者在定义格式时,会在文件开头放一串固定字节,用来告诉解析器“我是谁”。比如 JPEG 以FF D8 FF开头,PNG 固定八字节89 50 4E 47 0D 0A 1A 0A,ZIP 固定50 4B 03 04,RAR 开头是52 61 72 21,也就是 ASCII 的Rar!。这串字节在业内常叫 magic number,中文社区叫魔数。不同格式的魔数几乎是互斥的,所以它能当第一道筛选条件,而且完全不受文件名、扩展名、创建时间这些元数据影响。
但要记住,魔数不是万能的。它只是“开头第一句话”,不是完整内容校验。真实世界里,文件头可能被裁剪,可能在开头塞了自定义前缀,可能本身就是嵌套容器,单靠前四个字节会出现误判。所以这篇文章不会只让你背魔数表,还会讲怎么结合文件尾、内部特征字符串、容器内文件列表来做二次确认。整体思路是分级识别:先看文件头,再看结构,最后再看内容特征。
2. 先记下这20种最常见的16进制文件头
2.1 出场率最高的魔数对照表
我把日常工作中出现频率最高的文件头整理成了一张表。你不需要一次全部背下来,但至少要知道“长什么样、去哪里查”。我把十六进制和 ASCII 对照一起列出,方便你用hexdump或任意带 ASCII 栏的十六进制工具核对。
| 文件类型 | 典型文件头(十六进制) | ASCII / 备注 |
|---|---|---|
| JPEG | FF D8 FF | 常见FF D8 FF E0或FF D8 FF E1,后跟应用段标识 |
| PNG | 89 50 4E 47 0D 0A 1A 0A | 第 2~4 字节是PNG |
| GIF | 47 49 46 38 | GIF8,第 6 字节判断版本 7a/9a |
| BMP | 42 4D | BM,老格式也偶见BA、CI等 |
| WebP | 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 | 前四字节 RIFF,偏移 8 处WEBP |
| ICO | 00 00 01 00 | 光标格式为00 00 02 00 |
25 50 44 46 2D | 即%PDF-,版本号紧跟其后 | |
| ZIP | 50 4B 03 04 | 即PK,空的 ZIP 可能是50 4B 05 06 |
| RAR4 | 52 61 72 21 1A 07 00 | Rar!后跟版本信息 |
| RAR5 | 52 61 72 21 1A 07 01 00 | 和 RAR4 前五字节一样,第 6 字节不同 |
| 7z | 37 7A BC AF 27 1C | 第 1~2 字节是7z |
| gzip | 1F 8B 08 | 压缩数据常见,后 4 字节可能含 mtime |
| bzip2 | 42 5A 68 | BZh |
| xz | FD 37 7A 58 5A 00 | 第 2~6 字节是7zXZ |
| ELF | 7F 45 4C 46 | .ELF,Linux 可执行文件/共享库 |
| PE / DOS | 4D 5A | 即MZ,常见于 Windows 可执行文件 |
| SQLite | 53 51 4C 69 74 65 20 66 6F 72 6D 61 74 20 33 00 | 完整 ASCII 是SQLite format 3 |
| MP3 | 49 44 33 | ID3,无 ID3 时常见裸帧同步FF FB |
| FLV | 46 4C 56 01 | FLV后跟版本号 |
| WAV | 52 49 46 46 ?? ?? ?? ?? 57 41 56 45 | RIFF 容器,偏移 8 处WAVE |
| AVI | 52 49 46 46 ?? ?? ?? ?? 41 56 49 20 | RIFF 容器,偏移 8 处AVI |
这张表是我自己整理过很多次之后沉淀下来的基础版本。实际使用中,建议把“基础类型”和“子类型”分开记忆。比如RIFF本身只是一个容器,具体是 WAV 还是 AVI,要看第四到第七字节之后,也就是偏移 8 处的子类型字符串。
2.2 不能只盯偏移 0,有些标志藏在文件中间
很多人以为“文件头 = 文件开头第一个字节”,但真实文件格式比这复杂。一个经典例子是 tar 归档:它的前 257 字节是各个文件的头部信息,只有偏移 257 处才出现ustar字符串,用来标记这个格式是 POSIX tar。所以做识别的时候,必须支持“偏移匹配”,不能一律从偏移 0 开始。
再看 Windows 的主引导记录,文件最开始的 512 字节里有引导代码,但55 AA这个标志出现在偏移 510 处。如果只读前两个字节,永远识别不到它。还有一些日志导出格式、自定义协议数据,会在开头放几字节时间戳或长度字段,真正的格式标识藏在偏移 16 甚至偏移 1024 之后。这类情况继续用“前 4 字节等于什么”去套,一定会漏。
另外,有的格式即使偏移是对的,也可能因为头部被附加了额外内容而失配。比如 PDF 文件规范允许开头存在多行二进制注释,有些工具生成的 PDF 会在%PDF-之前加 BOM 或垃圾字节。这时候要做的不是直接放弃,而是“滑窗搜索”:在文件头一定范围内查找%PDF-子串。后面第 4 节我会具体讲。
2.3 ASCII 可读头与大小端判断
用hexdump -C看一个文件头,左边是十六进制,右边是 ASCII 对照,这是我最推荐的习惯。50 4B 03 04右边显示PK..,52 61 72 21右边显示Rar!,一眼就能认出来,不用背。但注意,不是所有魔数都是可读字符。PNG 的第一个字节89、ZIP 的03 04、7z 的BC AF 27 1C都是不可见字节,纯 ASCII 栏看不出含义,必须记十六进制值。
大小端问题则是另一个坑。ZIP 头部后面的版本号、通用标志位、压缩方式是 little-endian 存储的。比如文件压缩方式位为00 00表示无压缩,08 00表示 deflate,但如果按大端读,08 00会被理解成 2048,整个解析逻辑就崩了。好在做文件类型识别时,我们只比固定字节序列,不用解析数值字段,所以大小端对“魔数匹配”本身影响不大,可一旦你想往下解析文件结构,必须心里有这根弦。
3. Python 实现一个文件头识别脚本
3.1 读取前 32 字节
识别脚本第一步很朴素:读文件开头 N 个字节。为什么是 32 而不是 2 或 8?因为大部分格式的魔数不超过 8 字节,但我们需要为后续扩展留空间。比如 ZIP 的加密标志位于第 6、7 字节,想判断它是不是加密压缩,读 32 字节就能覆盖;SQLite 完整魔数有 16 字节;很多自定义格式需要看前 24 字节。综合下来,第一轮读取 32 字节是比较合适的平衡点,既不会把大文件加载进内存,也能覆盖绝大多数特征。
from pathlib import Path def read_head(file_path, n=32): """读取文件前 n 字节,权限不足或文件不存在时返回空字节串""" try: with open(file_path, "rb") as f: return f.read(n) except OSError as exc: print(f"[skip] {file_path}: {exc}") return b""读写二进制文件一定用"rb"模式,不要用"r"文本模式,否则文件内容会被尝试解码,遇到非 UTF-8 字节直接报错。read(32)这种写法也保证了不会因为文件巨大而内存爆炸,两千兆的文件也只读开头 32 字节,这是做批量扫描时性能预算最友好的操作。
3.2 魔数表怎么设计
魔数表不要写成一长串 if-else,维护起来太痛苦。我习惯用“名称 + 魔数字节串 + 偏移量”的元组列表,对照起来非常清晰:
MAGIC_TABLE = [ ("JPEG", b"\xFF\xD8\xFF", 0), ("PNG", b"\x89PNG\r\n\x1a\n", 0), ("GIF", b"GIF8", 0), ("BMP", b"BM", 0), ("PDF", b"%PDF-", 0), ("ZIP", b"PK\x03\x04", 0), ("ZIP_EMPTY", b"PK\x05\x06", 0), ("RAR4", b"Rar!\x1a\x07\x00", 0), ("RAR5", b"Rar!\x1a\x07\x01\x00", 0), ("7Z", b"7z\xbc\xaf\x27\x1c", 0), ("GZIP", b"\x1f\x8b\x08", 0), ("BZIP2", b"BZh", 0), ("XZ", b"\xfd7zXZ\x00", 0), ("ELF", b"\x7fELF", 0), ("PE", b"MZ", 0), ("SQLITE", b"SQLite format 3\x00", 0), ] def match_magic(head, magic, offset=0): if offset + len(magic) > len(head): return False return head[offset:offset + len(magic)] == magic def classify_head(head): if not head: return "EMPTY" for name, magic, offset in MAGIC_TABLE: if match_magic(head, magic, offset): return name return "UNKNOWN"有一个必须处理的细节:某些魔数存在包含关系。比如MZ既可能是 PE 可执行文件,也可能是纯 DOS 可执行文件,继续往下看第 0x3C 偏移处指向的 PE 头才能区分。再比如RIFF是通用容器,光匹配到RIFF不能直接说它是 WAV。所以真正的分类函数里,要比对子类型字符串,或者把容器类格式放到后面的二次判断中处理。
3.3 批量扫描目录并识别扩展名伪装
批量扫描目录是文件头识别最常用的形式。直接上代码:
EXT_TO_TYPE = { ".jpg": "JPEG", ".jpeg": "JPEG", ".png": "PNG", ".gif": "GIF", ".bmp": "BMP", ".pdf": "PDF", ".zip": "ZIP", ".rar": "RAR4", ".7z": "7Z", } def scan_dir(root): result = [] for p in Path(root).rglob("*"): if not p.is_file(): continue head = read_head(p, 32) detected = classify_head(head) real_ext = p.suffix.lower() expected = EXT_TO_TYPE.get(real_ext, "") remark = "" if expected and expected != detected: remark = "MISMATCH" elif detected == "UNKNOWN": remark = "UNKNOWN_TYPE" result.append((str(p), detected, real_ext, remark)) return result这里输出的是一个列表,你可以直接转 CSV。MISMATCH的意思是说扩展名预期是 JPEG,但文件头识别出的类型不是 JPEG,这种文件大概率被改了扩展名,或者文件头本身损坏。要注意,不匹配不代表一定是恶意文件,也可能是业务系统自动重命名,所以扫描脚本只要把“可疑”标出来,不要自动改文件名。
3.4 编码细节和异常处理
写这个脚本时最容易翻车的是字节串和普通字符串混用。b"\xFF\xD8\xFF"是字节串,"\xFF\xD8\xFF"是普通字符串,两者不能互相比较。Python 里b"PNG"合法,b"{:c}"这种格式化方式也别乱用。魔数表里全部统一写成bytes类型,读取函数也用rb,就不会有编码问题。
空文件要单独处理,head是b"",不要拿去和魔数比较,直接返回EMPTY。权限不足时会抛PermissionError,所以读取函数要把OSError捕获住。符号链接文件在Path.rglob里可能出现重复访问,如果遇到循环链接,要加try/except或在遍历时跳过符号链接。最后,把所有异常都打印到日志而不是终端,方便排错。
4. 比文件头更可靠的辅助信息
4.1 从文件尾和中间偏移找补充特征
文件头能确定“开场白”,文件尾能确定“结束语”,两者结合,准确率会高很多。常见文件尾特征如下:PNG 的 IEND 块是49 45 4E 44 AE 42 60 82,PDF 经常以%%EOF结尾,ZIP 在文件末尾有50 4B 05 06的中央目录结束标记。读取文件尾部也要小心,不能直接读最后 n 字节,要先seek到文件尾部前面:
def read_tail(file_path, n=64): try: with open(file_path, "rb") as f: f.seek(0, 2) size = f.tell() pos = max(0, size - n) f.seek(pos) return f.read() except OSError as exc: print(f"[skip] {file_path}: {exc}") return b"" def classify_with_tail(file_path): head = read_head(file_path, 32) tail = read_tail(file_path, 64) base = classify_head(head) if base == "PNG" and tail.rfind(b"\x49\x45\x4E\x44\xAE\x42\x60\x82") != -1: return "PNG" if base == "PDF" and b"%%EOF" in tail: return "PDF" return base实际中,文件头被截断但尾部完好的情况很常见。比如一个人用十六进制编辑器删掉了某个文件开头几个字节,扩展名还是.png,只读头会变成UNKNOWN,但文件尾还留着 PNG 的 IEND 块,结合中间是否有 IDAT 块,就能大概率推断它原来是 PNG。当然,这种推断要标注为“猜测”,不能当成百分百结论。
4.2 文本文件怎么识别
文本文件没有固定魔数,但同样有规律可循。第一看 BOM,UTF-8 的 BOM 是EF BB BF,UTF-16 LE 是FF FE,UTF-16 BE 是FE FF。第二看首行关键字,XML 声明以<?xml开头,HTML 常见<!DOCTYPE html>或<html,JSON 多数以{或[开头。第三看 shebang,Linux 脚本第一行常有#!,比如#!/bin/bash、#!/usr/bin/python3。
def sniff_text(head): if head.startswith(b"\xef\xbb\xbf"): return "UTF8_TEXT" if head.startswith(b"\xff\xfe"): return "UTF16LE_TEXT" if head.startswith(b"\xfe\xff"): return "UTF16BE_TEXT" head_clean = head.lstrip() if head_clean.startswith(b"<?xml"): return "XML" if head_clean.startswith(b"<!DOCTYPE html") or head_clean.startswith(b"<html"): return "HTML" if head.startswith(b"#!"): return "SHELL_SCRIPT" if head.startswith(b"{") or head.startswith(b"["): return "JSON_LIKE" return "PLAIN_TEXT_CANDIDATE"注意,head.lstrip()对二进制数据也可以,它会去掉开头所有空白字节,因为这些空白在文本文件里通常不影响判断。但不要对 PDF、PNG 这类二进制格式做 lstrip,否则容易把特征弄丢。
4.3 嵌套容器要拆开看
文件头识别最迷惑人的案例就是“头不对形”。一个扩展名是.docx的文件,用十六进制打开后看到的其实是50 4B 03 04,也就是 ZIP 头。为什么?因为 Office Open XML 格式本质上就是 ZIP 容器,内部再放 XML 和媒体文件。同理,EPUB、JAR、APK 也都是 ZIP 容器。只看文件头,只能判断它们是“ZIP”,不能继续细分。
这时需要打开容器看内部文件列表,做一个“二次识别”:
import zipfile def detect_zip_subtype(file_path): try: with zipfile.ZipFile(file_path) as zf: names = [n.lower() for n in zf.namelist()] except zipfile.BadZipFile: return "BAD_ZIP" if "[content_types].xml" in names: if any(n.startswith("word/") for n in names): return "DOCX" if any(n.startswith("xl/") for n in names): return "XLSX" if any(n.startswith("ppt/") for n in names): return "PPTX" return "OOXML_ZIP" if "mimetype" in names: return "EPUB_OR_OEBPS" if "meta-inf/manifest.mf" in names: return "JAR_OR_APK" return "ZIP"同理,RIFF 容器内部由子类型字符串区分 WAV、AVI、WebP;OLE2 容器内部则要看目录流的名字区分老版本 doc、xls、ppt。碰到这类格式,仅靠“文件头识别”只是完成第一层,真正准确的结论需要做容器解析。
4.4 熵值看通道:加密压缩的旁证
文件头只能告诉你“外壳是什么”,看不出内部数据的状态。一个正常的 ZIP 包和一个加密的 ZIP 包,魔数一模一样。这时候有一个辅助指标:信息熵。未压缩的文本熵通常比较低,压缩或加密后的数据熵接近每字节 8 bit。
import collections, math def entropy(data): if not data: return 0.0 counter = collections.Counter(data) length = len(data) ent = 0.0 for count in counter.values(): p = count / length ent -= p * math.log2(p) return ent这只是一个旁证。比如一个文件头显示是 ZIP,但内部所有文件名的熵值极低,内容熵值极高,说明它很可能是一个加密压缩包,或者里面全被压缩过。如果熵值很高但没有任何压缩工具能正常解压,那就要怀疑文件是不是被改成无意义随机数据了。不要把熵值当唯一结论,只把它当成“要不要人工介入复查”的信号。
5. 常见问题与排查技巧实录
5.1 明明文件头正确,为什么还是识别错
我见过最多的误判有这几种:
第一,文件头被截断。文件损坏或被不完整下载时,可能只有前两个字节FF D8,没有完整的FF D8 FF。我的做法是遇到这种“短匹配”,输出JPEG_PARTIAL,并继续检查文件尾,避免直接下结论。
第二,魔数表顺序不对。我的表是有优先级顺序的,PK\x03\x04必须排在PK前面,否则看到 ZIP 头会被先匹配成PK。同样RAR4和RAR5前五字节一样,要先匹配长的那个。
第三,容器冲突。前面说过RIFF、ZIP、OLE2都可能代表多种真实格式。如果只按第一层匹配,满目录的 WAV 和 AVI 都会变成RIFF。解决办法是在classify函数里做“二级跳转”,用子类型字符串精确区分。
排查时第一件事是打印原始字节:
print(file_path, head.hex(" "), head[:16])bytes.hex(" ")能把head转成带空格分隔的十六进制字符串,方便对照文档。这条命令我在现场排查时几乎天天用,比人脑记忆靠谱得多。
5.2 大小端和版本号导致的魔数偏差
我遇到过一个典型问题:一个 RAR 文件开头是52 61 72 21 1A 07 01 00,我想当然用 RAR4 的魔数去匹配,结果死活匹配不上。后来查了文档才知道,RAR5 在第 6 字节从00变成了01,所以光记“Rar!”五个字节不够,必须连版本位一起判断。
大小端问题在 RIFF 容器里尤其明显。WAV 文件头第七到第十字节是文件大小,以 little-endian 存储。比如52 49 46 46 24 08 00 00,这里的24 08并不是十六进制数 0x2408,而是 little-endian 的 0x0824,等于 2084。如果你用大端去读,解析出来的尺寸会完全错误。这类问题不是魔数本身错,是后续解析逻辑错,但很容易被误归结为“文件头对不上”。建议在代码里写清楚字节序,必要时用struct.unpack("<I", data[4:8])这种显式解包。
5.3 伪装扩展名到底要不要自动改
我的经验是:不要自动改。就算魔数明确告诉你它是 PNG,但扩展名是.jpg,也最好不要写个循环把所有文件批量重命名成识别结果。原因有三点:
一是同名扩展名可能有多种合法子类型。.bin、.dat、.tar都是典型的“万能扩展名”,内部可能是任何东西。改成.png只对了外壳,可能影响后续业务。二是同一份文件可能同时属于多种格式。比如一个 EPUB 文件,你既可以说它是 ZIP,也可以说它是 EPUB,还可以说它是 OOXML 容器,不同系统需要的扩展名不同。三是自动改名具有不可逆性,一旦改错,原始扩展名就丢了。除非你能拷到新文件,别动源文件。
更好的做法是导出一个“建议名”字段,比如photo_001.png,但实际执行改名之前先出一份 dry-run 清单给人确认。批量操作脚本里,--dry-run参数一定要保留。
5.4 大批量文件的性能细节
识别脚本本身很轻,但文件数量大了之后容易遇到两类问题:遍历慢和读文件慢。Path.rglob("*")很好用,但会在深层目录里反复stat,如果目录层级特别深,性能急剧下降。更稳妥的是用os.scandir自己写迭代器,或者先限制文件大小,比如只处理 100MB 以内的文件,超过直接跳过。
读取文件只读前 32 字节和尾部 64 字节,绝不调用read()把全部内容装进来。对于几十万个文件的目录,串行扫描通常已经够用,是不是用多进程需要看你的瓶颈在 I/O 还是 CPU。文件头匹配本身不消耗 CPU,所以多进程收益不大,反而增加上下文切换开销。我最后把结果直接写入 CSV 而不是 print 到终端,否则几十万条输出会把终端卡死。
6. 把这些整合成随手可用的脚本工具
6.1 从函数到命令行工具
当函数写完后,下一步是做成命令行工具。用标准库argparse就能实现,不需要引入额外依赖。核心用法是:传入一个文件或目录,可选--csv参数把结果输出到文件。
import argparse import csv import sys from pathlib import Path def main(): parser = argparse.ArgumentParser(description="基于16进制文件头识别文件类型") parser.add_argument("path", help="文件或目录路径") parser.add_argument("--csv", default="", help="输出到CSV文件") parser.add_argument("--dry-run", action="store_true", help="只输出不修改") args = parser.parse_args() target = Path(args.path) rows = [] if target.is_dir(): rows = scan_dir(target) elif target.is_file(): rows = scan_one(target) else: print("路径不存在", file=sys.stderr) return 1 if args.csv: with open(args.csv, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["path", "detected_type", "ext", "remark"]) writer.writerows(rows) else: for row in rows: print("\t".join(row)) return 0 if __name__ == "__main__": sys.exit(main())你还可以加一个--hex参数,在每个输出行附上文件前 16 字节的十六进制值,这样即使识别结果不对,也能立刻人工检查。命令行没有输出图片、表格这些花哨功能,但胜在稳定、可脚本化、可定时跑。
6.2 我的几个“后悔没早知道”的经验
第一个经验是:永远先看原始十六进制,再下结论。有一次脚本报UNKNOWN,我把文件头打印出来一看,开头是89 50 4E 47,分明是 PNG,但脚本匹配失败。原因是文件被处理成了文本模式,b"\x89PNG"被当作 Unicode 解码污染了。这类问题如果不上来看原始字节,光看报错信息很难查到。
第二个经验是:魔数表要单独建模块,不要和业务逻辑写在一起。文件格式在持续演进,新版本可能调整头部结构,把这些数据放在一个magic_table.py里,改起来非常快。RAR5 出来后,我只需要在表里加一条,主逻辑一行不用动。
第三个经验是:先定“unknown”语义。我的脚本有三种未知状态:EMPTY表示空文件,UNKNOWN表示前缀匹配失败,PARTIAL表示匹配到了部分魔数但长度不够完整。把这三者分开后,排查效率大幅提升。之前混在一起,看到UNKNOWN根本不知道是文件坏了还是格式真不认识。
6.3 后面还能怎么扩展
这套“文件头识别 + 容器拆包 + 文件尾校验 + 熵值旁证”的流程,天然能延伸到更多方向。比如做安全审计时,把扫描结果接入告警系统,凡是扩展名与文件头不一致的文件全部进入高风险队列;做数据恢复时,扫描损坏磁盘导出的大块数据,利用魔数定位文件起点,再结合文件尾估算恢复边界;做后端上传校验时,服务端读前几个字节判断真实类型,比只信任浏览器传的Content-Type可靠得多。
还有一个方向是自定义格式识别。音频、视频、图片格式的规范在不断更新,如果你有自己定义的封包格式,完全可以在魔数表里加一行自定义魔数。只要你的格式规范里写了固定头部,这套东西就能识别。
踩过几次坑之后,我现在的习惯是:手边常备一个“打印文件头”的小脚本,无论遇到什么问题,先把hex(head)打出来,再对照魔数表。识别脚本只是辅助,真正下结论前一定要看原始字节。尤其是当你面对的是.bin和.dat那一堆无从下手的文件时,能稳定输出 JPEG、PDF、ZIP 的魔数表,会让你少很多头疼。