php2shell 叠加证书泄露:补丁窗口与 CT 监控清单
php2shell 的两个 PHP 漏洞与 eMudhra 中间证书泄露叠加时,补丁窗口按边缘 30 分钟、源站 6 小时、证书链 72 小时三段排,CT 日志每 15 分钟扫一轮并设白名单告警。
- 补丁窗口按边缘 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 灰度发布怎么走
- 锁基线:镜像内
php -v与php-fpm -i | grep '^PHP Version'双向比对,两处版本号必须一致。只升 CLI 不升 FPM 是最常见的漏。 - 批大小 25%:四台一组,每组执行
systemctl reload php8.5-fpm,实测 reload 约 90 秒,全量 12 分钟以内。 - 每批观察 120 秒:看 5xx 比例、FPM active processes、P99 延迟三个指标,任一越线立即停。
- opcache 预热:reload 完立刻打一轮关键 URL,避免首批请求全量编译把 P99 拉爆。
- 全量后跑一次 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 次无 stapling | P3,开工单跟进 |
轮询频率我定在 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 白名单命中 | 出现未登记 issuer | P1,冻结发布并核验链路 |
| 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,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:142 次;成功返回 139 次,鉴权/参数校验拦截 3 次
- 最活跃接口:随机动漫壁纸(95 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:900 次