伪造邮件放回 .eml,Received 链、Return-Path、Message-ID 三处对不上

作者:Ran1112
首发:先知社区
文章类型:企业安全 / 应急响应
关键词:邮件取证 · SMTP · Received 链 · Return-Path · 钓鱼邮件

前言

本文用纯标准库搭了一个邮件靶场(发信端、中转、收信端三个角色),投出两封信——正常的一封 973 字节、伪造的一封 754 字节——再用一个取证脚本把它们读成投递链。伪造那封命中两条硬信号:Received 链时间倒挂 6000 秒,以及信封发件域 mail-relay.test 与显示发件域 example-corp.test 不一致;判定脚本对两封信分别给出退出码 0 和 1,可以直接进流水线做批量初筛。全程只跑本机回环,不联网、不依赖任何邮件服务。

上一篇《给一份 RBAC 清单,判断它能不能拿到 cluster-admin》是把一份配置读成权限图,让程序回答"这个主体能走到哪里"。这一篇换到邮件,思路一样:把一封 .eml 读成一张投递链,让程序回答"这封信到底是谁交的"。

本文覆盖四件事:邮件头部的三层结构(哪些内容由投递方写、哪些由发信方自带);手写 SMTP 的三个角色和它们各自的职责;两封信的完整投递过程与落盘对比;我自己踩的三个协议坑(多行回复、点号填充、读反链条)以及它们如何影响取证结论。收尾给六条可判定的信号、一张判定顺序图和企业侧的加固方向。

不做的事也说清楚:SPF/DKIM/DMARC 的实现与验证器写法不在本文范围——那是另一篇的体量,本篇只讲它们在头部链条里的位置,并且会明确标出本地靶场验不了的那一条。

一、一封信的头部是怎么叠出来的

先把几个概念放到它们各自的位置上,后面的判定顺序全部由这个结构推出来。

简单邮件传输协议(Simple Mail Transfer Protocol,SMTP) 是投递这封信用的应用层协议,它维护两套发件人:信封发件人(MAIL FROM: 命令里的地址,投递过程使用)和头部发件人(From: 头里的地址,人看的那一个)。这两者可以完全不同,这是伪造邮件的立足点,也是取证最有力的抓手。

Received 是每一跳邮件服务器(Mail Transfer Agent,MTA,负责接收与转发邮件的服务程序)在交付前追加到头部最上面的一行,记录"谁交给了谁、什么时候交的"。关键性质是顺序:后加的在最上面,所以文件里自上而下等于投递时间倒序——读链条必须自下而上。

回信地址(Return-Path) 由最后投递的那台 MTA 按信封发件人写入。它跟 From: 不是一回事:From: 是发信方自己写的字符串,Return-Path 是收信端根据投递时实际看到的信封写的。

邮件标识(Message-ID) 由发信方生成,常见形态是 <时间戳.随机串@域>。它同样由发信方写,所以单独看不算证据,但它的域和时间戳能和链条对照——对不上就是线索。

认证结果头(Authentication-Results) 由收信网关写入,内容是 SPF、DKIM、DMARC 三件套的校验结论。邮件认证三件套分别工作在这三层:SPF(Sender Policy Framework,发件人策略框架)用发件域的 DNS 记录声明"哪些 IP 可以用这个域发信";DKIM(DomainKeys Identified Mail,域名密钥邮件签名)给信件加数字签名,收信方用选择器(selector)去 DNS 取公钥验签;DMARC(Domain-based Message Authentication, Reporting and Conformance,基于域的消息认证、报告与一致性)把前两者的结果与 From: 头里的域做对齐,并给出 none/quarantine/reject 三档处置策略。

为什么本地靶场里这一行是空的:我们没有 DNS、没有网关,投递只在回环上进行,所以本文所有报告里 Authentication-Results 都是 0 条。这是本文最需要交代清楚的一条边界——生产环境里它才是第一判据,本地靶场只能用"信封 + 链条"这两条它替代不了的信号。这一点我在第五节还会回到。

上图:三层结构。最外层是收信端刚写的(信封发件人 + 一条 Received),中间是每一跳加的 Received,最内层全部由发信方自带。判定顺序由此而来:先看最外层,再看链条是否连续,最后才轮到发信方写的那一层。

一个对照就够说明问题:信封发件人和 Received 由别人的服务器写,攻击者进不去;From:、Message-ID、乃至手动塞进去的假 Received 都写在投递之前,想写什么写什么。所以取证的第一个动作永远是"分清这一行是谁写的",而不是"读这一行说了什么"。

二、靶场:三个自己写的 SMTP 角色

为什么不用 Postfix 或现成的邮件服务?有三个具体的理由:第一,我要看到信封(MAIL FROM/RCPT TO 的原始值)——很多图形化工具只展示头部,信封反而被藏起来;第二,我要能控制协议细节——多行回复、点号填充这些行为必须能被打开和关闭,否则复现不了后面的坑;第三,不想在测试机上装一个常驻 MTA 服务。三个理由加起来,代价是自己写大约 700 行标准库代码(不含把流程串起来的 demo_all.py)。

mail-lab/ 里五个文件,职责分得很细:

文件

角色

职责

mini_smtp.py

协议工具

回复读取(含多行)、命令收发、点号填充与还原

mail_sink.py

收信端 MTA

收信、写 Return-Path 与自己的 Received、落盘到 maildrop/

mail_relay.py

中转 MTA

收下邮件、加自己的 Received、转交给下一跳

send_mail.py

发信端

手写会话投递;--mode genuine/spoofed 两种信件

forensics.py

取证解析器

把 .eml 读成信号与判定,退出码 0/1

环境是 CPython 3.14 + Windows 10.0.26200,全部跑在 127.0.0.1 上。先看两个关键片段,它们对应后面两个坑:

def read_reply(f, *, naive: bool = False) -> tuple[int, list[str]]:
    """读一个完整 SMTP 回复,返回 (状态码, 行列表)。

    naive=True 时只读一行(复现"没处理多行续行导致的命令错位",见 README)。
    """
    lines: list[str] = []
    while True:
        raw = f.readline()
        if not raw:
            raise SMTPError("等回复时连接被关闭(读到 EOF)")
        line = raw.decode("utf-8", "replace").rstrip("\r\n")
        lines.append(line)
        if naive or len(line) < 4 or line[3] != "-":   # "250-xxx" 是续行,最后一行才是结果
            break
    code = int(line[:3]) if line[:3].isdigit() else 0
    return code, lines
elif verb == "DATA":
    reply("354 End data with <CR><LF>.<CR><LF>")
    body: list[str] = []
    while True:
        raw2 = f.readline()
        bl = raw2.decode("utf-8", "replace").rstrip("\r\n")
        if bl == ".":                      # 单独一个点=正文结束标记
            break
        body.append(unstuff(bl))           # 还原点号填充:开头的 ".." 变回 "."
    mid = next_id()
    stamp = email.utils.formatdate(localtime=True)
    received = (f"Received: from {helo or 'unknown'} ([{addr[0]}]){CRLF}"
                f"\tby sink.test with ESMTP id {mid}{CRLF}"
                f"\tfor <{rcpts[-1] if rcpts else 'unknown'}>; {stamp}{CRLF}")
    raw_msg = (f"Return-Path: <{mail_from}>{CRLF}"      # 信封发件人,收信端写入
               + received
               + CRLF.join(body) + CRLF)

注意收信端写的两行:Return-Path 取的是信封里的 MAIL FROM,Received 里的 by sink.test 是它自己的身份。这两行在信件里最可信,理由很朴素——这封信只有走到这一步才会出现它们。

靶场的启动与验证(每条命令后面跟一句怎么确认成功):

python mail_sink.py --port 2525     # 看到"[sink] 监听 127.0.0.1:2525,落盘目录 maildrop"即成功
python mail_relay.py --port 2526 --next-port 2525
                                     # 看到"[relay] 监听 ... 转交给 127.0.0.1:2525"即成功

demo_all.py 会把上面这些自动串起来(端口取空闲口、跑完自动收摊),一条命令产出 transcript.txt。本文引用的全部输出都来自它的一次完整运行。

三、投递两封信

第一封:正常邮件,走中转(两跳)。 信封与头部同域,投递路径是发信端 → relay.test → sink.test:

python send_mail.py --port 60405 --mode genuine --save samples

上图:会话逐条落到 220 → EHLO → MAIL FROM → RCPT TO → DATA(354) → 正文 → 250。250-relay.test greets client.lab.test 那三行就是多行回复,第四节第一个坑从这里来。

第二封:伪造邮件,直投收信端(一跳)。 From: 头写的是公司域,MAIL FROM 却是另一个域:

python send_mail.py --port 60404 --mode spoofed --save samples

收信端在落盘时注意到了一件事,直接打进了日志:

[sink] 收信 id=63494-02 helo='client.lab.test' 信封发件人=<bounce@mail-relay.test> 收件人=['finance@example-corp.test'] 大小=754B 落盘=1791963494400-63494-02.eml
[sink]   ! 投递前这段报文里就自带 1 条 Received——发送方自己塞的(投递方只会往链条上加,不会往下塞)

落盘之后的伪造信件长这样(域名为示例值,下同):

Return-Path: <bounce@mail-relay.test>                       ← 收信端写的:真实信封
Received: from client.lab.test ([127.0.0.1])                ← 收信端写的:它看到的第一跳
    by sink.test with ESMTP id 63494-02
    for <finance@example-corp.test>; Wed, 14 Oct 2026 15:38:14 +0800
Received: from mx.example-corp.test (mx.example-corp.test [203.0.113.9])
    by inbound.example-corp.test; Wed, 14 Oct 2026 17:18:14 +0800   ← 伪造者自己塞的
From: "IT 支持部门" <it-support@example-corp.test>            ← 全部由发信方自带
To: 财务部 <finance@example-corp.test>
Subject: 请立即复核本月付款账号
Date: Wed, 14 Oct 2026 15:38:14 +0800
Message-ID: <20261014153814.91bd@mail-relay.test>

同一封信里出现了三个不同的域,各自代表一层:bounce@mail-relay.test 是真实发信域(伪造者真实使用的发信设施),example-corp.test 是想冒充的域(收件人看到的),inbound.example-corp.test 是伪造者替收信方编造的网关。三个域凑不到一起,就是这封信的破绽。

上面 Message-ID 尖括号里的 20261014153814 是 14 位时间戳,读法可以自己算一遍:2026 10 14 15 38 14 → 2026-10-14 15:38:14,和 Date: 头写着的时间一致。真正值得看的是它后面那个域——它属于发信设施(mail-relay.test),而 From: 头声称的域是 example-corp.test,两个域对不上。(伪造者塞进来的 203.0.113.9 取自 RFC 5737 的文档保留地址段,不会指向真实主机。)

四、三处踩坑

这一节是本文我认为最有用的部分。三处都不是"知识",而是我实际写错、跑出来才发现的。

第一处:EHLO 的回复是三行,我只读了一行。 收信端对 EHLO 的回复是标准的续行格式:

250-sink.test greets client.lab.test
250-8BITMIME
250 HELP

前两行以 250- 结尾(连字符=还有后续),最后一行 250 才是结果。我第一版代码读完第一行就返回,于是后续每条命令读到的都是上一条命令的回复——命令与回复整体错位一格,直到 DATA 阶段才炸出来:

-> MAIL FROM:<ops@example-corp.test>
<- 250-8BITMIME          ← 这是 EHLO 的第二行,不是 MAIL FROM 的回复
-> RCPT TO:<finance@example-corp.test>
<- 250 HELP              ← 这是 EHLO 的第三行
-> DATA
[client] !! DATA: 期望 (354,) 得到 250 —— 250 2.1.0 OK

这个坑在真实环境里同样会踩:250-SIZE、250-8BITMIME、250 STARTTLS 都是标准回复项,任何按行读协议的代码都要处理续行。修复就是把"读到最后一行为止"写进 read_reply(见第二节代码),另外留了一个 --naive-ehlo 开关专门用于复现这条错误。

第二处:点号填充。 SMTP 的正文结束标记是单独一行 .,所以正文里以 . 开头的行必须多发一个点(点号填充,dot-stuffing),收信端再还原。我的第一版发送端没做这件事,两个后果同时出现:

  • 以 . 开头的那行少掉一个点:. /etc/profile.d/legacy-env.sh 到收信端变成 /etc/profile.d/legacy-env.sh;
  • 正文里单独一个点的那行被当成结束标记,信件在那里被截断——后面的内容全部被当成 SMTP 命令,收信端回了一串 502,发送端最后一步收到 QUIT: 期望 (221,) 得到 502。

两封落盘文件对比,差别很直白——正常样本的正文结尾是单独一个点的行和落款行,被截断的样本停在"下面是从旧脚本里抄出来的记录……":

---- 正常样本(填充=开):sample-正常邮件-两跳.eml(非空 20 行)----
    含 legacy-env 的那一行:. /etc/profile.d/legacy-env.sh      # 行首一个点,不填充就会少一个点
    正文最后一行:—— 运维组
---- 复现残留(填充=关):1791963494649-63494-03.eml(非空 15 行)----
    含 legacy-env 的那一行: /etc/profile.d/legacy-env.sh      # 上面这行的点是丢的
    正文最后一行:下面是从旧脚本里抄出来的记录,最后单独一个点是内容的一部分,别删:

(残留下来的这封整体少 5 个非空行:正文尾部被截掉的两行,加上它没走中转、少三行 Received。)

这个坑对取证的意义不在于"信发丢了",而在于证据会在你不注意的地方失真:一封被截断的信,它的正文和真实发送的内容不是同一份,拿它当证据会得出错误结论。

还有一个连带坑:中转那一跳要重新填充。收信端还原过点号之后,正文里存的是真正的 .;中转转发时如果原样发出,就会在下一跳触发同一个截断。我第一版漏了这一步,结果是"直接投递正常、经中转必断"——现象是客户端读到 等回复时连接被关闭(读到 EOF),根因在第二跳。修复就是转发时再过一遍 stuff()。

第三处:把 Received 链读反。 我第一版解析器按文件顺序把最上面那条当成第一跳,于是投递顺序被读成了"sink 先收信、公司网关后收信"——顺序本身就荒谬,更糟的是结论跟着错:时间差从 -6000s 变成 +6000s,"时间倒挂"这条硬信号直接消失了(详见第五节的对照截图,同样的文件,硬信号从 2 条变 1 条)。

修复只有一行:ordered = list(reversed(hops))。但这一行值得单独写一节——它说明"信号"和"读法"是两件事:链上的时间戳都在那里,读法错了,信号就不会被算出来。解析器里留了 --naive-order 开关复现这条。

五、检测:六条信号与判定

六条信号,前五条只依赖信件本身,第六条第 5 条在本靶场里验不了(原因见第一节):

#

信号

正常信件的样子

伪造信件的样子

本靶场

1

Received 条数与连续性

每跳一条,主机名能衔接

少一条,或出现"投递方不会写"的条目

可验

2

链条时间单调性

自下而上递增(允许少量时钟偏差)

倒挂:下面的跳比上面的还晚

可验(-6000s)

3

信封域 vs 显示域

Return-Path 与 From: 同域

不同域,显示的发件人是假的

可验

4

Message-ID 的域与时间

与 From: 域一致,时间贴近首跳

域是发信设施域,或时间对不上

可验(域不一致)

5

Authentication-Results

网关写入 spf/dkim/dmarc 结果

结果里有 fail,或缺失得没道理

本地为空

6

主机名与 IP 的对应

与发件域的基础设施一致

声称的网关不在收信方资产里

靶场里都是 127.0.0.1,无线索

判定顺序压缩成五步(对应脚本的执行顺序),以及一封可疑邮件到手后的判定树:

两封信一起跑判定,伪造那封报出两条硬信号:

python forensics.py --dir maildrop            # 分析目录下全部 .eml

[1] Received 链:2 条(信件里自上而下存放,等于投递时间倒序)
    文件顺序 1:by sink.test                  2026-10-14 15:38:14 +0800
    文件顺序 2:by inbound.example-corp.test  2026-10-14 17:18:14 +0800
    按投递顺序(自下而上)排列(hop 1 = 最早的一跳):
    hop1  from mx.example-corp.test     by inbound.example-corp.test  17:18:14
    hop2  from client.lab.test          by sink.test                  15:38:14  Δ -6000s
[5] 结论:疑似伪造
    硬信号 1:时间倒挂:hop1(by inbound.example-corp.test,17:18:14)比它后面那一跳 hop2(by sink.test,15:38:14)还晚 6000 秒——链条不可能这样产生
    硬信号 2:信封域 mail-relay.test ≠ 头部显示域 example-corp.test(显示的发件人不是真实发信人)

两条硬信号里,第二条是"域名对不上",第一条需要解释一下为什么"6000 秒"是硬信号:链条是按投递顺序自上而下叠的,下面那条到得早、上面那条到得晚,时间必须单调。伪造者塞进来的那条声称是 17:18 到的,而真正的投递发生在 15:38,它"到达"的时间比处理它的服务器收到它的时间还晚 100 分钟。这不是时钟偏差——6000 秒就是伪造者随手写了个未来时间,而链条的顺序把他的笔迹钉死了。

批量初筛看这张汇总表:

python forensics.py --dir maildrop --table
信件                          Received  信封域                    显示域                    时间倒挂                         结论
----------------------------------------------------------------------------------------------------------------------------------
1791963494292-63494-01.eml         2  example-corp.test      example-corp.test      无                            未发现硬信号
1791963494400-63494-02.eml         2  mail-relay.test        example-corp.test      inbound.example-corp.test 比下一跳晚 6000s 疑似伪造(2 条硬信号)

脚本对两类文件给出 0 与 1 两个退出码,所以一行 for 循环就能把一整个 maildrop/ 初筛完,疑似伪造的再人工细看。

写这段解析时还有个小细节值得提:Received 头是折叠的(真实 MTA 会把一条长记录折成三行,靶场里也照此实现),按行 split 会把一条记录读成三条,必须先把续行合并再解析。解析器里统一做了 unfold()。

六、修复与加固

这一节分清两件事:本次实测过的和配置侧未实测的。

先把收信侧的核验顺序固定下来(这一套是可以照做的):

  1. 看 Authentication-Results:有这行且 spf/dkim/dmarc 全 pass,域名没有被冒用,基本结案;有 fail,直接取值定性(比如 spf=fail 会带发信 IP,可以顺手进封禁流程)。
  2. 没有这行就看信封:Return-Path 与 From: 的域不一致,就足以定性"显示的发件人是假的"——这一条不依赖任何网关设施。
  3. 再看链条:自下而上读 Received,时间必须递增;出现倒挂、或出现"声称由本公司网关处理、但本公司网关根本没这台机器"的记录,都按可疑处理。
  4. 最后看 Message-ID 和其它头部:域与时间是否自洽,只当辅助线索。
  5. 存证:保留原始信件(.eml 原文,而不是转发或截图之后的版本),并保留 MTA 日志里对应的那条投递记录——日志里的信封信息是信件里改不掉的那一部分。

配置侧的加固方向属于通用做法,本文没有在本机实测(本地没有 DNS 与网关),列在这里是为了和信息链闭环;开好之后那封伪造信会变成什么样,写在括号里可以对照:

  • 给真实发信域配 SPF(v=spf1 ip4:203.0.113.9 -all;~all 是软失败,-all 是硬失败,先软后硬是常见过渡路径)——配好之后这封伪造信的 spf 会是 fail(发信 IP 不在授权列表里)。
  • 配 DKIM 并轮换选择器(选择器是 DNS 记录名的一部分,轮换时新旧并存)——伪造信没有对应签名,dkim 会是 none。
  • DMARC 从 p=none 起步观察(先看报告,确认自有发信源都对齐),再收紧到 quarantine、reject——这里伪造信会因为域对齐失败被判定 dmarc=fail。
  • 本地 MTA 日志留存至少 90 天(应急场景里,"这封信什么时候、从哪个 IP、用哪个信封地址进来"经常比信件本身更早知道答案)。

再补一条本靶场验证过的回归方式:修复不该改变样本的结论。把 samples/ 里两封信重新喂给判定脚本,期望值仍然是一个 0 一个 1——修复的是配置,不是证据;配置生效后的变化发生在 Authentication-Results 这一行上,而它在本地靶场里是空的。

七、总结

  • 判断一封邮件的真伪,先分清"这一行是谁写的":信封与 Received 由投递方写,改不掉;From:、Message-ID、手动塞的假 Received 全部由发信方自带,单独看都不构成证据。
  • Received 链要自下而上读。读反之后,时间倒挂这种硬信号会直接消失——同一封文件,硬信号从 2 条变 1 条。
  • 协议细节会变成证据问题:漏掉点号填充,18 行正文落盘只剩 15 行,且以 . 开头的那行少一个点;中转跳要重新填充,否则链路在第二跳断。
  • 本靶场实跑的两个判定:正常信(两跳,Δ +0s,信封与显示域一致)未发现硬信号、退出码 0;伪造信(时间倒挂 6000 秒 + 域名不一致)疑似伪造、退出码 1。

当前状态与边界:实测环境 CPython 3.14 + Windows 10.0.26200,全部输出来自本机实跑,记录在 mail-lab/transcript.txt,一条 python demo_all.py 可重放。四点边界:一是本地投递没有网关,Authentication-Results 全为空,本文的判定只用了"信封 + 链条"两条信号,真实企业环境里应先看那一行;二是时间倒挂的阈值取 120 秒(SKEW),跨机房时钟漂移大或链路慢时这个阈值要按环境调,否则会误报;三是样本是自造的,两封信、一封 973 字节一封 754 字节,不是真实案件的邮件,所有域名都用了 .test 与文档保留地址段;四是 SPF/DKIM/DMARC 的实现与验证器写法没有覆盖,那是下一步的题目。

参考资料

  • RFC 5321《Simple Mail Transfer Protocol》—— 信封(MAIL FROM/RCPT TO)、回复码与点号填充的出处;靶场里的协议行为按它实现
  • RFC 5322《Internet Message Format》—— 头部字段语法,Received 的折叠规则
  • RFC 7208(SPF)、RFC 6376(DKIM)、RFC 7489(DMARC)—— 三件套的判定语义;本文只用到它们在头部链条里的位置,未做验证器实现
  • RFC 5737《IPv4 Address Blocks Reserved for Documentation》—— 文中的 203.0.113.0/24 取自这份文档的保留段
  • 相邻题材供对照:攻防社区《应急响应之钓鱼邮件》(https://forum.butian.net/share/883,处置流程视角)、先知社区《记一次真实的邮件钓鱼演练》(https://xz.aliyun.com/news/5043,演练视角)。本篇与它们的分工是"信件本身的证据结构 + 可跑的判定脚本"——本文的全部代码与输出都是自己写的,未引用上述文章的结论
  • 靶场与全部实测输出:mail-lab/,一条 python demo_all.py 可重放

免责声明:本文的靶场与脚本仅用于安全研究与授权测试,只在自己拥有管理权限的环境里运行;文中所有域名、地址与账号均为示例,不指向任何真实目标。

本文首发于先知社区