From a6e41cf35eb2b49e767c1af443fe892a75893be7 Mon Sep 17 00:00:00 2001 From: Matthias Jaros Date: Fri, 28 Aug 2026 13:08:19 +0200 Subject: Added new ScanUdpPortTask class to also handle udp using FFM --- src/main/java/com/it_jaros/jns/CliDefaults.java | 1 + src/main/java/com/it_jaros/jns/CliParser.java | 5 + .../java/com/it_jaros/jns/scan/ScanOptions.java | 1 + .../com/it_jaros/jns/scan/engine/ScanHostTask.java | 7 +- .../it_jaros/jns/scan/engine/ScanUdpPortTask.java | 105 +++++++++++ .../scan/engine/platform/NativeCallException.java | 7 + .../engine/platform/linux/LinuxUdpSocketAPI.java | 210 +++++++++++++++++++++ .../platform/linux/RawLinuxSocketException.java | 11 ++ 8 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/ScanUdpPortTask.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/platform/NativeCallException.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/platform/linux/LinuxUdpSocketAPI.java create mode 100644 src/main/java/com/it_jaros/jns/scan/engine/platform/linux/RawLinuxSocketException.java (limited to 'src/main') diff --git a/src/main/java/com/it_jaros/jns/CliDefaults.java b/src/main/java/com/it_jaros/jns/CliDefaults.java index e000fc9..328e485 100644 --- a/src/main/java/com/it_jaros/jns/CliDefaults.java +++ b/src/main/java/com/it_jaros/jns/CliDefaults.java @@ -10,6 +10,7 @@ public class CliDefaults { public static final boolean DISABLE_HOST_CHECK = false; public static final boolean QUIET = false; public static final boolean SHOW_FILTERED_PORTS = false; + public static final boolean USE_UDP = false; public static final int DELAY_IN_MILLIS = 0; public static final int MAX_HOSTS_LIMIT = 100; public static final int MAX_WORKERS_PER_HOST = Integer.MAX_VALUE; diff --git a/src/main/java/com/it_jaros/jns/CliParser.java b/src/main/java/com/it_jaros/jns/CliParser.java index 0a3dcc5..cf4f863 100644 --- a/src/main/java/com/it_jaros/jns/CliParser.java +++ b/src/main/java/com/it_jaros/jns/CliParser.java @@ -22,6 +22,7 @@ public class CliParser { boolean help = false; boolean quiet = CliDefaults.QUIET; boolean showFilteredPorts = CliDefaults.SHOW_FILTERED_PORTS; + boolean useUdp = CliDefaults.USE_UDP; int delayInMillis = CliDefaults.DELAY_IN_MILLIS; int hostsLimit = CliDefaults.MAX_HOSTS_LIMIT; int socketLimit = CliDefaults.SOCKET_LIMIT; @@ -66,6 +67,9 @@ public class CliParser { case "--showFilteredPorts", "-sf": showFilteredPorts = true; break; + case "--useUdp", "-uu": + useUdp = true; + break; case "--socketLimit", "-sl": socketLimit = parseIntOption(args, ++i, currentArg, (Integer value) -> value > 0); break; @@ -89,6 +93,7 @@ public class CliParser { disableOnlineCheck, quiet, showFilteredPorts, + useUdp, delayInMillis, hostsLimit, workersPerHostLimit, diff --git a/src/main/java/com/it_jaros/jns/scan/ScanOptions.java b/src/main/java/com/it_jaros/jns/scan/ScanOptions.java index d6035c7..f2e0222 100644 --- a/src/main/java/com/it_jaros/jns/scan/ScanOptions.java +++ b/src/main/java/com/it_jaros/jns/scan/ScanOptions.java @@ -10,6 +10,7 @@ public record ScanOptions( boolean disableOnlineCheck, boolean quiet, boolean showFilteredPorts, + boolean useUdp, int delayInMillis, int maxHostsLimit, int maxWorkersPerHost, diff --git a/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java b/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java index 7fee311..9a4d6db 100644 --- a/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java +++ b/src/main/java/com/it_jaros/jns/scan/engine/ScanHostTask.java @@ -9,6 +9,7 @@ import com.it_jaros.jns.scan.domain.ScanResult; import java.net.InetAddress; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; +import java.util.function.Function; public class ScanHostTask implements Callable { @@ -64,11 +65,15 @@ public class ScanHostTask implements Callable { } final PortRange portRange = new PortRange(scan.getPorts()); + Function> scanFactory = this.context.scanOptions().useUdp() + ? port -> new ScanUdpPortTask(port, context, hostContext) + : port -> new ScanPortTask(port, context, hostContext); + // producer thread ProducerState state = new ProducerThread(context).startProducer( portRange.iterator(), maxWorkersPerHost, - port -> new ScanPortTask(port, context, hostContext) + scanFactory ); new CompletionConsumer<>(cancelledToken, state).runConsumerLoop(poll -> { diff --git a/src/main/java/com/it_jaros/jns/scan/engine/ScanUdpPortTask.java b/src/main/java/com/it_jaros/jns/scan/engine/ScanUdpPortTask.java new file mode 100644 index 0000000..21b93f8 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/ScanUdpPortTask.java @@ -0,0 +1,105 @@ +package com.it_jaros.jns.scan.engine; + +import com.it_jaros.jns.scan.Scan; +import com.it_jaros.jns.scan.domain.PortResult; +import com.it_jaros.jns.scan.domain.PortState; +import com.it_jaros.jns.scan.engine.platform.linux.LinuxUdpSocketAPI; +import com.it_jaros.jns.scan.engine.platform.linux.RawLinuxSocketException; + +import java.io.IOException; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.net.InetAddress; +import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; + +public class ScanUdpPortTask implements Callable { + + private static final int RECV_BUFFER_SIZE = 1024; + + private final CancelledToken cancelledToken; + private final InetAddress inetAddress; + private final PortScanRateLimiter portScanRateLimiter; // per-host shared limiter + private final Scan scan; + private final Semaphore socketLimit; + private final int port; + private final long timeoutInMillis; + + ScanUdpPortTask(int port, ScanExecutionContext context, ScanHostContext hostContext) { + this.inetAddress = hostContext.hostAddress(); + this.port = port; + this.cancelledToken = context.cancelledToken(); + this.portScanRateLimiter = hostContext.portScanRateLimiter(); + this.scan = context.scan(); + this.socketLimit = context.socketLimit(); + this.timeoutInMillis = context.scanOptions().timeoutInMillis(); + } + + @Override + public PortResult call() throws Exception { + try { + socketLimit.acquire(); + try { + scan.portStart(); + portScanRateLimiter.apply(); + if (cancelledToken.isCancelled()) { + return PortResult.empty(); + } + return checkPort(); + } finally { + scan.portFinish(); + socketLimit.release(); + } + } catch (Exception e) { + throw new ScanPortException(port, e); + } + } + + private PortResult checkPort() { + PortResult result = new PortResult(); + result.setPort(port); + result.setState(PortState.UNKNOWN); + try { + PortState portState = probeUdpPort(); + result.setState(portState); + } catch (IOException e) { + result.setException(e); + } + return result; + } + + private PortState probeUdpPort() throws IOException { + try (Arena arena = Arena.ofConfined()) { + LinuxUdpSocketAPI linuxUdpSocketAPI = new LinuxUdpSocketAPI(arena); + MemorySegment destination = linuxUdpSocketAPI.destination(inetAddress, port); + final int family = inetAddress.getAddress().length == 16 ? LinuxUdpSocketAPI.AF_INET6 : LinuxUdpSocketAPI.AF_INET; + int fd = linuxUdpSocketAPI.socket(family); + try { + linuxUdpSocketAPI.setReceiveTimeout(fd, timeoutInMillis); + linuxUdpSocketAPI.connect(fd, destination); + linuxUdpSocketAPI.sendEmptyDatagram(fd); + + MemorySegment recvBuffer = arena.allocate(RECV_BUFFER_SIZE); + try { + int bytesRead = linuxUdpSocketAPI.recv(fd, recvBuffer); + if (bytesRead >= 0) { + return PortState.OPEN; + } + } catch (RawLinuxSocketException e) { + int errno = e.errno; + if (errno == LinuxUdpSocketAPI.ECONNREFUSED) { + return PortState.CLOSED; + } + if (errno == LinuxUdpSocketAPI.EAGAIN) { + return PortState.FILTERED; + } + throw new IOException("recv() failed: errno=" + errno); + } + } finally { + linuxUdpSocketAPI.close(fd); + } + } + + return PortState.UNKNOWN; + } +} diff --git a/src/main/java/com/it_jaros/jns/scan/engine/platform/NativeCallException.java b/src/main/java/com/it_jaros/jns/scan/engine/platform/NativeCallException.java new file mode 100644 index 0000000..3373ad5 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/platform/NativeCallException.java @@ -0,0 +1,7 @@ +package com.it_jaros.jns.scan.engine.platform; + +public class NativeCallException extends Exception { + public NativeCallException(String message, Throwable t) { + super(message, t); + } +} diff --git a/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/LinuxUdpSocketAPI.java b/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/LinuxUdpSocketAPI.java new file mode 100644 index 0000000..fe087d7 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/LinuxUdpSocketAPI.java @@ -0,0 +1,210 @@ +package com.it_jaros.jns.scan.engine.platform.linux; + +import com.it_jaros.jns.scan.engine.platform.NativeCallException; + +import java.io.IOException; +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.VarHandle; +import java.net.InetAddress; +import java.nio.ByteOrder; + +public class LinuxUdpSocketAPI { + + public static final int AF_INET = 2; + public static final int AF_INET6 = 10; + public static final int EAGAIN = 11; + public static final int ECONNREFUSED = 111; + public static final int EWOULDBLOCK = 11; + public static final int SOCK_DGRAM = 2; + public static final int SOL_SOCKET = 1; + public static final int SO_RCVTIMEO = 20; + + private static final Linker LINKER = Linker.nativeLinker(); + private static final SymbolLookup LOOKUP = LINKER.defaultLookup(); + + private static final Linker.Option CAPTURE_ERRNO = + Linker.Option.captureCallState("errno"); + + private static final StructLayout CALL_STATE_LAYOUT = + Linker.Option.captureStateLayout(); + + private static final VarHandle ERRNO = + CALL_STATE_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("errno") + ); + + + private static final MethodHandle SOCKET = LINKER.downcallHandle( + LOOKUP.findOrThrow("socket"), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT + ), + CAPTURE_ERRNO + ); + private static final MethodHandle CONNECT = LINKER.downcallHandle( + LOOKUP.findOrThrow("connect"), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.JAVA_INT + ), + CAPTURE_ERRNO + ); + + private static final MethodHandle SEND = LINKER.downcallHandle( + LOOKUP.findOrThrow("send"), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, // ssize_t return + ValueLayout.JAVA_INT, // int sockfd + ValueLayout.ADDRESS, // const void *buf + ValueLayout.JAVA_LONG, // size_t len + ValueLayout.JAVA_INT // int flags + ), + CAPTURE_ERRNO + ); + + private static final MethodHandle RECV = LINKER.downcallHandle( + LOOKUP.findOrThrow("recv"), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, // ssize_t + ValueLayout.JAVA_INT, // sockfd + ValueLayout.ADDRESS, // void *buf + ValueLayout.JAVA_LONG, // size_t len + ValueLayout.JAVA_INT // flags + ), + CAPTURE_ERRNO + ); + + private static final MethodHandle SETSOCKOPT = LINKER.downcallHandle( + LOOKUP.findOrThrow("setsockopt"), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.JAVA_INT + ), + CAPTURE_ERRNO + ); + + private static final MethodHandle CLOSE = LINKER.downcallHandle( + LOOKUP.findOrThrow("close"), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT + ), + CAPTURE_ERRNO + ); + + private Arena arena; + + public LinuxUdpSocketAPI(Arena arena) { + this.arena = arena; + } + + T wrapNativeMethodCall(MethodHandle methodHandle, Object... argv) throws IOException { + MemorySegment state = arena.allocate( + Linker.Option.captureStateLayout() + ); + Object[] newArgv = new Object[argv.length + 1]; + newArgv[0] = state; + System.arraycopy(argv, 0, newArgv, 1, argv.length); + try { + final T rc = callNativeMethod(methodHandle, newArgv); + if (rc.intValue() < 0) { + throw new RawLinuxSocketException(methodHandle.toString(), errno(state)); + } + return rc; + } catch (NativeCallException e) { + throw new IOException(e); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private T callNativeMethod(MethodHandle methodHandle, Object... args) throws NativeCallException { + try { + return (T) methodHandle.invokeWithArguments(args); + } catch (Throwable t) { + throw new NativeCallException(methodHandle.toString(), t); + } + } + + public int socket(int family) throws IOException { + return wrapNativeMethodCall(SOCKET, family, SOCK_DGRAM, 0); + } + + public void connect(int fd, MemorySegment destination) throws IOException { + wrapNativeMethodCall(CONNECT, fd, destination, (int) destination.byteSize()); + } + + public void sendEmptyDatagram(int fd) throws IOException { + wrapNativeMethodCall(SEND, fd, MemorySegment.NULL, 0L, 0); + } + + public int recv(int fd, MemorySegment buffer) throws IOException { + return wrapNativeMethodCall(RECV, fd, buffer, (int) buffer.byteSize(), 0); + } + + public void setReceiveTimeout(int fd, long timeoutInMillis) throws IOException { + MemorySegment timeval = arena.allocate(16); // struct timeval (two longs) + timeval.set(ValueLayout.JAVA_LONG, 0L, timeoutInMillis / 1000L); + timeval.set(ValueLayout.JAVA_LONG, 8L, (timeoutInMillis % 1000L) * 1000L); + wrapNativeMethodCall(SETSOCKOPT, fd, SOL_SOCKET, SO_RCVTIMEO, timeval, 16); + } + + public void close(int fd) throws IOException { + wrapNativeMethodCall(CLOSE, fd); + } + + public int errno(MemorySegment callState) { + return (int) ERRNO.get(callState, 0L); + } + + public MemorySegment destination( + InetAddress inetAddress, + int port + ) { + byte[] addressBytes = inetAddress.getAddress(); + + boolean ipv6 = addressBytes.length == 16; + int size = ipv6 ? 28 : 16; + int family = ipv6 ? AF_INET6 : AF_INET; + + MemorySegment segment = arena.allocate(size); + + // sa_family_t -> native byte order + segment.set( + ValueLayout.JAVA_SHORT, + 0, + (short) family + ); + + // in_port_t -> network byte order + segment.set( + ValueLayout.JAVA_SHORT.withOrder(ByteOrder.BIG_ENDIAN), + 2, + (short) port + ); + + // IPv4: address starts at offset 4 + // IPv6: address starts at offset 8 + long addressOffset = ipv6 ? 8 : 4; + + MemorySegment.copy( + addressBytes, + 0, + segment, + ValueLayout.JAVA_BYTE, + addressOffset, + addressBytes.length + ); + + return segment; + } +} diff --git a/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/RawLinuxSocketException.java b/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/RawLinuxSocketException.java new file mode 100644 index 0000000..f93ea65 --- /dev/null +++ b/src/main/java/com/it_jaros/jns/scan/engine/platform/linux/RawLinuxSocketException.java @@ -0,0 +1,11 @@ +package com.it_jaros.jns.scan.engine.platform.linux; + +import java.io.IOException; + +public class RawLinuxSocketException extends IOException { + public final int errno; + public RawLinuxSocketException(String message, int errno) { + super(message); + this.errno = errno; + } +} \ No newline at end of file -- cgit v1.3.1