1. 项目概述:当“完整性”不再完整
在信息安全领域,哈希函数(Hash Function)长期扮演着“数字指纹”或“完整性校验器”的角色。MD5(Message-Digest Algorithm 5)作为其中曾经的一员大将,其设计初衷正是为了将任意长度的数据映射为一个固定长度(128位)的唯一摘要值。理论上,哪怕原始数据只改动一个比特,其MD5值也会发生“雪崩效应”,变得面目全非。因此,它被广泛用于验证文件完整性、数字签名以及——我们今天要重点讨论的——消息认证码(MAC)的构造。
然而,正是这个看似固若金汤的“完整性”堡垒,存在一个经典且危险的裂缝:长度扩展攻击(Length Extension Attack)。这个漏洞与MD5算法本身的结构性缺陷直接相关,它允许攻击者在不知道原始密钥(Secret Key)的情况下,仅凭已知的原始消息(Message)及其对应的MD5哈希值,就能构造出一条新的、有效的“消息+填充+附加数据”,并计算出其合法的MD5值。这意味着,一个依赖“密钥+消息”计算MD5来验证数据完整性的系统,其防线可能被轻易绕过。
我之所以对这个话题有如此深的感触,是因为在早年的安全审计工作中,曾亲眼见过一个基于“MD5(密钥+交易参数)”进行API签名的系统被此漏洞攻破。攻击者并未破解密钥,却成功伪造了高额交易请求。从那时起,我就意识到,理解并复现这种攻击,对于任何从事开发或安全相关工作的人来说,都不是纸上谈兵,而是构建真正安全意识的必修课。本次,我们将借助一个名为HashPumpy的强大工具,亲手揭开MD5长度扩展攻击的神秘面纱,让你不仅明白其原理,更能亲手操作,深刻理解其危害。
2. 核心原理深度拆解:MD5算法的“内部时钟”
要理解长度扩展攻击,我们必须先钻进MD5算法的“肚子”里看看它是如何工作的。MD5属于Merkle–Damgård结构(简称MD结构)的哈希函数,其核心过程可以概括为“压缩”二字。
2.1 MD5的压缩函数与内部状态
MD5处理消息时,首先会对消息进行填充,使其长度满足(长度 % 512) = 448比特,然后附加一个64位的原始消息长度信息。填充后的消息被分割成若干个512比特(64字节)的块。
对于每一个消息块,MD5算法会将其与一个128比特的“内部状态”(Internal State)一起,送入一个复杂的压缩函数(Compression Function)中进行多轮混淆和压缩运算。这个128位的内部状态,在算法开始时被初始化为一个固定的值(IV,Initialization Vector)。处理完第一个消息块后,输出的128位结果作为新的内部状态,与下一个消息块一起送入压缩函数,如此迭代,直到处理完所有消息块。最终的那个128位内部状态,就是整个消息的MD5哈希值。
这里的关键在于:这个128位的内部状态,是算法处理过程的“记忆单元”和“上下文”。它完整地编码了到当前为止所有已处理消息块的累积效应。
2.2 长度扩展攻击的致命逻辑
现在,考虑一个典型的MAC使用场景:MAC = MD5(Secret_Key + Message)。服务器和客户端共享密钥Secret_Key,客户端发送消息Message和对应的MAC值给服务器,服务器用同样的方式计算并比对,以验证消息的完整性和真实性。
攻击者截获了Message和MAC。在长度扩展攻击中,攻击者扮演了一个“时间旅行者”的角色:
- 逆向初始状态:攻击者知道最终的
MAC值。由于MD5的压缩函数在理论上是不可逆的,但MD5算法的最后一个输出(即MAC)恰恰就是处理完Secret_Key + Message + Padding整个序列后的内部状态。攻击者可以将这个MAC值,作为继续计算新哈希的初始状态(IV‘)。 - 构造扩展消息:攻击者想要附加任意数据
Append。他需要知道原始消息Secret_Key + Message被填充后的总长度(虽然他不知道密钥,但可以假设或猜测密钥长度)。利用这个长度信息,他可以正确地构造出针对Secret_Key + Message + Padding + Append这个新序列的填充格式。 - 计算新哈希:现在,攻击者以
MAC(即旧的内部状态)作为新的IV,以Append(加上针对新总长度的正确填充)作为新的消息块,输入MD5压缩函数进行计算。得到的结果,正是MD5(Secret_Key + Message + Padding + Append)的哈希值。
这个结果的正确性,源于MD5算法的迭代结构。服务器在验证时,计算的是MD5(Secret_Key + Message + Padding + Append)。由于攻击者构造的扩展消息格式完全正确,并且使用了正确的“中间状态”作为起点,所以双方的计算过程从Append部分开始是完全同步的,最终结果必然一致。
注意:这里最精妙也是最危险的地方在于,攻击者完全不需要知道密钥
Secret_Key的内容。他只需要知道密钥的长度(或能合理猜测),以及原始的(Message, MAC)对,就可以完成攻击。密钥长度往往可以通过旁路攻击(如时间差)或简单枚举(如常见的16、32字节)来确定。
2.3 为什么是MD5?其他算法呢?
MD5因其广泛的历史应用和已被证实的多重密码学缺陷(如碰撞攻击),使得长度扩展攻击对其尤为有效。同样基于MD结构的哈希函数,如SHA-1、SHA-256等,在未进行特殊处理的情况下,同样普遍存在长度扩展攻击的风险。
因此,现代安全实践在构造MAC时,绝对不直接使用H(key + message)这种模式(其中H是MD结构的哈希函数)。取而代之的是HMAC(Hash-based Message Authentication Code)方案。HMAC通过两次哈希和密钥的特殊处理,有效地防御了长度扩展攻击。其公式大致为HMAC = H((key ⊕ opad) || H((key ⊕ ipad) || message)),这种结构将密钥与消息的交互方式复杂化,使得攻击者无法简单地利用最终哈希值作为扩展计算的起点。
3. 工具与环境准备:HashPumpy详解
工欲善其事,必先利其器。手动实现MD5的长度扩展攻击涉及繁琐的位操作、填充计算和状态初始化,极易出错。因此,我们使用一个专门为此而生的工具——HashPumpy。
3.1 HashPumpy是什么?
HashPumpy是一个用Python编写的开源工具,其名称正是“Hash Length Extension Attack”的趣味缩写。它的核心功能就是自动化地执行我们上面描述的攻击过程:给定原始数据的哈希值、原始数据长度、要附加的数据以及密钥长度(或原始数据总长度),它能够计算出扩展后数据的正确哈希值,并生成完整的攻击载荷(即原始数据+填充+附加数据)。
它的优势在于将复杂的密码学操作封装成简单的函数调用,让我们可以专注于攻击逻辑的理解和利用场景的构建,而非算法实现的细节。
3.2 环境搭建与安装
HashPumpy的安装非常简单。由于它是一个Python 2/3兼容的库,我们通常直接通过pip安装其移植版本hashpumpy(注意包名是hashpump的移植)。
# 使用pip安装 pip install hashpumpy安装完成后,可以在Python交互环境中测试是否安装成功:
import hashpumpy help(hashpumpy.hashpump) # 查看主要函数帮助主要函数hashpump的签名通常如下:hashpump(original_hash, original_data, data_to_add, key_length) -> (new_hash, new_data)
original_hash: 原始消息的十六进制哈希字符串(即我们截获的MAC)。original_data: 原始消息(不含密钥)的字符串。data_to_add: 想要附加的数据字符串。key_length: 猜测的密钥长度(字节)。- 返回值:一个元组,包含扩展后新消息的十六进制哈希字符串,以及完整的攻击载荷(二进制形式)。
3.3 可选辅助工具
为了更完整地复现攻击链,我们可能还需要:
- 一个简单的Web服务器或脚本:用于模拟使用
MD5(key+data)进行验证的受害服务。可以用Python的Flask框架快速搭建。 - 网络请求工具:如
curl或Python的requests库,用于发送构造好的攻击请求。 - 十六进制编辑器或
xxd命令:用于查看和分析包含不可见填充字符的攻击载荷。
4. 实战复现:构建一个完整的攻击案例
理论说得再多,不如亲手一试。让我们构建一个完整的场景,从搭建一个脆弱的服务开始,到利用HashPumpy完成攻击。
4.1 场景设定:一个脆弱的API签名
假设我们有一个简单的API服务,它提供一个/admin/getflag接口来获取敏感信息(flag)。为了确保请求来自合法客户端,它使用了一种“经典”的签名方案:
- 密钥
secret = "sup3rs3cr3tk3y"(服务器和合法客户端共享,攻击者未知)。 - 客户端构造请求,例如
message = "user=guest&cmd=view"。 - 客户端计算签名
sig = md5(secret + message),并将message和sig一起发送给服务器。 - 服务器收到后,用同样的方式计算
md5(secret + message),并与收到的sig比对。一致则执行请求。
我们的目标:作为攻击者,我们不知道secret,但我们可以截获一个合法请求(例如message="user=guest&cmd=view"和其对应的sig)。我们想构造一个能通过验证的恶意请求,将其中的message篡改为"user=admin&cmd=getflag"。
4.2 步骤一:搭建脆弱服务(靶机)
我们用一个简单的Python Flask应用来模拟这个服务:
# vulnerable_server.py from flask import Flask, request, jsonify import hashlib app = Flask(__name__) SECRET_KEY = b"sup3rs3cr3tk3y" # 假设的密钥 def compute_mac(data): """脆弱的MAC计算方式:MD5(secret || data)""" h = hashlib.md5() h.update(SECRET_KEY + data.encode('utf-8')) return h.hexdigest() @app.route('/api/action', methods=['POST']) def api_action(): data = request.form.get('data', '') sig = request.form.get('sig', '') if not data or not sig: return jsonify({"error": "Missing data or sig"}), 400 # 服务器端验证 calculated_sig = compute_mac(data) if calculated_sig == sig: # 验证通过,解析并执行命令(这里简化处理) if "cmd=getflag" in data and "user=admin" in data: return jsonify({"status": "success", "flag": "FLAG{This_Is_Your_Hacked_Flag}"}) else: return jsonify({"status": "success", "message": "Command executed (but no flag for you)"}) else: return jsonify({"error": "Invalid signature"}), 403 if __name__ == '__main__': app.run(debug=True, port=5000)运行这个脚本,一个脆弱的API服务就在本地的5000端口启动了。
4.3 步骤二:截获与观察合法请求
假设我们通过流量监听,截获了一个合法客户端发出的请求:
- 原始数据
data:user=guest&cmd=view - 签名
sig:b5b37e2b6d6cbcc0c6e3f7d8f103a3c5(这是用上述密钥计算出的MD5值,此处为示例,实际运行会得到不同结果)
我们不知道密钥,但我们可以观察或猜测API的格式。假设我们通过信息收集,推测密钥长度可能是16字节(sup3rs3cr3tk3y正好是13字节,但我们可以尝试常见长度如8, 12, 16, 24, 32)。
4.4 步骤三:使用HashPumpy构造攻击
现在,我们扮演攻击者,使用HashPumpy来构造一个能通过签名验证的恶意请求。
# attack.py import hashpump import requests import hashlib # 已知信息 original_hash = 'b5b37e2b6d6cbcc0c6e3f7d8f103a3c5' # 截获的签名 original_data = 'user=guest&cmd=view' # 截获的原始消息 data_to_add = '&user=admin&cmd=getflag' # 想要附加的恶意指令 # 注意:附加的数据需要以符合原数据格式的方式连接。因为原数据末尾没有分隔符,我们添加'&'来扩展参数。 # 猜测密钥长度。我们需要尝试。已知密钥是13字节,但攻击者不知道,需要枚举。 # 我们先尝试一个可能的长度,比如 16。 key_length_guess = 16 # 使用hashpump进行攻击计算 new_hash, new_data = hashpump.hashpump(original_hash, original_data, data_to_add, key_length_guess) print("[*] 原始签名:", original_hash) print("[*] 原始数据:", repr(original_data)) print("[*] 猜测的密钥长度:", key_length_guess) print("[*] 要附加的数据:", repr(data_to_add)) print("\n[+] 攻击生成结果:") print(" 新的签名 (sig):", new_hash) print(" 新的数据 (data, 十六进制显示):", new_data.hex()) print(" 新的数据 (data, 尝试解码):", repr(new_data)) # 为了发送HTTP请求,我们需要将new_data进行URL安全的编码 # 因为new_data包含了MD5填充比特(不可打印字符),不能直接作为表单字符串。 # 通常我们会将其进行十六进制编码或Base64编码后发送。 import base64 new_data_b64 = base64.b64encode(new_data).decode('ascii') print(" 新的数据 (Base64编码,用于传输):", new_data_b64) # 构造并发送攻击请求 url = 'http://127.0.0.1:5000/api/action' payload = { 'data': new_data_b64, # 服务器端需要相应解码 'sig': new_hash } # 注意:实际攻击中,服务器可能直接接收二进制数据或另一种编码方式,需要根据目标调整。 # 这里我们假设服务器端修改了接口,能接受base64编码的data。 print("\n[*] 发送攻击载荷...") response = requests.post(url, data=payload) print("[+] 服务器响应:", response.text)关键点解析:
hashpump函数返回的new_data是二进制数据,它包含了original_data + MD5填充字节 + data_to_add。这些填充字节是根据len(secret) + len(original_data)计算出来的,包含比特1、若干0和长度信息。- 这些填充字节是不可打印字符,无法直接放在URL或表单的
data字段中。因此在实际攻击中,我们需要根据目标服务器的数据解析方式,对new_data进行适当的编码(如Base64、十六进制),并确保服务器端能以同样的方式解码。 - 密钥长度
key_length_guess是关键。如果猜错,构造的填充就不正确,攻击会失败。在实际攻击中,这可能需要进行枚举。
4.5 步骤四:服务端适配与攻击验证
为了让上面的攻击脚本能直接工作,我们需要稍微修改一下服务器端代码,使其能处理Base64编码的data字段。
# 修改 vulnerable_server.py 中的 /api/action 端点部分 import base64 @app.route('/api/action', methods=['POST']) def api_action(): data_b64 = request.form.get('data', '') sig = request.form.get('sig', '') if not data_b64 or not sig: return jsonify({"error": "Missing data or sig"}), 400 try: # 解码Base64得到原始数据(包含填充和附加数据) data = base64.b64decode(data_b64) # 注意:compute_mac函数接收的是字节串,我们需要将整个解码后的data传给它 # 但compute_mac内部是 SECRET_KEY + data,这里data已经是包含原始消息和填充的完整载荷了。 # 实际上,服务器应该计算 MD5(SECRET_KEY || 原始消息 || 填充 || 附加数据) # 而我们的data变量现在就是(原始消息 || 填充 || 附加数据)。 # 所以直接调用 compute_mac(data) 在逻辑上不对,因为compute_mac会再做一次 SECRET_KEY + data。 # 这里暴露出我们模拟的一个简化点:真实攻击中,服务器是拿收到的整个“数据部分”去验证的。 # 我们需要调整服务器验证逻辑,使其直接计算 MD5(SECRET_KEY || 收到的完整数据)。 # 为了简化演示,我们假设服务器端知道客户端发送的是“原始消息”,而攻击者发送的是“原始消息+填充+附加数据”。 # 更真实的模拟是,服务器端验证时,会重新计算整个序列的哈希。 # 让我们修正compute_mac_for_attack函数,使其能处理这种场景。 calculated_sig = compute_mac(data.decode('latin-1')) # 简单解码,可能不严谨,仅演示 except Exception as e: return jsonify({"error": f"Data decode failed: {e}"}), 400 if calculated_sig == sig: # 验证通过后,服务器需要从data中解析出真正的“命令”部分。 # 由于我们附加了`&user=admin&cmd=getflag`,服务器解析整个参数字符串时,后面的参数会覆盖前面的。 # 这里我们简单判断字符串内容。 if b"cmd=getflag" in data and b"user=admin" in data: return jsonify({"status": "success", "flag": "FLAG{This_Is_Your_Hacked_Flag}"}) else: return jsonify({"status": "success", "message": "Command executed"}) else: return jsonify({"error": "Invalid signature"}), 403这个修改后的服务器会解码Base64的data,然后计算MD5(secret + 解码后的数据),并与签名比较。这正是长度扩展攻击成功的前提:服务器验证的是整个拼接后的字符串的哈希。
运行修改后的服务器和攻击脚本,如果密钥长度猜测正确,你应该会看到攻击成功,服务器返回了包含flag的成功响应。
5. 攻击的深层细节与难点剖析
复现成功只是第一步。在实际渗透测试或安全研究中,会遇到更多复杂情况。
5.1 密钥长度枚举
攻击成功的关键之一是正确猜测密钥长度。HashPumpy需要这个参数来正确计算原始消息的填充。如果猜错,构造的填充块就不对,最终的哈希值必然无法通过验证。
枚举策略:
- 常见长度优先:尝试常见的密钥长度,如8、12、16、24、32字节。
- 错误信息分析:观察服务器对不同长度猜测的响应。有时签名验证失败和格式解析错误会返回不同的HTTP状态码或错误信息,这可以提供线索。
- 时间侧信道:在某些实现中,验证过程可能因长度不同而有细微的时间差异,但这需要精密的测量。
- 已知格式推断:如果密钥是某种特定格式(如UUID是36字符,但作为字节是16或22等),可以缩小范围。
在脚本中,我们可以简单地写一个循环进行枚举:
for key_len in range(8, 33): # 尝试8到32字节 try: new_hash, new_data = hashpump.hashpump(original_hash, original_data, data_to_add, key_len) # 然后用new_hash和new_data去尝试请求 # 如果收到成功响应,就找到了正确的key_len print(f"尝试密钥长度 {key_len}: 生成的签名 {new_hash[:8]}...") # ... 发送请求测试 ... except Exception as e: # hashpump可能对某些长度参数抛出异常 continue5.2 数据格式与编码难题
这是实际攻击中最棘手的部分之一。我们的攻击载荷new_data是二进制数据,包含了不可见的填充字符。
服务器如何解析
data?- 直接作为字符串处理:如果服务器期望
data是纯文本参数(如application/x-www-form-urlencoded),那么包含\x80、\x00等填充字节的二进制数据会破坏解析,导致攻击失败。服务器可能在验证签名前就因解析错误而拒绝请求。 - 作为二进制流或特定编码处理:如果服务器将
data字段视为二进制数据(例如,先进行Base64解码,或直接读取原始请求体),那么攻击才有可能成功。我们的模拟案例采用了Base64编码来绕过这个问题。
- 直接作为字符串处理:如果服务器期望
参数污染(Parameter Pollution): 在我们的例子中,附加的数据是
&user=admin&cmd=getflag。当服务器解析整个参数字符串时,后面的user=admin会覆盖前面的user=guest。这种技巧在Web攻击中很常见。但需要了解目标服务器的参数解析顺序(通常是后到优先)。填充破坏原有语义: MD5填充会在原始消息后添加一个
0x80字节,然后是若干个0x00,最后是长度编码。如果原始消息是像JSON或XML这样的结构化数据,这些额外的字节会破坏其语法,导致解析失败。攻击可能需要对附加数据进行精心设计,或者利用某些解析器的容错性。
5.3 哈希值编码与比较
服务器端如何比较哈希值也可能引入问题:
- 大小写敏感:MD5哈希值通常是32位十六进制小写字符串。确保攻击生成的
new_hash格式与服务器期望的一致。 - 二进制比较:有些实现可能直接比较二进制哈希值(16字节),而非十六进制字符串。需要根据情况调整。
6. 防御之道:如何避免长度扩展攻击
理解了攻击,防御措施就非常明确了。核心原则是:不要使用H(key || message)或H(message || key)这种简单的拼接方式来构造MAC。
使用HMAC:这是最标准、最安全的解决方案。几乎所有现代编程语言的标准库都提供了HMAC的实现。例如在Python中:
import hmac import hashlib key = b"sup3rs3cr3tk3y" message = b"user=admin&cmd=getflag" sig = hmac.new(key, message, hashlib.sha256).hexdigest() # 使用SHA-256HMAC的结构天然免疫长度扩展攻击。
使用抗长度扩展的哈希函数:一些较新的哈希函数,如SHA-3(Keccak)家族,其内部结构(海绵结构)与MD结构不同,不存在长度扩展攻击问题。可以直接使用
SHA3-256(key || message),但为了保持最佳实践和一致性,仍然推荐使用HMAC-SHA3。对消息进行变形:如果因历史原因必须使用MD5或SHA-1等,可以采用一些变形来破坏攻击,但这只是权宜之计,并非根本解决方案。例如:
H(key || H(key || message)):双哈希嵌套。H(key || message || key):将密钥包裹消息。- 确保消息具有固定的、已知的格式或长度,使得攻击者无法正确构造填充。但这些方法都可能存在其他潜在弱点,且增加了复杂性。
密钥与消息分离验证:一种思路是将签名与消息长度绑定。例如,在签名中包含原始消息的长度
sig = H(key || length(message) || message)。这样,攻击者扩展消息后,长度信息不匹配,验证会失败。但这需要修改验证逻辑。
最重要的建议:对于任何新的系统,无条件地使用HMAC。对于遗留系统,应将其迁移到HMAC。安全审计时,将MD5(key||msg)或SHA1(key||msg)的使用标记为高危漏洞。
7. 工具扩展与相关漏洞挖掘
HashPumpy主要针对MD5和SHA-1。但长度扩展攻击的概念适用于所有基于Merkle–Damgård结构的哈希函数。社区也有其他工具支持更多算法:
- Hash Extender:另一个功能类似的工具,支持更多哈希算法(如MD4, MD5, SHA-1, SHA-256, SHA-512等)。
- 手动实现:对于研究者,理解原理后可以手动编写攻击脚本,这有助于深入理解哈希函数每一轮的处理细节。
在实际漏洞挖掘中,长度扩展攻击常出现在:
- Web应用API签名:如我们演示的例子。
- 某些自定义的会话Cookie或令牌生成机制。
- 文件完整性校验(如果校验方式是
H(secret || file_content))。 - 区块链某些智能合约的验证逻辑(虽然以太坊的SHA3不受此影响,但自定义的验证模式可能出错)。
在代码审计时,可以搜索模式如md5(.*?\+.*?)、hashlib\.md5\(.*?\+.*?\)(注意实际连接符可能是字符串拼接或格式化),并仔细审查其验证逻辑。
8. 总结与个人体会
通过这次从原理到实战的完整复现,我们可以清晰地看到,一个看似简单的密码学原语(MD5)在不正确的使用模式下,会带来多么严重的后果。长度扩展攻击的精妙之处在于,它完全绕过了对密钥的破解,利用的是算法结构本身的特性。
我在多次内部培训和代码审计中反复强调这一点:开发者对于密码学API的使用,必须遵循权威指南和最佳实践,切忌自己发明“聪明”的签名方案。md5(secret + data)这种模式在互联网上随处可见的示例代码中流传,其危险性却未被充分认知。
使用HashPumpy这样的工具进行复现,价值不仅仅在于“学会了一种攻击”。更深层的价值在于,它能以一种非常直观和令人印象深刻的方式,让开发者和安全人员建立起对密码学抽象概念的具象理解。当你亲手用几行代码就让一个看似安全的验证机制沦陷时,你才会真正敬畏那些看似枯燥的安全规范。
最后一个小技巧:在测试自己系统的MAC实现时,除了使用HashPumpy进行正向攻击测试外,还可以尝试构造一些畸形的、包含填充字符的输入,观察系统的处理是否健壮,是否会在验证哈希之前就因解析错误而崩溃或暴露信息,这有时能发现其他意想不到的漏洞。安全是一个整体,任何一个环节的疏忽都可能成为突破口。