收到 wordpress.org 寄來的下架通知那天,我看著信裡那串 CVE-2026-5229,腦中浮現的是當初那段 fallback 程式碼——它確實是為了一個真實的客戶需求寫的,但寫的時候沒想到攻擊者也能照走同一條路。
漏洞被 Wordfence 評為 CVSS 9.8,Critical。攻擊路徑很乾淨:Form Notify 在處理 LINE OAuth callback 時,如果 LINE 沒回傳 email,會去讀一個叫 form_notify_line_email 的 cookie 來決定要登入哪個 WP 帳號。任何人從瀏覽器 devtools 把這個 cookie 設成 admin@example.com,跑一次 LINE 登入流程,就能登入網站的管理員。
那這個 cookie 是哪來的?為什麼一個 OAuth 流程會去讀使用者自己塞的 cookie 來決定身分?這得從一個很實際的客戶需求講起。
為什麼會做一個「LINE 沒給 Email 就讀 cookie」的 fallback
Form Notify 的 LINE 登入功能,原本的設計是這樣:使用者按下「用 LINE 登入」按鈕,跳到 LINE 的授權頁,同意之後 LINE 會把 email 一起回傳,外掛就用這個 email 去 WordPress 找對應帳號,找到就登入、找不到就建一個新帳號。
問題出在「LINE 不一定會給 email」。
LINE 的 email scope 需要使用者事前在 LINE 帳號裡綁好 email,而且要在授權頁特別勾選同意提供。實務上,這個比例比想像中低很多——尤其是台灣很多客戶的目標客群是長輩。長輩的 LINE 通常是兒女幫忙裝的,當初註冊時用手機號碼就過了,根本沒綁 email;他們開始用之後,更不會去設定那種隱藏在「個人資料」裡的東西。
當時有客戶直接反映:「我們有八成的會員是阿公阿嬤,他們按下 LINE 登入根本不會出現 email,整個流程就斷在那裡。」
於是我做了一個當時覺得很合理的設計:
- 第一次登入時 LINE 沒給 email → 跳一個前端表單請使用者手動填一個 email
- 把這個 email 存進 cookie
form_notify_line_email - callback 處理時讀這個 cookie,當作這個 LINE 帳號對應的 email
- 用這個 email 去 WordPress 找帳號或建新帳號
聽起來很體貼,對吧?實際上這就是教科書等級的 Authentication Bypass。
攻擊者怎麼用這個 cookie 拿走管理員帳號
把通報內容拆開來看,整個攻擊路徑其實只有四步,理解它才知道修哪裡:
- 攻擊者自己去申請一個 LINE 帳號,這個帳號跟目標網站完全無關,也沒綁 email
- 用這個 LINE 帳號去點目標網站的「LINE 登入」按鈕,正常授權
- 在瀏覽器 devtools 把 cookie
form_notify_line_email的值改成目標站管理員的 email,例如admin@example.com - 完成 LINE 登入流程
接下來伺服器的判斷會走成這樣:「LINE 這次沒給 email⋯⋯那看看 cookie 有沒有⋯⋯有,是 admin@example.com⋯⋯資料庫找一下,這個 email 對應到 administrator 帳號⋯⋯好,登入。」
整個過程不需要密碼、不需要驗證碼、不需要任何屬於管理員的東西。攻擊者甚至不用真的有那個管理員帳號——只要猜得到 email 就好。而很多 WordPress 站的作者頁、文章署名、留言區會直接把管理員的 email 印出來,等於把鑰匙留在門口。
回頭看當初的程式碼,問題其實很明顯:
// 簡化過的舊邏輯(src/APIs/Line/Login/Route.php)
$line_response = $this->verify_line_token( $code );
$email = $line_response->email;
if ( empty( $email ) ) {
// LINE 沒給 email,讀 cookie 補上 ← 災難就在這裡
$email = isset( $_COOKIE['form_notify_line_email'] )
? sanitize_email( $_COOKIE['form_notify_line_email'] )
: '';
}
$user = get_user_by( 'email', $email );
if ( $user ) {
wp_set_auth_cookie( $user->ID ); // 登入
}
sanitize_email 在這裡完全幫不上忙——它只檢查格式合不合法,不檢查這個 email 跟眼前這個 LINE 使用者有沒有任何關聯。用 client 端 cookie 決定要登入哪個 WordPress 帳號這件事本身就是錯的,做再多 sanitize 都救不回來。
修法:用 LINE sub 當帳號識別,不要再用 email
收到通報之後,我立刻移除 cookie fallback 並 push 1.1.10。但這只是把一個明顯的洞補起來,沒有解決原本要服務的場景:長輩沒 email 怎麼辦?
正確的解法是:換一個欄位來識別帳號。
LINE 的 OAuth response 裡有一個叫 sub 的欄位,這是 LINE 給每個使用者的唯一 ID(OpenID Connect 規範裡的 subject)。它的特性正好是我們需要的:
- LINE 經過完整驗證才會回傳給你(不可偽造)
- 同一個 LINE 帳號每次拿到的 sub 都一樣
- 跟 email 完全脫鉤,沒有 email 也照樣有 sub
所以新版的邏輯改成:第一次登入時把 sub 存進 user_meta,之後每次登入直接拿 sub 去找對應的 WP 使用者。
// 1.1.11 之後(簡化版)
$line_response = $this->verify_line_token( $code );
$line_sub = sanitize_text_field( $line_response->sub );
$line_email = isset( $line_response->email ) ? sanitize_email( $line_response->email ) : '';
// 用 LINE sub 找既有帳號(user_meta),不靠 email
$users = get_users( array(
'meta_key' => 'form_notify_line_user_id',
'meta_value' => $line_sub,
'number' => 1,
) );
if ( ! empty( $users ) ) {
// 既有 LINE 使用者,直接登入
wp_set_auth_cookie( $users[0]->ID );
return;
}
// 新使用者:LINE 有給 email 就用真 email;沒有就用佔位符
$user_email = $line_email ? $line_email : $line_sub . '@line.local';
// 註冊新帳號,把 sub 寫進 meta
$user_id = wp_insert_user( array(
'user_login' => 'line_' . $line_sub,
'user_email' => $user_email,
'user_pass' => wp_generate_password( 32, true, true ),
) );
update_user_meta( $user_id, 'form_notify_line_user_id', $line_sub );
幾個重點:
form_notify_line_user_id是這套邏輯的根——任何時候都用它來確認「這個 LINE 使用者是誰」,不再用 email 比對- 沒 email 的長輩可以照樣註冊——
<sub>@line.local是內部用的佔位符,使用者不會看到、也不會跟真實 email 衝突 @line.local而不是@line.com——後者是真的有人註冊的網域,用.local確保這個假 email 永遠不會跟真實 email 撞到
長輩客戶想要的「不用 email 也能登入」這個體驗保留了,但身分識別這件事完全交給 LINE 已驗證的 sub,不再相信任何客戶端傳來的東西。
這次踩坑學到的事
如果你也在做 OAuth 整合的外掛,這次經驗能直接抄走的幾條:
OAuth 身分識別永遠用 provider 的 unique ID,不用 email
Email 是給人看的、會變、可能沒有;sub 是給程式比對用的、不會變、一定有。把 sub 存進 user_meta,之後所有登入流程都從這個 meta 去查。任何 OAuth provider 都會給一個對應的欄位——LINE 的是 sub、Google 也是 sub、Facebook 是 id、GitHub 是 id,都是同一個概念。
Client cookie 不能參與「決定登入誰」的邏輯
Cookie 是使用者自己可以隨便改的東西,不管你 sanitize 得多乾淨,它的內容都不該影響「這個 request 對應到哪個 WordPress 帳號」這個決定。需要在 OAuth 流程中暫存狀態的話,用 transient 或 server-side session(雖然 wp.org 不准 session_start,但 transient 是 OK 的)。
使用者體驗不能蓋過身分驗證
我當初想解決的「長輩沒 email」是真實問題,但解法選錯了。正確的做法是讓 fallback 機制本身仍然安全(用 sub 識別),而不是退回去信任使用者輸入。每次想做「為了體驗放寬一下」的時候,先問自己:放寬的這條路,攻擊者能不能照走一次?
外掛上架後的 SVN tag 是公開、永久、無法收回的
1.0.0 寫進去的東西,你之後再怎麼修都還在 trac 裡可以瀏覽。Wordfence 的研究員會直接從 SVN 歷史挖。所以送審前的 code review 一定要當成「這份程式碼會永遠被人翻來翻去」的標準在做,而不是「能跑就好」。
Form Notify 1.1.11 已經部署到 SVN,等 wordpress.org 重新審核。如果你正好在做 LINE 登入或任何 OAuth 整合,希望這篇能幫你少踩這個洞——它真的不是那種「明顯的錯誤」,是當你想著「怎麼讓阿嬤也能用」的時候,一不小心就會走到的路。
如果你的網站正在使用 Form Notify,請務必盡快更新到 1.1.11,舊版本(≤ 1.1.08)存在 CVE-2026-5229 的高風險漏洞,攻擊者可在不知道密碼的情況下接管管理員帳號。
下載連結:https://github.com/oberonlai/form-notify/releases/tag/1.1.11
別等 wordpress.org 重新上架,現在立即更新!