一封邮件在服务器之间怎么传?答案是一场 40 年历史的文本对话:HELO、MAIL FROM、RCPT TO、DATA。这篇在本机起一个 SMTP 服务器,逐行看这场对话、信封和信件头为什么不一样、错误码怎么分类、STARTTLS 怎么把明文连接升级成加密,最后到真实的 Gmail、QQ、163 邮件服务器上只做问候和 EHLO。
S: 220 mail.w98.test Python SMTP 1.4.6
C: EHLO client.w98.test
S: 250-mail.w98.test
S: 250-SIZE 2000
S: 250-8BITMIME
S: 250-SMTPUTF8
S: 250-STARTTLS
S: 250 HELP
C: MAIL FROM:<envelope-sender@w98.test>
S: 250 OK
C: RCPT TO:<bob@w98.test>
S: 250 OK
C: RCPT TO:<hidden-bcc@w98.test>
S: 250 OK
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: <信件(带点开头的行已转义)>
S: 250 OK: queued as w98-1
C: QUIT
S: 221 Bye
EHLO 的多行答复:前面各行用 “250-”,最后一行用 “250 ”(横线表示后面还有,空格表示最后一行);列出的扩展有 ['mail.w98.test', 'SIZE', '8BITMIME', 'SMTPUTF8', 'STARTTLS', 'HELP']
服务器收到的信封:MAIL FROM = 'envelope-sender@w98.test',RCPT TO = ['bob@w98.test', 'hidden-bcc@w98.test']——信封里有 hidden-bcc@w98.test,但信件头里的 To 只写了 bob@w98.test(这就是 Bcc 的实现原理:信封收件人 ≠ 信件头里的收件人)
信件头里的 From = '"Alice" <alice@example.org>',与信封的 MAIL FROM 'envelope-sender@w98.test' 完全不同:SMTP 不核对两者,这是伪造发件人的基础
点转义:我发出的正文里有一行以点开头 ".dot-line",在线上写成 "..dot-line"(上面 C: 行里显示的是转义前的说明;实际字节见下);服务器还原后收到的正文里这一行是 ['.dot-line']
线上字节(转义后正文的一部分):b'First line.\r\n..dot-line\r\nLast '220 → EHLO(多行 250)→ MAIL FROM → RCPT TO → DATA(354)→ 正文以 CRLF.CRLF 结束 → 250 → QUIT。
S: 220 mail.w98.test Python SMTP 1.4.6
C: EHLO client.w98.test
S: 250 HELP
C: MAIL FROM:<a@w98.test>
S: 250 OK
C: RCPT TO:<blocked@w98.test>
S: 550 5.7.1 Relaying denied
C: RCPT TO:<later@w98.test>
S: 450 4.2.0 Greylisted, try again later
C: RCPT TO:<ok@w98.test>
S: 250 OK
C: FOOBAR
S: 500 Error: command "FOOBAR" not recognized
C: RSET
S: 250 OK
C: DATA
S: 503 Error: need RCPT command
C: QUIT
S: 221 Bye
550 永久失败:发送方的邮件系统应该退信;450 临时失败(灰名单):应该稍后重试;500 未知命令;503 命令顺序不对(这里 RSET 之后还没有 MAIL FROM 就 DATA)收件人和发件人都有两套:信封给传输用,信件头给人看,SMTP 不核对它们。
C: EHLO client.w98.test
S: 250-SIZE 2000
C: MAIL FROM:<a@w98.test> SIZE=5000
S: 552 Error: message size exceeds fixed maximum message size
C: <约 3000 字节正文,每行 98 字节>
S: 552 Error: Too much mail data
C: QUIT
S: 221 Bye2 成功、4 临时失败、5 永久失败;增强状态码 5.7.1 更精确。
升级前 EHLO 答复里有 STARTTLS:True;STARTTLS 命令的答复:220 Ready to start TLS;升级后再次 EHLO 的答复里还有 STARTTLS:False(已经加密就不再宣告) 整个连接的字节里:能找到明文 "EHLO client.w98.test" 吗?能(升级前的那一次);能找到明文 "STARTTLS" 命令吗?能;能找到升级后才发的 "secret-sender@w98.test" 吗?找不到(已加密) 注意 STARTTLS 的弱点:升级前一切是明文,路径上的设备可以把 “250 STARTTLS” 从 EHLO 答复里删掉(降级攻击),发送方若是“尽量用 TLS”策略就会继续明文
EHLO 宣告上限,MAIL FROM 带 SIZE 可以提前被拒绝。
gmail-smtp-in.l.google.com:问候 '220 mx.google.com ESMTP 41be03b00d2f7-cd635e4c53dsi3795446a1'(996 ms);EHLO 扩展 ['SIZE', '8BITMIME', 'STARTTLS', 'ENHANCEDSTATUSCODES', 'PIPELINING', 'CHUNKING', 'SMTPUTF8'] mx1.qq.com:问候 '220 newxmmxszc26-0.qq.com MX QQ Mail Server.'(146 ms);EHLO 扩展 ['STARTTLS', '8BITMIME', 'SMTPUTF8', 'SIZE', 'OK'] smtp.163.com:问候 '220 163.com Anti-spam GT for Coremail System (163com[2014120'(90 ms);EHLO 扩展 ['AUTH', 'AUTH=LOGIN', 'coremail', 'STARTTLS', 'ID', '8BITMIME']
把已有的明文连接升级成 TLS,升级前一切明文,存在降级攻击;Gmail、QQ、163 都宣告 STARTTLS。