
简介一套基于Java的漏洞扫描系统完整项目面向Java工程师与网络安全入门者解决从零搭建漏洞扫描工具的核心需求覆盖端口扫描、服务识别、漏洞指纹匹配、扫描策略配置与报告生成等关键环节。资源包共927个文件大小33.07MB主体为604个.nse脚本与146个Lua文件主要承担漏洞探测与指纹匹配同时提供Java源码、class字节码、jar依赖包及XML/TXT配置文档用于扫描引擎构建、规则加载和结果输出。目前已有182人学习下载。借助这套资源读者可深入理解Java Socket网络通信、多线程并发扫描、漏洞数据库集成、SSL/TLS指纹识别等实现思路项目还包含Nmap服务探测数据库、SSL指纹库和端口服务列表等数据文件便于直接运行也可用于课程设计、毕业设计或安全测试工具是研究漏洞扫描系统内部机制的高质量素材。1. 基于Java的漏洞扫描系统.zip比“能跑”更值钱的是扫描引擎怎么拆当你解压一个基于Java的漏洞扫描系统.zip第一反应可能是 mvn spring-boot:run看它能不能起来。可一旦跑通你会发现问题不在启动而在于扫描引擎怎么拆任务调度、网络探测、漏洞判定、结果沉淀。我见过太多把端口扫描和漏洞匹配写进一个大 for 循环的毕设扫 100 个 IP 就内存吃满、误报不断。这篇文章不替某个 zip 做源码讲解而是把这个标题背后最常见的工程方案拆给你看从 Java 线程池、Socket 探测、指纹匹配到报告输出每一层可复用的代码和参数都写出来并标出容易翻车的地方。适合两类人用 Java 做安全工具开发的工程师以及想拿这个方向做面试项目的 Java 学习者。2. 先立骨架把漏洞扫描拆成调度、探测、判定三层2.1 为什么不用脚本而用 Java 重写调度层做漏洞扫描最快的原型是 Shell 或 Python 脚本nc 探测端口、curl 拉 Banner、grep 匹配漏洞规则。为什么还要用 Java 重写两个核心原因并发和状态管理。Python 脚本的并发往往靠多进程子进程通信、资源回收都是麻烦Java 从语言层面给你线程池、Future、CompletableFuture可以精确控制扫描的速率和并发度。第二个原因是集成——企业里已有的资产管理系统、工单系统大多跑在 JVM 上用 Java 写的扫描器可以作为一个 Maven 模块直接嵌进去而不是在 Jenkins 上额外维护一套 Python 环境。另外Java 的类型系统在构造扫描任务时很有用。你有一个目标 IP、一个端口列表、一组超时策略如果全用 MapString,Object 塞来塞去写起来快但跑起来很容易出现 ClassCastException。定义成带字段的类后续加规则也好、加报告字段也好编译器能帮你拦掉一批低级错误。这也是为什么很多一线团队在重写安全工具时默认选 Java 而不是脚本。常被忽略的一点是定时触发。漏洞扫描不能只跑一次通常需要固定节奏比如每周对全量资产扫一遍。Java 这边有成熟的定时任务框架Spring Task 的 Scheduled、Quartz 的 CronTrigger都可以让扫描任务自动排队。注意如果你的扫描器是独立进程而不是 Spring Boot 应用用 ScheduledExecutorService 也够但要处理 JVM 退出时的任务持久化这业务上并不简单很容易丢任务。所以我的习惯是调度器只负责触发扫描任务本身写进数据库靠状态字段驱动而不是靠内存里的 Map 保存待扫队列。这里展开说下什么叫“状态字段驱动”。最常见的一张表是 scan_job字段包括 job_id、target_range、scan_type、statuspending/running/finished/failed、create_time、finish_time。调度器到点后只做一件事把新的 job 插入数据库状态置为 pending。然后一组 worker 线程轮询 pending 的 job取出来拆分成 ScanTask 丢进线程池。这样 JVM 重启未完成任务仍然在数据库里不会凭空消失。2.2 任务模型与线程池参数从 ScanTask 到 ExecutorService 的最小代码先定义扫描任务。一个任务代表“对某个 IP 的某个端口做一次探测”包含重试次数和超时参数。不过实际中更常见的粒度是“一个目标主机的全端口扫描”但为了并发控制精细我一般拆到单端口这样某几个慢端口不会阻塞同一主机的其他端口。public class ScanTask implements Runnable { private final String host; private final int port; private final int timeoutMs; private final int retries; private final String scanId; public ScanTask(String host, int port, int timeoutMs, int retries, String scanId) { this.host host; this.port port; this.timeoutMs timeoutMs; this.retries retries; this.scanId scanId; } Override public void run() { // 具体探测逻辑在第三章实现这里先做超时包裹 try { boolean open PortProber.probe(host, port, timeoutMs); if (open) { ScanResultHolder.record(scanId, host, port, open); } } catch (Exception e) { ScanResultHolder.record(scanId, host, port, error: e.getMessage()); } } }这段代码把“要做什么”和“怎么做”分离了。ScanTask 只负责携带参数和结果落点真正的 Socket 逻辑放在 PortProber 里。ScanResultHolder 是一个内部缓冲可以用 ConcurrentHashMap 按 scanId 存结果避免在线程里直接操作共享的 ArrayList。线程池不能乱写。我见过直接用 Executors.newFixedThreadPool(1000) 的瞬间把系统文件描述符打满。正确姿势是手动 new ThreadPoolExecutor显式指定队列和拒绝策略int cores Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor new ThreadPoolExecutor( cores * 2, cores * 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(scan-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );参数含义核心线程数给 CPU 数的两倍是因为扫描任务大部分时间阻塞在网络上不需要占满 CPU核心线程多一些能让更多连接并行。最大线程数给四倍防止极端情况下把内存打爆。队列用有界队列容量 200一旦挤满就触发拒绝策略。拒绝策略我选 CallerRunsPolicy即把多余任务退回提交者线程执行这样不会丢任务同时天然给系统一个背压信号。如果你用 Java 8ThreadFactoryBuilder 来自 Guava不想引依赖就实现 ThreadFactory写个带前缀的线程工厂也很简单。这里多啰嗦一句线程池里线程名一定要起好不然线程 dump 时你看到的全是 pool-3-thread-1排查问题想哭。另一个容易踩的坑是线程池用了无界队列任务量一大队列里堆几万个 ScanTask每个都持有 IP 和端口字符串内存几十兆起步。用有界队列加上拒绝策略是最稳妥的做法。2.3 扫描速率控制令牌桶在调度层的实现不管线程池参数调得多好真正推到生产环境时最容易被安全设备盯上的就是“扫描速率失控”。我见过一个并发 500 的任务把目标机房的千兆带宽打满最后对方运维直接拔线。要控制速率最实用的工具是 Guava 的 RateLimiter或者你自己写一个简单的令牌桶。public class ScanRateLimiter { private final Semaphore semaphore; private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); private final int permitsPerSecond; public ScanRateLimiter(int permitsPerSecond) { this.permitsPerSecond permitsPerSecond; this.semaphore new Semaphore(permitsPerSecond); scheduler.scheduleAtFixedRate(() - semaphore.release(permitsPerSecond - semaphore.availablePermits()), 1, 1, TimeUnit.SECONDS); } public void acquire() { semaphore.acquireUninterruptibly(); } }这段代码每秒补充固定数量的许可ScanTask 在真正发起 Socket 连接前先 acquire()。速率设置要根据目标资产规模调整扫描内网 50 台机器每 IP 每秒 10 个包足够扫描公网域名建议降到每 IP 每秒 2-3 个包。别小看这个参数它决定你的扫描器是被当成正常审计流量还是被 IDS 重点标记。令牌桶的另一个好处是天然支持突发秒级突发几个包不会触发告警长期匀速才是最安全的。3. 探测层落地端口扫描与 Banner 抓取的两个核心模块3.1 TCP Connect 端口扫描超时、并发、结果过滤的调参细节端口扫描是漏洞扫描的地基端口不对后面所有指纹和规则都白搭。Java 实现一个 TCP Connect 扫描非常直接就是新建 Socket 去 connect在规定时间内连上说明端口开放。这里的核心变量是超时时间太短网络抖动一下就会漏报太长几百个端口扫下来整体耗时会拖垮调度。public class PortProber { public static boolean probe(String host, int port, int timeoutMs) { try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); socket.setSoTimeout(timeoutMs); return true; } catch (IOException e) { return false; } } public static ListInteger scanRange(String host, int startPort, int endPort, int timeoutMs) { ListInteger openPorts Collections.synchronizedList(new ArrayList()); CountDownLatch latch new CountDownLatch(endPort - startPort 1); // 配合线程池逐个提交或使用 parallelStream for (int p startPort; p endPort; p) { executor.submit(() - { try { if (probe(host, p, timeoutMs)) { openPorts.add(p); } } finally { latch.countDown(); } }); } try { latch.await(2, TimeUnit.MINUTES); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return openPorts; } }注意两点一是 try-with-resources 确保 Socket 一定关闭否则连接多了会耗尽文件描述符。二是 connect 的 timeoutMs 和 SO_TIMEOUT 是两回事前者管建立连接后者管后续读取。这里 setSoTimeout 是为了后面 Banner 读取不被卡死。超时参数的江湖经验是同机房内网800ms 够用跨公网扫描建议 1500ms 到 3000ms。如果你想扫描的端口不是常见的 1-65535而是针对特定服务比如 3306、6379、9200可以把超时降到 300ms毕竟这些内部服务暴露在公网本来就值得怀疑。扫描结果里经常会混入一些“半开”端口——防火墙接受 TCP 握手但不给数据。区分方法是连接建立后立刻写一个空数据包然后读一次流读不到数据并且连接被 reset多半是防火墙。这个技巧在结果过滤里很有用。并发度怎么定前面线程池最大线程数给了 cores*4但实际单机扫描 1000 个端口时系统默认的 socket 连接数上限Linux 下 /proc/sys/net/core/somaxconn 和 ulimit -n会先打满。经验值单进程同时保持 200 个 TCP 连接比较安全再多容易把目标服务打挂也容易被 IDS 特征匹配到。控制手段很简单信号量或者线程池容量。3.2 服务指纹识别NIO 与正则匹配的取舍端口开了之后下一步是知道背后跑的是什么服务。最常见的探测是发一个协议触发包比如 HTTP 服务就发 GET / HTTP/1.0\r\n\r\nSSH 服务等它主动弹 Banner。Java 里做这个传统 IO 就能干但你要处理“服务不响应”和“响应半截”的情况。NIO 能批量管理成百上千个非阻塞连接可代价是代码复杂度和调试难度直线上升。我的看法是如果扫描规模在几千个 IP 以内用普通 Socket 线程池就够了只有到了资产数十万才值得引入 Netty 或 NIO 重写探测层。public class BannerGrabber { public static String grab(String host, int port, int timeoutMs) { try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); socket.setSoTimeout(timeoutMs); OutputStream out socket.getOutputStream(); out.write(GET / HTTP/1.0\r\nHost: host \r\n\r\n.getBytes(StandardCharsets.UTF_8)); out.flush(); BufferedReader reader new BufferedReader(new InputStreamReader(socket.getInputStream())); StringBuilder banner new StringBuilder(); char[] buffer new char[1024]; int len; long deadline System.currentTimeMillis() timeoutMs; while ((len reader.read(buffer)) ! -1 System.currentTimeMillis() deadline) { banner.append(buffer, 0, len); if (banner.length() 8192) break; // 防止被大响应包拖死 } return banner.toString(); } catch (IOException e) { return ; } } }这里有个坑很多服务要等先发数据才回 Banner比如 MySQL、Redis它们不一定理解 HTTP 请求。所以完整的指纹探测要对常见端口准备不同的触发包比如 MySQL 用它的握手包Redis 直接发 PING\r\n。这个触发包与端口的映射表是扫描器里最占维护精力的部分。别想着用一套万能请求通吃不现实。正则匹配 Banner 时建议不要用贪婪匹配。比如你想提取 SSH 版本号用 (?i)SSH-([\d.]) 而不是 SSH-(.*)。前者能准确抓版本后者会把整个 Banner 都吞进去影响后续漏洞版本匹配。另外Banner 里经常带 ANSI 颜色码、乱码控制字符匹配前先清理一下非可见字符能显著降低误报。清理可以用 banner.replaceAll([\x00-\x1f\x7f], )但这会去掉换行如果需要按行匹配就先 split 之后再处理。3.3 UDP 扫描为什么大多数 Java 扫描器不做端口扫描还有一个 UDP 维度但绝大多数基于 Java 的漏洞扫描系统并不实现 UDP 深度探测。原因是 UDP 无连接你发一个包过去对端可能不理你也可能返回一个 ICMP 不可达Java 标准库拿不到 ICMP 信息所以判断“端口是否开放”很难。常见的做法是用 UDP Socket 发送探测包后等待响应等不到就算关闭。这个逻辑简单但误报率很高。如果你的项目标题里明确要支持 UDP 服务比如 DNS、SNMP、TFTP我建议不要自己造轮子直接调外部工具如 nmap 的结果导入扫描器Java 端只负责解析输出。这不是偷懒而是把专业的事交给专业工具避免你花两周调一个永远不准的 UDP 扫描器。4. 判定层与结果沉淀规则匹配与报告生成4.1 用 Java 规则引擎做漏洞匹配Map、正则、优先级拿到服务 Banner 和端口信息后进入判定层。很多初学者一上来就引入 Drools、Easy Rules实际上你手头只有几百条漏洞规则时用 Java 的 Pattern 和 HashMap 就是最高效、最容易调试的方案。规则引擎只有在规则数量上万、需要复杂的条件组合和冲突消解时才有优势。规则定义一个不可变类public class VulnRule { private final String vulnId; // 例如 CVE-2021-41773 private final String serviceName; private final String versionPattern; // 正则从 Banner 提取版本 private final String matchPattern; // 正则直接匹配 Banner private final String[] affectedVersions; // 受影响版本列表 private final int severity; // 1-5 public boolean matches(String banner) { if (matchPattern ! null !Pattern.compile(matchPattern, Pattern.CASE_INSENSITIVE) .matcher(banner).find()) { return false; } if (versionPattern ! null) { Matcher matcher Pattern.compile(versionPattern).matcher(banner); if (!matcher.find()) return false; String version matcher.group(1); for (String affected : affectedVersions) { if (versionMatches(version, affected)) { return true; } } return false; } return true; } }这里的关键是 versionMatches 方法。直接 equals 比较版本号会漏比如 Banner 写 Apache 2.4.49 而规则写 2.4.49中间带空格。我一般会写一个简单的比较器先把版本拆成数字段再按段比较支持通配符和区间。不要用什么语义化版本库漏洞版本范围往往包含 2.4.49 这类标记自己维护一个 30 行的小工具类更可控。规则匹配的顺序要讲究优先级。端口 80/443 上的漏洞规则很可能有几十条如果每来一个 Banner 就把所有规则全跑一遍性能没问题但会有“重复命中”和“子规则覆盖父规则”的问题。我的做法是按 serviceName 建 MapString, List 先过滤服务再按 severity 降序排列命中的第一条加入结果同时记录一个“已覆盖”的标记后面更低优先级的规则如果命中同一个 CVE 组自动跳过。这样报告里不会出现同一漏洞被报三次的尴尬。规则从哪来如果你是个人项目可以手工从 NVD、CNVD 维护一小部分高危漏洞规则如果是生产系统建议接入商业或开源的漏洞库然后转换成内部规则格式。手工维护规则时一定要留一个规则来源字段方便到时候回溯。我见过有人把网上乱抄的规则塞进生产库误报率直接飙到 80%最后只能一条条翻日志删规则。4.2 扫描结果落库与 JSON 报告输出扫描结果不能只放在内存。生产环境通常要落库MySQL 或 PostgreSQL 建一张 scan_result 表字段大概有scan_id、host、port、service_name、vuln_id、severity、matched_banner、scan_time。写库可以用 Spring Data JPA但如果你不想引整套框架用 JDBC 一个线程安全的 Batch 插入也能凑合。注意批量插入时控制 batch size200 条一批比较稳太多容易撑爆数据库连接的 prepared statement。public class ScanResult { private String scanId; private String host; private int port; private String serviceName; private String vulnId; private int severity; private String matchedBanner; private long scanTime; public String toJson() { // 使用 Jackson ObjectMapper这里省略依赖导入 ObjectMapper mapper new ObjectMapper(); try { return mapper.writeValueAsString(this); } catch (JsonProcessingException e) { return {\error\:\serialize failed\}; } } }如果你不想引 Jackson手动拼 JSON 也可以但一定要处理转义Banner 里出现双引号和反斜杠是常事。我建议至少用 Jackson 或 Gson手动拼字符串的翻车率实在太高。报告输出上我一般同时生成 JSON 和 CSV 两种格式JSON 给下游系统对接CSV 给人看用 Excel 打开后可以透视排序。CSV 的坑是逗号和换行Banner 里都有输出时必须用引号包裹并按 RFC 4180 转义双引号。报告里最好带上落败的规则匹配记录也就是“哪些规则候选但没匹配上”。这看起来浪费空间但对后续规则调优是救命的数据。你在线上看到某个服务版本特别多却一直没有命中漏洞有了这个候选记录就能快速发现正则写窄了。4.3 结果去重与聚合别让报告淹没运维漏洞扫描的原始记录是“主机-端口-CVE”三件套但运维同学看报告时真正想知道的是“我们一共有多少个主机受影响、涉及哪些漏洞”。所以判定层之后一定要有一层聚合。我习惯的做法是扫描结果落库后跑一个 SQL 按 vuln_id 和 service_name 做分组统计受影响主机数量生成一张 risk_summary 表。前端展示时直接查这张表比一次查几千条原始记录快得多。聚合时要注意同一个 CVE 在同一主机多个端口命中应该合并成一条否则报告里一个漏洞出现三四次排名虚高。5. 避坑指南Java 漏洞扫描系统开发中常见的 6 个问题5.1 线程一多就卡死文件描述符耗尽与连接池没有上限现象扫描线程从 50 加到 200程序突然全部阻塞日志出现 SocketException: Too many open files。 原因Linux 默认单进程文件描述符限制是 1024每个 Socket 连接至少占一个 fd加上线程栈和日志文件200 个并发连接很容易超限。 解决先改系统 ulimit -n 到 65535更关键的是在 Java 线程池里加一个 Semaphore 限制同时打开的 Socket 数不超过 300。另外所有 Socket 创建都用 try-with-resources确保异常路径也关闭连接。这里有个细节Semaphore 要在 try 块里 acquire但 release 必须在 finally 中执行否则探测异常时许可不会归还线程池很快全部阻塞。5.2 超时设太短导致漏报错报Connect 超时和 Read 超时是两回事现象扫描结果里大量端口显示 closed但手工 nc 连一下是通的。 原因很多人只设了 connectTimeout 1000ms然后读取 Banner 时沿用同一个超时某些服务响应慢超过 1 秒就被判定失败。 解决把连接超时和读取超时分开设置。连接超时控制在 1000ms 左右读取超时至少 3000ms。同时建议加一个重试机制对首次无响应的端口做第二次探测避免网络抖动影响结果。重试次数不是越多越好我一般设 1 次再多会显著拉长整体扫描时间。5.3 正则贪婪匹配导致版本识别失败现象Apache 2.4.49 的 Banner 匹配不到 CVE-2021-41773 规则或误报成 2.4.49-2.4.50。 原因用 Server: Apache/(.*?) 时捕获组里包含后续的 OS 信息版本比较时遇到非数字字符。 解决版本提取正则改为 Server: Apache/([\d.])只捕获数字和点并且用非贪婪模式。另外匹配前先做数据清洗去掉换行、转义序列和多余空格。这里可以加一个小测试把典型的 Banner 存成单元测试样本每次改正则后先跑测试避免把原本正确的匹配改坏。5.4 内存溢出结果列表无界增长现象扫描 2 万个端口后内存占用持续上升最终 OOM。 原因把全部 ScanResult 放在内存 ArrayList 里没有及时落库GC 回收不掉。 解决ScanResultHolder 里用一个有界队列比如 ArrayBlockingQueue(5000)当队列满时由消费者线程批量写入数据库。写库失败时要丢弃还是重试我建议丢弃并记录错误计数避免队列被卡死。另一个容易忽视的点是 matchedBanner 字段Banner 最长可能 8KB2 万条就是 160MB所以在存储前要把 Banner 截断到 1KB既保留关键指纹又不爆内存。5.5 JDK 版本差异TLS 握手报 “算法被禁用”现象在 JDK 8 上跑得好好的扫描器升级到 JDK 17 后连很多 HTTPS 站点都报 SSLHandshakeException。 原因新版 JDK 默认禁用了 TLSv1、TLSv1.1 和一部分弱加密套件而很多老旧服务只支持这些协议。 解决如果扫描目标确实包含老设备可以在 JVM 参数里临时启用-Dhttps.protocolsTLSv1,TLSv1.1,TLSv1.2。但要注意启用旧协议本身也有合规风险只应在授权的内部资产扫描时使用。更稳妥的做法是扫描器里针对 HTTPS 资产单独维护一个 SSLContext只对老设备名单启用弱协议而不是全局放开。5.6 扫描目标未授权被安全设备封 IP 甚至产生法律问题现象扫描器跑得正欢突然所有请求超时然后运维邮件过来说是扫描器触发了 IDS把对端设备搞宕机了。 原因没有确认目标授权扫描速率过高或没有在扫描前加目标白名单校验。 解决扫描入口加一个授权状态检查数据库维护一张“已授权资产表”不在表里的 IP 一律拒绝扫描。同时把并发度降低扫描速率限制在每个 IP 每秒不超过 20 个包。这条不是技术问题但比所有技术问题都重要。我还建议在扫描计划里加一个“紧急停止开关”一旦收到目标方投诉能立刻暂停整个扫描集群的任务分发。6. 把扫描系统当产品打磨用自建靶机集做误报率回归前一章说了这么多坑最后分享一个我长期保留的习惯给扫描器建一个最小化的“靶机集”用来做规则回归。我本地用 Docker 起一组容器固定版本Apache 2.4.49、OpenSSH 7.4、Redis 5.0 等每个容器只暴露对应端口然后写一个 JUnit 参数化测试把 Banner 样例和期望命中的漏洞 ID 直接放进测试数据。ParameterizedTest CsvSource({ Apache/2.4.49, CVE-2021-41773, true, Apache/2.4.50, CVE-2021-41773, false, SSH-2.0-OpenSSH_7.4, CVE-2017-15906, true }) void testVulnMatch(String banner, String vulnId, boolean expected) { VulnRule rule ruleRegistry.find(vulnId); Assertions.assertEquals(expected, rule.matches(banner)); }这个技巧的价值在于每当你改了一条正则或者新增一个漏洞规则跑一遍测试就能知道有没有影响其他规则。以前我写扫描器总是先写探测后写匹配结果线上误报率很高被同事吐槽是“漏洞复读机”。后来改成先积累测试样例再写规则先把已确认的 Banner 样本变成测试集规则只会在测试集上越改越准。这个习惯比任何调优技巧都管用。你可以把测试集放到 src/test/resources 下每次发布扫描器前跑一次 mvn testCI 里直接挡住回归。除了单元测试我还会每个月做一次真机验证从已授权资产里抽 5 台手工跑一遍扫描把结果和商业扫描器做对比。漏报的补规则误报的调正则。这套流程跑下来扫描器的准确率会稳定在一个可信水位。希望这个方向能帮到你哪怕是刚开始写第一个 Socket 探测也先把这个骨架立住后面加规则只是时间问题。希望帮到你。本文还有配套的精品资源点击获取