在PHP的Zend Engine(下面簡稱ZE)中,有一個非常重要的數據結構——哈希表(HashTable)。哈希表在ZE中有非常廣泛的應用,PHP的複雜數據結構中數組和類的存儲和訪問就是用哈希表來組織,PHP語言結構中的常量、變量、函數等符號表也是用它來組織。
1. 哈希表的基本概念
什麼是哈希表呢?哈希表在數據結構中也叫散列表。是根據鍵名經過hash函數計算後,映射到表中的一個位置,來直接訪問記錄,加快了訪問速度。在理想情況下,哈希表的操作時間複雜度爲O(1)。數據項可以在一個與哈希表長度無關的時間內,計算出一個值hash(key),在固定時間內定位到一個桶(bucket,表示哈希表的一個位置),主要時間消耗在於哈希函數計算和桶的定位。
在分析PHP中HashTable實現原理之前,先介紹一下相關的基本概念:
如下圖例子,希望通過人名檢索一個數據,鍵名通過哈希函數,得到指向bucket的指針,最後訪問真實的bucket。
鍵名(Key):在哈希函數轉換前,數據的標識。
桶(Bucket):在哈希表中,真正保存數據的容器。
哈希函數(Hash Function):將Key通過哈希函數,得到一個指向bucket的指針。MD5,SHA-1是我們在業務中常用的哈希函數。
哈希衝突(Hash Collision):兩個不同的Key,經過哈希函數,得到同一個bucket的指針。
2. PHP的哈希表實現原理
哈希表的結構:
Zend/zend_hash.h
typedef struct _hashtable {
uint nTableSize; //哈希表的長度,不是元素個數
uint nTableMask; //哈希表的掩碼,設置爲nTableSize-1
uint nNumOfElements; //哈希表實際元素個數
ulong nNextFreeElement; //指向下一個空元素位置
Bucket *pInternalPointer; //用於遍歷哈希表的內部指針
Bucket *pListHead; //哈希表隊列的頭部
Bucket *pListTail; //哈希表隊列的尾部
Bucket **arBuckets; //哈希表存儲的元素數組
dtor_func_t pDestructor; //哈希表的元素析構函數指針
zend_bool persistent; //是否是持久保存,用於pmalloc的參數,可以持久存儲在內存中
unsigned char nApplyCount; // zend_hash_apply的次數,用來限制嵌套遍歷的層數,限制爲3層
zend_bool bApplyProtection; //是否開啓嵌套遍歷保護
#if ZEND_DEBUG
int inconsistent;
#endif
} HashTable;
1) nTableSize 哈希表的大小。最小容量是2^3(8),最大容量是2^31(2147483648)。當如果進行一次操作後發現元素個數大於nTableSize,會申請當前nTableSize * 2的空間。假設當前nTableSize爲8,當插入元素達到9個的時候,會申請nTableSize=16的空間。2) nTableMask 爲nTableSize-1,用於調整最大索引值。當哈希後值大於索引值時候,把這個值映射到索引值範圍內。
3) nNumOfElements HashTable中的個數。數組操作中,sizeof和count函數獲取的是這個值。
4) nNextFreeElement 下一個空元素的地址。
5) pInternalPointer 存儲了HashTable當前指向的元素的指針,當我們使用一些內部循環函數的時候會用到這個指針比如reset(), current(), prev(), next(), foreach(), end()。相當於遊標。
6) pListHead和pListTail則具體指向了該哈希表的第一個和最後一個元素,對應就是數組的起始和結束元素。哈希表的pListHead、pListTail與Bucket的pListNext、pListLast維護了一個哈希表中Bucket的雙向鏈表,按照插入的先後順序,用於哈希表的遍歷。
7) arBuckets 實際存儲Buckets的數組。
8) pDestructor 是一個析構函數,當某個值被從哈希表刪除的時候會觸發此函數。他還有一個主要作用是用於變量的GC回收。在PHP裏面GC是通過引用計數實現的,當一個變量的引用計數變爲0,就會被PHP的GC回收。
9) persistent 定義了hashtable是否能在多次request中獲得持久存在。
10) nApplyCount 和 bApplyProtection 是用來防止嵌套遍歷的。
11) inconsistent 是在調試模式下捕獲對HT不正確的使用。
Bucket的結構:
typedef struct bucket {
ulong h; //數組索引的哈希值
uint nKeyLength; //索引數組爲0,關聯數組爲key的長度
void *pData; //元素內容的指針
void *pDataPtr; // 如果是指針大小的數據,用pDataPtr直接存儲,pData指向pDataPtr
struct bucket *pListNext; //哈希鏈表中下一個元素
struct bucket *pListLast; //哈希鏈表中上一個元素
struct bucket *pNext; //解決哈希衝突,變爲雙向鏈表,雙向鏈表的下一個元素
struct bucket *pLast; //解決哈希衝突,變爲雙向鏈表,雙向鏈表的上一個元素
const char *arKey; //最後一個元素key的名稱
} Bucket;
通過下圖來表示HashTable的原理:
我們先來看一下,ZE是如何創建一個hash表的。創建並初始化一個Hash比較容易,調用_zend_hash_init函數。PHP的哈希表最小容量8(2^3),最大容量是0x80000000(2^31,即2147483648)。nTableSize會按照2的整數次冪圓整來增加,直到超過預設值的nSize。
Zend/zend_hash.c
ZEND_API int _zend_hash_init(HashTable *ht, uint nSize, hash_func_t pHashFunction, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)
{
uint i = 3;
SET_INCONSISTENT(HT_OK);
if (nSize >= 0x80000000) {
/* prevent overflow */
ht->nTableSize = 0x80000000;
} else {
while ((1U << i) < nSize) {
i++;
}
ht->nTableSize = 1 << i;
}
/* 省略哈希表初始化步驟 */
return SUCCESS;
}
1) *ht 是哈希表的指針,這裏既可以傳入一個已存在的HashTable, 也可以通過內核宏ALLOC_HASHTABLE(ht)來自動申請一塊HashTable內存。ALLOC_HASHTABLE(ht)相當於ht=emalloc(sizeof(HashTable))
2) nSize 哈希表能擁有的最大數量。通過預先申請好內存的方式,減少哈希表rehash操作。
3) pHashFunction 自定義哈希函數的鉤子
4) pDesctructor 哈希表析構的回調函數,當刪除一個哈希表的時候,會調用。
5) persistent 對應HashTable.persistent,當設置爲true的時候,不會在RSHUTDOWN階段自動銷燬。
我們通過更新哈希表的操作方式,來分析哈希表的操作機制:
h = zend_inline_hash_func(arKey, nKeyLength);
nIndex = h & ht->nTableMask;
p = ht->arBuckets[nIndex];
while (p != NULL) {
if (p->arKey == arKey ||
((p->h == h) && (p->nKeyLength == nKeyLength) && !memcmp(p->arKey, arKey, nKeyLength))) {
if (flag & HASH_ADD) {
return FAILURE;
}
/* 省略 */
UPDATE_DATA(ht, p, pData, nDataSize); // 找到h 和 Key都相等的Buckets,說明需要更新
/* 省略 */
}
p = p->pNext; // 這裏說明有哈希衝突,按照Buckets[nIndex]的鏈表找下去
}
/* 省略 */
p->nKeyLength = nKeyLength;
INIT_DATA(ht, p, pData, nDataSize); // 把Bucket.pData數據更新
p->h = h;
CONNECT_TO_BUCKET_DLLIST(p, ht->arBuckets[nIndex]); // 掛到
if (pDest) {
*pDest = p->pData;
}
HANDLE_BLOCK_INTERRUPTIONS();
CONNECT_TO_GLOBAL_DLLIST(p, ht);
ht->arBuckets[nIndex] = p;
HANDLE_UNBLOCK_INTERRUPTIONS();
ht->nNumOfElements++;
ZEND_HASH_IF_FULL_DO_RESIZE(ht); /* 如果哈希表滿了,重新散列,這裏有一定開銷 */
1) 通過哈希算法 times33(Key) & (nTableSize-1) ,生成Key對應的哈希值A,獲取arBuckets[A]的值2) 判斷arBuckets[A]是否存在,如果存在而且沒有哈希衝突,進行數據update(UPDATE_DATA)。如果存在但是Key不相同說明有哈希衝突,在arBuckets[A]鏈表中尋找Key是否存在,如果存在,執行update操作(UPDATE_DATA)
3) 如果arBuckets[A]不存在,創建新的arBucket[A](INIT_DATA)。或哈希衝突情況下,在arBuckets[A]的鏈表中找不到Key。創建新的bucket(INIT_DATA),並把新的buckets放在arBucket[A]鏈表頭
4) 維護哈希表的邏輯鏈表(CONNECT_TO_GLOBAL_DLLIST)。
5) 如果發現新插入元素已經超過HashTable的nTableSize,自動擴容至2倍nTableSize,重新哈希後維護新的HashTable。
3. PHP使用的哈希函數
PHP的哈希表是用Times33哈希算法,又稱爲DJBX33A。這是一個使用比較廣泛的對字符串的哈希算法,計算速度快,散列均勻,Perl和Apache都使用了這個算法。算法原理就是不斷的乘以33,其算法原型如下:
hash(i) = hash(i-1) * 33 + str[i]
爲什麼是33呢?對於33這個數,DJB註釋中是說,1到256之間的所有奇數,都能達到一個可接受的哈希分佈,平均分佈大概是86%。而其中33,17,31,63,127,129這幾個數在面對大量的哈希運算時有一個更大的優勢,就是這些數字能將乘法用位運算配合加減法替換,這樣運算速度會更高。gcc編譯器開啓優化後會自動將乘法轉換爲位運算。PHP實際算法如下: static inline ulong zend_inline_hash_func(const char *arKey, uint nKeyLength)
{
register ulong hash = 5381;
/* variant with the hash unrolled eight times */
for (; nKeyLength >= 8; nKeyLength -= 8) {
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
}
switch (nKeyLength) {
case 7: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 6: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 5: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 4: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 3: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 2: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 1: hash = ((hash << 5) + hash) + *arKey++; break;
case 0: break;
EMPTY_SWITCH_DEFAULT_CASE()
}
return hash;
}
PHP在哈希算法上有所優化,使用了(hash<<5)+hash,效率有所提高。至於hash的初始值爲什麼爲一個大素數5381,要數學上來解釋了,不是很理解。
4. 操作哈希表的內部函數
PHP的變量符號表是通過哈希表來維護,首先介紹一下再PHP擴展中如何創建一個新的變量。
ZEND_FUNCTION(variable_creation)
{
zval *new_var1, *new_var2, *new_var3; //創建兩個新的變量容器
char *string_contents = "This is a new string variable";
MAKE_STD_ZVAL(new_var1); //爲new_var1申請空間並初始化
MAKE_STD_ZVAL(new_var2);
ZVAL_LONG(new_var1, 10); //設置new_var1並賦值爲long
ZVAL_LONG(new_var2, 5);
ZVAL_STRINGL(new_var3, string_contents, sizeof(string_contents), 0); //設置new_var3爲字符串
ZEND_SET_SYMBOL(EG(active_symbol_table), "local_variable", new_var1); //設置long_variable爲函數variable_creation的局部變量
ZEND_SET_SYMBOL(&EG(symbol_table), "global_variable", new_var2); //設置global_variable爲全局變量
zend_hash_update(
&EG(symbol_table),
"new_var3",
strlen("new_var3") + 1,
&new_var3,
sizeof(zval *),
NULL
);
RETURN_NULL();
}
這裏的zend_hash_update會更新變量符號表。PHP的數組也是用哈希表來維護,下面通過操作一個array來解釋如何使用哈希表來才做數組。增加一個關聯數組:
zval *new_array, *new_element;
char *key = "element_key";
MAKE_STD_ZVAL(new_array);
MAKE_STD_ZVAL(new_element);
array_init(new_array);
ZVAL_LONG(new_element, 10);
if(zend_hash_update(new_array->value.ht, key, strlen(key) + 1, (void *)&new_element, sizeof(zval *), NULL) == FAILURE)
{
// do error handling here
}
增加一個索引數組: zval *new_array, *new_element;
int key = 2;
MAKE_STD_ZVAL(new_array);
MAKE_STD_ZVAL(new_element);
array_init(new_array);
ZVAL_LONG(new_element, 10);
if(zend_hash_index_update(new_array->value.ht, key, (void *)&new_element, sizeof(zval *), NULL) == FAILURE)
{
// do error handling here
}
哈希表的增刪改查
int zend_hash_add( HashTable *ht, char *arKey, uint nKeyLen,void *pData, uint nDataSize, void **pDest);
int zend_hash_update( HashTable *ht, char *arKey, uint nKeyLen, void *pData, uint nDataSize, void **pDest);
int zend_hash_index_update( HashTable *ht, ulong h, void *pData, uint nDataSize, void **pDest);//與zend_hash_update類似,不過哈希值計算是用h&TableMask
int zend_hash_next_index_insert(HashTable *ht, void *pData, uint nDataSize, void **pDest);
int zend_hash_find(HashTable *ht, char *arKey, uint nKeyLength,void **pData);
int zend_hash_index_find(HashTable *ht, ulong h, void **pData);
ZEND_API int zend_hash_exists(const HashTable *ht, const char *arKey, uint nKeyLength)
ZEND_API ulong zend_get_hash_value(const char *arKey, uint nKeyLength)
ZEND_API void zend_hash_merge_ex(HashTable *target, HashTable *source, copy_ctor_func_t pCopyConstructor, uint size, merge_checker_func_t pMergeSource, void *pParam)
通過source的邏輯雙向鏈表,遍歷source插入target
ZEND_API void zend_hash_copy(HashTable *target, HashTable *source, copy_ctor_func_t pCopyConstructor, void *tmp, uint size)
哈希表的遍歷
ZEND_API int zend_hash_get_pointer(const HashTable *ht, HashPointer *ptr)
ZEND_API int zend_hash_set_pointer(HashTable *ht, const HashPointer *ptr)
ZEND_API void zend_hash_internal_pointer_reset_ex(HashTable *ht, HashPosition *pos)
ZEND_API void zend_hash_internal_pointer_end_ex(HashTable *ht, HashPosition *pos)
ZEND_API int zend_hash_move_forward_ex(HashTable *ht, HashPosition *pos)
ZEND_API int zend_hash_move_backwards_ex(HashTable *ht, HashPosition *pos)
數組操作函數reset(), each(), current(), next()會用這些函數來實現。
比較,排序
ZEND_API int zend_hash_sort ( HashTable * ht , sort_func_t sort_func , compare_func_t compar , int renumber TSRMLS_DC )
ZEND_API int zend_hash_minmax (const HashTable * ht , compare_func_t compar , int flag , void ** pData TSRMLS_DC )
ZEND_API int zend_hash_compare ( HashTable * ht1 , HashTable * ht2 , compare_func_t compar , zend_bool ordered TSRMLS_DC )
哈希表有一套排序算法。sort(), asort(), resort(), arsort(), ksort(), krsort()詳細請見: http://php.net/manual/en/array.sorting.php
5. 哈希衝突(Hashtable Collisions)
因爲任何一個哈希表的長度都是有限制的,所以一定會發生鍵名不同,hash函數計算後得到相同的bucket位置。也就是key1 != key2,但是HASH(key1) = HASH(key2)。如下圖2,在發生哈希衝突時(Hash Collision),最壞情況下,所有的鍵名全部衝突,哈希表會退化成雙向鏈表,操作時間複雜度爲O(n)。
當發生了哈希衝突,會把當前bucket插入到哈希值所在鏈表的第一位,並插入HashTable的邏輯鏈表。
6. 哈希碰撞攻擊及解決
在去年發現了PHP的哈希碰撞攻擊漏洞,PHP5.3.9以下的版本都會受影響。我們在業務壓力很重的情況下,還是最短時間內把運營服務器全部更新到5.3.13以上,防止通過PHP的哈希碰撞進行拒絕服務攻擊。
如何哈希碰撞攻擊呢?運用哈希衝突。在我們對PHP哈希算法足夠了解以後,通過精心構造,可以讓PHP的哈希表全部衝突,退化成鏈表,每插入元素時候,PHP都要遍歷一遍鏈表,消耗大量的CPU,造成拒絕服務攻擊。最簡單的方法是利用掩碼規律製造碰撞,我們知道HashTable的長度nTableSize會被圓整爲2的整數次冪,假設我們構造一個長度爲2^16的哈希表,nTableSize的二進制表示爲:1 0000 0000 0000 0000,而nTableMask = nTableSize – 1爲:0 1111 1111 1111 1111。這樣我們只要保證後16位均爲0,則與掩碼與運算後得到的哈希值全部碰撞在位置0。
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
。。。
以下這個例子就是這個原理的實現,插入65535個數據需要消耗30秒,而正常情況下僅需要0.01秒。
<? php
echo '
';
$size = pow(2, 16); // 16 is just an example, could also be 15 or 17
$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = ($size - 1) * $size; $key <= $maxKey; $key += $size) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo 'Inserting ', $size, ' evil elements took ', $endTime - $startTime, ' seconds', "\n";
$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = $size - 1; $key <= $maxKey; ++$key) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo 'Inserting ', $size, ' good elements took ', $endTime - $startTime, ' seconds', "\n";
?>
結果是
Inserting 65536 evil elements took 32.726480007172 seconds
Inserting 65536 good elements took 0.014460802078247 seconds
文章來源:http://nikic.github.io/2011/12/28/Supercolliding-a-PHP-array.html
對於哈希碰撞攻擊有2中常見形式:通過POST攻擊或通過反序列化攻擊。PHP會自動把HTTP包中POST的數據解析成數組$_POST,如果我們構造一個無限大的哈希衝突的值,可以造成拒絕服務攻擊。
PHP5.3.9+是通過增加一個限制來儘量避免被此類攻擊影響:
- max_input_vars - 指定 GET/POST/COOKIE 的最大輸入變量數。默認是1000。
反序列化同樣是利用數組的哈希衝突,如果POST的數據有字段爲數組serialize後的值,或數組json_encode後的值,在unserialize或json_decode後,會有可能造成哈希碰撞攻擊。解決方法,儘量避免在公網上以數組的序列化形式傳遞數據,如果不可避免,請使用私有協議(TLV)增加供給難度,或使用加密協議(HTTPS)防止中間人攻擊。
7. 總結
PHP的哈希表採用times33的哈希算法,通過HashTable數據結構維護Buckets,當有哈希衝突的時候,會將元素插入到該Buckets前形成雙向鏈表。同時爲了方便遍歷,HashTable也會維護邏輯雙向鏈表(按照插入順序),通過內部遊標指針可以遍歷Hashtable。PHP的變量符號表、常量符號表和函數都是用哈希表維護,PHP的數組類型變量也是通過哈希表維護。
哈希表容易遭到哈希碰撞攻擊,請更新PHP版本到5.3.9以上,可以解決POST數據的攻擊問題;反序列化(把序列化字符串還原爲Array)的哈希碰撞攻擊,到目前位置PHP官方還沒有徹底解決這個問題,請儘量避免用戶篡改數據和中間人攻擊。