什么是哈希洪水攻击(Hash-Flooding Attack)?

如果一名程序员想要接触信息安全的话,哈希洪水攻击我是一定会重点圈出来的。一方面是因为它的原理非常简单,只要掌握一点数据结构方面的基本知识就能理解;另一方面是因为,它是我入坑以来对我启发最大的技术之一,从中不仅可以学到一项具体的攻击技术,还可以看到“软件开发从业人员”和“信息安全从业人员”之间决定性的分野。

比较遗憾的是,国内互联网上真正搞懂这项攻击的人不多。虽然有几篇不错的文章讲清了它的攻击原理,但他们给出的防御手段也不过是“限制参数个数”、“禁止不明用户提交数据”之类的东西,无法从根本上解决哈希洪水攻击。所以我才决定这几天抽时间写篇长文回答,讲讲哈希洪水攻击的攻与防。

顺便一提,我没记错的话,阿里还曾经拿这个问题做过面试题,这个选题可以说是很有品味了。


0x01 哈希洪水攻击的成因

哈希洪水攻击(Hash-Flooding Attack)是一种拒绝服务攻击(Denial of Service),一旦后端接口存在合适的攻击面,攻击者就能轻松让整台服务器陷入瘫痪。

那么,所谓的“合适的攻击面”到底指什么呢?我们先来复习一下本科水平的数据结构知识吧。

在各种常用的数据结构里,有些数据结构的“平均运行时间”和“最差运行时间”会差很远,比如哈希表(Hash Table)。假设我们想要连续插入 个元素到哈希表中:

  • 如果这些元素的键(Key)极少出现相同哈希值,这项任务就只需 的时间。
  • 如果这些键频繁出现相同的哈希值(频繁发生碰撞),这项任务就需要 的时间。

这应该是每个学过数据结构的学生都知道的常识,不过大部分人看过之后就很快忘掉了。

2003年,Scott A. Crosby 和 Dan S. Wallach 两位研究人员发表了一篇论文:Denial of Service via Algorithmic Complexity Attacks。在这篇论文里他们首次提出:既然有些数据结构的最差运行时间这么废物,我们有没有可能通过算法上的漏洞,强行构造出一个最差情况,让服务器把全部的资源都浪费在处理这个最差情况上?

例如,Java自带的字符串哈希函数,使用的是“DJBX33A算法”的变种,这个算法是这样定义的:

什么是哈希洪水攻击(Hash-Flooding Attack)?_第1张图片

From: Efficient Denial of Service Attacks on Web Application Platforms

而根据这个算法定义,我们就可以轻松地构造出一批具有一样哈希值的字符串:

什么是哈希洪水攻击(Hash-Flooding Attack)?_第2张图片

From: Efficient Denial of Service Attacks on Web Application Platforms

这样一来,只要构造出几万个同样哈希的字符串,把它们提交给服务器做哈希表, 就能用很低的成本将服务器打瘫了。

这个成本具体有多低呢?依2011年的实验数据,攻击一台基于Java(Tomcat)的服务器时,仅仅需要6KB/s的流量就能打瘫一颗 Intel i7 处理器,1GB/s的流量可以打瘫 100000 颗 Intel i7 处理器,性价比远超TCP半开连接等传统的拒绝服务攻击。


0x02 哈希洪水攻击的防御

搞清原理之后,很多人第一时间想到的防御手段应该是:“限制参数个数”、“禁止不明用户提交数据”这类吧?是,这类方案理论上是可行的,起码在项目规模不大的时候没什么问题。

然而随着项目的不断演进,项目人员的入职离职,整个项目的数据接口会逐渐脱离掌控。你固然可以通过一些全局配置(比如PHP的max_input_vars)来限制参数个数,但其他团队的程序员却可能在不知情的情况下,为了“绕过那个搞网络安全的哥们设的神经病限制”,而故意选择用 JSON 等方式提交大量数据,给整个系统深深地埋下一颗地雷。

因此,为了根绝隐患,我们需要从更根本上避免攻击的发生。比如,我们能否找到更优秀的哈希算法,让那些键的哈希值完全不发生碰撞?

很遗憾,答案是不行,从数学角度上讲这根本不可能。因为一个哈希表的长度一般也就是几千个元素,根据生日悖论我们可以证明:不管你的算法设计得多么精妙,只要黑客掌握算法的所有细节,那就总能算出一组频繁碰撞的键来。

注意到我刚才那句话里隐藏的线索了吗?

如果黑客不能掌握算法的所有细节,是不是就不能算出一组频繁碰撞的键,也就没法发动哈希洪水攻击

换句话说,我们能不能在算法中加入一个黑客不知道的秘密参数?每建一张哈希表,我们就随机生成一个新的秘密参数。这样一来,即使是同样的内容,放在不同的表里也会产生完全不同的内存分配。这整个过程黑客完全无法预测,即使发生碰撞,也是小概率的巧合,而不是黑客在主动控制,攻击也就不可能成立了

这个黑客不知道的秘密参数,我们现在称之为哈希种子(Hash Seed)。而这类使用哈希种子的哈希算法,我们称之为带密钥哈希算法(Keyed Hash Function)

黑客一方的攻击目标,是想办法刺探出种子的值,或者在不知道种子的情况下构造出一组会碰撞的键来。而安全研究人员的目标,就是设计出更安全的带密钥哈希算法,保护好种子的安全,避免种子被黑客绕过。

这些年来,攻守双方在这个领域展开了激烈的攻防,来自Google、UIC等机构的众多研究人员设计了许多新的哈希函数:SipHash、MurmurHash、CityHash等等。这些算法不停地被推翻,不停地更新版本,到现在已经形成了一套稳定的算法标准,被众多编程语言和开源项目所采纳。

下面这张表来自SipHash官网,里面列举了众多采用SipHash-2-4算法的知名项目。其中Rust、Python、Ruby等语言更是把SipHash-2-4作为默认的哈希表实现方法,用这些语言编写的项目天生免疫哈希洪水攻击:

什么是哈希洪水攻击(Hash-Flooding Attack)?_第3张图片

SipHash User List

这些算法虽然在中文资料里名不见经传,很多人甚至可能是读到我这篇文章,才第一次听说这些算法的名字。但正是多亏了这些默默无名的算法,以及设计了这些算法的研究人员,我们才没有生活在一个“服务器三天一崩溃、五天一瘫痪”的世界里。

附注:在读了评论区知守的评论之后,补充一下Java提出的解决方案(JEP 180)。

从JDK 8开始,HashMap、LinkedHashMap和ConcurrentHashMap三个类引入了一套新的策略来处理哈希碰撞。

  • 当一个位置存储的元素个数小于8个时,仍然使用链表存储。
  • 当一个位置存储的元素个数大于等于8个时,改为使用平衡树来存储。

这样一来,就能保证最差的运行时间是 了。

为什么要设立“8个元素”(TREEIFY threshold)这样一个限制呢?因为平衡树相比链表而言有着更高的开销,以及更散乱的内存布局(影响缓存命中率)。在正常情况下,哈希表的一个位置大约只会存储1~4个左右的元素,所以没有必要专门开一个平衡树来存储冲突的元素,对一些性能敏感的应用来说会造成显著的负面影响。

实际应用中究竟选用平衡树还是SipHash,完全是一件见仁见智的事情,两边没有哪个有显著的优势,实现起来也都不是很困难。


0x03 后记

在本文开始的时候,我说过一句话:“它是我入坑以来对我启发最大的技术之一,从中不仅可以学到一项具体的攻击技术,还可以看到‘软件开发从业人员’和‘信息安全从业人员’之间决定性的分野”。

那么这项决定性的分野是什么呢?

我将它总结为一句话,并作为自己的座右铭:“Stay Malicious,保持恶意。”

“哈希表的最差时间复杂度是 ”——这是一项所有软件开发人员烂熟于心的基础知识,所有人都知道,但是所有人都只是看过一眼就忘在脑后了。直到2003年,才第一次有人提出可以用这个东西发动网络攻击,而且效果十分之出色。

在这个故事中,两位研究人员并没有掌握什么复杂深奥的神仙技术,仅仅只是用到一句本科水平的基础常识而已,可是深究下来却走出了这样一条别出心裁的攻击路径,为什么?因为他们懂得怎么样“怀着恶意”去应用手中的技术,这正是很多软件开发人员所欠缺的。而这一点点意识上的欠缺,将来也许就会变成一笔昂贵的学费砸在头上。

所以,Stay Malicious。

在一个充满恶意的虚拟世界,保持恶意才能让你走得更远。


觉得本文有价值的话,欢迎点个赞支持一下。

你可能感兴趣的:(网络安全,哈希算法,ddos,算法)