散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。哈希表有以下特点:
- 访问速度很快
- 需要额外的空间(以空间换时间)
- 无序
- 可能会产生碰撞
因为访问速度很快,因此在程序中需要使用内存表并且能够快速访问时它就是首选。关于第4点可能产生碰撞,是指对于不同关键词得到同一个散列值。这种现象称为冲突(或碰撞)。发生碰撞时会导致操作(插入或查询)哈希表时间复杂度增加。当不发生碰撞时操作哈希表的时间复杂度是Οn,如果键频繁发生碰撞,这项任务的时间复杂度是On2
。因此如果被黑客利用,在掌握算法细节后总能算出一组频繁碰撞的键来。这样会给系统留下隐患:可能因为频繁的碰撞导致的时间消耗使得相关的服务处于挂起状态,这就是哈希洪水攻击(Hash-Flooding Attack)。攻击者模拟相同的哈希值导致相关服务停止运作,作为一种拒绝服务攻击(Denial of Service - DoS),一旦存在合适的攻击面,攻击者就能让整台服务器陷入瘫痪。
那么要从更根本上避免攻击的发生,我们能否找到更优秀的哈希算法,让那些键的哈希值完全不发生碰撞?很遗憾,答案是不行,从数学角度上讲这根本不可能。因为一个哈希表的有限长度(一般也就是几千或上万个元素)。根据生日悖论我们可以证明:不管你的算法设计得多么精妙,只要黑客掌握算法的所有细节,那就总能算出一组频繁碰撞的键来。如果黑客不能掌握算法的所有细节,是不是就不能算出一组频繁碰撞的键,也就没法发动哈希洪水攻击?换句话说,我们能不能在算法中加入一个黑客不知道的秘密参数?每建一张哈希表,我们就随机生成一个新的秘密参数。这样一来,即使是同样的内容,放在不同的表里也会产生完全不同的内存分配。这整个过程黑客完全无法预测,即使发生碰撞,也是小概率的巧合,而不是黑客在主动控制,攻击也就不可能成立了。这个黑客不知道的秘密参数,我们现在称之为哈希种子(Hash Seed)。而这类使用哈希种子的哈希算法,我们称之为带密钥哈希算法(Keyed Hash Function)。黑客一方的攻击目标,是想办法刺探出种子的值,或者在不知道种子的情况下构造出一组会碰撞的键来。而安全研究人员的目标,就是设计出更安全的带密钥哈希算法,保护好种子的安全,避免种子被黑客绕过。
SipHash 由 Jean-Philippe Aumasson 和 Daniel J. Bernstein 于 2012 年设计,用于防御哈希洪水 DoS 攻击。 SipHash 是针对短消息的哈希运算速度进行了优化,它非常快速且高效。哈希种子(Hash seed)是一个128位的数据(可以是两个64位的无符号数),计算结果是64位的哈希值。这里的短消息是指息长度不超过255个字节。为了说明方便,SipHash可表示为:
其中c表示在压缩过程中使用轮函数的次数,d表示在结束时使用轮函数的次数,一般c=2, d=4,k是128位的key,m表示消息, 输出64位。可以描述为: SipHash2-4-64
在Bitcoin中的P2P网络中,一个对等节点(Peer)中需要维护一张哈希表用来保存对等节点的信息,包括IP地址、端口,访问权限等,以方便节点之间进行连接和转发区块数据。因此对于Peer节点的连接信息使用255字节长度已足够。有人可能会问,为什么不使用HMAC(Hash-256,Hash-512)来计算Hash key呢?这是因为HMAC返回的键值达到或超过了32字节长度,对于小规模的内存表来说,完全没有必要,且Hash2的计算复杂度远超SipHash,不利于快速和高效地访问。
SipHash实现过程:
1. 初始化(Initialization)
初始化四个大小为64位内部状态变量
![](/qrcode.jpg)
符号⊕是亦或运算,k0是key的首8个字节,k1是key的后8个字节。其中四个常量是以下字符串按每8个字节分割并以16进制表示的结果,该字符串是:“somepseudorandomlygeneratedbytes”
上述的使用C++代码可表示为:
m_v[0] = k0 ^ UINT64_C(0x736f6d6570736575); // “somepseu”
m_v[1] = k1 ^ UINT64_C(0x646f72616e646f6d); // “dorandom”
m_v[2] = k0 ^ UINT64_C(0x6c7967656e657261); // “lygenera”
m_v[3] = k1 ^ UINT64_C(0x7465646279746573); // “tedbytes”
2. 压缩(Compression)
先将消息按照每个64位(8个字节)长度分割,该消息使用little-endian字节顺序。假设分割后的消息使用mi表示,压缩过程如下:
如果消息的字节长是8的倍数,最后需要将消息长度按照little-endian顺序转换为作为64位进行上述压缩。如果消息的字节长度不是8的倍数,需将消息最后的字节的最高位使用长度来补足。凑足64位后进行压缩。这里的消息长度是一个8位无符号数据,也就是说消息最长为255个字节。假设输入的消息是0xab,因为它只有一个字节,因此它的高位使用长度来补足,结果是0x01000000000000ab,使用该值作为m进行上述压缩运算。压缩过程示意图如下:
3. 结束(Finalization)
在经过上述所有压缩运算后,接下来进行如下的运算:
最后输出的64位的结果就是SipHash的结果。
4. 轮函数(SipRound)
用图表示如下:
翻译成二进制运算顺序就是:
其中表示左循环移位,轮函数SipRound使用C++实现如下:
#define ROTL(x, b) (uint64_t)(((x) << (b)) | ((x) >> (64 - (b))))
#define SIPROUND do { \
v0 += v1; v1 = ROTL(v1, 13); v1 ^= v0; \
v0 = ROTL(v0, 32); \
v2 += v3; v3 = ROTL(v3, 16); v3 ^= v2; \
v0 += v3; v3 = ROTL(v3, 21); v3 ^= v0; \
v2 += v1; v1 = ROTL(v1, 17); v1 ^= v2; \
v2 = ROTL(v2, 32); \
} while (0)
5. 使用C++实现完整的SipHash
下面使用了C++并参考了Bitcoin中的CSipHasher类实现完整的SipHash功能并做了一些扩展。
// siphash.h
#ifndef DRAGONBTC_SIPHASH_H_
#define DRAGONBTC_SIPHASH_H_
#include <cstdint>
#include <stddef.h>
#include <array>
#include <span>
#include <string>
#include <vector>
// convert uint8_t array to little-endian uint64_t
#define U8TO64_LE(p) \
( ((uint64_t)((p)[0])) | \
((uint64_t)((p)[1]) << 8) | \
((uint64_t)((p)[2]) << 16) | \
((uint64_t)((p)[3]) << 24) | \
((uint64_t)((p)[4]) << 32) | \
((uint64_t)((p)[5]) << 40) | \
((uint64_t)((p)[6]) << 48) | \
((uint64_t)((p)[7]) << 56))
class SipHash
{
private:
std::array<uint64_t, 4> m_v;
uint8_t m_byte_count;
uint64_t m_message;
public:
SipHash(uint64_t k0, uint64_t k1);
SipHash(const std::string& key);
SipHash& Write(const unsigned char* data, size_t size);
SipHash& Write(uint64_t data);
uint64_t Finalize() const;
};
#endif // DRAGONBTC_SIPHASH_H_
// siphash.cpp
#include <cassert>
#include <crypto/siphash.h>
#define ROTL(x, b) (uint64_t)(((x) << (b)) | ((x) >> (64 - (b))))
#define SIPROUND do { \
v0 += v1; v1 = ROTL(v1, 13); v1 ^= v0; \
v0 = ROTL(v0, 32); \
v2 += v3; v3 = ROTL(v3, 16); v3 ^= v2; \
v0 += v3; v3 = ROTL(v3, 21); v3 ^= v0; \
v2 += v1; v1 = ROTL(v1, 17); v1 ^= v2; \
v2 = ROTL(v2, 32); \
} while (0)
SipHash::SipHash(uint64_t k0, uint64_t k1)
{
m_v[0] = k0 ^ UINT64_C(0x736f6d6570736575); // “somepseu”
m_v[1] = k1 ^ UINT64_C(0x646f72616e646f6d); // “dorandom”
m_v[2] = k0 ^ UINT64_C(0x6c7967656e657261); // “lygenera”
m_v[3] = k1 ^ UINT64_C(0x7465646279746573); // “tedbytes”
m_byte_count = 0;
m_message = 0;
}
SipHash::SipHash(const std::string& key)
{
// 如果key.size() < 16,则后面使用0补齐
std::vector<uint8_t> v_key(16, 0);
std::copy_n(key.begin(), std::min(key.size(), 16UL), v_key.begin());
std::span<const uint8_t> span_key = v_key;
std::span<const uint8_t, std::dynamic_extent> span_first_dynamic = span_key.first(8);
std::span<const uint8_t, std::dynamic_extent> span_last_dynamic = span_key.last(8);
uint64_t k0 = U8TO64_LE(span_first_dynamic.data());
uint64_t k1 = U8TO64_LE(span_last_dynamic.data());
m_v[0] = k0 ^ UINT64_C(0x736f6d6570736575);
m_v[1] = k1 ^ UINT64_C(0x646f72616e646f6d);
m_v[2] = k0 ^ UINT64_C(0x6c7967656e657261);
m_v[3] = k1 ^ UINT64_C(0x7465646279746573);
m_byte_count = 0;
m_message = 0;
}
SipHash& SipHash::Write(const unsigned char* data, size_t size)
{
// 获取v0 ~ v3的值
uint64_t v0 = m_v[0], v1 = m_v[1], v2 = m_v[2], v3 = m_v[3];
// 获取上一次解析后的消息(words)
uint64_t m = m_message;
// 获取已经过轮函数SipRound的字节数
uint8_t c = m_byte_count;
// 对当前输入的字节按照little-endian规则进行SipRound.
while (size--) {
// 将data中的数据按照little-endian进行转换
// 相当于: data[0] << 0 | data[1] << 8 | data[2] << 16 | data[3] << 24 | data[4] << 32 | data[5] << 40 | data[6] << 48 | data[7] << 56
m |= ((uint64_t)(*(data++))) << (8 * (c % 8));
c++;
if ((c & 7) == 0) { // c的值是0, 8, 16,24,32……m满足64位
v3 ^= m;
SIPROUND;
SIPROUND;
v0 ^= m;
m = 0;
}
}
// 将v0 ~ v3当前计算的结果保存
m_v[0] = v0;
m_v[1] = v1;
m_v[2] = v2;
m_v[3] = v3;
// 保存已完成SipRound的字节
m_byte_count = c;
// 保存最后的消息字节,如果最后已完成的字节数刚好是8的倍数,那么m = 0,否则作为下一次计算的基础
m_message = m;
return *this;
}
// 该函数与 Write(const unsigned char* data, size_t size) 一致,只不过每次都是固定长度
SipHash& SipHash::Write(uint64_t data)
{
uint64_t v0 = m_v[0], v1 = m_v[1], v2 = m_v[2], v3 = m_v[3];
assert(m_byte_count % 8 == 0);
v3 ^= data;
SIPROUND;
SIPROUND;
v0 ^= data;
// 将v0 ~ v3当前计算的结果保存
m_v[0] = v0;
m_v[1] = v1;
m_v[2] = v2;
m_v[3] = v3;
m_byte_count += 8;
return *this;
}
uint64_t SipHash::Finalize() const
{
uint64_t v0 = m_v[0], v1 = m_v[1], v2 = m_v[2], v3 = m_v[3];
// 加入消息长度,如果最后的消息字节是已经是64位,那么SipRound一个新的字节,否则将长度作为该消息字高8位。
uint64_t m = m_message | (((uint64_t)m_byte_count) << 56);
v3 ^= m;
SIPROUND;
SIPROUND;
v0 ^= m;
v2 ^= 0xFF;
SIPROUND;
SIPROUND;
SIPROUND;
SIPROUND;
return v0 ^ v1 ^ v2 ^ v3;
}
Bitcoin中的SipHash代码在:
https://github.com/bitcoin/bitcoin/blob/master/src/crypto/siphash.cpp
6. 替换C++中默认的哈希函数
在使用C++的unordered_map类时,系统会使用默认的哈希函数来创建哈希值,如何替换默认的转而使用SipHash函数呢?下面我们先创建一个新的类,名为ServiceHash,并重载其中的访问操作符。
class ServiceHash
{
private:
const uint64_t m_salt_k0 = RandomContext::instance().GetRand(std::numeric_limits<uint64_t>::max());
const uint64_t m_salt_k1 = RandomContext::instance().GetRand(std::numeric_limits<uint64_t>::max());
public:
size_t operator()(const std::string& a) const noexcept
{
SipHash hasher(m_salt_k0, m_salt_k1);
hasher.Write((const uint8_t*)a.data(), a.size());
return static_cast<size_t>(hasher.Finalize());
}
};
其中operator()(const std::string& a) 操作符必须跟你在使用unordered_map时使用的key的类型一致,m_salt_k0和m_salt_k1是SipHash使用的key值。上述使用了GetRange来获取一个随机数作为Hash seed,该函数的实现过程可参考Bitcoin中的randomcontext.cpp中的代码。
接下来我们就可以使用上面的哈希类自定义一个map变量,这样就可以使用SipHash算法的哈希表了。
std::unordered_map<std::string, int, ServiceHash> map_info;
map_info["mike"] = 40;
map_info["Jeams"] = 50;
7. 参考文章
https://www.aumasson.jp/siphash/siphashdos_29c3_slides.pdf