Steal My Bitcoin CTF PoC:单文件 Python 复现 PHP 内存泄漏利用链
Steal My Bitcoin CTF PoC
安全研究者发布 stealmybitcoin_poc.py,用仅依赖 Python 标准库的单文件脚本复现 Steal My Bitcoin CTF 的完整攻击链,最多 20 个 HTTP 请求、无循环无重试。
Steal My Bitcoin CTF 概念验证
针对 Steal My Bitcoin 挑战的一次实时 PHP 回调,从头到尾,全部放在一个仅用标准库的 Python 文件里。
本 README 介绍 stealmybitcoin_poc.py,这是一个单文件 Python 脚本,复现了我在 Steal My Bitcoin CTF 中使用的请求序列。它只使用 Python 标准库。脚本自行构造请求,在继续之前检查每一次泄漏,并记录一次新运行中的 HTTP 字节。
不用那些多余的调试垃圾,这件事大概能做得更简单;而且就像我在关于 Steam 本地权限提升的 John Hammond 视频里那样,你大概能把这个丑陋的 PoC 清理干净。
注意
⚠️ 说真的,请把它清理干净,或者做一个你自己的、不被 Sol 揉皱的更好版本。 ⚠️
下面所有内容都是针对 Docker 实验环境写的,不是针对线上挑战。该实验环境是 nginx 后面的本地 Alpine PHP 8.4.26 CLI 服务器,偏移量正是在这个相同的构建和 worker 模型上校准的。一分钟内把它搭起来,整条链就能在你自己的机器上端到端跑通。真正解出挑战的那次线上运行,作为历史记录保留在底部。
stage what goes out what comes back
─────────────────────────────────────────────────────────────────────────────
0 health GET /php-version.php -> PHP 8.4.26 confirmed
1 heap leak 249,042 B producer + 8 x 6,672 B -> 256 B cookie
(OOM poisons a worker) └─ Zend chunk base
2 code leak 251,557 B producer + 8 x 92,388 B -> 16 B cookie
(OOM poisons a worker) └─ zval_ptr_dtor
3 callback 368,812 B POST -> forged HashTable runs
(fake HashTable + command) └─ zif_system(cmd)
─────────────────────────────────────────────────────────────────────────────
20 HTTP requests at most. No looping, no retries.
简版
- 一次小的 multipart 上传会让 PHP 消耗远超线上大小的内存。
- 当它在会话上传进度解析中途耗尽内存时,PHP 会在一个随后被复用的 worker 中留下陈旧状态。
- 把后续请求构造得当,字节就会通过会话 cookie 泄漏出来。我把它们读作一个堆地址和一个 PHP 代码地址。
- 有了这两个地址,最后一个请求会把一个伪造的 PHP 结构放到清理逻辑能找到的地方,然后清理逻辑会调用
zif_system,即 PHP 对system的处理程序。 - 该命令会通过仍然打开的连接发回一个新的证明标记和一个
print-flag结果。
为什么需要两个地址? 操作系统会独立地随机化 PHP 代码和堆的落点。堆泄漏告诉脚本把伪造结构放在哪里,代码泄漏告诉它想调用的函数实际位于哪里。来自更早 worker 或更早运行的地址不是安全的替代品,每次我尝试时,尝试都只是失败了。
目录
实验环境
本 PoC 中的偏移量是针对 运行内置 CLI 服务器、带四个 worker 的 Alpine PHP 8.4.26 校准的。实验环境在回环地址上、nginx 后面精确复现了这一点。
重要
构建比版本字符串更重要。Debian 的 php:8.4.26-fpm 栈报告相同的 PHP/8.4.26,但二进制布局不同,因此代码指针指纹永远不会匹配,运行会在代码泄漏阶段中止。请使用下面的 Alpine 镜像。
三个小文件。把它们放进一个空目录:
public/php-version.php(健康检查端点,也是 PoC 唯一需要的页面)
<?php header('Content-Type: text/plain'); echo 'PHP ', PHP_VERSION, "\n"; echo 'PID: ', getmypid(), "\n";
print-flag(挑战二进制的替身,必须可执行)
#!/bin/sh printf '%s\n' 'LOCAL_DUMMY_WIF_NEVER_USE'
nginx.conf
events {} http { server { listen 80; server_name _; location / { proxy_pass http://ctf-cross-php:8000; proxy_http_version 1.1; proxy_set_header Host $host; } } }
然后启动它:
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
检查它:
$ curl -s http://127.0.0.1:18166/php-version.php PHP 8.4.26 PID: 1
PID 会随应答的 worker 不同而变化,这没关系。版本行才是 PoC 检查的部分。
用 docker rm -f ctf-cross-nginx ctf-cross-php && docker network rm ctf-cross-net 将其拆除。
提示
在 Windows 上,请从 PowerShell 运行那些 docker run 命令,或将 $PWD 替换为完整驱动器路径,如 C:/lab。在 Git Bash 中,$PWD 会展开为 MSYS 路径,例如 /tmp/...,Docker Desktop 会在其自己的 VM 内解析该路径。随后绑定挂载会静默地在 nginx.conf 和 print-flag 本应所在的位置创建空目录,容器启动后即处于损坏状态。
关于该配方有几点说明。镜像为 fpm 标签,但容器运行的是 php -S,因此 SAPI 为 cli-server,校准正是针对此进行的。PHP_CLI_SERVER_WORKERS=4 使泄漏阶段有多个 worker 可供竞争。我自己的实验装置还传入了 --cap-add SYS_PTRACE --security-opt seccomp=unconfined,以便将跟踪器附加到 worker;PoC 本身从不这样做,因此上述内容中省略了这些。
从这里开始
Python 3.11 或更新版本,仅使用标准库,无需安装任何内容:
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": "..."}
注意该 zval_ptr_dtor 值的低 21 位:0x112930,正是脚本期望从此 Alpine 构建中获得的指纹。这就是拒绝不匹配镜像的检查。
返回的响应体:
<br />
<b>Fatal error</b>: Allowed memory size of 134217728 bytes exhausted (tried to allocate 131072 bytes) in <b>Unknown</b> on line <b>0</b><br />
1ef0e73d8615157a
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
0ec066e8b91457c7c19b5612f3e2ded7f117df08f402693669c1ffc2cfc24942 /usr/local/bin/print-flag
LOCAL_DUMMY_WIF_NEVER_USE
先是 PHP 致命错误,然后是本次运行生成的 nonce,接着是 id,再是它解析出的二进制文件的哈希,最后是 dummy flag。验证通过时退出状态为 0,其他情况为 1。
警告
生产者会故意让 PHP 触及内存限制。仅在行为已获授权的地方运行此操作。脚本只会与挑战端点或回环实验 URL 通信,其他任何情况都会在发出数据包前中止。
一次运行意味着一次健康 GET、最多两批泄漏,以及至多一次最终请求。不循环,不重试。每个 multipart 主体均保持在 1 MiB 以下,尽管每批泄漏会通过一个连接流水线发送九个独立请求。
| Flag | 它的作用 |
|---|---|
--url |
实验端点,或挑战。执行任何操作所必需。 |
--output-dir |
运行证据的写入位置。默认为 poc-run-<UTC timestamp>。 |
每个请求的作用
| 步骤 | 发出什么,应返回什么 | 原因 |
|---|---|---|
| 健康检查 | 一个普通的 GET /php-version.php 必须返回 HTTP 200、PHP 8.4.26 和 X-Powered-By: PHP/8.4.26。 |
使内存限制载荷远离已更改或不健康的端点。 |
| 堆泄漏 | 一个 249,042 字节的 multipart 生产者,然后是八个流水线化的 6,672 字节消费者。生产者必须返回 PHP 128 MiB 内存限制致命错误。消费者可能返回一个 256 字节的 PHPSESSID cookie。 |
该 cookie 中四个或更多看似指针的值必须一致指向一个对齐的 2 MiB Zend 堆基址。 |
| 代码泄漏 | 一个 251,557 字节的生产者,然后是八个流水线化的 92,388 字节消费者。消费者可能返回一个 16 字节的 PHPSESSID cookie。 |
其前八个字节必须编码 16,后八个字节必须看起来像本地校准的 zval_ptr_dtor 指针。 |
| 最终回调 | 一个 368,812 字节的 multipart POST。 | 伪造的 PHP HashTable 将清理指向 zif_system 并提供命令参数。新的 nonce、id 输出和 print-flag 二进制哈希是回调实际运行的证据。 |
即至多 20 个 HTTP 请求:一次健康检查,每批泄漏九个,一次最终请求。一批中的全部八个消费者在读取第一个响应之前发出,这有助于时序,但也意味着如果生产者失败,你无法撤回它们。失败或格式错误的批次会停止下一阶段。
1. 一个小生产者如何达到内存限制
生产者是 multipart/form-data,携带 PHPSESSID 和 PHP_SESSION_UPLOAD_PROGRESS 字段。PHP 在仍在解析请求时就处理上传进度。数百个空文件控件被赋予类似 f[0][x][x]... 的名称,并带有数十个嵌套的 [x] 段,因此请求体在网络上传输时保持在约 250 KiB,而解析它会生成大量嵌套的 PHP 数组以及随之而来的所有内存分配。生产者还携带 1,001 个不同的空 cookie 名称,这正是当时请求构建器被配置为发送的内容。
你想要的致命错误是 PHP 耗尽了其 128 MiB 内存限制。你真正关注的是这一特定故障在 worker 中留下的陈旧状态。
重要
就其本身而言,内存限制致命错误或 nginx 502 并不能证明存在地址泄漏或代码执行。
2. 如何恢复堆地址
下一个请求针对与该陈旧会话状态相关的内存。它重复一个单一的数字表单键,然后插入一个精心构造的值,改变 PHP 在写入 PHPSESSID 时使用的长度。落在正确的被复用 worker 上并具有正确的堆布局,cookie 返回时就是 256 字节而不是通常的 32 字节,其中包含相邻的堆数据。
heap_candidate() 将该 cookie 作为小端 64 位字遍历,并且只有当至少四个看起来像指针的值落在同一个对齐的 2 MiB 堆区域时,才会接受一个基址。除此之外,运行器还要求 HTTP 200、预期的 PHP 版本、来自全部八个消费者的有效 cookie 长度,以及恰好一个一致的基址。任何一项不满足,它就会停止。
3. 如何恢复 PHP 代码地址
第二个生产者触发相同的内存限制条件。其消费者使用不同的数组布局,并在本地校准以找出一个 PHP 函数指针。你想要的 cookie 恰好是 16 字节:一个八字节的 16 值,然后是一个针对 Alpine PHP 指纹读取为 zval_ptr_dtor 的指针。
脚本检查指针的范围及其低 21 位,0x112930。这是该符号在 Alpine PHP 8.4.26 镜像中的页偏移,ASLR 不会移动它。将其视为指纹,而不是证明远程可执行文件逐字节相同。对于此布局,zif_system 计算结果为:
zif_system = zval_ptr_dtor - 0x204BC0
两个泄漏都必须来自当前调用。脚本不会将历史指针值加载到实时尝试中。
4. 最终请求如何到达 system
最终的 POST 将一个 384 字节的伪造 HashTable 放在我控制的一个 multipart 字段中。该结构与一个伪造的调用帧以及一个持有命令的伪造 zend_string 重叠。一个 276 字节的引导程序提供一个指向受控区域的指针,然后一个长表单字段名驱动另一次内存限制失败。在清理路径中,PHP 将伪造表的 pDestructor 读取为 zif_system,并将字符串参数交给它。
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 └──────────────────────────┘
目标地址使用在四 worker Alpine 设置中测量的距离:
fake_table = zend_heap_base - 0x8400000 + 0x16D1D8
该距离取决于 worker 和分配布局。更改镜像、内存配置或 worker 数量,即使 PHP 版本仍显示为 8.4.26,这也会失败。
最初有效的命令只运行了一次 print-flag。此版本打印一个新的随机 nonce、id(UID 和 GID)的输出,以及解析后的 print-flag 二进制的 SHA-256,然后调用一次 print-flag。它在 /proc/self/fd 下找到活跃的已接受套接字,以便输出实际到达 HTTP 响应。普通的进程 stdout 对我毫无用处。
读取结果
每次运行都会生成一个新的 poc-run-<UTC timestamp> 目录,除非 --output-dir 指向一个新目录。所有内容都在验证之前写入,因此中途中止的运行仍会留下可用的证据。值得打开的文件:
| 文件 | 内容 |
|---|---|
heap.request.http、code.request.http |
交给每个泄漏阶段套接字 sendall 的明文 HTTP 字节,一个生产者加八个流水线消费者。如果传输失败,你无法确定实际送达了什么。 |
heap.response.http, code.response.http |
从每个泄漏阶段套接字读回的明文 HTTP 字节,包含 cookie。 |
heap.json, code.json |
解析后的响应状态、长度、哈希和候选地址。 |
final.request.http |
构造出的最终请求,包括头部和 multipart 正文。关于传输失败的注意事项相同。 |
final.response.http, final.response.body, final.response.headers.json |
读回的最终响应,以及解析后的正文和头部。针对线上挑战,该正文可能包含一个私钥 WIF。 |
final.json, run.json |
摘要字段,以及预期的执行标记是否出现。 |
execution_proof_verified: true 表示最终响应返回 HTTP 200,携带新的 nonce、一个 id 结果,以及 print-flag 的 SHA-256 行。
注意
针对真实挑战,运行目录可能包含一个有效的私钥。请发布此源文件,而不是某次运行的输出。
该脚本不声称返回的 WIF 是否控制所宣称的比特币地址。校验和有效的 WIF 只能说明该字符串正确编码了一个私钥。推导地址并进行比较是另一项工作。
实际成功率
两个泄漏阶段都是与另外三个 worker 的竞争,因此单次运行不预期每次都成功。针对上述实验环境连续运行十次,不重试,也没有其他程序与容器通信:
| 结果 | 次数 |
|---|---|
execution_proof_verified: true |
7 |
| 到达回调,标记缺失 | 2 |
| 堆泄漏从未命中 | 1 |
所以大约十次中有七次。值得知道的是,一旦容器启动,两个泄漏都是稳定的:针对同一容器的每次运行都返回相同的 zend_heap_base 和相同的 zval_ptr_dtor,因为 worker 持续存在,而 ASLR 是按进程而非按请求。运行之间的差异纯粹是哪个 worker 响应哪个流水线消费者。
这两种失败值得识别,因为它们看起来完全不同:
堆阶段未命中。 生产者正确触发 OOM,所有八个消费者都返回 HTTP 200 和普通的 32 字节 cookie。没有泄漏,没有候选,因此运行在伪造任何内容之前停止:
[abort] RuntimeError: heap: expected one strict address, found 0
回调未命中。 两个泄漏都成功,最终 POST 返回 HTTP 200,正文只有内存限制致命错误,三个标记一个都没有。伪造的表没有落在清理读取它的位置:
{"status": 200, "nonce_seen": false, "id_seen": false,
"binary_hash_seen": false, "execution_proof_verified": false}两种情况都不是部分成功,也都不会自动重试。再运行一次即可。
成功解决它的实际运行
在 2026 年 10 月 25 日 15:47:38 UTC,较早的受保护运行器记录了一个健康的 PHP 8.4.26 页面、一个带有 HTTP 200 内存限制致命错误和 256 字节 cookie 的堆阶段,以及一个带有 HTTP 200 内存限制致命错误和 16 字节 cookie 的代码阶段。其最终的 368,812 字节 POST 返回 HTTP 200,正文 204 字节:PHP 致命错误,然后是一个校验和有效的 WIF。本地 nginx/PHP 实验环境用虚拟数据复现了回调和套接字输出方法。综合来看,这有力地证明了 PHP 进程内的实际命令执行。
有必要坦诚说明这些缺口。原始运行从未捕获完整的出站 HTTP 请求或响应头。完整请求是事后根据构建器和残留的元数据重建的,而匹配的哈希并不能把它变成原始的线路捕获。我从未拉取远程 PHP ELF 或 print-flag 二进制文件,它们只是被执行了,正如挑战网站上所述。我恢复的那个 WIF 并不能推导出当时保存的主页上显示的奖品地址。那是创建者方面的错误,并已通过电子邮件确认。
术语简释
| 术语 | 含义 |
|---|---|
| Zend 堆 | PHP 用于请求值和内部结构的内存区域。 |
| Worker | 处理请求的一个 PHP 进程。陈旧状态和后续消费者需要同一个被复用的进程,而流水线并不保证你能得到它。 |
PHPSESSID cookie |
在泄漏阶段出现意外内存字节的位置。 |
zval_ptr_dtor |
一个 PHP 内部函数指针,在此用于锚定代码映像地址的计算。 |
zif_system |
PHP 对 system 的内部处理程序,用作伪造的清理回调。 |
| WIF | Wallet Import Format,一种比特币私钥的编码。有效的校验和并不能说明谁拥有某个特定的钱包地址。 |
改为阅读代码
它按攻击顺序排列,各节标题与上述四个阶段对应:
leak_producer() -> heap_consumer() -> code_consumer()
-> run_leak_stage()
-> fake_hashtable() -> final_multipart() -> final_request()
每个调校过的数字都位于顶部的校准块或 STAGES 表中,因此当需要针对不同构建重新测量时,那里就是该看的地方。
致谢
- cloudlandsdev 提供
"$744#6-'rugdrbyr" - LiveOverflow,他制作了精彩的视频,并且在我需要联系创建者时提供了很大帮助 :)
- 我还在等我的 Overwatch 会话 lusilly :|
来源:Hacker News · github.com