邮件安全态势感知与异常检测 — 日志分析、行为建模与安全编排自动化

📅 2026-07-04 📂 邮件安全 · 安全分析 ⏱ 约 18 分钟

一、邮件日志标准化:从 maillog 到结构化数据

邮件系统的安全分析能力,起点永远是日志的可读性与结构化程度。Postfix 默认写 /var/log/maillog,格式为传统 syslog 文本行:

Jul  4 04:12:31 mx1 postfix/smtpd[21045]: connect from unknown[203.0.113.45]
Jul  4 04:12:33 mx1 postfix/smtpd[21045]: 3f8A2b1Lkz: client=unknown[203.0.113.45], sasl_method=LOGIN, sasl_username=user@example.com
Jul  4 04:12:35 mx1 postfix/cleanup[21048]: 3f8A2b1Lkz: message-id=<abc123@example.com>
Jul  4 04:12:36 mx1 postfix/qmgr[1203]: 3f8A2b1Lkz: from=<user@example.com>, size=4512, nrcpt=1
Jul  4 04:12:38 mx1 postfix/smtp[21052]: 3f8A2b1Lkz: to=<recip@external.com>, relay=mx.external.com[198.51.100.1]:25, delay=5.1, delays=0.2/0.1/2.1/2.7, dsn=2.0.0, status=sent (250 OK)

这种格式在几十封邮件时勉强接受,当日志量达到数十万条/天时,靠 grep 逐一排查已不现实。必须做两件事:统一字段定义,并将关键事件转换为结构化格式。

1.1 Postfix 日志字段定义

Postfix 进程 关键事件 可提取字段
smtpd connect / disconnect remote_ip, helo_name, tls_version, tls_cipher
smtpd sasl_username 认证 sasl_username, sasl_method, remote_ip
cleanup message-id 入队 queue_id, message_id, from, size
qmgr 投递队列状态 queue_id, from, size, nrcpt, delay
smtp 投递完成 / 延迟 to, relay, delay, dsn, status
bounce 退信 reason, original_recipient, status_code
anvil 速率统计 rate, connections_per_min, message_count

1.2 awk 聚合示例

用 awk 把 maillog 按 SASL 用户名聚合,统计每个认证用户的外发邮件数:

awk '/sasl_username=/ {
    match($0, /sasl_username=([^, )]+)/, u)
    match($0, /nrcpt=([0-9]+)/, r)
    user = u[1]; rcpts = r[1] ? r[1] : 1
    count[user] += rcpts
    if (!(user in first)) first[user] = $1" "$2" "$3
    last[user] = $1" "$2" "$3
}
END {
    printf "%-40s %8s %8s  %s  --  %s\n", "USER", "SENDS", "RCPTS", "FIRST_SEEN", "LAST_SEEN"
    for (u in count)
        printf "%-40s %8d %8d  %s  --  %s\n", u, count[u], count[u], first[u], last[u]
}' /var/log/maillog | sort -t\| -k2 -rn | head -20

1.3 输出为结构化 JSON / CEF

用 rsyslog 的 mmjsonparse 模块,配合自定义模板,将 Postfix 日志实时转写为 JSON 一行一条:

# /etc/rsyslog.d/30-postfix-json.conf
module(load="mmjsonparse")

template(name="postfix_json" type="list") {
    constant(value="{")
    constant(value="\"timestamp\":\"")    property(name="timereported" dateFormat="rfc3339")
    constant(value="\",\"hostname\":\"")  property(name="hostname")
    constant(value="\",\"program\":\"")   property(name="programname")
    constant(value="\",\"pid\":")         property(name="procid")
    constant(value=",\"msg\":\"")         property(name="msg" format="jsonfr")
    constant(value="\"}\n")
}

if $programname startswith "postfix" then {
    action(type="omfile" file="/var/log/postfix-json.log" template="postfix_json")
    action(type="omfwd" target="192.168.10.50" port="5140" protocol="tcp" template="postfix_json")
}

对于 SIEM 产品,CEF(Common Event Format)是更通用的选择。下面是一段 CEF 格式模板,直接在 rsyslog template 中使用:

template(name="postfix_cef" type="string"
    string="CEF:0|MailSystem|Postfix|3.6|smtp-auth|SMTP Authentication|5|src=%fromhost-ip% suser=%sasl_username% msg=%msg%\n"
)

RFC 5424(The Syslog Protocol)定义了结构化 syslog 消息的完整头部格式,包括 MSGID 与 STRUCTURED-DATA 字段。rsyslog 的 RainerScript 语法完整支持 RFC 5424 的 structured-data 规范。若使用 TLS 传输 syslog,还应遵循 RFC 5425(TLS Transport Mapping for Syslog),确保日志在网络传输中的机密性与完整性。

二、日志采集管道:rsyslog → 集中存储

在生产环境中,日志采集的总架构通常是:

Postfix maillog (多台 MX)
   │
   ├── rsyslog (本地预处理 + 格式化为 JSON/CEF)
   │       │
   │       ├── TCP/5140 ──→ 集中 rsyslog 汇聚节点
   │       │                    │
   │       │                    ├── 写出本地 /var/log/aggregated/*.log (轮转)
   │       │                    └── 转发 TCP/24224 ──→ Fluentd
   │       │                                              │
   │       │                                              ├── filter: 解析 JSON
   │       │                                              ├── output: ElasticSearch (daily index)
   │       │                                              └── output: Kafka (实时流)
   │       │
   │       └── 本地轮转 + logrotate 压缩保留 90 天
   │
   └── filebeat (备选轻量采集) ──→ Logstash ──→ ElasticSearch

2.1 rsyslog 汇聚节点配置

# /etc/rsyslog.d/00-aggregator.conf
module(load="imtcp")
module(load="imudp")

input(type="imtcp" port="5140" Ruleset="remote")
input(type="imudp" port="5140" Ruleset="remote")

ruleset(name="remote") {
    # 按来源 IP 分文件
    $template PerHostLog,"/var/log/aggregated/%FROMHOST-IP%/%$YEAR%-%$MONTH%/%$YEAR%-%$MONTH%-%$DAY%.log"
    *.* ?PerHostLog

    # 同时转发给 Fluentd
    *.* action(type="omfwd" target="127.0.0.1" port="24224" protocol="tcp")
}

2.2 用于离线取证的 sed 技巧

当需要提取特定时间段(如凌晨 2:00-5:00)的 SASL 登录事件:

sed -n '/^Jul  4 02:/,/^Jul  4 05:/p' /var/log/maillog \
  | grep 'sasl_username=' \
  | awk '{print $1,$2,$3,$NF}' > night_auth_events.txt

从退信日志中提取被投递到外部的高频目标域名:

grep 'status=bounced' /var/log/maillog \
  | grep -oP 'to=<\K[^>]+' \
  | awk -F@ '{print $2}' | sort | uniq -c | sort -rn | head -15

三、行为基线建模:正常画像的数学表达

没有基线就没有异常。基线建模的核心思路是:在一个足够长的窗口(建议 ≥ 30 天)内,为每个维度的指标建立统计学分布,然后用该分布判定新观测值是否离群。

3.1 用户发信量基线

为每个已认证用户统计以下指标:

指标 统计方法 典型正常范围
每小时发信量 滚动 1h 窗口 sum < μ + 3σ(约 5-30 封)
每日发信总量 按天聚合 < P95 值(通常 < 200 封)
每日唯一收件域数 COUNT DISTINCT domain < 15 个域
收信域熵值 -Σ pi × log₂(pi) < 2.5(集中比分散更正常)
认证 IP 数 / 天 COUNT DISTINCT auth_ip 1-3 个
工作时间占比 08:00-20:00 发信 / 全天 > 85%

3.2 收信域名拓扑熵

一个正常用户发信给 3 个内部同事和 1 个客户,域分布是 {corp.com: 3, client-a.com: 1},熵为 0.81。而僵尸账号在 1 小时内向 50 个不同域各发出 1 封邮件,域分布均匀,熵值会飙升至 log₂(50)≈5.64。这个差值是最直接的异常信号

用 Python 在日志入库后做批量计算:

import math, json
from collections import Counter

def domain_entropy(domains: list[str]) -> float:
    """计算收件域分布的 Shannon 熵"""
    total = len(domains)
    if total == 0:
        return 0.0
    counter = Counter(domains)
    return -sum((c / total) * math.log2(c / total) for c in counter.values())

# 示例:从 ES 查询某用户最近 1h 外发记录
# domains = [r['_source']['recipient_domain'] for r in results]
# entropy = domain_entropy(domains)
# if entropy > 3.0: trigger alert

四、异常检测模式与实战规则

4.1 突发外发量(僵尸邮件账号)

这是最常见的沦陷信号。攻击者获取 SMTP 凭证后,通常会在短时间内对外群发垃圾/钓鱼邮件。检测方法:

# 从结构化日志(JSON)中用 jq 快速过滤
cat /var/log/postfix-json.log | jq -r '
  select(.msg | test("sasl_username="))
  | [.timestamp, (.msg | capture("sasl_username=(?<u>[^ ,]+)").u)]
  | @tsv
' | awk '{
    user=$2
    ts=$1
    count[user]++
    if (count[user]==1) first[user]=ts
    last[user]=ts
}
END {
    for (u in count)
        if (count[u] > 100) printf "%s\t%d\t%s\t%s\n", u, count[u], first[u], last[u]
}'

在生产环境中,这条规则应该做成流式窗口聚合——Fluentd 每 60 秒输出一个滑动窗口内的用户发信计数,写入 Kafka 后由实时消费者裁决:

# Fluentd config: 按 sasl_username 聚合 60s 窗口
<filter postfix.*>
  @type record_transformer
  enable_ruby true
  <record>
    sasl_user ${record["msg"].match(/sasl_username=([^, ]+)/)&.captures&.first || "unknown"}
  </record>
</filter>

<match postfix.output>
  @type flowcounter
  unit minute
  aggregate tag
  count_keys sasl_user
  <store>
    @type kafka2
    brokers kafka1:9092,kafka2:9092
    topic postfix-user-stats
    compression_codec gzip
  </store>
</match>

4.2 非工作时间异常登录

结合两个维度:时间窗口(凌晨 00:00-06:00)+ 地域突变(平时在北京登录,突然从境外 IP 认证)。

# 提取非工作时间的 SASL 认证事件
grep 'sasl_username=' /var/log/maillog \
  | awk '{
      split($3, t, ":")
      hour = t[1] + 0
      if (hour >= 0 && hour < 6) print
  }' \
  | grep -oP 'sasl_username=\K[^, ]+' \
  | sort | uniq -c | sort -rn

4.3 同一 IP 多账号登录

正常情况下,一个 IP 属于一个 NAT 出口或办公网段,上面可能有多个用户通过 SMTP 认证发信——这本身不异常。但如果同一 IP 在短时间内认证了 10+ 个不同账号,而这些账号的历史登录 IP 没有交集,则极可能是凭证攻击。

awk '/sasl_username=/ {
    match($0, /sasl_username=([^, )]+)/, u)
    match($0, /\[([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\]/, ip)
    user = u[1]; addr = ip[1]
    ip_users[addr][user] = 1
}
END {
    for (ip in ip_users) {
        n = 0; for (u in ip_users[ip]) n++
        if (n > 5) printf "%-20s %d users\n", ip, n
    }
}' /var/log/maillog

4.4 收发信比偏离

大多数企业用户的收发信比大约在 1:3 到 1:10 之间(发出去的远少于收到的)。如果某个用户突然变成 10:1(发远超收),说明该账号可能在滥发。

场景 收发比 判定
普通文职用户 1:5 ~ 1:15 正常
市场/公关岗位 1:1 ~ 3:1 合理(岗位特性)
任意用户突然飙到 50:1 以上 高度可疑
新建账号 24h 内 100:0 几乎确定是恶意注册

MITRE ATT&CK T1566 (Phishing):钓鱼邮件分为 T1566.001(Spearphishing Attachment,带附件定向攻击)和 T1566.002(Spearphishing Link,含恶意链接)。当检测到用户外发邮件中嵌入的 URL 命中威胁情报库(如 URLhaus、PhishTank),应立即归入此战术标签进行追踪。内部被钓鱼沦陷后转为外发钓鱼节点,对应 T1566 + T1584.006(Compromise Infrastructure: Web Services)的连锁攻击链。

五、洞见式警报 vs 阈值式警报

传统阈值式警报容易产生两个极端:阈值太松则漏报,阈值太紧则告警疲劳。操作人员很快学会忽略。

洞见式警报的核心思想是:不依赖单一数值触发,而是组合多个弱信号形成一个强判断。每条警报本身已经附带了解释——"这个用户为什么可疑"。

示例对比

阈值式(差) 洞见式(好)
"用户 alice 过去 1h 发了 180 封邮件,超过阈值 150" "用户 alice 过去 1h 发信量(180)是其 30 天同期均值的 15 倍,且收件域从平日的 3 个陡增至 47 个,IP 从 2 个变为 1 个(首次出现的境外地址 185.x.x.x),凌晨 03:12 开始活跃。综合评分 0.91/1.00,建议立即冻结。"

实现上,每个维度独立打分(0-1),最后按权重加总,总分超过 0.70 即触发:

# 综合评分公式(Python 伪代码)
def risk_score(user, window="1h"):
    s = 0.0
    w = {
        "volume_zscore":   0.25,  # 发信量 Z-score
        "entropy":         0.20,  # 收件域熵
        "off_hours":       0.15,  # 非工作时段
        "ip_mutation":     0.15,  # IP 突变
        "send_recv_ratio": 0.15,  # 收发比偏离
        "new_contact":     0.10,  # 首次出现的收件域
    }

    metrics = compute_metrics(user, window)

    s += w["volume_zscore"]   * sigmoid(metrics["zscore"] / 3.0)
    s += w["entropy"]         * min(metrics["entropy"] / 5.0, 1.0)
    s += w["off_hours"]       * (1 if metrics["is_off_hours"] else 0)
    s += w["ip_mutation"]     * (1 if metrics["ip_not_in_history"] else 0)
    s += w["send_recv_ratio"] * min(metrics["ratio_deviation"] / 20.0, 1.0)
    s += w["new_contact"]     * min(metrics["new_domains_ratio"], 1.0)

    return s

def sigmoid(x):
    return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else 0.0

针对不同的组织规模和邮件文化,权重应可调。例如技术服务团队可能给外部客户发大量邮件,此时应提高 entropy 和 ip_mutation 的权重,降低 volume_zscore 的权重以避免误报。

六、SIEM 集成与看板建设

日志从采集到可视化的完整数据流:

Postfix ──rsyslog──▶ ElasticSearch ◀── Kibana 看板
                         │
                         ├── 每日索引: postfix-YYYY.MM.DD
                         ├── 告警索引: postfix-alerts-YYYY.MM
                         └── 滚动策略: 热数据 7d → 温 30d → 冷 90d → 删除
                                    │
                                    ▼
                              SOAR playbook (webhook)

6.1 Kibana 看板设计

建议建设以下四个核心看板:

看板名称 核心面板 刷新频率
邮件流量概览 每小时收发总量趋势、Top10 发件人、Top10 收件域、投递成功率 5min
安全检测面板 实时风险评分 Top20 用户、异常登录IP地图、突发外发事件时间线 1min
退信与声誉 退信率趋势、被拒原因分布、发件 IP 在 DNSBL 中的命中情况 15min
系统健康 队列长度、延迟 P50/P95/P99、磁盘使用率、SMTP 并发连接数 1min

6.2 ElasticSearch 索引模板

{
  "index_patterns": ["postfix-*"],
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "30s"
  },
  "mappings": {
    "properties": {
      "timestamp":           { "type": "date" },
      "hostname":            { "type": "keyword" },
      "remote_ip":           { "type": "ip" },
      "sasl_username":       { "type": "keyword" },
      "queue_id":            { "type": "keyword" },
      "message_id":          { "type": "keyword" },
      "from_address":        { "type": "keyword" },
      "to_address":          { "type": "keyword" },
      "recipient_domain":    { "type": "keyword" },
      "status":              { "type": "keyword" },
      "dsn":                 { "type": "keyword" },
      "delay":               { "type": "float" },
      "size":                { "type": "integer" },
      "nrcpt":               { "type": "integer" },
      "tls_version":         { "type": "keyword" },
      "risk_score":          { "type": "float" }
    }
  }
}

NIST SP 800-92(Guide to Computer Security Log Management)提出了日志管理的三个优先级:日志生成(确保日志格式一致且包含必要字段)、日志传输与存储(保护日志在传输中的完整性和机密性)、日志分析与处置(建立告警、关联分析和报告机制)。本架构的 rsyslog→ES→Kibana 管道与之完全对齐。日志保留策略建议参考该标准的 6.3 节,至少保留 90 天用于取证回溯。

七、文件完整性监控与主机层防护

邮件系统的安全不能只靠日志分析。攻击者一旦获取服务器权限,第一件事往往是篡改配置文件、注入后门、修改认证模块。文件完整性监控(FIM)是最后一道防线。

7.1 关键文件清单

# 以下文件/目录应被 FIM 监控(任何非预期变更应立即告警)
/etc/postfix/main.cf
/etc/postfix/master.cf
/etc/postfix/virtual
/etc/postfix/transport
/etc/postfix/sasl_passwd
/etc/postfix/aliases
/etc/dovecot/
/etc/ssl/certs/postfix/
/usr/libexec/postfix/
/etc/sysconfig/saslauthd

7.2 OSSEC 监控规则

<!-- /var/ossec/rules/postfix_fim.xml -->
<group name="syscheck,postfix">
  <rule id="100200" level="12">
    <if_sid>550</if_sid>
    <match>/etc/postfix/</match>
    <description>Postfix configuration file modified.</description>
  </rule>
  <rule id="100201" level="14">
    <if_sid>100200</if_sid>
    <match>main.cf|master.cf|sasl_passwd</match>
    <description>Critical Postfix config altered — possible compromise.</description>
  </rule>
</group>

Wazuh(OSSEC 的现代分支)支持更灵活的配置:

<!-- /var/ossec/etc/ossec.conf -->
<syscheck>
  <directories check_all="yes" realtime="yes"
    report_changes="yes">/etc/postfix</directories>
  <directories check_all="yes" realtime="yes"
    report_changes="yes">/etc/dovecot</directories>
</syscheck>

当主配置文件发生非授权变更时,Wazuh agent 会在数秒内推送告警到管理节点,经规则匹配后触发 Syslog 通知管理员。

八、安全编排自动化响应(SOAR)

检测到异常之后,延迟每多一秒,攻击者就可能多发数百封邮件。手动响应不可靠。自动化 SOAR playbook 的目标是将"检测→确认→遏制→通知"压缩到秒级。

8.1 自动化响应 Playbook

触发条件: risk_score >= 0.85 且 持续 >= 2 个评估周期

Step 1 — 确认(自动化交叉验证)
  ├── 查询该用户过去 30 天是否有类似高评分
  ├── 检查该 IP 是否在内部白名单中(CEO 办公室 VPN 出口等)
  └── 确认命中多个弱信号(不是单一抖动)

Step 2 — 遏制
  ├── 通过 SSH/API 在 Postfix 执行: postconf -e "smtpd_sender_restrictions =
  │   check_sender_access hash:/etc/postfix/blocked_senders"
  │   → 将 user@domain 写入 blocked_senders → postmap → postfix reload
  ├── 或者通过 REST API 调用邮件网关的账号冻结接口
  └── 记录到事件系统(ticket_id 关联整个响应链)

Step 3 — 通知
  ├── Syslog WARNING 级别发出结构化告警:
  │   { "action":"account_frozen", "user":"alice", "score":0.92,
  │     "reason":"burst+suspect_ip+entropy", "ticket":"INC-20260704-001" }
  ├── 邮件通知安全运维团队
  └── 若关联到 SIEM 看板,在告警仪表盘中以红色标记高亮

8.2 Postfix 快速拒绝一个发件地址

# 将异常用户加入拒绝列表
echo "user@example.com REJECT: Account suspended due to suspicious activity" \
  >> /etc/postfix/blocked_senders
postmap /etc/postfix/blocked_senders

# 确保 main.cf 中已配置
postconf -e "smtpd_sender_restrictions = check_sender_access hash:/etc/postfix/blocked_senders, permit_mynetworks, permit_sasl_authenticated"

# 重新加载配置(不中断服务)
postfix reload

8.3 Syslog 通知格式

# 由 SOAR 引擎发出的标准化告警日志
logger -p local0.warning -t SOAR-EMAIL \
  "ACCOUNT_FROZEN | user=alice@corp.com | score=0.92/1.00 | triggers=burst_anomaly,entropy_spike,off_hours,suspect_ip | action=frozen | ticket=INC-20260704-001 | src_ip=185.213.x.x | timestamp=$(date -Iseconds)"

M3AAWG Best Practices for Logging:邮件反滥用工作组(M3AAWG)在其日志最佳实践中强调,邮件服务日志应记录每条消息的"发送方身份认证路径"(SPF/DKIM/DMARC 验证结果、SASL 用户名、TLS 协商信息),以便滥用调查时能完整还原消息来源。本文的结构化日志字段定义正是基于此建议扩展而来。

九、威胁情报联动

日志分析解决的是内部视角,威胁情报补充外部视角——哪些 IP/域名/URL 已经是已知恶意实体。

9.1 自动拉取并下发到 Postfix 拒绝列表

#!/bin/bash
# fetch-badips.sh — 每小时从 OSINT 情报源拉取恶意 IP,更新 Postfix 拒绝规则
# 建议通过 cron 每小时执行一次

TMPFILE=$(mktemp)
REJECT_DB="/etc/postfix/bad_ips"

# 从多个开源威胁情报源聚合(示例)
curl -s https://feodotracker.abuse.ch/downloads/ipblocklist_recommended.txt \
  | grep -v '^#' | awk '{print $1}' > "$TMPFILE"

curl -s https://sslbl.abuse.ch/blacklist/sslipblacklist.csv \
  | grep -v '^#' | cut -d, -f2 >> "$TMPFILE"

# 去重后生成 Postfix cidr 格式
sort -u "$TMPFILE" \
  | awk '{print $1, "REJECT: Listed in threat intelligence feed"}' \
  > "$REJECT_DB"

postmap "$REJECT_DB"
rm -f "$TMPFILE"

# 验证配置并平滑重载
postfix check && postfix reload

9.2 在 main.cf 中挂载

# 放在 smtpd_client_restrictions 的首位
smtpd_client_restrictions =
    check_client_access cidr:/etc/postfix/bad_ips,
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_unauth_destination

9.3 DNSBL 查询作为补充层

smtpd_recipient_restrictions =
    reject_invalid_hostname,
    reject_non_fqdn_sender,
    reject_unknown_sender_domain,
    reject_rbl_client zen.spamhaus.org,
    reject_rbl_client bl.spamcop.net,
    reject_rhsbl_sender dbl.spamhaus.org,
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_unauth_destination

DNSBL 和本地 IP 黑名单形成双层过滤:DNSBL 负责已知大规模恶意源,本地列表负责从自有情报和分析结果中积累的针对性拒绝规则。


邮件安全态势感知不是一次性的工程交付,而是一个持续运行、持续优化的分析闭环。日志标准化是地基,行为基线是标尺,异常检测是引擎,自动化响应是把引擎的发现转化为行动的最后一步。把这几个环节跑通之后,运维团队将从"告警太多看不完"变成"每天只看真正有料的几条综合告警"——这才是态势感知的本质。

参考文献

  1. RFC 5424 — The Syslog Protocol (IETF, 2009)
  2. RFC 5425 — TLS Transport Mapping for Syslog (IETF, 2009)
  3. M3AAWG — Best Common Practices for Logging (Messaging, Malware and Mobile Anti-Abuse Working Group)
  4. NIST SP 800-92 — Guide to Computer Security Log Management (National Institute of Standards and Technology, 2006)
  5. MITRE ATT&CK — T1566 Phishing (Initial Access)