與工程防御:從CTF題目到代碼審計(jì))
1. 這題的“坑”其實(shí)埋在 PHP 的語(yǔ)言設(shè)計(jì)里“Make PHP Great Again” 這個(gè)標(biāo)題我第一次看到的時(shí)候第一反應(yīng)是這又是個(gè)用梗的比賽題。wmctf2020 里面這類(lèi)命名不少主辦方喜歡把一個(gè)看起來(lái)輕松的名字扣在一道并不輕松的代碼審計(jì)題上。等真正打開(kāi)環(huán)境才發(fā)現(xiàn)核心考點(diǎn)不是某個(gè)“神奇的繞過(guò)姿勢(shì)”而是 PHP 這門(mén)語(yǔ)言在過(guò)去十幾年里一直被人反復(fù)討論的結(jié)構(gòu)性特性類(lèi)型轉(zhuǎn)換、魔術(shù)方法、反序列化、動(dòng)態(tài)調(diào)用。先說(shuō)結(jié)論這道題所考察的核心場(chǎng)景基本可以收斂為“PHP 反序列化 正則過(guò)濾繞過(guò)”的組合。當(dāng)然網(wǎng)上能找到的原題源碼版本并不完全統(tǒng)一不同隊(duì)伍賽后還原出來(lái)也有細(xì)節(jié)出入。我這里更想講的是按主流寫(xiě)法還原出的等價(jià)最小用例以及從這個(gè)最小用例延伸開(kāi)去的排查方法、修復(fù)思路和工程防御手段。換句話(huà)說(shuō)就算你沒(méi)有親自參加過(guò)這幾場(chǎng)比賽只要把下面這套分析走通以后再遇到任何帶unserialize的代碼你都能快速建立自己的判斷框架。適合看這篇文章的人我覺(jué)得有三類(lèi)第一類(lèi)是剛開(kāi)始打 CTF 的選手尤其是對(duì) PHP 題型還沒(méi)形成體系的新人第二類(lèi)是很早就把 PHP 寫(xiě)成業(yè)務(wù)代碼、但很少做安全審計(jì)的開(kāi)發(fā)第三類(lèi)是正準(zhǔn)備梳理面試知識(shí)的人因?yàn)?PHP 反序列化、弱類(lèi)型比較、文件上傳校驗(yàn)這些點(diǎn)幾乎是面試?yán)锢@不開(kāi)的“基礎(chǔ)知識(shí)”。我自己復(fù)盤(pán)的時(shí)候最大的感受是很多人把時(shí)間花在了記“payload”上卻沒(méi)有先花時(shí)間理解 PHP 為什么會(huì)出現(xiàn)這些“奇奇怪怪”的行為。一旦理解了你會(huì)發(fā)現(xiàn)所謂的繞過(guò)并不是“運(yùn)氣好”而是 PHP 在解析輸入時(shí)的設(shè)計(jì)本就和很多樸素直覺(jué)不一致。這就是為什么我覺(jué)得“Make PHP Great Again”是個(gè)很妙的題目名它與其說(shuō)是在喊口號(hào)不如說(shuō)是在提醒你把 PHP 當(dāng)年那些讓你皺眉頭的東西一個(gè)一個(gè)重新?lián)炱饋?lái)認(rèn)真讀一遍。1.1 為什么 PHP 的考題總是圍繞語(yǔ)言特性寫(xiě) Java、Go 或者 Rust 的 CTF 題出題人更多是把考點(diǎn)放在框架漏洞、加密協(xié)議、邏輯漏洞上因?yàn)檎Z(yǔ)言本身幫你擋掉了很多誤用。PHP 不是這樣。PHP 的口號(hào)是“為 Web 而生”它的函數(shù)庫(kù)極其龐大類(lèi)型系統(tǒng)又非常寬松加上$_GET、$_POST、$_FILES這些超全局變量直接把用戶(hù)輸入鋪在你眼前天然就容易寫(xiě)出“使用者只需要傳入一個(gè)參數(shù)剩下的交給語(yǔ)言自動(dòng)處理”的代碼。這種寬松帶來(lái)了開(kāi)發(fā)效率也帶來(lái)了很多邊界情況。最典型的就是比較運(yùn)算符和在 PHP 里的行為差異幾乎可以撐起一門(mén)單獨(dú)的安全課。字符串1e3在參與比較的時(shí)候會(huì)被當(dāng)作數(shù)字處理某些以0e開(kāi)頭的字符串在哈希比較里又會(huì)被當(dāng)成科學(xué)計(jì)數(shù)法結(jié)果數(shù)值恒為 0于是兩個(gè)完全不同的哈希值就能被判定為相等。在 CTF 里這叫“魔法哈希”在真實(shí)業(yè)務(wù)里如果用它去做簽名校驗(yàn)后果不亞于直接把驗(yàn)證邏輯刪掉。再看反序列化。PHP 提供serialize()和unserialize()是為了方便把對(duì)象狀態(tài)存到文件或傳進(jìn) session這本是好意??蓡?wèn)題在于反序列化不只是簡(jiǎn)單恢復(fù)數(shù)據(jù)它會(huì)在對(duì)象被創(chuàng)建時(shí)自動(dòng)調(diào)用__wakeup在對(duì)象被銷(xiāo)毀時(shí)自動(dòng)調(diào)用__destruct。如果這些魔術(shù)方法里存在危險(xiǎn)操作比如執(zhí)行命令、讀寫(xiě)文件、調(diào)用方法攻擊者只要精心構(gòu)造一個(gè)序列化字符串就能在不讓程序“主動(dòng)執(zhí)行攻擊代碼”的情況下完成利用。放在業(yè)務(wù)流程里這就是典型的“數(shù)據(jù)通道被當(dāng)成了代碼通道”。所以我復(fù)盤(pán)這道題時(shí)最大的收獲不是學(xué)會(huì)了一條繞過(guò)正則的路徑而是建立了一個(gè)習(xí)慣看到 PHP 代碼時(shí)先看這個(gè)變量是從哪來(lái)的下一個(gè)動(dòng)作會(huì)不會(huì)觸發(fā)類(lèi)型轉(zhuǎn)換、方法回調(diào)或?qū)ο笊芷阢^子。這個(gè)習(xí)慣比背一百條 payload 都值錢(qián)。2. 常見(jiàn) PHP 題面里的五類(lèi)攻擊入口如果把近幾年的 CTF PHP 題拉一個(gè)清單你會(huì)發(fā)現(xiàn)考來(lái)考去基本離不開(kāi)下面幾個(gè)入口。我把它們整理成一個(gè)對(duì)照表方便你快速定位一道題大概在考什么也方便你以后審計(jì)代碼時(shí)按圖索驥。類(lèi)型常見(jiàn)成因CTF 里高頻入口對(duì)應(yīng)到真實(shí)項(xiàng)目弱類(lèi)型比較和混用字符串自動(dòng)轉(zhuǎn)數(shù)字md5碰撞、0e開(kāi)頭哈希、數(shù)組與字符串直接比較簽名/口令校驗(yàn)、支付金額判斷反序列化魔術(shù)方法自動(dòng)調(diào)用serialize/unserialize直接作用于用戶(hù)輸入可控參數(shù)進(jìn)入unserialize()配合__destruct/__wakeup觸發(fā)危險(xiǎn)方法把外部傳入的 JSON/緩存數(shù)據(jù)交給不安全的反序列化函數(shù)變量覆蓋與動(dòng)態(tài)回調(diào)extract()、parse_str()、變量函數(shù)、call_user_func()覆蓋$config、$page、$whitelist等關(guān)鍵變量后包含文件或執(zhí)行方法框架里通過(guò)用戶(hù)參數(shù)決定調(diào)用哪個(gè)方法、加載哪個(gè)模板文件包含與文件上傳include/require路徑可控上傳后綴和內(nèi)容校驗(yàn)不嚴(yán)LFI本地文件包含、RFI遠(yuǎn)程文件包含、phar://觸發(fā)反序列化、上傳偽裝文件頭像上傳功能只校驗(yàn)了 MIME或上傳目錄放在 Web 根目錄下危險(xiǎn)函數(shù)直接把用戶(hù)輸入傳給eval、assert、system、exec、shell_exec一句話(huà)木馬、代碼執(zhí)行、命令執(zhí)行后臺(tái)為了讓業(yè)務(wù)人員“靈活配置”把用戶(hù)規(guī)則交給了eval執(zhí)行這張表看起來(lái)是在分析 CTF但每一項(xiàng)都能在真實(shí)項(xiàng)目里找到對(duì)應(yīng)版本。我見(jiàn)過(guò)不少團(tuán)隊(duì)把反序列化當(dāng)成“緩存技術(shù)”來(lái)用用unserialize處理 Redis 里的用戶(hù)數(shù)據(jù)卻忘了那些數(shù)據(jù)從哪來(lái)也見(jiàn)過(guò)上傳組件只查了Content-Type導(dǎo)致攻擊者把一段 PHP 代碼包裝成普通圖片就通過(guò)了校驗(yàn)。CTF 題只是把這些問(wèn)題濃縮在一個(gè)短期場(chǎng)景里核心邏輯和所有業(yè)務(wù)代碼是同一個(gè)道理。2.1 為什么“危險(xiǎn)函數(shù)”防不勝防eval、assert、system、exec、shell_exec這類(lèi)函數(shù)本身沒(méi)有錯(cuò)問(wèn)題在于“誰(shuí)給它們傳參數(shù)”。在 PHP 里assert在歷史版本中可以直接把字符串當(dāng)成代碼執(zhí)行很多人會(huì)用assert來(lái)做簡(jiǎn)單的條件判斷但一旦參數(shù)可控就變成了一個(gè)和eval等價(jià)的執(zhí)行點(diǎn)。更麻煩的是有些函數(shù)乍一看不危險(xiǎn)組合起來(lái)就危險(xiǎn)call_user_func只是“調(diào)用一個(gè)函數(shù)”但當(dāng)函數(shù)名和參數(shù)都可控時(shí)你就能調(diào)用system并傳進(jìn)任意命令。所以審計(jì)時(shí)我一般會(huì)先做一個(gè)“數(shù)據(jù)流追蹤”找出所有能進(jìn)入函數(shù)的變量路徑重點(diǎn)看$_GET、$_POST、$_COOKIE、$_FILES、$_SERVER以及從數(shù)據(jù)庫(kù)、緩存、消息隊(duì)列里讀出來(lái)的內(nèi)容。很多隊(duì)伍在打 CTF 時(shí)會(huì)忽略“二次輸入”的位置比如反序列化數(shù)據(jù)不是直接從請(qǐng)求參數(shù)進(jìn)入的而是先寫(xiě)進(jìn) session 文件下一次請(qǐng)求再被加載。這種間接路徑往往比直接傳參更難防御也更值得在實(shí)戰(zhàn)中關(guān)注。3. 從反序列化到正則繞過(guò)一處處還原處理場(chǎng)景現(xiàn)在進(jìn)入這道題最核心的部分反序列化過(guò)濾的繞過(guò)。當(dāng)年這道題被討論得很多其中一個(gè)關(guān)鍵點(diǎn)是不少選手手上有現(xiàn)成的 payload卻匹配不上正則然后就開(kāi)始懷疑是不是類(lèi)的名字不對(duì)、屬性名不對(duì)。實(shí)際上問(wèn)題往往出在 PHP 的unserialize對(duì)序列化格式的“過(guò)度寬容”上。3.1 先看懂 PHP 序列化字符串的結(jié)構(gòu)PHP 的序列化格式并不神秘一個(gè)對(duì)象可以表示成下面這段樣子O:4:Flag:1:{s:3:cmd;s:2:id;}這段字符串拆開(kāi)來(lái)看是這樣的O表示對(duì)象類(lèi)型4是類(lèi)名的字節(jié)長(zhǎng)度Flag是類(lèi)名1是對(duì)象屬性的數(shù)量s:3:cmd是屬性名s表示字符串3是屬性名字節(jié)長(zhǎng)度s:2:id是屬性值。很多人第一次手寫(xiě)序列化字符串時(shí)會(huì)栽在“屬性名”上。比如 PHP 類(lèi)里的屬性名如果是cmd序列化生成的是s:3:cmd不能寫(xiě)多也不能寫(xiě)少。如果你處理的是中文還要注意字符串長(zhǎng)度按字節(jié)算而不是按字符算一個(gè)中文在 UTF-8 編碼下通常是 3 個(gè)字節(jié)所以序列化里面會(huì)出現(xiàn)s:6:你好這種寫(xiě)法。這道題之所以讓不少人頭疼就是因?yàn)樗麄儗?duì)這個(gè)格式的構(gòu)造不夠熟一旦遇到正則攔截又漏掉了這個(gè)符號(hào)的存在。3.2 過(guò)濾O:的漏洞點(diǎn)加號(hào)繞過(guò)我再畫(huà)一個(gè)等價(jià)的最小環(huán)境。假設(shè)目標(biāo)代碼長(zhǎng)這樣?php class Flag { public $cmd; public function __destruct() { system($this-cmd); } } $data $_GET[data]; if (preg_match(/^O:\d/, $data)) { die(blocked); } unserialize($data);這段代碼的邏輯很簡(jiǎn)單如果傳入?yún)?shù)以O(shè):加數(shù)字開(kāi)頭就攔截否則直接反序列化。乍一看正常的序列化字符串確實(shí)都以O(shè):4:之類(lèi)開(kāi)頭正則/^O:\d/應(yīng)該能擋住絕大多數(shù)對(duì)象輸入。但 PHP 的序列化解析器在識(shí)別對(duì)象類(lèi)型標(biāo)記時(shí)非常寬容。你在O和數(shù)字之間插入一個(gè)解析器會(huì)照單全收。于是前面那個(gè)正常 payload 可以改寫(xiě)成O:4:Flag:1:{s:3:cmd;s:2:id;}正則里的意思是O后面必須直接跟冒號(hào)、冒號(hào)后面必須是數(shù)字。O:4中:的后面是不是數(shù)字所以preg_match(/^O:\d/, O:4:Flag:...)匹配失敗。程序以為自己攔住了實(shí)際上unserialize依然把它當(dāng)成一個(gè)合法的對(duì)象去解析。你可以本地跑一段最簡(jiǎn)單的驗(yàn)證代碼?php var_dump(unserialize(O:4:Flag:1:{s:3:cmd;s:2:id;}));只要Flag類(lèi)存在并能順利包含進(jìn)來(lái)你就會(huì)看到一個(gè)Flag對(duì)象被創(chuàng)建。接下來(lái)腳本結(jié)束__destruct被觸發(fā)system(id)被執(zhí)行命令結(jié)果直接輸出到頁(yè)面。我這里用一個(gè)完全可復(fù)現(xiàn)的小代碼片段來(lái)說(shuō)明原理一點(diǎn)也不依賴(lài)當(dāng)時(shí)題目環(huán)境里的文件名、路由和額外配置。這就是反序列化類(lèi)題目里最常用的“正則繞過(guò)”思路過(guò)濾條件只針對(duì)了“看起來(lái)正?!钡男蛄谢_(kāi)頭卻沒(méi)有去約束 PHP 解析器對(duì)加號(hào)的容忍度。這類(lèi)問(wèn)題在真實(shí)代碼里也很常見(jiàn)比如做輸入校驗(yàn)時(shí)只攔截了字符串O:卻沒(méi)有考慮大小寫(xiě)、空白、加號(hào)、URL 編碼、Unicode 等價(jià)變形這些“同一語(yǔ)義不同寫(xiě)法”的情況。3.3__wakeup繞過(guò)數(shù)字不一致帶來(lái)的對(duì)象生命周期差異如果你把過(guò)濾器改得更嚴(yán)格一些比如已經(jīng)能攔住O:4那么攻擊者還可以考慮另一條路繞過(guò)__wakeup方法。__wakeup和__destruct是對(duì)象生命周期里的一進(jìn)一出。反序列化創(chuàng)建對(duì)象后會(huì)先執(zhí)行__wakeup而__destruct會(huì)在對(duì)象被銷(xiāo)毀時(shí)執(zhí)行。如果一個(gè)類(lèi)在__wakeup里強(qiáng)制把cmd屬性改掉攻擊者等于拿到了一個(gè)“沒(méi)有攻擊能力的對(duì)象”。不過(guò)在 PHP 7.4 之前的版本里存在一個(gè)反序列化特性當(dāng)序列化字符串中聲明的屬性個(gè)數(shù)大于對(duì)象實(shí)際的屬性個(gè)數(shù)時(shí)__wakeup不會(huì)被調(diào)用。我按這個(gè)邏輯設(shè)計(jì)一份代碼?php class Flag { public $cmd; public function __wakeup() { $this-cmd echo test; } public function __destruct() { system($this-cmd); } }正常反序列化一個(gè)擁有cmdid的對(duì)象__wakeup會(huì)把cmd改掉。但如果你提交的是O:4:Flag:2:{s:3:cmd;s:2:id;}注意這里屬性個(gè)數(shù)寫(xiě)的是2而對(duì)象實(shí)際上只有一個(gè)cmd屬性。在 PHP 7.4 之前的版本里這個(gè)不一致會(huì)導(dǎo)致__wakeup被跳過(guò)對(duì)象直接進(jìn)入后續(xù)生命周期。等到腳本結(jié)束__destruct觸發(fā)system(id)照樣執(zhí)行。這個(gè)繞過(guò)并不是新東西它對(duì)應(yīng) CVE-2016-7124PHP 官方在后續(xù)版本里做了修復(fù)。但在比賽里主辦方經(jīng)常把容器版本固定在 PHP 7.3.x為的就是讓選手有機(jī)會(huì)使用這個(gè)繞過(guò)??吹竭@里你應(yīng)該能理解為什么我一直強(qiáng)調(diào)“看完代碼還要看環(huán)境版本”一個(gè)在 PHP 8.0 里完全無(wú)效的 payload在 PHP 7.3 里可能就是唯一解。3.4 本地用 Docker 復(fù)現(xiàn)比紙上談兵有效得多很多人打 CTF 時(shí)只在腦子里推邏輯一旦 payload 不生效就開(kāi)始亂試。我的做法是本地快速拉起一個(gè)等價(jià)環(huán)境用 Docker 最省事。例如你手頭有一個(gè)包含上述反序列化代碼的目錄叫php-lab可以這樣跑docker run -d -p 8080:80 -v $PWD/php-lab:/var/www/html php:7.3-apache然后打開(kāi)http://127.0.0.1:8080/index.php?data...直接觀察輸出。這種方式的好處是能快速驗(yàn)證“加號(hào)是否被接受”“__wakeup是否被繞過(guò)”“不同 PHP 版本的差異”這些關(guān)鍵結(jié)論。比賽中環(huán)境版本一般能通過(guò)響應(yīng)頭、報(bào)錯(cuò)信息或題目描述推測(cè)出來(lái)本地 Docker 鏡像版本和容器版本保持一致能少走很多彎路。我當(dāng)時(shí)也是這么復(fù)驗(yàn)的先跑一個(gè) php:7.3 容器把上面兩段代碼分別放進(jìn)去再用不同的 payload 去測(cè)試。結(jié)果很直觀O:4能過(guò)正則O:4:Flag:2能跳過(guò)__wakeup。這些結(jié)論如果沒(méi)有實(shí)際環(huán)境驗(yàn)證你很難確定它在邊界情況下是否成立。4. 反過(guò)來(lái)想這套題放到真實(shí)項(xiàng)目中該怎么修CTF 題目的價(jià)值不只在于“打進(jìn)去”還在于“修回去”。把一個(gè)反序列化繞過(guò)考到極致最終要回答的問(wèn)題仍然是真實(shí)項(xiàng)目里我應(yīng)該怎么避免自己掛在同一個(gè)點(diǎn)上4.1 能不用unserialize就盡量不用對(duì)絕大多數(shù) Web 項(xiàng)目來(lái)說(shuō)存儲(chǔ)結(jié)構(gòu)化數(shù)據(jù)首選json_encodejson_decode。JSON 格式里沒(méi)有“類(lèi)”的概念危險(xiǎn)魔術(shù)方法就不會(huì)被自動(dòng)觸發(fā)。如果數(shù)據(jù)并不需要某一個(gè)具體類(lèi)的實(shí)例完全沒(méi)必要把整個(gè)對(duì)象序列化進(jìn)去。如果確實(shí)需要反序列化PHP 7.0 以后提供allowed_classes參數(shù)可以白名單化允許創(chuàng)建的類(lèi)$obj unserialize($data, [allowed_classes [UserProfile, CartItem]]);這樣即使攻擊者把序列化數(shù)據(jù)改成O:4:Flag:...反序列化器也會(huì)拒絕創(chuàng)建不在白名單里的類(lèi)。這是我在審計(jì)時(shí)最推薦的最低成本加固方案。4.2 給魔術(shù)方法里的危險(xiǎn)動(dòng)作“上鎖”即使你用了allowed_classes也沒(méi)法保證被允許的類(lèi)自身沒(méi)有問(wèn)題。審計(jì)時(shí)重點(diǎn)檢查這些類(lèi)的__wakeup、__destruct、__toString、__call、__get、__set等方法看看它們內(nèi)部是否操作了命令執(zhí)行、文件讀寫(xiě)、方法回調(diào)、數(shù)據(jù)庫(kù)訪問(wèn)等敏感動(dòng)作。如果有盡量拆出去不要在反序列化自動(dòng)觸發(fā)的邏輯里做這些事。以__destruct為例對(duì)象銷(xiāo)毀時(shí)機(jī)不可控你很難判斷它發(fā)生在請(qǐng)求哪個(gè)階段更不能假設(shè)“用戶(hù)輸入過(guò)了過(guò)濾所以這里一定安全”。比較好的設(shè)計(jì)是讓這些魔法方法只做“數(shù)據(jù)清理”不要做“數(shù)據(jù)執(zhí)行”。執(zhí)行操作放在顯式調(diào)用的業(yè)務(wù)方法里并單獨(dú)做權(quán)限校驗(yàn)。4.3 把錯(cuò)誤信息關(guān)進(jìn)日志里很多 PHP 項(xiàng)目在出問(wèn)題時(shí)會(huì)直接把錯(cuò)誤打到頁(yè)面上這對(duì)調(diào)試很方便但在生產(chǎn)環(huán)境等于把內(nèi)部信息免費(fèi)送給攻擊者。一個(gè)小小Warning就能暴露文件路徑、數(shù)據(jù)庫(kù)表名、框架版本。你可以這樣設(shè)置error_reporting(E_ALL); ini_set(display_errors, 0); ini_set(log_errors, 1); ini_set(error_log, /var/log/php_errors.log);開(kāi)發(fā)環(huán)境可以開(kāi)display_errors生產(chǎn)環(huán)境就必須關(guān)掉。能看到的錯(cuò)誤越少攻擊者做指紋探測(cè)和信息收集的難度就越高。這個(gè)點(diǎn)雖然是基礎(chǔ)但在真實(shí)項(xiàng)目里仍然有大量遺漏。每次審計(jì) PHP 代碼時(shí)我都習(xí)慣先看錯(cuò)誤處理配置因?yàn)橐粋€(gè)過(guò)度“友善”的報(bào)錯(cuò)頁(yè)面往往已經(jīng)幫攻擊者省掉一半的工作量。4.4 文件上傳的幾道閘門(mén)都要關(guān)緊搜索的時(shí)候我注意到很多人關(guān)心的另一個(gè)點(diǎn)是“php 上傳漏洞”。這類(lèi)問(wèn)題和反序列化一樣本質(zhì)是“把用戶(hù)輸入當(dāng)成可信內(nèi)容”。文件上傳至少要過(guò)四道閘門(mén)擴(kuò)展名白名單不要只攔危險(xiǎn)后綴文件內(nèi)容頭部校驗(yàn)比如判斷圖片的真實(shí)簽名存儲(chǔ)目錄放在 Web 根目錄外部不要給上傳目錄配置 PHP 執(zhí)行權(quán)限文件名隨機(jī)化不要使用用戶(hù)提供的文件名。如果是在 nginx 環(huán)境里可以在上傳目錄的 location 里寫(xiě)死拒絕執(zhí)行 PHPlocation ^~ /uploads/ { location ~ \.php$ { deny all; } }這道配置的意思是上傳目錄下所有以.php結(jié)尾的請(qǐng)求直接拒絕不管文件是原本就叫這個(gè)名字還是通過(guò)雙擴(kuò)展名、大小寫(xiě)變體繞過(guò)來(lái)的。在 Windows 下還要額外注意x.php.、x.php%20、x.php::$DATA這類(lèi)文件系統(tǒng)解析差異字符串校驗(yàn)只是第一層真正可靠的還是“目錄里沒(méi)有腳本執(zhí)行能力”。4.5 輸出側(cè)的防御也不能忘我看了搜索熱度里還有“php跨域jsonp”“php div彈窗”這類(lèi)詞。其實(shí)它們都指向輸出側(cè)的同一個(gè)問(wèn)題數(shù)據(jù)進(jìn)入 HTML、JavaScript、JSONP 回調(diào)時(shí)如果沒(méi)有正確編碼就會(huì)帶來(lái) XSS。JSONP 在過(guò)去的年代很常見(jiàn)但它的回調(diào)函數(shù)名通常由前端或用戶(hù)傳入如果服務(wù)端沒(méi)做白名單就會(huì)變成一個(gè)反射型 XSS 出口。現(xiàn)在更推薦的做法是能不用 JSONP 就不用用 CORS 白名單域名代替如果必須支持 JSONP回調(diào)名只允許[A-Za-z_][A-Za-z0-9_]*其他一律拒絕。HTML 輸出時(shí)不要偷懶該轉(zhuǎn)義的字段用htmlspecialchars($value, ENT_QUOTES, UTF-8)轉(zhuǎn)義。聽(tīng)起來(lái)像是在講基礎(chǔ)課但真實(shí)問(wèn)題往往就是從一個(gè)未轉(zhuǎn)義的字段開(kāi)始一路變成賬號(hào)被盜。5. 工程自查清單別等被打了才回頭審計(jì)復(fù)盤(pán)一道 CTF 題不是終點(diǎn)我更愿意把里面出現(xiàn)的每一個(gè)考點(diǎn)翻譯成工程自查問(wèn)題。下面這個(gè)清單是我每次幫團(tuán)隊(duì)做 PHP 代碼頭審計(jì)時(shí)都會(huì)過(guò)一遍的你可以直接拿來(lái)當(dāng)模板。風(fēng)險(xiǎn)點(diǎn)自查問(wèn)題修復(fù)建議輸入來(lái)源哪些函數(shù)直接使用了$_GET、$_POST、$_COOKIE、$_FILES或請(qǐng)求頭入口處統(tǒng)一做參數(shù)白名單/類(lèi)型校驗(yàn)反序列化項(xiàng)目里有沒(méi)有對(duì)外部數(shù)據(jù)調(diào)用unserialize()用了什么過(guò)濾改用 JSON必須用時(shí)加allowed_classes白名單魔術(shù)方法所有類(lèi)里__wakeup、__destruct、__toString等方法是否涉及敏感操作把執(zhí)行邏輯從魔術(shù)方法中拆出危險(xiǎn)函數(shù)代碼里有多少處eval、assert、system、exec、shell_exec、call_user_func能用白名單方法列表替代就替代參數(shù)嚴(yán)格約束比較邏輯登錄、驗(yàn)簽、金額判斷用的是還是涉及哈希和數(shù)字判斷一律用SQL 查詢(xún)用的是 PDO 預(yù)處理還是字符串拼接全量切到 prepared statement文件上傳上傳目錄是否可執(zhí)行腳本文件名是否可控白名單 隨機(jī)文件名 目錄禁止執(zhí)行 PHP文件包含include/require的文件名是否由用戶(hù)控制用映射表或內(nèi)置路由禁止直接拼接路徑錯(cuò)誤輸出生產(chǎn)環(huán)境是否關(guān)閉了display_errors關(guān)掉頁(yè)面報(bào)錯(cuò)只寫(xiě)日志輸出編碼HTML、JavaScript、JSONP 回調(diào)處是否有轉(zhuǎn)義統(tǒng)一htmlspecialchars 回調(diào)名白名單依賴(lài)安全composer 依賴(lài)?yán)镉袥](méi)有已知反序列化漏洞用composer audit定期掃描及時(shí)升級(jí)版本信息PHP 版本是多少是否還處于社區(qū)支持期內(nèi)升級(jí)到受支持版本老版本的坑很難靠代碼補(bǔ)齊這個(gè)清單看著很多實(shí)際執(zhí)行起來(lái)并不復(fù)雜。先全局搜索unserialize、eval、system、include、require、extract、assert這些關(guān)鍵字把命中點(diǎn)逐個(gè)確認(rèn)一遍再把所有入口參數(shù)整理成一張表最后用自動(dòng)化工具跑一輪依賴(lài)漏洞掃描。重點(diǎn)是形成習(xí)慣而不是真的等到出了事故再返工?!癕ake PHP Great Again”這個(gè)題目本身我后來(lái)反復(fù)復(fù)盤(pán)過(guò)好多次。第一次做的時(shí)候滿(mǎn)腦子都在想怎么把正則繞過(guò)去第二次做我開(kāi)始琢磨為什么 PHP 解析器會(huì)對(duì)這么寬容第三次做我真正關(guān)心的已經(jīng)變成了“如果這是我自己寫(xiě)的代碼我應(yīng)該在哪里攔一手”。這個(gè)轉(zhuǎn)變恰好對(duì)應(yīng)代碼審計(jì)的三個(gè)層次先看到能打再理解為什么能打最后想明白怎么防。如果你正卡在第一步不用著急回去搭一個(gè)本地環(huán)境把序列化字符串一行一行拆開(kāi)看就會(huì)突然發(fā)現(xiàn)這些題并沒(méi)有想象中那么玄幻。