PHP核心層解析反序列化漏洞
0x00 前言
在學習PHP的過程中發現有些PHP特性的東西不好理解,如PHP中的00截斷,MD5缺陷,反序列化繞過__wakeup等等。本人不想拘泥於表面現象的理解,想探究PHP核心到底是怎樣做到的。
下面將用CTF中常用的一個反序列化漏洞CVE-2016-7124(繞過魔法函式__wakeup)為例,將此次除錯PHP核心的過程分享出來。包括從核心原始碼除錯環境的搭建,序列化與反序列化核心原始碼分析到最後的漏洞分析整個部分。
0x01 一個例子引發的思考
我們可以首先看本人寫的小例子。
根據上圖我們先介紹下PHP中的魔法函式:
我們先看下官方文件對幾個常用魔法函式的介紹:
這裡稍作總結,當一個類被初始化為例項時會呼叫__construct,當被銷燬時會呼叫__destruct。
當一個類呼叫serialize進行序列化時會自動呼叫__sleep函式,當字串要利用unserialize反序列化成一個類時會呼叫__wakeup函式。上述魔法函式如果存在都將會自動進行呼叫。不用自己手動進行顯示呼叫。
現在我們來看最開始的程式碼部分,在__destruct函式中有寫入檔案的敏感操作。我們這裡利用反序列化構造危險的字串有可能會造成程式碼執行漏洞。
當我們構造好相應的字串準備進行利用時,我們卻發現它的__wakeup函式中有過濾操作,這就給我們的構造造成了阻礙。因為我們知道反序列化無論如何都是要先呼叫__wakeup函式的。
這裡我們不禁想到了利用這個PHP反序列化漏洞CVE-2016-7124(繞過魔法函式__wakeup),輕鬆繞過反序列化會自動呼叫的魔法函式___wakeup,把敏感操作寫入進了檔案。
當然,上面的程式碼只是我個人舉得一個簡單例子,真實情況中不乏有上述情況的出現。但是這種繞過方法卻使我非常感興趣。PHP的內部到底是如何操作和處理才會影響到上層程式碼邏輯出現如此神奇的情況(BUG)。接下來本人將對PHP核心進行動態除錯分析。探究此問題。
此漏洞(CVE-2016-7124)受影響版本PHP5系列為5.6.25之前,7.x系列為7.0.10之前。所以我們後面會編譯兩個版本:一為不受此漏洞影響的版本7.3.0,另一個版本為漏洞存在的版本5.6.10。通過兩個版本的對比來更詳細的瞭解其差異。
0x02 PHP原始碼除錯環境搭建
我們都知道PHP是由C語言開發,因本人所使用環境為WIN 10,所以主要介紹Windows下的環境搭建。我們需要如下材料:
PHP原始碼。
PHP SDK工具包,用於構建PHP
除錯所需要IDE。
原始碼可在GITHUB上下載,連結:https://github.com/php/php-src,可以選擇所需要的版本進行下載。
PHPSDK的工具包下載地址:https://github.com/Microsoft/php-sdk-binary-tools 這個地址所下載的工具包只支援VC14,VC15。當然你也可以從https://windows.php.net/downloads/找到支援PHP低版本的VC11,VC12等,在使用PHP SDK之前必須保證你有安裝對應版本Windows SDK元件的VS。
後文中會使用PHP7.3.0和5.6.10,下面會介紹這兩個版本的原始碼編譯,其他版本手法類似。
2.1 編譯Windows PHP 7.3.0
本機環境WIN10 X64,PHP SDK是在上述github連結上下載。進入SDK目錄,發現4個批處理檔案,這裡雙擊phpsdk-vc15-x64。
接著在此shell中輸入 phpsdk_buildtree php7,會發現同目錄下出現了php7資料夾,並且shell目錄也發生了變化。
接著我們把解壓後的原始碼放在\php7\vc15\x64下,shell進入此資料夾內,利用phpsdk_deps --update --branch master 命令更新下載相關依賴元件。
等待完成後,進入原始碼目錄下雙擊buildconf.bat批處理檔案,它會釋放configure.bat和configure.js兩個檔案,在shell中執行configure --disable-all --enable-cli --enable-debug --enable-phar 配置相應的編譯選項,如還有別的需求,可執行 configure --help 檢視
根據提示,直接使用nmake進行編譯。
編譯完成,可執行檔案目錄在php7\vc15\x64\php-src\x64\Debug_TS資料夾下。我們可輸入php -v檢視相關資訊。
2.2 編譯Windows PHP 5.6.10
方法跟7.3.0 相同,只需注意的是PHP5.6使用Windows SDK元件版本為VC11,需要下載VS2012,並且不能使用github上下載的PHP SDK進行編譯,需要在https://windows.php.net/downloads/上選擇VC11 的PHP SDK和相關依賴元件進行編譯,其餘和上述完全相同,這裡不再重複。
2.3 除錯配置
因為我們上述已經編譯好了PHP直譯器,我們這裡直接使用VSCODE來進行除錯。
下載完成後安裝C/C++除錯擴充套件。
接著開啟原始碼目錄,點選除錯--->開啟配置,會開啟launch.json檔案。
根據上圖,配置好這三個引數後,可在當前目錄下1.php中寫PHP程式碼,在PHP原始碼中下斷點直接進行除錯。
除錯環境搭建完成。
0x03 PHP反序列化原始碼解析
一般提及PHP反序列化,往往就是serialize和unserialize兩個成對出現的函式,當然必不可少的還有__sleep()和__wakeup()這兩個魔術方法。眾所周知,序列化簡單點來說就是物件存檔案,反序列化剛好相反,從檔案中把物件讀取出來並例項化。
下面,我們根據上面搭好的除錯環境,通過動態除錯的手法來直觀的反應PHP(7.3.0版本)中序列化與反序列化到底幹了哪些事情。
3.1 serialize原始碼分析
我們先寫個不含有__sleep魔法函式的簡單Demo:
接著我們在原始碼中全域性搜尋serialize函式,定位此函式是在var.c檔案中。我們直接在函式頭下斷點,並啟動除錯。
我們可見在做了一些準備工作後,開始進入序列化處理函式,我們跟進php_var_serialize函式。
我們這裡繼續跟進php_var_serialize_intern函式,下面就是主要處理函數了,因為函式程式碼比較多,我們這裡只截出關鍵部分,此函式還在var.c檔案中。
整個函式的結構是switch case,通過巨集Z_TYPE_P解析struc變體的型別(此巨集展開為struc->u1.v.type),來判斷要序列化的型別,從而進入相應的CASE部分進行操作。下圖為型別定義。
根據上圖紅框中的數字8,我們可知此時需要要序列化為一個物件IS_OBJECT,進入相應的CASE分支
我們在上圖中看到了魔法函式__sleep的呼叫時機,因為我們寫的Demo中並沒有此函式,所以流程並不會進入此分支。不同的分支代表不同的處理流程,我們稍後再看帶有魔法函式__sleep的流程。
因上面case IS_OBJECT分支中沒有流程命中,case中又沒有break語句,繼續執行進入IS_ARRAY分支,在這裡從struc結構中提取出類名,計算其長度並賦值到buf結構中,並提取出類中要序列化的結構存入雜湊陣列中。
接下來就是利用php_var_serialize_intern函式遞迴解析整個雜湊陣列的過程,從中分別提取出變數名和值進行格式解析並將解析完成的字串拼接到buf結構中。最後當整個過程結束後,整個字串講完全存進柔性陣列結構buf中。
從上圖紅框中可看出跟最終結果是相吻合的。我們接下來稍微修改下Demo,新增魔法函式__sleep,根據官方文件中描述,__sleep函式必須返回一個數組。我們並在該函式中呼叫了一個類的成員函式。觀察其具體行為。
前面流程完全相同,此處不再重複,我們從分支點開始看。
我們直接跟進php_var_serialize_call_sleep函式。
我們這裡繼續跟進call_user_function,根據巨集定義,它實際上是呼叫了_call_user_function_ex函式,在這裡做了一些拷貝動作,故不做截圖,流程接下來進入zend_call_function函式的呼叫。
函式zend_call_function中,實際情況下,在__sleep中需要做一些我們自己的事情,這裡PHP將要做的操作壓入PHP自己的zend_vm引擎堆疊中,稍後會進行一條條解析(就是解析相應的OPCODE)。
這裡流程會命中此分支,我們跟進zend_execute_ex函式。
我們這裡可以看到在ZEND_VM中,整體體處理流程為while(1)迴圈,不斷解析ZEND_VM棧中的操作。上圖紅框中ZEND_VM引擎會利用ZEND_FASTCALL方式派發到到相應的處理函式。
因為我們在__sleep中呼叫了成員函式show,這裡首先定位出了show,接著會將接下來的操作繼續壓入ZEND_VM堆疊中進行下一輪新的解析(這裡是處理show中的操作),直到解析完整個操作為止。我們這裡不再繼續跟進。
還記得上面的傳出引數retval麼,也就是__sleep的返回值,上圖為返回陣列的第一個元素x,當然你也可以從變數中直接檢視。
繞了這麼大一圈,殊途同歸,在處理完_sleep函式中的一系列操作之後,接下來用php_var_serialize_class函式來序列化類名,遞迴序列化其_sleep函式返回值中的結構。最終都把結果存在了buf結構中。至此序列化的整個流程完畢。
3.1.1 serialize流程小結
我們總結下序列化的流程:
當沒有魔法函式時:序列化類名-->利用遞迴序列化剩下的結構。
當存在魔法函式時:呼叫魔法函式__sleep-->利用ZEND_VM引擎解析PHP操作—>返回需要序列化結構的陣列-->序列化類名-->利用遞迴序列化__sleep的返回值結構。
3.2 unserialize原始碼分析
看完serialize的流程,接下來,我們還是從最簡單的一個Demo來看unserialize流程。此例子不含魔法函式。
方法跟上面相同,unserialize原始碼也在var.c檔案中。
上圖中涉及到了PHP7中的新特性,帶過濾的反序列化,根據allowed_classes的設定情況來過濾相應的PHP物件,防止非法資料注入。被過濾的物件會被轉化成__PHP_Incomplete_Class物件不能被直接使用,但是這裡對反序列化流程沒有影響,這裡不做詳細探討。我們跟進php_var_unserialize函式。
我們這裡繼續跟入php_var_unserialize_internal函式。
此函式內部主要操作流程為對字串進行解析,然後跳轉到相應的處理流程。上圖中解析出第一個字母0,代表此次反序列化為一個物件。
這裡首先會解析出物件名字,並進行查表操作確定此物件確實存在,我們繼續向下看。
上述操作做完之後,我們這裡根據物件名稱new出了自己新的物件並進行了初始化,但是我們的反序列化操作還是沒有完成,我們跟進object_common2函式。
在這裡我們看到了對魔法函式的判斷與檢測,但是呼叫部分並不在此。我們繼續跟進process_nested_data函式。
看來這個函式利用WHILE迴圈來巢狀解析剩餘的部分了,·其中包含兩個php_var_unserialize_internal函式,第一個會解析名稱,第二個是解析名稱所對應的值。process_nested_data函式執行完畢後,字串解析完畢,反序列化操作主要內容已經完成,流程即將進入尾聲了。
逐層返回至最初的函式PHP_FUNCTION中,我們看到就是一些掃尾工作了,釋放申請的空間,反序列化完畢。這裡並沒有呼叫到我們的魔法函式__wakeup。為了找出__wakeup的呼叫時機,我們這裡修改下Demo。
這裡開始新的一輪除錯。發現在序列化完成後,在PHP_VAR_UNSERIALIZE_DESTROY釋放空間處出現了我們所希望看到的呼叫。
還記得反序列化流程中當發現有__wakeup時對其進行的VAR_WAKEUP_FLAG標誌麼,在這裡當遍歷bar_dtor_hash陣列遇到這個標誌時,正式開啟對__wakeup呼叫,後期的呼叫手法和前面所介紹的__sleep呼叫手法完全相同,這裡不再做重複說明。至此,反序列化所有流程完畢。
3.2.1 un serialize流程小結
我們可以從上面可以看到,反序列化流程相對於序列化流程來說並沒有因為是否出現魔法函式來對流程造成分歧。Unserialize流程如下:
獲取反序列化字串-->根據型別進行反序列化—>查表找到對應的反序列化類-->根據字串判斷元素個數-->new出新例項-->迭代解析化剩下的字串-->判斷是否具有魔法函式__wakeup並標記—>釋放空間並判斷是否具有具有標記—>開啟呼叫。
0x04 PHP反序列化漏洞
有了上面原始碼基礎的鋪墊,我們現在再來探究漏洞CVE-2016-7124(繞過__wakeup)魔法函式。因此漏洞對版本有一定要求,我們使用上面編譯好的另一個PHP版本(5.6.10)來複現和除錯此漏洞。
首先我們進行一下漏洞復現:
我們這裡可以看到,TEST類中只包含一個元素$a,我們這裡在反序列化時當修改元素字串中代表元素個數的數值時,會觸發此漏洞,該類避過了魔法函式__wakeup的呼叫。
當然在觸發漏洞的過程中也發現了一個有趣的現象,觸發手段並不只有這一種.
上圖中4個payload所對應的反序列化操作都會觸發此漏洞。雖然說下方這四個都會觸發漏洞,但是其中還有一些微小的差別。這裡我們稍微修改下程式碼:
我們根據上圖可以看到,在反序列化的字串中,只要在解析類中的元素出現錯誤時,都會觸發此漏洞。但是更改類元素內部操作(如上圖的修改字串長度,類變數型別等)會導致類成員變數賦值失敗。只有修改類成員的個數(比原有成員個數大)時,才能保證類成員賦值時成功的。
我們下面通過除錯來看問題所在:
根據第三部分我們對反序列化原始碼的分析,猜測可能是在最後解析變數那裡出了問題。我們這裡直接上偵錯程式動態除錯下:
我們可以看到,與7.3.0版本的原始碼對比,此版本沒有過濾引數,且經過這麼多版本的迭代,低版本的處理過程現在看來也相對簡略。但是整體諧邏輯並沒有改變,我們這裡直接跟進php_var_unserialize函式,此後相同邏輯不再進行重複說明,我們直接跟到差異處(object_common2函式)也就是處理類中成員變數的程式碼。
在函式object_common2中,存在兩個主要操作,process_nested_data迭代解析類中的資料和魔法函式__wakeup的呼叫,且當process_nested_data函式解析失敗後,直接返回0值,後面的__wakeup函式將沒有呼叫的機會。
這裡就解釋了為何觸發漏洞不止一種payload。
當只修改類成員的個數時,while迴圈可以完成的進行一次,這使得我們類中成員變數能被完整的賦值。當修改成員變數內部時,pap_var_unserialize函式呼叫失敗,緊接著會呼叫zval_dtor 和FREE_ZVAL函式釋放當前key(變數)空間,導致類中的變數賦值失敗。
反觀在PHP7.3.0版本中此處並沒有出現呼叫過程,只是做了簡單的標記,整個魔法函式的呼叫過程的時機移至釋放資料處。這樣就避免了這個繞過的問題。此漏洞應該屬於邏輯上的缺陷導致的。
天融信阿爾法實驗室成立於2011年,一直以來,阿爾法實驗室秉承“攻防一體”的理念,匯聚眾多專業技術研究人員,從事攻防技術研究,在安全領域前瞻性技術研究方向上不斷前行。作為天融信的安全產品和服務支撐團隊,阿爾法實驗室精湛的專業技術水平、豐富的排異經驗,為天融信產品的研發和升級、承擔國家重大安全專案和客戶服務提供強有力的技術支撐。
天融信
阿爾法實驗室
長按二維碼關注我們