外掛都說是最新版,其實差了好幾個大版本:一場意外的 WordPress 除錯偵探記
這是一篇意外催生的文章。原本只是例行維護:主機搬遷、網路線修好、雙 IP 復原之後,順手把幾個 WordPress 網站的外掛跟核心更新到最新版。但其中一站冒出一句很違和的話——「外掛都有新版可更,可是系統卻堅持說目前安裝的已經是最新版」。追下去之後,發現這句違和背後藏著好幾層一環扣一環的假象,最後兇手完全出乎意料。整個過程曲折到值得記錄下來。
案發現場:明明過時,卻說是最新
起因很單純:wp plugin list 這個 wp-cli 指令,理應會列出每個外掛「目前版本」跟「有沒有更新」。但這次不管查哪個外掛,update 那一欄清一色都是 none(沒有更新)。乍看沒問題,直到拿其中一個外掛(Elementor)去 wordpress.org 官方頁面核對版本號——已安裝的是 3.18.3,官方最新版是 4.3.x,中間隔了好幾個大版本。這已經不是「剛好還沒發布新版」,是系統整個判斷錯誤。
第一個直覺:會不會是 wp-cron(WordPress 用來排程背景工作的機制)壞了,導致「該去外部檢查一次版本」這個排程任務根本沒有執行?查了排程清單,還真的抓到一個異狀——wp_update_plugins 這個負責檢查外掛更新的排程,記錄的「下次執行時間」停留在將近一個月前,剛好跟這台主機的搬遷時間點吻合。
wp cron event list --path=/path/to/site
# wp_update_plugins 2026-08-27 23:10:23 now 12 hours
# ^-- 「下次執行」停在一個月前,代表這段時間根本沒真的跑過
手動補跑這個排程事件,它確實正常執行、也重新排到了未來的時間。看起來抓到兇手了——結案,收工。
結果,兇手還在現場作案
補跑完排程,重新查一次 wp plugin list——結果一模一樣,全部還是 none。這下真的不對勁了:排程明明重新跑過,為什麼判斷結果完全沒變?
往更底層挖,直接檢查 WordPress 用來存放「上次檢查結果」的那個資料結構(術語叫 site transient,名字是 update_plugins)。正常情況下,這份資料裡應該有一個叫 response 的陣列,裡面列著每個有更新的外掛。結果一查,這個陣列整個不存在——不是空陣列,是連這個欄位本身都沒有。這種殘缺程度,不像是「資料舊了」,比較像是有什麼東西動過手腳、用了一個格式不完整的假資料去頂替。
第一個嫌疑犯:搬家外掛自己的更新邏輯
WordPress 允許外掛「掛勾」到這個更新檢查的資料流程上(技術上叫 filter),用來加料或修改內容。查了一下目前掛在這個流程上的程式碼,找到了:All-in-One WP Migration(前面幾篇文章介紹過的搬家神器)自己的更新檢查程式碼。
翻開它的原始碼,邏輯大致是:「如果收到的資料不是一個正常物件,就建立一個全新的空物件」,然後只往裡面補上「這個外掛自己的付費附加元件」的更新資訊。本站沒有裝任何付費附加元件,所以這段程式碼實際上什麼都沒補,但它建立的那個空殼子,卻取代了原本應該有的完整資料——如果傳進來的原始資料剛好是空的,它就會在讀取的當下,把空資料包裝成一個「看起來像物件、但缺東缺西」的殘次品。
合理推測:這支搬家外掛的程式碼有點小瑕疵,遇到原始資料是空的情況,處理得不夠周全。抓到真正的兇手了吧?——於是決定親手把正確的資料組好、直接寫回資料庫。用官方 API 手動查了一輪真實的最新版本號,組成 WordPress 預期的正確格式,寫入。確認寫入成功,重新查詢——這次總算看到正確的「有 14 個外掛可以更新」。
結果,下一秒再查一次——又變回去了。而且這次連「重新整理頁面」都不需要,同一個程式流程裡,前一行才剛確認寫入成功,下一行馬上讀回來,資料已經消失。
真兇現身:一個被遺忘的「暫停鍵」
同一個 process 裡,寫完馬上讀就消失,這代表兇手不是「事後才被誰動了手腳」,是**寫入的當下就被同步攔截刪除**。這種行為模式,通常代表有某個功能正在監聽「有新資料被建立」這個事件,並且立刻採取行動。
查了一下目前掛在「新資料被建立」這個事件上的所有程式碼,其中一條的函式名稱直接把底牌掀開了:maybe_block_set_transient——「或許要封鎖這個 transient 的建立」。而它來自的外掛,正是本站另一支已經裝好、平常用來手動檢視/編輯/刪除 transient 的小工具——Transients Manager。
翻開它的原始碼,真相大白:這支外掛有一個「暫停所有 transient」的全域開關(存在一個叫 pw_tm_suspend 的設定值裡)。這個開關一旦被打開,它會同時攔截三個地方——「讀取前」、「寫入前」、以及「剛被建立的當下」——只要名稱裡含有 _transient 字樣的東西,一律攔截或直接刪除。
wp option get pw_tm_suspend --path=/path/to/site
# 1 <-- 這代表「全站暫停」目前是啟用狀態
wp option update pw_tm_suspend 0 --path=/path/to/site
# 關掉之後,外掛更新檢查、以及其他依賴這套快取機制的功能,才會恢復正常
查了一下這個開關的用途:Transients Manager 的介面上有一顆「Suspend Transients」按鈕,設計原意是給開發者在除錯效能問題時,暫時性地關掉整個 transient 快取機制,方便觀察「如果沒有快取,網站效能/行為會怎樣」。這種功能通常是「按一下、看完效果、記得按回去」的暫時性開關——但顯然在某次操作之後,忘記關回去了,而且一忘就是將近一個月,完全沒有察覺,因為它造成的表面現象太過安靜:不會報錯、不會讓網站當掉,只會讓所有仰賴這套機制的功能,安靜地停在「查不到真正結果」的狀態。
回頭看最開始的搬家外掛:它的程式碼確實有點粗糙,遇到空資料處理得不夠嚴謹,但它本身不是真正的病灶,只是把病灶造成的空白,用一個更難辨認的假象填補起來,讓人一開始很容易被牽著鼻子走、誤判方向。
關掉開關之後,一切恢復正常
把 pw_tm_suspend 關掉、重新觸發一次更新檢查,這次終於看到真實、正確、跟官方版本核對得上的結果:本來以為「都是最新」的外掛,其實有 14 個真的落後好幾個版本,核心版本也確實該更新(而且剛好是修補了兩個已知安全漏洞的版本)。全部更新完成,網站存活檢查一切正常。
意外的插曲:更新途中翻到的攻擊痕跡
整起事件還有個小插曲。在做最後健康檢查、翻 nginx 錯誤 log 的時候,意外發現有一個來源 IP 已經持續攻擊這個網站超過一天半,手法還逐步升級——從掃描已知的後門檔案名稱,到嘗試用特殊參數繞過路徑限制,再到密集地嘗試寫入並執行程式碼、順便偽造各種瀏覽器的身分字串來混淆視聽。所幸從回應內容判斷,這些嘗試目前都是失敗的;而巧的是,這次順手更新的核心版本,剛好補上了其中一個攻擊路徑用到的已知安全漏洞。
這算是意外的提醒:例行維護跟看 log,有時候真的會撞見正在進行中的事,不是每次都只是例行公事。
給之後的自己(跟任何遇到同樣狀況的人)
如果之後又遇到「WordPress/wp-cli 信誓旦旦說外掛都是最新版,但明明看得出來版本落後」這種矛盾現象,不用重新走一次完整的偵探流程,直接照這個順序檢查:
- 如果站上有裝 Transients Manager(或任何類似、標榜可以「暫停/凍結」快取機制的除錯工具),第一件事就是檢查它的全域暫停開關有沒有不小心被留著開啟。
- 如果沒有裝這類工具,可以直接檢查更新檢查資料本身的完整性(
update_plugins這個 site transient 該有的欄位是否齊全),比單純「重新觸發排程」更能看清楚問題出在哪一層。 - 不要看到第一個「有掛勾在這個流程上」的外掛就認定是兇手——本次案例裡,真正顯眼、程式碼也確實有瑕疵的搬家外掛,其實只是幫真兇(一個完全不相關、平常看起來人畜無害的除錯工具)擋了一次子彈而已。
除錯這件事,有時候最花時間的不是解決問題本身,而是意識到自己抓錯了兇手,願意放下已經投入的推論,重新往下挖一層。