PHP2Shell 和 eMudhra 证书漏洞:怎么回事,怎么修
摘要
2026 年 10 月的两个安全事件:PHP 解释器的 PHP2Shell 远程代码执行漏洞,和证书机构 eMudhra 的中间 CA 私钥泄露。包括发现过程、影响和处理步骤。
2026 年 10 月,PHP 和 HTTPS 证书各出了一件事。
PHP 的事影响网站服务器,攻击者可能不用登录就执行命令。证书的事影响 HTTPS 的信任,攻击者可能伪造网站证书。
1. PHP2Shell 是什么
PHP 是跑网站程序的语言。WordPress、Drupal、Laravel 这些系统都靠它处理请求。
PHP2Shell 是两个漏洞的合称:
- CVE-2026-109618:PHP 在内存耗尽时没有把内部数据处理完整,可能把无效数据当成有效数据。
- CVE-2026-109619:一个请求结束后,PHP 没有完全清掉内部状态,下一个请求可能用到上一个请求留下的内存。
这两个问题都在 PHP 解释器本身,不在某个插件或框架里。攻击者不需要先登录,也不需要网站代码有漏洞。只要请求能进到 PHP,就可能触发。
官方给这两个漏洞的评分是 CVSS 9.2,属于严重级别。
2. PHP2Shell 是谁发现的
不是一个人发现的。
PHP 官方在 2026 年 10 月 21 日的说明里写了过程:2024 年 PHP 做外部安全审计时,核心开发者已经注意到内存耗尽相关的异常;2026 年 8 月 11 日,安全公司 Calif 的研究员 Ryan A 报告了一条可以利用的路径,OpenAI 在同一天独立报告了同一个根因,之后 Wiz 又报告了更多攻击路径。
所以准确地说,是 PHP 官方、Calif、OpenAI、Wiz 等几方先后发现并确认了这个问题。不是先有大规模攻击才被知道。
PHP 官方的安全公告 记录了修复版本,发现和修复过程写在 PHP Foundation 的说明 里。
3. PHP2Shell 会造成什么
最严重的结果是服务器被接管。
攻击者能执行命令后,网站配置、数据库密码都可能被读走;网站文件可能被改出后门;这台机器还可能被拿去攻击别人。用户数据也在风险里。
这不是“页面被改”那么简单。PHP 进程通常有权限读网站目录,有些部署还直接跑在 root 或高权限用户下,一旦被利用,影响会扩大到整台机器。
4. PHP2Shell 怎么修
4.1 先查实际版本
不要只看面板显示。先登录服务器,查正在跑的 PHP:
php -v
php-fpm -v
如果网站跑在 Docker 里:
docker ps --format '{{.Names}}\t{{.Image}}'
docker exec <PHP 容器名> php -v
docker exec <PHP 容器名> php-fpm -v
如果用的是 1Panel、宝塔这类面板,面板里显示的版本和容器里实际跑的版本可能不是一回事,以容器里的输出为准。
看到 8.3.35 这个版本号,判断就简单了:它比修复线低一个修订号,不用再猜有没有受影响。
4.2 对照修复版本
PHP 官方给出的修复版本:
- PHP 8.5.12
- PHP 8.4.27
- PHP 8.3.36
- PHP 8.2.35
低于这些版本的,都在受影响范围。PHP 8.1 及更早版本已经停止维护,官方不再提供修复,只能升级到受支持的分支。
4.3 升级
用系统包管理器的:
Debian/Ubuntu:
sudo apt update
apt-cache policy php8.3-fpm
sudo apt install --only-upgrade php8.3-fpm
RHEL/TencentOS 这类:
sudo dnf check-update php-fpm
sudo dnf upgrade php-fpm
装完先看包版本,确认到了修复版本。发行版仓库有时会比官方慢,慢的话看下一节。
用 Docker 的:
把镜像 tag 改成修复版本,例如:
services:
php:
image: php:8.3.36-fpm
然后:
docker compose pull php
docker compose up -d php
不要只执行 docker restart。重启只是把旧镜像里的进程重新拉起来,PHP 版本没变。
用 1Panel 这类面板的:
在面板的 PHP 运行环境里升级到 8.3.36。如果面板商店还没推新版本,先做 4.5 的临时缓解,等镜像更新;不要直接把 8.3.35 的容器重启了事。
4.4 确认进程真的换了版本
升级完要查正在提供服务的进程,不是只查命令行:
php-fpm -v
systemctl status php8.3-fpm
Docker:
docker exec <PHP 容器名> php-fpm -v
docker inspect <PHP 容器名> --format '{{.Config.Image}}'
再访问一次网站:
curl -I https://你的站点
返回 200 或 301 都算正常,500 说明扩展或配置出了问题,看 PHP-FPM 日志。
4.5 升不上去时,先做临时缓解
临时缓解不能代替升级,只能在补丁到位前降低风险。
在 PHP 的配置目录新建一个文件。系统安装一般在 /etc/php/8.3/fpm/conf.d/,1Panel 一般在 /opt/1panel/runtime/php/php83/conf/conf.d/:
cat > /etc/php/8.3/fpm/conf.d/99-php2shell-mitigation.ini <<'EOF'
memory_limit = -1
post_max_size = 8M
upload_max_filesize = 8M
session.upload_progress.enabled = 0
EOF
四个设置的作用:
memory_limit = -1:去掉单请求内存上限,避开内存耗尽那条利用路径。post_max_size = 8M:限制 POST 请求体大小。upload_max_filesize = 8M:限制单个上传文件大小。session.upload_progress.enabled = 0:关掉上传进度回调。
重启 PHP-FPM:
sudo systemctl restart php8.3-fpm
Docker:
docker restart <PHP 容器名>
这些设置会改变网站行为。上传大文件的站点可能开始失败,所以补丁装好后要删掉这个文件,再重启一次。
4.6 官方镜像还没发布时
有些时候包管理器和 Docker 都还没有修复版本。2026 年 10 月 21 日这天,php:8.3.36-fpm 和 1panel/php:8.3.36-fpm 都还不存在,php:8.4-fpm 也还是 8.4.26。
如果业务不能等,只能用旧镜像加源码编译一个新镜像。先看原镜像的编译参数:
docker exec <PHP 容器名> php -i \
| sed -n 's/^Configure Command => //p' > configure-args.txt
把文件开头的 './configure' 删掉,只留下后面的参数。然后下载源码:
curl -fsSLO https://www.php.net/distributions/php-8.3.36.tar.xz
sha256sum php-8.3.36.tar.xz
Dockerfile 的关键部分:
FROM <原来的 PHP 镜像>
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential autoconf bison re2c pkg-config xz-utils \
libxml2-dev libsqlite3-dev libssl-dev libcurl4-openssl-dev \
libonig-dev libsodium-dev libargon2-dev libreadline-dev \
zlib1g-dev libicu-dev \
&& rm -rf /var/lib/apt/lists/*
COPY php-8.3.36.tar.xz configure-args.txt /tmp/
RUN cd /tmp \
&& tar -xJf php-8.3.36.tar.xz \
&& cd php-8.3.36 \
&& ./configure $(cat /tmp/configure-args.txt) \
&& make -j"$(nproc)" \
&& make install
构建并起临时容器验证:
docker build -t php-8.3.36-local .
docker run --rm --entrypoint php php-8.3.36-local -v
docker run --rm --entrypoint php php-8.3.36-local -m
原镜像的扩展如果是挂载进去的,临时容器也要按原样挂载,不然 -m 只看到编译进镜像的那部分。确认版本和扩展都对了,再换生产容器。
确认版本是 8.3.36,pdo_mysql、intl、bcmath 这些扩展都在。再用原来的配置和挂载起临时容器,确认站点能跑,最后按 4.3 的方式把容器镜像换成 php-8.3.36-local。
这一步比升级包麻烦,也更容易漏扩展。能等官方镜像就等,不能等再做。
5. eMudhra 证书是什么
浏览器地址栏的小锁,靠的是 HTTPS 证书。
证书由证书颁发机构签发。浏览器和操作系统内置了一批信任的根证书,根证书再授权中间证书,中间证书给网站签证书。
可以把它想成盖章:你信任总章,总章授权分章,分章给文件盖章。看到分章的印,就认这份文件。
eMudhra 是印度的一家证书机构。这次出问题的是它的 8 张中间证书,不是最顶上的根证书。
6. eMudhra 是谁发现的
安全研究员 Wayne 把问题报告给了 Mozilla 和 Chrome 的根证书项目。
Mozilla 当天独立复现,确认这些中间证书的私钥生成过程有缺陷,可以很快恢复出来。Google、Mozilla、苹果、微软随后封禁了相关证书,Cloudflare 也做了处理。CSO Online 的报道 里记录了 Mozilla 的报告和恢复过程。
eMudhra 自己的说法是,这些中间 CA 没有签发过终端证书,根证书和其他中间 CA 没有受影响。但私钥泄露意味着别人可以自己签证书,不需要找 eMudhra 申请。
7. eMudhra 会造成什么
风险是伪造 HTTPS 证书。
攻击者可以给假网站签一张看起来合法的证书,或者做中间人攻击。如果用户的设备还信任 eMudhra 的根证书,浏览器可能不报警。
这和 PHP 那个漏洞不一样:它不是直接接管服务器,而是破坏“这个网站确实是它”的验证。对服务器来说,出站访问 HTTPS 时也可能被伪造证书骗过。
浏览器和手机一般会自动封禁,服务器不会。服务器的系统 CA、Java、Python、容器都要单独检查。
8. eMudhra 怎么修
要清四层:系统 CA、Java truststore、Python certifi、容器内 CA bundle。
8.1 先找系统 CA 里的 eMudhra
awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' \
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
| openssl crl2pkcs7 -nocrl -certfile /dev/stdin \
| openssl pkcs7 -print_certs -noout \
| grep -iE 'emudhra|emsign'
有输出就说明系统信任库还信 eMudhra。不同发行版路径不同,Debian/Ubuntu 常见的是 /etc/ssl/certs/ca-certificates.crt。
8.2 从系统 CA 里排除
RHEL/TencentOS 这类用 update-ca-trust 的系统:
csplit -z -q -f /tmp/ca- -b '%03d.pem' \
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem '/BEGIN CERTIFICATE/' '{*}'
mkdir -p /etc/pki/ca-trust/source/blocklist
for f in /tmp/ca-*.pem; do
openssl x509 -in "$f" -noout -subject | grep -qiE 'emudhra|emsign' \
&& cp "$f" /etc/pki/ca-trust/source/blocklist/
done
update-ca-trust extract
Debian/Ubuntu 没有 blocklist 目录,可以从 bundle 里删掉对应证书后重新生成,或者把过滤后的 bundle 指给应用。不同发行版机制不一样,先确认自己的系统用哪套。
验证:
awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' \
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
| openssl crl2pkcs7 -nocrl -certfile /dev/stdin \
| openssl pkcs7 -print_certs -noout \
| grep -icE 'emudhra|emsign'
输出 0 才算清干净。
8.3 清 Java 的 truststore
Java 应用不一定用系统 CA。Halo、Keycloak 这类 Java 服务用的是 JDK 自带的 cacerts。
先找路径:
docker exec <Java 容器名> sh -c 'echo $JAVA_HOME'
docker exec <Java 容器名> sh -c '"$JAVA_HOME/bin/keytool" -list -cacerts -storepass changeit' \
| grep -iE 'emudhra|emsign'
有输出就删。别名可能是 emsign...,也可能是很长的 CN 串,按输出里的别名填:
docker exec <Java 容器名> sh -c \
'"$JAVA_HOME/bin/keytool" -delete -cacerts -storepass changeit -alias "<别名>"'
删完重启容器:
docker restart <Java 容器名>
注意:直接在容器里删,容器重建后会回到镜像里的版本。要长期保持,把 cacerts 拷出来处理,再用 compose 挂载:
services:
halo:
volumes:
- ./trust/halo-cacerts:/opt/java/openjdk/lib/security/cacerts:ro
路径按 $JAVA_HOME 的实际值改。
8.4 清 Python 的 certifi
先确认 certifi 文件在哪:
python3 -c 'import certifi; print(certifi.where())'
服务器上可能有多个 venv,每个都带一份 cacert.pem,都要查:
find /opt /srv -path '*/certifi/cacert.pem' 2>/dev/null
替换前先备份。把上一步查到的路径填进来:
CERTIFI=<certifi 的 cacert.pem 路径>
cp "$CERTIFI" "$CERTIFI.bak-emudhra"
过滤脚本:
python3 - "$CERTIFI.bak-emudhra" "$CERTIFI" <<'EOF'
import re, subprocess, sys, pathlib
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
blocks = re.findall(
r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
src.read_text(), re.S)
kept = []
for b in blocks:
subject = subprocess.run(
['openssl', 'x509', '-noout', '-subject'],
input=b.encode(), capture_output=True).stdout.decode().lower()
if 'emudhra' in subject or 'emsign' in subject:
continue
kept.append(b)
dst.write_text('\n'.join(kept) + '\n')
print(f'{len(blocks)} -> {len(kept)}')
EOF
直接替换会立刻生效,但重装依赖、重建 venv 会把文件覆盖回去。更稳的方法是把过滤后的文件挂进容器,或者用 REQUESTS_CA_BUNDLE、SSL_CERT_FILE 指过去。
8.5 查容器内 CA bundle
容器有自己的 CA 文件,系统更新不会自动同步:
docker exec <容器名> sh -c \
'grep -ciE "emudhra|emsign" /etc/ssl/certs/ca-certificates.crt'
返回大于 0 就要换。把过滤后的 bundle 拷进容器,或者用 compose 挂载。直接在容器里改,容器重建后会丢。
9. 修完怎么验证
PHP:
php -v
php-fpm -v
curl -I https://你的站点
Docker:
docker exec <PHP 容器名> php -v
docker exec <PHP 容器名> php-fpm -v
证书:
trust list | grep -iE 'emudhra|emsign'
docker exec <Java 容器名> sh -c \
'"$JAVA_HOME/bin/keytool" -list -cacerts -storepass changeit' \
| grep -icE 'emudhra|emsign'
PHP 版本到修复线,证书查询输出 0,站点返回 200,才算处理完。
10. 普通用户要做什么
- 浏览器和手机系统保持更新
- 看到证书警告不要点“继续访问”
- 不要在警告页面输入账号、密码、验证码
11. 容易踩的坑
Q1:升级完 php -v 是新的,网站还是旧版本?
php -v 查的是命令行,不一定是正在处理网站请求的 PHP-FPM。再查:
php-fpm -v
docker exec <PHP 容器名> php-fpm -v
Docker 里还要看容器用的镜像 tag。只 docker restart 不会换镜像,进程起来还是旧版本。
Q2:为什么不能只重启容器?
重启只是把容器里的旧进程重新拉起来,镜像里的 PHP 还是旧版本。要换镜像,再重建容器。
Q3:为什么 php:8.3.36-fpm 拉不到?
先查 Docker Hub 的 tag 是否存在:
curl -s -o /dev/null -w '%{http_code}\n' \
https://hub.docker.com/v2/repositories/library/php/tags/8.3.36-fpm/
返回 404 就是 tag 不存在,不是网络问题。换镜像源也可能一样。
php:8.4-fpm 如果还是 8.4.26,别用它顶替 8.4.27。版本号要看修复线,不是看大版本。
Q4:临时缓解能长期用吗?
不能。memory_limit=-1、post_max_size=8M、关掉 session.upload_progress 都会改变网站行为,上传大文件可能直接失败。补丁装好后要删掉 mitigation 文件,再重启 PHP-FPM。
Q5:系统 CA 清完了,为什么 Java 和容器里还能查到?
系统 CA 和 Java truststore 不是同一份文件。Halo、Keycloak 这类 Java 应用用 JDK 自带的 cacerts;容器里还有自己的 CA bundle。系统更新不会自动同步到这些地方,要一层一层查。
Q6:容器重建后 eMudhra 又回来了?
如果只是 docker exec 进去删,改的是容器临时层,重建后回到镜像里的版本。把 cacerts 或 CA bundle 拷到宿主机处理,再用 compose 只读挂载,或者重新构建镜像。
Q7:certifi 替换完,重装依赖又出现了?
直接替换 venv 里的 cacert.pem 会被 pip install、重建 venv 覆盖。要么把过滤后的文件挂进容器,要么用 REQUESTS_CA_BUNDLE、SSL_CERT_FILE 指向它,并在重装依赖后复查。
Q8:怎么确认不是只改了命令行?
查三处:php-fpm -v 的版本、容器实际使用的镜像 tag、网站返回状态。三个都对上,才算处理完。