Daxia Blog
Uncategorized | WebUI | CyberSecurity | Rust | Javascript | KB | FHIR | EA

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字节。那么他就可以做以下事情:

  1. 计算填充:根据密钥长度+M1长度(32+19=51字节),计算出M1后面原本应该添加的填充字节(包括0x80、若干0x00和最后8字节的长度值)。
  2. 构造新消息M_mal = M1 || 填充 || 恶意数据,比如&something=true&role=admin
  3. 恢复内部状态:将S1作为SHA-256处理完密钥 || M1 || 填充后的内部状态,加载到一个新的SHA-256实例中。
  4. 继续哈希:向这个实例输入恶意数据,得到新签名S2

这个S2恰好等于SHA-256(密钥 || M_mal)!也就是说,攻击者可以伪造一个包含恶意数据的新令牌,而服务器验证时会认为签名有效。

代码验证:眼见为实

代码附在最后。

作者定义了一个函数generate_padding来生成填充,然后用load_sha256将合法签名加载为内部状态,最后用forge_signature生成伪造签名。验证结果显示,伪造的签名确实能通过服务器的验证。

现实是骨感的:填充导致的数据解析问题

读到这里,我忍不住想:既然攻击这么简单,为什么现实世界中很少听到利用长度扩展攻破Web应用的案例呢?文章其实埋了一个伏笔——填充字节会破坏数据格式。

在伪造的消息M_mal中,M1和恶意数据之间夹着一堆不可打印的二进制字节(0x800x00……)。当服务器收到令牌后,通常会先解析数据部分,遇到这些二进制字节,解析器大概率会报错,或者直接截断字符串,导致恶意数据被忽略。这样一来,攻击就无法生效。

然而,这并不意味着攻击毫无价值。在某些场景下,数据格式可能非常宽容,比如:

  • 系统直接比对整个字节串,不进行结构化解析(如某些二进制协议)。
  • 解析器恰好能容忍这些字节(可能性极低)。
  • 攻击者能控制数据格式,使填充落在允许的范围内。

所以,长度扩展攻击更像是一把特制钥匙,只有在特定锁芯上才能转动。但是千万记住:

1、不能依赖“数据格式”作为安全防线,真正的防御要从算法层面入手。 2、即使算法本身坚固,如果在协议或应用中错误地使用,也会引入严重漏洞。

这个漏洞并非SHA-256本身的缺陷,而是其**Merkle-Damgård**结构的一个特性。

这篇文章的真正价值在于教育意义:

  1. 警示开发者:永远不要使用 H(密钥 || 消息) 这种方式来构建签名或校验数据的完整性。
  2. 解释底层原理:通过代码和内存布局,清晰展示了哈希函数的内部工作机制。
  3. 推动最佳实践:引导开发者使用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 重复块大小次
  • ||:连接操作
  • :异或操作

这个结构有两个核心优势:

  1. 免疫长度扩展攻击:因为内部哈希值H((K ⊕ ipad) || m)是未知的(需要密钥),攻击者无法从最终的HMAC值反推出中间状态,也就无法进行扩展。
  2. 密钥与消息混合更充分:通过异或操作,密钥被以不同方式融入内外两层哈希,避免了简单拼接可能带来的碰撞风险。

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
我是一名独立开发者,国家工信部认证高级系统架构设计师,在健康信息化领域与许多组织合作。具备大型卫生信息化平台产品架构、设计和开发的能力,从事软件研发、服务咨询、解决方案、行业标准编著相关工作。
我对健康信息化非常感兴趣,尤其是与HL7和FHIR标准的健康互操作性。我是HL7中国委员会成员,从事FHIR培训讲师和FHIR测评现场指导。
我还是FHIR Chi的作者,这是一款用于FHIR测评的工具。