<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>誰在冒用你的 Domain 寄信 - Bobo 的學思山丘</title><description>客戶委託第三方資安評等,我們的 email 認證拿了 F。但這類評等其實是被動 OSINT——只讀公開 DNS。我用 60 行 bash 重現了那個分數,順便聊繼承來的 tech debt 為什麼總是沒人修。</description><link>https://bobochen.dev/</link><item><title>把 DMARC 推到強制的那天:三個「看起來成功」的假訊號</title><link>https://bobochen.dev/blog/dmarc-enforcement-day-false-signals/</link><guid isPermaLink="true">https://bobochen.dev/blog/dmarc-enforcement-day-false-signals/</guid><description>SPF、DKIM、DMARC 都設好了,dig 也查得到——但其中兩條寄件管道其實根本沒在簽章。記錄推到 p=reject 那天踩到的三個假訊號,以及一張讓人最安心也最危險的資料表。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>三週前我寫了 [#3 那份 playbook](/blog/dmarc-none-to-reject-playbook/),把 DMARC 從 `p=none` 推到 `p=reject` 的分階段流程整理得很整齊。這篇是實際執行那天的記錄。

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

&gt; 📎 這是「誰在冒用你的 Domain 寄信」系列的第 4 篇,執行紀錄。完全不熟 SPF/DKIM/DMARC 先看 [#2 白話入門](/blog/spf-dkim-dmarc-explained/);想照流程自己做一次,看 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);整件事的起點是 [#1 我如何重現一個資安評等的 F](/blog/reproduce-vendor-security-rating-f/)。

## 先講結果

當天做完的東西:

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

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

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

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

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

## 三個假訊號住在哪裡

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

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

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

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

Zendesk 的設定文件會叫你在 DNS 加兩筆 CNAME:`zendesk1` 和 `zendesk2`。我加了,`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 作為範例網域](./images/zendesk-custom-domain-dkim-toggle.webp)

用 [#2 那條姓氏規則](/blog/spf-dkim-dmarc-explained/)來說:火漆印確實有蓋,但印上壓的是 **Zendesk 家的姓**,不是我們家的。對齊要的是同姓,不同姓就不算數——連分部都不是。

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

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

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

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

### 順序不能反

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

```mermaid
flowchart LR
    subgraph OK[&quot;正確順序&quot;]
        direction LR
        A1[&quot;加 CNAME&quot;] --&gt; A2[&quot;確認解得到&quot;] --&gt; A3[&quot;才勾開關&quot;]
    end
    subgraph NG[&quot;順序反了&quot;]
        direction LR
        B1[&quot;先勾開關&quot;] --&gt; B2[&quot;CNAME 還沒生效&quot;] --&gt; B3[&quot;查不到公鑰&lt;br/&gt;→ 投遞失敗&quot;]
    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 上的路標完全正確,指向存放印模的櫃子;但櫃子當下是空的。郵局照路標去找,找到一個空櫃,驗不了章。

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

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

```bash
# 只查 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 記錄已在全球各地的解析伺服器生效](./images/shopify-email-domain-authenticated.webp)

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

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

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

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

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

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

這是當天最有料的一個。

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

不是。

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

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

```json
{&quot;id&quot;: &quot;&lt;20260907...@mg.yourdomain.com&gt;&quot;, &quot;message&quot;: &quot;Queued. Thank you.&quot;}
```

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

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

而我們那張表存的是:

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

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

&lt;figure style=&quot;margin:1.75em 0&quot;&gt;
&lt;svg viewBox=&quot;0 0 720 216&quot; role=&quot;img&quot; aria-label=&quot;email_logs 資料表示意圖:表格有 id、from、to、subject、created_at 五個欄位,兩列資料除了 id 與收件者信箱之外欄位值完全相同;表格右邊隔著一條虛線標註著實際結果——第一列實際送達、第二列實際被 550 退回,但這一欄不存在於資料表中,因為表上沒有 status 欄位也沒有存 Mailgun 的 message id,所以兩列在表上分不出差別&quot; style=&quot;width:100%;height:auto;display:block;font-family:ui-sans-serif,system-ui,&apos;Noto Sans TC&apos;,sans-serif&quot;&gt;
&lt;rect x=&quot;8&quot; y=&quot;30&quot; width=&quot;462&quot; height=&quot;108&quot; rx=&quot;8&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.03&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;g stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.12&quot; stroke-width=&quot;1&quot;&gt;&lt;path d=&quot;M60,30 V138&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M178,30 V138&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M300,30 V138&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M388,30 V138&quot;&gt;&lt;/path&gt;&lt;/g&gt;
&lt;g stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;path d=&quot;M8,62 H470&quot;&gt;&lt;/path&gt;&lt;/g&gt;
&lt;g stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.12&quot; stroke-width=&quot;1&quot;&gt;&lt;path d=&quot;M8,100 H470&quot;&gt;&lt;/path&gt;&lt;/g&gt;
&lt;text x=&quot;8&quot; y=&quot;20&quot; font-size=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;email_logs&lt;/text&gt;
&lt;g font-size=&quot;11&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;&lt;text x=&quot;16&quot; y=&quot;52&quot;&gt;id&lt;/text&gt;&lt;text x=&quot;68&quot; y=&quot;52&quot;&gt;from&lt;/text&gt;&lt;text x=&quot;186&quot; y=&quot;52&quot;&gt;to&lt;/text&gt;&lt;text x=&quot;308&quot; y=&quot;52&quot;&gt;subject&lt;/text&gt;&lt;text x=&quot;396&quot; y=&quot;52&quot;&gt;created_at&lt;/text&gt;&lt;/g&gt;
&lt;g font-size=&quot;11&quot; fill=&quot;currentColor&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;&lt;text x=&quot;16&quot; y=&quot;86&quot;&gt;1041&lt;/text&gt;&lt;text x=&quot;68&quot; y=&quot;86&quot;&gt;no-reply@ex.com&lt;/text&gt;&lt;text x=&quot;186&quot; y=&quot;86&quot;&gt;alice@acme.com&lt;/text&gt;&lt;text x=&quot;396&quot; y=&quot;86&quot;&gt;10:12:04&lt;/text&gt;&lt;/g&gt;
&lt;g font-size=&quot;11&quot; fill=&quot;currentColor&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;&lt;text x=&quot;16&quot; y=&quot;124&quot;&gt;1042&lt;/text&gt;&lt;text x=&quot;68&quot; y=&quot;124&quot;&gt;no-reply@ex.com&lt;/text&gt;&lt;text x=&quot;186&quot; y=&quot;124&quot;&gt;bob@acme.com&lt;/text&gt;&lt;text x=&quot;396&quot; y=&quot;124&quot;&gt;10:12:04&lt;/text&gt;&lt;/g&gt;
&lt;g font-size=&quot;12&quot; fill=&quot;currentColor&quot;&gt;&lt;text x=&quot;308&quot; y=&quot;86&quot;&gt;密碼重設&lt;/text&gt;&lt;text x=&quot;308&quot; y=&quot;124&quot;&gt;密碼重設&lt;/text&gt;&lt;/g&gt;
&lt;path d=&quot;M478,24 V144&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.3&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/path&gt;
&lt;text x=&quot;490&quot; y=&quot;52&quot; font-size=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot;&gt;實際結果(不在表裡)&lt;/text&gt;
&lt;circle cx=&quot;497&quot; cy=&quot;82&quot; r=&quot;5&quot; fill=&quot;#2F6B4F&quot;&gt;&lt;/circle&gt;
&lt;text x=&quot;512&quot; y=&quot;87&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot;&gt;對方收下了&lt;/text&gt;
&lt;circle cx=&quot;497&quot; cy=&quot;120&quot; r=&quot;5&quot; fill=&quot;#C8102E&quot;&gt;&lt;/circle&gt;
&lt;text x=&quot;512&quot; y=&quot;125&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot;&gt;被 550 退回&lt;/text&gt;
&lt;text x=&quot;8&quot; y=&quot;172&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot;&gt;表上沒有 status,也沒有存 Mailgun 的 message id。&lt;/text&gt;
&lt;text x=&quot;8&quot; y=&quot;196&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot;&gt;虛線右邊那一欄才是你真正想知道的事,而它不在資料庫裡。&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption style=&quot;font-size:0.85em;opacity:0.65;text-align:center;margin-top:0.5em&quot;&gt;送達的和被退回的,在 &lt;code&gt;email_logs&lt;/code&gt; 裡是同一種樣子。&lt;/figcaption&gt;
&lt;/figure&gt;

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

```mermaid
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-&gt;&gt;MG: POST /messages
    MG--&gt;&gt;App: HTTP 200 — Queued. Thank you.
    App-&gt;&gt;App: 觸發 MessageSent 事件
    App-&gt;&gt;Q: dispatch listener(ShouldQueue)
    Q-&gt;&gt;DB: INSERT from / to / subject / body
    Note over DB: 這一列寫下的瞬間&lt;br/&gt;信還躺在 Mailgun 的佇列裡
    MG-&gt;&gt;MTA: 幾秒到幾分鐘後才真的投遞
    MTA--&gt;&gt;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。信可能好好地寄到了。

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

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

```mermaid
flowchart TB
    subgraph HUMAN[&quot;同仁用 Gmail 寄&quot;]
        direction TB
        H1[&quot;Gmail 寄出&quot;] --&gt; H2[&quot;對方擋掉&quot;] --&gt; H3[&quot;退信通知回到&lt;br/&gt;同仁自己的收件匣&quot;] --&gt; H4[&quot;有人看到 → 會來問&quot;]
    end
    subgraph SYSTEM[&quot;系統走 Mailgun 寄&quot;]
        direction TB
        S1[&quot;API 回 200 Queued&quot;] --&gt; S2[&quot;對方擋掉&quot;] --&gt; S3[&quot;退信回到 Mailgun 的&lt;br/&gt;Return-Path&quot;] --&gt; S4[&quot;repo 裡沒有 webhook&lt;br/&gt;接退信事件&quot;] --&gt; S5[&quot;沒有任何人看到&quot;]
    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](/blog/spf-dkim-dmarc-explained/)。

### RFC 7489 已經不是現行標準

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

連帶的實務影響是 `pct` 標籤被移除了,「用 `pct=25` → `50` → `100` 漸進放大」那套要重新想。這件事 [#3 已經整理過](/blog/dmarc-none-to-reject-playbook/),這裡不重複。

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

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

## 三個假訊號對照表

| 假訊號 | 看起來像什麼 | 實際是什麼 | 怎麼真的驗 |
|---|---|---|---|
| **#1 SaaS 開關** | `dig` 查得到 `zendesk1` / `zendesk2` 兩筆 CNAME | 後台開關沒勾,還在用 `d=zendesk.com` 簽 | 寄測試信,看 `Authentication-Results` 的 `header.d=` 是不是你的網域 |
| **#2 CNAME 鏈** | CNAME 指向 100% 正確、一路解得到 | 目標端的 TXT 是空的,還沒 provision | `dig +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 白話入門](/blog/spf-dkim-dmarc-explained/);想照分階段流程自己推一次,看 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);想知道整件事為什麼會從一個 F 開始,回 [系列起點](/blog/reproduce-vendor-security-rating-f/)。</content:encoded><media:content url="https://bobochen.dev/images/og/dmarc-enforcement-day-false-signals.png" medium="image"/><category>資安</category><category>email-security</category><category>DMARC</category><category>DKIM</category><category>Laravel</category><enclosure url="https://bobochen.dev/images/og/dmarc-enforcement-day-false-signals.png" length="0" type="image/png"/></item><item><title>把 DMARC 從 p=none 安全推到 p=reject 的實戰 playbook</title><link>https://bobochen.dev/blog/dmarc-none-to-reject-playbook/</link><guid isPermaLink="true">https://bobochen.dev/blog/dmarc-none-to-reject-playbook/</guid><description>p=none 沒有保護力,但直接跳 p=reject 會擋掉自家信。這篇是分階段、零停機把 DMARC 推到強制的實戰流程:寄件來源盤點、alignment 對齊的姓氏規則、rua 監控,以及 pct 被移除後現在該怎麼漸進。</description><pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate><content:encoded>`p=none` 的 DMARC 是很多公司的現狀:裝了,但等於沒鎖門——它只觀察、不擋任何冒名信。

但你不能直接把它改成 `p=reject`。因為一旦強制,**所有「驗證沒對齊」的信都會被退回——包括你自己沒設好的那些合法寄件管道**。員工信、電子報、客服回覆、系統通知……任何一個沒對齊的,使用者就收不到。

這篇是把 DMARC 從 `p=none` 安全推到 `p=reject` 的分階段 playbook。核心觀念是:**前置步驟全部零風險,真正的風險集中在最後一步,而它被監控期擋著。**

&gt; 📎 這是「誰在冒用你的 Domain 寄信」系列的進階篇。不熟 SPF/DKIM/DMARC 先看 [#2 白話入門](/blog/spf-dkim-dmarc-explained/);想看這套流程的真實案例,看系列起點 [我如何重現一個資安評等的 F](/blog/reproduce-vendor-security-rating-f/);這套 playbook 真的按下去那天的記錄在 [#4 執行日的假訊號](/blog/dmarc-enforcement-day-false-signals/)。

整條路長這樣——前面四格都不會擋掉任何一封信,只有最後一格會:

```mermaid
flowchart TD
    subgraph ZERO[&quot;零風險區 · 只增不減,不會擋掉任何一封信&quot;]
        S0[&quot;階段 0&lt;br/&gt;DMARC 補上 rua&lt;br/&gt;政策維持 p=none&quot;] --&gt; S1[&quot;階段 1&lt;br/&gt;盤點每一條合法寄件管道&quot;]
        S1 --&gt; S2[&quot;階段 2&lt;br/&gt;補 SPF include、每條管道開 DKIM&lt;br/&gt;SPF 結尾先留 ~all&quot;]
        S2 --&gt; S3[&quot;階段 3&lt;br/&gt;讀 rua 報告 2–4 週&quot;]
        S3 --&gt; Q{&quot;每一條合法管道&lt;br/&gt;都 pass 而且對齊了嗎?&quot;}
        Q --&gt;|&quot;報告裡冒出沒授權過的寄件者&quot;| S1
    end
    Q --&gt;|&quot;連續數週乾淨,沒有新面孔&quot;| S4[&quot;階段 4 · 唯一會擋信的一步&lt;br/&gt;p=quarantine → p=reject&lt;br/&gt;最後 SPF 收成 -all&quot;]
    S4 --&gt; OK[&quot;冒名信被退掉&lt;br/&gt;自家的信照樣送達&quot;]
```

那個回頭的箭頭是整套流程的重點:報告一冒出沒見過的寄件者,就退回階段 1 重新盤點,而不是照日曆往下走。

## 先理解:為什麼會擋到自家信(alignment)

DMARC 不只看 SPF/DKIM 有沒有過,還看「**通過的那個網域**」和「**From 顯示的網域**」對不對得起來,這叫 **alignment(對齊)**。

用系列裡的掛號信比喻來說,對齊就是一條**姓氏規則**。信封上寫的寄件人是「陳家公司」,可是火漆印上蓋的卻是「陳家公司・第三分部」——因為交易信外包給自己的子網域 `mg.yourdomain.com` 在寄。這兩個名字算不算同一家?看你設的是哪一種規則:

&lt;figure style=&quot;margin:1.75em 0&quot;&gt;
&lt;svg viewBox=&quot;0 0 720 368&quot; role=&quot;img&quot; aria-label=&quot;上半部左邊是信封,標示「信封上的寄件人 From:」,名字寫「陳家公司」,底下是 @yourdomain.com;右邊是蓋了紅色火漆印的簽名,標示「火漆印上的簽名 DKIM d=」,名字寫「陳家公司・第三分部」,底下是 mg.yourdomain.com。下半部兩列比較:綠色那列是寬鬆 relaxed 預設規則,只看姓,兩邊的姓都是 yourdomain.com,判定通過;紅色那列是嚴格 strict 規則,要一模一樣,差一個 mg. 就不算,判定不通過&quot; style=&quot;width:100%;height:auto;display:block;font-family:ui-sans-serif,system-ui,&apos;Noto Sans TC&apos;,sans-serif&quot;&gt;
&lt;rect x=&quot;16&quot; y=&quot;20&quot; width=&quot;320&quot; height=&quot;128&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.03&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;40&quot; y=&quot;48&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;信封上的寄件人 · From:&lt;/text&gt;
&lt;text x=&quot;40&quot; y=&quot;90&quot; font-size=&quot;21&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;陳家公司&lt;/text&gt;
&lt;text x=&quot;40&quot; y=&quot;122&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;@yourdomain.com&lt;/text&gt;
&lt;rect x=&quot;384&quot; y=&quot;20&quot; width=&quot;320&quot; height=&quot;128&quot; rx=&quot;10&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.03&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;408&quot; y=&quot;48&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;火漆印上的簽名 · DKIM d=&lt;/text&gt;
&lt;circle cx=&quot;668&quot; cy=&quot;44&quot; r=&quot;17&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.14&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/circle&gt;
&lt;text x=&quot;668&quot; y=&quot;50&quot; text-anchor=&quot;middle&quot; font-size=&quot;14&quot; font-weight=&quot;700&quot; fill=&quot;#C8102E&quot;&gt;印&lt;/text&gt;
&lt;text x=&quot;408&quot; y=&quot;90&quot; font-size=&quot;21&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;陳家公司・第三分部&lt;/text&gt;
&lt;text x=&quot;408&quot; y=&quot;122&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;mg.yourdomain.com&lt;/text&gt;
&lt;rect x=&quot;16&quot; y=&quot;176&quot; width=&quot;688&quot; height=&quot;82&quot; rx=&quot;10&quot; fill=&quot;#2F6B4F&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#2F6B4F&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;40&quot; y=&quot;208&quot; font-size=&quot;15&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;寬鬆 relaxed(預設):只看姓&lt;/text&gt;
&lt;text x=&quot;40&quot; y=&quot;237&quot; font-size=&quot;13.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.8&quot;&gt;兩邊的姓都是 yourdomain.com,同姓就算同一家&lt;/text&gt;
&lt;text x=&quot;684&quot; y=&quot;228&quot; text-anchor=&quot;end&quot; font-size=&quot;20&quot; font-weight=&quot;700&quot; fill=&quot;#2F6B4F&quot;&gt;✓ 對齊&lt;/text&gt;
&lt;rect x=&quot;16&quot; y=&quot;270&quot; width=&quot;688&quot; height=&quot;82&quot; rx=&quot;10&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;40&quot; y=&quot;302&quot; font-size=&quot;15&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;嚴格 strict:要一模一樣&lt;/text&gt;
&lt;text x=&quot;40&quot; y=&quot;331&quot; font-size=&quot;13.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.8&quot;&gt;差一個 mg. 就不是同一個名字,不算&lt;/text&gt;
&lt;text x=&quot;684&quot; y=&quot;322&quot; text-anchor=&quot;end&quot; font-size=&quot;20&quot; font-weight=&quot;700&quot; fill=&quot;#C8102E&quot;&gt;✗ 不對齊&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption style=&quot;font-size:0.85em;opacity:0.65;text-align:center;margin-top:0.5em&quot;&gt;對齊就是一條姓氏規則:預設只看姓,嚴格則要求一字不差。&lt;/figcaption&gt;
&lt;/figure&gt;

- **寬鬆 relaxed(`adkim=r; aspf=r`,預設)**:只看姓。`mg.yourdomain.com` 和 `yourdomain.com` 同姓,算同一家 → 對齊。
- **嚴格 strict(`adkim=s; aspf=s`)**:要一模一樣。差一個 `mg.` 就不算 → 不對齊。

**幾乎所有公司的信,都是靠「只看姓」這條預設規則活著的。** 用子網域幫你寄信的 SaaS(交易信、電子報、客服)一抓一大把,規則一改嚴格,它們全變成外人。

上面這張圖只畫了盤點時真的會用到的那一半。完整的比喻、以及「改成嚴格為什麼代價遠大於收益」的完整推導,在 [#2 的〈對齊(alignment):姓氏規則〉](/blog/spf-dkim-dmarc-explained/);這裡只留執行時要記住的兩句:

- **DMARC 是 OR 邏輯**:SPF 對齊且通過,**或** DKIM 對齊且通過,任一成立就算 pass。所以「改 strict 一定爆炸」是過度斷言——真正會整批掛掉的,是 DKIM 的 `d=` 和 Return-Path 都掛在子網域上、而你又把兩個 tag 一起設成 `s` 的組合(真的長成這樣的一條管道在 [#4](/blog/dmarc-enforcement-day-false-signals/))。
- **但改成嚴格,擋不掉一般的冒名信。** 偽造信從來就不是任何一個「陳家」簽的,在寬鬆規則下本來就已經失敗;你多擋掉的幾乎都是自己的電子報、帳單和密碼重設信。(唯一的例外是子網域被接管的情況,見 [#2](/blog/spf-dkim-dmarc-explained/)。)

所以這套流程的預設就是:`adkim=r; aspf=r` 放著別動。

## 階段 0:先能「看見」(設 rua)

沒有報告,你不知道誰會被擋。第一步把 DMARC 加上 `rua=mailto:...`,**維持 `p=none`**。

原始 XML 人是看不了的,丟給 DMARC 分析服務解析([dmarcian](https://dmarcian.com)、Postmark DMARC、Valimail 等;或自架開源解析器)。

&gt; 這步零風險、零投遞影響,卻是整個流程的基礎。

## 階段 1:盤點所有合法寄件來源(最關鍵)

列出每一個會用 `@yourdomain.com` 寄信的東西,常見的有:

- 公司信箱(Google Workspace / Microsoft 365)
- 交易信 / 系統通知(Mailgun、SendGrid、SES、Postmark……通常走子網域如 `mg.`)
- 行銷電子報(Mailchimp、HubSpot……)
- 客服系統(Zendesk、Intercom……)
- **最容易遺漏的**:電子簽核、問卷、CRM、招募系統、發票系統

但只列出名字不算盤點完。清單上的**每一條**,都要走過同樣三個問題:

```mermaid
flowchart TD
    P[&quot;清單上的一條寄件管道&lt;br/&gt;例:客服系統、發票系統&quot;] --&gt; Q1[&quot;先查:它實際上用哪個網域把信交出去?&lt;br/&gt;看 Return-Path 和 DKIM 的 d= 欄位&quot;]
    Q1 --&gt; Q2{&quot;① SPF 授權過&lt;br/&gt;這個來源了嗎?&quot;}
    Q2 --&gt;|&quot;沒有&quot;| A1[&quot;補 include&lt;br/&gt;同時盯住 10 次 DNS 查詢上限&quot;]
    Q2 --&gt;|&quot;有&quot;| Q3{&quot;② 這條管道的 DKIM&lt;br/&gt;簽章開了、公鑰進 DNS 了嗎?&quot;}
    A1 --&gt; Q3
    Q3 --&gt;|&quot;沒有&quot;| A2[&quot;去該服務後台開 DKIM&lt;br/&gt;把公鑰貼進自己的 DNS&quot;]
    A2 --&gt; Q4
    Q3 --&gt;|&quot;有&quot;| Q4{&quot;③ 通過的網域和 From&lt;br/&gt;對得起來嗎?&lt;br/&gt;就是上一節的姓氏規則&quot;}
    Q4 --&gt;|&quot;對不起來&quot;| A3[&quot;改成用自己的子網域寄&lt;br/&gt;或請該服務改簽你的網域&quot;]
    A3 --&gt; OK
    Q4 --&gt;|&quot;對得起來&quot;| OK[&quot;這條管道過關&lt;br/&gt;p=reject 之下照樣送達&quot;]
```

三個問題全部答完,這條管道才算盤好。**遺漏一條,它的信就會在 reject 時死掉。**

&gt; 真實案例:[系列起點那篇](/blog/reproduce-vendor-security-rating-f/)裡,我盤點後發現高流量的交易信和電子報其實早就對齊了,缺口主要落在員工信和客服——爆炸半徑比想像中小。但盤點清單不會就此封閉:真正推上去那天,還是有一條電商管道的 DKIM 沒 provision 好([#4](/blog/dmarc-enforcement-day-false-signals/))。盤點讓你知道風險多大,監控期才是抓漏的那一關。

## 階段 2:鋪設(零風險)

- **SPF**:把所有合法來源補進去,結尾先用 `~all`(softfail,不會擋信)。注意 10-lookup 上限。
- **DKIM**:每個寄件管道都開簽章(Google Workspace 後台、各 SaaS 的 DKIM 設定)。
- **DMARC**:維持 `p=none`,但 rua 已經在收報告。

這些動作都是「多授權 / 加簽章」,**只增不減**,不會擋掉任何現有信件。

## 階段 3:監控(2–4 週)

- 讀 rua 報告,確認**每一個合法來源都 pass 且對齊**。
- 揪出階段 1 沒想到的寄件者(它們會在報告裡冒出來),補上授權。
- 這步是整個流程的**安全閥**——不要急,讓報告告訴你還有誰沒對齊。

## 階段 4:漸進強制

到這裡才第一次真的會擋信,所以一階一階來:

1. `p=quarantine`——驗不過的信進垃圾桶,還撈得回來。放個一到兩週,持續盯 rua。
2. `p=reject`——驗不過的信直接被退。
3. 最後把 SPF 從 `~all` 收緊成 `-all`。

每一階之間都要回頭看報告有沒有誤殺合法信;有,就退回上一階,補完再上。

### 關於 `pct`:那是舊 RFC 的做法

早期的 playbook(包括我自己最早寫的版本)會教你用 `pct=25` → `pct=50` → `pct=100`,讓政策只對一部分驗不過的信生效、再慢慢放大。**這個標籤已經不在標準裡了。**

DMARC 現在的規範是 2026 年 5 月發布的 **[RFC 9989](https://www.rfc-editor.org/rfc/rfc9989)**,它取代了原本的 RFC 7489 與 RFC 9091,並把 DMARC 從 Informational 升級成 Standards Track。`pct` 在附錄 A.6 被列為移除項目,registry 也已標成 historic。

實務上的意思是:某些收件方的舊實作可能還讀得懂 `pct`,但**你不能把上線計畫建立在「有些人還吃這個標籤」上面**——一部分人吃、一部分人不吃,你只會得到一個連自己都說不準生效範圍的政策。

那現在怎麼漸進?**用政策階梯本身當節奏,而不是百分比**:

- `none` → `quarantine` → `reject` 這三階,本來就是三個不同強度,一階一階爬。
- 每階停留多久由 rua 報告決定,不是由日曆決定——報告連續乾淨才往上加一階。
- 想更保守,可以讓子網域先進強制:主網域維持 `p=none`,先設 `sp=quarantine` 只對子網域生效,觀察沒事再動主網域。

## 要注意的 side-effect

### 轉寄信與郵件群組(最常見的真實災情)

`p=reject` 之下最容易被退的,往往不是冒名信,而是**被轉寄過的自家信**。原因在 SPF 和 DKIM 的本質差別:SPF 只看「站在門口把信交出來的是誰」,信被轉寄一次,交信的就換成轉寄主機,SPF 當場斷掉;DKIM 的火漆印是壓在信上、跟著信走的,中間換幾手都還在。這條機制的完整對照圖在 [#2 的〈信被轉寄一次〉](/blog/spf-dkim-dmarc-explained/)。

對這份 playbook 來說,結論只有一句:**把每一條管道的 DKIM 都簽好、簽對齊**,轉寄斷掉的 SPF 就不會要你的命(OR 邏輯,DKIM 那邊過就算過)。大型收件方(Gmail、Microsoft 365)另外有 ARC 可以緩解,但那是對方的善意,不是你能控制的。至於會改標題、加尾註的郵件論壇,它連 DKIM 都會弄壞——那種只能個案處理。

### 其他幾個

- **遺漏的寄件者**:最常見的翻車原因——監控期就是為了抓它們。
- **relaxed 對齊別動**:`adkim=r; aspf=r` 保留。改嚴格擋不掉一般的冒名信,卻會擋掉自己人(上面講過)。
- **SPF 10-lookup**:include 太多會 `permerror` 讓 SPF 整個失效;用 SPF flattening 或精簡 include。
- **`sp=` / `np=` 沒寫不代表沒設**:`sp=` 缺席時,子網域直接繼承 `p=`;`np=`(不存在的子網域)缺席時繼承 `sp=`。所以在 `p=reject` 後面補一句 `sp=reject; np=reject` **不會改變任何一封信的處置**,只會讓彙整報告的 `policy_published` 長得比較清楚。要補可以,但那一行打錯一個字,整筆記錄可能被降級成 `p=none`——辛苦推上來的強制瞬間歸零。純粹寫爽的改動,反而是風險。

## 一張表收尾

| 階段 | 動作 | 風險 |
|---|---|---|
| 0 | DMARC 加 rua | 零 |
| 1 | 盤點寄件來源 | 零 |
| 2 | 補 SPF/DKIM、`~all` | 零 |
| 3 | 監控報告 2–4 週 | 零 |
| 4 | `quarantine`→`reject`、`-all` | 有(被前面擋著) |

把風險留到最後、用監控期當安全閥——這就是為什麼一個 email 認證的「F」聽起來嚇人,實際動工卻可以很穩。

---

想看這套流程是怎麼從一個 F 開始的,回 [系列起點:我如何重現一個資安評等的 F](/blog/reproduce-vendor-security-rating-f/);想看它真的按下去那天發生什麼事、哪些嚇人的訊號其實是假警報,看 [#4 執行日的假訊號](/blog/dmarc-enforcement-day-false-signals/)。</content:encoded><media:content url="https://bobochen.dev/images/og/dmarc-none-to-reject-playbook.png" medium="image"/><category>資安</category><category>email-security</category><category>DMARC</category><category>SPF</category><category>DKIM</category><enclosure url="https://bobochen.dev/images/og/dmarc-none-to-reject-playbook.png" length="0" type="image/png"/></item><item><title>SPF / DKIM / DMARC 到底在幹嘛?三個 DNS 記錄一次搞懂</title><link>https://bobochen.dev/blog/spf-dkim-dmarc-explained/</link><guid isPermaLink="true">https://bobochen.dev/blog/spf-dkim-dmarc-explained/</guid><description>email 的寄件認證三件套,用寄實體掛號信的比喻加圖解一次講清楚:SPF 是郵差白名單、DKIM 是跟著信走的火漆印、DMARC 是驗不過時的處置政策,以及最難懂的「對齊」——姓氏規則。附 dig 自我檢查指令。</description><pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate><content:encoded>你有沒有想過:為什麼詐騙集團能寄出一封「看起來真的來自某銀行」的信?

因為 email 有個先天缺陷——**寄件人地址(From)可以隨便填**,就像實體信封上的寄件人欄,你想寫誰就寫誰。SMTP 協定誕生的年代沒人想到要防偽。

於是後來補了三道關卡:**SPF、DKIM、DMARC**。它們都是你網域 DNS 裡的幾筆記錄,合起來回答三個問題:**誰能幫我寄信?信有沒有被動手腳?驗不過時收件方該怎麼辦?**

這篇用「寄一封實體掛號信」的比喻,把這三個一次講清楚。

&gt; 📎 這是「誰在冒用你的 Domain 寄信」系列的入門篇。系列起點是一篇真實案例:[我如何重現一個資安評等的 F](/blog/reproduce-vendor-security-rating-f/);讀完想動手把 DMARC 推到 `p=reject`,接 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);想看真的按下去那天發生什麼事,看 [#4 執行日記錄](/blog/dmarc-enforcement-day-false-signals/)。

## 先看全貌:一封信抵達的那幾秒鐘

在拆解三個記錄之前,先看一眼它們合起來長什麼樣。一封信寄到收件伺服器,大約是這樣跑完的:

```mermaid
flowchart TD
    MAIL[&quot;一封信抵達收件伺服器&lt;br/&gt;From: bobo@yourdomain.com&quot;]
    MAIL --&gt; SPF[&quot;① SPF&lt;br/&gt;送信來的伺服器&lt;br/&gt;在你的白名單上嗎&quot;]
    MAIL --&gt; DKIM[&quot;② DKIM&lt;br/&gt;信上的火漆印&lt;br/&gt;驗得過嗎&quot;]
    SPF --&gt; ALIGN[&quot;③ 對齊&lt;br/&gt;通過的那個網域&lt;br/&gt;跟 From 顯示的算同一家嗎&quot;]
    DKIM --&gt; ALIGN
    ALIGN --&gt;|任一邊通過且對齊| PASS[&quot;DMARC pass&lt;br/&gt;照常收下&quot;]
    ALIGN --&gt;|兩邊都沒過| POLICY{&quot;去查 _dmarc&lt;br/&gt;你的 p= 寫什麼&quot;}
    POLICY --&gt;|&quot;p=none&quot;| NONE[&quot;照收,只寫進報表&quot;]
    POLICY --&gt;|&quot;p=quarantine&quot;| QUAR[&quot;丟垃圾郵件匣&quot;]
    POLICY --&gt;|&quot;p=reject&quot;| REJ[&quot;直接退回&quot;]
```

三件事值得先記住:

- **SPF 和 DKIM 是兩條各自獨立的路**,誰過誰不過互不影響。
- **兩條路的終點都是「對齊」**——這是 DMARC 才有的關卡,也是全篇最難的一段,我後面用一整節講。
- **只要其中一條走到底,DMARC 就算過**(這是 OR 邏輯,不是 AND)。這個設計等一下講「轉寄」的時候會救你一命。

## SPF:誰可以幫我寄信(郵差白名單)

**比喻**:你公告「只有 A、B、C 這三家快遞,能用我公司的名義寄件」。郵局收到信,先看寄件方是不是名單上的。

**技術**:SPF 是一筆 DNS TXT,列出被授權的寄信伺服器。收件方檢查「這封信是不是從名單上的伺服器來的」。

```mermaid
flowchart LR
    SEND[&quot;寄信的伺服器&lt;br/&gt;209.85.220.41&quot;] --&gt;|把信交出來| RECV[&quot;收件伺服器&quot;]
    RECV --&gt;|&quot;查 TXT yourdomain.com&quot;| DNS[&quot;你公告的白名單&lt;br/&gt;v=spf1 include:_spf.google.com ~all&quot;]
    DNS --&gt; Q{&quot;這個 IP&lt;br/&gt;在名單上嗎&quot;}
    Q --&gt;|在| OK[&quot;SPF pass&quot;]
    Q --&gt;|不在| NG[&quot;照結尾的 all 機制處理&lt;br/&gt;~all 收下但標記 / -all 拒收&quot;]
```

範例:`v=spf1 include:_spf.google.com ~all`(用 Google Workspace 寄,其餘 softfail)。

這裡先記一個細節,後面講轉寄會用到:**SPF 看的是「站在門口把信交出來的那台機器」**,不是信本身。信的內容它一個字都不看。

結尾那個 `all` 機制最關鍵——它決定「名單以外的人來」時怎麼辦:

| 寫法 | 意思 | 防偽力 |
|---|---|---|
| `+all` | 放行任何人 | 等於沒設(危險) |
| `?all` | neutral(中性) | 幾乎沒用 |
| `~all` | softfail(收下但標記) | 中 |
| `-all` | hardfail(拒絕) | 強 |

⚠️ 陷阱:SPF 有「**10 次 DNS 查詢**」上限,`include` 串太多會讓整筆 SPF 失效。

## DKIM:這封信確實是我、且沒被竄改(火漆印)

**比喻**:你在信封封口蓋一個只有你有的火漆印章;對方比對印模,就知道是不是你寄的、有沒有被中途拆過。

**技術**:寄件伺服器用**私鑰**對信件簽章,**公鑰**放在 DNS(`selector._domainkey.你的網域`)。收件方用公鑰驗章。

```mermaid
flowchart LR
    A[&quot;寄件伺服器&lt;br/&gt;用私鑰在信上蓋章&quot;] --&gt; B[&quot;信帶著火漆印上路&lt;br/&gt;DKIM-Signature 標頭&lt;br/&gt;d=yourdomain.com、s=google&quot;]
    B --&gt; C[&quot;收件伺服器&lt;br/&gt;照 s= 去查&lt;br/&gt;google._domainkey.yourdomain.com&lt;br/&gt;拿到公鑰&quot;]
    C --&gt; D{&quot;印模對得上?&lt;br/&gt;內容也沒被改?&quot;}
    D --&gt;|是| OK[&quot;DKIM pass&lt;br/&gt;確實是你寄的&lt;br/&gt;而且途中沒被動手腳&quot;]
    D --&gt;|否| NG[&quot;DKIM fail&quot;]
```

它同時證明兩件事:信來自持有私鑰的人 + 內容沒被中途改過。

範例:Google Workspace 的 selector 是 `google`,所以公鑰在 `google._domainkey.你的網域`。

## 信被轉寄一次:SPF 壞了,DKIM 沒事

這是入門讀者最容易踩到、卻很少有人先講的一件事,而且它直接解釋了為什麼 DMARC 要設計成 OR 邏輯。

SPF 檢查的是「**站在門口把信交出來的是誰**」。信只要被轉寄一次——同事設了自動轉信、或信寄進一個郵件群組再發給所有成員——最後把信交到收件伺服器手上的,就是那台轉寄主機。它當然不在你的 SPF 名單上,SPF 就 fail 了。**不是信被改壞,是交信的人換了。**

DKIM 不一樣。火漆印是**壓在信上、跟著信走的**。中間換手幾次都還在,只要沒人去動信的內容和被簽章的那些標頭,公鑰照樣驗得過。

```mermaid
flowchart TD
    subgraph DIRECT[&quot;情境 A:直達&quot;]
        direction LR
        S1[&quot;你的寄件伺服器&quot;] --&gt; R1[&quot;收件伺服器&quot;]
        R1 --&gt; V1[&quot;SPF ✓ 交信的 IP 在名單上&lt;br/&gt;DKIM ✓ 火漆印驗得過&quot;]
    end
    subgraph FWD[&quot;情境 B:中途被轉寄一次&quot;]
        direction LR
        S2[&quot;你的寄件伺服器&quot;] --&gt; F2[&quot;轉寄主機&lt;br/&gt;個人自動轉信 / 郵件群組&quot;]
        F2 --&gt; R2[&quot;最終收件伺服器&quot;]
        R2 --&gt; V2[&quot;SPF ✗ 交信的是轉寄主機&lt;br/&gt;不在你的名單上&lt;br/&gt;DKIM ✓ 火漆印跟著信走,還在&quot;]
    end
```

這個差別在 `p=none` 的時候完全看不出來(反正什麼都不會被擋)。但一旦走到 `p=reject`,它就是最常見的真實災情:**轉寄信和郵件群組被退回**。

也因為這樣,DMARC 才規定「SPF 或 DKIM 任一邊對齊通過就算過」——**DKIM 是信被轉寄之後,唯一還活著的那道**。

## DMARC:驗不過時怎麼辦 + 給我報告(處置政策)

**比喻**:前兩關是「檢查」,DMARC 是「公司規定」——萬一掛號信驗不過,是退回、丟到一旁、還是照收?順便每天給你一份「今天有多少冒名信」的報表。

**技術**:DMARC 是 `_dmarc.你的網域` 的 TXT,做三件事:

1. **政策 `p=`**:`none`(只觀察)、`quarantine`(丟垃圾桶)、`reject`(直接拒收)。
2. **對齊(alignment)**:光 SPF/DKIM 通過還不夠,被驗過的那個網域要和 From 顯示的網域「對得起來」才算數。這是 DMARC 比單獨 SPF/DKIM 強的關鍵,下一節整節都在講它。
3. **報告 `rua=`**:把每日彙整報告寄到你指定的信箱。**沒有 rua,你等於瞎子。**

那三個 `p=` 的差別,用門來想最快:

```mermaid
flowchart LR
    FAIL[&quot;一封 DMARC 驗不過的信&lt;br/&gt;可能是冒名&lt;br/&gt;也可能是你自己漏設的合法信&quot;] --&gt; P{&quot;你的 p= 寫什麼&quot;}
    P --&gt;|&quot;p=none&quot;| N[&quot;門開著&lt;br/&gt;信照樣進收件匣&lt;br/&gt;只在報表裡記一筆&quot;]
    P --&gt;|&quot;p=quarantine&quot;| Q[&quot;門虛掩&lt;br/&gt;丟進垃圾郵件匣&lt;br/&gt;收件人翻得到&quot;]
    P --&gt;|&quot;p=reject&quot;| R[&quot;門鎖上&lt;br/&gt;連收都不收,直接退回&lt;br/&gt;收件人完全不知道有這封信&quot;]
```

範例:`v=DMARC1; p=none; rua=mailto:dmarc@你的網域; adkim=r; aspf=r`

`adkim=r; aspf=r` 其實是**預設值,不寫也一樣**。我還是把它寫出來,純粹是為了讓後面接手的人看到它、知道它是刻意留的——避免有人手癢改成 `s`(下一節會講為什麼它的代價遠大於收益)。

注意:`p=none` 沒有保護力,只是觀察;真正擋冒名要走到 `p=quarantine`/`reject`——但中間有眉角,所以才有 [#3 playbook](/blog/dmarc-none-to-reject-playbook/)。

&gt; 📌 查資料時的小提醒:DMARC 的規範在 2026-05 由 [RFC 9989](https://www.rfc-editor.org/rfc/rfc9989) 取代了舊的 RFC 7489,正式升為 Standards Track。網路上大量文章還是照 RFC 7489 在寫,看到的時候記得它已經是舊版。對上線流程最直接的影響是 `pct` 標籤被移除了,[#3](/blog/dmarc-none-to-reject-playbook/) 有整理該怎麼改用政策階梯漸進。

## 對齊(alignment):姓氏規則

這是整個系列最難懂的一段,值得慢慢講。

問題出在:「**信封上的名字**」和「**火漆印上的名字**」不一定會一樣。

因為現實裡,你的信常常是外包出去寄的——電子報交給 Mailchimp、交易信和系統通知交給 Mailgun。這類服務幫你寄的時候,很常是掛在你的**子網域**底下(像 `mg.yourdomain.com`),而不是主網域本身。

於是變成這樣:

- **信封上寫**「陳家公司」(收件人看到的 From 是 `@yourdomain.com`)
- **火漆印上寫**「陳家公司・第三分部」(DKIM 的 `d=mg.yourdomain.com`)

DMARC 要判斷「這兩個到底算不算同一家」,有兩套規則:

&lt;figure style=&quot;margin:1.75em 0&quot;&gt;
&lt;svg viewBox=&quot;0 0 760 436&quot; role=&quot;img&quot; aria-label=&quot;對齊的姓氏規則圖解。上方兩張卡片並排:左卡是信封上寫的名字「陳家公司」,對應收件人看到的 From 是 bobo@yourdomain.com;右卡是火漆印上寫的名字「陳家公司・第三分部」,對應 DKIM 的 d 等於 mg.yourdomain.com。兩張卡片的「陳家」兩個字下面都畫了粗線,標示那是姓。下方兩個面板做並排比對:左邊綠框是寬鬆規則 adkim=r 和 aspf=r,也就是預設值,只看姓,陳家等於陳家,判定對齊通過,底下註記幾乎所有公司的信都靠這條活著;右邊紅框是嚴格規則 adkim=s 和 aspf=s,要求一模一樣,陳家公司不等於陳家公司第三分部,判定對齊失敗,底下註記擋掉的幾乎都是自家的電子報、帳單、密碼重設信。&quot; style=&quot;width:100%;height:auto;display:block;font-family:ui-sans-serif,system-ui,&apos;Noto Sans TC&apos;,sans-serif&quot;&gt;
&lt;text x=&quot;20&quot; y=&quot;24&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;信封上寫的名字(收件人看到的 From)&lt;/text&gt;
&lt;text x=&quot;410&quot; y=&quot;24&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;火漆印上寫的名字(DKIM 的 d=)&lt;/text&gt;
&lt;rect x=&quot;20&quot; y=&quot;38&quot; width=&quot;330&quot; height=&quot;108&quot; rx=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.03&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;410&quot; y=&quot;38&quot; width=&quot;330&quot; height=&quot;108&quot; rx=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.03&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.25&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;44&quot; y=&quot;84&quot; font-size=&quot;23&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;陳家公司&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;84&quot; font-size=&quot;23&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;陳家公司・第三分部&lt;/text&gt;
&lt;line x1=&quot;44&quot; y1=&quot;95&quot; x2=&quot;90&quot; y2=&quot;95&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;4&quot; stroke-linecap=&quot;round&quot;&gt;&lt;/line&gt;
&lt;line x1=&quot;434&quot; y1=&quot;95&quot; x2=&quot;480&quot; y2=&quot;95&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;4&quot; stroke-linecap=&quot;round&quot;&gt;&lt;/line&gt;
&lt;text x=&quot;44&quot; y=&quot;128&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;From: bobo@yourdomain.com&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;128&quot; font-size=&quot;13&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.55&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;d=mg.yourdomain.com&lt;/text&gt;
&lt;text x=&quot;20&quot; y=&quot;174&quot; font-size=&quot;13.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.78&quot;&gt;粗線標的是「姓」——外包寄信時,火漆印上常常會多一個「分部」(子網域)。&lt;/text&gt;
&lt;text x=&quot;20&quot; y=&quot;208&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;同一封信,兩套規則判出兩種結果:&lt;/text&gt;
&lt;rect x=&quot;20&quot; y=&quot;222&quot; width=&quot;330&quot; height=&quot;196&quot; rx=&quot;12&quot; fill=&quot;#2F6B4F&quot; fill-opacity=&quot;0.09&quot; stroke=&quot;#2F6B4F&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;410&quot; y=&quot;222&quot; width=&quot;330&quot; height=&quot;196&quot; rx=&quot;12&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.09&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;44&quot; y=&quot;254&quot; font-size=&quot;16&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;寬鬆 relaxed(預設值)&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;254&quot; font-size=&quot;16&quot; font-weight=&quot;700&quot; fill=&quot;currentColor&quot;&gt;嚴格 strict&lt;/text&gt;
&lt;text x=&quot;44&quot; y=&quot;277&quot; font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;adkim=r / aspf=r&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;277&quot; font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;adkim=s / aspf=s&lt;/text&gt;
&lt;text x=&quot;44&quot; y=&quot;310&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot;&gt;只看姓:&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;310&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot;&gt;要一模一樣:&lt;/text&gt;
&lt;text x=&quot;44&quot; y=&quot;335&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&gt;陳家 ＝ 陳家&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;335&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot; fill=&quot;currentColor&quot;&gt;陳家公司 ≠ 陳家公司・第三分部&lt;/text&gt;
&lt;text x=&quot;44&quot; y=&quot;370&quot; font-size=&quot;22&quot; font-weight=&quot;700&quot; fill=&quot;#2F6B4F&quot;&gt;✓ 對齊通過&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;370&quot; font-size=&quot;22&quot; font-weight=&quot;700&quot; fill=&quot;#C8102E&quot;&gt;✗ 對齊失敗&lt;/text&gt;
&lt;text x=&quot;44&quot; y=&quot;400&quot; font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.65&quot;&gt;幾乎所有公司的信都靠這條活著&lt;/text&gt;
&lt;text x=&quot;434&quot; y=&quot;400&quot; font-size=&quot;12.5&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.65&quot;&gt;擋掉的幾乎都是自家的電子報、帳單、密碼重設信&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption style=&quot;font-size:0.85em;opacity:0.65;text-align:center;margin-top:0.5em&quot;&gt;對齊就是一條姓氏規則:預設只看姓,同姓就算同一家;改成嚴格,連分部都得一字不差。&lt;/figcaption&gt;
&lt;/figure&gt;

- **寬鬆 relaxed(`adkim=r` / `aspf=r`,預設)**:只看姓。同姓就算同一家,通過。
- **嚴格 strict(`adkim=s` / `aspf=s`)**:要一模一樣,連分部都得對上,不通過。

這裡最該記住的一句:**幾乎所有公司的信,都是靠「只看姓」這條預設規則活著的。**

### 為什麼「改成嚴格」幾乎沒有收益,代價卻很大

看到 `r` 這個預設值,很多人的直覺反應是「這聽起來很鬆,收緊一點應該更安全」。

不會。把它改成 `s`,**擋不掉一般的冒名信**。

偽造信從來就不是任何一個「陳家」簽的——它們用的是自己的網域、自己的鑰匙,連姓都不一樣。在寬鬆規則下,它們本來就已經在失敗了。收緊姓氏規則,對它們的處境沒有造成任何改變。

你多擋掉的,幾乎都是**自己的**電子報、帳單、密碼重設信——那些真的姓陳、只是名字後面掛了分部的信。

唯一的例外寫在 [RFC 9989](https://www.rfc-editor.org/rfc/rfc9989) §11.8:如果你的某個子網域 DNS 委派給了別人、或哪天被接管,寬鬆規則會讓那個子網域簽出來的信也算「同姓」而通過。嚴格規則擋得掉這一種。但那本質上是子網域治理問題,而代價是自家所有掛子網域的寄件管道一起陣亡——先把子網域管好,通常划算得多。

所以預設的 `r` 不是「還沒調好」,是「應該留著」。

(補一句給想追細節的人:因為 DMARC 是 OR 邏輯,只把其中一個 tag 改成 `s` 未必當場出事——要 SPF 和 DKIM 兩邊都被收緊、而寄信管道剛好兩邊都掛在子網域上,才會整批掛掉。真的長成這樣的一條管道、以及在 `p=reject` 上線那天為什麼決定不去動它,記在 [#4](/blog/dmarc-enforcement-day-false-signals/)。)

## 三個一起看

- **SPF** 管「從哪台機器寄」、**DKIM** 管「內容真偽」、**DMARC** 管「驗不過怎麼辦 + 回報」。
- 三者缺一不可:只有 SPF/DKIM 沒 DMARC,冒名信還是進得來;有 DMARC 但停在 `p=none`,等於裝了監視器卻不鎖門。
- 而**對齊**是把前兩者和 From 綁在一起的那條線——沒有它,詐騙集團大可用自己的網域簽一封 DKIM 完全通過的信,然後在 From 寫你的名字。

## 30 秒自我檢查

```bash
dig +short TXT yourdomain.com | grep spf1           # 有 SPF 嗎?結尾是 -all 還是 ?all?
dig +short TXT _dmarc.yourdomain.com                # 有 DMARC 嗎?p= 是什麼?
dig +short TXT google._domainkey.yourdomain.com     # (Google Workspace) DKIM 設了嗎?
```

懶得記指令的話,[mxtoolbox](https://mxtoolbox.com)、[dmarcian](https://dmarcian.com)、[internet.nl](https://internet.nl) 都能一鍵體檢。

## 為什麼要在意

- **防冒名釣魚**:保護你的客戶、員工不被社交工程。
- **保護品牌**:沒人能假冒你寄垃圾信。
- **送達率**:認證齊全的網域,信比較不會被丟進垃圾桶。

---

這三個記錄是「設定」不是「開發」,改 DNS 就好,技術門檻其實很低——難的從來不是技術,是把它排進待辦。真的要動手把 DMARC 安全推到 `reject`,接著看 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);想先看看真的按下去那天會遇到什麼,看 [#4 執行日的假訊號](/blog/dmarc-enforcement-day-false-signals/)。</content:encoded><media:content url="https://bobochen.dev/images/og/spf-dkim-dmarc-explained.png" medium="image"/><category>資安</category><category>email-security</category><category>SPF</category><category>DKIM</category><category>DMARC</category><enclosure url="https://bobochen.dev/images/og/spf-dkim-dmarc-explained.png" length="0" type="image/png"/></item><item><title>一個從第一天就知道的資安問題,為什麼拖到客戶給我們一個 F 才修?</title><link>https://bobochen.dev/blog/reproduce-vendor-security-rating-f/</link><guid isPermaLink="true">https://bobochen.dev/blog/reproduce-vendor-security-rating-f/</guid><description>客戶委託第三方資安評等,我們的 email 認證拿了 F。但這類評等其實是被動 OSINT——只讀公開 DNS。我用 60 行 bash 重現了那個分數,順便聊繼承來的 tech debt 為什麼總是沒人修。</description><pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate><content:encoded>我接手公司主網域的維運時,它已經跑很多年了。email 的寄件認證——SPF、DKIM、DMARC——從草創期就沒設好。不是我搞砸的,是更早的「古人」留下的;那些人離職多久已經不可考,當初為什麼這樣設、有沒有什麼理由,也隨著他們一起消失了。

團隊其實「知道」這件事。它是那種會在閒聊時被提起、大家點點頭、然後沒有人把它寫進 sprint 的 folklore。它太低調了:不會讓服務掛掉、不會跳 alert、不會有人因為它半夜被 call。所以它就這樣躺著,躺了好幾年。

直到有一天,一個大客戶委託第三方資安評等廠商掃了我們,email 項目給了一個 **F**——百分位數幾乎墊底。

這篇想講的不是「怎麼設 DMARC」(網路上一堆)。而是兩件更有意思的事:**為什麼一個大家都知道的問題能躺這麼久**,以及我發現的——**那個嚇人的 F,其實是你可以自己重現、自己量測的分數**。

&gt; 📎 **系列文「誰在冒用你的 Domain 寄信」**:這是系列起點(案例篇)。完全不熟 SPF/DKIM/DMARC?先看 [#2 白話入門篇](/blog/spf-dkim-dmarc-explained/);想直接動手把 DMARC 推到 `p=reject`,看 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);真的按下去那天發生什麼事,在 [#4 執行日的假訊號](/blog/dmarc-enforcement-day-false-signals/)。

## 背景:繼承來的、沒人排程的債

第三方資安評等(RiskRecon、BitSight、SecurityScorecard 這類)現在很常見。大客戶在簽約前或定期稽核時,會請這種廠商幫你「打分數」,然後把報告丟給你。一個對外可見的 F,在這種情境下特別刺眼——因為打分的是你的客戶。

但我接手的這個 email 認證問題有個特性:**它是繼承來的**。我沒設定它,我甚至不認識設定它的人。這種「孤兒 tech debt」最危險的地方在於——**沒有人覺得自己該為它負責**。新人覺得「這是以前就這樣」,老人走了,於是它變成一塊沒有 owner 的地。

## 發現過程:先別修,先搞懂它怎麼算的

我的第一反應本來是直接去改 DNS。但我停了一下,問自己一個問題:**這個 F 到底是怎麼算出來的?**

關鍵的領悟是:這類評等是 **被動 OSINT(open-source intelligence)**。報告裡白紙黑字寫著——它不碰你的系統、不打你的 API、不猜密碼。它只是從外面讀你的**公開 DNS 記錄**。

這跟大部分人的直覺相反。我第一次看到「被掃了」的時候,也以為對方在打我們的伺服器,還想說防火牆 log 裡應該找得到痕跡。並沒有——它連門都沒碰:

```mermaid
flowchart TB
    subgraph MYTH[&quot;以為的:有人在掃我的系統&quot;]
        direction LR
        A1[&quot;評等廠商&quot;] -.-&gt;|&quot;掃 port、打 API&lt;br/&gt;試弱密碼&quot;| A2[&quot;你的伺服器&quot;]
    end
    subgraph REAL[&quot;實際的:被動 OSINT,只讀公開資料&quot;]
        direction LR
        B1[&quot;評等廠商&quot;] --&gt;|&quot;dig&quot;| B2[&quot;全球公開 DNS&lt;br/&gt;誰都查得到&quot;]
        B3[&quot;你,用同一支 dig&quot;] --&gt;|&quot;dig&quot;| B2
    end
    MYTH --&gt;|&quot;報告白紙黑字:&lt;br/&gt;不碰你的系統、不打你的 API&lt;br/&gt;你的 log 裡不會有任何痕跡&quot;| REAL
    B2 --&gt; C[&quot;它讀得到的全部訊號&lt;br/&gt;SPF 結尾是 -all 還是 ?all&lt;br/&gt;DMARC 的 p= 是什麼&lt;br/&gt;DKIM 查不查得到公鑰&quot;]
    C --&gt; D[&quot;加權加總,換算成 A 到 F&quot;]
    D --&gt; E[&quot;所以:它看得到的,你也看得到&lt;br/&gt;這個 F 是可以自己重現的&quot;]
```

也就是說,它看得到的東西,我用一支 `dig` 也看得到。於是我把它會看的訊號全查了一遍:

```bash
dig +short TXT example.com            # SPF：發現是 &quot;?all&quot;（neutral，等於沒防護）
dig +short TXT _dmarc.example.com     # DMARC：發現是 &quot;p=none&quot;（只監控、不執行）
dig +short TXT google._domainkey.example.com   # 主要寄信的 DKIM：沒設
```

每一條都對得上報告的扣分點。既然如此,我乾脆寫了一支 **60 行的 bash**,用它報告裡列出的同一批訊號、自己配一組權重,算一個分數出來。

跑完——**3/10,F**。官方給的是 3.5。**幾乎一模一樣。** 廠商真正的加權演算法是專有的,我配的權重只是逼近——但至少我不用再等它下一份報告,就能自己量一次。

過程中還有兩個額外收穫:

- **報告會過時。** 它的掃描日跟我讀它的那天差了兩週。我用 `dig`/`openssl` 實測,發現有些被扣分的項目其實早就修好了。**實測永遠比讀報告可靠。**
- **恐慌是不必要的。** 我把實際在寄信的管道一個個查清楚,發現真正高流量的寄信來源(交易信、行銷信)其實早就認證對齊了。缺口比 F 看起來小得多。

## 具體數據 / 結果

**自製腳本 vs 官方分數**:3/10 vs 3.5/10,差 0.5 分,等級同樣落在 F。足以拿來當免費、即時的對照工具。

**email 認證的三個關鍵旋鈕**(這就是評分的核心):

| 紀錄 | 弱(會被扣分) | 強(拿分) |
|---|---|---|
| SPF | `?all`(neutral,不防偽) | `-all`(hardfail) |
| DMARC | `p=none`(只監控) | `p=reject`(強制) |
| DKIM | 主要寄件管道沒簽章 | 有簽章且對齊 |

同一件事換成腳本的算法,就是下面這張計分表。它也解釋了我們為什麼剛好卡在 3 分:

| 檢查項目 | 查法 | 配分 | 我們當時 |
|---|---|---|---|
| SPF 結尾機制 | `dig +short TXT 網域` | `-all` 3 / `~all` 2 / `?all` 1 / 沒有 0 | `?all` → 1 |
| DKIM 公鑰 | `dig +short TXT google._domainkey.網域` | 查得到 1 / 查不到 0 | 沒設 → 0 |
| DMARC 政策 | `dig +short TXT _dmarc.網域` | `reject` 5 / `quarantine` 3 / `none` 1 / 沒有 0 | `p=none` → 1 |
| MX 指向託管信箱 | `dig +short MX 網域` | 是 1 / 否 0 | Google Workspace → 1 |

畫成格子更有感——**滿分 10 格裡,DMARC 一筆 TXT 記錄就佔了 5 格**:

&lt;figure style=&quot;margin:1.75em 0&quot;&gt;
&lt;svg viewBox=&quot;0 0 520 272&quot; role=&quot;img&quot; aria-label=&quot;計分表格子圖:滿分 10 格。SPF 結尾佔 3 格,只拿到 1 格;DKIM 公鑰佔 1 格,拿到 0 格;DMARC 政策佔 5 格,只拿到 1 格;MX 託管佔 1 格,拿到 1 格。合計 3 分 / 10 分,等級 F&quot; style=&quot;width:100%;height:auto;display:block;font-family:ui-sans-serif,system-ui,&apos;Noto Sans TC&apos;,sans-serif&quot;&gt;
&lt;rect x=&quot;116&quot; y=&quot;8&quot; width=&quot;16&quot; height=&quot;14&quot; rx=&quot;3&quot; fill=&quot;#2F6B4F&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;140&quot; y=&quot;20&quot; font-size=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;拿到的分&lt;/text&gt;
&lt;rect x=&quot;228&quot; y=&quot;8&quot; width=&quot;16&quot; height=&quot;14&quot; rx=&quot;3&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;252&quot; y=&quot;20&quot; font-size=&quot;12&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;沒拿到的分&lt;/text&gt;
&lt;text x=&quot;104&quot; y=&quot;61&quot; text-anchor=&quot;end&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot;&gt;SPF 結尾&lt;/text&gt;
&lt;rect x=&quot;116&quot; y=&quot;40&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#2F6B4F&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;168&quot; y=&quot;40&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;220&quot; y=&quot;40&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;392&quot; y=&quot;61&quot; font-size=&quot;15&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;1 / 3&lt;/text&gt;
&lt;text x=&quot;104&quot; y=&quot;109&quot; text-anchor=&quot;end&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot;&gt;DKIM 公鑰&lt;/text&gt;
&lt;rect x=&quot;116&quot; y=&quot;88&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;392&quot; y=&quot;109&quot; font-size=&quot;15&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;0 / 1&lt;/text&gt;
&lt;text x=&quot;104&quot; y=&quot;157&quot; text-anchor=&quot;end&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot;&gt;DMARC 政策&lt;/text&gt;
&lt;rect x=&quot;116&quot; y=&quot;136&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#2F6B4F&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;168&quot; y=&quot;136&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;220&quot; y=&quot;136&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;272&quot; y=&quot;136&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;rect x=&quot;324&quot; y=&quot;136&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#C8102E&quot; fill-opacity=&quot;0.08&quot; stroke=&quot;#C8102E&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;392&quot; y=&quot;157&quot; font-size=&quot;15&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;1 / 5&lt;/text&gt;
&lt;text x=&quot;104&quot; y=&quot;205&quot; text-anchor=&quot;end&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot;&gt;MX 託管&lt;/text&gt;
&lt;rect x=&quot;116&quot; y=&quot;184&quot; width=&quot;44&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#2F6B4F&quot;&gt;&lt;/rect&gt;
&lt;text x=&quot;392&quot; y=&quot;205&quot; font-size=&quot;15&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.75&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;1 / 1&lt;/text&gt;
&lt;line x1=&quot;8&quot; y1=&quot;230&quot; x2=&quot;512&quot; y2=&quot;230&quot; stroke=&quot;currentColor&quot; stroke-opacity=&quot;0.18&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;
&lt;text x=&quot;104&quot; y=&quot;258&quot; text-anchor=&quot;end&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.6&quot;&gt;合計&lt;/text&gt;
&lt;text x=&quot;116&quot; y=&quot;258&quot; font-size=&quot;19&quot; font-weight=&quot;700&quot; fill=&quot;#C8102E&quot; font-family=&quot;ui-monospace,SFMono-Regular,Menlo,monospace&quot;&gt;3 / 10&lt;/text&gt;
&lt;text x=&quot;204&quot; y=&quot;258&quot; font-size=&quot;14&quot; fill=&quot;currentColor&quot; fill-opacity=&quot;0.7&quot;&gt;等級 F,百分位數幾乎墊底&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption style=&quot;font-size:0.85em;opacity:0.65;text-align:center;margin-top:0.5em&quot;&gt;缺的 7 格裡有 4 格在 DMARC 那一列——所以先修 DMARC,分數跳得最明顯。&lt;/figcaption&gt;
&lt;/figure&gt;

**可預期的修復軌跡**:因為 DMARC 強制要分階段上線(先監控、再 quarantine、最後 reject),分數會**一階一階往上爬**,而不是一次到位:

```mermaid
flowchart LR
    S0[&quot;現在&lt;br/&gt;SPF ?all · DKIM 沒設&lt;br/&gt;DMARC p=none&lt;br/&gt;3/10 F&quot;]
    S1[&quot;鋪設完成&lt;br/&gt;5/10 C&quot;]
    S2[&quot;先丟垃圾桶&lt;br/&gt;7/10 B&quot;]
    S3[&quot;完全強制&lt;br/&gt;10/10 A&quot;]
    S0 --&gt;|&quot;補 SPF、開 DKIM&lt;br/&gt;DMARC 加 rua 收報告&lt;br/&gt;零風險,不擋任何信&quot;| S1
    S1 --&gt;|&quot;p=quarantine&lt;br/&gt;盯著報告看 2 到 4 週&quot;| S2
    S2 --&gt;|&quot;p=reject&lt;br/&gt;SPF 收成 -all&quot;| S3
```

**那支腳本(去識別化版)**,你可以直接拿去量自己的網域:

```bash
#!/usr/bin/env bash
# email-auth-scorecard.sh — 用公開 DNS 重現「email 資安評等」分數
D=&quot;${1:-example.com}&quot;
score=0

# ① SPF(0-3 分):撈出 TXT 裡 v=spf1 那一筆,只看結尾的 all 機制有多寬鬆
spf=&quot;$(dig +short TXT &quot;$D&quot; | tr -d &apos;&quot;&apos; | grep -i &apos;v=spf1&apos; | head -1)&quot;
case &quot;$spf&quot; in
  *&quot;-all&quot;*) score=$((score+3));; *&quot;~all&quot;*) score=$((score+2));;
  *&quot;?all&quot;*) score=$((score+1));; esac

# ② DKIM(0-1 分):查得到公鑰就算有簽章。google 是 Google Workspace 的 selector,
#    換寄件管道要換名字(Mailgun 常見 mg、SendGrid 是 s1)
[ -n &quot;$(dig +short TXT google._domainkey.&quot;$D&quot;)&quot; ] &amp;&amp; score=$((score+1))

# ③ DMARC(0-5 分):權重最重的一項,一筆 TXT 就佔掉滿分的一半
dmarc=&quot;$(dig +short TXT _dmarc.&quot;$D&quot; | tr -d &apos;&quot;&apos;)&quot;
case &quot;$dmarc&quot; in
  *reject*) score=$((score+5));; *quarantine*) score=$((score+3));;
  *p=none*) score=$((score+1));; esac

# ④ MX(0-1 分):有指向託管信箱,當作「這個網域真的在收信、有人在管」
dig +short MX &quot;$D&quot; | grep -qiE &apos;google|aspmx&apos; &amp;&amp; score=$((score+1))

echo &quot;$D → $score/10&quot;
```

**3 分鐘上手**:① 存成 `email-auth-scorecard.sh` ② `chmod +x email-auth-scorecard.sh` ③ `./email-auth-scorecard.sh yourdomain.com`。回傳 0–10 分;想交叉驗證可再丟進 [internet.nl](https://internet.nl)、[Hardenize](https://www.hardenize.com)、或寄封測試信到 [mail-tester.com](https://www.mail-tester.com)。

&gt; 順帶一提:前置步驟(補 SPF、開 DKIM、DMARC 先開監控)其實**零風險**——它們只是「多授權」,不會擋掉任何信。唯一有風險的是最後把 DMARC 切到強制,而那步被監控期擋著。所以「F」聽起來嚇人,實際動工的爆炸半徑很小。

## 反思

### 技術面

第三方資安評等不是魔法——至少 email 這一項,它讀的就是**任何人都查得到的那幾筆 DNS 記錄**。一旦你知道它只讀 DNS,那個字母分數就從「客戶丟給你的判決書」變成「一個你能自己重現、自己追蹤的數字」。把黑箱變成儀表板,修起來才有對照、有信心。

### 心態面

- **繼承來的債最容易沒人修。** 不是因為難,是因為沒有 owner。設定它的人走了,institutional knowledge 跟著走,剩下的人都覺得「這不是我弄的」。孤兒 tech debt 會一直是孤兒,直到出現一個逼它認領的理由。
- **「知道」不等於「會修」。** 我們知道這問題很多年。讓它終於上線的不是內部的自覺,而是一個**外部、可見、有人盯著**的 forcing function——客戶的分數。這不太光彩,但很真實。
- **所以,與其等外部羞辱,不如自己動手讓債「可見」。** 那支 60 行腳本最大的價值不是算分,是它把一塊抽象的、沒人理的 tech debt,變成主管會議上一句「我們現在 3/10,改完會到 10/10」。**可見性,就是被排程的機率。**

### 有趣發現

最諷刺的一點:我原本只是想搞懂報告怎麼算,結果「自己重現分數」這件事,意外成了整個專案最有效的**溝通工具**。一份 20 頁的 PDF 很難推動排程;但一個會跳動的數字、一條看得到終點的軌跡,讓「修這個」第一次變得有吸引力。

技術債從來不缺「知道的人」,缺的是讓它**被看見**的人。

---

如果讀到這裡,還是不太確定 SPF、DKIM、DMARC 各自在管什麼,系列 [#2 白話入門篇](/blog/spf-dkim-dmarc-explained/) 用「寄一封實體掛號信」的比喻把三個一次講完;已經懂了、想直接動手把 `p=none` 推到 `p=reject`,接 [#3 實戰 playbook](/blog/dmarc-none-to-reject-playbook/);想先看看真的按下去那天會遇到什麼,看 [#4 執行日的假訊號](/blog/dmarc-enforcement-day-false-signals/)。</content:encoded><media:content url="https://bobochen.dev/images/og/reproduce-vendor-security-rating-f.png" medium="image"/><category>資安</category><category>tech-debt</category><category>email-security</category><category>DMARC</category><category>vendor-risk</category><enclosure url="https://bobochen.dev/images/og/reproduce-vendor-security-rating-f.png" length="0" type="image/png"/></item></channel></rss>