commit f6c3d70d2f412a6569ffc7a180582df01cf654a8
parent f78c37edd54d7ccea53f8d3101832c479aeb1a1c
Author: Matthias Jaros <jaros@mailbox.org>
Date: Wed, 1 Jul 2026 13:43:38 +0200
Added banner recognition for basic discovery of services
Diffstat:
7 files changed, 135 insertions(+), 24 deletions(-)
diff --git a/src/main/java/com/it_jaros/jscanner/App.java b/src/main/java/com/it_jaros/jscanner/App.java
@@ -31,10 +31,10 @@ public class App {
System.out.println(result.host());
if (!result.openPorts().isEmpty()) {
- System.out.printf("\t (%d) open:\t%s%n", result.openPorts().cardinality(), map(result.openPorts()));
+ System.out.printf("\t (%d) open:\t%s%n", result.openPorts().cardinality(), map(result.openPorts(), result.bannerRecognition()));
}
if (showFilteredPorts && !result.filteredPorts().isEmpty()) {
- System.out.printf("\t (%d) filtered:\t%s%n", result.filteredPorts().cardinality(), map(result.filteredPorts()));
+ System.out.printf("\t (%d) filtered:\t%s%n", result.filteredPorts().cardinality(), map(result.filteredPorts(), null));
}
System.out.println();
}
@@ -42,4 +42,16 @@ public class App {
private static String map(BitSet ports) {
return ports.stream().mapToObj(String::valueOf).collect(Collectors.joining(","));
}
+
+ private static String map(BitSet ports, java.util.HashMap<Integer, ServiceType> bannerRecognition) {
+ return ports.stream().mapToObj(port -> {
+ ServiceType serviceType = bannerRecognition.getOrDefault(port, ServiceType.UNKNOWN);
+
+ if (serviceType == ServiceType.UNKNOWN) {
+ return String.valueOf(port);
+ }
+
+ return port + "/" + serviceType.name().toLowerCase();
+ }).collect(Collectors.joining(","));
+ }
}
diff --git a/src/main/java/com/it_jaros/jscanner/PortResult.java b/src/main/java/com/it_jaros/jscanner/PortResult.java
@@ -1,4 +1,31 @@
package com.it_jaros.jscanner;
-public record PortResult(int port, PortState state) {
+public class PortResult {
+ private int port;
+ private PortState state;
+ private String banner;
+
+ public int getPort() {
+ return port;
+ }
+
+ public void setPort(int port) {
+ this.port = port;
+ }
+
+ public PortState getState() {
+ return state;
+ }
+
+ public void setState(PortState state) {
+ this.state = state;
+ }
+
+ public String getBanner() {
+ return banner;
+ }
+
+ public void setBanner(String banner) {
+ this.banner = banner;
+ }
}
diff --git a/src/main/java/com/it_jaros/jscanner/PortState.java b/src/main/java/com/it_jaros/jscanner/PortState.java
@@ -1,7 +1,8 @@
package com.it_jaros.jscanner;
public enum PortState {
- OPEN,
CLOSED,
- FILTERED
+ FILTERED,
+ OPEN,
+ UNKNOWN
}
diff --git a/src/main/java/com/it_jaros/jscanner/ScanResult.java b/src/main/java/com/it_jaros/jscanner/ScanResult.java
@@ -2,4 +2,5 @@ package com.it_jaros.jscanner;
import java.util.BitSet;
-public record ScanResult(String host, BitSet openPorts, BitSet filteredPorts) {}
+public record ScanResult(String host, BitSet openPorts, BitSet filteredPorts,
+ java.util.HashMap<Integer, ServiceType> bannerRecognition) {}
diff --git a/src/main/java/com/it_jaros/jscanner/Scanner.java b/src/main/java/com/it_jaros/jscanner/Scanner.java
@@ -3,12 +3,15 @@ package com.it_jaros.jscanner;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.io.InputStream;
import java.net.*;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.BitSet;
+import java.util.HashMap;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
@@ -108,6 +111,7 @@ public class Scanner {
Semaphore maxWorkers = new Semaphore(maxWorkersPerHost);
BitSet openPorts = new BitSet(PortRange.MAX_PORT);
BitSet filteredPorts = new BitSet(PortRange.MAX_PORT);
+ HashMap<Integer, ServiceType> bannerRecognition = new HashMap<Integer, ServiceType>();
Progress progress = new Progress(host, portRange.getTotal(), new AtomicInteger(), new AtomicInteger(), new AtomicInteger());
progressBar.submit(progress);
@@ -115,7 +119,7 @@ public class Scanner {
if(!disableHostCheck && !checkHostOnline(host)) {
// Visually show that this host is basically done
progress.done().set(progress.total());
- return new ScanResult(host, openPorts, filteredPorts);
+ return new ScanResult(host, openPorts, filteredPorts, bannerRecognition);
}
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
@@ -126,13 +130,7 @@ public class Scanner {
futures.add(executor.submit(() -> {
try {
waitForSlot(portSlotFactory);
- PortState state = getPortState(host, currentPort);
- if (state.equals(PortState.OPEN)) {
- progress.open().incrementAndGet();
- } else if (state.equals(PortState.FILTERED)) {
- progress.filtered().incrementAndGet();
- }
- return new PortResult(currentPort, state);
+ return checkPort(host, currentPort);
} finally {
maxWorkers.release();
progress.done().incrementAndGet();
@@ -146,10 +144,11 @@ public class Scanner {
// bitset is not thread-safe, so it is set
// outside the other virtual threads that update progress
PortResult portResult = f.get();
- if (portResult.state().equals(PortState.OPEN)) {
- openPorts.set(portResult.port());
- } else if (portResult.state().equals(PortState.FILTERED)) {
- filteredPorts.set(portResult.port());
+ if (portResult.getState().equals(PortState.OPEN)) {
+ openPorts.set(portResult.getPort());
+ bannerRecognition.put(portResult.getPort(), ServiceDetector.detect(portResult.getBanner()));
+ } else if (portResult.getState().equals(PortState.FILTERED)) {
+ filteredPorts.set(portResult.getPort());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@@ -163,7 +162,7 @@ public class Scanner {
}
}
- return new ScanResult(host, openPorts, filteredPorts);
+ return new ScanResult(host, openPorts, filteredPorts, bannerRecognition);
}
private boolean checkHostOnline(String host) {
@@ -185,18 +184,22 @@ public class Scanner {
}
}
- private PortState getPortState(String host, int port) {
+ private PortResult checkPort(String host, int port) {
socketLimit.acquireUninterruptibly();
counter.inc();
+ PortResult result = new PortResult();
+ result.setPort(port);
+ result.setState(PortState.UNKNOWN);
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), timeoutInMillis);
- return PortState.OPEN;
+ result.setBanner(getBanner(socket));
+ result.setState(PortState.OPEN);
} catch (NoRouteToHostException ignored) {
// this can be safely ignored because the port is closed if a host is unreachable
} catch (SocketTimeoutException ignored) {
- return PortState.FILTERED;
+ result.setState(PortState.FILTERED);
} catch (ConnectException ignored) {
- return PortState.CLOSED;
+ result.setState(PortState.CLOSED);
} catch (IOException ignored) {
// Will happen a lot when scanning for open ports, so not needed
} finally {
@@ -204,6 +207,22 @@ public class Scanner {
socketLimit.release();
}
- return PortState.CLOSED;
+ return result;
+ }
+
+ private String getBanner(Socket socket) {
+ int READ_BUFFER_SIZE = 1024;
+ byte[] buffer = new byte[READ_BUFFER_SIZE];
+ try(InputStream input = socket.getInputStream()) {
+ socket.setSoTimeout(timeoutInMillis);
+ int bytesRead = input.read(buffer);
+ if (bytesRead <= 0) {
+ return null;
+ }
+ return new String(buffer,0, bytesRead, StandardCharsets.UTF_8).trim();
+ } catch (IOException e) {
+ // we ignore this failure
+ }
+ return null;
}
}
diff --git a/src/main/java/com/it_jaros/jscanner/ServiceDetector.java b/src/main/java/com/it_jaros/jscanner/ServiceDetector.java
@@ -0,0 +1,45 @@
+package com.it_jaros.jscanner;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+public class ServiceDetector {
+ private static final List<ServicePattern> patterns = List.of(
+ new ServicePattern(ServiceType.SSH, Pattern.compile("(?i)^SSH-")),
+
+ new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)^HTTP/")),
+ new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)\\bServer:\\s*")),
+
+ new ServicePattern(ServiceType.FTP, Pattern.compile("(?i)^220.*\\bFTP\\b")),
+
+ new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bSMTP\\b")),
+ new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bESMTP\\b")),
+
+ new ServicePattern(ServiceType.IMAP, Pattern.compile("(?i)^\\* OK.*\\bIMAP\\b")),
+
+ new ServicePattern(ServiceType.POP3, Pattern.compile("(?i)^\\+OK.*\\bPOP3\\b"))
+ );
+
+ public static ServiceType detect(String banner) {
+ if (banner == null || banner.isBlank()) {
+ return ServiceType.UNKNOWN;
+ }
+
+ for (ServicePattern servicePattern : patterns) {
+ if (servicePattern.matches(banner)) {
+ return servicePattern.serviceType();
+ }
+ }
+
+ return ServiceType.UNKNOWN;
+ }
+
+ private record ServicePattern(
+ ServiceType serviceType,
+ Pattern pattern
+ ) {
+ boolean matches(String banner) {
+ return pattern.matcher(banner).find();
+ }
+ }
+}
+\ No newline at end of file
diff --git a/src/main/java/com/it_jaros/jscanner/ServiceType.java b/src/main/java/com/it_jaros/jscanner/ServiceType.java
@@ -0,0 +1,5 @@
+package com.it_jaros.jscanner;
+
+public enum ServiceType {
+ HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, SSH
+}