多數人以為駭客要闖進一個 WordPress 後台,第一步是拼命亂猜密碼;其實在那之前,他們通常先解決一個更簡單的問題,這個網站上有哪些帳號名稱是真的存在的。只要先把「有效帳號」這件事確定下來,接下來要猜的就只剩密碼一項,成功率立刻拉高一截。這個先確認帳號存不存在的動作,就是安全圈說的使用者列舉(user enumeration),針對 WordPress 網站做的就叫WordPress使用者列舉,攻擊者不需要登入、不需要任何權限,光靠瀏覽網址或呼叫幾支公開的 API,就能把一個 WordPress 網站上真正在用的登入帳號名稱一個一個掃出來。
多數架站的人以為只要密碼夠複雜就安全,卻沒注意到 WordPress 預設安裝本身就留了好幾個管道,會把「登入用的帳號名稱」洩漏出去,而且這個帳號名稱跟文章作者欄看到的顯示名稱根本是兩件事,換掉顯示名稱不代表登入名稱也跟著藏好了。作者封存頁的數字網址、REST API 的使用者清單、內建的 XML Sitemap,一路到登入表單本身的錯誤訊息,都是自動化工具最常拿來掃描帳號名稱的管道。
使用者列舉是什麼?
先把兩件事分清楚:一個是登入用的帳號名稱(WordPress 內部叫 user_login),也就是你在登入頁那個帳號欄要打的字串;另一個是公開顯示名稱,也就是訪客在文章作者欄、留言簽名看到的那個名字。這兩者預設常常長得一模一樣,但它們其實可以完全不同。使用者列舉攻擊的目標,就是想辦法把前者(登入帳號)挖出來,就算網站主人已經很小心地把顯示名稱換成別的字,登入用的那串帳號名稱只要走錯地方還是照樣曝光。

之所以要特別在意這件事,是因為它把攻擊者原本要解的兩道難題,硬生生砍成一道。正常情況下,要闖進一個帳號需要同時猜中帳號名稱與密碼;一旦帳號名稱先被公開查到,剩下要解的就只有密碼這一項,暴力破解與帳密填充(credential stuffing,拿其他外洩事件流出的帳密組合去試)這類自動化攻擊的效率會明顯提升。作者封存頁的數字網址、REST API 的使用者清單、內建 XML Sitemap 裡的作者清單,以及登入表單本身透露出來的線索,正是最常被自動化掃描工具拿來用的幾個管道。這幾個管道處理的都是攻擊鏈最前面那一步的偵察,密碼夠不夠強是另一層防線,留到文末再談。
預設安裝下,帳號名稱外洩的四個管道
這不是單一一個漏洞,而是四件各自獨立、原本各有各的正當用途的功能,湊在一起就變成帳號名稱的洩漏管道。作者封存頁是為了讓訪客瀏覽某位作者發過的文章、REST API 是給區塊編輯器與各種外部應用程式讀寫資料用的、XML Sitemap 是為了讓搜尋引擎更快找到頁面、登入表單的錯誤訊息則是想讓使用者清楚知道自己打錯了什麼。每一項功能單獨看都合理,但只要沒特別處理,同時湊在一起就等於幫任何想掃描帳號的人把資料準備好了。把這四個管道拆開來看,也才知道接下來要一步一步各自處理,而不是找一個外掛裝上去就能一次解決。

作者封存頁把數字直接對應到帳號名稱
WordPress 每個使用者都有一組固定的作者封存頁網址,用來列出這位作者發表過的文章。如果在網站網址後面加上 ?author=1、?author=2 這種參數,WordPress 會自動把請求轉址到那個數字對應帳號的作者頁,網址列也會跟著換成該帳號的固定網址。這代表攻擊者完全不需要事先知道任何帳號名稱,寫一個簡單的迴圈把數字從 1 掃到幾百,看每次轉址後網址列變成什麼,就能把整個網站的登入帳號名稱兜出來,而且這個動作連登入都不必,是最古老也最容易自動化的一種列舉手法。
特別值得留意的是使用者編號 1,這通常是網站建立時第一個建立的帳號,早期的 WordPress 安裝流程,以及部分主機商到現在仍在提供的一鍵安裝工具,可能還是沿用舊版的預設帳號名稱 admin。也就是說,光是掃 ?author=1 這一組,就有機會直接拿到「最有價值」的那個帳號名稱。麻煩的是 WordPress 核心並不允許直接修改一個既有帳號的登入名稱,常見的處理方式是新建一個帳號、把原帳號名下的文章轉移到新帳號,再把舊帳號刪除。
REST API 使用者清單預設不必驗證即可讀取
GET /wp-json/wp/v2/users 是 WordPress 核心內建的一支端點,任何人不用登入、不用任何驗證,直接呼叫就能拿到一份使用者資料清單,一次請求就能把好幾個帳號的資料兜出來,效率比一個一個試 ?author=N 快得多。要注意的是,未登入的訪客看到的其實是限縮過的欄位版本,不會直接看到 username 這個登入欄位本身,公開看得到的是 slug(作者頁網址裡那段固定字串)、name(顯示名稱)、id、link(作者頁網址)、url(個人網站網址)、description(自我介紹)、avatar_urls 這幾項;username、email 這類欄位只有在已登入且有足夠權限的情況下才會出現。
問題在於,WordPress 建立帳號時,slug 這個欄位預設就是直接照登入用的帳號名稱自動產生的,除非管理員事後特別去改,否則兩者幾乎是同一串字。也就是說,即使 REST API 沒有把 username 欄位原封不動攤開,公開回應裡的 slug 跟作者頁網址,在多數預設安裝下實質上就等於洩漏了登入帳號名稱。這裡還有一段容易被誤解的版本紀錄,WordPress 曾在 4.7.1 這個版本修補過這支端點的曝光範圍,在那之前,只要某個帳號發表過任何公開文章類型的內容,資料就會被列進清單;修補之後,範圍限縮成只列出「該文章類型有設定要顯示在 REST API 裡」的使用者。但一般文章這個類型,預設就是開著顯示在 REST API 的,所以典型部落格網站上「發過文章的作者」,在今天的預設安裝下依然可以透過這支端點被公開查到,那次修補並沒有把一般作者的資料整個藏起來。
內建 XML Sitemap 一併把作者清單攤開
這是四個管道裡相對較新、也最容易被忽略的一個。WordPress 核心從 5.5 版開始內建 XML Sitemap 功能,會在 /wp-sitemap.xml 自動產生一份索引,預設涵蓋文章、分類法、以及使用者(也就是作者封存頁)這三種既有的 sitemap provider,而且 robots.txt 還會主動指向這份索引,方便搜尋引擎去讀。作者這個 provider 一樣會把每一位有發文的使用者對應的作者封存頁網址列進去,等於在做 SEO 的同時,順手把帳號名稱清單準備好,而且不只是給搜尋引擎看,任何人打開這份索引都能直接讀到。
因為這個功能是後來才加進核心的,很多在它推出之前寫成的舊文章、舊教學完全沒提到這個管道,導致不少網站主人明明已經處理掉作者封存頁的數字列舉、也關掉了 REST API 的使用者端點,卻漏了這個一樣會把作者清單攤開的地方。要移除某個 provider,可以用核心提供的 wp_sitemaps_add_provider 這個過濾器,在回呼函式裡判斷傳進來的名稱是不是等於 users,是的話就回傳 false,讓這個 provider 不再輸出任何內容。
登入表單的錯誤訊息會透露帳號是否存在
前三個管道都是攻擊者主動去查、去讀某支端點或某個網址;這一個不一樣,是被動洩漏。預設情況下,WordPress 登入表單對「這個帳號名稱根本不存在」跟「帳號名稱存在、但密碼打錯」,回傳的錯誤訊息文字並不相同,前者通常會直接告訴使用者帳號不存在,後者才會提示密碼錯誤。這個差異等於讓登入表單本身變成一個現成的驗證工具,攻擊者只要一組一組把猜測的字串丟進去,看回傳訊息就能反推出哪些帳號名稱是真的存在,完全不需要動用網址參數或呼叫任何 API。
這個管道之所以值得單獨拿出來講,是因為前面三種都能靠伺服器設定或程式碼一次性堵住,這一個卻要動到登入流程本身的訊息內容,處理方式也不同,屬於「主動查詢」之外的另一條線。
從列舉帳號到暴力破解只差一組密碼
先處理帳號名稱洩漏這件事,而不是先去談密碼要多複雜,是因為兩者在攻擊鏈上的位置不一樣。暴力破解與帳密填充要有效率地成立,通常需要同時掌握一個真實存在的帳號名稱與一組密碼;一旦帳號名稱可以先被公開查到,攻擊者要解的問題就從「同時猜兩件事」降成「只猜密碼」,自動化工具能在同樣的時間裡試更多組合,成功機率也隨之提高。國際資安風險框架 OWASP 在其 2021 年版的十大風險清單裡,把「允許帳密填充攻擊」與「允許暴力破解或其他自動化攻擊」都列進同一個「身分識別與驗證失敗」的弱點特徵底下討論,並在如何預防那段明白建議,註冊、憑證救援與各種 API 路徑都應該針對帳號列舉攻擊做強化,做法是不論結果如何都回傳同樣的訊息,避免對外洩漏帳號是否存在。
安全業者 Melapress 也提出類似的觀察,指出攻擊者一旦掌握有效的帳號名稱,接下來只需要專注在密碼猜測這一件事上,而且常把列舉到的帳號名稱,跟其他資料外洩事件流出的密碼庫組合起來做帳密填充攻擊,不必自己一個一個亂猜。這也是為什麼把帳號名稱藏好,能直接壓低整個攻擊鏈的成功率,即使密碼設定沒有跟著變得更複雜。攻擊鏈最前面那一步的偵察,只要能擋掉,後面不管是暴力破解還是帳密填充,攻擊者要下的功夫都會多出一大截。密碼本身這道防線要怎麼補強,留到文末再談,先看帳號名稱要怎麼一步一步藏好。
第一步,讓帳號名稱與公開顯示名稱脫鉤
第一個修法不用寫任何程式碼就能先做,而且值得放在最前面,做法是把登入用的帳號名稱,跟站上任何人都看得到的顯示名稱徹底分開。就算後面四個管道要花時間一個一個堵,只要帳號名稱本身不再等於任何公開看得到的字串,攻擊者要「猜到」正確帳號名稱的難度就先拉高了一截,這一步做完,後面每一項修法的效果都會更扎實。
這件事包含兩個動作。第一個動作是把還在用 admin 或其他容易猜的帳號改掉。前面提過,WordPress 核心不允許直接修改一個既有帳號的登入名稱,通常的做法是新建一個帳號、把原本那個帳號名下的文章全部轉移過去,確認內容都搬完之後,再把舊帳號刪除。第二個動作是回到後台「使用者」底下的「個人資料」頁面,設定一個跟登入帳號不同的暱稱,並在「以此暱稱公開顯示為」的下拉選單裡,挑一個不是登入帳號本身的顯示組合,例如暱稱、名字加姓氏都可以,只要不選擇跟登入帳號同名的那個選項,前台的文章作者欄與留言簽名就不會直接印出登入用的帳號名稱。這兩個動作做完,就算之後某個管道還是不小心洩漏出一串字,那串字也不再是攻擊者拿去試密碼的有效帳號。

第二步,攔截作者網址的數字列舉手法
處理完帳號名稱本身,接下來要對付最經典的那個管道,網址帶著 author 數字參數、或直接連到某個作者的固定網址時,把這個請求攔下來,不再乖乖轉址到「這個數字對應這個帳號」的頁面。這裡有兩種做法可以選,選哪一種要看網站架在什麼樣的伺服器環境上。
伺服器層級的做法是在 .htaccess 裡加規則,在請求進入 WordPress 應用程式之前,就先在網站伺服器這一層判斷網址是不是帶了 author 參數,是的話直接擋下來,回一個 404 或轉回首頁。這種做法的好處是攔截動作發生在應用程式啟動之前,處理起來比較輕量,但前提是網站架在支援 .htaccess 的 Apache 環境上;如果主機是 Nginx,沒有 .htaccess 可用,就得改走下一種做法。WordPress 程式層級的做法是在 functions.php 或一支獨立的 mu-plugin 裡掛一段程式碼,判斷目前請求是不是作者封存頁、且訪客沒有登入,是的話就攔下來轉址回首頁或回應 404,這種寫法不需要動主機的設定檔,也不挑伺服器種類,缺點是請求已經進到 WordPress 內部才被攔下,比伺服器層級稍微多花一點資源。
如果網站本身只有一位作者,或者整個網站根本沒有部落格功能、作者封存頁對讀者來說毫無意義,其實可以更乾脆一點,直接把作者封存頁整個關掉,而不是只攔截數字列舉這一種手法。市面上也有像 Disable Author Archives 這類啟用後不用設定的輕量外掛,可以做到同樣的效果,讓任何人不管用什麼方式試著進到作者封存頁,看到的一律是 404。這種做法比單純攔截 ?author=數字 更徹底,因為就算攻擊者換一種網址寫法去試探,作者封存頁本身已經不存在了。
第三步,關閉 REST API 使用者清單的公開存取
這一步不必把整個 REST API 關掉,區塊編輯器這類核心功能要靠它才能運作,整組關閉會連帶弄壞編輯器;只要讓 /wp/v2/users 對未登入的訪客失效就夠了。已經登入的編輯者、管理員仍然要能讀這支端點,因為區塊編輯器裡指派文章作者的下拉選單,就是靠讀取使用者資料才能運作的。
做法是掛一個 rest_endpoints 過濾器,先用 is_user_logged_in() 判斷,只在訪客沒有登入時,把不帶 id 的使用者清單路由與帶著使用者 id 的單一使用者路由一起移除:
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
if ( isset( $endpoints['/wp/v2/users'] ) ) {
unset( $endpoints['/wp/v2/users'] );
}
if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
}
}
return $endpoints;
} );Code language: PHP (php)
這段程式碼建議寫成一支放在 wp-content/mu-plugins/ 目錄底下的 must-use plugin,而不是放進主題的 functions.php,換主題時才不會跟著一起消失。前面提過,WordPress 4.7.1 那次核心修補並沒有把一般作者的資料整個藏起來,這裡的過濾器仍然是必要的一步。
第四步,把內建 Sitemap 裡的作者清單也一併關掉
最容易被忽略的一個管道,也是相對新的一個,處理起來反而最簡單。前面提過 WordPress 核心會在 /wp-sitemap.xml 自動產生一份 sitemap 索引,預設涵蓋文章、分類法、以及使用者這三種既有的 provider,而且 robots.txt 還會主動指向這份索引,讓搜尋引擎方便讀取。要拿掉作者這個 provider,同樣用 wp_sitemaps_add_provider 這個過濾器,在回呼函式裡判斷傳入的 provider 名稱是不是等於 users,是的話回傳 false,這個 provider 就不會再輸出任何 sitemap 內容,程式碼可以直接寫進前面同一支 mu-plugin 裡:
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
if ( 'users' === $name ) {
return false;
}
return $provider;
}, 10, 2 );Code language: PHP (php)
如果網站本身有裝 Yoast SEO、Rank Math 這類 SEO 外掛,通常不用自己動手寫程式碼,因為這些外掛大多會接管或關閉核心內建的 sitemap 功能,改用自己的 sitemap 系統,而在外掛的設定畫面裡,一般都能找到對應的開關,直接把作者的 sitemap 關掉即可。哪一種方式適合,取決於網站有沒有裝這類外掛,兩種做法達到的效果是一樣的,重點是這個管道不能因為它比較新、比較少人提到,就漏掉不處理。
第五步,把登入表單的錯誤訊息統一成同一句話
第二到第四步處理的都是「主動查詢」型的管道,攻擊者要嘛掃網址、要嘛呼叫 API、要嘛翻 sitemap 索引;登入表單這個管道不一樣,屬於被動洩漏,只要有人肯一組一組把猜測的帳號丟進登入框,答案就會自己浮現。WordPress 預設對「帳號名稱根本不存在」跟「帳號名稱存在、密碼打錯」,回傳的錯誤訊息文字並不相同,等於幫攻擊者內建了一支免費的驗證工具。
要堵住這個管道,做法是掛 WordPress 核心自 2.1 版就內建的 login_errors 這個過濾器,它會在登入失敗、準備把錯誤訊息印到畫面上之前先跑過一次,接手的函式可以把傳入的訊息整段換成一句固定文字,不管背後真正的原因是帳號打錯還是密碼打錯,訪客看到的永遠是同一句話。程式碼大致長這樣:
add_filter( 'login_errors', function () {
return '帳號或密碼不正確,請重新確認後再試一次。';
} );Code language: PHP (php)

這段程式碼跟前面 REST API、Sitemap 那兩段一樣,建議放進 wp-content/mu-plugins/ 目錄底下同一支 must-use plugin,理由也一樣,不會因為換主題而跟著被停用。這裡刻意不去判斷傳入的字串裡有沒有出現「帳號」或「密碼」這幾個字才決定要不要換句話,而是無論如何都整段覆蓋,原因是 WordPress 核心的錯誤訊息文字會隨著版本更新、也會因為多語系安裝而長得不一樣,用字串比對去抓特定訊息很容易漏接某個版本或某個語系。WordPress 開發者文件這個過濾器頁面下方,使用者提供的範例裡還有另一種寫法,是先讀取全域變數 $errors 裡的錯誤代碼、再判斷要顯示哪一句話,但同一頁就有開發者回報,那個全域變數在某些情況下會是空值,直接讀取會讓程式噴出例外;整段覆蓋不需要判斷任何代碼,自然就避開這個雷。
統一成同一句話之後,有一個必須接受的取捨,網站主人自己登入打錯時,也一樣只會看到這句通用訊息,沒辦法馬上分辨是帳號打錯還是密碼打錯,得自己重新確認兩者。這跟前面幾個管道擋掉就是全贏不太一樣,統一錯誤訊息換來的是安全性,付出的代價是自己除錯時要多一個步驟,屬於合理的取捨。
設定完成後,用這四個檢查點確認帳號名稱已經藏好
前面五步設定完,不代表一定生效,尤其某些做法牽涉到快取或外掛之間的互相影響,設定完就直接假設有用是很危險的習慣,回頭實際測試一次,確認每個管道真的看不到帳號名稱了,才算把這件事做完。
第一個檢查點是重新在網址後面加上 ?author=1 測試,改完之後應該看到 404 頁面,或是被轉址回首頁,而不是像原本那樣被轉到某個帳號的作者頁。第二個檢查點是用無痕視窗直接打開 /wp-json/wp/v2/users 這個網址,應該看到找不到路由的 404 錯誤(rest_no_route),而不是一份完整的使用者清單;接著用已登入的帳號打開區塊編輯器,確認文章作者下拉選單還列得出作者,代表沒有連帶鎖死後台的功能。直接在網址列打開這支端點不會帶上 REST API 驗證用的 nonce,就算已經登入也會被當成未登入的訪客,所以已登入這一端要從編輯器確認,不要看網址列的結果。第三個檢查點是打開 /wp-sitemap.xml 這份索引,確認清單裡已經不再出現作者這個項目,如果網站裝了 SEO 外掛,也要到外掛自己的 sitemap 設定裡確認同一件事。第四個檢查點是回到登入表單,刻意打一個不存在的帳號名稱,再打一個存在但密碼錯誤的帳號,比較兩次看到的錯誤訊息是不是已經統一成同一句話,如果還是分別顯示帳號不存在跟密碼錯誤,代表這個管道還沒處理。四個檢查點都過了,才算真正把帳號名稱藏好,而不是只是設定完就以為沒事。
帳號名稱藏好之後,密碼防線還要靠登入防護補強
藏好帳號名稱,解決的是「先擋住猜帳號」這一層,但誠實地說,這不是終點。就算前面四個管道全部堵好,帳號名稱完全查不到,密碼夠弱一樣可能被直接猜中,或是被拿現成的外洩密碼庫成功撞庫。帳號列舉防護跟密碼層的防線,扮演的角色不一樣,前者擋的是攻擊者的偵察階段,讓對方一開始就拿不到有效的帳號名稱;後者,也就是登入次數限制、雙重驗證這類機制,擋的是攻擊者已經在嘗試登入的階段,這兩層互補,不是二選一的關係。
OWASP 在同一份身分驗證失敗的指引裡,也建議盡可能導入多重要素驗證,防止自動化的帳密填充、暴力破解與被竊憑證重複使用攻擊,並建議限制或逐步延遲失敗的登入嘗試次數,同時要留意別因此把系統設計成攻擊者能拿來癱瘓正常使用者登入的漏洞。帳號名稱先藏好,只解決了第一層;第二層要怎麼設定登入防護、要選哪一種雙重驗證方式,是接下來需要處理的另一個課題。這兩層都做到位,一個 WordPress 網站的登入防線,才算真正經得起自動化工具的長期試探。
