04 · 插件体系与利用链#
这一章回答:一个漏洞是怎么变成"一个插件"的?插件从哪来、怎么被选中、结果怎么变成报告里的一行?
1. 设计纲领:每漏洞一插件#
plugins/base.py 的开头直接引用了设计文档的纲领:
一个漏洞 = 一个类 = 一个文件。这条纪律带来三个可预期的好处:
- 新增漏洞不改任何既有文件(只在包
__init__.py里加一行注册); - 插件的元信息(CVE/CVSS/修复建议/复现命令)与检测逻辑同居一处,不会漂移;
--plugin-list、报告、API/api/plugins都能直接从类属性生成,无需额外元数据表。
代价是文件数量:54 个已注册插件 = 54 个文件 + 4 个 __init__.py。
2. PluginBase 契约#
2.1 类属性(元信息)#
所有字段都是类属性,不在 __init__ 里赋值——因为插件实例是引擎每轮新建的(plugin_cls()),元信息应属于类。
| 属性 | 默认 | 用途 |
|---|---|---|
name | "" | 中文漏洞名(报告主键) |
cve | "" | CVE 编号(无 CVE 时填 CNVD 或 N/A) |
severity | "low" | high / medium / low |
category | "" | 模式过滤键:recon / vuln / brute |
description | "" | 漏洞描述 |
fix | "" | 修复建议(一句话概要) |
fix_detail | "" | D18 修复详情(代码/配置 diff、升级版本号、操作步骤,多行) |
reproduce | "" | D24 复现命令(可直接复制的 curl / Python 片段,多行) |
affected_versions | "" | D2 影响版本区间,如 ">=4.2,<4.6";空串 = 全版本适用 |
variant | "" | E1 适用变体,如 "ruoyi-app";空串 = 全变体适用 |
cvss_vector | "" | D12 CVSS v3.1 向量 |
compliance | "" | D12 合规映射标签,如 "等保2.0:8.1.3;OWASP:A03:2021" |
vuln_type | "" | D7 漏洞类型(sqli/xss/rce/file_read/auth),供绕过策略匹配 |
supports_waf_bypass | False | 是否支持 WAF 绕过 |
bypass_max_attempts | 3 | 每策略算一次的最大绕过尝试数 |
2.2 方法#
| 方法 | 抽象 | 作用 |
|---|---|---|
verify(target, session) | ✅ | 核心判定,返回 ScanResult(三态) |
verify_with_bypass(target, bypass_session, bypass_ctx) | — | D7 绕过验证;默认实现=用 BypassSession 调 verify() |
_build_result(status, url, evidence, extra) | — | 构造结果的便捷方法,自动填充元信息、CVSS、合规 |
enrich(result) | — | 幂等回填:只填空值,不覆盖插件已写的值 |
meta() | — | 返回元信息字典(API /api/plugins 用) |
2.3 verify() 的硬性约定#
@abstractmethod
def verify(self, target: str, session: "SessionManager") -> ScanResult:
"""执行检测,返回 ScanResult(三态判定)
网络异常等不可判定情形必须返回 status=UNKNOWN,不得判为 SAFE。
"""
这是整个项目的判定纪律,写在了抽象方法的 docstring 里:
CONFIRMED—— 有明确证据确认存在SAFE—— 有明确证据确认不存在UNKNOWN—— 无法判定(网络异常、响应异常、被 WAF 拦截等)
绝不把"没检测出来"当成"不存在"。这条规则决定了报告的可信度:UNKNOWN 会被单独统计并可由 G3 智能降噪处理,而不会被悄悄洗成 SAFE。
2.4 _build_result() 里的 kind 联动#
kind 字段(vuln/brute/dir/info)必须与 status 一致:只有 CONFIRMED 才能是 vuln。enrich() 里还有一道兜底:
因为绝大多数插件是直接构造 ScanResult 而不是调 _build_result(),很容易忘掉这个联动。enrich() 的存在就是为了修这类不一致:
背景:绝大多数插件直接构造
ScanResult而非调用_build_result(),导致类上声明的 cve / cvss_vector / compliance / fix_detail / reproduce 等元信息从未进入结果对象,报告里这些字段恒为空。
enrich() 在引擎收集结果时统一调用,只填空值(因此对所有插件安全、幂等),把类属性上的元信息补进结果对象。
2.5 CVSS v3.1 计算(cvss_score)#
plugins/base.py 里内联了一份完整的 CVSS v3.1 Base Score 实现,不引第三方库:
| 步骤 | 说明 |
|---|---|
| 解析向量 | 剥离 CVSS:3.1/ 前缀,按 / 拆成 k:v;8 个基础指标(AV/AC/PR/UI/S/C/I/A)缺一即返回 0.0 |
| ISS | 1 - ((1-C)(1-I)(1-A)) |
| Impact | S:C → 7.52*(ISS-0.029) - 3.25*(ISS-0.02)^15;否则 6.42*ISS |
| Exploitability | 8.22 * AV * AC * PR * UI |
| PR 权重 | S:C 时用专用表 _CVSS_PR_SC(PR:L=0.68 而非 0.62)——跨安全边界所需权限门槛更高 |
| 封顶 | impact <= 0 直接 0;S:C 时额外乘 1.08 并与 10.0 取小 |
| 取整 | math.ceil(base*10)/10 —— 规范要求的 Roundup(向上取整到 0.1),不是四舍五入 |
「向上取整而非四舍五入」是 CVSS 规范里最容易被实现错的一处:7.61 必须报 7.7。
2.6 合规标签解析(parse_compliance)#
用 split(":", 1) 而非 split(":")——因为 OWASP:A03:2021 的右值里还有冒号。
3. 插件发现:五条路径#
插件不是"扫描目录自动发现",而是五条显式路径叠加(顺序即优先级,见 03 章 §3.4):
| # | 路径 | 入口函数 | 说明 |
|---|---|---|---|
| 1 | 内置包 plugin_list | load_plugins() | 每个包 __init__.py 显式登记;顺序 = 执行优先级 |
| 2 | 外部路径 | load_external_plugins() | --plugin-path 目录或 .py 文件,模块名前缀 _external_plugin_ 隔离 |
| 3 | 用户目录 | load_user_installed_plugins() | ~/.ruoyi-scan/plugins/(由 --plugin-update 安装) |
| 4 | entry_points | load_entry_point_plugins() | ruoyi_scan.plugins 组,pip 安装即发现 |
| 5 | nuclei YAML | load_nuclei_templates() | E4 兼容层,把 YAML 模板包装成插件类 |
另外还有一个独立的 discover_plugin_packages():遍历 plugins/ 下所有子包(排除 chain,因为链步骤插件不参与常规扫描)。
外部插件用模块名前缀隔离的原因:若外部插件文件名与内置插件重名,直接 import 会污染 sys.modules,导致后续加载拿到错误的类。加前缀让两者共存。
plugin_list 顺序即优先级:各包注释里明确了排序原则——高危优先。
4. 插件清单#
4.1 plugins/ruoyi — 20 个#
| # | 类名 | 文件 | 覆盖点 |
|---|---|---|---|
| 1 | DirectoryScanPlugin | directory_scan.py | 目录/路径发现 |
| 2 | FileReadPlugin | file_read.py | 任意文件读取 |
| 3 | JobInvokeTargetPlugin | job_invoke_target.py | 定时任务 invokeTarget |
| 4 | SqlInjectRolePlugin | sql_inject_role.py | /system/role/list SQL 注入 |
| 5 | SqlInjectDeptPlugin | sql_inject_dept.py | /system/dept/list SQL 注入 |
| 6 | FileUploadPlugin | file_upload.py | 任意文件上传 |
| 7 | JobRcePlugin | job_rce.py | 定时任务 RCE |
| 8 | ThymeleafSstiPlugin | thymeleaf_ssti.py | Thymeleaf SSTI |
| 9 | RuoyiFileReadPathPlugin | file_read_path.py | 路径穿越文件读取 |
| 10 | UnauthBatchPlugin | unauth_batch.py | 未授权接口批量探测 |
| 11 | RuoyiNacosUnauthPlugin | nacos_unauth.py | Nacos 未授权 |
| 12 | DruidBrutePlugin | druid_brute.py | Druid 监控页口令 |
| 13 | DefaultPasswordPlugin | default_password.py | 默认口令 |
| 14 | RuoyiCloudNacosPlugin | ruoyi_cloud_nacos.py | 微服务版 Nacos |
| 15 | RuoyiSwaggerUnauthPlugin | ruoyi_swagger_unauth.py | Swagger 未授权 |
| 16 | RuoyiGenRcePlugin | ruoyi_gen_rce.py | 代码生成器 RCE |
| 17 | Cve202546174ResetPwdScopePlugin | cve_2025_46174_resetpwd_scope.py | 重置密码越权(CVE-2025-46174) |
| 18 | Cve202570986SelectDeptTreePlugin | cve_2025_70986_select_dept_tree.py | selectDeptTree SQL 注入(CVE-2025-70986) |
| 19 | PlusAuthLoginProbePlugin | plus_auth_login.py | Plus 版登录探测 |
| 20 | PlusJobUnauthPlugin | plus_job_unauth.py | Plus 版定时任务未授权 |
20 个文件全部登记进
plugin_list。
4.2 plugins/spring — 14 个#
| # | 类名 | 文件 | 覆盖点 |
|---|---|---|---|
| 1 | Spring4shell | spring4shell.py | Spring4Shell RCE |
| 2 | SpringGatewayRce | gateway_rce.py | Gateway Actuator RCE |
| 3 | SpringActuatorEnvRce | actuator_env_rce.py | Actuator env 属性注入 RCE |
| 4 | SpringJolokiaRce | jolokia_rce.py | Jolokia RCE |
| 5 | SpringJolokiaMletRce | jolokia_mlet_rce.py | Jolokia MLet RCE |
| 6 | SpringCloudFunctionRce | cloud_function_rce.py | Cloud Function SpEL RCE |
| 7 | SpringH2ConsoleRce | h2_console_rce.py | H2 Console RCE |
| 8 | SpringActuatorUnauth | actuator_unauth.py | Actuator 未授权 |
| 9 | SpringHeapdumpLeak | heapdump_leak.py | heapdump 泄露 |
| 10 | SpringMappingsLeak | mappings_leak.py | mappings 接口泄露 |
| 11 | SpringTraceLeak | trace_leak.py | trace 泄露 |
| 12 | SpringCloudConfig | spring_cloud_config.py | Cloud Config 泄露 |
| 13 | SpringBootAdmin | spring_boot_admin.py | Boot Admin 暴露 |
| 14 | SpringDataRest | spring_data_rest.py | Data REST 暴露 |
4.3 plugins/common — 12 个(plugin_list 登记)#
| # | 类名 | 文件 | 覆盖点 |
|---|---|---|---|
| 1 | GitLeak | git_leak.py | .git 泄露 |
| 2 | EnvLeak | env_leak.py | .env 泄露 |
| 3 | BackupScan | backup_scan.py | 备份文件泄露 |
| 4 | RedisUnauth | redis_unauth.py | Redis 未授权 |
| 5 | CorsMisconfig | cors_misconfig.py | CORS 配置错误 |
| 6 | SwaggerLeak | swagger_leak.py | Swagger 泄露 |
| 7 | SourceLeak | source_leak.py | 源码泄露 |
| 8 | MinioUnauth | minio_unauth.py | MinIO 未授权 |
| 9 | RocketmqUnauth | rocketmq_unauth.py | RocketMQ 未授权 |
| 10 | ShiroRememberme | shiro_rememberme.py | Shiro 框架指纹(rememberMe 特征,SAFE/UNKNOWN) |
| 11 | DirListing | dir_listing.py | 目录列表 |
| 12 | TraceMethod | trace_method.py | TRACE 方法 |
shiro_rememberme为指纹类插件(category=recon、severity=low、不挂 CVE): 只输出SAFE(无 rememberMe 特征)或UNKNOWN(检测到特征,密钥可利用性需人工验证), 设计上永不输出CONFIRMED。2026-11-02 方案 A 收口:原先挂CVE-2016-4437+high属能力虚报 (报告会显示 high 级 RCE,读者以为已验证可利用性),真正的密钥爆破验证留待后续版本。 其探测思路同时被 lib/component_detect.py 的 Shiro 组件版本检测复用。
4.4 plugins/jeecgboot — 8 个#
| # | 类名 | 文件 | 覆盖点 |
|---|---|---|---|
| 1 | JeecgFreemarkerSsti | freemarker_ssti.py | Freemarker SSTI RCE(high) |
| 2 | JeecgSqlInjectQueryUser | sql_inject_query_user.py | queryUserByDepId SQL 注入(high) |
| 3 | JeecgSqlInjectJmreport | sql_inject_jmreport.py | jmreport SQL 注入(high) |
| 4 | JeecgFileUploadJmreport | file_upload_jmreport.py | jmreport 任意文件上传(high) |
| 5 | JeecgFileReadDownload | file_read_download.py | 任意文件读取(high) |
| 6 | JeecgJmreportListUnauth | jmreport_list_unauth.py | 报表未授权(medium) |
| 7 | JeecgDictUnauth | dict_unauth.py | 字典越权(medium) |
| 8 | JeecgDefaultPassword | default_password.py | 默认口令(brute) |
这个包的战略意义写在 plugins/jeecgboot/init.py 的注释里:
JeecgBoot 插件包(F5:第一个拓展框架) 定位:证明基建通用性——指纹/路由/三态/报告零改动接入新框架
它是架构通用性的证明物:接入 JeecgBoot 时,core/ 一个文件都没改,只加了「特征库数据 + 一个插件包 + 一行路由映射」。
4.5 合计#
| 包 | 登记数 |
|---|---|
plugins.ruoyi | 20 |
plugins.spring | 14 |
plugins.common | 12 |
plugins.jeecgboot | 8 |
| 合计 | 54 |
插件数量有三个口径,勿混用: - 57 =
plugins/下插件文件总数(scripts/verification_matrix.py按文件统计,含plugins/chain/的 3 个链步骤文件); - 54 =plugin_list登记总数(上表合计,本 Wiki 与 README 统一采用此口径); - 52 = 通用目标实际执行数(Router().resolve()减去 2 个 RuoYi-Plus 变体专属插件,与 tests/test_fingerprint.py 断言一致)。README 原先的「51 / 若依 18」即第三种口径,已统一为 54 / 20 并保留 Plus 专属说明。
5. 路由:指纹怎么变成插件包#
core/router.py 的映射表:
mapping = {
"ruoyi": "plugins.ruoyi", "ruoyi-cloud": "plugins.ruoyi",
"ruoyi-vue3": "plugins.ruoyi", "ruoyi-app": "plugins.ruoyi",
"ruoyi-plus": "plugins.ruoyi", "ruoyi-cloud-plus": "plugins.ruoyi",
"ruoyi-magic": "plugins.ruoyi",
"spring": "plugins.spring",
"jeecgboot": "plugins.jeecgboot",
}
| 方法 | 过滤维度 |
|---|---|
candidates(fp) | cms + variant(不过滤版本) |
resolve(fp) | cms + variant + affected_versions |
resolve_by_name(cms) | 仅 cms(手动指定时无版本信息) |
7 个若依变体共享 plugins.ruoyi:差异靠插件类上的 variant / affected_versions 表达。这样包数量可控,但代价是 plugins/ruoyi 内部要靠属性区分适用面。
candidates() 单独存在是为版本对照矩阵服务——矩阵必须看"过滤前"的全集才有信息量(见 03 章 §3.6)。
6. 利用链(D6)#
6.1 链的定位#
插件回答"某个漏洞存不存在",链回答"这些漏洞串起来能走多远"。这是从「漏扫」到「渗透验证」的跃迁,也是安服交付里最有说服力的部分。
6.2 注册表与惰性导入#
chains/registry.py 用 name → (模块路径, 变量名) 的字典登记三条链:
_CHAIN_REGISTRY = {
"ruoyi_sql_to_rce": ("chains.ruoyi_sql_to_rce", "CHAIN"),
"ruoyi_defaultpw_to_webshell": ("chains.ruoyi_defaultpw_to_webshell", "CHAIN"),
"ruoyi_nacos_to_dbcreds": ("chains.ruoyi_nacos_to_dbcreds", "CHAIN"),
}
两个设计点:
- 惰性导入:
get_chain()里才importlib.import_module。不用--chain时零导入开销。 list_chains()不导入链模块,而是另存一份元信息字典(_META)。这样--chain-list极快,且避免"列个链还要把所有链模块的依赖都加载一遍"的副作用。代价是元信息有两处(list_chains的_META与链模块里的ChainDef),存在漂移风险。
register_chain() 允许第三方插件替换内置链(同名直接覆盖,不报重复注册错误)——这是刻意的扩展点。
6.3 链定义契约#
变量名 CHAIN 是 _CHAIN_REGISTRY 里第二个元素指定的,改名会静默失效(getattr 抛 AttributeError)。代码里有注释显式警告。
6.4 三条链#
| 链名 | 路径 | 严重度 | 影响版本 |
|---|---|---|---|
ruoyi_sql_to_rce | SQL 注入 → 文件读取配置 → 定时任务 RCE | high | >=4.0,<4.7 |
ruoyi_defaultpw_to_webshell | 默认口令 → 登录链 → 任意文件上传 → webshell | high | — |
ruoyi_nacos_to_dbcreds | Nacos 未授权 → 配置泄露 → 数据库凭证 | high | — |
ruoyi_defaultpw_to_webshell的描述里明确写了「上传 JSP 探针文件验证可执行性(非真实 webshell)」——链的产物是"证明可执行",不是"真的驻留后门"。这是合规边界的自觉。
6.5 逐行解剖 ruoyi_sql_to_rce#
步骤 1: sql_inject on_fail=abort ← 链路起点,失败则整链无意义
步骤 2: config_read depends_on=[sql_inject] on_fail=continue
步骤 3: job_rce depends_on=[sql_inject] on_fail=continue ← 只依赖步骤 1,不依赖步骤 2
三个关键决策:
(a) on_fail 的语义分层
| 值 | 含义 | 用在 |
|---|---|---|
abort | 整链终止 | 起点步骤(后续所有步骤都以它为前置) |
continue | 跳过依赖此步的支线,继续其他支线 | 支线步骤 |
fallback | 预留:fallback_steps 目前仅作"救援"——备用节点是链内普通节点,照常按拓扑序执行 | 暂无生产链使用 |
⚠️
fallback_steps不是条件调度:备用节点会在拓扑序上正常执行,on_fail=fallback只会在"备用节点被无关上游的 abort 误标记为跳过"时解除该标记(救援)。 真正的"仅当声明步骤失败才执行备用节点"语义尚未实现(见core/chain.py失败策略分支注释)。
(b) job_rce 只依赖 sql_inject,不依赖 config_read
注释写得很清楚:
RCE 验证仅依赖 sql_inject(而非 config_read):配置读取失败(continue)不阻塞该支线
如果写成 depends_on=["sql_inject", "config_read"],配置读取失败就会连带跳过 RCE 验证——丢掉了本来能拿到的结论。依赖边要表达"真正的前置条件",不是"执行顺序"。
(c) outputs 的敏感度前缀
outputs={"db_name": "extra:db_name"} # 附加事实,明文
outputs={"db_password": "secret:db_password", # 凭证类,脱敏
"redis_password": "secret:redis_password"}
secret: → 脱敏存储(报告里不出现明文口令);extra: → 明文保留。这个前缀约定在链定义注释里被显式声明为契约。
6.6 链步骤插件#
plugins/chain/ 下是链专用的步骤插件,不参与常规扫描(因此 discover_plugin_packages() 显式排除 chain):
| 文件 | 提供的步骤插件 |
|---|---|
sql_to_rce_steps.py | SQLInjectExtractPlugin / ConfigReadPlugin / JobRCEVerifyPlugin |
defaultpw_to_webshell_steps.py | 默认口令→上传 链的步骤 |
nacos_to_dbcreds_steps.py | Nacos→凭证 链的步骤 |
6.7 ChainEngine 执行机制#
core/chain.py 的执行流程:
_topological_sort() # 拓扑排序(ChainDef.validate() 已在加载期做过环检测)
↓ for step in 拓扑序
_evaluate_condition() # 条件分支(condition 不满足则跳过该步)
↓
_execute_step() # 实例化插件 → verify → 结果写入 ChainContext
↓ 失败时
_is_critical_failed() # 判定是否关键失败
_propagate_abort() # 按 on_fail 传播:abort 终止 / continue 跳过下游支线
↓ 全部结束
_aggregate_status() # 聚合链状态 → ChainResult
ChainContext 的 extract_outputs() 按 outputs 映射把插件结果里的字段抽成变量(如 db_name),供后续步骤引用;snapshot() 提供执行过程的可回溯视图。
ChainResult.to_scan_result() 把链结果转回标准 ScanResult,因此链的产出与普通插件的产出在报告里走同一条管线,无需特殊渲染。
7. data/ 数据资产#
| 文件 | 大小 | 用途 |
|---|---|---|
password.txt | 10 KB | 完整口令字典(--pass-level full) |
pass_top1000.txt | 9.5 KB | Top1000 口令(--pass-level top1000) |
pass_top100.txt | 0.7 KB | Top100 口令(--pass-level top100) |
ruoyi.txt | 15.5 KB | 若依路径字典(目录扫描) |
component_cve_map.json | 7.5 KB | E2 组件 → CVE 映射表 |
cve_offline.json | 5.1 KB | D32 离线 CVE 库(内网模式 --cve-offline) |
acceptance_baseline.json | 1.7 KB | 验收基线 |
acceptance_report.json / _fixed.json | 1.4 KB ×2 | 验收报告样例 |
scan_cache.db | 20 KB | D37 结果缓存(SQLite) |
tasks.db | 112 KB | D11 Web API 任务持久化(SQLite) |
字典分级由 config/settings.py 的 PASSWORD_DICT_BY_LEVEL 映射,--pass-level 在 orchestrator 第 3 步解析并注入当前扫描的 session.password_dict(并发隔离;爆破插件优先读取该属性,回退 settings.PASSWORD_DICT)。见 03 章 §3。
scan_cache.db/tasks.db是运行时产物,不应随仓库分发(已在.gitignore中)。
8. 写一个新插件(最短路径)#
- 在对应包下建文件,继承
PluginBase:from common.models import STATUS_CONFIRMED, STATUS_SAFE, STATUS_UNKNOWN from plugins.base import PluginBase class MyVulnPlugin(PluginBase): name = "某某漏洞" cve = "CVE-2025-XXXXX" severity = "high" category = "vuln" affected_versions = ">=4.2,<4.6" variant = "" cvss_vector = "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N" compliance = "等保2.0:8.1.3;OWASP:A03:2021" description = "..." fix = "..." fix_detail = "..." reproduce = "curl ..." def verify(self, target, session): try: r = session.get(target + "path") except Exception as e: return self._build_result(STATUS_UNKNOWN, evidence=str(e)) # 网络异常 → UNKNOWN if "关键字" in r.text: return self._build_result(STATUS_CONFIRMED, url=r.url, evidence="命中关键字") return self._build_result(STATUS_SAFE) - 在包
__init__.py的plugin_list里按严重度位置插入注册。 - 用
--plugin-check <path>校验、--plugin-list确认加载。
也可用 --plugin-new <name> 生成脚手架,--plugin-init <name> 生成模板。
外部插件无需改仓库:--plugin-path ./my_plugins/ 即可加载(模块名前缀自动隔离)。
9. 本章小结#
- 插件契约的价值集中在三点:类属性承载元信息、三态判定纪律、
enrich()幂等回填。 - 插件发现是"五条显式路径叠加",而非目录扫描——可控性与可审计性优先。
candidates()与resolve()的区分(是否过滤版本)是为版本矩阵服务的,容易误用。- 利用链是小型 DAG 引擎:
on_fail分层语义、依赖边表达真实前置条件、outputs用前缀区分敏感度——三个决策都直接影响链的结论质量。 plugins/jeecgboot是架构通用性的活证明:接新框架零改core/。
→ 下一章:05 · lib 工具库