邮件信头折行(folding)为什么会导致解析不一致和安全绕过?

1 邮件信头折行(folding)为什么会导致解析不一致和安全绕过?
折行是什么:一条逻辑行的多行表示

按 RFC 5322 §2.2,每一个信头字段在逻辑上都是单独一行字符,由字段名、冒号与字段体组成,以 CRLF 结束。但为了方便,也为了应对每行 998 / 78 字符的长度限制,字段体部分可以拆成多行表示,这种做法称为折行(folding)

规范给出的一般规则很简洁:凡是本规范允许出现折叠空白(folding white space)的位置,都可以在任何空白字符之前插入一个 CRLF。注意措辞是「折叠空白」而非单纯的空白字符——这个区分在解析结构化字段时很重要。

规范以 Subject 字段为例说明:Subject: This is a test 可以表示为两行,第二行以空白字符开头。规范同时给出一条建议:尽管结构化字段体的定义允许在许多词法单元之间(甚至某些词法单元内部)折行,折行仍应当限制在较高层级的句法断点处;例如字段体定义为逗号分隔值时,推荐在逗号之后折行,而不是在其他虽被允许的位置折。

展开规则:一条极其简单的规则,以及它带来的全部麻烦

从折叠的多行表示回到单行表示的过程称为展开(unfolding)。RFC 5322 §2.2.3 对它的定义只有一句话:展开的方法就是简单地移除任何紧跟着 WSP 的 CRLF。规范并指出,每个信头字段在做进一步的句法与语义分析时都应当按其展开后的形式处理。

规则本身没有歧义,麻烦出在「什么算 CRLF」与「什么算 WSP」在不同实现中的边界判定并不一致

  • 如果报文里出现的是裸 LF 而不是完整的 CRLF,某些实现按 CRLF 处理并展开,另一些实现认为这不是合法折行而按行终止处理。
  • WSP 通常指空格与水平制表符。若续行以其他控制字符或非 ASCII 空白开头,不同实现的判定会分歧。
  • 信头与正文的分界是一个空行。如果某个实现在判定「空行」时把只含空白的行也算作空行,而另一个实现不算,两者看到的信头区范围就不同——一个把某几行当信头,另一个把它们当正文。

这些分歧单独看都很细微,组合起来就构成了典型的解析器差异(parser differential):同一份字节流,链路上不同环节解析出不同的信头集合。

解析不一致带来的实际后果

邮件链路上串联着多个会读信头的组件:网关、反垃圾引擎、认证校验模块、归档系统、最终的客户端。只要其中任意两个对折行的处理不一致,就会出现「检测组件看到的邮件」与「用户看到的邮件」不是同一封的情况。具体表现包括:

  • 发件人展示与判定不一致:检测组件展开后读到的 From 值与客户端渲染出的不同。RFC 6854 已更新 From 与 Sender 字段以允许组语法,这类字段的解析本就比直觉复杂,折行处理再有分歧,判定就会失准。
  • DKIM 校验莫名失败:RFC 6376 定义的签名覆盖被签信头字段,其规范化方式对空白与折行高度敏感。中间环节若重新折行、改变续行缩进或替换空白字符,签名即告失效,现象是「本域签的名,对端验不过」。
  • 信头注入:若应用把未经校验的用户输入拼进信头值,而输入中含有行终止序列,就可能凭空插入新的信头字段。这类问题的根因不在邮件系统而在生成报文的应用,但故障最终暴露在邮件链路上。
  • 编码字(encoded-word)跨行拼接分歧:RFC 2047 定义的编码字用于在信头中表达非 ASCII 文本。编码字被折行拆开后,不同实现对续行空白是否保留的处理存在差异,结果是主题或显示名在某些环节被还原成另一段文本。
排错方法
  1. 取原始字节,不要取渲染结果。客户端展示的信头已经过展开与解码。必须拿到原始报文(例如从队列或归档中导出的 .eml),再用 hexdump -C 观察行终止序列究竟是 0d 0a 还是裸 0a这一步不做,后续全是猜测。
  2. 逐跳落地比对。在链路的每一跳保存一份副本,比对同一封邮件在各跳上展开后的信头集合。出现差异的那一跳就是引入问题的组件。
  3. 重点检查会改写信头的设备。插入免责声明、改写链接、添加扫描标记、重新编码主题的中间设备,都可能重新生成信头。它们是折行不一致最常见的来源。
  4. 构造边界样本做基准测试。固定用例应至少包含:以裸 LF 折行的字段、以制表符作为续行首字符的字段、只含空白的「疑似空行」、被折行拆开的编码字、超过 998 字符的单行字段。这些用例应在每次组件升级后重跑。
  5. DKIM 失败时比对签名前后的信头字节。重点看被签字段的续行缩进与结尾空白是否被改动。
生成与处理两侧的实现建议
  • 生成侧使用成熟库,不要手工拼接信头字符串。折行位置的选择、编码字与折行的交互、结构化字段的语法边界,都属于「看似简单、边界情况极多」的逻辑。手工拼接是这类故障最主要的来源。
  • 生成侧严格过滤注入字符。任何进入信头值的外部输入都必须剥除或拒绝 CR 与 LF。这是在系统边界上做校验,属于必须做的一类。
  • 折在高层句法断点上。遵循规范建议,在逗号等结构分隔符之后折行,避免折在词法单元内部,可显著降低下游解析分歧。
  • 处理侧宁严勿宽。对不符合 CRLF + WSP 形式的可疑折行,明确策略是拒绝还是规范化;无论选哪种,都要记录日志,否则问题会长期静默存在。
  • 让检测与投递看到同一份内容。这是整节最重要的一条工程原则:如果安全检测组件与最终投递看到的信头不是同一份,那么检测结论本身就不成立。链路设计上应保证检测发生在最后一次信头改写之后。

参考:RFC 5322《Internet Message Format》§2.1.1 Line Length Limits、§2.2 Header Fields、§2.2.3 Long Header Fields、§3.2.2 Folding White Space and Comments,P. Resnick 编,2008 年 10 月,Standards Track,DOI 10.17487/RFC5322,https://www.rfc-editor.org/rfc/rfc5322.html ;RFC 5321《Simple Mail Transfer Protocol》,J. Klensin,2008 年 10 月,https://www.rfc-editor.org/rfc/rfc5321.html ;RFC 2047《MIME (Multipurpose Internet Mail Extensions) Part Three: Message Header Extensions for Non-ASCII Text》,K. Moore,1996 年 11 月,https://www.rfc-editor.org/rfc/rfc2047.html ;RFC 6376《DomainKeys Identified Mail (DKIM) Signatures》,D. Crocker、T. Hansen、M. Kucherawy 编,2011 年 9 月,STD 76,https://www.rfc-editor.org/rfc/rfc6376.html ;RFC 6854《Internet Message Format 更新:允许在 From: 与 Sender: 信头中使用组语法(Group Syntax)》,B. Leiba,2013 年 3 月,https://www.rfc-editor.org/rfc/rfc6854.html