简介:这份资源是面向计算机专业毕业生、课程设计选题者与网络安全入门学习者的Java漏洞扫描系统毕业设计项目包,可用于答辩演示、方案借鉴和二次开发。压缩包共927个文件、约33.11MB,主体由604个NSE脚本和146个Lua脚本构成,承载Nmap服务识别与漏洞探测规则;另有30个class、19个jar、12个java源码、23个xml配置及exe、dll等运行组件,形成扫描引擎、规则库、启动脚本并存的目录结构。项目内容围绕目标发现、端口与服务识别、常见Web漏洞检测、结果汇总和报告输出展开,涉及Socket通信、Jsoup页面解析、多线程调度与日志记录等实现思路,规则库与Java端调度代码相互配合,便于替换检测项或补充扫描逻辑,对理解扫描流程、规则组织和模块拆分有直接帮助。已有205人学习,适合希望把Java编程与网络攻防知识落到可运行系统上的读者参考。
1. 从一个能跑通的 Java 漏洞扫描系统说起:它到底扫什么、适合谁用
去年帮人看一份毕业设计压缩包,解压出来是个 Spring Boot 工程,双击 jar 就能起,浏览器打开localhost:8080是个带侧边栏的管理台。填一个 URL,点「开始扫描」,进度条走完,下面列出一串结果:目标用了什么中间件、开了哪些敏感路径、哪几个页面存在未授权访问。整套流程没有任何外部服务依赖,MySQL 建个库就能跑。这就是一份典型的 Java 漏洞扫描系统毕设——麻雀虽小,爬虫、指纹识别、POC 检测、结果入库、报告导出五段链路齐全。它不适合拿去扫生产环境,但对想搞懂「扫描器内部到底怎么运转」的开发者来说,是个能拆开看、能改、能加规则的完整样本;对要交毕设、要做课程设计的人,它更是一份能讲清楚每行代码干嘛的底座。
2. 技术栈与分层架构:一次扫描任务从入队到出报告的路径
拿到这类工程,我第一件事不是看 Controller,而是先看pom.xml和目录树。技术选型基本决定了一个项目能改到什么程度——用重量级框架堆出来的,改一处牵全身;用轻量组合搭的,反而好动手。这份工程属于后者,整体是「单体 Spring Boot + 引擎分层」的结构,没有微服务、没有消息中间件,所有扫描逻辑跑在同一个 JVM 里,靠线程池并发。
2.1 技术栈组合与选型理由
后端是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8,前端是 Vue 2 + Element UI,前后端分离,前端打包后丢进static目录,一个 jar 全带走。这套组合在毕设场景里几乎是标准答案:Spring Boot 省掉 Tomcat 配置,MyBatis-Plus 让单表 CRUD 不用手写 XML,MySQL 存任务和结果,答辩现场演示时不用额外装东西。
真正干活的三块依赖值得单独说:
| 依赖 | 版本量级 | 在这项目里干什么 |
|---|---|---|
| OkHttp | 4.x | 所有 HTTP 请求的统一出口,爬虫抓页面、POC 发探测包都走它 |
| Jsoup | 1.15+ | 解析 HTML,抽<a href>、<form>、<script src> |
| Hutool | 5.x | URL 处理、摘要计算、字符串工具,省掉大量样板代码 |
| EasyExcel | 3.x | 扫描结果导出 xlsx,答辩时要的「报告」靠它 |
请求层选 OkHttp 而不是原生HttpURLConnection,原因是连接池和超时控制更直观:connectTimeout、readTimeout、callTimeout三个参数分开设,扫到慢站点时不会一个请求把线程挂死。这一点在后面排查章节会反复提到,很多「扫着扫着不动了」的问题都出在这里。
<!-- pom.xml 关键依赖,版本号按工程实际填 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- ORM,单表操作用它省事 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <!-- HTML 解析,爬虫和指纹识别都要用 --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.15.3</version> </dependency> <!-- HTTP 客户端,统一请求出口 --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.10.0</version> </dependency> <!-- YAML 解析,POC 规则文件靠它加载 --> <dependency> <groupId>org.yaml</groupId> <artifactId>snakeyaml</artifactId> <version>1.33</version> </dependency> </dependencies>这段依赖清单里,snakeyaml容易被忽略,但它是整个插件化设计的地基——POC 规则不写在 Java 代码里,而是写成 YAML 文件放在resources/pocs/下,启动时扫描目录加载。想加一条检测规则,只需要扔一个 yml 进去重启,不用重新编译。这个设计决定了后面二次开发的成本上限。
配置集中在application.yml,跟扫描行为相关的参数都提出来了,改完重启即生效:
scanner: # 爬虫并发线程数,本地演示 8 足够,扫大站别超过 32 crawler-threads: 8 # POC 检测线程数,跟爬虫分开,避免互相抢资源 poc-threads: 16 # 最大爬取深度,1 表示只扫种子页面的直接链接 max-depth: 3 # 单个域名最多收集多少 URL,防止无限膨胀 max-urls-per-host: 500 # 连接超时、读超时、整体调用超时,单位秒 connect-timeout: 5 read-timeout: 10 call-timeout: 15 # 是否跟随重定向,扫登录跳转时建议关掉 follow-redirects: false # 静态资源后缀,爬虫直接跳过 skip-suffix: js,css,png,jpg,gif,svg,woff,ico,mapmax-urls-per-host和max-depth是两个保命参数。毕设演示时把深度调到 3、单域上限 500,扫一个中小型站点大概两三百个请求,两分钟内出结果;不设上限的话,遇到带日历、带分页参数的站点,URL 数量会呈指数级膨胀,线程池队列瞬间撑爆,内存直接起飞。
2.2 目录结构与模块职责
包结构是典型的「按层 + 按引擎」混合划分,engine下面再按功能拆子包,这样加新引擎时不用动其他目录:
| 路径 | 职责 |
|---|---|
controller/ | 任务创建、进度查询、结果分页、报告下载四个入口 |
service/ | 任务编排,负责把爬虫、指纹、POC 串起来 |
engine/crawler/ | URL 采集、规范化、去重、表单提取 |
engine/fingerprint/ | 指纹规则加载与匹配打分 |
engine/poc/ | POC 定义解析、请求构造、匹配器执行 |
engine/report/ | 结果聚合与 Excel 导出 |
mapper/+entity/ | 数据访问层,MyBatis-Plus 自动生成基础 CRUD |
resources/pocs/ | 所有 YAML 规则文件,按组件名分目录存放 |
resources/fingerprints/ | 指纹规则表,JSON 或 SQL 初始化脚本 |
engine下的三个子包之间是单向依赖:crawler产出 URL 列表,fingerprint消费 URL 并给出组件标签,poc根据组件标签筛选候选规则。这种流水线式依赖的好处是每一段都能单独测——想调爬虫就 mock 一个 URL 队列,想测指纹就喂一段固定的 HTML 字符串,不用起整个应用。
2.3 一次扫描任务的数据流
用户在前端填http://demo.local点开始,后端做的事情可以拆成七步:创建scan_task记录,状态置为WAITING;种子 URL 规范化后塞进待爬队列;爬虫线程池开始消费,每抓到一个页面就用 Jsoup 抽链接和表单,规范化去重后入队;每发现一个新 URL,同步交给指纹识别;指纹命中后,从 POC 库中查出affected_component匹配的规则;POC 线程池对候选 URL 逐条发包检测;命中的结果按(task_id, url_md5, poc_id)三元组去重后写入vuln_result。
任务进度不是实时推送,前端每两秒轮询一次/api/task/{id}/progress,后端返回「已发现 URL 数 / 已检测 URL 数 / 命中数 / 当前状态」。这个方案在单机场景下够用,也省掉了 WebSocket 的心智负担。整个链路里最容易出问题的环节是第三步和第六步:前者怕 URL 膨胀,后者怕请求超时,这两处后面会单独讲。
3. 核心引擎拆解:爬虫、指纹与 POC 匹配的三段式流水线
把扫描器拆开看,本质就是「找入口 → 认组件 → 打 payload → 判结果」四件事。这份工程的代码量不大,三个引擎加起来两千行出头,但每一段该有的边界处理都在,适合拿来当模板改。
3.1 爬虫模块:URL 规范化与去重
爬虫的核心不是「抓」,是「去重」。同样的页面,/index、/index/、/index?a=1&b=2、/index?b=2&a=1、/index#top在字符串层面长得完全不一样,但对服务器来说是同一个资源。不做规范化,队列里会堆进大量重复任务,扫一个小站能跑出几千条结果。
public final class UrlUtils { /** * URL 规范化:统一协议、域名、端口、路径、参数顺序 * @param raw 原始链接,可能是相对路径 * @param base 当前页面的绝对地址,用于拼接相对路径 * @return 规范化后的绝对 URL,非法或应跳过时返回 null */ public static String normalize(String raw, String base) { try { URL url = new URL(new URL(base), raw); // 相对路径转绝对 String protocol = url.getProtocol().toLowerCase(); if (!"http".equals(protocol) && !"https".equals(protocol)) { return null; // 过滤 mailto、javascript 等 } String host = url.getHost().toLowerCase(); int port = url.getPort(); // 去掉默认端口,避免 :80 和空端口被当成两个 URL if ((protocol.equals("http") && port == 80) || (protocol.equals("https") && port == 443)) { port = -1; } String path = url.getPath().isEmpty() ? "/" : url.getPath(); // 统一去掉结尾斜杠(根路径除外),/admin 和 /admin/ 视为同一个 if (path.length() > 1 && path.endsWith("/")) { path = path.substring(0, path.length() - 1); } // 参数按 key 排序,保证 ?a=1&b=2 与 ?b=2&a=1 一致 String query = sortQuery(url.getQuery()); String portPart = port == -1 ? "" : ":" + port; return protocol + "://" + host + portPart + path + (query == null ? "" : "?" + query); } catch (Exception e) { return null; // 解析失败直接丢弃,不阻断整轮爬取 } } }这段代码里有三个判断值得细看。协议过滤挡掉了mailto:、javascript:void(0)这类伪链接,不挡的话队列里会混进大量垃圾;默认端口归一化解决的是「同一站点两种写法」的问题;参数排序则是防止分页参数顺序变化产生重复。sortQuery用TreeMap实现,按 key 字典序重排后重新拼接。
去重用两层:内存里用ConcurrentHashMap.newKeySet()存已访问 URL 的 MD5,任务级别有效;如果需要跨任务去重或者多实例部署,再换成 Redis 的SET结构,SADD返回 0 就说明重复。工程默认走内存版,避免多一个中间件依赖。
抓取逻辑本身没什么花活,OkHttp 发 GET,Jsoup 解析,抽三类元素:a[href]拿链接、form拿表单的 action 和 method、script[src]和link[href]拿静态资源路径(用于指纹识别,不进爬取队列)。表单提取这一步很多毕设会漏,但它是后面检测未授权访问和注入类问题的前提——只爬 GET 链接,后台接口一个都摸不到。
// 表单提取:把 action、method、参数名全部记录下来 public List<FormInfo> extractForms(Document doc, String pageUrl) { List<FormInfo> forms = new ArrayList<>(); for (Element form : doc.select("form")) { FormInfo info = new FormInfo(); // action 为空时默认提交到当前页面 String action = form.hasAttr("action") ? form.attr("action") : pageUrl; info.setAction(UrlUtils.normalize(action, pageUrl)); // method 缺省是 GET,统一转大写方便后面判断 info.setMethod(form.attr("method").toUpperCase()); List<String> params = new ArrayList<>(); for (Element input : form.select("input[name], textarea[name], select[name]")) { params.add(input.attr("name")); } info.setParams(params); forms.add(info); } return forms; }FormInfo这个对象后面会被 POC 引擎复用——检测登录接口是否存在默认口令时,需要知道表单提交到哪、带哪些字段。参数名收集只取name属性,值不填,因为扫描阶段不应该真的往业务库里写数据。这也是我改这类项目时坚持的一条底线:只做探测,不做写入,POST 请求体一律用占位符填充。
3.2 指纹识别:规则表 + 加权打分
指纹识别的作用是缩小 POC 的打击面。全网规则几百条,不可能对每个 URL 都跑一遍,先认出目标用了什么框架、什么中间件,再挑对应的规则,命中率能提高一个数量级。
规则存在数据库表fingerprint里,字段包括match_type(匹配位置:header / body / cookie / meta)、pattern(正则或关键字)、weight(权重)、component(组件名)、version_regex(版本提取正则)。匹配时对同一组件的多条规则累加权重,超过阈值(默认 60)就判定命中。
/** * 对单个响应做指纹匹配 * @param resp 已读取完 body 的响应对象 * @param rules 已按组件分组加载好的规则 * @return 命中的组件及版本,key 为组件名 */ public Map<String, String> match(Response resp, List<FingerprintRule> rules) { // 同一组件的权重累加器 Map<String, Integer> scoreMap = new HashMap<>(); Map<String, String> versionMap = new HashMap<>(); String body = resp.body() == null ? "" : resp.body(); Map<String, String> headers = resp.headers(); for (FingerprintRule rule : rules) { String target; switch (rule.getMatchType()) { case "header": target = headers.getOrDefault(rule.getKey(), ""); break; case "cookie": target = headers.getOrDefault("Set-Cookie", ""); break; case "body": default: target = body; break; } // 正则匹配,失败不抛异常,只跳过 Matcher m = Pattern.compile(rule.getPattern(), Pattern.CASE_INSENSITIVE) .matcher(target); if (m.find()) { scoreMap.merge(rule.getComponent(), rule.getWeight(), Integer::sum); // 有版本提取正则时,把第一个分组当作版本号 if (rule.getVersionRegex() != null) { versionMap.merge(rule.getComponent(), m.group(1) != null ? m.group(1) : "unknown", (a, b) -> b); } } } // 只保留权重达标的组件 Map<String, String> result = new HashMap<>(); scoreMap.forEach((component, score) -> { if (score >= 60) { result.put(component, versionMap.getOrDefault(component, "unknown")); } }); return result; }权重设计的经验值:Server头里出现某个中间件名,权重给 40;X-Powered-By给 50;页面 body 里出现框架特有的静态资源路径(比如某类 UI 框架独有的xxx.min.js),给 60;cookie 名给 70。为什么 cookie 权重最高——因为 cookie 名通常是框架自动生成的,业务代码很难伪造。单条规则权重都不超过 60,靠组合累加过线,这样能有效降低误判。
版本号提取是加分项,也是坑点。规则里写jquery-(\d+\.\d+\.\d+)\.min\.js能精准拿到版本,但换成jquery.*?(\d+\.\d+)就会在压缩混编的 JS 里匹配到奇怪的东西。我的做法是版本正则只允许出现在version_regex字段,主pattern只负责判断「是不是」,两个职责分开,误报好定位。
3.3 POC 引擎:YAML 描述与匹配器
这是整个项目最有设计感的部分。POC 不用 Java 写,用 YAML 描述,启动时扫描resources/pocs/**/*.yml全量加载,构建成Map<pocId, PocDefinition>。一条规则包含三块:元信息(id、名称、等级、影响的组件和版本范围)、请求定义(method、path、headers、body)、匹配器(status / word / regex / header 四类,支持 and、or 组合)。
# resources/pocs/demo/unauth-admin.yml id: demo-unauth-admin name: 演示用-后台页面未授权访问 level: high component: demo-admin-framework # 影响的组件,指纹识别命中它才会跑这条 version_range: ">=1.0,<2.3" # 版本范围,不填表示全版本 request: method: GET path: /admin/index # 相对于目标根路径 headers: User-Agent: "Mozilla/5.0 Scanner/1.0" follow_redirects: false # 不跟随跳转,跳转到登录页就说明未授权不成立 matchers: - type: status value: 200 - type: word part: body words: ["用户管理", "退出登录"] condition: and # 两个词都得出现加载器用 SnakeYAML 反序列化到PocDefinition对象,加载失败只打 warn 不中断启动,避免一条坏规则拖垮整个应用。这一点很关键——二期开发时规则可能是别人写的,格式错了不能让服务起不来。
执行器负责把 YAML 翻译成真实请求,再跑匹配器:
public VulnResult execute(PocDefinition poc, String baseUrl) { // 路径拼接并规范化,防止 path 里写死斜杠导致双斜杠 String url = UrlUtils.normalize(poc.getRequest().getPath(), baseUrl); if (url == null) { return null; } Request.Builder builder = new Request.Builder().url(url); // 逐条设置自定义请求头 poc.getRequest().getHeaders().forEach(builder::header); if ("POST".equalsIgnoreCase(poc.getRequest().getMethod())) { RequestBody body = RequestBody.create( poc.getRequest().getBody() == null ? "" : poc.getRequest().getBody(), MediaType.parse("application/x-www-form-urlencoded")); builder.post(body); } try (Response resp = httpClient.newCall(builder.build()).execute()) { int code = resp.code(); String body = resp.body() == null ? "" : resp.body().string(); // 所有匹配器必须全部通过(matchers 之间是 and 关系) for (MatcherDef m : poc.getMatchers()) { if (!MatcherExecutor.matches(m, code, body, resp.headers())) { return null; // 任一不满足直接判定未命中 } } VulnResult r = new VulnResult(); r.setPocId(poc.getId()); r.setLevel(poc.getLevel()); r.setUrl(url); // 证据只截取前 500 字符,避免大页面撑爆数据库 r.setEvidence(body.length() > 500 ? body.substring(0, 500) : body); return r; } catch (IOException e) { // 单个请求失败不冒泡,扫描任务继续 log.warn("poc {} 请求 {} 失败: {}", poc.getId(), url, e.getMessage()); return null; } }execute的返回值设计成「命中返回对象、未命中返回 null」,调用方只判空,逻辑简单。try-with-resources保证 Response 一定关闭,不然连接池很快被占满——这是 OkHttp 使用中最常见的资源泄漏点。证据截断 500 字符是权衡后的选择:完整 body 存下来数据库膨胀快,扫一千个页面就能撑到几百 MB;截断后既能证明漏洞存在,又不影响报告阅读。
匹配器的实现里,word类型用String.contains,regex用Pattern预编译后缓存,status直接比 int,header比对响应头。condition字段支持and/or,默认and。这里有个容易踩的坑:模糊匹配用的是contains而不是equals,URL 里带admin的页面会被误判,所以规则必须叠加状态码和第二个关键词,靠单关键词判高危的做法在实际使用中误报率高得没法看。
3.4 结果去重与证据留存
同一漏洞可能在爬虫阶段被发现多次:/admin、/admin/、/admin?x=1规范化后虽然会合并,但 POST 表单和 GET 链接指向同一接口的情况很常见。去重靠数据库唯一索引兜底,uk_task_url_poc (task_id, url_md5, poc_id),插入时用INSERT IGNORE或捕获重复键异常,业务层不用写复杂的查重逻辑。
url_md5存的是规范化 URL 的 MD5,不是原始 URL——32 位定长字符串做索引比变长 URL 效率高得多,也避开了 MySQL 索引长度限制。证据字段存请求报文和响应片段,报告导出时按level排序,高危在前。
4. 本地跑通:环境准备、建表与第一轮扫描验证
这类工程能不能跑起来,八成取决于配置和数据库初始化。我在本地复现时踩过的坑集中在两处:JDK 版本和 SQL 脚本编码。下面按顺序走一遍。
4.1 环境准备与依赖安装
需要的东西不多:JDK 8 或 11(Spring Boot 2.7 两个都支持,但工程里如果用了var关键字就必须 11 以上)、Maven 3.6+、MySQL 8。前端如果要改,再装 Node 14+,不改的话直接用打包好的静态资源。
# 1. 建库,字符集必须是 utf8mb4,不然中文规则名和证据会乱码 mysql -uroot -p -e "CREATE DATABASE vuln_scan DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入初始化脚本(工程 doc 目录下一般会带) mysql -uroot -p vuln_scan < doc/schema.sql # 3. 编译打包,跳过测试,第一次跑建议加 -U 强制更新依赖 mvn clean package -DskipTests -U # 4. 启动,把数据库连接通过参数传进去,避免改配置文件 java -jar target/vuln-scan.jar \ --spring.datasource.url=jdbc:mysql://127.0.0.1:3306/vuln_scan?useUnicode=true\&characterEncoding=utf8 \ --spring.datasource.username=root \ --spring.datasource.password=你的密码characterEncoding=utf8这个参数不能省,JDBC 连接串里不写,中文证据入库会变成问号。启动日志里看到 Tomcat started on port 8080 就算成功,浏览器打开能进登录页或者首页,说明前端资源也打进去了。
如果前端要单独调试,进web/目录npm install && npm run serve,然后在vue.config.js里把代理指到 8080。生产模式就npm run build,产物拷到src/main/resources/static,重新打包。
4.2 数据库初始化与配置项说明
核心表就四张,字段不多但索引设计决定了扫描大站时会不会卡:
-- 扫描任务表 CREATE TABLE scan_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, target_url VARCHAR(512) NOT NULL COMMENT '种子地址', status VARCHAR(20) DEFAULT 'WAITING' COMMENT 'WAITING/RUNNING/FINISHED/FAILED', url_count INT DEFAULT 0 COMMENT '已发现 URL 数', vuln_count INT DEFAULT 0 COMMENT '命中数', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 漏洞结果表,唯一索引负责去重 CREATE TABLE vuln_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, poc_id VARCHAR(64) NOT NULL, level VARCHAR(10) NOT NULL COMMENT 'high/medium/low', url VARCHAR(1024) NOT NULL, url_md5 CHAR(32) NOT NULL COMMENT '规范化 URL 的 MD5', evidence TEXT COMMENT '响应证据,截断 500 字符', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_task_url_poc (task_id, url_md5, poc_id), KEY idx_task_level (task_id, level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 指纹规则表 CREATE TABLE fingerprint ( id BIGINT PRIMARY KEY AUTO_INCREMENT, component VARCHAR(64) NOT NULL COMMENT '组件名,与 POC 的 component 对应', match_type VARCHAR(16) NOT NULL COMMENT 'header/body/cookie', `key` VARCHAR(64) NULL COMMENT 'header 名,body 类型可为空', pattern VARCHAR(512) NOT NULL, version_regex VARCHAR(256) NULL, weight INT DEFAULT 40 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_task_level这个联合索引是为报告页准备的,前端按任务查结果、按等级排序,没这个索引扫出上千条结果后翻页会明显卡顿。url_md5用CHAR(32)而不是VARCHAR(32),定长字段在 InnoDB 里存储和比较都更快。
配置项里最需要根据环境调的是线程数。本地演示 8 个爬虫线程足够,扫内网靶场时请求响应快,可以把crawler-threads提到 16;但如果目标在外网、RTT 高,线程开太多反而会因为连接池排队拖慢整体速度。我的经验是线程数控制在「目标平均响应时间(秒)× 2」左右,10 秒延迟的站点开 20 个线程已经是极限。
提示:如果启动报
Table 'vuln_scan.scan_task' doesn't exist,八成是 schema.sql 导入到了别的库,或者连接串里库名拼错。先SHOW TABLES确认再排查代码。
4.3 启动与验证:用本地靶场跑一轮
直接扫外网站点既慢又不合适,工程里一般会带一个演示靶场,或者自己起一个最简单的 HTTP 服务充当目标。目的是验证「爬虫能爬到、指纹能认出来、POC 能命中」这三条链路都通。
# 起一个本地演示服务,模拟后台页面和登录表单 # 这里用 python 自带的 http.server 快速验证连通性 mkdir -p /tmp/demo-target/admin cat > /tmp/demo-target/admin/index.html <<'EOF' <html><head><title>后台管理</title></head> <body> <div>用户管理</div> <a href="/admin/user/list">用户列表</a> <a href="/login">退出登录</a> </body></html> EOF cd /tmp/demo-target && python3 -m http.server 9000然后在管理台里新建任务,目标填http://127.0.0.1:9000,深度 2,点开始。预期看到的结果是:URL 数增长到 2 到 3 个,指纹识别可能命中不了(因为规则库里没有这个假组件),POC 也不会命中——这属于正常。真正的验证方式是手工往数据库里插一条指纹规则和一个 POC,让组件名对上,再跑一次,看结果列表里有没有出现那条规则。
-- 手工插一条指纹规则,权重给 60 直接过线 INSERT INTO fingerprint (component, match_type, `key`, pattern, weight) VALUES ('demo-admin', 'body', NULL, '用户管理', 60);再把前面那个unauth-admin.yml的component改成demo-admin,重启后重新扫描,结果页应该能看到一条 high 级别的记录,证据里是那段 HTML。这一步走通,说明整条流水线是活的,后面加规则、改逻辑都有底了。
扫描进度是轮询拿的,前端两秒一次打/api/task/{id}/progress,后端返回当前统计。如果进度一直停在「已发现 1 个 URL」,不是卡死了,是爬虫没抽到有效链接——检查目标页面的<a>标签是不是用了javascript:伪协议,或者链接是相对路径但 base 拼错了。
5. 避坑排查:扫描器上线前最容易翻车的五处细节
扫描器这类程序的特点是「平时看着没事,一跑大站就原形毕露」。下面这几条是我在这类工程上真实遇到过的问题,按「现象 → 原因 → 解决」列出来,照着排查能省不少时间。
5.1 扫描跑不动:线程池与超时的三个坑
第一坑:扫到一半内存飙升,最后 OOM。现象是日志里开始刷 GC,任务卡在 60% 不动,jmap一看堆里全是String。原因是爬虫队列用了LinkedBlockingQueue的无参构造,默认容量是Integer.MAX_VALUE,URL 只进不出,加上结果对象全量攒在内存里,扫一个带日历控件的站点就能堆到几百万条。解决办法是把队列改成有界,比如new LinkedBlockingQueue<>(10000),配合CallerRunsPolicy让生产端降速;结果不要攒着,每命中一条就落库,内存里只保留计数。
第二坑:请求挂死,整个任务停住。现象是任务状态一直 RUNNING,但 URL 数不涨,jstack里一堆线程停在 socketRead。原因是 OkHttp 只设了connectTimeout,没设readTimeout,遇到那种接受连接但永不返回内容的服务,线程就无限等。解决是三个超时都配上,connectTimeout 5s、readTimeout 10s、callTimeout 15s,callTimeout是兜底的,管整个调用周期,包括重定向和重试。
// 连接池和超时的推荐配置 OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) // 建连超时 .readTimeout(10, TimeUnit.SECONDS) // 读数据超时 .callTimeout(15, TimeUnit.SECONDS) // 整个调用兜底 .connectionPool(new ConnectionPool(32, 5, TimeUnit.MINUTES)) .followRedirects(false) // 扫描场景建议关掉 .build();followRedirects关掉是有讲究的:检测未授权访问时,如果目标返回 302 跳到登录页,跟随重定向后拿到的是登录页的 200,规则里的关键词可能误命中。关了之后看原始状态码,逻辑更干净。
第三坑:线程数开太大反而更慢。有人觉得线程越多扫得越快,直接把poc-threads调到 200。结果是连接池排满、目标端限流、大量请求超时重试,整体耗时反而翻倍。经验值是并发数不要超过目标站点能承受的量级,内网靶场 16 到 32 足够,外网目标 8 到 16 比较稳。
5.2 结果不准:误报与漏报的排查路径
第四坑:高危结果一大片,点开全是误报。现象是扫完一个普通企业站,出来几十条 high。原因通常是规则写得太松,比如只匹配响应 body 里包含admin就判定后台未授权,结果任何带这个词的页面全中。解决分两步:一是规则强制多条件,状态码 + 两个以上关键词 + 特定 header,缺一不可;二是加二次验证,第一次命中后换个路径再请求一次,两次都命中才入库。这一步能把误报率压下去一大截。
第五坑:明明有漏洞但扫不出来。这是漏报,排查顺序是「指纹 → 版本范围 → 路径」。先看指纹有没有识别出目标组件,识别不出来就说明规则没覆盖,POC 根本不会跑;再看version_range是不是把目标版本排除了,版本号拿不到时默认按unknown处理,很多规则会把 unknown 直接跳过;最后看path是不是写死了绝对路径,目标部署在子目录下就匹配不上。定位方法是打开 debug 日志,把每条 POC 的执行情况打出来,看是「没跑」还是「跑了没命中」。
注意:调试规则时不要拿真实业务系统做靶子,自己搭个演示页面,命中与不命中都心中有数,再去验证规则才有意义。
6. 二次开发:把规则插件化和任务分布式化的改造路径
这套代码我后来拿去改过一版,主要动了两个地方,思路可以借鉴。
先说加规则。因为 POC 是 YAML 驱动的,加一条检测逻辑真的不用碰 Java:在resources/pocs/下按组件建目录,写一个 yml,把component对上指纹表里的组件名,重启生效。验证方法是本地起演示页面,手工构造一个「有漏洞」和「没漏洞」的响应,各跑一遍,确认前者命中后者不命中。我习惯给每条新规则写两条断言,用 JUnit 跑,避免后面改匹配器逻辑时把老规则改坏。
再说分布式。单机版的任务表只有状态没有归属,多实例部署会重复扫。改造方式很轻:给scan_task加worker_id和heartbeat_time两个字段,每个实例启动时生成一个 UUID 作为 worker_id,用一条带条件的 UPDATE 抢任务。
-- 抢占一个待执行任务,原子操作,天然避免重复 UPDATE scan_task SET status = 'RUNNING', worker_id = #{workerId}, heartbeat_time = NOW() WHERE status = 'WAITING' ORDER BY id ASC LIMIT 1;抢到之后每 30 秒更新一次心跳,调度端扫描heartbeat_time超过 2 分钟没更新的任务,重置成WAITING让别的实例接手。这套机制没有引入任何中间件,够用且好调。
还有个小技巧:报告导出别在主线程做。扫完一个大站,结果几千条,EasyExcel 写 xlsx 要好几秒,接口直接超时。改成异步任务,前端拿到一个report_id轮询下载地址,体验会好很多。
这类扫描器项目我前后碰过三四个,早期最常犯的错是「跑通了就以为改对了」——改完匹配器逻辑不回归,结果老规则全失效,扫出来一片空白还以为是目标站点的问题。从那以后我每次动引擎代码,都强制先把演示靶场那套断言跑一遍,再去碰真实目标。希望帮到你。
本文还有配套的精品资源,点击获取