:字符串逃逸與繞過技巧)
Burst Lab | PHP 審計 14反序列化四字符串逃逸與繞過技巧前言一、先回顧前三篇本篇要解決什么問題二、源碼閱讀PHP 序列化字符串的結(jié)構(gòu)邊界三、基礎(chǔ)語法長度按字節(jié)計算1. 英文字符串2. UTF-8 字符串四、為什么推薦直接使用 serialize()五、字符串邊界探針觀察解析器實際讀取范圍六、字符串邊界問題的審計判斷標準1. 輸入是否進入序列化結(jié)構(gòu)2. 長度是否在最后一步計算3. 是否存在二次替換4. 是否檢查了解析結(jié)果七、__wakeup() 的觸發(fā)條件八、__serialize() 與 __unserialize() 的版本差異九、對象聲明屬性數(shù)與實際屬性數(shù)十、從源碼到運行結(jié)果的審計流程十一、常見誤區(qū)誤區(qū) 1序列化字符串是加密內(nèi)容誤區(qū) 2長度按字符數(shù)計算誤區(qū) 3只要使用 strlen() 就一定安全誤區(qū) 4看到 __wakeup() 就直接下結(jié)論誤區(qū) 5屬性數(shù)量異常在所有 PHP 版本都一樣誤區(qū) 6allowed_classes 可以解決所有反序列化問題十二、開發(fā)側(cè)防御方案1. 外部數(shù)據(jù)優(yōu)先使用 JSON2. 必須反序列化時限制類3. 使用 HMAC 校驗完整性4. 不要手工拼接序列化字符串5. 魔術(shù)方法保持最小副作用十三、本篇總結(jié)免責聲明上一篇前言在前面三篇文章中我們已經(jīng)完成了三個階段的學習第 11 篇理解 PHP 序列化字符串的基本格式第 12 篇分析__wakeup()、__destruct()等魔術(shù)方法的觸發(fā)時機第 13 篇從對象屬性關(guān)系出發(fā)建立安全的 POP Chain 調(diào)用圖。本篇繼續(xù)研究一個經(jīng)常出現(xiàn)在代碼審計中的問題當業(yè)務(wù)代碼手工拼接序列化字符串時字符串長度和結(jié)構(gòu)邊界會發(fā)生什么變化本文重點討論PHP 序列化字符串長度為什么按字節(jié)計算手工拼接序列化數(shù)據(jù)為什么容易出現(xiàn)邊界錯誤字符串邊界變化如何影響后續(xù)字段解析__wakeup()的觸發(fā)條件與 PHP 版本特性差異如何從源碼閱讀、版本復現(xiàn)和開發(fā)防御三個角度分析問題。??免責聲明本文僅用于網(wǎng)絡(luò)安全學習、CTF 競賽、靶場練習以及獲得書面授權(quán)的代碼審計。本文示例只使用固定字符串、狀態(tài)標記和解析結(jié)果進行本地驗證不提供針對真實系統(tǒng)的可直接利用內(nèi)容。本機運行環(huán)境本文使用實際版本為PHP 7.3.4 NTS x64Zend Engine 3.3.4。文中“本機實測結(jié)果”均來自該環(huán)境。一、先回顧前三篇本篇要解決什么問題第 11 篇中我們看到字符串格式類似s:5:admin第 12 篇中我們知道對象被unserialize()還原后可能自動進入__wakeup() __unserialize() __destruct()第 13 篇中我們進一步把多個對象通過屬性連接起來觀察方法之間的數(shù)據(jù)流。本篇把這幾部分連起來但不直接討論第三方系統(tǒng)利用而是研究解析器的輸入邊界外部數(shù)據(jù) ↓ 手工拼接序列化字符串 ↓ 字符串長度決定讀取范圍 ↓ unserialize() 按結(jié)構(gòu)解析 ↓ 對象恢復和魔術(shù)方法觸發(fā)因此審計時不能只看到unserialize($data);還要繼續(xù)向上追蹤$data是如何生成的特別是它是否經(jīng)過了字符串拼接、替換、編碼和長度計算。二、源碼閱讀PHP 序列化字符串的結(jié)構(gòu)邊界PHP 序列化結(jié)果不是普通的文本格式而是由類型標記、長度字段和數(shù)據(jù)內(nèi)容共同組成的結(jié)構(gòu)化字符串。例如s:5:admin可以拆成片段含義sString字符串類型5字符串占用的字節(jié)數(shù)admin字符串內(nèi)容反序列化時PHP 會根據(jù)5讀取后面的 5 個字節(jié)而不是只依賴下一個雙引號判斷字符串結(jié)束位置。這意味著下面兩部分必須一致聲明長度5 實際字節(jié)數(shù)5如果業(yè)務(wù)代碼手工構(gòu)造類似內(nèi)容$serializeda:2:{.s:4:note;s:.strlen($input).:.$input.;.s:4:role;s:5:guest;.};那么$input就進入了序列化語法內(nèi)部。此時必須重點檢查長度是否由 PHP 正確計算輸入是否經(jīng)過了編碼轉(zhuǎn)換輸入中的引號和分號是否會影響后續(xù)結(jié)構(gòu)拼接完成后是否真正執(zhí)行過unserialize()驗證。三、基礎(chǔ)語法長度按字節(jié)計算1. 英文字符串s:5:adminadmin占用 5 個字節(jié)所以長度為 5。2. UTF-8 字符串中文在 UTF-8 編碼下一個字符通常占用多個字節(jié)。PHP 序列化記錄的是字節(jié)長度而不是人眼看到的字符數(shù)量。配套代碼01_length_bytes.php?phpclassDemo{public$nameadmin;public$age18;}$objnewDemo();$asciiserialize($obj);echo[ASCII] ,$ascii,PHP_EOL;echo[ASCII length] ,strlen($ascii),PHP_EOL;$obj-name中文;$utf8serialize($obj);echo[UTF-8] ,$utf8,PHP_EOL;echo[name bytes] ,strlen($obj-name),PHP_EOL;preg_match_all(/./us,$obj-name,$matches);echo[name chars] ,count($matches[0]),PHP_EOL;本機運行結(jié)果[ASCII] O:4:Demo:2:{s:4:name;s:5:admin;s:3:age;i:18;} [ASCII length] 53 [UTF-8] O:4:Demo:2:{s:4:name;s:6:中文;s:3:age;i:18;} [name bytes] 6 [name chars] 2可以看到中文2 個字符 UTF-86 個字節(jié) 序列化片段s:6:中文這也是手工分析序列化數(shù)據(jù)時非常容易忽略的地方。四、為什么推薦直接使用serialize()如果直接對 PHP 變量調(diào)用serialize()長度由 PHP 自動計算$record[note$input,roleguest,];$serializedserialize($record);這種方式不會要求開發(fā)者手動填寫s:長度:內(nèi)容相反下面這種拼接方式維護成本較高$serializeda:2:{.s:4:note;s:.strlen($input).:.$input.;.s:4:role;s:5:guest;.};它至少存在三個審計風險輸入長度必須和實際編碼保持一致輸入內(nèi)容可能改變解析邊界后續(xù)開發(fā)者修改固定字段時容易破壞整體格式。因此業(yè)務(wù)代碼不應(yīng)該把序列化字符串當成普通模板拼接。需要序列化時應(yīng)讓 PHP 負責完整編碼并在解析前進行完整性校驗。五、字符串邊界探針觀察解析器實際讀取范圍下面的示例故意使用手工拼接只用于觀察邊界不代表推薦寫法。?phpfunctionbuildManualRecord($note){returna:2:{.s:4:note;s:.strlen($note).:.$note.;.s:4:role;s:5:guest;.};}普通輸入hello會生成a:2:{s:4:note;s:5:hello;s:4:role;s:5:guest;}此時note的長度為 5后面的role字段可以正常解析。配套代碼03_boundary_probe.php會分別觀察普通輸入和包含序列化片段的邊界探針。本機運行結(jié)果[safe] a:2:{s:4:note;s:5:hello;s:4:role;s:5:guest;} [safe result] array(2) { [note] string(5) hello [role] string(5) guest } [boundary-probe] a:2:{s:4:note;s:30:hello;s:4:role;s:5:admin;;s:4:role;s:5:guest;} [boundary-probe result] array(2) { [note] string(30) hello;s:4:role;s:5:admin; [role] string(5) guest }這個結(jié)果說明前面的內(nèi)容被當成了note字符串的一部分后面的字段仍然按照解析器讀完指定長度后的位置繼續(xù)處理。這里要強調(diào)兩點這不是普通字符串替換而是結(jié)構(gòu)化數(shù)據(jù)邊界的變化如果長度、引號和分號組合不完整unserialize()也可能返回false或產(chǎn)生警告。代碼審計時應(yīng)把這類問題記錄為“手工序列化格式構(gòu)造風險”而不是只檢查某一個關(guān)鍵詞。六、字符串邊界問題的審計判斷標準遇到類似代碼可以按下面的順序分析1. 輸入是否進入序列化結(jié)構(gòu)$input$_POST[note];$raw...s:.strlen($input).:.$input....;如果外部輸入直接進入結(jié)構(gòu)化字符串需要繼續(xù)檢查編碼和長度。2. 長度是否在最后一步計算推薦$rawserialize([note$input]);風險較高$raws:.strlen($input).:.$input.;即使使用了strlen()也要確認中間沒有發(fā)生 URL 解碼、字符集轉(zhuǎn)換或其他內(nèi)容替換。3. 是否存在二次替換例如$rawserialize($data);$rawstr_replace(guest,$input,$raw);這種邏輯可能讓原本已經(jīng)正確的長度字段失效。序列化完成后不建議再對結(jié)構(gòu)化字符串進行業(yè)務(wù)替換。4. 是否檢查了解析結(jié)果$valueunserialize($raw);if($valuefalse){exit(invalid serialized data);}需要注意false本身也可以是合法的序列化值所以更穩(wěn)妥的寫法是配合格式來源、類型檢查和業(yè)務(wù)字段檢查。七、__wakeup()的觸發(fā)條件第 12 篇已經(jīng)介紹過__wakeup()通常會在對象被unserialize()還原后自動調(diào)用。配套代碼02_wakeup_order.php?phpclassWakeupTrace{public$stateoriginal;publicfunction__wakeup(){echo[trace] __wakeup() called,PHP_EOL;$this-staterestored;}}$objectnewWakeupTrace();$serializedserialize($object);echo[1] before unserialize: ,$serialized,PHP_EOL;$restoredunserialize($serialized);echo[2] after unserialize: state,$restored-state,PHP_EOL;本機運行結(jié)果[1] before unserialize: O:11:WakeupTrace:1:{s:5:state;s:8:original;} [trace] __wakeup() called [2] after unserialize: staterestored執(zhí)行順序讀取序列化結(jié)構(gòu) ↓ 恢復對象屬性 ↓ 調(diào)用 __wakeup() ↓ 繼續(xù)后續(xù)業(yè)務(wù)邏輯審計時要繼續(xù)追蹤__wakeup()修改了哪些屬性以及這些屬性是否會被后續(xù)方法讀取。八、__serialize()與__unserialize()的版本差異PHP 7.4.0 引入了新的對象序列化接口__serialize()__unserialize()當類實現(xiàn)了這套新機制時PHP 7.4 及以上版本會優(yōu)先使用它們處理對象序列化和還原舊版本主要使用 public/protected/private 屬性和__wakeup()等舊機制。配套代碼06_version_hooks.php同時定義了三種方法?phpclassVersionHooks{public$stateoriginal;publicfunction__serialize(){echo[trace] __serialize() called,PHP_EOL;return[state$this-state];}publicfunction__unserialize($data){echo[trace] __unserialize() called,PHP_EOL;$this-state$data[state];}publicfunction__wakeup(){echo[trace] __wakeup() called,PHP_EOL;$this-statewakeup-called;}}在本機 PHP 7.3.4 中實際輸出為[serialized] O:12:VersionHooks:1:{s:5:state;s:8:original;} [trace] __wakeup() called [state] wakeup-called本機結(jié)果說明PHP 7.3.4 沒有把__serialize()和__unserialize()當作新的序列化協(xié)議入口而是繼續(xù)使用舊的屬性恢復和__wakeup()流程。版本分析應(yīng)記錄為行為點PHP 7.3.4 本機實測PHP 7.4 及以上復測重點serialize()處理新式方法未調(diào)用__serialize()檢查是否進入__serialize()unserialize()處理新式方法調(diào)用__wakeup()檢查是否優(yōu)先進入__unserialize()屬性數(shù)量異常數(shù)據(jù)本機樣例返回false需要在目標版本單獨復測這里不能簡單寫成“某個大版本全部可用或全部不可用”。實際結(jié)果還會受到 PHP 小版本、SAPI、錯誤處理設(shè)置、類定義和序列化數(shù)據(jù)結(jié)構(gòu)影響。九、對象聲明屬性數(shù)與實際屬性數(shù)對象序列化格式中類名后面的數(shù)字表示屬性數(shù)量O:10:CountTrace:1:{...}這里的1是屬性數(shù)量字段。配套代碼04_property_count_observation.php使用兩個觀察樣本聲明數(shù)量為 1實際提供 1 個屬性 聲明數(shù)量為 2實際仍提供 1 個屬性本機 PHP 7.3.4 輸出 declared-1 [trace] __wakeup() called result state: wakeup-called declared-2 result: false當前環(huán)境下數(shù)量為 1 的樣本成功還原并觸發(fā)__wakeup()數(shù)量為 2 的樣本直接解析失敗。這個結(jié)果不能被擴展成跨版本結(jié)論。審計報告中應(yīng)該寫清楚PHP 版本7.3.4 SAPICLI 輸入結(jié)構(gòu)對象聲明屬性數(shù)與實際屬性數(shù)不一致 實際結(jié)果unserialize() 返回 false如果在其他 PHP 版本中觀察到不同結(jié)果應(yīng)把它記錄為版本特性差異并在等價環(huán)境中復現(xiàn)而不是直接套用網(wǎng)上的歷史結(jié)論。十、從源碼到運行結(jié)果的審計流程第一步定位反序列化入口搜索unserialize($data)并向前追蹤$data的來源$_GET、$_POST、$_COOKIE請求頭或上傳內(nèi)容數(shù)據(jù)庫、緩存和 SessionBase64、URL 編碼或壓縮數(shù)據(jù)解碼結(jié)果。第二步確認序列化數(shù)據(jù)的生成方式區(qū)分$rawserialize($value);和$raw...s:.strlen($input).:.$input....;后者需要重點檢查邊界、編碼和字段數(shù)量。第三步追蹤魔術(shù)方法檢查目標類中是否存在__wakeup()__unserialize()__destruct()__toString()__get()__call()重點記錄誰觸發(fā)方法 方法讀取哪個屬性 屬性是否可被輸入影響 方法是否改變后續(xù)流程第四步確認版本和運行方式至少記錄PHP 具體版本 SAPI 類型 操作系統(tǒng) 相關(guān)擴展 錯誤處理設(shè)置第五步建立最小復現(xiàn)樣本不要一開始就分析完整項目。先保留目標類和相關(guān)方法使用固定字符串、狀態(tài)變量和日志輸出還原問題。十一、常見誤區(qū)誤區(qū) 1序列化字符串是加密內(nèi)容錯誤。序列化只是編碼和結(jié)構(gòu)化不提供保密性也不自動提供完整性校驗。誤區(qū) 2長度按字符數(shù)計算錯誤。PHP 序列化字符串記錄的是字節(jié)長度。UTF-8 中文需要特別注意。誤區(qū) 3只要使用strlen()就一定安全不一定。如果后續(xù)發(fā)生 URL 解碼、字符集轉(zhuǎn)換或字符串替換之前計算的長度仍可能失效。誤區(qū) 4看到__wakeup()就直接下結(jié)論錯誤。要確認對象是否成功還原、方法是否觸發(fā)、當前 PHP 版本如何處理以及方法是否影響后續(xù)業(yè)務(wù)。誤區(qū) 5屬性數(shù)量異常在所有 PHP 版本都一樣錯誤。對象解析行為屬于版本相關(guān)實現(xiàn)細節(jié)必須在目標版本或等價環(huán)境中復測。誤區(qū) 6allowed_classes可以解決所有反序列化問題錯誤。它主要限制允許還原的類不能替代簽名校驗、類型檢查和業(yè)務(wù)校驗。十二、開發(fā)側(cè)防御方案1. 外部數(shù)據(jù)優(yōu)先使用 JSON如果業(yè)務(wù)只是傳遞數(shù)組或狀態(tài)優(yōu)先使用 JSON$datajson_decode($input,true,512,JSON_THROW_ON_ERROR);if(!is_array($data)||!isset($data[name])||!is_string($data[name])){thrownewInvalidArgumentException(invalid data);}JSON 不會根據(jù)輸入內(nèi)容自動恢復任意 PHP 對象但字段類型和業(yè)務(wù)含義仍然需要校驗。2. 必須反序列化時限制類$valueunserialize($input,[allowed_classes[SafeRecord],max_depth32,]);限制類只能縮小對象恢復范圍不能替代完整性驗證。3. 使用 HMAC 校驗完整性$expectedhash_hmac(sha256,$input,$secret);if(!hash_equals($expected,$providedSign)){thrownewRuntimeException(invalid signature);}簽名密鑰必須保存在服務(wù)端不能由客戶端提交。4. 不要手工拼接序列化字符串錯誤示例$raws:.strlen($input).:.$input.;推薦$rawserialize([value$input]);5. 魔術(shù)方法保持最小副作用__wakeup()、__unserialize()和__destruct()應(yīng)盡量只做狀態(tài)恢復和資源清理不要在其中直接處理外部輸入、動態(tài)路徑、動態(tài)回調(diào)或高風險操作。十三、本篇總結(jié)字符串邊界問題可以概括為輸入進入手工序列化結(jié)構(gòu) ↓ 長度或編碼計算出現(xiàn)偏差 ↓ 解析器讀取范圍發(fā)生變化 ↓ 后續(xù)字段可能被誤讀或解析失敗本篇重點PHP 序列化字符串的長度按字節(jié)計算手工拼接序列化文本容易造成長度和邊界錯誤unserialize()成功還原對象后可能觸發(fā)__wakeup()或__unserialize()對象聲明屬性數(shù)與實際屬性數(shù)不一致時結(jié)果必須結(jié)合具體 PHP 版本觀察PHP 7.4 之后需要額外關(guān)注__serialize()和__unserialize()審計結(jié)論應(yīng)包含版本、SAPI、輸入結(jié)構(gòu)和實測輸出開發(fā)側(cè)優(yōu)先使用 JSON配合類限制、簽名校驗和業(yè)務(wù)字段檢查。下一篇《PHP 審計 15反序列化五Phar 反序列化與原生類利用》將繼續(xù)討論 PHP 原生類、特殊數(shù)據(jù)格式和反序列化入口的審計方法。免責聲明本文所有內(nèi)容僅限授權(quán)靶場、本地私有實驗環(huán)境學習研究任何未經(jīng)授權(quán)對第三方系統(tǒng)開展測試屬于違法行為。請在合法授權(quán)范圍內(nèi)進行安全研發(fā)與驗證。上一篇Burst Lab | PHP 審計 13反序列化三POP 鏈構(gòu)造與 Gadget Chain 分析