如何防止因哈希碰撞引起的DoS攻击

Posted 双子孤狼

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何防止因哈希碰撞引起的DoS攻击相关的知识,希望对你有一定的参考价值。

理解哈希

哈希大家在开发中可以说是接触的非常多,在 Java 中的 HashMapHashSet 等都是哈希的应用,但是到底什么是哈希呢?对哈希我们是否真的非常了解呢?

什么是哈希

哈希(Hash)也称为散列,就是把任意⻓度的输⼊,通过哈希(散列)算法,变换成固定⻓度的输出,这个输出的值就是哈希(散列)值。

哈希和数组

哈希表(Hash table,也叫散列表),是一种可以用 O(1) 时间复杂度来随机读取元素的一种数据结构。前面我们学了数组,链表,栈,队列这些基础数据结构,似乎只有数组在随机访问元素的时间复杂度达到了 O(1) 级别,而哈希表也达到了 O(1) 级别,我们是不是可以猜测这两种数据结构有什么联系呢?

其实哈希表就是利用了数组可以随机访问元素的特性,可以说没有数组就没有哈希表,哈希表是数组的一种扩展。

比如我们现在有一个数组,长度为 8,现在我们想把一个整数 100 存入数组内,这时候假设我们选择了通过取模的算法来决定当前元素存入数组哪个位置,通过 100%8=4,这时候我们就把 100 存入数组内下标为 4 的位置,如下图所示:

在这个例子当中,取模运算就是哈希函数(或者散列函数,Hash 函数),元素 100 称之为 key 或者关键字,而经过哈希函数计算后得到的下标 4 称之为哈希值(散列值),而这个存储数据的数组就称之为哈希表。

哈希算法

想要将一个 key 对应的 value 存入哈希表,必须先经过哈希函数得到数组的下标(散列值),取值的的时候也必须先经过哈希运算才能取值,所以哈希函数一定不能太复杂,否则计算哈希就会带来大量性能的损耗,其次就是如果 key1==key2,那么 hash(key1)==hash(key2) 也一定成立,否则存进去的数我们无法准确的取出来

另外哈希函数还有一个特点就是经过哈希运算后所得到的哈希值必须是一个非负整数,因为数组的下标是从 0 开始的。

综上所述,我们可以得到如果需要将一个数据存入哈希表,那么这种哈希函数有以下三个特点:

  • 哈希函数并不是越复杂越好,要综合考虑时间成本,否则存取数据时哈希计算会带来大量性能消耗。
  • 经过哈希函数得到的哈希值是一个非负整数,因为数组的下标是从 0 开始的。
  • 如果 key1==key2,那么 hash(key1)==hash(key2)

哈希碰撞

如果 key1 != key2,但是 hash(key1) == hash(key2),那么我们就称之为发生了哈希碰撞,或者说哈希冲突。比如说我们上面示例中的取模运算这个哈希函数,100 经过哈希运算后得到的哈希值是 4,其实还有 1220,等关键字经过哈希函数运算之后得到的都是 4,这就发生了哈希碰撞了。

有人可能会想,取模运算太简单了才会发生哈希碰撞,如果哈希函数复杂一点就能避免哈希碰撞了,但是哈希碰撞真的能避免吗?

哈希碰撞是不能避免的,业内比较有名的像 MD5SHACRC 等都不能做到避免哈希碰撞。这其实就是数学中的一个基础理论鸽巢原理

鸽巢原理

鸽巢原理(也叫抽屉原理),这个原理其实比较简单。就是说假如现在有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,也就是说,至少有 2 只鸽子会在一个鸽巢里。

为什么不能避免哈希碰撞

通过鸽巢原理我们明白了哈希碰撞的根本原因就是哈希值是有限的,而我们要存储的数据却是无限的。

比如我们上面例子中,定义了一个数组,数组的长度始终是有限的(也就是说哈希值始终是有限的),而我们要存储的数据一般都假设是无限的,所以不可避免的会引起哈希碰撞,只能说数组空间越大发生哈希碰撞的概率就越小而已,我们所要做的就是尽量减少哈希碰撞的发生。

我们再看看著名的摘要(哈希)算法 md5,通过 md5 加密后得到的哈希值是 128 位,那么最多能表示 2^128 个数据,而我们需要被哈希的数据是无限的,所以基于鸽巢原理,如果我们对 2^128+1 个数据求哈希值,就必然会存在哈希值相同的情况,也就是必然会发生哈希碰撞。

哈希算法的特点

前面我们提到了,如果我们需要将关键字存入一个数组哈希表中时,哈希函数具有的三个特点,这只是针对于映射数组而言,实际上对于通用的哈希算法来说,主要有以下特点:

  • 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)。
  • 对输入数据非常敏感,哪怕原始数据只修改了一个 bit,最后得到的哈希值也大不相同(比如 md5 算法)。
  • 哈希冲突的概率要尽量减少。
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

如何解决哈希碰撞

解决哈希碰撞最常用的有两种方法:开放寻址法链地址法,除此之外,还有:再哈希法建⽴公共溢出区法

开放寻址法

开放寻址法(Open Addressing)的核心思想是:如果出现了哈希冲突,我们就重新寻找一个空闲位置的地址,并将当前数据插入。

如何再重新寻找一个空闲位置呢?常用的也有三种方法:线性探测二次探测双重散列

线性探测

线性探测(Linear Probing)很简单,就是说当我们发生哈希冲突后,继续往后面寻找,直到找到一个空闲位置,就将当前数据插入,比如我们上面的例子中,因为 100 已经插入了数组的下标 4 位置处,如果再插入一个 12,取模后也是得到下标 4,此时因为已被占用,继续往后找,发现位置 5 是空的,则存入 12,如果位置 5 也有数据,那么就会继续往后找直到找到空闲位置。

如果说循环找了一遍还是没找到空闲位置,那么这时候可以触发扩容(正常会有一个扩容阈值,不会等到满了才触发扩容)或者也可以拒绝插入。

了解了线性探测法之后,可能有人会有疑问,使用线性探测方法后,查询不就会有问题了吗?事实上查询也会采用同样的方法,首先依然是计算哈希找到哈希值所在位置,然后取出关键字进行比较,如果不相等,那么就依次往后查找,如果遍历到数据的空位或者重新遍历到当前哈希值所在位置还没有找到当前元素,那就说明当前元素不存在该哈希表中。

线性探测法的缺陷其实也很明显,当数组中的空闲位置越来越少,极端情况会导致遍历整个哈希表,所以时间复杂度是 O(n)

上面我们提到,查询的时候需要往后找,当找到一个空闲位置之后就会停下来不再继续寻找,但是假如说这个空闲位置一开始并不是空闲的,而是后面被删除的,那么这时候就会出问题了,所以我们在删除一个元素的时候,不能将数组中的元素直接删除,而是应该打上一个删除标记,这样如果查找元素的时候遍历到删除标记所在的位置,就依然会继续往后查找(如果删除的元素恰好是自己,那就会直接返回不存在)。

线性探测法适用场景

Java 中的 ThreadLocalMap 就是采用了开放寻址法来解决哈希冲突,因为开放寻址法在极端环境下时间复杂度会退化成 O(n),所以适用于数据量较少的场景。

二次探测

所谓的二次探测(Quadratic Probing)其实和线性探测是类似的,线性探测是一次走一步,如:hash(key)->hash(key)+1->hash(key)+2,依次类推。而二次探测只是每次走的步数变成了原来的平方而已,如:hash(key)->hash(key)+1^2->hash(key)+2^2,并依次类推。

双重散列

双重散列(Double Hashing)意思就是我们使用的哈希函数不仅仅只是一个,而是准备多个哈希函数:hash1(key)hash2(key)hash3(key) …。计算哈希值的时候,我们先用第一个哈希函数,如果计算得到的存储位置已经被占用,再用第二个哈希函数,依次类推,直到找到空闲的存储位置。

链地址法

链地址法也叫链表法,这种方法比较常见也比较简单,就是插入一个元素时,如果发现当前位置已经有元素,则以当前节点为头节点(尾插法)或者尾结点(头插法)构造一个链表,如果进一步优化的话,可以将链表修改为红黑树等数组结构,比如 jdk 1.8 之后的 HashMap 就是采用的这种方式进行优化。

链地址法适用场景

基于链表的哈希冲突处理方法比较适合存储大对象、大数据量的哈希表,因为链表本身就需要额外的空间来存储指针地址,所以如果一个节点存储的数据大小还没有指针大,那就会造成很大空间浪费;相反,如果指针的大小相对于数据大小可以忽略,那用链地址法解决冲突会是一个比较好的选择,而且链地址法会更加灵活,支持更多的优化策略,比如 HashMap 中当链表节点数大于 8 就会转化为红黑树来进行优化。

哈希碰撞攻击

前面我们提到,采用链地址法的情况下发生冲突时会在哈希冲突处构造一个链表,但是在极端情况下,有些恶意的攻击者,可能会通过精心构造的数据,使得所有的数据经过哈希函数之后,都映射到到同一个位置,这时候哈希表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。这样就有可能发生因为查询操作消耗大量 CPU 或者线程资源,而导致系统无法响应其他请求的情况,从而达到拒绝服务攻击(DoS)的目的。

要防止哈希碰撞攻击,就要求我们在设计一个哈希表的时候要考虑到在极端情况下性能也不不能退化到无法接受的情况,所以一般我们为了防止哈希碰撞攻击需要从以下几个方面着手:

  • 哈希函数需要设计好,即使只有细微改动,经过哈希函数后得到的哈希值也要大不相同,这样可以增加伪造数据的难度。
  • 设计负载因子,支持动态扩容。也就是不要等到哈希表满了才开始扩容,而要达到一定百分比之后就要开始扩容。
  • 选择合适的方法来解决哈希冲突,如果选择链地址法,可以引入红黑树或者跳表等数据结构来避免出现过长的链表,从而导致性能急剧下降。

总结

本文主要讲述了哈希原理以及哈希函数的一些特点,并解释了为什么哈希表访问元素的时间复杂度可以达到 O(1) 级别。随后讲解了两种解决哈希冲突的方法:开放寻址法和链地址法,并针对两种处理方式进行了对比分析,详述了不同方式的应用场景,最后我们提到了哈希碰撞攻击以及其对应的应对措施。

以上是关于如何防止因哈希碰撞引起的DoS攻击的主要内容,如果未能解决你的问题,请参考以下文章

如何防止因哈希碰撞引起的DoS攻击

Istio 发布安全版本,防止 DoS 攻击

如何防止android中的拒绝服务(DOS)攻击?

[转帖] 阮一峰:哈希碰撞与生日攻击

一种高级的DoS攻击-Hash碰撞攻击

PHP哈希表碰撞攻击