03 · core 核心引擎#
这一章回答:一条扫描请求进来之后,
core/里的 24 个模块是怎么协作的?每个模块的边界在哪里?
1. core 层的定位#
core/ 是与 CLI/API 无关的纯引擎层:它不 print、不读 argparse、不碰 sys.exit。CLI 与 Web API 都通过同一个 ScanOrchestrator 驱动它,差异只体现在事件回调的落点上。
这条边界的价值在 orchestrator.py 的头部注释里写得很明确:
架构约束(红线):
- core/engine.py 零修改(通过 on_result 回调)
- core/router.py 零修改
- core/models.py 零修改
- ThreadPoolExecutor 使用 daemon 线程(避免测试/进程退出时后台线程阻塞)
「零修改」是接口稳定性契约:新增能力必须通过回调、插件、数据文件扩展,不能改这三个文件的签名。
2. 模块全景(24 个)#
按职责分为六组:
2.1 编排与执行#
| 模块 | 职责 | 关键符号 |
|---|---|---|
| orchestrator.py | 扫描编排器:CLI 与 API 共用主流程 | ScanOrchestrator ScanRequest ScanTask _DaemonThreadPoolExecutor |
| engine.py | 并发编排 + 令牌桶限速;单插件执行序列 | ScanEngine |
| loader.py | 插件动态发现与加载(内置/外部/entry_points) | load_plugins load_external_plugins discover_plugin_packages load_entry_point_plugins |
| chain.py | 漏洞利用链编排(DAG) | ChainDef ChainStep ChainEdge ChainContext ChainEngine ChainResult |
2.2 网络与会话#
| 模块 | 职责 | 关键符号 |
|---|---|---|
| session.py | 会话封装:Cookie/代理/重试/连接池/TLS/超时熔断 | SessionManager TargetUnresponsiveError _HostBreaker is_timeout_error |
| http.py | URL 归一化与可达性预检 | normalize_target join_url host_of split_target probe_reachable probe_http_responsive |
| portscan.py | 端口扫描 + Banner 识别(纯 socket,无 nmap 依赖) | PortScanner PortResult |
| proxy_server.py | HTTP/HTTPS 代理(被动扫描模式) | — |
| captcha_solver.py | 验证码识别(D3) | — |
| auth_chain.py | 若依登录链编排(D1) | — |
2.3 识别与路由#
| 模块 | 职责 | 关键符号 |
|---|---|---|
| fingerprint.py | 指纹识别接口 + 多 CMS 交叉判定 | Fingerprint FeatureBasedFingerprint RuoyiFingerprint detect_cms detect_waf detect_variant |
| fingerprint_features.py | 指纹特征库(数据驱动) | CMS_FEATURES VARIANT_FEATURES get_feature list_cms list_variants |
| waf_features.py | WAF 指纹特征库 | get_waf_names is_waf_blocked |
| ruoyi_versions.py | 若依版本指纹库与区间判定 | extract_version detect_version version_in_range build_version_matrix get_variant_api_prefixes |
| router.py | 指纹 → 插件包路由 | Router.candidates/resolve/resolve_by_name |
| cache.py | 指纹识别请求级缓存 | FingerprintCache |
2.4 结果处理#
| 模块 | 职责 | 关键符号 |
|---|---|---|
| dedup.py | 结果去重聚合 | fingerprint aggregate DedupReport AggregatedVuln |
| report.py | 报告渲染(HTML/JSON/CSV,纯标准库) | ReportBuilder BatchReport |
| report_pdf.py | PDF 报告(reportlab) | — |
| report_docx.py | Word 报告(python-docx) | — |
| report_xlsx.py | Excel 报告(openpyxl) | — |
| report_sarif.py | SARIF 2.1.0 报告(D22) | — |
2.5 服务端支撑#
| 模块 | 职责 | 关键符号 |
|---|---|---|
| task_registry.py | 任务状态管理 + WebSocket 推送桥 | TaskRegistry TaskRecord |
| storage.py | SQLite 持久层:任务历史 + 事件 + 定时任务 | Storage DEFAULT_DB_PATH |
2.6 共享基础与配置#
| 路径 | 职责 |
|---|---|
| common/models.py | ScanResult / FingerprintResult / ComponentVersionResult + 三态与严重度常量 |
| common/logger.py | 项目级日志(get_logger / setup_logging / set_quiet) |
| common/console.py | force_utf8_stdio() Windows 代码页兜底 |
| config/settings.py | 全局配置:超时/线程/限速/TLS/字典/报告目录/凭据 |
3. ScanOrchestrator._run() 九步主流程#
这是整个项目的主动脉。方法很长(约 450 行),但结构是线性的九步:
| 步 | 动作 | 产出 | 事件 |
|---|---|---|---|
| 1 | 端口扫描(req.portscan 时) | PortResult[] | portscan |
| 1b | D14 主动信息收集(爬虫/子域名/JS 提取) | recon_info | recon_start / recon / recon_error |
| 2 | 创建 SessionManager + ScanEngine | — | — |
| 2b | D26 认证注入 apply_auth_to_session | — | auth |
| 3 | 口令字典分级(pass_level → session.password_dict,扫描级注入) | — | — |
| 4 | 指纹识别 detect_cms(或 --cms 手动)→ Router.resolve | FingerprintResult + 插件类列表 | fingerprint |
| 5 | WAF 探测 detect_waf + 构建绕过协调器 + E2 组件检测 | waf_info / WafBypassCoordinator | waf / waf_bypass / component |
| 6 | 插件加载与过滤(5 条来源 + 模板过滤 + category 分组) | plugins_by_cat | plugins_loaded / template / plugin_fallback |
| 7 | 引擎执行(按 category 顺序调 engine.run) | all_results | category_start / result / progress |
| 8 | 报告生成(req.report_dir 非空时) | report_paths | report |
| 9 | 完成收尾 | task.status = "done" | status(done) / complete |
异常兜底:整个 try 块外层捕获后置 task.status = "failed",发 error + status(failed),并返回已收集的 task.results——部分结果不因异常丢失。
3.1 步骤 1b 的位置值得注意#
信息收集(爬虫/子域名)排在会话创建之前,且内部另建一个临时 session:
理由(代码注释):避免与主 session 状态污染。爬虫会带上大量非目标 Cookie / 走不同跳转链,混进主 session 会污染后续插件的请求基线。
3.2 步骤 4:手动 CMS 与自动识别是同一条出口#
if req.cms:
fp_result = FingerprintResult(cms=req.cms, version="", confidence=1.0, matched=["manual"])
all_plugins = router.resolve_by_name(req.cms)
else:
fp_result = detect_cms(target, session)
all_plugins = router.resolve(fp_result)
两条分支都产出「FingerprintResult + 插件类列表」,后续流程完全一致。matched=["manual"] 是可审计的标记——报告里能看出这次是手动指定还是自动识别。
3.3 步骤 5:WAF 绕过的三个分支#
if waf_type and bypass_mode in ("auto", "on"): # 识别到 WAF → 建协调器(含源站 IP 探测)
if not waf_type and bypass_mode == "on": # 没识别到但用户强制 on → 仍建(对未知 WAF 保留尝试)
return None # 其余(off / auto 且无 WAF)→ 不建
auto + 未识别 WAF 时返回 None,不产生任何额外请求——这是"默认零成本"的取舍。
3.4 步骤 6:插件来源的叠加顺序#
Router.resolve(cms) # 1. 按指纹路由(可能为空)
└─ 空 → load_plugins("plugins.ruoyi") fallback 并记 plugin_fallback 事件
load_plugins("plugins.common") # 2. 通用检测包(恒加载)
load_external_plugins(plugin_paths) # 3. --plugin-path 外部插件
load_user_installed_plugins() # 4. ~/.ruoyi-scan/plugins/(--plugin-update 安装)
load_entry_point_plugins() # 5. pip entry_points
load_nuclei_templates(...) # 6. nuclei YAML 模板
↓
req.plugins 过滤(API 指定子集)
↓
template 过滤(filter_plugins)
↓
按 category 分组 → plugins_by_cat
叠加而非替换:后加载的来源只能追加插件,不能覆盖前面的。这样「外部插件意外覆盖内置插件」这类事故从结构上被排除。除第 2 条外,每一条加载路径都包在 try/except 里,失败只 logger.debug 并继续——插件生态故障不能拖垮主扫描。
3.5 步骤 7:进度计算的口径#
分母只统计本次模式会跑的 category(p 模式不会把 brute 插件算进进度),所以百分比是真实的完成度而非虚高。
3.6 步骤 8:版本矩阵用「未过滤」候选集#
# 用「未按版本过滤」的候选集——否则 applicable 恒为真,对照表失去意义
version_matrix = build_version_matrix(fp_result.version, router.candidates(fp_result))
router.resolve() 已经按 affected_versions 过滤过插件了;如果直接拿过滤后的结果做矩阵,所有条目都会显示"适用",矩阵就没信息量了。因此矩阵刻意回到过滤前的候选集 candidates()。这是一个很容易写错、且错了也不会报错的细节。
4. ScanEngine:单插件执行序列#
core/engine.py 只有 132 行,是并发与限速的唯一实现处。
4.1 构造函数#
threads=1是默认值——默认串行。理由:扫描目标多为生产系统,默认并发可能触发风控或造成压力;要并发得显式--threads。rate=0表示不限速。
4.2 _exec() 的五步序列#
1. _rate_limit() # 令牌桶:时间戳滑动窗口
2. plugin = plugin_cls() # 实例化(每个插件独立实例,无状态共享)
3. res = plugin.verify(target, session) # 核心判定
4. WAF 绕过重试(若 verify 未确认且有协调器)
→ plugin.verify_with_bypass(...)
5. res.enrich() # 幂等回填元数据 + kind 归一化
except Exception → 兜底为 UNKNOWN(绝不吞成 SAFE)
第 5 步的 except 是全项目最重要的错误处理:任何未预期异常都产出 UNKNOWN,而不是让它冒泡或静默成 SAFE。三态纪律在引擎层被物理保证。
4.3 _rate_limit():sleep 必须在锁外#
with self._lock:
now = time.time()
self._timestamps = [t for t in self._timestamps if now - t < 1.0]
if len(self._timestamps) >= self._rate:
wait = 1.0 - (now - self._timestamps[0])
else:
self._timestamps.append(now)
# ↓ sleep 在锁外
if wait > 0:
time.sleep(wait)
若把 sleep 放在锁内,多线程会退化为串行——每个线程持锁睡觉,等于全局停顿。这是限速器最常见的实现陷阱,此处有注释显式标出。
5. SessionManager:网络层的所有策略集中地#
core/session.py 承载 4 类策略,是唯一被允许直接构造 requests 请求的地方:
5.1 连接池与重试#
HTTPAdapter(pool_connections=..., pool_maxsize=..., max_retries=Retry(
total=2, backoff_factor=0.3, status_forcelist=(502, 503, 504)
))
只对 5xx 网关类错误重试(502/503/504),不对 4xx 重试——4xx 是确定的业务响应,重试只会浪费预算并可能触发风控。
5.2 TLS 策略#
读 settings.VERIFY_TLS(默认 False)。内网自签名证书是若依部署的常态,默认不校验是面向真实使用场景的选择,反向需求由 --verify-tls 显式打开。
5.3 超时熔断:_HostBreaker#
按 host 分键统计连续超时次数,达到阈值后抛 TargetUnresponsiveError(继承 requests.exceptions.ConnectionError,因此对既有 except requests.RequestException 代码天然兼容)。
配套工具:
| 符号 | 作用 |
|---|---|
is_timeout_error(exc) | 沿 reason → __cause__ → __context__ 链判定是否超时类异常 |
_HOST_BREAKERS | 模块级字典(跨 session 实例共享熔断状态) |
reset_host_breakers() | 测试隔离用 |
为什么熔断按 host 而不是全局:多目标批量扫描时,一个死目标不应影响其他目标的熔断统计。
5.4 request_count#
SessionManager 自己统计请求数,供报告与 API 的 request_count 字段使用。指标口径统一在会话层,避免各插件自报。
6. 指纹识别:数据驱动 + 两级防误判#
core/fingerprint.py 的设计核心是把知识放进数据文件、把逻辑放进通用引擎。
6.1 加权打分#
强特征(login_keywords / strong_paths / favicon 命中)→ +weight_strong(默认 0.5)/个
弱特征(weak_keywords / favicon 不匹配) → +weight_weak(默认 0.2)/个
confidence = min(1.0, 强×w_strong + 弱×w_weak)
6.2 D5 弱特征阈值:0.4#
weak_confidence = weak_hits * w_weak
if strong_hits > 0 or weak_confidence >= 0.4:
return FingerprintResult(cms=self.cms, ...)
return FingerprintResult(cms="", ...) # 未识别
代码注释写明了边界用例:
含"若依"二字的非若依页面(单弱特征)不应误判。
单个弱特征给 0.2,不到 0.4 阈值;需要至少 2 个弱特征或 1 个强特征才判为该 CMS。
6.3 软 404 排除#
expect="any" 的强特征路径探测里有一层额外校验:
理由(代码注释):SPA 前端兜底、静态站 catch-all、部分 CDN/WAF 会对任意路径返回与首页完全相同的内容。不排除就会把 wordpress/django/nginx 站点误判为若依变体。
6.4 detect_cms 的组合逻辑#
FingerprintCache(session) # 多 CMS 遍历共享根/favicon 响应
↓
for cms in list_cms(): 取 confidence 最高者
↓
if best.cms == "ruoyi":
detect_variant(...) # E1 变体细分
extract_version(...) # D2 版本号(优先从 cache 里已抓到的响应提取)
变体与版本探测都优先走 cache.get():如果根路径或 /login 已经被指纹流程请求过,就直接复用响应提取版本,不再多发请求。
6.5 变体识别的正负特征#
detect_variant() 对每个变体:
- 先查
negative_paths——任一命中(200)即排除该变体; - 再查
strong_paths,按expect(json/image/any)判定; - 取强特征命中数最多者;全未命中返回
''(通用版)。
「先排除再确认」的顺序很关键:正向路径可能被其他变体共享,负向路径才是区分度所在。
7. Router:指纹 → 插件包#
core/router.py 只有 83 行,但它是插件体系的入口闸门。
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) | 在 candidates 基础上按 affected_versions 过滤版本 |
resolve_by_name(cms) | 手动指定 CMS 时用(无版本信息,不过滤版本) |
所有 7 个若依变体共享 plugins.ruoyi——版本/变体差异靠插件自身的 variant / affected_versions 属性表达,而不是拆成 7 个包。这是控制包数量的取舍。
8. 去重聚合:dedup.py#
同一目标、同一端点、同一漏洞类型、同一 payload 类的结果聚合为一条 AggregatedVuln,证据合并(_merge_evidence)、extra 合并(_merge_extra)。
辅助函数 _normalize_endpoint / _strip_parens 负责把「同一端点的不同写法」(带括号、query 顺序不同)归一化到同一 key,否则去重会漏。
DedupReport 提供聚合视图;CLI/API 的 --no-dedup 可关闭。
9. chain.py:DAG 利用链#
core/chain.py 是一个小型工作流引擎(约 400 行),实现拓扑执行 + 条件分支 + 失败策略。
| 类 | 职责 |
|---|---|
ChainStep | 一个步骤:id / plugin_cls / depends_on / on_fail / outputs / condition |
ChainEdge | 步骤间连线(可作为步骤的补充表达) |
ChainDef | 链定义:name / display_name / severity / affected_versions / steps;validate() + _detect_cycle() 做环检测 |
ChainContext | 运行时上下文:set_result / extract_outputs / snapshot |
ChainResult | 链结果聚合,to_scan_result() 转回标准 ScanResult |
ChainEngine | 执行器:_topological_sort → _evaluate_condition → _execute_step → _is_critical_failed → _propagate_abort → _aggregate_status |
三个设计要点:
- 环检测在
validate()里做,不在执行时。链定义是静态资产,错误应该在加载期暴露。 on_fail三值语义:abort(整链终止)/continue(跳过依赖此步的支线)/ —— 由_propagate_abort()统一传播。outputs用前缀区分敏感度:secret:前缀 → 凭证类,脱敏存储;extra:→ 附加事实,明文保留。前缀约定见 chains/ruoyi_sql_to_rce.py。
细节见 04 章 · 插件与利用链。
10. 报告层#
10.1 ReportBuilder(core/report.py)#
| 方法 | 作用 |
|---|---|
_effective_results | 按 dedup 开关决定用原始结果还是去重后的结果 |
risk_distribution | 风险分布统计 |
confirmed_results | 仅 CONFIRMED 结果 |
compliance_summary / render_compliance_html | 合规映射汇总(等保/CWE→OWASP) |
sorted_results | 排序(严重度优先) |
to_dict / to_json / to_csv | 序列化 |
_render_risk_donut_svg | 内联 SVG 环形图(不引前端图表库) |
to_html | HTML 报告 |
render_all | 一次性渲染全部格式,返回路径列表 |
BatchReport 是批量扫描版本,对应 _render_targets_bar_svg(目标维度柱状图)。
HTML 报告用内联 SVG 画图:零第三方依赖、离线可看、单文件可发。这是「安服交付物要能直接微信发出去」这一真实需求的产物。
10.2 可选格式走独立模块#
report_pdf.py / report_docx.py / report_xlsx.py / report_sarif.py 各自独立,依赖 reportlab / python-docx / openpyxl。它们在 pyproject.toml 里属于 [report] 可选组——不装这些包,HTML/JSON/CSV 依旧可用。
11. 服务端支撑两个模块#
11.1 TaskRegistry(core/task_registry.py)#
内存中的任务表 + WebSocket 推送桥。
| 方法 | 作用 |
|---|---|
bind_loop / unbind_loop | 绑定/解绑 asyncio loop(跨线程推送的前提) |
register / update_task_dict / get / list | 任务状态读写 |
notify / unsubscribe | 向订阅者推送事件 |
get_history | 事件历史(WS 重连补播用) |
cleanup_expired / task_count | 清理与统计 |
restore_from_storage | 启动时从 SQLite 恢复 |
bind_loop 的存在说明了架构约束:扫描线程是普通线程,而 WS 推送必须在 asyncio loop 上执行,因此需要 loop.call_soon_threadsafe 这类桥接。绑定时机在 FastAPI 的 lifespan startup。
11.2 Storage(core/storage.py)#
SQLite 持久层,三张逻辑表(任务 / 事件 / 定时任务):
save_task get_task list_tasks delete_task cleanup_expired count_tasks · save_event get_events · save_schedule list_schedules get_schedule delete_schedule
默认路径 data/tasks.db(DEFAULT_DB_PATH),可由 --db-path 覆盖。
12. _DaemonThreadPoolExecutor:一个 CI 挂起 bug 的沉淀#
这是 core/ 里最"有故事"的 20 行代码:
class _DaemonThreadPoolExecutor(ThreadPoolExecutor):
def _adjust_thread_count(self) -> None:
# ... 创建 daemon=True 线程并启动
self._threads.add(t)
# 不注册到 _threads_queues,避免 _python_exit atexit handler join daemon 线程
问题:标准 ThreadPoolExecutor 会把线程注册进 concurrent.futures.thread._threads_queues,而 _python_exit atexit 处理器会 join 其中所有线程——即使是 daemon 线程。在 API 测试里,后台扫描线程会让 pytest 无法退出,表现为 CI 挂起。
解法:子类重写 _adjust_thread_count,跳过注册这一步。daemon 标志随之生效,进程退出时线程被直接终止。
代价是放弃了标准库的优雅关闭保证,因此 shutdown() 里补上了显式策略:
pool.shutdown(wait=False, cancel_futures=True)
for t in list(pool._threads):
t.join(timeout=5) # 给 5s 让运行中的任务收尾,超时则放弃
13. mypy 严格度分层#
pyproject.toml 对 core/ 与 common/ 开了 --strict,而 lib/ / api/ 是 ignore_errors:
[[tool.mypy.overrides]]
module = ["common.*", "core.*"]
strict = true
[[tool.mypy.overrides]]
module = ["lib.*", "api.*"]
ignore_errors = true
这是渐进式类型化的取舍:core/ 是接口契约所在(ScanResult、PluginBase、ChainDef 的字段签名一改就满地开花),值得付出 strict 成本;lib/ 是工具集与外部集成密集区(动态 JSON、LLM 响应、第三方 SDK),strict 的收益低于摩擦。
14. 本章小结#
core/是无 CLI/API 依赖的引擎层,三个文件的签名被当作红线契约保护。ScanOrchestrator._run()九步是主动脉;所有能力(信息收集、组件检测、WAF 绕过、模板、外部插件)都是在固定位置插入的可选段,主流程顺序不变。- 三态纪律在引擎层由
except → UNKNOWN物理保证,不依赖各插件的自觉。 - 网络策略全部收在
SessionManager,包括限速器(锁外 sleep)、超时熔断(按 host)、TLS(默认不校验)。 - 指纹与路由是数据驱动的:新增 CMS 改数据文件,
fingerprint.py/router.py不改。
→ 下一章:04 · 插件体系与利用链