← 中文资源

简体中文版本

文件魔数嗅探

根据文件头字节识别 PNG、JPEG、GIF、PDF、ZIP 与 gzip,并校验扩展名是否与实际类型一致。

阅读英文原文

功能说明

上传文件仅凭扩展名判断类型并不可靠,攻击者可以把脚本改名成图片上传。魔数是文件开头的固定字节序列,浏览器和操作系统都据此识别真实格式。这个嗅探器覆盖常见的图片、文档和压缩格式,返回枚举类型;扩展名检查则对比用户提供的后缀与嗅探结果,不一致时业务可以拒绝或重命名。

函数只读取开头若干字节,适合在上传流的第一块数据到达时立即判断,不必等待整个文件落盘。

关键代码

各格式的签名定义为常量切片,starts_with 逐个比较,顺序从最长签名到较短签名,避免前缀重叠导致误判。PNG 签名八字节包含回车换行和结束符,JPEG 只需前三字节。extension_matches 先把扩展名转小写,再用 matches! 对类型与后缀的组合做模式匹配,支持 jpg 与 jpeg、gz 与 tgz 等别名。

使用注意

魔数只能证明文件开头像某种格式,不能保证后续内容安全,压缩包和 PDF 仍可能包含恶意载荷。ZIP 与 docx、xlsx 共享同一签名,需进一步检查内部结构。扩展名缺失时返回空串,不会匹配任何类型。读取时应限制读取长度,避免大文件拖慢请求。真正的安全校验需要解析库、病毒扫描和内容策略共同完成。

验证方式

四组样本分别被识别为 PNG、PDF、JPEG 和 ZIP,扩展名检查全部通过;纯文本样本返回 Unknown。把图片内容改成 .pdf 后缀,extension_ok 会变为 false,说明仅凭后缀无法通过检查。可以再加入 GIF 两个版本的签名,确认都能识别。

代码示例

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum FileKind {
    Png,
    Jpeg,
    Gif,
    Pdf,
    Zip,
    Gzip,
    Unknown,
}

fn sniff(bytes: &[u8]) -> FileKind {
    const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
    const JPEG: &[u8] = &[0xff, 0xd8, 0xff];
    const GIF87: &[u8] = b"GIF87a";
    const GIF89: &[u8] = b"GIF89a";
    const PDF: &[u8] = b"%PDF-";
    const ZIP: &[u8] = &[0x50, 0x4b, 0x03, 0x04];
    const GZIP: &[u8] = &[0x1f, 0x8b];

    if bytes.starts_with(PNG) {
        return FileKind::Png;
    }
    if bytes.starts_with(JPEG) {
        return FileKind::Jpeg;
    }
    if bytes.starts_with(GIF87) || bytes.starts_with(GIF89) {
        return FileKind::Gif;
    }
    if bytes.starts_with(PDF) {
        return FileKind::Pdf;
    }
    if bytes.starts_with(ZIP) {
        return FileKind::Zip;
    }
    if bytes.starts_with(GZIP) {
        return FileKind::Gzip;
    }
    FileKind::Unknown
}

fn extension_matches(kind: FileKind, extension: &str) -> bool {
    let normalized = extension.to_ascii_lowercase();
    matches!(
        (kind, normalized.as_str()),
        (FileKind::Png, "png")
            | (FileKind::Jpeg, "jpg" | "jpeg")
            | (FileKind::Gif, "gif")
            | (FileKind::Pdf, "pdf")
            | (FileKind::Zip, "zip")
            | (FileKind::Gzip, "gz" | "tgz")
    )
}

fn main() {
    let samples: [(&[u8], &str); 4] = [
        (&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00][..], "logo.png"),
        (&b"%PDF-1.7"[..], "invoice.pdf"),
        (&[0xff, 0xd8, 0xff, 0xe0][..], "photo.jpeg"),
        (&[0x50, 0x4b, 0x03, 0x04, 0x14][..], "archive.zip"),
    ];
    for (bytes, name) in samples {
        let kind = sniff(bytes);
        let extension = name.rsplit('.').next().unwrap_or_default();
        println!(
            "{name}: {:?} extension_ok={}",
            kind,
            extension_matches(kind, extension)
        );
    }
    println!("unknown: {:?}", sniff(b"plain text"));
}