证书透明度日志监控实战:CT 证书滥用告警

证书透明度日志监控是一套针对 HTTPS 证书签发行为的第三方审计机制。只要企业域名存在公开 CA 误签、内部私签证书泄露、离职人员抢注子域证书等情况,证书透明度(Certificate Transparency,CT)日志里就会留下不可篡改的记录。本文给出一套可落地的证书透明度监控方案:从 crt.sh 拉取基线、编写差异比对脚本、配置 CAA 策略限制可签发 CA,到证书滥用后的应急处置,命令与代码均可直接复制执行。

适用场景

  • 企业域名单一入口由多个部门管理,需要发现「谁在什么时候为哪个子域申请了证书」。
  • 需要及时发现未授权 CA 签发的证书,防止钓鱼站、供应链仿冒站冒用企业品牌域名。
  • 资产台账不完整,想通过 CT 日志反查历史签发过的全部子域,补齐影子资产清单。
  • 安全运营要求把「证书异常签发」纳入日常告警,与 WAF、日志审计形成联动。

前置条件

  • 一份完整的企业主域名与关键子域清单(如 example.com、*.example.com)。
  • 一台可访问公网的 Linux 服务器(用于定时任务),安装 python3、curl、jq。
  • 一个告警通道:企业邮箱 SMTP、Webhook(企业微信/钉钉机器人)或工单系统 API。
  • 域名 DNS 可编辑权限(用于配置 CAA 记录)。
  • 可选:CT 实时流服务(CertStream)或自建 CT 镜像,用于降低对 crt.sh 的依赖。

原理说明

CT 的规范定义在 RFC 6962。其核心设计是:所有公开信任的 CA 在签发证书时,必须把证书提交到一个或多个 CT 日志服务器,日志服务器把证书追加到一个仅可追加(append-only)的 Merkle 树中,并返回一个签名确认(SCT,Signed Certificate Timestamp)。主流浏览器要求 TLS 证书至少携带两个来自合格日志的 SCT,否则会弹出不安全警告。这意味着任何面向浏览器可用的证书,其签发记录都必然公开可查。

监控的技术路径有三条:

  • crt.sh 查询:通过 https://crt.sh/?q=%25.example.com&output=json 返回该域名(含子域)的全部签发记录,字段包含颁发者、名称、有效期、序列号、条目时间戳,适合做定期全量快照与差异比对。
  • CertStream 实时流:订阅 WebSocket 事件流,近实时获取全球新签发证书,适合做「我方域名第一时间告警」,但数据量大、噪声多。
  • CAA 记录:DNS 中的 CAA 资源记录用于声明「本域名只允许哪些 CA 签发」,是事前约束,与事后监控互补。

需要说明的是,CT 监控解决的是签发环节的可见性,它无法阻止证书被签发,只能让你更快发现并处置;真正的事前限制依赖 CAA 与 CA 侧的域名验证强度。

操作步骤

步骤一:建立 CT 基线快照

# 安装依赖
sudo apt update && sudo apt install -y curl jq python3 python3-pip

# 查询 example.com 及其全部子域的签发记录(%25 即 URL 编码的 %)
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
  | jq -r '.[].name_value' | tr ',' '\n' | sort -u

# 查看包含颁发者与有效期的完整记录
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
  | jq -r '.[] | [.issuer_name, .common_name, .not_before, .not_after, .serial_number] | @tsv' \
  | sort -u | head -40

步骤二:编写监控与差异比对脚本

脚本每次运行时拉取全量记录,与上一次基线做集合差异,新增证书即触发告警。注意通配符证书的 name_value 会包含 *.example.com,需要进行规范化处理。

cat > /opt/ct-monitor/ct_monitor.py <<'PY'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""证书透明度 CT 日志监控:拉取 crt.sh 记录并与基线比对,新增即告警。"""
import json, os, sys, time, urllib.request, urllib.error, subprocess

DOMAINS = ["example.com"]                 # 待监控的主域名
BASE_DIR = "/opt/ct-monitor/state"
APPROVED_CA = ["Let's Encrypt", "DigiCert", "GlobalSign", "Sectigo"]
WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
UA = {"User-Agent": "ct-monitor/1.0"}


def fetch(domain, retry=3):
    """查询 crt.sh,带指数退避重试。"""
    url = "https://crt.sh/?q=%25.%s&output=json" % domain
    for i in range(retry):
        try:
            req = urllib.request.Request(url, headers=UA)
            with urllib.request.urlopen(req, timeout=60) as r:
                return json.loads(r.read().decode("utf-8"))
        except (urllib.error.HTTPError, urllib.error.URLError,
                json.JSONDecodeError) as e:
            print("fetch retry %d: %s" % (i + 1, e), file=sys.stderr)
            time.sleep(2 ** i * 5)
    return []


def normalize(rec):
    """把一条 CT 记录归一化为可比较的指纹。"""
    names = set()
    for n in (rec.get("name_value") or "").split("\n"):
        n = n.strip().lower().rstrip(".")
        if n:
            names.add(n)
    return {
        "id": rec.get("id"),
        "serial": (rec.get("serial_number") or "").lower(),
        "issuer": rec.get("issuer_name") or "",
        "names": sorted(names),
        "not_before": rec.get("not_before"),
        "not_after": rec.get("not_after"),
    }


def alert(text):
    payload = json.dumps({"msgtype": "text", "text": {"content": text}}).encode()
    try:
        urllib.request.urlopen(urllib.request.Request(
            WEBHOOK, data=payload,
            headers={"Content-Type": "application/json"}), timeout=20)
    except Exception as e:
        print("alert failed: %s" % e, file=sys.stderr)


def main():
    os.makedirs(BASE_DIR, exist_ok=True)
    total_new, messages = 0, []
    for domain in DOMAINS:
        raw = [normalize(r) for r in fetch(domain)]
        seen, uniq = set(), []
        for r in raw:
            key = r["serial"] or ",".join(r["names"])
            if key and key not in seen:
                seen.add(key)
                uniq.append(r)

        path = os.path.join(BASE_DIR, domain.replace(".", "_") + ".json")
        old = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else []
        old_keys = {r["serial"] or ",".join(r["names"]) for r in old}

        for r in uniq:
            key = r["serial"] or ",".join(r["names"])
            if key in old_keys:
                continue
            total_new += 1
            risk = "正常" if any(c in r["issuer"] for c in APPROVED_CA) else "异常CA"
            messages.append(
                "[%s] %s\n  名称: %s\n  颁发者: %s\n  生效: %s"
                % (risk, domain, ", ".join(r["names"]), r["issuer"], r["not_before"]))

        json.dump(uniq, open(path, "w", encoding="utf-8"),
                  ensure_ascii=False, indent=1)
        print("[%s] 当前 %d 条,新增基线已更新" % (domain, len(uniq)))

    if messages:
        alert("证书透明度监控告警:新增 %d 条签发记录\n\n%s"
              % (total_new, "\n\n".join(messages[:20])))
    else:
        print("无新增签发记录")


if __name__ == "__main__":
    main()
PY

chmod +x /opt/ct-monitor/ct_monitor.py
/opt/ct-monitor/ct_monitor.py

步骤三:配置 CAA 记录限制可签发 CA

CAA 属于事前约束,建议对所有对外域名配置,只允许白名单 CA 签发,并配置违规上报地址。

# 示例:仅允许 Let's Encrypt 签发,并声明违规上报邮箱
example.com.        IN CAA 0 issue "letsencrypt.org"
example.com.        IN CAA 0 issuewild "letsencrypt.org"
example.com.        IN CAA 0 iodef "mailto:security@example.com"

# 校验记录是否生效(等待 DNS 生效后执行)
dig +short CAA example.com
dig +short CAA www.example.com

步骤四:加入定时任务与实时流补充

# 每天 07:00 执行一次全量比对(crontab)
0 7 * * * /usr/bin/python3 /opt/ct-monitor/ct_monitor.py >> /var/log/ct-monitor.log 2>&1

# 可选:订阅 CertStream 实时流,仅匹配我方域名关键词
pip3 install certstream
python3 - <<'PY'
import certstream
KEYWORDS = ["example.com"]
def cb(message, context):
    if message.get("message_type") != "certificate_update":
        return
    d = message["data"]["leaf_cert"]
    names = set(d.get("all_domains") or [])
    if any(any(k in n for n in names) for k in KEYWORDS):
        print("实时命中:", d.get("fingerprint"), sorted(names))
certstream.listen_for_events(cb, url="wss://certstream.calidog.io/")
PY

步骤五:异常签发后的应急处置

  1. 核对新证书的域名与业务归属,确认是否为已知部门申请。
  2. 若为非授权签发:记录序列号与 SCT,向颁发 CA 提交误签发报告(Mis-issuance Report),要求吊销。
  3. 检查该域名是否被用于钓鱼或仿冒,提交至浏览器厂商与安全厂商的恶意域名黑名单。
  4. 复核 DNS 账号权限与域名验证方式,收紧 CAA 与账户多因素认证。

配置验证

  • 手动触发一次脚本:首次运行应把全部记录写为基线文件(位于 /opt/ct-monitor/state/),第二次运行输出「无新增签发记录」。
  • 模拟验证:临时在 DOMAINS 中加入一个新域名后运行,应看到新增记录与告警内容,确认告警通道可用。
  • CAA 校验:dig +short CAA example.com 返回预期记录,且证书签发仍可正常完成。
  • 日志校验:/var/log/ct-monitor.log 中有每日执行记录,无 Python 异常堆栈。

常见问题(FAQ)

Q1:crt.sh 查询经常超时或返回 502,脚本总是拉不到数据怎么办?

crt.sh 为公共服务,存在限流与不稳定情况。脚本已内置三次指数退避重试。进一步可采取:放慢轮询频率(改为每天一次而非每小时)、按单个子域拆分查询降低单次响应体、或改用 CertStream 实时流与 certspotter 等替代数据源。

# 放大超时并限制重试间隔;也可通过 -m 限制最大返回条目
curl -s --max-time 120 --retry 5 --retry-delay 10 \
  "https://crt.sh/?q=%25.example.com&output=json" -o /tmp/ct.json
jq 'length' /tmp/ct.json

Q2:泛域名证书的 name_value 含 *.example.com,导致差异比对误报怎么办?

需要做规范化:统一转小写、去掉尾部点号、拆分多行名称、对通配符保留原样参与比较,同时以 serial_number 作为主键而非域名集合,避免同一张证书因域名解析顺序不同被判定为新增。推荐以序列号为主键、域名集合为辅助字段。

# 以序列号去重,观察是否仍有重复条目
jq -r '.[].serial_number' /tmp/ct.json | sort | uniq -c | sort -rn | head

Q3:CT 监控能否阻止证书被签发?

不能。CT 只提供事后可见性。要在签发环节施加限制,必须依赖 CAA 记录(声明允许的 CA)以及 CA 侧使用的域名验证方式(如 DNS TXT 验证比 HTTP 文件验证更难被旁路)。CT 与 CAA 的正确关系是「事前约束 + 事后发现」,两者需同时配置。

总结

证书透明度日志监控的价值在于把「证书被签发」这一原本不可见的事件变成可审计、可告警的信号。落地要点有三:一是用 crt.sh 建立全量基线并做序列号级差异比对,避免通配符与重复条目造成误报;二是用 CAA 记录在事前收窄可签发 CA 范围;三是把新增签发纳入告警通道并预留误签发处置流程。对企业而言,CT 监控同时还是影子资产发现的顺手工具——历史上被签过证书的子域,往往就是台账里缺失的那些资产。

建议与证书到期监控、TLS 配置基线检查配合使用:CT 负责「谁签了证书」,到期监控负责「证书是否即将失效」,配置基线负责「加密套件与协议版本是否合规」,三者构成完整的证书生命周期治理闭环。