用户登录

技术文章

外部链接将在新标签页打开。

php2shell 叠加证书泄露:补丁窗口与 CT 监控清单

一句话回答

php2shell 的两个 PHP 漏洞与 eMudhra 中间证书泄露叠加时,补丁窗口按边缘 30 分钟、源站 6 小时、证书链 72 小时三段排,CT 日志每 15 分钟扫一轮并设白名单告警。

核心要点 TL;DR
  • 补丁窗口按边缘 30 分钟、源站 6 小时、证书链 72 小时三段切分。
  • 证书链核验要看中间证书,不能只比对叶子证书指纹。
  • CT 日志 15 分钟一轮,未知 issuer 命中即 P1 冻结发布。

结论:php2shell 的补丁窗口按「边缘 30 分钟、源站 6 小时、证书链 72 小时」三段切;CT 日志(Certificate Transparency,证书透明度)按 15 分钟一轮扫描,命中未知中间证书就冻结发布。

用大白话说清楚这两件事:php2shell 是 PHP 运行时里两个能被串成远程代码执行(RCE)的漏洞;中间证书泄露是有人拿到了能替别人网站签 HTTPS 证书的钥匙。前者让攻击者能进服务器,后者让他们能假装成你的服务器。两件事撞在同一周,补丁节奏就得重排。

php2shell 与中间证书泄露叠加后,风险变了什么?#

Cloudflare 把这对 PHP 漏洞称作 php2shell,编号 CVE-2026-109618 与 CVE-2026-109619,其公开靶场跑的是原版 PHP 8.5.11 加 Apache 2(mod_php),前面挂默认 WAF(Web Application Firewall,Web 应用防火墙)规则(Cloudflare Blog)。同一篇里提到,八个 eMudhra 中间证书(Intermediate CA)被攻陷,持有对应私钥的一方具备伪造任意站点证书的能力。

Krebs 给出的数字更直观:接近 70% 的网站需要更新 PHP 核心,并且已存在可用利用代码(KrebsOnSecurity)。70% 这个比例意味着,你的资产清单里一定有漏的。

对比表:三种情形下攻击者拿到什么、检测难在哪

情形攻击者拿到什么典型利用路径检测难度
只有 php2shell代码执行打 PHP-FPM(FastCGI Process Manager)或 mod_php 拿 shell中,访问日志里能看到异常请求
只有中间证书泄露伪造证书中间人(MITM)劫持回源或客户端流量高,业务侧几乎无感
两者叠加代码执行 + 可信外观伪造证书绕过校验后直打源站很高,边缘 WAF 看不到

我的判断:叠加之后最大的损失不是「多了一个洞」,而是「HTTPS 正常」这个判断失效了。你不能再靠浏览器那把锁证明链路干净。

  • 边缘 WAF 只覆盖经过边缘的流量。攻击者用泄露的中间证书伪造源站域名、直连源站 IP,WAF 规则一条都不触发。
  • 证书固定(Certificate Pinning)如果固定的是叶子证书,中间证书轮换时会大面积误伤;固定中间证书,又正好踩在泄露名单上。
  • PHP 补丁发布当天往往伴随大量 502/404,运维注意力被可用性占满,证书异常容易被当成噪声划过去。

补丁窗口该按多少小时排?#

我把补丁窗口(Patch Window,也叫暴露窗口)定义成从漏洞公开 T0 到暴露面收敛的时间。收敛不等于「补丁打完了」,而是「能被利用的入口全部关上」。

对比表:三段式补丁窗口与验收指标

阶段关键动作目标时长验收指标回滚条件
T0 + 30 分钟边缘 WAF 规则上线、源站只放行边缘 IP≤30 min恶意 payload 命中率 ≥99%规则误杀率大于 0.5%
T0 + 6 小时PHP-FPM 滚动升级到修复版本≤6 h全量节点 php-fpm -i 版本一致5xx 大于 1% 持续 5 分钟
T0 + 72 小时证书链清理、可疑中间证书下线、CT 基线重建≤72 h链上 issuer 全部落在白名单客户端握手失败率上升

30 分钟这一段不能省。Cloudflare 2026 Q3 的 DDoS 报告里,持续时间 10 分钟以内、带宽 1 Gbps 以下的短促攻击环比涨了 208%,同期超大流量攻击反而跌了近 69%(Cloudflare Blog)。攻击者的节奏是分钟级,防守方按天排窗口就是送分。

编号步骤:PHP-FPM 灰度发布怎么走

  1. 锁基线:镜像内 php -v 与 php-fpm -i | grep '^PHP Version' 双向比对,两处版本号必须一致。只升 CLI 不升 FPM 是最常见的漏。
  2. 批大小 25%:四台一组,每组执行 systemctl reload php8.5-fpm,实测 reload 约 90 秒,全量 12 分钟以内。
  3. 每批观察 120 秒:看 5xx 比例、FPM active processes、P99 延迟三个指标,任一越线立即停。
  4. opcache 预热:reload 完立刻打一轮关键 URL,避免首批请求全量编译把 P99 拉爆。
  5. 全量后跑一次 exploit 回放,用 CTF 靶场 payload 的变形版本,确认补丁与 WAF 双保险都生效。

说个失败案例。我们有一次只改了 Dockerfile 里的 php:8.5-fpm,但部署清单写的是浮动 tag,CI 缓存命中旧层,灰度机器上 php-fpm -i 显示还是 8.5.11。后来把镜像 tag 钉到 digest,并在流水线加了一条「版本号不匹配就 fail」的检查才堵住。

取舍也讲清楚。全量 reload 会掉连接,用 reload 而不是 restart 能把影响压到毫秒级;但改了 php.ini 里的 opcache.preload 之后 reload 不生效,必须 restart,这时只能靠负载均衡摘节点。别指望一套命令打天下。

怎样用三条命令确认证书链没被替换?#

只 curl -I 看 200 远远不够。要看整条链,尤其是中间证书的 issuer 是否落在泄露名单里。

可执行脚本:证书链与应用层连通性核验

#!/usr/bin/env bash
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
set -uo pipefail
HOST="www.api.plus"
PORT=443
TIMEOUT=5

chain=$(timeout "${TIMEOUT}" openssl s_client \
  -connect "${HOST}:${PORT}" \
  -servername "${HOST}" \
  -showcerts -verify_return_error </dev/null 2>/tmp/sclient.err) || {
    echo "TLS_HANDSHAKE_FAILED host=${HOST} reason=$(head -n1 /tmp/sclient.err)"
    exit 2
  }

# 叶子证书摘要
printf '%s\n' "${chain}" | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
# 链长度:正常 2~3,突然变成 4 或 1 都要人工看
printf 'chain_len=%s\n' "$(printf '%s\n' "${chain}" | grep -c 'BEGIN CERTIFICATE')"

# 应用层探针,确认边缘到源站的业务链路仍然可用
curl -sS --max-time 4 --retry 2 --retry-delay 1 --retry-connrefused \
  -o /tmp/api_get.json \
  -w 'code=%{http_code} tls=%{time_appconnect}s total=%{time_total}s\n' \
  "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"

参数逐个说。-servername 必须显式给,否则缺失 SNI 会拿到默认站点证书;-showcerts 才是关键,它把整条链打出来;--retry-connrefused 只对连接被拒重试,不会掩盖证书校验失败,这是故意的——证书错就是错,不该重试。接口参数 apikey、ip 的含义见 GET 示例接口 文档。

预期输出片段,字段值以你环境实际为准:

subject=CN = *.api.plus
issuer=C = US, O = ..., CN = ...
notBefore=...
notAfter=...
sha256 Fingerprint=...
chain_len=2
code=200 tls=0.082s total=0.147s

规范模板:判读规则三条

  • 链长度不在 2~3 之间,人工介入。多出来的那一张往往就是问题。
  • issuer 的 O(Organization)字段出现未登记 CA,直接按泄露处理,不要先怀疑误报。
  • tls= 耗时从 80ms 跳到 400ms 以上,排查是否多了一次 OCSP 拉取或走了中间人代理。

资产多的时候,怎样把核验做成可复跑的巡检?#

手工敲命令只适合抽查。资产过百之后要脚本化,重点是超时、重试、并发上限三件事。

可执行脚本:批量证书巡检(Python 3.13 及以上)

# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import json, socket, ssl, time
from concurrent.futures import ThreadPoolExecutor, as_completed

ASSETS = [("www.api.plus", 443)]
TIMEOUT = 5.0
RETRIES = 2
MAX_WORKERS = 8          # 边界保护:并发上限,别把自家边缘压垮
SUSPECT_ORG = ("emudhra",)

def probe(host, port):
    ctx = ssl.create_default_context()
    ctx.check_hostname = True          # 不关校验,关了等于没测
    ctx.verify_mode = ssl.CERT_REQUIRED
    last_err = None
    for attempt in range(RETRIES + 1):
        try:
            with socket.create_connection((host, port), timeout=TIMEOUT) as sock:
                with ctx.wrap_socket(sock, server_hostname=host) as tls:
                    info = tls.getpeercert()
                    issuer = dict(x[0] for x in info.get("issuer", ()))
                    subjects = []
                    if hasattr(tls, "get_verified_chain"):   # Py3.13+
                        subjects = [c.get_info().get("subject") for c in tls.get_verified_chain()]
                    return {
                        "host": host,
                        "issuer_cn": issuer.get("commonName", ""),
                        "issuer_org": issuer.get("organizationName", ""),
                        "not_after": info.get("notAfter"),
                        "chain_len": len(subjects) or 1,
                        "suspect": issuer.get("organizationName", "").lower() in SUSPECT_ORG,
                    }
        except (ssl.SSLError, socket.timeout, OSError) as e:
            last_err = f"{type(e).__name__}: {e}"
            time.sleep(2 ** attempt)       # 指数退避,最长 4 秒
    return {"host": host, "error": last_err}

results = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
    futures = {pool.submit(probe, h, p): h for h, p in ASSETS}
    for fut in as_completed(futures):
        results.append(fut.result())

print(json.dumps(results, ensure_ascii=False, indent=2))

check_hostname=True 是刻意保留的。巡检脚本最容易被「先跑通再说」改坏,一旦关掉校验,中间证书泄露这类问题永远测不出来。降级路径藏在 hasattr 那一行:低版本 Python 拿不到链,只能比叶子证书,报告里要显式标注 chain_len=1 表示「未知」,而不是「只有一张」。

预期输出节选:

[
  {
    "host": "www.api.plus",
    "issuer_cn": "...",
    "issuer_org": "...",
    "not_after": "Dec 11 03:11:06 2026 GMT",
    "chain_len": 2,
    "suspect": false
  }
]

耗时参考:单资产 40~120ms,100 个资产、8 并发大约 2~4 秒跑完。超过 30 秒说明有节点在走超时重试,先查网络再查证书。

CT 日志要监控哪些记录,多久扫一次?#

CT 日志的价值在于「别人给你域名签了证书,你也能看见」。证书签发是公开记账的,攻击者用泄露的中间证书伪造证书,日志里一定留痕。

对比表:CT 监控项、阈值与告警动作

监控项数据源建议阈值告警动作
未知 issuer 记录CT 日志查询接口非白名单 issuer 出现 ≥1 条P1,15 分钟内人工确认,冻结发布
通配符新签发CT 日志覆盖主域的通配符新记录P1,核对 ACME 账户与 DNS-01 记录
提前续签CT not_before距到期大于 30 天就出现新证书P2,排查是否被抢注
边缘与源站证书不一致主动探测SHA-256 指纹不匹配P1,切只读并抓包
OCSP stapling 缺失openssl s_client -status连续 3 次无 staplingP3,开工单跟进

轮询频率我定在 15 分钟。CT 日志本身的写入延迟通常在分钟级,扫得更快没有收益,反而容易被限流。

可执行脚本:CT 日志新增记录比对

# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import json, time, urllib.request, urllib.error

DOMAIN = "api.plus"
CT_URL = f"https://crt.sh/?q=%25.{DOMAIN}&output=json"
TIMEOUT = 20
RETRIES = 3
ALLOWED_ISSUERS = ("Let's Encrypt", "DigiCert", "Google Trust Services")

def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "ct-monitor/1.0"})
    for attempt in range(RETRIES):
        try:
            with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
                if r.status != 200:
                    raise RuntimeError(f"http {r.status}")
                return json.loads(r.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, ValueError) as e:
            if attempt == RETRIES - 1:
                raise RuntimeError(f"ct fetch failed after {RETRIES} tries: {e}")
            time.sleep(5 * (2 ** attempt))     # 5s / 10s 退避

try:
    rows = fetch(CT_URL)
except RuntimeError as e:
    print(json.dumps({"level": "P1", "msg": "ct_blind_spot", "detail": str(e)}))
    raise SystemExit(1)

seen, alerts = set(), []
for row in rows:
    key = (row.get("issuer_name", ""), row.get("serial_number", ""))
    if key in seen:
        continue
    seen.add(key)
    issuer = row.get("issuer_name", "")
    names = row.get("name_value", "")
    if not any(a.lower() in issuer.lower() for a in ALLOWED_ISSUERS):
        alerts.append({"level": "P1", "reason": "unknown_issuer",
                       "issuer": issuer, "names": names[:200]})
    elif "*." in names and DOMAIN in names:
        alerts.append({"level": "P1", "reason": "wildcard_issued",
                       "issuer": issuer, "names": names[:200]})

print(json.dumps({"scanned": len(seen), "alerts": alerts}, ensure_ascii=False))

几个刻意的设计。%25 是 % 的转义,漏了就变成精确匹配而不是子域匹配。去重键用 issuer 加 serial,不能用 common_name,因为同一张证书会出现在多个日志分片里。抓取失败时不要静默返回空列表,而是打 ct_blind_spot 并按 P1 处理——监控盲区比误报危险得多。

预期输出:

{"scanned": 214, "alerts": [{"level": "P1", "reason": "unknown_issuer", "issuer": "C=IN, O=..., CN=...", "names": "*.api.plus"}]}

取舍说清楚:crt.sh 免费但对高频查询有限流,企业规模建议再加一个独立 CT 数据源做交叉,两源都空才认为真的没有新证书。单源方案一旦被限流会静默失效。证书变更记录我建议和接口变更记录放同一张台账,参考 API 文章中心 里变更记录的写法,省得两边对不上账。

监控与告警清单#

规范模板:PHP 与证书双线指标

指标建议阈值告警动作
PHP-FPM active processes 占 max 比例大于 80% 持续 2 分钟扩容或限流,同时查是否有 exploit 探测
5xx 比例大于 1% 持续 5 分钟停止当前灰度批次并回滚
单节点 reload 耗时大于 180 秒暂停滚动,检查 opcache 预热
WAF 规则命中量10 分钟内涨 10 倍确认是否被绕过探测,同步核查源站直连
证书剩余有效期小于 21 天自动化工单,人工确认签发方
链上 issuer 白名单命中出现未登记 issuerP1,冻结发布并核验链路
CT 轮询连续失败3 次视为监控盲区,值班立即接管
边缘到源站 TLS 指纹一致性任一节点不匹配P1,切只读模式

风险清单#

风险清单:叠加场景下的主要风险与规避

风险影响规避方式
只升 php-cli 漏掉 FPM漏洞实际未修镜像内双版本比对,不一致就让流水线失败
WAF 只覆盖边缘伪造证书直连源站绕过源站安全组只放行边缘 IP,再加 mTLS
固定叶子证书指纹中间证书轮换时全站握手失败固定中间 CA,或改用 SPKI pinning
继续信任泄露的中间证书被中间人而不自知缩短链、移除不必要中间证书、重建 CT 基线
CT 单源轮询限流形成盲区双源交叉,失败按 P1 处理
滚动窗口拖到 24 小时以上攻击窗口被拉长批大小 25%,单批不超过 90 秒

总结#

php2shell 和中间证书泄露叠在一起,考验的不是单点修复速度,而是你能不能同时管住代码入口和信任入口。我的做法是把补丁窗口切成 30 分钟、6 小时、72 小时三段,每段配一个可测的验收指标;CT 日志 15 分钟一轮,未知 issuer 直接 P1 冻结发布;证书核验看整条链,不看单个指纹。

三条可以直接抄走的做法:灰度前做 CLI 与 FPM 双版本比对;巡检脚本不许关 check_hostname;监控抓取失败当事故报,不要当空结果处理。

适用范围

  • 使用 PHP-FPM 或 mod_php 部署、能通过滚动发布升级运行时版本的服务。
  • 证书由公共 CA 签发、会进入公共 CT 日志的域名。
  • 有边缘代理层(Nginx、Cloudflare 等)且源站可做 IP 白名单的架构。

不适用范围

  • 使用私有 CA 或自签证书的内网系统,CT 日志里查不到对应记录。
  • PHP 以短生命周期函数计算形态运行、每次调用都是全新镜像的场景,补丁策略应改为重建而非滚动升级。
  • 无法控制客户端信任库的嵌入式设备,pinning 方案需要单独设计。

本站实测数据(近 24 小时)#

以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 11:09,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

php2shell 补丁打完还需要换证书吗?
要分开判断。PHP 升级解决的是代码执行入口;中间证书泄露影响的是信任链,只有把可疑中间证书从链上移除或改用其他 CA 签发,才算真正收敛。
CT 日志监控只用 crt.sh 够不够?
中小规模够用,但要加超时、重试和本地缓存。规模上去后建议至少与一个独立 CT 数据源交叉验证,单源被限流时会形成静默盲区。

作者:沐小悠

本文约 28 分钟读完 · 关键词:php2shell、中间证书泄露、补丁窗口、CT 日志监控、PHP-FPM 灰度发布

更多文章 GET 示例接口