Hmac原理及cpp实现
一、背景
hmac为什么会出现?传统的哈希算法,MD结构那种都不能抗住长度扩展攻击:知道 H(M) 就能算出 H(M || padding || 后缀)。
如果直接做 MAC = H(secret || message),也就是普通的加盐,攻击者就能在不知道 secret 的情况下伪造 H(secret || message || padding || 伪造后缀)。这就是为什么不能用”裸哈希”当 MAC。
这个可以百度一下哈,挺有意思的攻击手段。
HMAC 的设计目标:
即使底层哈希(如 MD5)已经被攻破碰撞性,HMAC-MD5 仍然是一个安全的 MAC。
这是 HMAC 最漂亮的地方——它把哈希的抗碰撞性和抗长度扩展性解耦了。
二、公式
这个就是它的公式啦,我们先来看看各个符号的含义。
HMAC(K, m) = H( (K' ⊕ opad) || H( (K' ⊕ ipad) || m ) )| 符号 | 含义 |
|---|---|
H | 底层哈希函数(MD5 / SHA-1 / SHA-256) |
K | 原始密钥 |
K' | 处理后的密钥(长度 = 块大小 B) |
B | 哈希函数的块大小(字节) |
ipad | 0x36 重复 B 次 |
opad | 0x5C 重复 B 次 |
⊕ | 异或 |
| ` |
块大小 B:
| 哈希 | B(字节) |
|---|---|
| MD5 | 64 |
| SHA-1 | 64 |
| SHA-256 | 64 |
| SHA-512 | 128 |
回忆一下啥是块,就是每一段512位或者64字节的消息(最后那块要填充后)。
K’ 的构造规则
如果密钥比一个块的大小大,那么就对密钥进行一次哈希把长度压到哈希输出长度(MD5 是 16 字节),然后对其填充0到块大小。如果比密钥长度小,就在后面直接填充0直到达到块的长度,而不是哈希长度。
三、原理
用一句话概括:
内层:
inner = H((K' ⊕ ipad) || m)外层:H((K' ⊕ opad) || inner)
两层哈希,中间夹一个 16/20/32 字节的 inner 结果。
防住长度扩展攻击关键在于外层哈希:
- 攻击者拿到
HMAC(K, m),想算HMAC(K, m || 后缀),需要构造H((K'⊕opad) || inner'),其中inner'必须等于H((K'⊕ipad) || m || 后缀)。 - 但攻击者不知道
K'⊕ipad,所以算不出inner'。 - 就算用长度扩展从
inner推出某个inner',外层哈希的输入是(K'⊕opad) || inner',而K'⊕opad攻击者也不知道,所以外层这一步也过不去。
两层都夹着未知密钥派生的前缀,长度扩展被彻底堵死。
四、cpp实现
废话不多说,直接上代码。应用层入手。
// ============================================================// HMAC-MD5// ============================================================
// HMAC 常量static const size_t MD5_BLOCK_SIZE = 64; // B:MD5 块大小(字节)static const size_t MD5_DIGEST_SIZE = 16; // MD5 输出长度(字节)
//这里的md5就不说了,可以去看看我之前的博客。// 底层接口:H(data) -> out[16],复用已有 MD5static void md5_raw(const uint8_t* data, size_t len, uint8_t out[MD5_DIGEST_SIZE]) { MD5Context ctx; md5Update(ctx, data, len); md5Final(ctx, out);}
// HMAC-MD5 核心// key/keyLen : 密钥// data/dataLen : 消息// out : 输出 16 字节
static void hmacMd5(const uint8_t* key, size_t keyLen, const uint8_t* data, size_t dataLen, uint8_t out[MD5_DIGEST_SIZE]){ // ---------- 1. 构造 K'(长度恰好 = B = 64)---------- uint8_t k[MD5_BLOCK_SIZE]; std::memset(k, 0, MD5_BLOCK_SIZE); // 先全 0,天然实现"右侧补零"
if (keyLen > MD5_BLOCK_SIZE) { // 密钥太长:K' = H(K),结果只占前 16 字节,后面保持 0 md5_raw(key, keyLen, k); //结果拷贝给k } else { // 密钥够短:直接拷贝到前面,剩余字节已经是 0 std::memcpy(k, key, keyLen); }
// ---------- 2. 构造 ipad / opad ---------- uint8_t ipad[MD5_BLOCK_SIZE]; uint8_t opad[MD5_BLOCK_SIZE]; for (size_t i = 0; i < MD5_BLOCK_SIZE; i++) { ipad[i] = (uint8_t)(k[i] ^ 0x36); //这里跳步骤了,ipad填充原本是0x363636······ 64个36,但是这里直接把k'异或ipad的值也就是结果值赋值给ipad,不是很严谨但是代码可行且节省内存。 opad[i] = (uint8_t)(k[i] ^ 0x5C);//这里也是同样跳步了。 }
// ---------- 3. 内层:inner = H((K'⊕ipad) || m) ---------- uint8_t inner[MD5_DIGEST_SIZE]; { MD5Context ctx; md5Update(ctx, ipad, MD5_BLOCK_SIZE); //这里的ipad其实是 (K' ⊕ ipad) md5Update(ctx, data, dataLen); //同一个ctx上下文,作用就是拼接(K' ⊕ ipad) || m md5Final(ctx, inner); }
// ---------- 4. 外层:out = H((K'⊕opad) || inner) ---------- { MD5Context ctx; md5Update(ctx, opad, MD5_BLOCK_SIZE); //这里也是同理,就不说了。 md5Update(ctx, inner, MD5_DIGEST_SIZE); md5Final(ctx, out); }}
// HMAC-MD5 便捷接口:字符串进,16 字节出static void hmacMd5(const std::string& key, const std::string& msg, uint8_t out[MD5_DIGEST_SIZE]) { hmacMd5(reinterpret_cast<const uint8_t*>(key.data()), key.size(), reinterpret_cast<const uint8_t*>(msg.data()), msg.size(), out);}五、换成 SHA-1 / SHA-256 要改什么
只有三处:
| 改动点 | MD5 | SHA-1 | SHA-256 |
|---|---|---|---|
B(块大小) | 64 | 64 | 64 |
| 输出长度 | 16 | 20 | 32 |
| 哈希函数 | md5Update/md5Final | sha1Update/sha1Final | sha256Update/sha256Final |
ipad/opad | 0x36 / 0x5C | 0x36 / 0x5C | 0x36 / 0x5C |
ipad 和 opad 的字节值是固定的,不随哈希函数变化(RFC 2104 规定)。块大小对 MD5/SHA-1/SHA-256 都是 64,只有 SHA-512 是 128。
所以把上面代码里的 MD5_BLOCK_SIZE、MD5_DIGEST_SIZE、md5Update/md5Final 全部替换即可,逻辑一字不改。
六、总结
逆向时识别 HMAC 的经典特征:
- 连续出现
0x36和0x5C的常量(或0x36363636、0x5C5C5C5C这种 32 位扩展)。 - 两次调用同一个哈希函数,中间夹着一次 16/20/32 字节的输出。
- 看到
0x36363636基本就锁定 HMAC。
除了这个特征,还真没什么好定位的。
妈的,五点多了,要和军爷抢饭吃了,溜了溜了。
部分信息可能已经过时









