跳至主要內容
技術

把 DMARC 推到強制的那天:三個「看起來成功」的假訊號

誰在冒用你的 Domain 寄信 第 4 / 4 篇 ,前往系列總覽

本篇是「誰在冒用你的 Domain 寄信」系列的第 4 / 4 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

三週前我寫了 #3 那份 playbook,把 DMARC 從 p=none 推到 p=reject 的分階段流程整理得很整齊。這篇是實際執行那天的記錄。

實際的節奏跟原訂計畫不完全一樣:監控期比 playbook 寫的短,quarantine 那一階也沒有停滿。這是公司內部的排程決定,就照著做了。結果是沒出事,但過程中踩到三個「看起來已經完成、實際上沒有」的訊號——那才是這篇要記的東西。

📎 這是「誰在冒用你的 Domain 寄信」系列的第 4 篇,執行紀錄。完全不熟 SPF/DKIM/DMARC 先看 #2 白話入門;想照流程自己做一次,看 #3 實戰 playbook;整件事的起點是 #1 我如何重現一個資安評等的 F

先講結果

當天做完的東西:

  • 12 筆 DNS 變更(SPF、DKIM、DMARC 三類)
  • 6 條寄件管道逐條盤點確認
  • 11 條 DKIM 鏈全部追到最終記錄驗過
  • 最後把 DMARC 切到 p=reject,SPF 收緊成 -all

驗收方式很土:寄一封測試信到 Gmail,開「顯示原始郵件」,看那三行。

Gmail「顯示原始郵件」頁面的上半部資訊區:左半邊依序列出郵件大小、建立時間、傳送時間 11 秒等欄位,右半邊是三行驗證結果——第一行 SPF 標示 PASS、第二行 DKIM 標示 'PASS'、第三行 DMARC 標示 'PASS',三行全部通過;主旨欄位寫著「DKIM 測試」,寄件者信箱、收件者信箱與伺服器 IP 等敏感欄位已用深色色塊打碼遮蔽

三行全綠。看起來就是「做完了」。

問題是,這張截圖只證明了我手動寄的這一封通過。當天真正花時間的,是搞清楚另外五條管道到底有沒有跟上——而那件事,dig 查不出來。

三個假訊號住在哪裡

一封信從「我設定好了」到「對方真的收下」,中間有五個環節。我原本以為只要第一個對了,後面就會跟著對:

flowchart LR
    A["DNS 上有記錄<br/>dig 查得到"] -->|假訊號 2| B["DKIM 鏈追到底<br/>真的有公鑰"]
    B -->|假訊號 1| C["SaaS 後台真的<br/>用你的網域簽章"]
    C --> D["寄出去<br/>API 回 200"]
    D -->|假訊號 3| E["收件方真的收下"]

三個假訊號各自住在其中一個縫隙裡。(編號是我遇到的順序,不是管線上的順序。)

假訊號 #1:DNS 加了 DKIM ≠ DKIM 開了

Zendesk 的設定文件會叫你在 DNS 加兩筆 CNAME:zendesk1zendesk2。我加了,dig 查得到,一切看起來完成。

但 Zendesk 後台還有一個勾選框叫 Custom domain for DKIM。沒勾之前,Zendesk 照樣用它自己的 d=zendesk.com 簽章。

Zendesk Admin Center 的 email 設定畫面:一個標示為 Custom domain for DKIM 的勾選框,目前狀態是已勾選;勾選框下方是 Zendesk 自己的說明文字,要求在自家 DNS 加上兩筆 CNAME 記錄,分別命名為 zendesk1 與 zendesk2,說明中以 example.com 作為範例網域

#2 那條姓氏規則來說:火漆印確實有蓋,但印上壓的是 Zendesk 家的姓,不是我們家的。對齊要的是同姓,不同姓就不算數——連分部都不是。

而最陰險的地方是:從 DNS 那端完全看不出差別。

flowchart TB
    DNS["DNS 上:zendesk1 / zendesk2<br/>兩筆 CNAME 都加了、都解得到"]
    DNS --> OFF["後台開關<br/>沒勾"]
    DNS --> ON["後台開關<br/>勾了"]
    OFF --> R1["簽章送出 d=zendesk.com<br/>跟 From 不同姓 → DKIM 對齊失敗"]
    ON --> R2["簽章送出 d=yourdomain.com<br/>同姓 → DKIM 對齊通過"]

左右兩條路,dig 看到的東西一模一樣。差別只存在於一個別人家後台裡的勾選框。

Google Workspace 是同一個模式:DKIM 的 TXT 加進 DNS 之後,還要回後台按 Start authentication,沒按就是沒開。

順序不能反

Zendesk 自己的文件把這件事寫得很清楚:那個開關要等 CNAME 生效之後才能開。反過來做——先勾開關、CNAME 還沒生效——送出去的信會帶著一把查不到公鑰的簽章,DKIM 這條路直接廢掉;在 p=reject 之下,能不能撐住就只剩 SPF 那條沒把握的路。

flowchart LR
    subgraph OK["正確順序"]
        direction LR
        A1["加 CNAME"] --> A2["確認解得到"] --> A3["才勾開關"]
    end
    subgraph NG["順序反了"]
        direction LR
        B1["先勾開關"] --> B2["CNAME 還沒生效"] --> B3["查不到公鑰<br/>→ 投遞失敗"]
    end

也就是說,這個開關早開會讓 DKIM 直接失效、晚開會沒對齊——p=reject 之下兩邊都是把賭注壓在沒把握的 SPF 上。順序本身就是唯一的安全路徑。

一個要講清楚的精確度

DKIM 沒對齊不等於 DMARC 一定失敗。DMARC 是 OR 邏輯——SPF 對齊且通過、或 DKIM 對齊且通過,任一成立就算 pass。所以 d=zendesk.com 的信會不會被退,還要看那條管道的 SPF 對齊狀況。

網路上關於 Zendesk 的 SPF 對齊有互相矛盾的說法,它自己的文件也沒交代 Return-Path 怎麼設,我沒辦法證實任何一邊。所以當天的做法很單純:把 DKIM 這條補到確定對齊,不去賭 SPF 那條。

怎麼真的驗:寄一封測試信,看 Authentication-Results 裡的 dkim=pass header.d= 是誰。header.d= 是你的網域才算數。這是唯一從外面看得出差別的方法。

假訊號 #2:CNAME 解得到 ≠ DKIM 有效

Shopify 這條的狀況不一樣,而且更難發現。

它的 selector CNAME 指向 100% 正確——名字對、目標對、dig 一路都解得到。但把這條鏈追到最終目標的 TXT,那格是空的。目標端當下還沒 provision 好。

比喻的話:DNS 上的路標完全正確,指向存放印模的櫃子;但櫃子當下是空的。郵局照路標去找,找到一個空櫃,驗不了章。

flowchart LR
    A["selector._domainkey.<br/>yourdomain.com"] -->|"CNAME 完全正確"| B["Shopify 的<br/>DKIM 主機"]
    B --> C{"目標端的 TXT<br/>真的有公鑰嗎?"}
    C -->|"有"| D["DKIM 可用"]
    C -->|"空的,還沒 provision"| E["DKIM 驗不過<br/>但 CNAME 一路都對"]

大部分人(包括我)查到第二格就收工了,因為看到 CNAME 有回應就以為結束了。

# 只查 CNAME:看得到指向,看不到內容
dig +short CNAME selector._domainkey.yourdomain.com

# 追到底:這才是收件方真正拿去驗章的東西
dig +short TXT selector._domainkey.yourdomain.com

第二個指令的回傳裡如果看不到 v=DKIM1; p=... 那一段,就是還沒好。驗 DKIM 不能只看 CNAME 有沒有解到,要追到最終的 TXT。

後來 Shopify 那端 provision 完成,後台狀態才轉成 Authenticated:

Shopify 後台的 Email domain authentication 設定頁面:網域名稱旁邊有一個狀態徽章,寫著 Authenticated;徽章下方有一行說明文字寫著 DNS records updated globally,表示 DNS 記錄已在全球各地的解析伺服器生效

同一個 p=reject,兩家 SaaS 的反應完全不同

這是當天另一個意外收穫。同樣是「你的網域已經 p=reject、但這條管道沒設好」:

  • Zendesk:照寄。萬一那條管道的 SPF 也沒對齊,信就會被收件方退掉——至少會有退信、有聲音。(前面說過,Zendesk 的 SPF 對齊我沒能證實,所以這條路徑實際會不會退,我只能說「有機會有聲音」。)
  • Shopify:偵測到驗證記錄沒設好時,把 From 換成它自己的網域寄出去。不會退信,但寄件人不是你。

Shopify 文件寫的觸發條件是「authentication records aren’t configured」,不是「偵測到你的網域有 DMARC」。這兩件事在 debug 的時候很容易被講混,講混了就會去查錯的東西。

「不會退信」聽起來比較友善,但它其實更難抓:信有送到、收件人也讀了,只是寄件人顯示的不是你的品牌。整條路上沒有任何錯誤訊息——除非你自己去看那封收到的信長什麼樣子。

假訊號 #3:email_logs 有紀錄 ≠ 信送到了

這是當天最有料的一個。

我們自己有一張 email_logs,每寄一封信就寫一列。要驗「今天的系統信有沒有正常寄出」,直覺就是去查這張表:表裡有那一列 → 寄出去了。

不是。

Laravel 的 MessageSent 事件,是在 Mailer.php 把訊息交給 transport 之後才觸發的。用 Mailgun 的話,那個「之後」的精確時間點是——Mailgun API 回 HTTP 200 的那一刻

而 Mailgun 那個 200 的 response body,依它自己的文件是:

{"id": "<20260907...@mg.yourdomain.com>", "message": "Queued. Thank you."}

(這是 Mailgun 文件記載的回應字串,不是我當天抓的封包。)

Queued. 它一直很誠實。是我們把「排進佇列」讀成了「寄到了」。

而我們那張表存的是:

// 我們自己的 listener(示意)
class LogSentMessage implements ShouldQueue
{
    public function handle(MessageSent $event): void
    {
        EmailLog::create([
            'from'    => ...,
            'to'      => ...,
            'subject' => ...,
            'body'    => ...,
        ]);   // 沒有 status、沒有存 Mailgun 回的 message id
    }
}

所以一封被 550 退掉的信,在這張表裡跟一封成功送達的信長得一模一樣:

email_logs idfromtosubjectcreated_at 1041no-reply@ex.comalice@acme.com10:12:04 1042no-reply@ex.combob@acme.com10:12:04 密碼重設密碼重設 實際結果(不在表裡) 對方收下了 被 550 退回 表上沒有 status,也沒有存 Mailgun 的 message id。 虛線右邊那一欄才是你真正想知道的事,而它不在資料庫裡。
送達的和被退回的,在 email_logs 裡是同一種樣子。

畫成時序圖更清楚——問題不在哪一步做錯了,而在最後那條回饋路徑根本不存在:

sequenceDiagram
    autonumber
    participant App as Laravel App
    participant MG as Mailgun API
    participant Q as queue worker
    participant DB as email_logs
    participant MTA as 收件方 MTA

    App->>MG: POST /messages
    MG-->>App: HTTP 200 — Queued. Thank you.
    App->>App: 觸發 MessageSent 事件
    App->>Q: dispatch listener(ShouldQueue)
    Q->>DB: INSERT from / to / subject / body
    Note over DB: 這一列寫下的瞬間<br/>信還躺在 Mailgun 的佇列裡
    MG->>MTA: 幾秒到幾分鐘後才真的投遞
    MTA-->>MG: 550 拒收
    MG--xApp: repo 裡沒有 webhook 接這個事件

兩個容易被忽略的精確度細節

(a) transport 判斷成功是精確比對 HTTP 200,不是任何 2xx。 Mailgun 目前就是回 200,所以沒事;但這個假設沒有寫在任何地方,哪天對方改成 202 Accepted 之類的,行為會怎麼變是另一件事。

(b) 寫這張表的 listener 是 ShouldQueue 那一列是 queue worker 寫進去的,不是寄信的那個 request 寫的。這代表這張表兩個方向都不能當證據:

  • 有 row,只證明 Mailgun 回了 200 Queued。
  • 缺 row,什麼都證明不了——queue job 掉了、worker 沒跑、job 正在失敗重試,都會沒有 row。信可能好好地寄到了。

人寄的看得到,系統寄的看不到

當天最後理清楚的是這件事:同樣被收件方擋掉,人跟系統的「可見度」差非常多。

flowchart TB
    subgraph HUMAN["同仁用 Gmail 寄"]
        direction TB
        H1["Gmail 寄出"] --> H2["對方擋掉"] --> H3["退信通知回到<br/>同仁自己的收件匣"] --> H4["有人看到 → 會來問"]
    end
    subgraph SYSTEM["系統走 Mailgun 寄"]
        direction TB
        S1["API 回 200 Queued"] --> S2["對方擋掉"] --> S3["退信回到 Mailgun 的<br/>Return-Path"] --> S4["repo 裡沒有 webhook<br/>接退信事件"] --> S5["沒有任何人看到"]
    end

p=reject 上線後,如果哪條系統寄件管道沒設好,你會知道的唯一方式是——有人跑來跟你說「我沒收到」

一個誠實的但書:也有收件方是先收下、再默默丟進垃圾桶或直接丟棄的,那種情況連退信都不會產生。所以反過來也成立——「沒有退信」也不等於「有送到」。

如果你手上真的抓到退信,順帶提醒一句:Gmail 的 550 5.7.26 不是 DMARC 專用碼,它有三種文件記載的意義。判讀請連錯誤訊息後面那串文字一起讀,只看數字會判錯方向。

當天沒做的一件事,和差點引錯的一份文件

沒把對齊改成嚴格

當天有人問:既然都要推 p=reject 了,要不要順便把 adkim / aspf 改成 s(嚴格)?看起來比較安全。

沒改,而且這是當天最果斷的一個決定。我們的交易信走 Mailgun,DKIM 的 d= 和 Return-Path 兩邊都掛在 mg. 子網域上。兩個 tag 同時收緊,這條管道的 SPF 和 DKIM 會在同一瞬間一起失去對齊——DMARC 的 OR 邏輯救不了它,因為兩條路同時斷了。整批交易信會當場掛掉。

換來的收益幾乎是零。改成嚴格擋不掉一般的冒名信——偽造信本來就不姓我們家的姓,在寬鬆規則下早就在失敗了。你多擋掉的幾乎都是自己的電子報、帳單和密碼重設信。姓氏規則的完整說明在 #2

RFC 7489 已經不是現行標準

當天翻規範才發現的:DMARC 在 2026-05 由 RFC 9989 取代了 RFC 7489(同時也取代 RFC 9091),並從 Informational 升級成 Standards Track。2026 年的文章還把 7489 當現行標準引用是錯的——包括我自己這系列前幾篇的初稿(已改)。

連帶的實務影響是 pct 標籤被移除了,「用 pct=2550100 漸進放大」那套要重新想。這件事 #3 已經整理過,這裡不重複。

順帶:Gmail 2024 的要求不適用你以為的那些人

Gmail 2024 的寄件者要求只適用於個人 @gmail.com 收件者,不適用 Google Workspace 託管的信箱。拿「Gmail 已經強制要求了」當理由去推內部排程之前,先確認你的收件方到底是哪一種——講錯了,下次就沒人相信你講的期限。

三個假訊號對照表

假訊號看起來像什麼實際是什麼怎麼真的驗
#1 SaaS 開關dig 查得到 zendesk1 / zendesk2 兩筆 CNAME後台開關沒勾,還在用 d=zendesk.com寄測試信,看 Authentication-Resultsheader.d= 是不是你的網域
#2 CNAME 鏈CNAME 指向 100% 正確、一路解得到目標端的 TXT 是空的,還沒 provisiondig +short TXT 追到最終記錄,確認看得到 v=DKIM1; p=
#3 應用層紀錄email_logs 裡有那一列那一列只代表 Mailgun 回了 200 Queued去 Mailgun 的 delivery log 對;長期解法是掛 webhook 收 bounce

收尾

三個假訊號有個共同點很直白:我查得到的每一個訊號,都停在「我把東西交出去了」那一刻,沒有一個訊號來自「對方收下了」那一端。 dig 停在 DNS、後台截圖停在設定頁、email_logs 停在 API 回 200。

那 12 筆 DNS 變更前後花不到一小時。驗這三件事花掉一整個下午。

當天最後補上的東西不是 DNS 記錄,是把 Mailgun 的 delivery log 打開來當對照。至於把 bounce webhook 接進 email_logs、讓那張表至少長出一個 status 欄位——那件事還沒做。


想從頭理解這三個 DNS 記錄在幹嘛,看 #2 白話入門;想照分階段流程自己推一次,看 #3 實戰 playbook;想知道整件事為什麼會從一個 F 開始,回 系列起點

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →