2000美元悬赏挑战背后:PHP2Shell实战PoC全解

导语:10月21日PHP官方发布补丁修复了两个9.2分的高危漏洞(CVE-2026-109618 / CVE-2026-109619),业界俗称PHP2Shell。此前数周,所有公开的利用尝试都止步于”崩溃”,无法证明可被真正利用。10月25日,安全研究员 KillaBoi 用一个单文件Python脚本把目标服务器里一把标价2000美元的比特币私钥掏了出来,并在次日公开了完整PoC——这是第一次有人把PHP2Shell的完整利用链跑通并可复现。


项目背景:一个悬赏2000美元的”公开考场”

事件的起点不是漏洞,而是一位安全从业者的好奇心。

挑战主办方 James Pavur(网名 cloudlandsdev)在一台运行未打补丁PHP的服务器上放了一把真实比特币私钥,并在自家站点公布对应地址——然后发帖悬赏 anyone who can break in。他自己写道:

“做这个是因为我想知道到底有没有人真的搞明白怎么用这个 PHP RCE,我们这几天一直在慌。祝你好运!”

奖池从约 502 美元起,通过社区捐赠涨到 0.02511679 BTC,按当时价格约 2021 美元。挑战最终在开启约 19 小时后被 KillaBoi 拿下。更早的报道称,服务器在真正变为可利用状态约两小时后即被攻破。服务器因大量尝试者访问而负载吃紧,存储限制导致部分日志未能保留。

这个挑战的设计意图非常明确:PHP2Shell 此前最大的问题不是”存在漏洞”,而是”没人证明能真的利用”。Cloudflare、watchTowr、Patchstack 等厂商此前记录到的全是失败尝试;Amazon 威胁情报团队观察到扫描活动甚至早于补丁发布四天。当一个 RCE 被反复报告为”存在但不可用”时,社区最容易形成的错误判断是”不急着修”。这次公开悬赏,本质是一次强制性的、可验证的能力校准。

Wordfence 创始人 Mark Maunder 对 KillaBoi 的技术写作出租车级别的评价:

“这是我能看到的、关于 PHP2Shell 到底有多难利用的最好的解释。”

更值得注意的是赛场之外:Perplexity 的检测与响应负责人 Adel Karimi 表示,其蜜罐捕获了针对一台运行 PHP 8.4.26 的 Apache 服务器的确认远程代码执行,并称”这已经是确认的、带有可用内存泄露的 RCE,而不只是崩溃加可疑进程”。Expel 威胁研究员 Marcus Hutchins 称其为”迄今我在野利用 php2shell 质量最高的确认”。这两个结果,共同推翻了”这漏洞太别扭没人会用”的最后一道心理防线。

漏洞本体

PHP2Shell 由两个漏洞组成,两者本质上都是 use-after-free:

  • CVE-2026-109618 —— 上传进度反序列化 UAF。PHP 的 php_session_rfc1867_callback 把两个裸 zval * 桶指针(post_bytes_processed、current_file_bytes_processed)直接存进存活的 HashTable;一次进度更新调用 zend_hash_update() 时,zval_ptr_dtor 把旧数据树释放掉,悬垂指针随后被重定向到伪造的 zend_fcall_info / zend_internal_function 结构上,最终走到 zend_call_function()。这个漏洞单独就能泄露内存——上传进度处理器会把已释放的 worker 内存塞进 PHPSESSID 响应 cookie 返回给客户端,而这段内存里可能含其他请求留下的密码、session token、API key 甚至 TLS 私钥。该处理器运行在应用代码之前,所以业务代码再干净也拦不住。
  • CVE-2026-109619 —— fatal-error 回溯 UAF 与 OPcache 类型混淆。

共同的触发前提是请求中途 fatal error(典型是 OOM 撞上 memory_limit)打断了 PHP 的原生代码、没走到正常清理路径,随后同一个 worker 被复用时把已释放内存当成活数据用。

两者组合可在无需任何账户的前提下远程执行代码。PHP 团队于 10月21日发布修复版本:8.5.12、8.4.27、8.3.36、8.2.35,CVSS v4.0 评分均为 9.2。受影响范围覆盖 5.4 之后的每一个未打补丁的 PHP,因此 Magento、Shopware、WooCommerce 这类自身代码毫无问题的站点同样中招。

值得记住的是几个时间点上的”次生灾害”:荷兰、澳大利亚、日本、芬兰等国的政府机构在补丁准备期间主动下线了关键站点;Shodan 数据显示约 150 万台暴露 PHP 版本的公网主机中,约 88.4 万台(59%)运行的是不再获得官方修复的版本;PHP 8.2 的安全支持将于 2026年12月31日结束,而停留在 8.1 及更早的服务器早已越过生命周期。此外,PHP 团队随后又发布了第三个 RCE 漏洞 CVE-2026-115560(暂无补丁),临时缓解手段仍然是关闭 session.upload_progress——这正是 PHP2Shell 攻击链的关键前置条件。


工具本身:stealmybitcoin_poc.py

KillaBoi 在 10月26日将 PoC 开源到 GitHub(仓库创建于 10月25日,短时间内收获 800+ star、120+ fork)。仓库只有两个文件:README.md 和 stealmybitcoin_poc.py(约 32 KB / 763 行)。

它和 PHP2Shell 是什么关系

一句话:PHP2Shell 是漏洞,StealMyBitcoin-PoC 是这个漏洞的第一个”打通了全链路”的公开利用实现,而 “Steal My Bitcoin” 是验证它的那张公开靶场。

三者关系如下:

CVE-2026-109618 + CVE-2026-109619   ← 漏洞本体(PHP 引擎 UAF)
            │
            ├── Calif 的 php2shell 工具(更早,通用发行版适配)
            │
            └── StealMyBitcoin-PoC(KillaBoi,针对单一构建的极简实现)
                        │
                        ▼
        Steal My Bitcoin 悬赏靶场(未打补丁 PHP + 真实 BTC 私钥)
                        │
                        ▼
              2026-10-25 19 小时内被攻破

技术路径完全一致:都走 session.upload_progress → 触发 OOM/fatal → worker 复用导致已释放内存回读 → 从 PHPSESSID cookie 里读出堆地址与代码地址 → 伪造 HashTable 把清理路径的 pDestructor 指向 zif_system(或 zend_call_function)→ system() 执行命令。

差别在工程取舍上:Calif 的工具用 --syms 传符号偏移来适配 Debian/Ubuntu 等不同发行版的二进制布局,覆盖面更广;KillaBoi 的版本把偏移硬编码,只对 Alpine PHP 8.4.26 有效,换镜像就跑不动——但换来的是单文件、零依赖、20 个请求、无循环无重试的极简实现,以及一个能一条命令起起来的 Docker 靶场。

换句话说:它不是”又一个 PHP2Shell 扫描器”,而是 PHP2Shell 从”理论可利用”变成”已验证可利用”的那件证物。 此前业界记录到的全是失败尝试,正因如此,官方补丁发布时普遍存在”先等等看”的心态;这次挑战把利用链完整跑通并开源复现,才让这个判断彻底站不住。

设计目标与形态

项目说明
依赖仅 Python 3.11+ 标准库,无需 pip 安装任何东西
请求总数最多 20 个 HTTP 请求,无循环、无重试
单请求体每个 multipart body 均小于 1 MiB
目标单次运行 = 1 次健康检查 + 最多 2 批泄露 + 1 次最终回调
安全护栏仅允许 stealmybitcoin.com 或 localhost/127.0.0.1,其他目标在发出任何数据包前即中止
证据留存每次运行自动落盘 poc-run-<UTC时间戳>/,包含原始 HTTP 字节、cookie、JSON 摘要

护栏设计是这份 PoC 值得点赞的地方。parse_target() 强制校验 scheme、主机白名单、路径与端口——挑战目标必须是 https://stealmybitcoin.com/php-version.php 的 443 端口,本地靶场只允许 loopback,且 URL 中不得携带凭据、query 或 fragment。作者在注释里写得很直白:”这是挡在一次手误和一次未授权内存耗尽请求之间的唯一东西,所以刻意写得很严格。”

四阶段攻击链

整个利用链被拆成四个阶段,代码结构也严格按攻击顺序排布:

leak_producer()  ->  heap_consumer()  ->  code_consumer()
                 ->  run_leak_stage()
                 ->  fake_hashtable()  ->  final_multipart()  ->  final_request()

阶段 0:健康检查。 一次普通 GET,必须返回 HTTP 200、X-Powered-By: PHP/8.4.26 且响应体以 PHP 8.4.26n 开头。不通过就不发任何内存耗尽载荷。

阶段 1:堆地址泄露。 一个 249,042 字节的 multipart “producer” 携带 PHPSESSID 与 PHP_SESSION_UPLOAD_PROGRESS 字段,内含数百个名为 f[0][x][x]... 的深层嵌套空文件控件,加上 1001 个不同的空 cookie 名。线上传输只有约 250 KiB,但 PHP 解析时会生成海量嵌套数组与分配,触发 128 MiB 内存上限 fatal。随后 8 个 pipeline 的 “consumer” 请求复用被”毒化”的 worker,从返回的 256 字节 PHPSESSID cookie(正常为 32 字节)中提取出 Zend 堆基址——脚本要求至少 4 个”像指针”的值落在同一个 2 MiB 对齐区间内才肯接受。

阶段 2:代码地址泄露。 同样手法再来一次(251,557 字节 producer + 8 × 92,388 字节 consumer),但采用不同的数组布局,目标是抖出一个 PHP 内部函数指针。目标 cookie 恰好 16 字节:前 8 字节是 16,后 8 字节读作 zval_ptr_dtor。脚本校验该指针的低 21 位必须为 0x112930——这是 Alpine PHP 8.4.26 镜像中该符号的页内偏移,ASLR 不会改变它。作者明确提醒:把它当作指纹,而不是”远端二进制与此字节级一致”的证明。由此可推出 zif_system = zval_ptr_dtor - 0x204BC0。

阶段 3:伪造回调。 最终 POST(368,812 字节)在可控 multipart 字段中安放一个 384 字节的伪造 HashTable:

         0x00  ┌──────────────────────────┐
               │ zend_array header        │  pDestructor -> zif_system
         0x20  ├──────────────────────────┤
               │ bucket / fake call frame │  arg1 -> zend_string below
         0x80  ├──────────────────────────┤
               │ zend_string header       │
         0x98  ├──────────────────────────┤
               │ command (231 B max)      │
         0x180 └──────────────────────────┘

PHP 清理路径读取该表的 pDestructor 字段,认定其为 zif_system,并把 zend_string 当作参数传下去——于是 system() 被调用。目标地址按四 worker 的 Alpine 环境标定:

fake_table = zend_heap_base - 0x8400000 + 0x16D1D8

命令本身不只输出 flag:它生成一次性随机 nonce、打印 id 的 UID/GID、给出 print-flag 二进制的 SHA-256,然后再调用一次 print-flag。输出通过 /proc/self/fd 下定位活跃的已 accept socket 直送回 HTTP 响应——作者特别说明,普通进程 stdout 什么都拿不到。

为什么必须拿到两个地址

这是整条链里最容易被忽略、也最关键的工程约束。OS 会独立随机化 PHP 代码段和堆段的加载位置:堆泄露告诉你伪造结构该放哪,代码泄露告诉你 system 实际住在哪。用上一轮 worker 或上一次运行的地址做替代基本必败——作者原话是”我每次这么试都失败了”。

成功率与失败特征

在本地 Docker 靶场连续跑十次、无重试、无其他干扰的结果:

结果次数
execution_proof_verified: true(完整拿下)7
到达回调但标记缺失2
堆泄露阶段就失败1

约七成成功率。有意思的是,两个泄露阶段在容器起来之后非常稳定——同一容器每次都返回相同的 zend_heap_base 和 zval_ptr_dtor,因为 worker 进程持续存活,而 ASLR 是每进程而非每请求生效的。变化的只有”哪个 worker 响应了哪个 pipeline 请求”。

两种失败长得完全不一样,值得记住:

[abort] RuntimeError: heap: expected one strict address, found 0

这是堆阶段落空:producer 正常 OOM,8 个 consumer 也都返回 200 和普通 32 字节 cookie,无候选地址,脚本直接停止,不会伪造任何东西。

{"status": 200, "nonce_seen": false, "id_seen": false,
 "binary_hash_seen": false, "execution_proof_verified": false}

这是回调落空:两次泄露都成功,最终 POST 返回 200,但响应体里只有内存上限 fatal,三个标记一个都没有——伪造的表没落在清理路径会去读的位置。


使用方法

下载

资源地址
仓库主页https://github.com/KillaBoi/StealMyBitcoin-PoC
源码打包下载(zip)https://github.com/KillaBoi/StealMyBitcoin-PoC/archive/refs/heads/main.zip
单文件直接下载https://raw.githubusercontent.com/KillaBoi/StealMyBitcoin-PoC/main/stealmybitcoin_poc.py
Git 克隆git clone https://github.com/KillaBoi/StealMyBitcoin-PoC.git

注:仓库当前未附带 LICENSE 文件,转载与二次分发前请与作者确认授权范围。

本地 Docker 靶场(推荐)

偏移量是针对 Alpine PHP 8.4.26 + 内置 CLI server + 4 worker 标定的,README 明确警告:Debian 的 php:8.4.26-fpm 虽然版本号一样,但二进制布局不同,代码指针指纹永远匹配不上,运行会在代码泄露阶段中止。

三个文件:public/php-version.php(健康检查页)、print-flag(本地假 flag 脚本,需可执行)、nginx.conf(反向代理到 ctf-cross-php:8000)。启动方式:

docker network create ctf-cross-net

docker run -d --name ctf-cross-php --network ctf-cross-net 
  --read-only --tmpfs /tmp:rw,size=32m,mode=1777 --memory 1g 
  -v "$PWD/public:/app:ro" 
  -v "$PWD/print-flag:/usr/local/bin/print-flag:ro" 
  -e PHP_CLI_SERVER_WORKERS=4 
  php:8.4.26-fpm-alpine3.23 php -S 0.0.0.0:8000 -t /app

docker run -d --name ctf-cross-nginx --network ctf-cross-net 
  -p 127.0.0.1:18166:80 
  -v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" 
  nginx:1.27-alpine

注意镜像 tag 是 fpm 但容器实际执行 php -S,SAPI 是 cli-server——这才是标定时的环境。PHP_CLI_SERVER_WORKERS=4 则为泄露阶段提供了不止一个可供竞态的 worker。清理:

docker rm -f ctf-cross-nginx ctf-cross-php && docker network rm ctf-cross-net

Windows 用户注意:在 PowerShell 中执行上述命令,或把 $PWD 换成 C:/lab 这类完整路径。Git Bash 会把 $PWD 展开成 MSYS 路径,Docker Desktop 会在自己的 VM 里解析它,导致 bind mount 静默创建空目录、容器看起来起来了实则损坏。

运行

python .stealmybitcoin_poc.py --url http://127.0.0.1:18166/php-version.php

不带参数运行只会打印帮助并退出,不触碰网络。成功输出形如:

[health] PHP 8.4.26 confirmed
[heap] strict Zend base 0x78113e800000
[code] strict zval_ptr_dtor 0x615b1f512930
{"stage": "final", "nonce": "1ef0e73d8615157a", "execution_proof_verified": true, "response_path": "..."}

响应体里依次是:PHP fatal 错误、本次运行的 nonce、id 输出、print-flag 二进制的哈希、假 flag:


Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 131072 bytes) in Unknown on line 0
1ef0e73d8615157a uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),... 0ec066e8b91457c7c19b5612f3e2ded7f117df08f402693669c1ffc2cfc24942 /usr/local/bin/print-flag LOCAL_DUMMY_WIF_NEVER_USE

退出码 0 表示已验证执行成功,1 表示其他任何情况。参数只有两个:--url(必填)与 --output-dir(证据目录,默认 poc-run-)。


这份 PoC 揭示的四个现实

第一,它不是”点一下就能用”的工具。 偏移量绑定到特定的 Alpine 构建、特定的内存配置、特定的 worker 数量。换镜像、换内存配置、换 worker 数,fake_table 的计算距离就会失效——尽管 PHP 版本号仍然显示 8.4.26。这就是为什么本次挑战的价值不在于”工具人人可用”,而在于它证明了这条路径真实可达。

第二,作者对证据链的态度值得学习。 README 里有一段罕见的自我拆台:原始那次运行没有捕获完整的出站 HTTP 请求和响应头,完整请求是事后根据构建器和残留元数据重建的,”哈希能对上也不能把它提升为原始线路捕获”;他没有拉取远端 PHP ELF 和 print-flag 二进制,它们只是被执行了;恢复出的那把 WIF 校验和有效,但推导出的主页地址与公布地址不符,作者明确指出这是主办方的设置失误并已通过邮件承认。

第三,WIF 有效 ≠ 钱被拿走。 校验和正确的 WIF 只说明这个字符串确实编码了一把合法私钥,推导出地址并比对是另一件独立的事。公开证据显示的是”密钥被取出”,而不是”奖金被盗”。

第四,也是最重要的一条:漏洞修复的依据不能只是”公开利用失败”。 挑战开启 19 小时被解、蜜罐捕获确认的野外 RCE——这两个结果一起把”我们还没被攻击”和”我们还不需要修”这两句话都推翻了。


防守方的验证清单

  • 确认自己的 PHP 版本与构建:仅看版本号不够,Debian 重新打包往往不改版本串。CVE-2026-109618 / CVE-2026-109619 的修复版本为 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。
  • 共享主机用户要问宿主:修复由主机商推送,不是 WordPress 或某个插件能更新的,该问的是”我的站点跑的是哪个 PHP 构建、什么时候打的补丁”。
  • 无法升级的系统:CISA 已建议无法更新或仍运行 PHP 8.1 及更早版本的机构,在完成升级前将系统下线。
  • 临时缓解:关闭 session.upload_progress(PHP 官方对 CVE-2026-115560 与补丁前阶段均推荐此步骤)。
  • 排查处:在 WAF 与应用日志中检索异常体量的 multipart POST、PHP_SESSION_UPLOAD_PROGRESS 字段、f[0][x][x]... 深层嵌套字段名,以及超长 cookie 响应。
  • 不要直接对公网目标跑这个脚本:即使脚本内置了白名单,任何未打补丁的第三方服务器都不该被用作测试床。

结语

PHP2Shell 曾经的”难利用”形象,一半来自漏洞本身的苛刻(两个独立 ASLR 地址、worker 复用竞态、构建指纹绑定),一半来自没有人愿意把最难的那一步做完。KillaBoi 把这份 PoC 连同它的丑陋、它的七成成功率、它的两种失败形态、它的 Docker 靶场脚本一起公开——作者自己在 README 顶部写着”请把它清理一下,或者做个更好的版本,别被 Sol 弄皱巴巴的”。

从防守视角看,这份公开比藏着更有价值:一个有明确失败模式、可用本地靶场完整复现、且有明确指纹判定的攻击链,会逼迫整个生态停止用”公开利用都失败了”来自我安慰。而对所有跑着 PHP 的站点而言,问题的答案其实从 10 月 21 日起就没有变化过:升级,或者下线。


版权声明:本文由华盟网原创发布,保留所有权利。配图由华盟网授权使用。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容