简介:面向网络安全学习者与Java开发人员的Web漏洞扫描系统设计资源,聚焦扫描引擎的整体实现,帮助理解构建思路、漏洞规则组织以及如何与Nmap脚本体系联动。压缩包内共927个文件,整体大小约33.07MB,以604个NSE脚本和146个Lua脚本作为核心检测规则库,同时提供30个class字节码、19个jar可运行组件、12个Java源文件,可对照学习系统编译产物与源码实现。另有xml配置文件、properties配置、txt说明文档等辅助内容,并配套Nmap的数据库与服务探测文件,能支撑从规则编写到扫描调度的完整流程。目前已有467人学习下载,适合具备Java基础、希望深入Web漏洞自动化检测原理的中级开发者;通过研读源码和脚本,可掌握漏洞库组织、探测规则定制以及调用Nmap引擎完成扫描的方法,为自研轻量级扫描工具提供可参考的工程样例。
1. 基于Java的Web漏洞扫描系统:能扫什么、适合谁、值不值得自己写
基于Java的Web漏洞扫描系统,简单说就是用Java写一套自动化程序,替你把网站常见的注入、跨站、目录泄露等问题过一遍。它解决的不是“用现成工具扫一下”的需求,而是“拿现成工具扫完说不出原理,或者想在后端工程里内置一个扫描模块”的需求。对做毕业设计、课程设计的人来说,从零写一套扫描器最大的价值不在检出率,而在把请求构造、响应分析、并发调度、结果归并这套链路亲手搭一遍;对做内网自查的测试工程师来说,Java实现的好处是能直接接进已有的工程体系,不用单独维护一套脚本环境。
这套系统适合三种人:想用它做毕设、需要完整模块设计的学生;负责内网自查、不想把疑似漏洞数据交给商业工具黑匣子的工程师;准备做安全产品原型的后端开发。它的边界也得说清:纯Java手写的规则库很难追平商业级扫描器,但“爬虫—检测—报告”的标准架构能力和稳定发现常规漏洞的能力,足够支撑一个学期设计题目或者一个轻量自查工具。下面我从架构设计开始,一步步把可复现的代码和参数讲清楚。
2. 系统架构怎么定:扫描调度、插件注册和报告落地的取舍
2.1 扫描器的主流程不是“发请求”,而是“任务分解”
先说原理。扫描器本质上是一个生产者消费者模型:爬虫是生产者,产出URL列表和参数位置;检测引擎是消费者,把请求发出去并判定结果。很多人写的扫描器像面条代码,就是因为把两阶段混在一起,边爬边测,同一个链接被重复请求十几次,登录后才可见的页面也测不到。
正确做法是把任务拆成三个阶段:目标发现、请求验证、结果归并。目标发现负责从入口URL扩散出链接清单;请求验证负责对每个参数位置做探测;结果归并负责去重、判信任级别、落报告。这样做还有个好处,某类检测器的误报率太高时可以单独摘掉,或者只对特定页面启用,不需要重写主流程。
我一般会把系统的顶层抽象定义成三个接口,检测器全部实现同一个接口,由调度器统一转发。核心代码如下:
public interface CrawlerTask { CrawlResult crawl(SeedUrl seed, CrawlContext ctx); } public interface VulnScanner { ScannerType type(); // SQLI / XSS / CMDI / INFO 等 List<VulnFinding> scan(ScanRequest req, ScanContext ctx); } public interface ScanReporter { void report(ScanReport report); // 落库或导出文件 }这段代码的设计重点是第二个接口。漏洞扫描器的检测逻辑天然是追加式的:今天只有SQL注入和XSS,下周可能加模板注入、反序列化探测。如果检测逻辑和调度逻辑耦合在同一个类里,每加一种漏洞就要改一遍主流程,迟早改出雷。让调度器只负责收集结果,检测器之间互不感知,新增漏洞类型时只需要新增实现类,不需要动老代码。
并发模型上也建议直接用线程池,而不是每来一个URL就new Thread。线程创建开销倒是其次,真正的问题是无法控制扫描目标承受的请求速率,容易把目标站点打挂。控制并发的常用做法是手动创建线程池,关键参数见下表:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 核心线程数 | 8 | 按机器CPU核数2倍设置,扫描是IO密集型 |
| 最大线程数 | 16 | 上限要明显低于核心数,避免瞬时请求淹没目标 |
| 空闲保活时长 | 60秒 | 任务间有空档时回收线程 |
| 任务队列 | LinkedBlockingQueue(256) | 队列过大可能积压大量请求,超时后全部浪费 |
| 拒绝策略 | CallerRunsPolicy | 队列满时由提交线程执行,天然起到背压作用 |
用Executors.newFixedThreadPool()虽然简单,但默认使用无界队列,高峰期可能积压数万个待发请求,扫描器看起来在跑,实际目标早就超时了。手动设置有界队列后,任务满了就触发拒绝策略,事后再看日志能明确判断是并发不够还是目标太慢。
2.2 调度器怎么防止任务“悄悄失败”
调度器拿到URL后,不能直接无脑提交,至少要做三件事:去掉已扫描的哈希指纹、根据域名做分组、记录提交时间。哈希指纹用于URL去重,域名分组用于后面讲到的按Host并发控制,提交时间则用来判断任务是否超时。
可行的提交伪代码如下:
String fingerprint = buildFingerprint(url); if (scannedCache.contains(fingerprint)) { return; // 已扫过,直接跳过 } if (!hostSemaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) { retryQueue.offer(url); // 拿不到信号量就排队,不要硬等 return; } Future<ScanResult> future = pool.submit(() -> { try { return scanSingle(url); } finally { hostSemaphore.release(); } }); tracking.put(fingerprint, future);这里有两个参数容易被忽略:一个是tryAcquire的等待时间,建议500到800毫秒,等不到就重新入队,而不是无限阻塞;另一个是scannedCache的容量,长期运行的扫描器建议用带过期时间的本地缓存,比如5分钟,避免内存涨不停。熟悉这些细节后,调度器就不容易成为扫描器的瓶颈。
2.3 报告落地的选型:JSON入库、可读报告为辅
很多设计文档喜欢把报告模块写成“生成HTML报告”,实际做下来作用有限。扫描器输出的第一消费对象是程序,不是人。所以报告落地优先级应该是:结构化数据入库 > JSON文件导出 > HTML展示页。
建议的报告文件结构如下表:
| 报告项 | 格式 | 作用 |
|---|---|---|
| 漏洞明细 | JSON数组 | 给自动化流程消费,记录URL、参数名、Payload、证据片段 |
| 任务汇总 | JSON对象 | 记录扫描耗时、URL总数、异常请求数 |
| 确认结果 | HTML页面 | 人工复核时使用,展示请求报文和响应证据 |
结果归并阶段要刻意做一次去重:同一个URL、同一个参数、同一个漏洞类型,只保留第一次命中的记录。真实场景里一个页面上往往多个相同参数都会触发同一条规则,如果不做聚合,报告会被刷屏,后续人工复核要花几小时。
3. 从爬虫开始:用Jsoup和HttpClient把目标站点变成可检测的URL队列
3.1 为什么要自己写爬虫,而不是直接喂URL列表
扫描器的准头一半取决于爬虫覆盖率。直接给检测引擎一个URL列表,看起来省事,但漏掉了两个关键信息:一是参数位置,很多漏洞藏在?id=1&name=test这类带参请求里;二是表单入口,搜索框、登录框往往是注入高发点。自己写爬虫,才能顺着页面把静态资源和可测参数一起解析出来。
另一个理由是登录态。不少系统里敏感功能在登录后才可见,爬虫如果能先走一遍登录流程,拿到会话标识,收录到的URL会多一个数量级。这部分换成人工维护URL列表就非常痛苦。
爬虫环节常见的做法是:用HTTP客户端抓HTML,用Jsoup解析DOM提取链接和表单,然后做URL规范化与去重。下面是提取链接的最小实现:
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.util.HashSet; import java.util.Locale; import java.util.Set; public class LinkExtractor { private static final Set<String> STATIC_SUFFIX = Set.of( "jpg", "png", "gif", "css", "js", "ico", "svg", "woff", "woff2" ); public Set<String> extract(String baseUrl, String html) { Set<String> urls = new HashSet<>(); Document doc = Jsoup.parse(html, baseUrl); Elements links = doc.select("a[href], area[href]"); for (Element el : links) { String href = el.absUrl("href"); if (href.isEmpty() || href.startsWith("javascript:")) { continue; } String suffix = href.substring(href.lastIndexOf('.') + 1) .toLowerCase(Locale.ROOT); if (suffix.contains("?") || STATIC_SUFFIX.contains(suffix)) { continue; // 静态资源不做漏洞探测 } urls.add(normalize(href)); } return urls; } }关键点在absUrl,它能把href="../login.jsp"这种相对路径解析成完整URL,省去手写字符串拼接的麻烦。后缀过滤我故意留了contains("?")的判断逻辑:一个链接写成a.js?callback=1时其实带参数,理论上也可能参与探测,但默认建议跳过,因为这类参数可测价值低且扫描噪音大。真要测,可以单独开一个“全参数模式”。
3.2 URL规范化:一个函数解决重复扫描的隐患
如果扫描器里出现同一个页面被请求几十次的场面,别急着调代码,多半是URL没有规范化。常见问题包括:URL里有锚点#section、query参数顺序不一致、大小写不统一。
推荐做法是把URL解析成标准结构再重拼。代码示例如下:
import java.net.URI; import java.net.URISyntaxException; import java.util.Arrays; public class UrlNormalizer { public static String normalize(String url) throws URISyntaxException { URI uri = new URI(url); String host = uri.getHost(); int port = uri.getPort(); String path = uri.getPath() == null ? "" : uri.getPath(); String query = uri.getQuery(); if (query != null) { String[] pairs = query.split("&"); Arrays.sort(pairs); // 参数顺序不影响服务端取值,但影响去重 query = String.join("&", pairs); } URI normalized = new URI(uri.getScheme(), null, host, port, path, query == null ? null : query, null); return normalized.toString(); } }这里把fragment直接丢掉了,因为锚点不影响服务端逻辑;query里的参数值不能排序内部字符,只对“参数对”做排序,否则改动到值内容就会破坏探测语义。比如?id=1&name=admin和?name=admin&id=1会被判定为同一个URL,而?id=1和?id=2一定是两个独立探测目标。
有了规范化函数,再加上一个HashSet做已访问记录,爬虫的重复请求量能直接下降一大截。这一步是很多人忽略的“后悔药”——一开始就做好,后面不用返工。
3.3 带上登录态:爬虫能否发现隐藏页面的分水岭
不带Cookie的扫描器,扫出来的结果基本等于目标站游客视角的内容;而多数真实漏洞只在登录后的功能里。处理登录态时,我的建议是用同一个HTTP连接上下文,并在爬虫和检测器之间共享同一个CookieStore。
简化代码如下:
CookieStore cookieStore = new BasicCookieStore(); HttpClientContext context = HttpClientContext.create(); context.setCookieStore(cookieStore); // 先请求一次登录接口,服务端返回的Set-Cookie会被自动存入cookieStore HttpGet login = new HttpGet(loginUrl + "?username=test&password=pwd"); httpClient.execute(login, context); // 后续所有爬取请求都带上同一个context HttpGet page = new HttpGet("http://target/user/list"); httpClient.execute(page, context);这里最容易翻车的是把HttpGet和HttpClientContext分别new到不同地方。只要各线程发起请求时都用同一个context,登录态才会被带到检测请求里。多线程爬取时,如果多个线程共享同一个CookieStore,建议让每个线程持有自己的HttpClient实例;只共享CookieStore会带来并发写问题,极端情况下会丢Cookie。
另外提一下robots.txt。对外网扫描要尊重目标站的robots约定,这是基本的网络公序良俗;但面向自己单位内网或本地测试站点时,建议提供ignoreRobots=true开关,把robots解析结果仅作为日志记录,不阻断爬取,否则很多测试站点会漏掉大量路径。爬虫收不到入口,检测引擎再强也是白搭。
4. 检测引擎的核心参数:从SQL注入与XSS规则到响应码判定
4.1 差异判定是检测引擎的底层逻辑
很多刚做扫描器的人以为检测就是“看到关键词就报漏洞”,比如响应里出现SQL语法错误就算SQL注入,出现<script>就算XSS。这种签名匹配模式在真实站点上误报率非常高,因为正常页面里完全可能包含这些字符串。
可靠的检测逻辑是差异判定:对比正常请求和恶意请求的响应,观察是否存在稳定的可解释差异。常见的差异维度有四种:响应体是否变化、响应状态码是否变化、响应时间是否显著变长、服务端是否返回了特征性报错。组合使用这四个维度,才能把1 AND 1=1和1 AND 1=2这种典型布尔盲注识别出来。
以SQL注入检测为例,一个被广泛采用的流程是:先用无害参数请求一次拿到基线响应,再分别注入true条件和false条件,对比两者响应指纹。如果true和false的响应指纹稳定不同,而正常值响应又落在两者之间,说明参数被拼进了SQL语句。指纹不能直接比全量HTML,要先去掉动态噪音。
4.2 SQL注入检测器:反射差异与时间盲注的结合
下面给一个可用性较高的检测核心片段。这段代码不追求覆盖所有数据库语法,而是把布尔盲注和时间盲注两条最通用的路径走通:
public class SqlInjectionScanner implements VulnScanner { private static final List<String> BOOLEAN_PAYLOADS = List.of( "1' AND '1'='1", // true "1' AND '1'='2" // false ); private static final List<String> TIME_PAYLOADS = List.of( "1' AND SLEEP(2)-- ", "1) AND SLEEP(2)-- " ); @Override public List<VulnFinding> scan(ScanRequest req, ScanContext ctx) { List<VulnFinding> findings = new ArrayList<>(); // 基线请求 String baselineBody = sendAndRead(req.url(), req.param(), "1"); // 布尔盲注:对比响应指纹 String trueBody = sendAndRead(req.url(), req.param(), BOOLEAN_PAYLOADS.get(0)); String falseBody = sendAndRead(req.url(), req.param(), BOOLEAN_PAYLOADS.get(1)); if (fingerprint(trueBody).equals(fingerprint(baselineBody)) && !fingerprint(falseBody).equals(fingerprint(baselineBody))) { findings.add(new VulnFinding("SQLI_BOOLEAN", BOOLEAN_PAYLOADS.get(0))); return findings; } // 时间盲注:同一payload发两次都超阈值才认定 if (isTimeBlindConfirmed(req, TIME_PAYLOADS.get(0))) { findings.add(new VulnFinding("SQLI_TIME", TIME_PAYLOADS.get(0))); } return findings; } private boolean isTimeBlindConfirmed(ScanRequest req, String payload) { long first = timeCost(req, payload); long second = timeCost(req, payload); return first > 3500 && second > 3500; } }这里最值得说的是isTimeBlindConfirmed里“同一条payload发两次”的设计。SLEEP(2)这条命令指望响应至少2秒。真实网络抖动下,正常请求也可能偶尔1秒多才返回,只测一次会把慢网络误判成漏洞。连续两次都超过阈值,误报率会明显下降。
时间阈值也值得单独调:不能用固定值。请求本身往返0.3秒、目标服务器GC停顿0.5秒都是常态,所以建议取值是payload中的sleep时长 + 1.5秒。上面代码里sleep是2秒,阈值就到3500毫秒。如果是扫描海外站点,RTT本身就高,阈值还要再放宽。
4.3 XSS检测与响应体解码
XSS检测很容易做成“响应体包含payload就报漏洞”,这是错误示范。正确流程是先定位payload出现在响应中的位置,再判断是否落在可执行上下文里。比如payload出现在<title>标签里、出现在HTML注释里、出现在<script>字符串里,危害等级完全不同。
下面给一个反射型XSS的最小判定片段:
public class XssScanner implements VulnScanner { private static final List<String> PAYLOADS = List.of( "<script>alert(1)</script>", "<img src=x onerror=alert(1)>" ); @Override public List<VulnFinding> scan(ScanRequest req, ScanContext ctx) { List<VulnFinding> findings = new ArrayList<>(); String responseBody = sendAndRead(req.url(), req.param(), "1"); for (String payload : PAYLOADS) { String bodyWithPayload = sendAndRead(req.url(), req.param(), payload); if (!bodyWithPayload.contains(payload)) { continue; // 响应里根本没反射,无需再判断 } // 检查payload是否被HTML实体编码 if (bodyWithPayload.contains(payload.replace("<", "<"))) { continue; // 输出点做了转义,风险较低 } findings.add(new VulnFinding("XSS_REFLECTED", payload)); break; } return findings; } }这段代码的核心不是找个字符串,而是“反射 + 解码 + 上下文判断”三级过滤。很多常见页面会把<转成<,此时payload还完整出现在响应里,但是无害的。直接报漏洞会让开发者花一下午复查一个根本不存在的安全问题。实际工程里我还会再判断payload是否出现在<script>标签内部,如果输出点在<script>里,转义逻辑更复杂,误报判定要更谨慎。
4.4 检测引擎必调的参数清单
下面这个表是扫描器上线前应该形成固定配置的参数。
| 参数 | 建议值 | 影响 |
|---|---|---|
requestTimeoutMs | 8000 | 太长会让盲注判定变慢,太短会误杀慢接口 |
blindConfirmTimes | 2 | 时间盲注至少复测两次再报,防网络抖动 |
maxRedirects | 3 | 超过后按异常处理,记录日志继续下一项 |
retryTimes | 1 | 仅对网络层失败重试,不应对业务响应重试 |
userAgent | 真实浏览器UA | 部分站点对无UA请求直接拦截或返回静态页 |
reportLevel | CONFIRMED | 只输出二次确认过的漏洞,减少人工复核 |
maxConcurrentPerHost | 2 | 避免单目标瞬间承受过高QPS |
其中maxConcurrentPerHost最容易被漏掉。全局并发16不代表每个目标最多16并发,如果任务队列里同时包含多个不同站点,每站分摊下来可能只有1到2并发;反过来,如果某个扫描任务只配置了一个目标,全局16并发就会全部打在同一台机器上,目标很容易直接被压出故障。按Host做信号量,每站限2个并发,是实践中相对稳妥的状态。
5. 避坑排查:误报、超时和把目标扫挂的三类现场
5.1 误报频发:扫描器把正常页面当成了SQL注入
现象:扫描完报告里列出十几条SQL注入,人工复核时发现全是同一个动态新闻列表页,返回内容随点击次数变化,跟注入毫无关系。
原因:检测逻辑只比较了“注入true响应”和“注入false响应”是否有差异,没先确认基线请求和true请求是否一致。新闻页内容本来就每刷新一次变一条,false响应差异可能只是换了一条新闻,被误读成了注入特征。
解决:引入内容指纹后做基线对齐。指纹算法先剥离掉动态区块里的噪音:
private String buildPageFingerprint(String html) { Document doc = Jsoup.parse(html); doc.select("script, style, nav, footer, #newsList, .ads").remove(); String text = doc.body() == null ? "" : doc.body().text(); return text.length() > 200 ? text.substring(0, 200) : text; }然后判定时才比较“基线指纹”和“true响应指纹”是否一致。如果不一致,说明页面本身在动态变化,本次不能作为有效证据,应标记为待复核而不是漏洞。这样能把大量动态页面造成的假阳性挡在报告之外。
5.2 目标被打挂:扫描器成了攻击源头
现象:扫描刚开始5分钟,目标站点响应码从200变成500,再往后直接连接超时。重启目标后扫描器再次运行,又复现同一问题。
原因:并发线程超过目标承受能力,加上部分请求参数会让后端执行慢查询,两个因素叠加导致数据库连接池耗尽。很多自称“我设置了8线程”的扫描器,实际上定时任务每轮都会新建线程,几轮下来总并发早就超过预期。
解决:给每个Host独立限流,而不是只设全局线程数。
Semaphore hostPermit = new Semaphore(2); // 每个Host最多2个并发 boolean acquired = hostPermit.tryAcquire(800, TimeUnit.MILLISECONDS); if (acquired) { try { executeScanRequest(req); } finally { hostPermit.release(); } } else { retryQueue.offer(req); // 拿不到信号量就排队,别硬等 }这段代码配合调度器是可行的:hostPermit信号量定义在Host维度的HashMap上,每来一个请求先按域名取信号量。拿不到就重新放回队列,而不是在调度线程里空转。这样即使扫描20个域名,总并发也不会让任何单一站点承受过载。另一个配套手段是设置“单任务最大千请求数”,一万个URL爆出来的站多半是爬虫误抓了列表页,不值得全量扫描。
5.3 登录态丢失:检测结果大面积401
现象:扫描器配置了登录账号,爬虫阶段能正常访问用户中心,但检测阶段大量请求返回401或302跳登录页,最终报告一片“未授权访问”错误。
原因:最常见的两种情况。一是爬虫和检测器用了不同的HTTP客户端,登录Cookie只在爬虫侧生效;二是在多线程场景下,每个线程都从各自的上下文取Cookie,有的线程没取到登录态就发了请求。
解决:让登录态成为扫描任务的全局共享上下文,在任务初始化阶段先完成登录预热,再把CookieStore的引用注入给每个工作线程。注意不要用static CookieStore,多线程并发读写同一个CookieStore会在高并发下丢Cookie,建议是基于ThreadLocal包装一层,或者用一个线程安全的CookieStore实现。判定请求失败时,先检查响应里是否包含登录页特征,比如标题变成login、状态码是302且跳转login,这类请求不做漏洞判定,单独计入无效请求日志,便于事后发现是登录态掉了还是目标本身做了会话失效。
5.4 把安全设备的拦截页当成漏洞证据
现象:报告里出现大量“疑似SQL注入”,证据片段不是目标应用报错,而是一段“非法请求已拦截”的提示文案。
原因:目标前面有WAF或IPS设备,恶意payload到达应用前就被拦截了。扫描器只看到响应变化,就以为是业务参数进入SQL语句后导致的差异。
解决:在检测器里加入“组件指纹识别”前置步骤。先把拦截页的典型特征(比如“非法请求”“blocked by security policy”)保存成一个特征库,扫描时看到疑似特征,就把该请求标记为blocked,不作为漏洞证据。另外,检出漏洞后自动重放一次请求并把正反两次响应存成证据文件,人工复核时能直接看到请求报文和响应差异,而不是只看到一个结论字段。这个机制同时还能对付那种“第一次命中、重放却不再命中”的偶发问题——这类多半是目标系统临时异常,也不是真实漏洞。
6. 验证跑通之后:用本地靶场量化检出率,再把扫描器接进自动化流程
很多设计做完就停在“能跑”这一步,其实还差一个关键动作:验证扫描器的检出率。没有验证,你根本分不清规则是有效还是自嗨。我的习惯是本地建一个最小的漏洞靶场,把自己实现的检测项全部埋进去,扫一遍看能命中几个。
我通常会用本地Java Web容器跑一个测试应用,只放四个接口:一个数值型SQL注入点,一个盲注点,一个反射型XSS点,一个返回登录页的未授权接口。然后跑一轮扫描,结果用一张表记录:
| 靶场接口 | 注入漏洞 | 预期结果 | 扫描器结果 | 判定 |
|---|---|---|---|---|
/lab/sqli?id= | 字符型报错注入 | 检出 | 命中 | 通过 |
/lab/boolean | 布尔盲注 | 检出 | 命中 | 通过 |
/lab/xss?kw= | 反射型XSS | 检出 | 命中 | 通过 |
/lab/admin | 无漏洞正常页 | 不报 | 未报 | 通过 |
这张表跑完,大问题基本都会暴露。比如布尔盲注没检出,说明指纹算法太激进,把正常差异也归一化了;XSS多报了几条,说明解码判断漏了转义分支。把这些修正完,扫描器才算有一个可交代的交付物。
验证通过后的进阶方向,是把扫描器接进自动化流程而不是停在“手动点按钮”。常见做法是把扫描器打包成一个命令行任务,输出JSON结果,由持续集成任务在部署前触发。检测到confirmed漏洞时任务返回非零退出码,阻断本次发布;没有漏洞则正常通过。这样扫描器就从“毕设Demo”变成了一个真正有人用的自查工具。
最后说句实操建议:做这类系统,别急着堆漏洞规则,先把爬虫覆盖率、响应指纹、误报过滤这三件事做扎实。我做完第一版时,规则库只有十条不到,但因为有完整的证据文件和指纹机制,每次扫描结果的可用性反而比自己后来堆到几十条规则、却没有过滤机制时更好。先求每个报出的结果都经得起复核,再求数量。希望这个思路帮到你。
本文还有配套的精品资源,点击获取