HashModifier: 一个文件hash修改工具
一、简介
HashModifier 是一个文件hash修改工具,通过批量修改图片/视频文件的元数据(EXIF、IPTC、PNG 文本块等)来改变文件的 MD5 哈希值,同时保持文件的视觉内容完全不变。
二、原因
日常测试工作中,有一些上传接口进行了内容重复性校验,不让上传曾经传过的资源,我不是画师,手里不可能随时凭空变出新的图片。曾经的做法是,改一个像素点,但还是有点麻烦。除了测试,运营同学工作中也会遇到这样的问题,可能错传了一个资源,与其他资源进行了绑定,那么想要重新上传的时候,就会被这个机制卡住,我们往往也是让运营同学自己去改一个像素来解决。虽然只改了一个像素,但毕竟还是对内容作了改动,不是很优雅,而且重新导出一次文件有点慢。
因此,HashModifier 的思路是:只改元数据不改像素/帧。
三、整体架构
四、核心功能实现
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 TruePNG 文件结构由 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 Trueduration 必须传入列表([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 Truemutagen 是直接操作文件二进制结构的库,不会对视频/音频流做重编码。写入一个随机 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。