義繞過與psql元命令注入)
PostgreSQL 最近有一個編號為 CVE-2025-1094 的漏洞補(bǔ)丁發(fā)出之后我在復(fù)盤排查時發(fā)現(xiàn)不少同行對這個漏洞的理解還停留在“知道名字但說不出細(xì)節(jié)”的程度。這個漏洞很不尋常它打破了一個很多人默認(rèn)的安全假設(shè)——只要我們用了官方提供的轉(zhuǎn)義函數(shù)把用戶輸入老老實實轉(zhuǎn)義一遍SQL 注入就會離我們很遠(yuǎn)。但這一次轉(zhuǎn)義函數(shù)自己先“失靈”了而且最終還能一路打到 psql 元命令執(zhí)行危險程度直接拉滿。這篇文章會把 CVE-2025-1094 從零開始講透先補(bǔ) SQL 注入和 psql 元命令的基礎(chǔ)再拆漏洞的根因和觸發(fā)鏈路最后給出自查方法、修復(fù)方案和防御加固建議。無論你是剛接觸數(shù)據(jù)庫的新手還是已經(jīng)在生產(chǎn)環(huán)境里摸爬滾打多年的老手這篇都值得花 10 分鐘認(rèn)真過一遍因為這次的坑真的很容易踩。1. 先搞清楚這個漏洞到底有多大1.1 漏洞卡片CVE-2025-1094 基本信息先把最關(guān)鍵的檔案信息列出來方便你轉(zhuǎn)發(fā)給團(tuán)隊或者快速歸檔項目內(nèi)容漏洞編號CVE-2025-1094受影響組件PostgreSQL 的 libpq 客戶端庫關(guān)鍵函數(shù)PQescapeLiteral()、PQescapeIdentifier()、PQescapeString()漏洞類型SQL 注入、轉(zhuǎn)義繞過最高危害任意 SQL 注入、psql 元命令執(zhí)行特定配置下可能到操作系統(tǒng)命令執(zhí)行CVSS 評分8.4高危受影響版本17.0 ~ 17.2、16.0 ~ 16.6、15.0 ~ 15.10、14.0 ~ 14.15、13.0 ~ 13.18修復(fù)版本17.3、16.7、15.11、14.16、13.19這幾個函數(shù)的定位是“官方欽定的轉(zhuǎn)義函數(shù)”很多代碼審計工具也會建議開發(fā)者用它們來處理動態(tài) SQL。所以這次不是某個小眾第三方組件的鍋而是 PostgreSQL 自帶客戶端庫核心函數(shù)出了問題影響面非常廣。1.2 一句話概括這個漏洞的本質(zhì)CVE-2025-1094 的核心問題可以壓縮成一句話當(dāng)輸入中包含特定格式的無效字節(jié)序列時libpq 的轉(zhuǎn)義函數(shù)在客戶端看似正確轉(zhuǎn)義了但數(shù)據(jù)到了服務(wù)端因為編碼解碼規(guī)則不一致某些字節(jié)會被服務(wù)端忽略或重組導(dǎo)致轉(zhuǎn)義效果被抵消單引號等危險字符重新“裸奔”。這個“兩端認(rèn)知不一致”的漏洞模式比普通注入更陰險。以前我們教新人防注入會說“拼 SQL 時一定要用轉(zhuǎn)義函數(shù)”這個經(jīng)驗本身沒錯但這次連轉(zhuǎn)義函數(shù)都防不住對很多團(tuán)隊的信任體系是一次不小的沖擊。2. 零基礎(chǔ)補(bǔ)課SQL 注入和 psql 元命令到底是什么2.1 SQL 注入的本質(zhì)把“數(shù)據(jù)”當(dāng)“代碼”執(zhí)行用大白話講SQL 注入就是在應(yīng)用程序拼 SQL 語句時用戶輸入沒有老老實實當(dāng)數(shù)據(jù)而是被數(shù)據(jù)庫當(dāng)成 SQL 命令的一部分執(zhí)行了。舉個最簡單的例子。你的程序?qū)懥艘痪銼ELECT * FROM users WHERE name 張三;如果這個“張三”是用戶填的攻擊者填成張三 OR 11拼接后就成了SELECT * FROM users WHERE name 張三 OR 11;這條語句會返回全表數(shù)據(jù)登錄驗證直接被打穿。更嚴(yán)重的攻擊者還可以塞; DROP TABLE users; --這類操作。為了方便理解你可以把正常的 SQL 拼接想象成一份打印好的表格表格里的固定文字是“代碼”用戶填的內(nèi)容是“數(shù)據(jù)”。注入攻擊干的事情就是把用戶輸入里的特殊字符主要是單引號當(dāng)作句法符號讓數(shù)據(jù)庫誤以為“這段數(shù)據(jù)應(yīng)該被當(dāng)成代碼處理”于是原本隔離的兩層邊界就被擊穿了。2.2 psql 元命令從反斜杠開始的“快捷通道”psql 是 PostgreSQL 自帶的命令行工具除了能執(zhí)行普通 SQL還支持一批以反斜杠\開頭的“元命令”。這些元命令不是 SQL 標(biāo)準(zhǔn)的一部分而是 psql 提供的交互快捷方式。常用的包括\set定義 psql 內(nèi)部變量\gset把查詢結(jié)果的列值存入 psql 變量\!在操作系統(tǒng) shell 中執(zhí)行一條命令\o把查詢結(jié)果輸出到文件\copy執(zhí)行客戶端側(cè)的數(shù)據(jù)拷貝問題在于psql 元命令擁有“脫離 SQL 語法”的執(zhí)行能力尤其是\!它直接調(diào)用操作系統(tǒng)命令。如果一個場景里攻擊者構(gòu)造的數(shù)據(jù)能滲透到 psql 變量再被元命令消費危害就會從“數(shù)據(jù)庫被注入”升級到“數(shù)據(jù)庫所在機(jī)器被控制”。CVE-2025-1094 的官方描述里明確提到了 psql 元命令執(zhí)行意味著攻擊鏈可以走得很深。2.3 轉(zhuǎn)義函數(shù)存在的意義給輸入穿上“防護(hù)服”開發(fā)者在寫動態(tài) SQL 時不能直接把用戶輸入塞進(jìn)去否則就是給別人遞刀子。轉(zhuǎn)義函數(shù)的職責(zé)就是把輸入字符串里的危險字符轉(zhuǎn)成無害的“字面文本”。PQescapeLiteral() 是 libpq 提供的標(biāo)準(zhǔn)做法它會把字符串用單引號包裹起來并對內(nèi)部的單引號、反斜杠等特殊字符做轉(zhuǎn)義。比如用戶輸入abc OR 11 --PQescapeLiteral() 輸出大致是abc OR 11 --也就是把單引號雙寫讓數(shù)據(jù)庫認(rèn)為這是數(shù)據(jù)里的一個普通單引號而不是字符串邊界。這層“防護(hù)服”在過去幾十年是無數(shù)應(yīng)用的安全底座也是為什么這次漏洞能引起這么大震動的原因——原來是鐵布衫現(xiàn)在告訴你有幾針沒縫緊而且很難看出來。3. 漏洞根因轉(zhuǎn)義邏輯被“無效字節(jié)”鉆了空子3.1 兩端編碼規(guī)則不一致是繞過的關(guān)鍵CVE-2025-1094 的根因出在 libpq 的轉(zhuǎn)義函數(shù)在處理“無效字節(jié)序列”時的邏輯缺陷。這里的無效字節(jié)序列指的是不符合當(dāng)前字符集編碼規(guī)則的字節(jié)組合。為了兼容歷史上各種不規(guī)范的客戶端PostgreSQL 服務(wù)端在解析某些無效多字節(jié)字符時并不會直接報錯而是選擇把無法識別的字節(jié)當(dāng)作“零寬度”處理也就是干脆忽略掉或者把序列重新組合成另一個合法字符。問題就出在這里客戶端 libpq 在轉(zhuǎn)義時以字節(jié)為單位檢查有沒有、\等特殊字符服務(wù)端在解析時以“字符”為單位進(jìn)行編碼重組。兩邊的解析粒度不一致。如果攻擊者構(gòu)造一個特殊字節(jié)序列里面夾帶單引號客戶端轉(zhuǎn)義時看到的是“普通字節(jié)單引號”會規(guī)規(guī)矩矩把單引號雙寫但服務(wù)端解析時那個特殊字節(jié)序列的“存在感”被抹掉單引號的語義就變了原本的雙寫單引號重新暴露成可以被利用的語法符號。這個感覺有點像加密傳輸兩端用了不同的解密規(guī)則發(fā)送方加了密但接收方解密時某些字符被靜默丟棄結(jié)果密文反而被還原成了明文??蛻舳艘詾樽约涸谵D(zhuǎn)義服務(wù)端卻以為根本沒轉(zhuǎn)義兩邊對不上賬危險就來了。3.2 從轉(zhuǎn)義失敗到注入的完整鏈路把整條攻擊鏈路拆開看大致是四步攻擊者構(gòu)造一個包含特定無效字節(jié)序列的字符串里面夾帶單引號、分號等 SQL 關(guān)鍵字符。業(yè)務(wù)代碼調(diào)用 PQescapeLiteral() 或 PQescapeIdentifier() 對這個字符串做轉(zhuǎn)義函數(shù)返回的結(jié)果從客戶端視角看是“已經(jīng)安全處理過”的。轉(zhuǎn)義后的字符串進(jìn)入 SQL 語句發(fā)送到服務(wù)端服務(wù)端在編碼解碼過程中按照自己的規(guī)則把無效字節(jié)剝離或重組導(dǎo)致原本雙寫的單引號恢復(fù)成可用的句法符號。攻擊者輸入中的 SQL 片段被服務(wù)端正常解析執(zhí)行造成注入。注入成功后如果這個環(huán)境還能通過某個通道把數(shù)據(jù)交給 psql 元命令處理比如\gset把查詢結(jié)果列值存入變量再在后續(xù)通過\set拼接構(gòu)造新命令就可能進(jìn)一步演變成元命令執(zhí)行甚至操作系統(tǒng)命令執(zhí)行。值得注意的是這個漏洞不僅影響應(yīng)用代碼中的動態(tài) SQL也影響 psql 自身的一些交互場景。比如你從某個不可信數(shù)據(jù)源拿到一份 SQL 腳本文件用 psql 去執(zhí)行它文件內(nèi)容里可能含有精心構(gòu)造的字節(jié)序列最終在\gset、\set這類元命令交互中被“激活”。3.3 為什么這個漏洞容易被忽略我在復(fù)盤時發(fā)現(xiàn)這個漏洞有幾個“天生隱蔽”的屬性。復(fù)現(xiàn)條件苛刻。它依賴特定的無效字節(jié)序列普通場景下很難碰到開發(fā)者日常測試很少會拿畸形編碼去挑戰(zhàn)轉(zhuǎn)義函數(shù)所以線上即便存在風(fēng)險也不容易暴露。靜態(tài)代碼掃描難以覆蓋。像PQescapeLiteral()這種函數(shù)在代碼里看起來就是標(biāo)準(zhǔn)的安全寫法常規(guī)代碼審計工具不會標(biāo)記它們是風(fēng)險點反而會認(rèn)為“這里做了轉(zhuǎn)義安全”。漏洞不在函數(shù)用沒用上而在函數(shù)內(nèi)部的邊界情況處理這超出了大多數(shù)白盒掃描工具的能力范圍。修復(fù)牽涉面廣。libpq 是客戶端庫服務(wù)端和客戶端都可能受影響。數(shù)據(jù)庫服務(wù)器升級了還不夠如果應(yīng)用服務(wù)器用的還是舊版 libpq漏洞照樣存在。這個問題在大型系統(tǒng)里尤其容易漏掉。4. 自查與修復(fù)三步確認(rèn)你的環(huán)境是否安全4.1 第一步確認(rèn) PostgreSQL 版本升級到修復(fù)版本先別急著猜直接查一下版本SELECT version();如果版本落在受影響區(qū)間立刻規(guī)劃升級。修復(fù)版本我已列在開頭的表格里17.3、16.7、15.11、14.16、13.19對應(yīng)的分支升級上去就能堵住漏洞。升級時的幾個關(guān)鍵點要注意libpq是客戶端庫服務(wù)端升級不代表客戶端自動安全。應(yīng)用服務(wù)器、ETL 工具、腳本環(huán)境里裝了什么版本的 libpq要一并排查使用pg_config --version或psql --version查看。升級前務(wù)必先做兼容性測試。這次修復(fù)涉及轉(zhuǎn)義函數(shù)和編碼處理邏輯極端情況下可能影響某些特殊的字符處理行為先在預(yù)發(fā)環(huán)境跑一遍完整回歸。如果業(yè)務(wù)依賴某些老版本特有的行為升級后需要重點驗證中文、表情符號等非 ASCII 字符的讀寫。提示版本檢查不止看服務(wù)端客戶端機(jī)器上的psql --version輸出也要看。CVE-2025-1094 同時影響服務(wù)端和客戶端兩邊都要達(dá)標(biāo)。4.2 第二步掃描代碼中的轉(zhuǎn)義函數(shù)調(diào)用評估暴露面即使版本暫時無法立即升級也要先搞清楚自己的代碼里到底哪些地方調(diào)用了這些函數(shù)。在項目目錄里搜一下grep -rn PQescapeLiteral\|PQescapeIdentifier\|PQescapeString --include*.c --include*.cpp --include*.h .把搜出來的調(diào)用點逐個人工確認(rèn)輸入來源是誰是終端用戶輸入、API 參數(shù)、配置文件還是外部文件讀取輸入流經(jīng)這些函數(shù)后拼到了 SQL 語句的什么位置有沒有可能被二次讀取、寫入日志、存到 psql 變量再被元命令消費如果輸入源完全受控那風(fēng)險相對低如果輸入源面向不可信用戶且拼進(jìn) SQL那就屬于高風(fēng)險路徑要在升級前臨時加固。4.3 第三步收緊運(yùn)行時權(quán)限減少“最后一擊”的傷害萬一被注入能做到什么程度很大程度上取決于數(shù)據(jù)庫賬號的權(quán)限。我在不少項目里見過用超級用戶跑業(yè)務(wù)查詢的情況一旦注入成功就是災(zāi)難。最小權(quán)限原則不只是一句口號具體落地至少要做到業(yè)務(wù)應(yīng)用使用的數(shù)據(jù)庫賬號只授予業(yè)務(wù)所必需的庫、表、函數(shù)權(quán)限撤銷超級權(quán)限。禁止業(yè)務(wù)賬號擁有COPY ... PROGRAM、CREATE EXTENSION這類高危權(quán)限尤其在無法確認(rèn)客戶端 libpq 版本時。開啟數(shù)據(jù)庫日志記錄所有語句至少臨時開啟一段時間方便發(fā)現(xiàn)異常模式log_statement all log_line_prefix %m [%p] %q%u%d 日志會帶來額外 IO 和存儲開銷生產(chǎn)環(huán)境可以只開一小段時間或者在低峰期開啟等排查結(jié)束后改回更嚴(yán)格的配置。5. 升級之外的防御加固與避坑經(jīng)驗5.1 參數(shù)化查詢才是最終解法這次漏洞最值得反思的一點是轉(zhuǎn)義函數(shù)本身只是“第二道防線”參數(shù)化查詢預(yù)處理語句才是根除 SQL 注入的根本辦法。參數(shù)化查詢把 SQL 結(jié)構(gòu)固定下來用戶輸入只作為參數(shù)綁定不參與語句結(jié)構(gòu)的解析根本不給“轉(zhuǎn)義失效”留機(jī)會。拿 C 語言配合 libpq 舉例不用字符串拼接 SQL而是用 PQprepare 加 PQexecPreparedPGresult *res PQprepare(conn, my_stmt, SELECT * FROM users WHERE name $1, 1, NULL); const char *paramValues[1] { user_input }; PGresult *res2 PQexecPrepared(conn, my_stmt, 1, paramValues, NULL, NULL, 0);這樣寫哪怕 user_input 里塞滿無效字節(jié)序列、單引號和分號也只是一堆“值”絕不會被解釋成 SQL 結(jié)構(gòu)。很多高級語言驅(qū)動里的參數(shù)化查詢也是同理Python 的 psycopg2 用%s占位符Java 的 JDBC 用?占位符效果一樣。轉(zhuǎn)義函數(shù)并沒有失去存在價值但這次漏洞應(yīng)該促使團(tuán)隊調(diào)整開發(fā)規(guī)范動態(tài)拼接 SQL 只能用于少量內(nèi)部場景面向不可信輸入的業(yè)務(wù)邏輯一律走參數(shù)化查詢。5.2 升級過程中的兼容性避坑如果你現(xiàn)在已經(jīng)在準(zhǔn)備升級提醒幾個我實際踩過的坑。第一個坑服務(wù)端升級后舊客戶端連不上。PostgreSQL 的主版本升級通常要求客戶端版本不低于某個基線如果一個業(yè)務(wù)集群里有的應(yīng)用服務(wù)器長期沒更新 libpq可能出現(xiàn)認(rèn)證協(xié)議不兼容的問題。升級前把客戶端的 libpq 版本一起納入排期最好統(tǒng)一升級避免“服務(wù)端安全但客戶端漏風(fēng)”的尷尬局面。第二個坑轉(zhuǎn)義行為差異。修復(fù)后的轉(zhuǎn)義函數(shù)遇到無效字節(jié)序列可能會報錯或拒絕處理。如果業(yè)務(wù)里有人拿一堆歷史遺留的臟數(shù)據(jù)做入庫升級后可能發(fā)現(xiàn)之前能跑通的流程突然報“invalid byte sequence”。這其實是一種“積極失敗”是安全機(jī)制在起作用。把臟數(shù)據(jù)擋在門外總比讓它們在庫里埋雷強(qiáng)。第三個坑主從切換。PostgreSQL 高可用集群升級時要特別注意先升級備節(jié)點再切換主備關(guān)系否則一次無意的主從切換會把未修復(fù)的舊節(jié)點重新拉回主節(jié)點等于治標(biāo)不治本。5.3 幾個模擬排查案例幫你理解現(xiàn)象根據(jù)真實高頻提問整理了幾個模擬場景場景現(xiàn)象原因分析處理建議升級前業(yè)務(wù)一切正常升級后某條包含特殊編碼的 SQL 報錯 “invalid byte sequence for encoding UTF8”新版本對無效字節(jié)序列的容忍度降低主動報錯攔截清理源頭數(shù)據(jù)改用參數(shù)化綁定代碼里用了 PQescapeLiteral注入仍然成功日志里出現(xiàn)異常 SQL 片段輸入包含特定無效字節(jié)序列轉(zhuǎn)義被服務(wù)端解碼抵消升級 libpq改用參數(shù)化查詢psql 執(zhí)行腳本腳本被注入后執(zhí)行了\!shell 命令腳本數(shù)據(jù)被元命令消費觸發(fā)命令執(zhí)行禁止執(zhí)行來源不可信的 psql 腳本升級版本監(jiān)控發(fā)現(xiàn)大量異常登錄查詢業(yè)務(wù)日志中單引號被反復(fù)雙寫但仍報語法錯誤有自動化掃描器正在探測端點但載荷未生效確認(rèn)版本已修復(fù)檢查訪問控制啟用日志記錄這些場景我在不同的群里都見過類似討論核心還是要回到版本和安全架構(gòu)兩個基本面版本修好參數(shù)化鋪開心理上才會徹底踏實。5.4 長期防御的三個習(xí)慣最后聊點“結(jié)硬寨”的打法。單靠一個 CVE 的補(bǔ)丁防不住下一個未知漏洞。這次事件讓我把幾個習(xí)慣刻進(jìn)了項目規(guī)范里第一個習(xí)慣構(gòu)建獨立的 SQL 模板層。所有 SQL 語句集中管理業(yè)務(wù)代碼拿到的都是函數(shù)接口不是可拼接的字符串。這樣審計起來非常輕松出問題時也能快速定位邊界。第二個習(xí)慣把編碼校驗做在入口。既然問題出在無效字節(jié)序列那就在程序入口統(tǒng)一做字符集校驗只接受合法 UTF-8連畸形數(shù)據(jù)進(jìn)系統(tǒng)的機(jī)會都掐掉??梢杂胾tf8proc或其他編碼校驗庫也可以簡單用語言自帶的能力判斷。第三個習(xí)慣訂閱安全通告。PostgreSQL 官方發(fā)布安全通告時認(rèn)真讀一遍確認(rèn)自己環(huán)境涉及的版本范圍。很多時候補(bǔ)丁當(dāng)天就出了但真正完成一輪全鏈路修復(fù)和驗證可能要好幾周。知道“哪里是弱點”比“知道要打補(bǔ)丁”更關(guān)鍵。按我個人經(jīng)驗每家公司都有幾套數(shù)據(jù)庫環(huán)境分布在不同的業(yè)務(wù)線上最大難點從來不是補(bǔ)丁包本身而是把“所有該升級的地方都找到”這件事做到位。這次 CVE-2025-1094 的教訓(xùn)就是別過分相信轉(zhuǎn)義函數(shù)也別只盯著服務(wù)端版本客戶端 libpq、psql 版本、應(yīng)用代碼路徑全部都要納入安全檢查范圍。最后再分享一個實戰(zhàn)小技巧如果你不確定當(dāng)前環(huán)境是否已經(jīng)被掃描器盯上可以在備庫臨時開啟一個不存在的賬號觀察是否有來源 IP 嘗試登錄并發(fā)送畸形 SQL。這類“蜜罐式”監(jiān)測能幫你判斷自己是否是攻擊目標(biāo)也能提前確認(rèn)日志采集是否正常運(yùn)轉(zhuǎn)。安全這件事多做一步晚上就能少醒幾次。