SHA-256 Lenght Attack
我最近读到一篇有趣的文章,讲的是SHA-256算法的长度扩展攻击(Length Extension Attack)。文章作者用Rust代码一步步演示了如何在不掌握密钥的情况下,伪造出合法的签名。读完之后,我不仅对SHA-256的内部机制有了更深的理解,也对“为什么不能用普通哈希函数替代消息认证码”这个原则有了切身体会。今天就跟大家分享一下我的阅读心得和思考。
一个看似完美的“自建令牌”方案
文章开篇假设了一个场景:你是一个Web开发者,想设计一个无状态的用户认证机制。你有一个只有服务器知道的密钥,对于每个用户,你生成一个包含用户信息(比如user_id=1&role=user)的数据,然后计算签名S = SHA-256(密钥 || 数据),最后把数据|S作为令牌发给客户端。客户端每次请求时带上这个令牌,服务器重新计算签名并比对,就能确认令牌的合法性——整个过程无需查询数据库,高效又简单。
这个方案看起来天衣无缝,因为SHA-256是单向的,理论上无法从签名反推出原始消息,更不可能伪造签名。但作者紧接着抛出一个炸弹:SHA-256其实有一个重大缺陷——它容易受到长度扩展攻击。 一旦攻击者拿到一个合法签名,即使不知道密钥,也能构造出新的有效签名。
简单来说,攻击的核心是:
如果你知道一个合法签名
S = SHA-256(密钥 || 数据),即使不知道密钥的具体内容,只要知道密钥的长度,你就可以计算出S' = SHA-256(密钥 || 数据 || 填充 || 恶意数据)的有效签名。
攻击的数学基础:SHA-256的填充与内部状态
为了理解攻击,文章先科普了SHA-256的内部工作方式。SHA-256处理消息时,会把消息分成512位(64字节)的块,逐块处理。如果消息长度不是64字节的倍数,就需要填充。填充规则是:先加一个0x80字节,然后加若干0x00,最后用8个字节表示原始消息的总比特数(大端序)。这样,填充后的消息长度正好是64字节的整数倍。
文章给出了一个例子:消息 密钥 || "user_id=1&role=user"(共51字节)经过填充后,最后一个块看起来是这样的:
user_id=1&role=user + 0x80 + 39个0x00 + 原始总比特数408 (0x0000000000000198)
这个完整的填充块(64字节)是SHA-256处理并生成最终签名的依据。攻击的关键就在于,这个填充后的块包含了我们可以预测和利用的信息。
攻击步骤详解
现在假设攻击者拿到了一个合法令牌:
- 数据
M1=user_id=1&role=user - 签名
S1=5b0b4b2472778fea87faac08a72a47d24538bff9d7f19a3a85d069893e2b08ab
攻击者还知道(或能猜到)密钥长度为32字节。那么他就可以做以下事情:
- 计算填充:根据密钥长度+
M1长度(32+19=51字节),计算出M1后面原本应该添加的填充字节(包括0x80、若干0x00和最后8字节的长度值)。 - 构造新消息:
M_mal = M1 || 填充 || 恶意数据,比如&something=true&role=admin。 - 恢复内部状态:将
S1作为SHA-256处理完密钥 || M1 || 填充后的内部状态,加载到一个新的SHA-256实例中。 - 继续哈希:向这个实例输入恶意数据,得到新签名
S2。
这个S2恰好等于SHA-256(密钥 || M_mal)!也就是说,攻击者可以伪造一个包含恶意数据的新令牌,而服务器验证时会认为签名有效。
代码验证:眼见为实
代码附在最后。
作者定义了一个函数generate_padding来生成填充,然后用load_sha256将合法签名加载为内部状态,最后用forge_signature生成伪造签名。验证结果显示,伪造的签名确实能通过服务器的验证。
现实是骨感的:填充导致的数据解析问题
读到这里,我忍不住想:既然攻击这么简单,为什么现实世界中很少听到利用长度扩展攻破Web应用的案例呢?文章其实埋了一个伏笔——填充字节会破坏数据格式。
在伪造的消息M_mal中,M1和恶意数据之间夹着一堆不可打印的二进制字节(0x80、0x00……)。当服务器收到令牌后,通常会先解析数据部分,遇到这些二进制字节,解析器大概率会报错,或者直接截断字符串,导致恶意数据被忽略。这样一来,攻击就无法生效。
然而,这并不意味着攻击毫无价值。在某些场景下,数据格式可能非常宽容,比如:
- 系统直接比对整个字节串,不进行结构化解析(如某些二进制协议)。
- 解析器恰好能容忍这些字节(可能性极低)。
- 攻击者能控制数据格式,使填充落在允许的范围内。
所以,长度扩展攻击更像是一把特制钥匙,只有在特定锁芯上才能转动。但是千万记住:
1、不能依赖“数据格式”作为安全防线,真正的防御要从算法层面入手。 2、即使算法本身坚固,如果在协议或应用中错误地使用,也会引入严重漏洞。
这个漏洞并非SHA-256本身的缺陷,而是其**Merkle-Damgård**结构的一个特性。
这篇文章的真正价值在于教育意义:
- 警示开发者:永远不要使用
H(密钥 || 消息)这种方式来构建签名或校验数据的完整性。 - 解释底层原理:通过代码和内存布局,清晰展示了哈希函数的内部工作机制。
- 推动最佳实践:引导开发者使用HMAC或SHA-3等免疫此类攻击的密码学原语。
如果你正在评估自己系统的风险,可以检查是否使用了 H(密钥 || 消息) 的签名方式。如果是,建议立即迁移到 HMAC-SHA256。
从错误中学习:为什么需要HMAC
文章最后没有详细展开防御措施,但读者自然会产生疑问:如果H(key || data)不安全,那应该用什么来替代?答案是HMAC。
HMAC(Hash-based Message Authentication Code)是专门设计用来做消息认证的,它的公式是:
HMAC(K, m) = H((K ⊕ opad) || H((K ⊕ ipad) || m))
其中:
H:底层哈希函数(如 SHA-256、MD5 等)K:原始密钥K':经过处理的密钥(若 K 短于哈希函数块大小,则补零至块长;若长于块大小,则先对 K 进行哈希得到较短的密钥)m:消息ipad:内填充,为字节0x36重复块大小次opad:外填充,为字节0x5c重复块大小次||:连接操作⊕:异或操作
这个结构有两个核心优势:
- 免疫长度扩展攻击:因为内部哈希值
H((K ⊕ ipad) || m)是未知的(需要密钥),攻击者无法从最终的HMAC值反推出中间状态,也就无法进行扩展。 - 密钥与消息混合更充分:通过异或操作,密钥被以不同方式融入内外两层哈希,避免了简单拼接可能带来的碰撞风险。
HMAC已经成为行业标准,广泛应用于API签名、JWT、TLS等场景。相比自制的H(密钥 || 数据)方案,HMAC提供了更可靠的安全保障。
结语
读完这篇文章,我最大的收获不是学会了如何进行长度扩展攻击,而是明白了**“不要自己发明密码学协议”**这个道理。SHA-256本身是坚固的,但错误的使用方式会让它变得脆弱。长度扩展攻击就是一面镜子,照出了简单拼接背后的隐患。
下次再遇到那种需要通过密钥来对身份进行鉴别时,一定要毫不犹豫地选择**HMAC**。毕竟,站在巨人的肩膀上,才能看得更远,也才能更安全。
代码附录
[package]
name = "sha256_length_extension_attacks"
version = "0.1.0"
edition = "2024"
[dependencies]
sha2 = "0.10"
hex = "0.4"
main.rs
use hex;
use sha2::{Sha256, Digest as Sha2Digest};
mod sha256;
use sha256::{Digest, generate_padding};
fn main() {
// 256-bit secret key
// in real life it should be generated using a cryptographically-secure random generator
let secret_key = b"secretsecretsecretsecretsecretse";
let legitimate_data = b"user_id=1&role=user";
let legitimate_signature = sign(secret_key, legitimate_data);
println!("SecretKey: {}", hex::encode(secret_key));
println!("Legitimate Data: {}", String::from_utf8_lossy(legitimate_data));
println!("Legitimate Signature SHA256(SecretKey || LegitimateData): {}", hex::encode(&legitimate_signature));
println!("Verify LegitimateSignature == SHA256(SecretKey || LegitimateData): {}", verify_signature(secret_key, &legitimate_signature, legitimate_data));
println!("\n---------------------------------------------------------------------------------------------------\n");
let malicious_data = b"&something=true&role=admin";
let malicious_message = generate_malicious_message(secret_key.len() as u64, legitimate_data, malicious_data);
let malicious_signature = forge_signature(&legitimate_signature, malicious_data, (secret_key.len() + legitimate_data.len()) as u64);
println!("Malicious Data: {}", String::from_utf8_lossy(malicious_data));
println!("Malicious Message: (LegitimateData || padding || MaliciousData):");
print_hex_dump(&malicious_message);
println!("Malicious Signature: {}", hex::encode(&malicious_signature));
println!("Verify MaliciousSignature == SHA256(SecretKey, MaliciousMessage): {}", verify_signature(secret_key, &malicious_signature, &malicious_message));
}
fn forge_signature(legitimate_signature: &[u8], malicious_data: &[u8], secret_key_and_data_length: u64) -> Vec<u8> {
let mut digest = Digest::new();
digest.load_sha256(legitimate_signature, secret_key_and_data_length).expect("Failed to load SHA256 state");
digest.write(malicious_data);
digest.checksum().to_vec()
}
fn generate_malicious_message(secret_key_length: u64, legitimate_data: &[u8], malicious_data: &[u8]) -> Vec<u8> {
let padding = generate_padding(secret_key_length + legitimate_data.len() as u64);
let mut message = Vec::with_capacity(legitimate_data.len() + padding.len() + malicious_data.len());
message.extend_from_slice(legitimate_data);
message.extend_from_slice(&padding);
message.extend_from_slice(malicious_data);
message
}
fn sign(secret_key: &[u8], data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(secret_key);
hasher.update(data);
hasher.finalize().to_vec()
}
fn verify_signature(secret_key: &[u8], signature_to_verify: &[u8], data: &[u8]) -> bool {
let expected_signature = sign(secret_key, data);
expected_signature.eq(signature_to_verify)
}
fn print_hex_dump(data: &[u8]) {
for (i, chunk) in data.chunks(16).enumerate() {
print!("{:08x} ", i * 16);
// Print hex values
for &byte in chunk {
print!("{:02x} ", byte);
}
// Calculate padding for hex section
let hex_padding = 16 - chunk.len();
for _ in 0..hex_padding {
print!(" "); // 3 spaces per missing byte
}
// Print ASCII representation
print!(" ");
for &byte in chunk {
if byte >= 32 && byte <= 126 {
print!("{}", byte as char);
} else {
print!(".");
}
}
println!();
}
}
sha256.rs
use std::convert::TryInto;
pub const SIZE: usize = 32; // 256 bits
pub const BLOCK_SIZE: usize = 64; // 512 bits
pub const CHUNK: usize = BLOCK_SIZE;
const INIT0: u32 = 0x6A09E667;
const INIT1: u32 = 0xBB67AE85;
const INIT2: u32 = 0x3C6EF372;
const INIT3: u32 = 0xA54FF53A;
const INIT4: u32 = 0x510E527F;
const INIT5: u32 = 0x9B05688C;
const INIT6: u32 = 0x1F83D9AB;
const INIT7: u32 = 0x5BE0CD19;
#[derive(Clone, Debug)]
pub struct Digest {
hash_state: [u32; 8],
buffer: [u8; CHUNK],
buffer_index: usize,
total_length: u64,
}
impl Digest {
pub fn new() -> Self {
let mut digest = Self {
hash_state: [0; 8],
buffer: [0; CHUNK],
buffer_index: 0,
total_length: 0,
};
digest.reset();
digest
}
pub fn reset(&mut self) {
self.hash_state = [INIT0, INIT1, INIT2, INIT3, INIT4, INIT5, INIT6, INIT7];
self.buffer_index = 0;
self.total_length = 0;
}
pub fn write(&mut self, input: &[u8]) -> usize {
let input_length = input.len();
self.total_length += input_length as u64;
let mut remaining_input = input;
// If there is data in the buffer, fill it first
if self.buffer_index > 0 {
let bytes_to_copy = std::cmp::min(remaining_input.len(), CHUNK - self.buffer_index);
self.buffer[self.buffer_index..self.buffer_index + bytes_to_copy]
.copy_from_slice(&remaining_input[..bytes_to_copy]);
self.buffer_index += bytes_to_copy;
remaining_input = &remaining_input[bytes_to_copy..];
if self.buffer_index == CHUNK {
let buffer_copy = self.buffer;
block(self, &buffer_copy);
self.buffer_index = 0;
}
}
// Process full chunks
while remaining_input.len() >= CHUNK {
let chunk_size = remaining_input.len() & !(CHUNK - 1);
block(self, &remaining_input[..chunk_size]);
remaining_input = &remaining_input[chunk_size..];
}
// Store remaining data
if !remaining_input.is_empty() {
self.buffer_index = remaining_input.len();
self.buffer[..self.buffer_index].copy_from_slice(remaining_input);
}
input_length
}
pub fn checksum(&mut self) -> [u8; SIZE] {
let total_length = self.total_length;
// Padding. Add a 1 bit and 0 bits until 56 bytes mod 64.
let mut tmp = [0u8; 64 + 8]; // padding + length buffer
tmp[0] = 0x80;
let padding_length = if total_length % 64 < 56 {
56 - (total_length % 64)
} else {
64 + 56 - (total_length % 64)
};
// Length in bits.
let bit_length = total_length << 3;
let padded_length_buffer = &mut tmp[..(padding_length + 8) as usize];
padded_length_buffer[padding_length as usize..(padding_length + 8) as usize]
.copy_from_slice(&bit_length.to_be_bytes());
self.write(padded_length_buffer);
if self.buffer_index != 0 {
panic!("d.nx != 0");
}
let mut digest = [0u8; SIZE];
for i in 0..8 {
let hash_bytes = self.hash_state[i].to_be_bytes();
let start_index = i * 4;
digest[start_index..start_index + 4].copy_from_slice(&hash_bytes);
}
digest
}
pub fn load_sha256(&mut self, hash_bytes: &[u8], secret_key_and_data_length: u64) -> Result<(), String> {
if hash_bytes.len() != SIZE {
return Err("load_sha256: not a valid SHA256 hash".to_string());
}
self.reset();
// Load the 8 hash state values (32 bits each) directly from the hash bytes
for i in 0..8 {
let word_start = i * 4;
let hash_bytes_slice = &hash_bytes[word_start..word_start +4];
self.hash_state[i] = u32::from_be_bytes(hash_bytes_slice.try_into().unwrap());
}
// After loading the hash state, we're at a block boundary
// The length should be the original data length plus padding
let padding = generate_padding(secret_key_and_data_length);
self.total_length = secret_key_and_data_length + padding.len() as u64;
self.buffer_index = 0; // We should be at a block boundary after padding
Ok(())
}
}
pub fn generate_padding(secret_key_and_data_length: u64) -> Vec<u8> {
let mut tmp = [0u8; 64 + 8]; // padding + length buffer
tmp[0] = 0x80;
let remainder = (secret_key_and_data_length % 64) as usize;
let padding_length = if remainder < 56 {
56 - remainder
} else {
64 + 56 - remainder
};
// Length in bits.
let bit_length = secret_key_and_data_length << 3;
let padded_length_buffer = &mut tmp[..padding_length + 8];
padded_length_buffer[padding_length..padding_length + 8].copy_from_slice(&bit_length.to_be_bytes());
padded_length_buffer.to_vec()
}
// SHA256 constants
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
];
fn block(digest: &mut Digest, input: &[u8]) {
let mut message_words = [0u32; 64];
let mut working_state = digest.hash_state;
let mut remaining_input = input;
while remaining_input.len() >= CHUNK {
// Prepare message words
for i in 0..16 {
let byte_index = i * 4;
message_words[i] = u32::from_be_bytes(remaining_input[byte_index..byte_index + 4].try_into().unwrap());
}
for i in 16..64 {
let sigma0 = message_words[i - 15].rotate_right(7)
^ message_words[i - 15].rotate_right(18)
^ (message_words[i - 15] >> 3);
let sigma1 = message_words[i - 2].rotate_right(17)
^ message_words[i - 2].rotate_right(19)
^ (message_words[i - 2] >> 10);
message_words[i] = message_words[i - 16]
.wrapping_add(sigma0)
.wrapping_add(message_words[i - 7])
.wrapping_add(sigma1);
}
let mut a = working_state[0];
let mut b = working_state[1];
let mut c = working_state[2];
let mut d = working_state[3];
let mut e = working_state[4];
let mut f = working_state[5];
let mut g = working_state[6];
let mut h = working_state[7];
for i in 0..64 {
let temp1 = h
.wrapping_add(e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25))
.wrapping_add((e & f) ^ (!e & g))
.wrapping_add(K[i])
.wrapping_add(message_words[i]);
let temp2 =
(a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22)).wrapping_add((a & b) ^ (a & c) ^ (b & c));
h = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
working_state[0] = working_state[0].wrapping_add(a);
working_state[1] = working_state[1].wrapping_add(b);
working_state[2] = working_state[2].wrapping_add(c);
working_state[3] = working_state[3].wrapping_add(d);
working_state[4] = working_state[4].wrapping_add(e);
working_state[5] = working_state[5].wrapping_add(f);
working_state[6] = working_state[6].wrapping_add(g);
working_state[7] = working_state[7].wrapping_add(h);
remaining_input = &remaining_input[CHUNK..];
}
digest.hash_state = working_state;
}
About Daxia