怎么判断一个文件的真实类型?靠扩展名可靠吗?
简化版
判断文件类型有三条路,可靠性依次递增:① 看扩展名(Path(p).suffix)——最不可靠,扩展名是给人和操作系统看的提示,随手改一下 .exe 就变成 .jpg;② 用 mimetypes.guess_type(p)——它仍然只是查扩展名到 MIME 的映射表**,不读文件内容;③ 读文件开头的「魔数」(magic number)——绝大多数二进制格式在文件头部有固定的标识字节,比如 PNG 是 \x89PNG\r\n\x1a\n、JPEG 是 \xff\xd8\xff、PDF 是 %PDF-、ZIP 是 PK\x03\x04、gzip 是 \x1f\x8b。只有第三种才反映文件的真实内容。处理用户上传时这三个都不能单独信任:浏览器发来的 Content-Type 和文件名完全由客户端控制,攻击者可以上传一个内容是 PHP 脚本、扩展名是 .jpg、Content-Type 是 image/jpeg 的文件。正确的做法是「魔数校验 + 白名单 + 重命名存储 + 不可执行目录」四件套,图片还应该用 Pillow 真正解码一次(能挡住「魔数正确但内容畸形」的攻击)。工具选择:标准库的 mimetypes(只看扩展名);第三方 filetype(纯 Python,读魔数,零依赖) 或 python-magic(绑定 libmagic,最准但要装系统库);注意标准库的 imghdr/sndhdr 已在 Python 3.13 移除。核心记忆:扩展名和 Content-Type 都是客户端说了算,只有魔数才反映真实内容;上传处理靠白名单 + 重命名 + 不可执行。
详细版
三种方式对比:
| 方式 | 依据 | 可靠性 | 用途 |
|---|---|---|---|
Path(p).suffix | 文件名 | ❌ 最低 | 只作提示 |
mimetypes.guess_type(p) | 扩展名→MIME 映射表 | ❌ 低(不读内容) | 设置 HTTP 响应头 |
读魔数(自己写/filetype) | 文件内容前几个字节 | ✅ 高 | 安全校验 |
python-magic(libmagic) | 内容 + 启发式规则 | ✅ 最高 | 复杂格式识别 |
| 用真正的解析器打开 | 完整解码 | ✅✅ 最终确认 | 图片/文档 |
常见魔数速查:
| 格式 | 起始字节 | 备注 |
|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A | \x89PNG\r\n\x1a\n |
| JPEG | FF D8 FF | 结尾是 FF D9 |
| GIF | GIF87a / GIF89a | |
%PDF- | ||
| ZIP / docx / xlsx / jar / apk | 50 4B 03 04(PK..) | 它们本质都是 zip |
| gzip | 1F 8B | |
| 7z | 37 7A BC AF 27 1C | |
| RAR | Rar!\x1a\x07 | |
| ELF(Linux 可执行) | 7F 45 4C 46(\x7fELF) | |
| Windows PE(exe/dll) | MZ | |
| WebP | RIFF....WEBP | 偏移 8 处才是 WEBP |
| MP4 | ....ftyp | 偏移 4 处是 ftyp |
| SQLite | SQLite format 3\x00 |
import mimetypes
from pathlib import Path
# ① 扩展名(★只是提示★)
print(Path("a.tar.gz").suffix) # '.gz' ← ★只取最后一段★
print(Path("a.tar.gz").suffixes) # ['.tar', '.gz']
# ② mimetypes:★仍然只看扩展名★,不读内容
print(mimetypes.guess_type("a.png")) # ('image/png', None)
print(mimetypes.guess_type("virus.exe.png")) # ('image/png', None) ← ★被骗了★
print(mimetypes.guess_extension("image/jpeg"))# '.jpg'(或 .jpe,★取决于平台★)
mimetypes.add_type("application/x-my", ".myx") # 自定义
# ③ ★魔数检测:真正读内容★
MAGIC = [
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpg"),
(b"GIF87a", "gif"),
(b"GIF89a", "gif"),
(b"%PDF-", "pdf"),
(b"PK\x03\x04", "zip"), # ★docx/xlsx/jar/apk 都命中这个★
(b"\x1f\x8b", "gz"),
(b"\x7fELF", "elf"),
(b"MZ", "exe"),
]
def detect(path, _head=32):
with open(path, "rb") as f:
head = f.read(_head)
for magic, kind in MAGIC:
if head.startswith(magic):
return kind
# 需要看偏移的格式
if head[8:12] == b"WEBP": return "webp"
if head[4:8] == b"ftyp": return "mp4"
return None
# ④ 第三方库(更省事)
# pip install filetype —— ★纯 Python,零系统依赖★
# import filetype; kind = filetype.guess("a.bin"); print(kind.mime, kind.extension)
# pip install python-magic —— 绑定 libmagic,★最准★但要装系统库
# import magic; print(magic.from_file("a.bin", mime=True))
# ⑤ ★上传文件的安全处理模板★
ALLOWED = {"png": "image/png", "jpg": "image/jpeg", "pdf": "application/pdf"}
def save_upload(file_bytes: bytes, orig_name: str, dest_dir: Path) -> Path:
kind = detect_bytes(file_bytes) # ★① 按内容判断★
if kind not in ALLOWED: # ★② 白名单(不是黑名单)★
raise ValueError(f"不支持的类型: {kind}")
if len(file_bytes) > 10 << 20: # ★③ 限大小★
raise ValueError("文件过大")
if kind in ("png", "jpg"): # ★④ 图片真正解码一次★
from PIL import Image; import io
with Image.open(io.BytesIO(file_bytes)) as im:
im.verify() # 畸形图片会抛异常
import uuid
safe = f"{uuid.uuid4().hex}.{kind}" # ★⑤ 重命名,不用原文件名★
(dest_dir / safe).write_bytes(file_bytes)
return dest_dir / safe
# ⑥ ★标准库变更:imghdr / sndhdr 已在 Python 3.13 移除★
# import imghdr; imghdr.what("a.png") # ✗ 3.13 起不存在(PEP 594)
# → 改用 filetype / python-magic / Pillow
⚠️ 三个必须记住的点:①
mimetypes不读文件内容——它只是一张「扩展名 → MIME 类型」的映射表(还会读系统的/etc/mime.types,所以同一段代码在不同系统上结果可能不同)。mimetypes.guess_type("木马.exe.png")会返回image/png,用它做安全校验等于没做。它的正确用途是给已经确认过类型的文件设置 HTTP 响应头。② HTTP 上传里的Content-Type和文件名完全由客户端控制,攻击者可以随意伪造——服务端必须按内容重新判断。③ 魔数正确不代表文件安全:一个文件可以「前 8 字节是合法的 PNG 头、后面全是 PHP 代码」(这类文件在图片查看器里可能还能正常显示)。所以魔数只是第一道筛,图片要用 Pillow 真正解码一次(Image.open(...).verify()),存储时必须重命名 + 放在不可执行的目录/对象存储——这才是真正挡住「上传即 RCE」的关键。
完整版教学
一、三条判断路径与可靠性
路径 ①:扩展名 —— ★最不可靠★
Path("photo.jpg").suffix → ".jpg"
★ 扩展名只是文件名的一部分,任何人都能改:
mv malware.exe photo.jpg 内容一个字节都没变
★ 而且扩展名和类型不是一一对应:
.doc 可能是老 Word(OLE)也可能是 RTF 甚至纯文本
.dat/.bin 完全没有语义
✓ 唯一合理用途:给用户看、决定用什么程序打开(这是操作系统的事)
路径 ②:mimetypes —— ★仍然只看扩展名★
mimetypes.guess_type("a.png") → ("image/png", None)
★ 它内部就是一张映射表(+ 读系统的 /etc/mime.types)
→ ★不读文件内容一个字节★
→ 同一段代码在 Ubuntu/macOS/Windows 上结果可能不同(系统表不一样)
✓ 正确用途:★给已确认类型的文件设置 HTTP Content-Type 响应头★
✗ 错误用途:安全校验
路径 ③:魔数(magic number)—— ★真正读内容★
绝大多数二进制格式在开头有固定标识:
PNG 89 50 4E 47 0D 0A 1A 0A ← 精心设计(后面会讲为什么这么长)
JPEG FF D8 FF
PDF %PDF-
ZIP 50 4B 03 04("PK" 是发明者 Phil Katz 的缩写)
✓ 这是判断"文件实际是什么"的基础
✗ 但★不是万能★:
- 纯文本(.txt/.csv/.json/.py)★没有魔数★ → 只能靠内容启发式判断
- 有些格式的魔数很短("MZ" 只有 2 字节)→ ★误判率高★
- 魔数正确 ≠ 文件完整/安全(后面全是垃圾数据也照样通过)
路径 ④:用真正的解析器打开 —— ★最终确认★
Image.open(f).verify() 图片
json.load(f) JSON
zipfile.ZipFile(f).testzip() ZIP
✓ 能发现"魔数对但内容畸形"的文件
✗ 代价:要完整解析(慢),而且★解析器本身可能有漏洞★(图片解码 CVE 很多)
★ 可靠性排序:解析器 > libmagic > 自写魔数 > mimetypes ≈ 扩展名
★ 性能排序: 扩展名 > mimetypes > 魔数 > libmagic > 解析器
→ 实践:★先用魔数快速筛(便宜),通过后再用解析器确认(贵)★
判断文件类型有四条路径,可靠性和性能恰好相反。扩展名最不可靠——它只是文件名的一部分,mv malware.exe photo.jpg 内容一个字节都不变;而且扩展名和类型并非一一对应(.doc 可能是 OLE 格式、RTF 甚至纯文本)。mimetypes 看起来”高级”但本质仍是查扩展名映射表,一个字节的文件内容都不读,而且它会加载系统的 /etc/mime.types,同一段代码在不同系统上结果可能不同。魔数才是真正读内容的方式,但也不是万能:纯文本格式(txt/csv/json/py)根本没有魔数,只能靠启发式判断;有些魔数很短(MZ 只有 2 字节)误判率高;而且魔数正确不代表文件完整或安全。最终确认要靠真正的解析器(Image.open().verify()、zipfile.testzip()),代价是慢且解析器本身可能有漏洞。实践策略是先用魔数快速筛(便宜),通过后再用解析器确认(贵)。
二、魔数的原理与常见格式
为什么 PNG 的魔数是 89 50 4E 47 0D 0A 1A 0A 这么复杂?
89 ★高位为 1★ → 如果被当成 7 位 ASCII 传输会被破坏,从而暴露传输错误
50 4E 47 ('PNG') → 人能在 hexdump 里认出来
0D 0A (\r\n) → ★检测 Windows↔Unix 换行符转换★(被转换过就不匹配了)
1A (Ctrl-Z) → 在 DOS 下 type 命令看到它会停止输出
0A (\n) → 再次检测换行符转换
★ 这是格式设计的经典范例:魔数不只是标识,还能★检测传输过程中的损坏★
魔数的三种位置形态:
① 从第 0 字节开始(最常见):PNG、JPEG、PDF、ZIP、gzip
② ★在固定偏移处★:
WebP:偏移 0 是 "RIFF",★偏移 8 是 "WEBP"★
MP4: ★偏移 4 是 "ftyp"★
TAR: ★偏移 257 是 "ustar"★
ISO: 偏移 32769 是 "CD001"
③ 在文件★尾部★:
ZIP 的"中央目录结束记录"在末尾(所以 zip 可以有前置数据 → 自解压包)
★这也是 zip 能和别的文件"拼接"的原因(图片马就利用这点)
★ 容器格式的坑(面试常问):
docx / xlsx / pptx / jar / apk / epub / ★odt★ 的魔数全是 "PK\x03\x04"
→ 因为它们★本质都是 zip★(里面装着 XML)
→ 只靠魔数只能判断出"这是个 zip"
✓ 要区分必须★打开 zip 看里面有什么★:
docx → 有 word/document.xml
xlsx → 有 xl/workbook.xml
jar → 有 META-INF/MANIFEST.MF
apk → 有 AndroidManifest.xml
→ 这正是 libmagic 比"自写魔数表"强的地方(它内置了这类启发式规则)
没有魔数的格式怎么判断:
纯文本(txt/csv/json/xml/py/md)没有固定头部
✓ 启发式:
- 尝试用 UTF-8 解码,成功且无控制字符 → 大概率是文本
- 有 BOM(EF BB BF / FF FE / FE FF)→ 文本且能确定编码
- 首字符是 { 或 [ 且能 json.loads → JSON
- 以 <?xml 开头 → XML
- 前 N 字节里有 \x00 → ★大概率是二进制★(这是 git 判断二进制的方法)
✓ 或者干脆"尝试解析":json.loads / csv.Sniffer().sniff()
魔数的设计比想象中讲究。PNG 的 8 字节魔数是格式设计的经典范例:89 是高位为 1 的字节(被当作 7 位 ASCII 传输会被破坏,从而暴露错误)、PNG 三个字母便于人在 hexdump 里辨认、\r\n 和 \n 用来检测 Windows↔Unix 的换行符转换(文件被文本模式传输过就不匹配了)、1A 是 DOS 下 type 命令的停止符——魔数不只是标识,还能检测传输损坏。魔数的位置有三种形态:从第 0 字节开始(最常见)、在固定偏移处(WebP 在偏移 8、MP4 在偏移 4、TAR 在偏移 257)、以及在文件尾部(ZIP 的中央目录在末尾,这也是 zip 能带前置数据做自解压包、以及「图片马」能拼接的原因)。容器格式是高频考点:docx/xlsx/pptx/jar/apk/epub 的魔数全是 PK\x03\x04,因为它们本质都是 zip——只靠魔数只能判断出「这是个 zip」,要区分必须打开看里面有什么文件(docx 有 word/document.xml、jar 有 META-INF/MANIFEST.MF),这正是 libmagic 比自写魔数表强的地方。
三、标准库能做什么,不能做什么
mimetypes(★只查扩展名★):
mimetypes.guess_type("a.png") → ("image/png", None)
mimetypes.guess_type("a.tar.gz") → ("application/x-tar", "gzip") ★第二个是编码★
mimetypes.guess_extension("image/jpeg") → ".jpg"(★可能是 .jpe,取决于平台★)
mimetypes.add_type("application/x-foo", ".foo")
mimetypes.init(files=["/path/mime.types"]) # 显式加载
★ 三个坑:
① 结果依赖★系统的 mime.types 文件★ → 跨平台不一致
(Windows 上还会读注册表 → 用户装了软件都可能影响结果)
② guess_extension 对同一 MIME 可能返回不同扩展名(.jpg vs .jpe)
③ ★完全不读文件内容★
✓ 合理用途:给下载响应设 Content-Type、决定静态文件的响应头
★ imghdr / sndhdr:Python 3.13 已移除(PEP 594)
以前:imghdr.what("a.png") → 'png'(读魔数判断图片类型)
这两个模块因为"格式支持过时、维护成本高"被移出标准库
✓ 替代:filetype(纯 Python)、python-magic、Pillow
自己写魔数检测(够用且零依赖):
def detect(head: bytes) -> str | None:
# ★注意顺序:先匹配长的、更具体的★
table = [
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpg"),
(b"GIF8", "gif"),
(b"%PDF-", "pdf"),
(b"\x1f\x8b", "gz"),
(b"PK\x03\x04", "zip"),
]
for magic, kind in table:
if head.startswith(magic):
return kind
if head[8:12] == b"WEBP": return "webp"
if head[4:8] == b"ftyp": return "mp4"
return None
★ 只需要读开头 32~64 字节,不必读整个文件:
with open(p, "rb") as f: head = f.read(64)
★ 处理上传时直接对 bytes 判断,不用先落盘
第三方库对比:
┌───────────────┬────────────┬──────────────────────────────────┐
│ filetype │ ★纯 Python★ │ 零系统依赖、只看魔数、覆盖常见格式 │
│ python-magic │ 绑定 libmagic│ ★最准★(内容+启发式),要装系统库 │
│ │ │ Windows 上要额外装 DLL(python-magic-bin)│
│ puremagic │ 纯 Python │ 类似 filetype,格式库更大 │
│ Pillow │ 图片解码 │ ★能确认图片是否真的可解码★ │
└───────────────┴────────────┴──────────────────────────────────┘
★ 选择:只判断常见格式 → filetype(零依赖,容器里友好)
需要识别几百种格式/文本编码 → python-magic
标准库在这件事上能力有限。mimetypes 只查扩展名,而且结果依赖系统的 /etc/mime.types(Windows 上还读注册表),所以跨平台不一致——它的正确用途是给已确认类型的文件设置 HTTP Content-Type 响应头,不是做校验。要特别注意一个标准库变更:imghdr 和 sndhdr 已在 Python 3.13 被移除(PEP 594),老代码里的 imghdr.what() 需要迁移到 filetype、python-magic 或 Pillow。自己写魔数检测其实完全够用且零依赖——只需读开头 32~64 字节、按「先匹配更长更具体的」顺序比对即可;处理上传时直接对 bytes 判断,不用先落盘。第三方库的选择很简单:只需判断常见格式用 filetype(纯 Python、零系统依赖,容器里友好);需要识别几百种格式和文本编码用 python-magic(绑定 libmagic,最准,但要装系统库、Windows 上还要额外的 DLL)。
四、上传文件的安全:为什么魔数也不够
攻击面(★服务端必须假设客户端全是恶意的★):
① HTTP 的 Content-Type 头 ← ★完全由客户端伪造★
② multipart 里的 filename ← ★同样由客户端控制★
③ 扩展名 ← 同上
→ ★这三个都不能作为安全判断的依据★
经典攻击手法:
① 双扩展名 / 解析漏洞
"shell.php.jpg"(有些服务器配置会当 php 执行)
"shell.jpg;.php"、"shell.php%00.jpg"(老版本的截断漏洞)
② ★图片马(polyglot 文件)★
一个文件★同时是合法 PNG 和合法 PHP/JS★:
前面是真正的 PNG 数据(能通过魔数校验、能被图片查看器打开)
后面追加 <?php system($_GET['c']); ?>
→ ★魔数校验完全通不过这一关★
→ 只要它被放进 Web 目录且能被当脚本执行 = RCE
③ SVG 里的 XSS
SVG 是 XML,可以包含 <script>
→ 用户上传"图片",别人访问时执行 JS(同源,能偷 Cookie)
✓ SVG 要么禁止,要么严格清洗(DOMPurify 之类),要么以 attachment 下载
④ ★解压/解码炸弹★
"图片炸弹":一张 6KB 的 PNG 解码后是 60000x60000 像素 = ★十几 GB 内存★
✓ Pillow 有 Image.MAX_IMAGE_PIXELS 保护(默认约 1.8 亿像素,会警告/报错)
⑤ 路径穿越
filename = "../../etc/cron.d/x" → 保存时写到目录外
✓ ★永远不要用客户端给的文件名做路径★
★ 正确的处理流程(四件套,缺一不可):
① ★按内容判断类型★(魔数 + 必要时用解析器确认)
② ★白名单★(只允许明确需要的类型,不是"禁止 exe/php"的黑名单)
③ ★重命名存储★:uuid4().hex + 由服务端决定的扩展名
→ 彻底切断"文件名/扩展名"这条攻击链
④ ★存到不可执行的位置★:
- 对象存储(S3/OSS)★最佳★,和应用完全隔离
- 或独立域名 + Web 服务器配置为"不解析任何脚本"
- 绝不放在 Web 应用的代码目录下
补充措施:
⑤ 限制大小(请求体大小 + 单文件大小)
⑥ 图片:用 Pillow 打开并 verify(),甚至★重新编码保存★(彻底去掉附加数据)
im = Image.open(io.BytesIO(data)); im.verify()
im = Image.open(io.BytesIO(data)); im.convert("RGB").save(out, "JPEG")
→ ★重新编码能干掉图片马的附加载荷和 EXIF 里的隐私信息★
⑦ 下载时设置正确的响应头:
Content-Type: 服务端确认的类型(★不要回显客户端给的★)
Content-Disposition: attachment(强制下载而不是浏览器内渲染)
X-Content-Type-Options: nosniff(★阻止浏览器"猜"类型★)
⑧ 病毒扫描(ClamAV 等)—— 面向公众的服务值得加
上传处理是这道题最重要的落地场景。服务端必须假设 Content-Type、filename、扩展名这三样全是伪造的。经典攻击有五类:双扩展名利用服务器解析配置;图片马(polyglot 文件)——一个文件同时是合法 PNG 和合法 PHP,前面是真图片数据(魔数校验完全通得过)、后面追加脚本代码;SVG 里的 <script> 造成存储型 XSS(SVG 本质是 XML);图片炸弹(6KB 的 PNG 解码后是 60000×60000 像素、吃掉十几 GB 内存);以及用 filename 做路径穿越。正确处理是四件套且缺一不可:按内容判断类型 → 白名单(不是黑名单)→ 重命名存储(uuid4().hex + 服务端决定的扩展名,彻底切断文件名这条攻击链)→ 存到不可执行的位置(对象存储最佳)。图片还应该用 Pillow 重新编码保存——这能同时干掉图片马的附加载荷和 EXIF 里的隐私信息。下载时也要设对响应头:服务端确认的 Content-Type、Content-Disposition: attachment、X-Content-Type-Options: nosniff(阻止浏览器猜类型)。
五、实战代码与工具选型
① 零依赖的检测函数(够用版)
MAGIC = (
(0, b"\x89PNG\r\n\x1a\n", "png"),
(0, b"\xff\xd8\xff", "jpg"),
(0, b"GIF8", "gif"),
(0, b"%PDF-", "pdf"),
(0, b"PK\x03\x04", "zip"),
(0, b"\x1f\x8b", "gz"),
(0, b"\x7fELF", "elf"),
(0, b"MZ", "exe"),
(8, b"WEBP", "webp"), # ★偏移 8★
(4, b"ftyp", "mp4"), # ★偏移 4★
)
def sniff(data: bytes) -> str | None:
for off, magic, kind in MAGIC:
if data[off:off + len(magic)] == magic:
return kind
return None
② 区分 zip 家族(docx/xlsx/jar/apk)
import zipfile, io
def zip_kind(data: bytes) -> str:
with zipfile.ZipFile(io.BytesIO(data)) as z:
names = set(z.namelist())
if "word/document.xml" in names: return "docx"
if "xl/workbook.xml" in names: return "xlsx"
if "ppt/presentation.xml" in names: return "pptx"
if "META-INF/MANIFEST.MF" in names: return "jar"
if "AndroidManifest.xml" in names: return "apk"
return "zip"
③ 判断"是文本还是二进制"(git 的方法)
def is_binary(data: bytes) -> bool:
if b"\x00" in data[:8000]: # ★有 NUL 字节就当二进制★
return True
try:
data[:8000].decode("utf-8")
return False
except UnicodeDecodeError:
return True
④ 图片的完整校验(★两次 open★)
from PIL import Image
def validate_image(data: bytes, max_px=50_000_000):
Image.MAX_IMAGE_PIXELS = max_px # ★防解码炸弹★
with Image.open(io.BytesIO(data)) as im:
im.verify() # ★verify 后对象不可再用★
with Image.open(io.BytesIO(data)) as im: # ★必须重新打开★
im.load() # 真正解码,发现截断/畸形
return im.format, im.size
⑤ 安全的下载响应(Web 框架)
headers = {
"Content-Type": confirmed_mime, # ★服务端确认的,不是客户端给的★
"Content-Disposition": f'attachment; filename="{safe_name}"',
"X-Content-Type-Options": "nosniff", # ★禁止浏览器嗅探★
}
⑥ 批量识别目录里的文件
for p in Path("uploads").iterdir():
with p.open("rb") as f:
kind = sniff(f.read(64)) # ★只读 64 字节,很快★
if kind is None or kind not in ALLOWED:
print("可疑文件:", p)
选型总结:
给下载设 Content-Type → mimetypes(够用)
快速判断常见格式(零依赖) → ★自写魔数表 或 filetype★
需要识别几百种格式/编码 → python-magic(libmagic)
安全校验用户上传 → ★魔数 + 解析器 + 白名单 + 重命名 + 隔离存储★
区分 docx/xlsx/jar → 打开 zip 看内部文件名
判断文本/二进制 → 查 NUL 字节 + 尝试 UTF-8 解码
实战代码里有几个细节值得注意。魔数表要支持「偏移量」(WebP 在偏移 8、MP4 在偏移 4),只匹配开头会漏掉这些格式。区分 zip 家族要打开 zip 看内部有哪些文件名——这是 libmagic 内部也在做的事。判断「文本还是二进制」用 git 的方法即可:前 8000 字节里有 NUL 字节就当二进制,否则尝试 UTF-8 解码。图片校验要 open 两次:verify() 之后对象就不可再用了,要真正解码检查截断必须重新打开再 load(),同时用 Image.MAX_IMAGE_PIXELS 防解码炸弹。下载响应的三个头(服务端确认的 Content-Type、Content-Disposition: attachment、X-Content-Type-Options: nosniff)要一起设,缺一个都可能让浏览器把文件当 HTML 渲染。
六、跨平台与工程实践
Windows 的差异:
- 扩展名的地位更重要(决定用什么程序打开、是否可执行)
- ★可执行扩展名列表很长★:.exe .com .bat .cmd .scr .pif .vbs .js .jse .wsf
.msi .lnk .hta .reg .ps1 ... → 黑名单几乎不可能穷举 → ★必须用白名单★
- 保留文件名:CON PRN AUX NUL COM1-9 LPT1-9(★"con.txt" 也不能创建★)
- 文件名不能含 < > : " / \ | ? *,不能以空格或点结尾
✓ 又一个"必须重命名存储"的理由
macOS / Linux:
- 扩展名只是约定;可执行由权限位(x)决定
- macOS 还有"资源分支"和 UTI(统一类型标识符)
- Linux 的 file 命令就是 libmagic
保存用户文件时的文件名清洗(★如果一定要保留原名★):
import re, unicodedata
def safe_filename(name: str, maxlen=100) -> str:
name = unicodedata.normalize("NFKD", name) # ★Unicode 规范化★
name = os.path.basename(name) # ★去掉路径部分★
name = re.sub(r"[^\w.\-]", "_", name) # 只留安全字符
name = re.sub(r"\.{2,}", ".", name).strip(". ") # 去掉 .. 和首尾点/空格
if not name or name.upper().split(".")[0] in WINDOWS_RESERVED:
name = "file"
return name[:maxlen]
★ 但更好的做法仍然是:★存储用 uuid,原文件名只存在数据库里用于展示★
版本与迁移注意:
★ imghdr / sndhdr 在 Python 3.13 被移除(PEP 594 "死电池"清理)★
→ 老代码 imghdr.what(path) 要改成 filetype.guess(path) 或 Pillow
★ mimetypes 的映射表随系统变化 → CI 和生产环境结果可能不同
→ 关键路径不要依赖它;必须用时显式 mimetypes.add_type 固定映射
日志与可观测:
记录"检测到的类型 vs 客户端声称的类型",不一致时告警
→ 能发现攻击尝试,也能发现前端 bug
logger.info("upload kind=%s claimed=%s name=%r", kind, content_type, orig_name)
★ 一句话总结实践:
★类型判断按内容、存储按白名单、命名用 uuid、位置要隔离、图片重编码★
工程实践上还有几个跨平台细节。Windows 的可执行扩展名列表极长(.exe .com .bat .cmd .scr .pif .vbs .js .hta .ps1…),黑名单几乎不可能穷举,所以必须用白名单;它还有 CON、PRN、NUL、COM1 这样的保留文件名(连 con.txt 都不能创建)和一堆禁用字符——这又是一个「必须重命名存储」的理由。如果业务上一定要保留原文件名,清洗时要做到:Unicode 规范化 → 取 basename 去掉路径 → 只保留安全字符 → 去掉连续的点和首尾空格/点 → 检查 Windows 保留名 → 限长;但更好的做法始终是「存储用 uuid,原文件名只存在数据库里用于展示」。版本方面记得 imghdr/sndhdr 在 3.13 已被移除,以及 mimetypes 的结果随系统变化(CI 和生产可能不一致,关键路径要用 add_type 显式固定)。最后一个很实用的可观测做法:记录「检测到的类型」和「客户端声称的类型」,不一致时告警——既能发现攻击尝试,也能发现前端 bug。
记忆钩子:「判断文件类型有四条路,★可靠性和性能恰好相反★:扩展名(最不可靠,改个名就变)< mimetypes(★仍然只查扩展名映射表、一个字节内容都不读★,而且结果依赖系统的 /etc/mime.types 所以跨平台不一致)< 魔数(真正读内容)< libmagic < 用真正的解析器解一遍(最终确认但最慢)。常见魔数要能背几个:★PNG=\x89PNG\r\n\x1a\n、JPEG=FF D8 FF、PDF=%PDF-、ZIP=PK\x03\x04、gzip=1F 8B、ELF=\x7fELF、exe=MZ★;注意有些魔数★不在偏移 0★(WebP 在偏移 8、MP4 的 ftyp 在偏移 4、tar 的 ustar 在 257),而 ★docx/xlsx/jar/apk 的魔数全是 PK 因为它们本质都是 zip★——要区分得打开 zip 看里面有没有 word/document.xml 之类。纯文本没有魔数,判断二进制用 git 的方法:★前 8000 字节里有 NUL 就当二进制★。★上传安全是重点:HTTP 的 Content-Type、multipart 的 filename、扩展名这三样全由客户端控制,一个都不能信★;而且★魔数正确也不够★——『图片马』可以前面是合法 PNG、后面追加 PHP 代码,照样通过魔数校验。正确做法是四件套:★按内容判断 → 白名单(不是黑名单,Windows 可执行扩展名根本列不完)→ 用 uuid 重命名存储(切断文件名攻击链)→ 存到不可执行的位置(对象存储最佳)★,图片再加一步『Pillow 重新编码保存』(顺带干掉附加载荷和 EXIF)并设 Image.MAX_IMAGE_PIXELS 防解码炸弹;SVG 是 XML 能带
七、常见误区与追问
- 误区:
mimetypes.guess_type()会分析文件内容来判断类型。 它一个字节的文件内容都不读——内部就是一张「扩展名 → MIME 类型」的映射表(还会加载系统的/etc/mime.types,Windows 上甚至读注册表)。所以mimetypes.guess_type("木马.exe.png")会老老实实返回('image/png', None),用它做安全校验等于没做。它还有个跨平台隐患:结果依赖系统的映射表,同一段代码在 Ubuntu、macOS、Windows 上可能给出不同答案,甚至用户装了某个软件都会改变结果(CI 通过而生产出错的经典来源)。它的正确用途只有一个:给「已经通过其他手段确认过类型」的文件设置 HTTPContent-Type响应头;关键路径上要用mimetypes.add_type()显式固定映射。 - 误区:校验了魔数,上传的文件就安全了。 魔数只能证明「文件开头这几个字节符合某格式」,不能证明整个文件是干净的。最典型的绕过是 polyglot 文件(俗称「图片马」):前面是一段真实、完整、能被图片查看器正常打开的 PNG 数据,后面直接追加
<?php system($_GET['c']); ?>——魔数校验、甚至 Pillow 的verify()都可能通过,但只要这个文件被放进 Web 目录且服务器配置会把它当脚本解析,就是完整的 RCE 链。魔数只是第一道筛,真正挡住攻击的是后面三件事:白名单、用 uuid 重命名(切断扩展名这条链)、存到不可执行的位置(对象存储或配置为不解析脚本的独立域名)。图片再加一步「用 Pillow 重新编码保存」最彻底——重编码会丢弃所有附加数据(同时也去掉了 EXIF 里的 GPS 等隐私信息)。 - 误区:禁止上传
.exe、.php、.jsp这些危险扩展名就够了。 黑名单在这个问题上是必输的。Windows 的可执行扩展名列表长得吓人(.exe .com .bat .cmd .scr .pif .vbs .js .jse .wsf .wsh .msi .lnk .hta .reg .ps1 .cpl…还在增加),Web 服务器的脚本解析配置也可能包含.phtml、.php5、.phar这类你没想到的后缀;再加上双扩展名(shell.php.jpg)、大小写变体(.PhP)、尾随空格或点(shell.php.,Windows 会自动去掉)、以及历史上的%00截断漏洞——穷举是不可能的。正确做法是白名单:明确列出业务需要的那几种类型(png/jpg/pdf),其余一律拒绝;并且扩展名由服务端根据检测出的类型决定,而不是沿用客户端给的。 - 误区:
Path(p).suffix能拿到完整的扩展名。 它只返回最后一段:Path("a.tar.gz").suffix是'.gz',要拿全部得用.suffixes(['.tar', '.gz'])。这在处理.tar.gz、.tar.bz2这类复合扩展名时会出错(比如你想根据扩展名选解压方式,只看.gz就丢掉了tar这一层)。另外还有几个边界:隐藏文件Path(".gitignore").suffix是''(前导点不算扩展名分隔符)、Path("a.")的 suffix 是''、没有点的文件返回''。而且要记住——扩展名本来就不该作为类型判断的依据,这些细节只在「生成文件名」「展示给用户」时才需要关心。 - 误区:SVG 是图片格式,可以和 PNG、JPEG 一样安全地展示用户上传的内容。 SVG 本质是 XML,可以包含
<script>、<foreignObject>、事件属性(onload=)和外部引用——当浏览器以image/svg+xml直接渲染它时,里面的 JavaScript 会在你的域名下执行,构成存储型 XSS(能读取同源的 Cookie、发起带凭证的请求)。所以处理用户上传的 SVG 有三个选项:① 干脆禁止(大多数业务不需要);② 严格清洗(用 DOMPurify 之类的库去掉脚本、事件属性和外部引用,注意自己写正则过滤几乎必然被绕过);③ 强制以Content-Disposition: attachment下载而不是内联渲染,并配合X-Content-Type-Options: nosniff和独立的存储域名。同类风险还有 HTML、PDF(可含 JS)和带宏的 Office 文档。 - 追问:为什么 PNG 的魔数要设计成 8 个字节那么长? 每个字节都有用途,是格式设计的经典范例。
\x89:高位为 1 的字节——如果文件经过只保留 7 位的传输通道(老式邮件网关),这个字节会被破坏,从而暴露传输错误;PNG(\x50\x4E\x47):可读的 ASCII,方便人在 hexdump 里辨认;\r\n:用来检测 Windows↔Unix 换行符转换——如果文件被用文本模式传输(FTP 的 ASCII 模式),\r\n会被改写,魔数就对不上;\x1a:DOS 下的文件结束符(Ctrl-Z),用type命令查看时会停止输出,避免满屏乱码;最后的\n:再次检测换行符转换(这次是检测\n被扩展成\r\n)。所以 PNG 的魔数不只是「标识这是 PNG」,还是一套传输完整性自检机制。这也提醒我们:魔数匹配失败时,除了「类型不对」还可能是「文件在传输中被损坏了」。 - 追问:怎么区分 docx、xlsx、jar、apk?它们的魔数是一样的。 因为它们本质都是 ZIP 归档(里面装着 XML 和资源文件),所以前四个字节都是
PK\x03\x04——单看魔数只能得出「这是个 zip」。区分方法是打开这个 zip、看里面有哪些成员文件:word/document.xml→ docx;xl/workbook.xml→ xlsx;ppt/presentation.xml→ pptx;META-INF/MANIFEST.MF→ jar;AndroidManifest.xml+classes.dex→ apk;mimetype成员(内容是application/epub+zip)→ epub。更规范的做法是读 zip 里名为mimetype的第一个成员(OpenDocument 和 EPUB 规范要求它必须是第一个且不压缩),或者读[Content_Types].xml(OOXML)。这正是 libmagic 比「自己写魔数表」强的地方——它内置了大量这种「先判容器、再看内部特征」的启发式规则。安全上还要注意:既然它们都是 zip,解压时同样要防路径穿越和解压炸弹。 - 追问:
imghdr被移除了,老代码该怎么迁移?imghdr(以及sndhdr、cgi等一批模块)在 Python 3.13 被移除,属于 PEP 594「死电池」清理——原因是它们支持的格式过时(imghdr认不出 WebP、AVIF 这些现代格式)、维护成本高、且有更好的第三方替代。迁移方案有三个:①filetype(pip install filetype)——纯 Python、零系统依赖,filetype.guess(path)返回带.mime和.extension的对象,覆盖常见图片、视频、音频、归档格式,最适合容器化部署;②python-magic(绑定 libmagic)——识别能力最强(几百种格式,还能判断文本编码),但需要系统装libmagic(Windows 上要用python-magic-bin);③Pillow——如果本来就在处理图片,直接Image.open(...)拿.format属性更直接,还顺便验证了文件确实可解码。如果只需要判断少数几种格式,自己写一个魔数表函数(读开头 64 字节比对)完全够用且零依赖,还能顺便加上偏移量匹配(WebP、MP4)。
八、加强记忆
判断文件类型有四条路径,可靠性和性能恰好相反:扩展名(最不可靠,改个名就变)< mimetypes(仍然只查「扩展名 → MIME」映射表、一个字节内容都不读,且结果依赖系统的 /etc/mime.types 所以跨平台不一致)< 魔数(真正读内容)< libmagic < 用真正的解析器解一遍(最终确认但最慢)——实践是「先用魔数快速筛,通过后再用解析器确认」。常见魔数要能记住几个:PNG = \x89PNG\r\n\x1a\n、JPEG = FF D8 FF、PDF = %PDF-、ZIP = PK\x03\x04、gzip = 1F 8B、ELF = \x7fELF、exe = MZ;注意有些魔数不在偏移 0(WebP 在偏移 8、MP4 的 ftyp 在偏移 4、tar 的 ustar 在 257),而 docx/xlsx/pptx/jar/apk/epub 的魔数全是 PK——因为它们本质都是 zip,要区分必须打开看内部有没有 word/document.xml、META-INF/MANIFEST.MF 这类特征文件。纯文本没有魔数,判断二进制用 git 的方法:前 8000 字节里出现 NUL 就当二进制。上传安全是本题重点:HTTP 的 Content-Type、multipart 的 filename、扩展名这三样全由客户端控制,一个都不能信;而且魔数正确也不够——「图片马」可以前面是合法 PNG、后面追加 PHP 代码,照样通过校验。正确做法是四件套:按内容判断 → 白名单(绝不用黑名单,Windows 的可执行扩展名根本列不完)→ 用 uuid 重命名存储(切断文件名这条攻击链)→ 存到不可执行的位置(对象存储最佳);图片再加一步「Pillow 重新编码保存」(顺带干掉附加载荷和 EXIF 隐私),并设 Image.MAX_IMAGE_PIXELS 防解码炸弹;SVG 是 XML、能带 <script>,要么禁止要么严格清洗。下载时三个响应头一起设:服务端确认的 Content-Type + Content-Disposition: attachment + X-Content-Type-Options: nosniff。工具选型:mimetypes 只用来设响应头、filetype 是零依赖的纯 Python 魔数库、python-magic 最准但依赖系统库、imghdr/sndhdr 已在 Python 3.13 被移除(PEP 594)。