php內核之內核利器哈希表與哈希碰撞攻擊

在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&nbsp;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,&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 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);&nbsp;
ZEND_API&nbsp;int&nbsp;zend_hash_exists(const&nbsp;HashTable&nbsp;*ht,&nbsp;const&nbsp;char&nbsp;*arKey,&nbsp;uint&nbsp;nKeyLength)
ZEND_API ulong zend_get_hash_value(const char *arKey, uint nKeyLength)

ZEND_API&nbsp;void&nbsp;zend_hash_merge_ex(HashTable&nbsp;*target,&nbsp;HashTable&nbsp;*source,&nbsp;copy_ctor_func_t&nbsp;pCopyConstructor,&nbsp;uint&nbsp;size,&nbsp;merge_checker_func_t&nbsp;pMergeSource,&nbsp;void&nbsp;*pParam)
通過source的邏輯雙向鏈表,遍歷source插入target
ZEND_API&nbsp;void&nbsp;zend_hash_copy(HashTable&nbsp;*target,&nbsp;HashTable&nbsp;*source,&nbsp;copy_ctor_func_t&nbsp;pCopyConstructor,&nbsp;void&nbsp;*tmp,&nbsp;uint&nbsp;size)

哈希表的遍歷

ZEND_API&nbsp;int&nbsp;zend_hash_get_pointer(const&nbsp;HashTable&nbsp;*ht,&nbsp;HashPointer&nbsp;*ptr)
ZEND_API&nbsp;int&nbsp;zend_hash_set_pointer(HashTable&nbsp;*ht,&nbsp;const&nbsp;HashPointer&nbsp;*ptr)
ZEND_API&nbsp;void&nbsp;zend_hash_internal_pointer_reset_ex(HashTable&nbsp;*ht,&nbsp;HashPosition&nbsp;*pos)
ZEND_API&nbsp;void&nbsp;zend_hash_internal_pointer_end_ex(HashTable&nbsp;*ht,&nbsp;HashPosition&nbsp;*pos)
ZEND_API&nbsp;int&nbsp;zend_hash_move_forward_ex(HashTable&nbsp;*ht,&nbsp;HashPosition&nbsp;*pos)
ZEND_API&nbsp;int&nbsp;zend_hash_move_backwards_ex(HashTable&nbsp;*ht,&nbsp;HashPosition&nbsp;*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&nbsp;
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官方還沒有徹底解決這個問題,請儘量避免用戶篡改數據和中間人攻擊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章