Java漏洞扫描系统实战:Spring Boot插件化检测与报告导出
发布时间:2026/11/2 2:05:38 锦皓数字建站

简介这是一份面向高校计算机与网络安全专业毕业设计场景的 Java 漏洞扫描系统项目包适合需要完成课题开发、论文实验或安全工具实践的学生与初中级开发者参考。压缩包共 927 个文件约 33.11MB主体由 604 个 nse 与 146 个 lua 扫描脚本、30 个 class 与 12 个 java 源码、19 个 jar 依赖以及 xml 配置、txt 说明、exe/dll 运行组件等构成较完整地呈现了扫描规则库、程序实现和运行环境的组织方式。资源已有 205 人学习读者可据此梳理网络扫描、漏洞检测、结果报告等模块的目录结构观察 nse/lua 脚本与 Java 程序的配合关系并借鉴 SQL 注入、XSS 等常见漏洞的检测思路、参数配置、并发处理与依赖打包方式作为毕业设计框架搭建、功能扩展和二次开发的起点。对缺少完整项目经验的学生而言这类包含源码、脚本库和运行组件的包便于对照理解扫描器从目标发现、规则匹配到报告输出的实现链路也能为答辩演示提供较完整的素材。1. 从一份 .zip 说起这套 Java 漏洞扫描系统解决的是“扫完能交差”的问题去年帮一个做外包的朋友看答辩材料他做的题目是基于 Java 的漏洞扫描系统代码跑起来之后页面上一片空白控制台只打印了一行crawl finished, urls1。问题不在于他不会用工具而在于大多数现成的扫描器要么是重量级商业产品要么是命令行小脚本中间那层能录入目标、能调度任务、能沉淀报告的工程壳子没人给他。这份 Java 漏洞扫描系统的源码包补的正是这一层它把站点爬取、插件化检测、规则配置、风险分级和报告导出串成一条完整链路用 Spring Boot 起服务前端点几下就能发一次扫描任务。适合两类人一类是拿它当毕业设计或课程项目的底座改改插件就能写出自己的工作量另一类是中小团队想有个内网自查的小工具不想为一周一次的自查去采购一套平台。往下读我会先拆工程骨架再逐段讲核心代码最后落在一个能实际动手的改造点上。2. 技术栈与工程骨架一次扫描请求从 URL 到报告的完整链路2.1 技术选型为什么是 Spring Boot Jsoup 自研规则引擎看一个扫描系统的技术栈重点不在框架新不新而在哪一层该由谁来干。这套项目的选型思路相当克制Web 层用 Spring Boot把任务提交、进度查询、报告下载做成 REST 接口爬虫层用 OkHttp 发请求、Jsoup 解析 DOM这两个组合几乎是 Java 侧做站点遍历的默认答案前者负责连接复用和超时控制后者负责把 HTML 变成能 xpath、能选 class 的文档对象检测层没有引入任何重型扫描框架而是自己定义了一套插件接口每个检测项就是一个实现类靠 Spring 的容器收集成ListScanPlugin规则层用 YAML 描述 payload 和匹配条件避免改一个检测点就要重新编译持久层用 MyBatis-Plus 或者原生 MyBatis四张表撑起整个业务报告层用 Thymeleaf 渲染 HTML 模板再交给 Flying Saucer 之类的库转 PDF。值得单独说的是自研规则引擎这个选择。很多同学第一反应是上 Drools但对扫描场景来说规则形态非常单一给一个 URL 拼参数、发请求、拿响应、用正则或状态码判断命中。这种结构用 Drools 属于杀鸡用牛刀规则文件写起来比 Java 还长。项目里的做法是定义一个小的规则模型字段就是id / match / payload / method / expression / risk加载成一堆对象放内存匹配时逐条过。代价是表达力有限好处是新人半小时能看懂全部逻辑。数据库方面MySQL 5.7 及以上都行字符集建议utf8mb4因为扫描结果里经常混进各种奇怪字符用utf8会在入库时截断甚至报错这个坑我在好几个项目里都踩过。依赖管理的常见做法是这样一套!-- pom.xml 关键依赖版本按自己 JDK 对齐 -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 爬虫发请求 解析 DOM -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency !-- 持久层 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency !-- 报告导出模板渲染 PDF -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency /dependencies这段依赖清单里Jsoup 和 OkHttp 是爬虫的两条腿MyBatis-Plus 省掉了大部分单表 CRUD 的 XMLThymeleaf 负责把扫描结果套进报告模板。参数上要注意 OkHttp 的版本要和 Kotlin 运行时对齐如果你把项目降级到 JDK 8 环境OkHttp 建议退到 3.x 分支否则启动时会报NoClassDefFoundError。2.2 目录结构七个包各自守哪一段链路把 zip 解压后src/main/java下的包结构基本决定了你后续改代码的动线。项目大致是这样的分层src/main/java/com/example/scanner ├── ScannerApplication.java // 启动类 ├── common/ // 通用统一响应、异常、常量、工具 │ ├── R.java │ ├── GlobalExceptionHandler.java │ └── HttpUtil.java ├── crawler/ // 站点遍历 │ ├── SiteCrawler.java │ ├── UrlFilter.java │ └── CrawlResult.java ├── plugin/ // 检测能力 │ ├── ScanPlugin.java // 插件接口 │ ├── impl/ // 各检测项实现 │ │ ├── SqlInjectionPlugin.java │ │ ├── XssPlugin.java │ │ ├── SensitiveFilePlugin.java │ │ └── WeakPasswordPlugin.java │ └── PluginRegistry.java ├── rule/ // 规则加载与匹配 │ ├── RuleLoader.java │ ├── Rule.java │ └── RuleMatcher.java ├── engine/ // 调度把爬虫和插件串起来 │ ├── ScanEngine.java │ └── TaskExecutorConfig.java ├── report/ // 报告 │ ├── ReportService.java │ └── template/report.html └── web/ // 接口层 ├── TaskController.java └── ReportController.java这个划分的价值在于你改检测逻辑时只碰plugin/impl改扫描范围时只碰crawler两者通过engine里的ScanEngine汇合。很多人拿开源扫描器改不动就是因为所有逻辑挤在一个Scanner.java里爬虫、发包、判断、入库互相穿插改一处崩三处。crawler包里UrlFilter单独抽出来是个细节它负责三件事过滤掉javascript:、mailto:这种非 HTTP 链接按域名白名单限制不爬到外站去对 URL 做归一化把?id1a2和?a2id1当成同一个地址。归一化不做你会发现同一个页面被扫几十遍任务时间直接翻倍。engine包承担调度职责ScanEngine里通常是一个固定大小的线程池把爬出来的 URL 列表切成批次丢给插件并发执行同时维护一个AtomicInteger记录已完成数供前端轮询进度。线程池大小不要凭感觉写Runtime.getRuntime().availableProcessors() * 2是个起点但对目标站点来说并发太高会被限流甚至封 IP所以配置里一般还要留一个threadPool项让人手工调。2.3 数据模型四张表撑起任务、资产、漏洞与规则扫描系统说到底是在记录四件事我扫了谁、扫到哪些地址、发现了什么问题、用的是什么规则。对应四张表表名作用关键字段scan_task一次扫描任务id、target_url、status、progress、start_time、end_timescan_url爬取到的资产地址id、task_id、url、method、depth、status_codevuln_record命中的漏洞id、task_id、url、plugin_id、risk_level、evidence、create_timerule_info规则库id、rule_key、payload、expression、risk_level、enabledscan_task.status我用枚举值 0/1/2/3 表示待执行、运行中、完成、失败前端按这个值切换按钮状态。vuln_record.evidence存原始证据比如响应片段、报错回显这一列一定要给text类型varchar(255)装不下数据库报错堆栈我见过有人把它设成 255结果 SQL 注入的报错信息全被截断报告里只剩半行答辩老师一问就露馅。CREATE TABLE vuln_record ( id bigint NOT NULL AUTO_INCREMENT, task_id bigint NOT NULL COMMENT 所属任务, url varchar(1024) NOT NULL COMMENT 命中地址, plugin_id varchar(64) NOT NULL COMMENT 检测插件标识, risk_level tinyint NOT NULL COMMENT 1低 2中 3高 4严重, title varchar(255) DEFAULT NULL, evidence text COMMENT 响应证据务必用 text, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_task (task_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表这段没什么玄学唯一要强调的是url长度给到 1024站点带一长串参数的地址很常见255 会直接抛出Data too long。idx_task这个索引建上去之后报告页按任务查漏洞的响应会从秒级降到毫秒级数据量上万之后差别很明显。3. 核心模块拆解爬虫、插件、规则匹配与报告3.1 站点爬取队列 去重 深度控制爬虫是整个系统的入口写不好后面全白搭。项目里用的是最朴素的 BFS一个阻塞队列存待访问地址一个ConcurrentHashMap.newKeySet()做已访问集合循环取地址、发请求、解析出新的链接再塞回队列直到队列空或者达到配置上限。Component public class SiteCrawler { private final OkHttpClient client new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .followRedirects(false) // 跳转交给业务层判断便于记录 30x 链路 .build(); private final SetString visited ConcurrentHashMap.newKeySet(); private final BlockingQueueString queue new LinkedBlockingQueue(); /** * param startUrl 种子地址 * param maxDepth 最大爬取深度0 表示只扫种子页 * param maxUrls 单任务最多抓取的地址数防止跑飞 * param host 限定域名跨域链接一律丢弃 */ public ListCrawlResult crawl(String startUrl, int maxDepth, int maxUrls, String host) { ListCrawlResult results new ArrayList(); queue.offer(startUrl #0); visited.add(normalize(startUrl)); while (!queue.isEmpty() results.size() maxUrls) { String[] parts queue.poll().split(#); String url parts[0]; int depth Integer.parseInt(parts[1]); CrawlResult r fetch(url); if (r null) continue; results.add(r); if (depth maxDepth) continue; for (String link : extractLinks(r.getHtml(), url)) { // 归一化后再判重避免 ?a1b2 与 ?b2a1 被当成两个页面 String key normalize(link); if (visited.add(key) host.equals(URI.create(link).getHost())) { queue.offer(link # (depth 1)); } } } return results; } private CrawlResult fetch(String url) { Request req new Request.Builder().url(url).get().build(); try (Response resp client.newCall(req).execute()) { String body resp.body() null ? : resp.body().string(); return new CrawlResult(url, resp.code(), body); } catch (IOException e) { return null; // 单条失败不影响整体任务 } } }这段代码有三个参数值得单独说。maxDepth控制广度一般给 3 就够再深基本都是分页和详情页扫了价值不大maxUrls是刹车片站点带日历或者 ID 遍历的时候不加这个限制能爬出几万个地址任务跑到第二天早上还没结束host做同域限制跨站链接一律不进队列这条既是性能考虑也是合规边界——只处理授权范围内的资产。followRedirects(false)是个容易被忽略的设置。默认跟随跳转虽然省事但你会丢掉 302 这一层信息而很多未授权访问问题恰恰藏在跳转链上。关掉之后自己处理状态码把 301/302 的Location也当一条资产记录进去报告会完整很多。normalize()方法里做的事不复杂去掉 fragment、参数按 key 排序、路径末尾多余的斜杠统一。看着琐碎但它决定了后面扫描的重复率。上线前可以打一行日志统计visited.size()和results.size()的比值如果去重后数量只有原始的十分之一说明站点参数组合极多这时候更适合把参数按 key 聚合扫描而不是每个组合都单独发请求。3.2 检测插件一个接口管住所有扫描能力插件层是整个系统最值得借鉴的设计。项目定了一个极简接口所有检测项实现它Spring 启动时自动收集成列表扫描引擎遍历执行。public interface ScanPlugin { /** 插件唯一标识入库和规则绑定时用 */ String id(); /** 展示名称报告里显示 */ String name(); /** 基础风险等级具体命中时还可以动态调整 */ RiskLevel baseRisk(); /** * 预判这个地址值不值得跑当前插件。 * 比如只对带参数的 URL 跑注入检测能省掉一大半无效请求。 */ boolean match(ScanContext ctx); /** 真正执行检测 */ PluginResult scan(ScanContext ctx); }match这个方法是性能关键。以 SQL 注入插件为例它的实现通常是ctx.getUrl().contains()因为一个没有任何参数的静态页面去测注入纯属浪费。文件敏感插件则相反它只关心路径判断逻辑是拿后缀名和一堆字典做比对。把要不要跑和怎么跑分开扫描引擎就能先做一轮廉价筛选再对候选地址发请求整体耗时能降一半以上。ScanContext里装的是当前待测地址的上下文Data public class ScanContext { private Long taskId; private String url; private String method; // GET / POST private MapString, String params; // 已解析的参数键值 private String cookie; // 登录态扫描后台页面时必需 private int timeoutMs; }cookie这个字段很多人一开始不加结果扫出来的全是登录页一个漏洞没有。加上之后扫描引擎在任务创建时接收一段 Cookie 字符串透传到每个插件里才能覆盖到后台功能。注意这只是用来扫自己系统或者明确授权的目标别拿去对别人的站点做无差别探测。写一个具体插件看看完整形态以敏感文件探测为例Component public class SensitiveFilePlugin implements ScanPlugin { // 常见泄露路径实际项目可以放配置或数据库 private static final ListString PATHS Arrays.asList( /.git/config, /backup.zip, /WEB-INF/web.xml, /actuator/env ); Override public String id() { return sensitive-file; } Override public String name() { return 敏感文件泄露; } Override public RiskLevel baseRisk() { return RiskLevel.HIGH; } Override public boolean match(ScanContext ctx) { // 只在站点根路径跑一次即可避免每个子页面重复探测 return /.equals(URI.create(ctx.getUrl()).getPath()); } Override public PluginResult scan(ScanContext ctx) { String base ctx.getUrl().replaceAll(/$, ); for (String path : PATHS) { String target base path; int code HttpUtil.head(target, ctx.getTimeoutMs()); // 200 才算命中403 说明存在但被拦也值得记录 if (code 200 || code 403) { return PluginResult.hit(this, target, status code, RiskLevel.HIGH); } } return PluginResult.miss(this); } }这里的取舍很典型match限定只在根路径执行scan里对四个路径各发一次 HEAD 请求状态码 200 说明文件真能下载403 说明文件存在但被访问控制挡住——后者严格说不算漏洞但值得记一笔让人去确认。HEAD而不是GET是因为文件可能很大只取头信息就够了省流量也省时间。timeoutMs从上下文来而不是写死是为了配合配置里的全局超时扫描内网慢速服务时可以整体调大。PluginRegistry负责按 id 取插件报告里展示的插件名、风险等级都从这里查避免前端硬编码字符串。这类注册表模式在扫描系统里很常见好处是新增插件完全不用改引擎代码加个Component就自动生效。3.3 规则加载与匹配把 payload 从代码里挪出去如果所有 payload 都写死在 Java 里加一条检测规则就要重新编译打包这在实操中很难受。项目的做法是把规则抽到 YAML# rules/sql-injection.yaml - key: sqli-error-based name: 报错型注入 method: GET payload: AND 11 -- # 响应中命中任一关键字即判定 expression: SQL syntax|ORA-|MySQLSyntaxErrorException|Unclosed quotation risk: HIGH - key: sqli-bool-based name: 布尔盲注 method: GET payload: AND 11 compare: AND 12 # 两次响应长度差超过阈值视为命中 expression: lenDiff50 risk: HIGHRuleLoader在启动时扫rules/目录把 YAML 解析成Rule对象放进内存 MapRuleMatcher负责一轮一轮比对。匹配分成两类关键字匹配和差异比较。前者简单把响应体contains一遍正则后者需要发两次请求一次真 payload、一次假 payload如果两次响应长度差超过阈值说明条件影响了查询结果这就是布尔盲注的典型信号。public class RuleMatcher { /** * param rule 规则定义 * param resp 真 payload 的响应体 * param ctrl 对照 payload 的响应体关键字型规则可传 null */ public boolean match(Rule rule, String resp, String ctrl) { String expr rule.getExpression(); if (expr.startsWith(lenDiff)) { int threshold Integer.parseInt(expr.split()[1]); return Math.abs(resp.length() - ctrl.length()) threshold; } // 剩余情况按正则匹配避免手写 contains 漏掉大小写差异 return Pattern.compile(expr, Pattern.CASE_INSENSITIVE) .matcher(resp).find(); } }lenDiff50这个阈值不是拍脑袋来的页面本身有动态时间戳、随机 token 会导致长度小幅抖动阈值定得太小会天天误报定太大又会漏掉真问题。我的经验是先在目标站点上跑一轮空 payload 建立基线看正常页面长度波动范围再往上加 30 左右。这个数字写进配置而不是写死在代码里不同站点好调。RuleLoader还要处理一个现实问题规则文件写错是常态。YAML 缩进多一个空格就解析失败如果直接抛异常会拖垮整个应用启动。常见做法是 try-catch 包住单条规则的解析失败的规则记 error 日志并跳过让系统能起来再让人去修文件。这一点我在实际项目里吃过亏一个逗号写错导致整站启动失败排查了半小时才发现是规则文件的问题。3.4 报告生成风险分级与导出检测跑完之后数据散在vuln_record表里报告层要做三件事按风险等级排序、按 URL 聚合去重、渲染成能看的文档。风险分级用的是四级制严重、高、中、低。分级依据主要看插件的基础等级再叠加一点上下文修正。比如敏感文件泄露如果是.git/config能直接下载升到严重如果是403降一级。SQL 注入永远是严重级XSS 根据是否反射到页面上判定。聚合这一步容易被跳过。同一类问题在一个页面的五个参数上都命中如果原样列出来报告会有几十条重复读的人直接崩溃。常见做法是按url plugin_id分组组内保留第一条作为详情其余记录在affected字段里计数报告上只显示该页面 5 处参数受影响。public ReportVO build(Long taskId) { ListVulnRecord all vulnMapper.selectByTaskId(taskId); // 按 url plugin 分组组内合并成一条附带受影响数量 MapString, ListVulnRecord grouped all.stream() .collect(Collectors.groupingBy(v - v.getUrl() # v.getPluginId())); ListReportItem items grouped.values().stream().map(list - { VulnRecord first list.get(0); ReportItem item new ReportItem(); item.setTitle(first.getTitle()); item.setUrl(first.getUrl()); item.setRisk(first.getRiskLevel()); item.setEvidence(first.getEvidence()); item.setAffected(list.size()); // 同页同类的命中总数 return item; }).sorted(Comparator.comparing(ReportItem::getRisk).reversed()) .collect(Collectors.toList()); return new ReportVO(taskId, items, countByRisk(items)); }groupingBy的 key 用url#pluginId拼接是为了同时满足同页面和同类型两个条件。affected字段在报告页展示成受影响参数 5 个比罗列五条一模一样的信息有用得多。最后按风险倒序排严重的排前面这个排序看似小事但报告是给人看的第一屏就得把最要紧的问题怼到眼前。导出 PDF 时用 Thymeleaf 渲染 HTML 再转注意中文字体模板里必须显式指定一个系统里有的字体否则导出来全是方块这个坑几乎所有做 Java 报告导出的人都踩过。4. 本地跑通环境准备、配置项与一次完整扫描4.1 环境与依赖JDK、MySQL、Maven 三件套准备工作没有想象中复杂需要的东西就三样JDK 8 或 11项目按 8 写的话别硬上 17某些反射相关的库会报模块访问错误、MySQL 5.7、Maven 3.6。IDE 用 IDEA 或 Eclipse 都行导入时选 Maven 项目让它自己下依赖。数据库初始化有两步建库建表、改连接配置。建库语句给个最简的CREATE DATABASE vuln_scanner DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集一定写utf8mb4前面说过原因扫描证据里有各种奇怪字符。建完库把项目里resources/sql/init.sql执行一遍四张表就齐了。如果包里没有这个文件按第 2.3 节的建表语句自己补字段对齐就行。Maven 依赖下载慢的话在settings.xml里配个国内镜像这一步不算技术活但能省十分钟。依赖下完先跑一次mvn clean package -DskipTests能打出 jar 说明依赖没问题再去配置数据库。4.2 配置文件哪些参数决定扫描的脾气application.yml里除了数据库连接还有一组scan开头的自定义配置这组参数直接决定扫描跑起来是什么性格server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/vuln_scanner?useUnicodetruecharacterEncodingutf8mb4 username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver scan: max-depth: 3 # 爬取深度超过 3 层基本都是分页 max-urls: 2000 # 单任务地址上限刹车片 thread-pool: 8 # 并发线程数内网可调高公网调低 timeout-ms: 8000 # 单请求超时慢站点适当加大 safe-mode: true # 安全模式只做无害探测不发破坏性 payload user-agent: Mozilla/5.0 (compatible; SelfCheckBot/1.0)几个参数的含义值得掰开讲。thread-pool给 8 是个保守值内网自查可以开到 20但如果目标是线上系统并发太高会被 WAF 拦甚至触发限流把正常用户也挡在外面所以要么调低要么放在业务低峰期跑。timeout-ms设 8000 是因为很多内网系统响应慢3 秒超时会导致大量请求被判失败扫描结果全是连接超时看着像没扫到东西。safe-mode这个开关建议默认开着它会把涉及写操作、删除操作的 payload 全部跳过只保留读取类探测跑自查的时候心里踏实。user-agent单独配一项是为了在目标系统的访问日志里能一眼认出这是扫描流量方便运维对照排查也算一种基本的礼貌。4.3 启动与首次扫描从命令行到页面配置改完启动方式两种IDE 里直接跑ScannerApplication的 main 方法或者命令行# 打包后启动指定配置文件位置方便改参数不重新打包 java -jar scanner.jar --spring.config.locationfile:./config/application.yml用外部配置文件是个好习惯参数调整不用重新打包部署到别的机器也能快速适配。启动日志里看到Tomcat started on port(s): 8080就起来了浏览器打开http://localhost:8080进主页。第一次扫描建议拿自己本地搭的模拟靶站练手别一上来就填线上域名。流程是新建任务 → 填目标地址 → 选择检测插件默认全选→ 提交 → 列表页看进度条 → 完成后点查看报告。进度条是靠前端定时轮询GET /api/task/{id}/progress实现的返回的是已完成 URL 数除以总数。第一次跑常见的情况是进度卡在 0% 不动。先看后端日志有没有爬虫输出如果没有多半是种子地址填的时候少了http://前缀OkHttp 解析 URL 直接抛异常被 catch 吞掉了。加上协议头再试。4.4 结果怎么读报告里每一栏代表什么报告页从上往下一般是四块概览、风险分布、明细列表、附录。概览显示目标、扫描时长、地址总数、漏洞总数。风险分布是个简单的条形图按严重/高/中/低四档统计。明细列表是最有价值的部分每条包含问题名称、风险等级、命中地址、证据片段、修复建议。证据片段这一栏要重点看。它存的是响应里触发规则的那一段原文比如数据库报错的前两行或者 XSS payload 被原样输出的位置。判断误报基本靠它如果证据里只是一个正常的页面结构说明规则匹配得太宽如果真能看到SQL syntax这种字样那就是实打实的问题。阅读顺序建议按风险从高到低先把严重级处理完。同一类问题在多个页面出现时列表里合并成一条并标注受影响数量点开能看到具体地址列表。修复建议是模板化的文字准确度有限当参考就行具体怎么改还得看业务代码。报告支持导出 HTML 和 PDF导出前记得在模板里确认字体中文乱码基本都出在这一步。5. 避坑与排查扫描跑不通、结果不准的五个高频问题5.1 启动阶段连不上库、端口占用、依赖冲突现象一启动报Communications link failure连接数据库失败。原因通常是三个MySQL 没启动、连接串里的时区参数缺失、账号没有远程访问权限。解决顺序是先telnet 127.0.0.1 3306确认端口通再在 JDBC URL 后面补serverTimezoneAsia/ShanghaiuseSSLfalse最后确认root账号允许从本机登录。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver写成老版本的com.mysql.jdbc.Driver会报找不到驱动这个错日志里写得很清楚但很多人不看日志直接搜反而绕远路。现象二Port 8080 was already in use。上一个进程没退干净或者本机装了别的 Web 服务。lsof -i:8080Windows 用netstat -ano | findstr 8080找到 PID 杀掉或者在配置里改成 8081。改端口是更省事的做法但注意前端如果有硬编码的接口地址得一并改。现象三NoSuchMethodError或者ClassNotFoundException项目能编译但起不来。典型的多版本依赖冲突。扫描系统里 Jsoup、OkHttp、Jackson 这几个库容易撞版本特别是你额外引入了别的 starter 之后。排查用mvn dependency:tree -Dincludesorg.jsoup看有没有同一个库的两个版本有的话在 pom 里用exclusions排掉旧的。这个坑很玄学有时候只是加了一个看似无关的依赖启动就崩了血泪经验是每加一个依赖就跑一次启动验证别攒着一起加。5.2 扫描阶段零结果、误报泛滥、任务卡死现象四任务跑完了漏洞列表是空的。先看scan_url表有没有数据如果没有问题在爬虫如果有几十条地址但vuln_record空问题在检测层。爬虫没数据八成是host判定写错了——比如目标填的是192.168.1.10而页面里的链接都是相对路径URI.create(link).getHost()对相对路径返回 nullequals直接 false链接全被丢掉。解决是先resolve成绝对地址再取 host。检测层没数据检查插件有没有被 Spring 扫到plugin/impl包必须在启动类的ComponentScan范围内包名写错一位就全部失效而且不报错静默不生效这是最难查的一类问题。现象五报告里几十条高危逐条看完全是误报。规则里的expression写得太宽是主因。比如用error这种词做关键字匹配页面上任何一句系统发生 error都会命中。解决是把表达式收紧到具体的技术特征比如ORA-\d{5}、SQL syntax.*MySQL同时给规则加一个响应状态码必须是 200的前置条件非 200 的响应不参与规则匹配能砍掉一大半噪声。另外布尔盲注的长度差阈值也要实测标定别直接用默认值。现象六任务一直显示运行中进度条不动也不结束。两种情况。一是线程池里的任务阻塞在某个慢请求上timeout-ms设置过大或者压根没生效。检查 OkHttp 客户端的readTimeout有没有真的传进去很多时候ScanContext里的超时字段没被使用。二是ScanEngine里的计数器没有正确递减任务全部跑完了但 status 没更新成 2。这种属于并发逻辑写漏可以在引擎里加一个finally块强制更新状态再加个定时任务把运行超过两小时的任务标记为失败避免一直挂着占资源。提示排查顺序永远是先看数据表再看日志最后看代码。数据库里有没有记录能一步定位问题出在哪一层比在代码里打断点快得多。6. 二次开发加一个自定义检测插件与规则热加载真正拉开工作量差距的地方是能不能在不破坏原有结构的前提下加东西。这套系统的插件接口设计得比较松加一个检测项就三步。第一步在plugin/impl下新建类实现ScanPlugin。假设要加一个响应头缺失安全策略的检测Component public class SecurityHeaderPlugin implements ScanPlugin { // 建议存在的安全响应头 private static final ListString EXPECTED Arrays.asList( X-Content-Type-Options, X-Frame-Options, Content-Security-Policy ); Override public String id() { return security-header; } Override public String name() { return 安全响应头缺失; } Override public RiskLevel baseRisk() { return RiskLevel.LOW; } Override public boolean match(ScanContext ctx) { return true; // 每个地址都值得看一眼响应头 } Override public PluginResult scan(ScanContext ctx) { MapString, String headers HttpUtil.headers(ctx.getUrl(), ctx.getTimeoutMs()); ListString missing EXPECTED.stream() .filter(h - !headers.containsKey(h)) .collect(Collectors.toList()); if (missing.isEmpty()) return PluginResult.miss(this); return PluginResult.hit(this, ctx.getUrl(), 缺失响应头: String.join(,, missing), RiskLevel.LOW); } }第二步确认包路径在扫描范围内重启应用PluginRegistry会自动把它收进去前端插件勾选列表里也能看到。第三步是验证拿一个已知缺失响应头的地址跑一次看vuln_record里有没有记录证据字段是不是把缺失的头名列全了。match返回 true 意味着每个地址都会发一次请求如果地址量大建议改成只在根路径跑或者干脆用爬虫阶段已经拿到的响应头避免重复发请求——这一点在地址数量上千的时候差别很明显。再说规则热加载。改 YAML 要重启调试规则的时候来回重启很折磨。加一个文件监听就能解决Component public class RuleWatcher { private final RuleLoader loader; PostConstruct public void watch() throws IOException { Path dir Paths.get(rules); WatchService ws FileSystems.getDefault().newWatchService(); dir.register(ws, StandardWatchEventKinds.ENTRY_MODIFY); // 后台线程监听文件改动后重新加载整包规则 Thread t new Thread(() - { while (true) { try { WatchKey key ws.take(); key.pollEvents().forEach(e - loader.reload()); key.reset(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); return; } } }, rule-watcher); t.setDaemon(true); t.start(); } }ENTRY_MODIFY只监听修改事件新建文件要额外注册ENTRY_CREATE。setDaemon(true)让这个线程随主进程退出不会卡住应用关闭。loader.reload()内部要做成原子的先加载到临时 Map全部解析成功再整体替换引用避免加载一半规则时正好有扫描任务在读读到空列表。这个细节不做会出现规则改了一下扫描突然全空了的情况。改造完怎么验证没跑偏我一般走三步先在模拟靶站上跑一遍确认新增的检测项能命中再拿一个干净页面跑一遍确认没有误报最后把规则文件故意写坏一行看系统是跳过坏规则还能起来还是整个启动失败——后者说明你漏了异常处理得回去补。这套流程走下来基本能排掉大部分低级问题。新增检测项之前我习惯先把它的成功长什么样写清楚命中时响应里应该出现什么特征不命中时又是什么样。这两句话写不出来说明这个检测项本身定义得模糊写出来也是误报制造机。从那以后我每次加插件都强制先把这两句话写进注释再动手写代码返工率肉眼可见地降了下来。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。