LINE 登入沒給 Email 怎麼辦?一個為長輩設計的功能,最後變成 CVE 漏洞

收到 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 拿走管理員帳號

把通報內容拆開來看,整個攻擊路徑其實只有四步,理解它才知道修哪裡:

  1. 攻擊者自己去申請一個 LINE 帳號,這個帳號跟目標網站完全無關,也沒綁 email
  2. 用這個 LINE 帳號去點目標網站的「LINE 登入」按鈕,正常授權
  3. 在瀏覽器 devtools 把 cookie form_notify_line_email 的值改成目標站管理員的 email,例如 admin@example.com
  4. 完成 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 );

幾個重點:

  1. form_notify_line_user_id 是這套邏輯的根——任何時候都用它來確認「這個 LINE 使用者是誰」,不再用 email 比對
  2. 沒 email 的長輩可以照樣註冊——<sub>@line.local 是內部用的佔位符,使用者不會看到、也不會跟真實 email 衝突
  3. @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 重新上架,現在立即更新!

目錄

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料

Picture of 賴俊吾 / Oberon Lai
賴俊吾 / Oberon Lai

現為全職 WordPress 工程師,網站開發經歷 11 年,專攻前端工程與 WordPress 佈景主題、外掛客製化開發

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!

專注於分享 WordPress 開發、接案技巧、專案管理等自由工作者必備知識與心得

© 2025 想點創意科技有限公司

想點創意科技有限公司 | 統一編號 90516823
Designed by Hend Design | 隱私權政策

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!