← 返回题目列表

怎么判断一个文件的真实类型?靠扩展名可靠吗?

中等 第 24 / 27 题 更新于 2026/07/31
文件类型魔数mimetypes上传安全

简化版

判断文件类型有三条路,可靠性依次递增:① 看扩展名(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)内容 + 启发式规则最高复杂格式识别
用真正的解析器打开完整解码✅✅ 最终确认图片/文档

常见魔数速查

格式起始字节备注
PNG89 50 4E 47 0D 0A 1A 0A\x89PNG\r\n\x1a\n
JPEGFF D8 FF结尾是 FF D9
GIFGIF87a / GIF89a
PDF%PDF-
ZIP / docx / xlsx / jar / apk50 4B 03 04PK..它们本质都是 zip
gzip1F 8B
7z37 7A BC AF 27 1C
RARRar!\x1a\x07
ELF(Linux 可执行)7F 45 4C 46\x7fELF
Windows PE(exe/dll)MZ
WebPRIFF....WEBP偏移 8 处才是 WEBP
MP4....ftyp偏移 4 处ftyp
SQLiteSQLite 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 响应头,不是做校验。要特别注意一个标准库变更:imghdrsndhdr 已在 Python 3.13 被移除(PEP 594),老代码里的 imghdr.what() 需要迁移到 filetypepython-magicPillow自己写魔数检测其实完全够用且零依赖——只需读开头 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-Typefilename、扩展名这三样全是伪造的。经典攻击有五类:双扩展名利用服务器解析配置;图片马(polyglot 文件)——一个文件同时是合法 PNG 和合法 PHP,前面是真图片数据(魔数校验完全通得过)、后面追加脚本代码;SVG 里的 <script> 造成存储型 XSS(SVG 本质是 XML);图片炸弹(6KB 的 PNG 解码后是 60000×60000 像素、吃掉十几 GB 内存);以及用 filename路径穿越正确处理是四件套且缺一不可按内容判断类型 → 白名单(不是黑名单)→ 重命名存储(uuid4().hex + 服务端决定的扩展名,彻底切断文件名这条攻击链)→ 存到不可执行的位置(对象存储最佳)。图片还应该用 Pillow 重新编码保存——这能同时干掉图片马的附加载荷和 EXIF 里的隐私信息。下载时也要设对响应头:服务端确认的 Content-TypeContent-Disposition: attachmentX-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-TypeContent-Disposition: attachmentX-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…),黑名单几乎不可能穷举,所以必须用白名单;它还有 CONPRNNULCOM1 这样的保留文件名(连 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 通过而生产出错的经典来源)。它的正确用途只有一个:给「已经通过其他手段确认过类型」的文件设置 HTTP Content-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(以及 sndhdrcgi 等一批模块)在 Python 3.13 被移除,属于 PEP 594「死电池」清理——原因是它们支持的格式过时(imghdr 认不出 WebP、AVIF 这些现代格式)、维护成本高、且有更好的第三方替代。迁移方案有三个:① filetypepip 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.xmlMETA-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)