目录

HashModifier: 一个文件hash修改工具

一、简介

HashModifier 是一个文件hash修改工具,通过批量修改图片/视频文件的元数据(EXIF、IPTC、PNG 文本块等)来改变文件的 MD5 哈希值,同时保持文件的视觉内容完全不变。

二、原因

日常测试工作中,有一些上传接口进行了内容重复性校验,不让上传曾经传过的资源,我不是画师,手里不可能随时凭空变出新的图片。曾经的做法是,改一个像素点,但还是有点麻烦。除了测试,运营同学工作中也会遇到这样的问题,可能错传了一个资源,与其他资源进行了绑定,那么想要重新上传的时候,就会被这个机制卡住,我们往往也是让运营同学自己去改一个像素来解决。虽然只改了一个像素,但毕竟还是对内容作了改动,不是很优雅,而且重新导出一次文件有点慢。

因此,HashModifier 的思路是:只改元数据不改像素/帧

三、整体架构

flowchart TB Input["输入文件夹"] --> Copy["复制到输出文件夹<br/>shutil.copy2 保持完整目录结构"] Copy --> Judge{"文件类型判断<br/>根据扩展名"} Judge --> JPEG["JPEG/JPG"] Judge --> PNG["PNG"] Judge --> TIFF["TIFF"] Judge --> WebP["WebP"] Judge --> MP4["MP4/MP3"] Judge --> Other["其他格式"] JPEG --> M1["修改 EXIF/IPTC 元数据<br/>Description / Software / Artist"] PNG --> M2["修改 PNG 文本块<br/>Software / ModificationTime"] TIFF --> M3["转换为 JPEG<br/>保留 Exif 信息"] WebP --> M4["修改 WebP EXIF<br/>保持动图帧信息"] MP4 --> M5["写入随机 UUID<br/>到 ©ART 字段"] Other --> M6["追加时间戳注释"] M1 --> Calc["重新计算 MD5"] M2 --> Calc M3 --> Calc M4 --> Calc M5 --> Calc M6 --> Calc Calc --> Result["输出 new_hash != original_hash"]

四、核心功能实现

1.修改 EXIF/IPTC

为什么选 EXIF 作为修改目标

需要保持图片的像素数据不变,那么可以改动文件头部的元数据。EXIF/IPTC 是图片元数据的标准存放位置,且工具 piexif 可以精确控制写入的字段,不会触发 JPEG 重编码。

JPEG 元数据修改

def modify_jpeg_iptc(file_path):
    """修改JPEG/JPG图片的IPTC元数据"""
    # 加载或创建 EXIF 数据
    exif_dict = {}
    try:
        exif_dict = piexif.load(file_path)
    except:
        exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None}

    # 写入唯一时间戳,保证每次 hash 不同
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")

    exif_dict["0th"][piexif.ImageIFD.ImageDescription] = (
        f"Modified: {timestamp}".encode("utf-8")
    )
    exif_dict["0th"][piexif.ImageIFD.Software] = b"HashModifier"
    exif_dict["0th"][piexif.ImageIFD.DateTime] = (
        datetime.now().strftime("%Y:%m:%d %H:%M:%S").encode("utf-8")
    )

    exif_bytes = piexif.dump(exif_dict)
    img = Image.open(file_path)
    img.save(file_path, "JPEG", exif=exif_bytes, quality="keep")

    return True

为什么修改的是 0th IFD 而不是 Exif IFD

0th IFD(Image File Directory)包含 ImageDescription、Software、Artist、DateTime 等字段。修改这些字段不仅会改变文件字节流,而且对文件的解码渲染无影响。而 Exif IFD 中的拍摄参数可能被下游工具解析使用,所以不动。

PNG 元数据修改

def modify_png_metadata(file_path):
    """修改PNG图片的元数据"""
    from PIL import PngImagePlugin

    img = Image.open(file_path)
    metadata = PngImagePlugin.PngInfo()

    # 保留现有的文本块
    for key, value in img.info.items():
        if isinstance(key, str) and isinstance(value, str):
            metadata.add_text(key, value)

    # 添加新的 tEXt 块
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
    metadata.add_text("Software", "HashModifier")
    metadata.add_text("ModificationTime", timestamp)

    # save() 时 PNG 编码器会将这些文本块写入文件
    img.save(file_path, "PNG", pnginfo=metadata)
    return True

PNG 文件结构由 8 字节签名 + 若干 chunk 组成,其中 tEXt chunk 存放文本元数据。通过 PngImagePlugin 添加 tEXt 块会在文件末尾追加数据,不会触及其他 chunk 的重新编码,因此像素数据(IDAT chunk)可以保持不变。

2.动图 WebP 的处理

WebP 的特殊性

WebP 可以是动态图,包含多帧。Pillow 的 ImageSequence 可以遍历读取所有帧,但保存时需要处理好帧时长列表。

def modify_webp_metadata(file_path):
    img = Image.open(file_path)

    # 获取所有帧和时长
    frames = []
    durations = []
    for frame in ImageSequence.Iterator(img):
        frames.append(frame.copy())
        durations.append(frame.info.get('duration', 100))

    # 读取已有 EXIF 或创建新的
    exif_dict = {}
    if "exif" in img.info:
        exif_dict = piexif.load(img.info["exif"])
    else:
        exif_dict = {"0th": {}, ...}

    # 写入时间戳
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
    exif_dict["0th"][piexif.ImageIFD.ImageDescription] = (
        f"WebP_Modified_{timestamp}".encode("utf-8")
    )
    exif_bytes = piexif.dump(exif_dict)

    # 保存时保留所有帧和时长
    frames[0].save(
        file_path,
        format="WEBP",
        save_all=True,
        append_images=frames[1:],
        exif=exif_bytes,
        duration=durations,   # 传入帧时长列表
        lossless=False,
        quality=95,
        method=6
    )

    return True

duration 必须传入列表([100, 100, 100, ...])而非单个值,否则 Pillow 会为所有帧使用同一个时长。method=6 是 WebP 最慢但压缩率最高的编码模式,可以最大程度保留原始质量。

3.MP4 和 MP3

MP4 使用 mutagen 库修改 ©ART 字段,MP3 使用 EasyID3 修改 artist 标签。

def modify_mp4_metadata(file_path):
    text = str(uuid.uuid4())  # 每次不同
    file = MP4(file_path)
    file['©ART'] = text       # 修改作者标签
    file.save()
    return True

mutagen 是直接操作文件二进制结构的库,不会对视频/音频流做重编码。写入一个随机 UUID 到 ©ART 元数据字段,即可改变文件 hash。

4.复制式处理 + 网络校验

复制到新文件夹

def process_folder(input_folder):
    shutil.copy2(file_path, output_file_path)  # 复制到输出
    modify_image_metadata(str(output_file_path))  # 修改副本

直接修改原文件有风险(脚本出 Bug 可能损坏文件)。同时复制一份也方便对比修改前后的 hash 差别。

网络校验作为授权手段

def is_internal_network():
    test_url = "http://xxx.com/"
    try:
        response = requests.get(test_url, timeout=5)
        return response.status_code == 200
    except:
        return False

工具启动时会检查是否能访问内网地址,非内网环境直接退出。这是因为我们希望本工具是用于开发测试工作与运营救急使用,不希望工具被发送给外部商家进行使用。

5.完整处理流程

def process_folder(input_folder):
    # 1. 创建输出文件夹(输入文件夹名 + _hash_modified)
    output_folder_name = f"{input_folder.name}_hash_modified"

    # 2. 遍历所有子目录,保持层级结构
    for root, dirs, files in os.walk(input_folder):
        rel_path = Path(root).relative_to(input_folder)
        output_subfolder = output_folder / rel_path
        output_subfolder.mkdir(parents=True, exist_ok=True)

        for file in files:
            file_path = Path(root) / file
            if file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
                # 复制 → 修改 → 验证 hash 已变化
                original_hash = calculate_file_hash(file_path)
                output_file = output_subfolder / file
                shutil.copy2(file_path, output_file)
                modify_image_metadata(output_file)
                new_hash = calculate_file_hash(output_file)

                if original_hash != new_hash:
                    print(f"✓ {file}")

支持的文件格式

格式 修改方式 依赖库
JPEG/JPG EXIF/IPTC 元数据 piexif, Pillow
PNG tEXt 文本块 Pillow PngImagePlugin
TIFF/TIF 转 JPEG + EXIF Pillow, piexif
WebP 内嵌 EXIF piexif, Pillow
BMP/GIF 追加时间戳注释 二进制追加
MP4 ©ART 元数据 mutagen
MP3 artist 标签 mutagen EasyID3

限制

  • 处理 TIFF 时会转成 JPEG,原 TIFF 文件会被替换掉,multi-page TIFF 会丢失额外页面
  • 修改后的 WebP 编码质量设为 95(quality=95),不是无损(lossless=False),对画质有极端要求的场景可能有细微差异

总结

HashModifier 的核心思路是:文件 hash 差异不必来自内容差异,元数据已经足够。通过操纵 EXIF/IPTC/PNG tEXt 等元数据层,可以在毫秒级别改变一个文件的 MD5。