2026-10-21-php2shell

2026-10-21-php2shell

十月 21, 2026

php2shell:我把它从一个「未授权 RCE」跑成了一个段错误

这几天 X 和几个群里都在传 php2shell,CVE-2026-109618 / CVE-2026-109619。

说法很统一:两个请求、未授权、RCE,PHP 8.4.26 及以下全中。GitHub 上跟着冒出来好几个 PoC 仓库,每个都说自己”已验证”。

我一开始也半信。直到我把它们挨个跑了一遍。

第一步 · 先把流传的 PoC 全跑一遍

不读代码,先看行为。四个仓库 + 一条 X 上的 payload,全都丢进同一个 8.4.26 环境里跑:

流传的 php2shell PoC,逐个跑一遍

结果有点尴尬:

  • 号称拿到 shell 的那个,第一次上传就 OOM 死了,第二个请求连不上,没有 shell,也没有 flag;
  • 0x4148 那个 lab 其实很老实,README 自己写着”不崩、不执行代码”,它是个可达性检查器;
  • ikow 和 Quackster 的链是真能跑通的,但前提是应用里已经存在 unserialize() 汇聚点,也就是要先有一个反序列化漏洞——那不叫 php2shell 打穿,那叫拿反序列化漏洞打穿;
  • X 上那条 filename="|O:4:\"Evil\":0:{}" 确实能写进 session 文件,但默认的 session.serialize_handler 根本不会去实例化它。

顺带说一句,我自己最早也传过一个(allureluoli/php2shell-…-PoC),当时按计数器 UAF 的思路做的,硬编码偏移 + 让人自己填泄漏地址。现在回头看,它也是不崩的那一类。这个仓库我留在那儿当反面教材。

所以问题变成:如果这些都不是,那 php2shell 到底是个什么东西?

第二步 · 回到源码,看 8.4.27 改了什么

8.4.26 到 8.4.27 之间,PHP 官方那条 commit 的标题很朴素:

506e87fa  Avoid state corruption via bailout

“避免 bailout 造成的状态损坏”。bailout 是 PHP 里的长跳转:内存耗尽、超时、致命错误时,直接 longjmp 跳出去,不跑清理代码。这条 commit 做的事是:让解析过程中的内存分配变成可失败的(fallible),把 OOM 的处理延后,并且在 RINIT 之前把受影响的全局变量重置掉。

重点在最后半句:在 RINIT 之前。

第三步 · 崩溃点为什么在 RINIT 之前

PHP 处理请求的顺序是这样的:

php_request_startup()
  → php_hash_environment()      // 建 $_POST 这些超全局变量
    → sapi_handle_post()
      → rfc1867_post_handler()  // 解析 multipart
        → php_session_rfc1867_callback()   // 有 session.upload_progress 时才走
          → php_session_initialize()
            → php_session_decode()

注意这条链的位置:它在 php_request_startup() 里面。也就是说,框架还没启动、路由还没匹配、鉴权还没跑、中间件一个都没执行,PHP 已经在解析你的 multipart body,并且在处理 session 了。而 session.upload_progress.enabled 默认是 1。

两条请求加起来是这样:

两个请求,一个进程

第一步请求让 session 数据解码成功,但编码时超了 memory_limit。这时 PHP 走 bailout 直接退出,序列化上下文里那个指针没被清理——它活着,但指向的位置已经不会再被维护了。

第二个请求进来,只要这个进程还活着,它就会在 RINIT 之前再去碰那个上下文。然后:

第四步 · 8.4.26 上的崩溃

环境:PHP 8.4.26,--enable-debug + -fsanitize=address 从源码编的,php -S 起单进程。

8.4.26 上的 ASAN 报告

AddressSanitizer: SEGV on unknown address 0x000000000138 (READ)
    #0 tmp_var                      ext/standard/var_unserializer.c:168
    #2 ps_srlzr_decode_php          ext/session/session.c:1134
    #4 php_session_initialize       ext/session/session.c:502
    #5 php_session_rfc1867_cleanup  ext/session/session.c:3211
    #7 rfc1867_post_handler         main/rfc1867.c:1273
   #13 php_request_startup          main/main.c:1873

栈顶在 tmp_var(),就是反序列化里读临时变量的那个位置,它在读一个已经死掉的地址,0x138 是零页偏移,读一下就 SEGV。

关键是:栈里没有一行应用代码。

第五步 · 8.4.27 什么都不发生

同样的两个请求,同样的 body,同样的 memory_limit=8M、session 6MB,只换 PHP 源码:

同一个触发器,两版 PHP

注意一个容易看错的地方:修复版也会 OOM,也会抛 Allowed memory size exhausted。它和漏洞版的区别不是”会不会耗尽内存”,而是”耗尽之后进程里还剩下什么”。8.4.27 在 bailout 之前把那些全局状态重置了,所以第二个请求进来时是干净的。

这里有个细节值得记一下:我实测的六个版本号里,8.4.27 的 fatal 报在 zend_string.h:198,8.4.26 报在 zend_string.h:176——同一个函数,不同的分配路径,这就是 fallible alloc 那条改动。

第六步 · 不需要 ASAN 也能看出来

ASAN 只是为了拿到可读的栈。这个 bug 在普通发行版 PHP 上一样崩:拿系统自带的 8.4.16(无 debug、无 ASAN)跑同一套两个请求,第二个请求直接没有响应,进程 exit 139。

所以验证成本可以压得很低:两个现成的 php 可执行文件,一个 30 秒的脚本,不编译、不用 docker。

第七步 · 做成差分,别做成单边演示

这类漏洞最容易做错的地方,是只演示”漏洞版崩了”。那说明不了任何问题——内存耗尽的请求本来就可能把进程搞挂。

所以脚本是差分的:两版各跑 3 次,只有 3/3 vs 0/3 才给正面结论,否则 exit 1。

# 就地验证,不用编译(8.4.16 vs 8.4.27)
PHP_VULN=/usr/bin/php PHP_FIXED=$HOME/pb/p8427/bin/php lab/quickstart.sh

# 严谨路线:两版都按 sha256 固定源码、ASAN 从源码编,各跑 3 次
lab/bootstrap.sh
lab/run-ab.sh

而且不只在我机器上跑:

VERDICT
8.4.26 ASAN hits: 3/3
8.4.27 ASAN hits: 0/3
RESULT: reproduced (vulnerable crashes, fixed does not)

GitHub Actions 上每次 push 都会重编两版再跑一遍,日志作为 artifact 上传。这部分我特意检查过 runner 的日志——是真的下载 tarball、真的 make、真的跑出 SEGV var_unserializer.c:168 in tmp_var,不是缓存命中后直接跳过。

第八步 · 所以这不是 RCE

这一点必须说清楚,因为网上那批”两请求拿 shell”的说法,跑起来都是不成立的:

  • 我们能稳定复现的,是 RINIT 之前的状态损坏(SEGV / heap-use-after-free);
  • 从崩溃到代码执行,中间还差两步:reclaim(把释放掉的那块内存用可控内容填回去)和劫持(让程序按你的数据去跳)。公开材料里没有人做到这两步;
  • 还有一层前提:要让编码阶段 OOM,session 里得先有一个足够大的值。我的 harness 是自己写这个 session 文件的(tools/seed_session.py),因为这是 lab 侧的前提。真实攻击者能不能把大值塞进自己的 session,取决于应用怎么写——这一点我没有证明,我也不打算假装证明了。

另外两个负向对照也顺手记一下:

  • 换一个干净的 SID(session 里没有大值):两版都正常返回 200,不崩。所以不是”任何上传进度请求都会崩”;
  • 用 php-fpm:第一个请求就把 worker 干掉了,根本没有”下一个请求”去复用任何东西。这也解释了为什么这个 bug 的适用面是 prefork mod_php 和 CLI server 这类能扛过启动失败的 SAPI。

修复版本

8.4.27、8.3.36、8.2.35、8.5.12。低于这些版本的,不管你的应用写得多干净,都建议直接升——这个漏洞不需要你的代码有 bug。

最后

整个东西我做成了一个可复现的 lab,两版 PHP 都从源码编、差分脚本、6 份原始日志、逐条 PoC 实测记录都在里面:

https://github.com/allureluoli/php2shell-lab

MIT,只用 127.0.0.1,payload 是一个大小而不是一条命令。

如果你手上有真能打到代码执行的链,欢迎来 issue 里贴——那个 issue 表单我按”能验证的复现”设计的,光有截图不算。