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