比特币源码分析--深入理解区块链 5.防御哈希洪水攻击:面向短消息的快速Hash算法SipHash

        散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。哈希表有以下特点:

  1. 访问速度很快
  2. 需要额外的空间(以空间换时间)
  3. 无序
  4. 可能会产生碰撞

        因为访问速度很快,因此在程序中需要使用内存表并且能够快速访问时它就是首选。关于第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位内部状态变量

符号是亦或运算,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

http://cr.yp.to/siphash/siphash-20120918.pdf

https://www.zhihu.com/question/286529973

猜你喜欢

转载自blog.csdn.net/dragon_trooquant/article/details/122458904