This is an automated email from the ASF dual-hosted git repository.
tomaswolf pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
The following commit(s) were added to refs/heads/master by this push:
new 88632d115 Fix SOCKS5 dynamic forwarding of fragmented and pipelined
requests
88632d115 is described below
commit 88632d1156658288072a6c5c18394e80d23751ab
Author: Bruno A. MuciƱo <[email protected]>
AuthorDate: Tue Aug 18 10:40:37 2026 -0600
Fix SOCKS5 dynamic forwarding of fragmented and pipelined requests
The SOCKS5 proxy used for dynamic port forwarding parsed each read as a
complete message, throwing a buffer underflow when the greeting or the
CONNECT request arrived split across several reads. It also reused the
CONNECT request buffer as the reply, echoing request bytes (and any
application data pipelined after the request) back to the client instead
of forwarding them.
Rewrite Socks5 as a state machine with a retained buffer: parse only
once a message is complete and queue bytes received before the channel
opens, forwarding them once it is up.
The state is synchronized because channel open and write futures
complete on other threads.
---
.../org/apache/sshd/common/forward/SocksProxy.java | 205 +++++++++++++++------
.../src/test/java/org/apache/sshd/ProxyTest.java | 91 +++++++++
2 files changed, 241 insertions(+), 55 deletions(-)
diff --git
a/sshd-core/src/main/java/org/apache/sshd/common/forward/SocksProxy.java
b/sshd-core/src/main/java/org/apache/sshd/common/forward/SocksProxy.java
index 28a9d6fe2..96edb2ced 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/forward/SocksProxy.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/forward/SocksProxy.java
@@ -201,12 +201,20 @@ public class SocksProxy extends AbstractCloseable
implements IoHandler {
}
}
+ private enum Socks5State {
+ GREETING,
+ CONNECT_REQUEST,
+ OPENING_CHANNEL,
+ FORWARDING,
+ CLOSED
+ }
+
/**
* @see <A HREF="https://en.wikipedia.org/wiki/SOCKS#SOCKS5">SOCKS5</A>
*/
public class Socks5 extends Proxy {
- private byte[] authMethods;
- private Buffer response;
+ private Socks5State state = Socks5State.GREETING;
+ private Buffer pending = new ByteArrayBuffer();
public Socks5(IoSession session) {
super(session);
@@ -214,65 +222,115 @@ public class SocksProxy extends AbstractCloseable
implements IoHandler {
@SuppressWarnings("synthetic-access")
@Override
- protected void onMessage(Buffer buffer) throws IOException {
- boolean debugEnabled = log.isDebugEnabled();
- if (authMethods == null) {
- int nbAuthMethods = getUByte(buffer);
- authMethods = new byte[nbAuthMethods];
- buffer.getRawBytes(authMethods);
+ protected synchronized void onMessage(Buffer buffer) throws
IOException {
+ if (state == Socks5State.FORWARDING && pending.available() == 0) {
+ super.onMessage(buffer);
+ return;
+ }
+ pending.putBuffer(buffer);
+ processPending();
+ }
+
+ protected void processPending() throws IOException {
+ if (state == Socks5State.OPENING_CHANNEL || state ==
Socks5State.CLOSED) {
+ return;
+ }
+
+ if (state == Socks5State.FORWARDING) {
+ forwardPending();
+ return;
+ }
+
+ if (state == Socks5State.GREETING) {
+ if (pending.available() < 1) {
+ return;
+ }
+ int authMethodsCount = pending.rawByte(pending.rpos()) & 0xFF;
+ if (pending.available() < authMethodsCount + 1) {
+ return;
+ }
+
+ pending.getUByte();
boolean foundNoAuth = false;
- for (int i = 0; i < nbAuthMethods; i++) {
- foundNoAuth |= authMethods[i] == 0;
+ for (int index = 0; index < authMethodsCount; index++) {
+ foundNoAuth |= pending.getUByte() == 0;
}
- buffer = new ByteArrayBuffer(Byte.SIZE, false);
- buffer.putByte((byte) 0x05);
- buffer.putByte((byte) (foundNoAuth ? 0x00 : 0xFF));
- session.writeBuffer(buffer);
+ pending.compact();
+ Buffer response = new ByteArrayBuffer(2, false);
+ response.putByte((byte) 0x05);
+ response.putByte((byte) (foundNoAuth ? 0x00 : 0xFF));
+ session.writeBuffer(response);
if (!foundNoAuth) {
throw new IllegalStateException("Received socks5 greeting
without NoAuth method");
- } else if (debugEnabled) {
+ } else if (log.isDebugEnabled()) {
log.debug("Received socks5 greeting");
}
- } else if (channel == null) {
- response = buffer;
- int version = getUByte(buffer);
+ state = Socks5State.CONNECT_REQUEST;
+ }
+
+ if (state == Socks5State.CONNECT_REQUEST) {
+ if (pending.available() < 4) {
+ return;
+ }
+ int offset = pending.rpos();
+ int version = pending.rawByte(offset) & 0xFF;
if (version != 0x05) {
throw new IllegalStateException("Unexpected version: " +
version);
}
- int cmd = buffer.getUByte();
+ int cmd = pending.rawByte(offset + 1) & 0xFF;
if (cmd != 1) { // establish a TCP/IP stream connection
throw new IllegalStateException("Unsupported socks
command: " + cmd);
}
- int res = buffer.getUByte();
+ int res = pending.rawByte(offset + 2) & 0xFF;
if (res != 0) {
- if (debugEnabled) {
+ if (log.isDebugEnabled()) {
log.debug("No zero reserved value: {}", res);
}
}
-
- int type = buffer.getUByte();
- String host;
+ int type = pending.rawByte(offset + 3) & 0xFF;
+ int addressLength;
if (type == 0x01) {
- host = Integer.toString(getUByte(buffer)) + "."
- + Integer.toString(getUByte(buffer)) + "."
- + Integer.toString(getUByte(buffer)) + "."
- + Integer.toString(getUByte(buffer));
+ addressLength = 4;
} else if (type == 0x03) {
- host = getBLString(buffer);
+ if (pending.available() < 5) {
+ return;
+ }
+ addressLength = pending.rawByte(offset + 4) & 0xFF;
} else if (type == 0x04) {
- host = Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer)) + ":"
- + Integer.toHexString(getUShort(buffer));
+ addressLength = 16;
} else {
throw new IllegalStateException("Unsupported address type:
" + type);
}
- int port = getUShort(buffer);
- if (debugEnabled) {
+ int requestLength = 4 + (type == 0x03 ? 1 : 0) + addressLength
+ 2;
+ if (pending.available() < requestLength) {
+ return;
+ }
+
+ getUByte(pending); // version already checked above
+ getUByte(pending); // command already checked above
+ getUByte(pending); // reserved byte
+ getUByte(pending); // address type
+ String host;
+ if (type == 0x01) {
+ host = Integer.toString(getUByte(pending)) + "."
+ + Integer.toString(getUByte(pending)) + "."
+ + Integer.toString(getUByte(pending)) + "."
+ + Integer.toString(getUByte(pending));
+ } else if (type == 0x03) {
+ host = getBLString(pending);
+ } else {
+ StringBuilder address = new StringBuilder();
+ for (int index = 0; index < 8; index++) {
+ if (index > 0) {
+ address.append(':');
+ }
+
address.append(Integer.toHexString(getUShort(pending)));
+ }
+ host = address.toString();
+ }
+ int port = getUShort(pending);
+ pending.compact();
+ if (log.isDebugEnabled()) {
log.debug("Received socks5 connection request to {}:{}",
host, port);
}
SshdSocketAddress remote = new SshdSocketAddress(host, port);
@@ -280,36 +338,73 @@ public class SocksProxy extends AbstractCloseable
implements IoHandler {
channel.setStreaming(Streaming.Async);
session.suspendRead();
service.registerChannel(channel);
+ state = Socks5State.OPENING_CHANNEL;
channel.open().addListener(this::onChannelOpened);
- } else {
- if (debugEnabled) {
- log.debug("Received socks5 connection message");
- }
- super.onMessage(buffer);
}
}
@SuppressWarnings("synthetic-access")
- protected void onChannelOpened(OpenFuture future) {
- session.resumeRead();
- int wpos = response.wpos();
- response.rpos(0);
- response.wpos(1);
+ protected synchronized void onChannelOpened(OpenFuture future) {
Throwable t = future.getException();
if (t != null) {
service.unregisterChannel(channel);
channel.close(true);
- response.putByte((byte) 0x01);
- } else {
- response.putByte((byte) 0x00);
+ pending.clear(true);
+ state = Socks5State.CLOSED;
+ sendReply((byte) 0x01, () -> session.close(false));
+ return;
}
- response.wpos(wpos);
+
+ state = Socks5State.FORWARDING;
+ sendReply((byte) 0x00, this::forwardPending);
+ }
+
+ protected void sendReply(byte status, Runnable completion) {
+ Buffer response = new ByteArrayBuffer(10, false);
+ // VER, REP, RSV, ATYP=IPv4, BND.ADDR=0.0.0.0, BND.PORT=0
+ response.putRawBytes(new byte[] { 0x05, status, 0x00, 0x01, 0, 0,
0, 0, 0, 0 });
try {
- session.writeBuffer(response);
+ session.writeBuffer(response).addListener(future -> {
+ if (future.isWritten()) {
+ completion.run();
+ } else {
+ session.close(true);
+ }
+ });
} catch (IOException e) {
log.error("Failed ({}) to send channel open response for {}:
{}", e.getClass().getSimpleName(), channel,
e.getMessage());
- throw new IllegalStateException("Failed to send packet", e);
+ session.close(true);
+ }
+ }
+
+ protected synchronized void forwardPending() {
+ if (state != Socks5State.FORWARDING) {
+ return;
+ }
+ Buffer payload = pending;
+ pending = new ByteArrayBuffer();
+ if (payload.available() == 0) {
+ session.resumeRead();
+ return;
+ }
+ int payloadSize = payload.available();
+ session.suspendRead();
+ try {
+ ThreadUtils.runAsInternal(channel.getAsyncIn(), out ->
out.writeBuffer(payload).addListener(future -> {
+ if (future.getException() == null) {
+ if (log.isDebugEnabled()) {
+ log.debug("Forwarded {} queued socks5 bytes",
payloadSize);
+ }
+ session.resumeRead();
+ } else {
+ session.close(true);
+ }
+ }));
+ } catch (IOException e) {
+ log.error("Failed ({}) to forward {} queued bytes for {}: {}",
e.getClass().getSimpleName(), payloadSize,
+ channel, e.getMessage());
+ session.close(true);
}
}
diff --git a/sshd-core/src/test/java/org/apache/sshd/ProxyTest.java
b/sshd-core/src/test/java/org/apache/sshd/ProxyTest.java
index a99f16196..196dcf28d 100644
--- a/sshd-core/src/test/java/org/apache/sshd/ProxyTest.java
+++ b/sshd-core/src/test/java/org/apache/sshd/ProxyTest.java
@@ -18,6 +18,7 @@
*/
package org.apache.sshd;
+import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -51,6 +52,7 @@ import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -319,6 +321,95 @@ public class ProxyTest extends BaseTestSupport {
}
}
+ @Test
+ void socks5HandlesFragmentedIPv4ConnectRequest() throws Exception {
+ final byte[] request = {
+ 5, 1, 0, 1, 127, 0, 0, 1,
+ (byte) (echoPort >>> Byte.SIZE), (byte) echoPort
+ };
+
+ assertFragmentedSocks5Connect(request);
+ }
+
+ @Test
+ void socks5HandlesFragmentedDomainConnectRequest() throws Exception {
+ final byte[] host = TEST_LOCALHOST.getBytes(StandardCharsets.US_ASCII);
+ final byte[] request = new byte[5 + host.length + 2];
+
+ request[0] = 5;
+ request[1] = 1;
+ request[2] = 0;
+ request[3] = 3;
+ request[4] = (byte) host.length;
+
+ System.arraycopy(host, 0, request, 5, host.length);
+
+ request[request.length - 2] = (byte) (echoPort >>> Byte.SIZE);
+ request[request.length - 1] = (byte) echoPort;
+
+ assertFragmentedSocks5Connect(request);
+ }
+
+ @Test
+ void socks5HandlesFragmentedIPv6ConnectRequest() throws Exception {
+ byte[] request = {
+ 5, 1, 0, 4,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // ::1
+ (byte) (echoPort >>> Byte.SIZE), (byte) echoPort
+ };
+ assertFragmentedSocks5Connect(request);
+ }
+
+ /**
+ * Sends a SOCKS5 greeting and CONNECT request one byte at a time, in
separate write() calls, so the address and
+ * port are each split across multiple I/O callbacks, then sends
application data without waiting for the CONNECT
+ * reply. Verifies that the proxy produces exactly one clean CONNECT reply
and that the payload reaches the
+ * destination.
+ */
+ private void assertFragmentedSocks5Connect(final byte[] request) throws
Exception {
+ final byte[] payload =
getCurrentTestName().getBytes(StandardCharsets.UTF_8);
+
+ try (ClientSession session = createNativeSession(null);
+ DynamicPortForwardingTracker tracker
+ = session.createDynamicPortForwardingTracker(new
SshdSocketAddress(TEST_LOCALHOST, 0));
+
+ Socket socket = new Socket(TEST_LOCALHOST,
tracker.getBoundAddress().getPort())) {
+ socket.setSoTimeout((int) TimeUnit.SECONDS.toMillis(10L));
+
+ final OutputStream output = socket.getOutputStream();
+ final DataInputStream input = new
DataInputStream(socket.getInputStream());
+
+ output.write(5); // version
+ output.flush();
+ output.write(1); // method count
+ output.flush();
+ output.write(0); // no-auth
+ output.flush();
+
+ assertArrayEquals(new byte[] { 5, 0 }, readNBytes(input, 2),
"Unexpected greeting reply");
+
+ for (byte b : request) {
+ output.write(b);
+ output.flush();
+ }
+ output.write(payload); // application data sent before the CONNECT
reply is received
+ output.flush();
+
+ assertArrayEquals(new byte[] { 5, 0, 0, 1, 0, 0, 0, 0, 0, 0 },
readNBytes(input, 10),
+ "Unexpected SOCKS5 CONNECT reply");
+
+ assertArrayEquals(payload, readNBytes(input, payload.length),
+ "Payload was not forwarded intact exactly once");
+ }
+ }
+
+ private static byte[] readNBytes(final DataInputStream input, final int
len) throws IOException {
+ final byte[] data = new byte[len];
+ input.readFully(data);
+
+ return data;
+ }
+
protected ClientSession createNativeSession(PortForwardingEventListener
listener) throws Exception {
client = setupTestClient();
CoreModuleProperties.WINDOW_SIZE.set(client, 2048L);