针对 Steal My Bitcoin 挑战的一次实时 PHP 回调,从开始到结束,全部包含在一个仅使用标准库的 Python 文件中。
本 README 介绍 stealmybitcoin_poc.py,这是一个单文件 Python 脚本,复现了我在 Steal My Bitcoin CTF 中使用的请求序列。它仅使用 Python 标准库。该脚本自行构建请求,在继续之前检查每一次泄露,并记录一次新运行产生的 HTTP 字节。
这大概可以做得更简单,不需要所有这些额外的调试垃圾,而且正如我在关于 Steam 本地权限提升的 John Hammond 视频中发现的那样,你大概可以清理掉这个丑陋的 PoC。
[!NOTE] ⚠️ 说实话,请清理它,或者制作你自己的更好版本,一个不会被 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.
zif_system,即 PHP 对 system 的处理函数。print-flag 结果。为什么需要两个地址? 操作系统会独立地随机化 PHP 代码和堆的落点。堆泄漏告诉脚本将伪造结构放在哪里,代码泄漏告诉脚本它想调用的函数实际位于何处。来自较早 worker 或较早运行的地址不能安全替代,而且每次我尝试时,该尝试都只是失败了。
此 PoC 中的偏移量是针对 运行内置 CLI 服务器并启用四个 worker 的 Alpine PHP 8.4.26 校准的。实验环境在回环地址上、nginx 之后精确复现了该配置。
[!IMPORTANT] 构建版本比版本字符串更重要。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 将其拆除。
[!TIP] 在 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 标志。验证通过时退出状态为 0,其他情况为 1。
[!WARNING] 生产者会故意让 PHP 触及内存上限。只应在行为已获授权的环境中运行。脚本只会与挑战端点或回环实验 URL 通信,其他任何目标都会在发出数据包之前中止。
一次运行意味着一次健康检查 GET、最多两批泄漏请求,以及至多一次最终请求。不循环、不重试。每个 multipart 请求体都保持在 1 MiB 以下,不过每批泄漏请求都会在一条连接上流水线发送九个独立请求。
| 标志 | 作用 |
|---|---|
--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 请求:一次健康检查、每批泄漏九个、一次最终请求。一批中的全部八个消费者都会在读取第一个响应之前发出,这有助于时序,但也意味着如果生产者失败,你无法撤回它们。一批失败或格式错误会中止下一阶段。
生产者是携带 PHPSESSID 和 PHP_SESSION_UPLOAD_PROGRESS 字段的 multipart/form-data。PHP 在仍在解析请求时就会处理上传进度。数百个空文件控件获得类似 f[0][x][x]... 的名称,其中包含数十个嵌套的 [x] 段,因此请求体在网络上保持在约 250 KiB,而解析它会生成大量嵌套 PHP 数组及其伴随的所有分配。生产者还携带 1,001 个不同的空 cookie 名称,这是请求构建器当时被配置发送的内容。
你想要的致命错误会表明 PHP 耗尽了其 128 MiB 内存上限。你真正追求的是这种特定失败在工作进程中留下的陈旧状态。
[!IMPORTANT] 单独一个内存上限致命错误或 nginx 502 并不能证明存在地址泄漏或代码执行。
下一个请求针对与该陈旧会话状态相关的内存。它重复一个数字表单键,然后插入一个精心构造的值,改变 PHP 写入 PHPSESSID 时使用的长度。命中正确的复用工作进程和正确的堆布局后,cookie 会返回 256 字节而不是通常的 32 字节,其中包含相邻的堆数据。
heap_candidate() 将该 cookie 按小端 64 位字遍历,只有当至少四个看似指针的值落在同一个对齐的 2 MiB 堆区域时才会接受一个基址。除此之外,运行器还要求 HTTP 200、预期的 PHP 版本、全部八个消费者返回有效的 cookie 长度,并且恰好只有一个一致的基址。任何一项不满足就会停止。