我帶人看 WordPress 外掛漏洞時,最常見的誤區是一打開檔案就想「這段 code 哪裡寫錯了」。這個順序是反的。比較快的方法是先扮一次攻擊者,看著一段 code 問「我是惡意使用者,能拿它做什麼」,等你想清楚怎麼打它,哪幾道防禦該擋沒擋自然就浮出來了。
說穿了,絕大多數能讓整站被接管的外掛漏洞都是同一個形狀:一個 wp_ajax handler,沒驗 nonce、沒查權限、還讓人改任意資料。這篇就用一個合成範例走一遍,這個情境是 anonymized 的,但很多真實的 plugin CVE 都長這形狀。
先看這段「正常」的 code
情境是某個外掛提供後台設定頁,使用者可以在後台改一些 option,例如站名顯示、客服 email。處理存檔的 code 大致長這樣:
add_action( 'wp_ajax_update_setting', 'my_update_setting' );
add_action( 'wp_ajax_nopriv_update_setting', 'my_update_setting' );
function my_update_setting() {
$key = $_POST['key'];
$value = $_POST['value'];
update_option( $key, $value );
wp_send_json_success();
}
八行,能跑,後台改設定也確實會存。如果你只是「功能驗收」,這段完全過關。問題是攻擊者不會照你設計的流程走。
Step 1:先扮攻擊者,問「我能用它做什麼」
別急著挑 code 的毛病,先把自己放到攻擊者的位子上。這段 code 給了我三個東西:
這個 endpoint 同時註冊了 wp_ajax_* 和 wp_ajax_nopriv_* 兩個 hook,意思是沒登入的訪客也能呼叫。handler 接受任意 key 加任意 value,沒有白名單。最後直接餵給 update_option(),而這是個重量級函式,幾乎可以改 wp_options 表裡的所有東西。
三個條件湊在一起,攻擊者只要一行請求:
curl -X POST https://victim.com/wp-admin/admin-ajax.php \
-d "action=update_setting&key=siteurl&value=https://attacker.com"
把目標站的 siteurl 改成攻擊者的網址,接下來每次有人開首頁,瀏覽器都會被導到攻擊者那邊,可以掛釣魚頁、可以塞廣告轉址、可以放 malware loader。
還有更安靜的玩法,把 users_can_register 設成 1、default_role 設成 administrator,下一秒任何人都能註冊成管理員。到這裡整個站就交出去了。
Step 2:回頭看 WordPress API 哪幾道該擋沒擋
現在切回防禦者視角,做真正的資安稽核。剛剛能一行打穿,是因為四道防禦全缺:
- Nonce 該擋住「非當事人觸發」:這段沒呼叫
wp_verify_nonce或check_ajax_referer,所以連 CSRF 都成立,騙登入中的管理員點一個連結就能觸發。 - 權限檢查該擋住「不該執行的人」:沒有
current_user_can( 'manage_options' ),等於 admin 操作完全不看身分。 - Endpoint 註冊該只給已登入者:
wp_ajax_nopriv_*這條 hook 對一個後台設定操作根本不該存在。 - 寫入的 key 該白名單化:讓
update_option吃任意 key 是設計層級的災難,能改的選項應該預先列舉。
這裡有個容易被忽略的重點:這四道不是「擇一」的關係。少任何一道都已經是漏洞,四道全缺就是整站接管。做稽核時不要找到一個問題就收手,要繼續問「還有什麼該擋沒擋的」,這就是縱深防禦的思維。
Step 3:修補長什麼樣
修補不是「補一行 nonce」就交差,而是讓四道防禦平行存在:
add_action( 'wp_ajax_update_setting', 'my_update_setting' );
// 刪掉 wp_ajax_nopriv_,admin 操作不該開放給訪客.
function my_update_setting() {
// 防 CSRF.
check_ajax_referer( 'my_plugin_settings', 'nonce' );
// 防權限提升.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'forbidden', 403 );
}
// 白名單 key.
$allowed = array( 'my_plugin_site_name', 'my_plugin_support_email' );
$key = sanitize_key( wp_unslash( $_POST['key'] ?? '' ) );
if ( ! in_array( $key, $allowed, true ) ) {
wp_send_json_error( 'invalid_key', 400 );
}
// 對 value 做對應型別的 sanitize,這裡假設是 email.
$value = sanitize_email( wp_unslash( $_POST['value'] ?? '' ) );
if ( empty( $value ) ) {
wp_send_json_error( 'invalid_value', 400 );
}
update_option( $key, $value );
wp_send_json_success();
}
四道防禦對應四個動作:移除 nopriv hook、check_ajax_referer 驗 nonce、current_user_can 查權限、用 $allowed 把 key 限死在你預期的範圍內。你可以試著把其中任一段拿掉,這個 endpoint 都還是有風險——這就是為什麼修補要當成一組來做,而不是補完第一個洞就以為結束。
Step 4:濃縮成一條可複用的 audit pattern
一個案子拆完,重點不是記住這個案子,而是萃取出未來看到類似 code 一眼能認出的規則。這個案例濃縮成一條:
Audit Pattern #1: 看到
wp_ajax_*或wp_ajax_nopriv_*註冊的 handler,立刻檢查四件事——(1) 有沒有驗 nonce、(2) 有沒有查 capability、(3)wp_ajax_nopriv_*是否真的合理(多數後台操作不該有)、(4) 寫入操作有沒有白名單。
有了這條 pattern,下次掃一個外掛時,你的眼睛會直接跳到這四個點,而不是一行一行讀。這也是把零散的「安全規則」變成可重複稽核流程的方法,跟站台層級的弱密碼、檔案竄改防護是兩個互補的層次,一個守 code、一個守環境。
為什麼用 AI 寫程式的人更該懂這個
update_option 接受任意 key 這種寫法,正是 AI 寫程式時很容易生出來的東西。你叫它「做一個能存後台設定的 ajax endpoint」,它會給你一段能跑的 code,但 nonce、capability、白名單這些「需求沒明說、但少了就出事」的防禦,它預設不會主動補齊。功能測得過,不代表稽核過得了。
所以如果你正在用 AI 加速 WordPress 開發,這條 audit pattern 的價值反而更高:它讓你能在「能跑」之後,再用攻擊者的眼睛掃一遍自己(或 AI)剛寫的 code。想更系統地建立這套習慣,可以先從 WordPress 外掛開發的基礎補起。
參考資料
本文提到的函式行為,皆以 WordPress 官方開發者文件為準:
- wp_ajax_nopriv_{$action} hook
- check_ajax_referer()
- current_user_can()
- update_option()
- wp_send_json_error()
- sanitize_key()
如果你需要 WordPress 或 WooCommerce 的客製化開發,歡迎直接跟我聯絡。如果你自己會用 AI 開發、想把 code 寫得更安全,可以參考 Codotx 的顧問諮詢服務:https://codotx.com/wordpress-ai-consulting/