Challenge Medium

Build a SOCKS5 Proxy From a TCP Relay

Extend the fixed-destination relay from the network proxies tutorial into a SOCKS5 server. Parse no-authentication IPv4 CONNECT requests and forward bytes in both directions.

In How to Borrow Someone Else's Network: Introduction to Network Proxies, you built a TCP relay that always connects to 172.16.0.10:6543. It reads the entire client request before forwarding it and then returns one response. That works for the tutorial's half-close protocol, but a client cannot choose a destination or exchange data in both directions as a conversation unfolds.

Turn that relay into a small SOCKS5 server. The playground starts with the tutorial's relay.py at ~/socks5/relay.py on the proxy VM. Modify that file and run it on 172.16.0.30:6544. The client VM will connect to it. A test service is running on the server VM at 172.16.0.10:6543 and 172.16.0.10:6545. As in the tutorial, the server rejects direct connections from the client but accepts connections from the proxy.

Here is the starting relay.py:

relay.py
import socket

# Fixed-destination relay from the network proxies tutorial.
serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0)
serv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serv_sock.bind(('172.16.0.30', 6544))
serv_sock.listen(10)

while True:
    client_sock, client_addr = serv_sock.accept()
    print('New connection from', client_addr)

    upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    upstream_sock.connect(('172.16.0.10', 6543))
    print('Upstream connection from', upstream_sock.getsockname())

    chunks = []
    while True:
        data = client_sock.recv(2048)
        if not data:
            break
        chunks.append(data)

    upstream_sock.sendall(b''.join(chunks))
    upstream_sock.shutdown(socket.SHUT_WR)

    while True:
        data = upstream_sock.recv(2048)
        if not data:
            break
        client_sock.sendall(data)
    upstream_sock.close()
    client_sock.close()

Notice the fixed upstream_sock.connect(('172.16.0.10', 6543)) line. The main goal is to replace those hard-coded values with the destination IPv4 address and port requested by each client. The proxy should use any reachable IPv4 destination the client provides. In this playground, a client can ask for port 6543 or 6545 without changing the proxy's code.

What to implement

Follow the connection in four stages:

  1. Choose an authentication method. The client tells the proxy which methods it supports. Select no authentication if the client offers it. Otherwise, tell the client that none of its methods are supported and close the connection.
  2. Connect to the client's destination. The client supplies a command, a destination address, and a port. Accept TCP CONNECT requests with an IPv4 address. Read the address and port from each request and use them in the upstream connect() call. Do not keep the tutorial's fixed destination.
  3. Report the result. Tell the client whether the upstream connection succeeded. On success, include the proxy's local address and port for that upstream connection. Send the RFC's appropriate failure reply for an unsupported command, an unsupported address type, or a failed connection, then close.
  4. Relay the conversation. Once CONNECT succeeds, copy bytes unchanged in both directions. The test server speaks first, so the server-to-client direction must work before the client sends application data. Keep both directions active for multiple messages, and pass along a half-close when one side finishes writing.

Use the greeting, request, and reply formats in RFC 1928, sections 3 through 6. Read complete fields before interpreting them: a TCP recv() can return fewer bytes than requested.

This challenge implements no authentication, IPv4 addresses, and TCP CONNECT only. RFC 1928 also defines other authentication methods, domain and IPv6 addresses, BIND, and UDP ASSOCIATE. Reject unsupported methods and requests. This small server is an exercise in the RFC's wire format, not a complete conforming implementation: the RFC says compliant implementations must support GSSAPI.

The conventional SOCKS port is 1080, but this playground uses the tutorial's port 6544. Keep the listener on 172.16.0.30:6544 so the checks can reach it. Run the process in a persistent terminal or as a service. You can inspect the target from the proxy VM with nc 172.16.0.10 6543: it sends a greeting as soon as a connection is established and echoes data.

Try your proxy with client.py

Once your SOCKS5 server is running on the proxy VM, run the client below on the client VM. The playground provides it at ~/socks5/client.py.

The client asks the proxy to connect to the target, reads the target's greeting, and sends a message. It then finishes writing while keeping its read side open for the echoed response. You can run this file as provided; your task is to implement the proxy.

client.py
import argparse
import socket


def read_exact(sock, count):
    data = bytearray()
    while len(data) < count:
        chunk = sock.recv(count - len(data))
        if not chunk:
            raise EOFError("Connection closed before the full response arrived")
        data.extend(chunk)
    return bytes(data)


parser = argparse.ArgumentParser(description="Try the challenge's SOCKS5 proxy")
parser.add_argument("--host", default="172.16.0.10")
parser.add_argument("--port", type=int, default=6543)
args = parser.parse_args()

with socket.create_connection(("172.16.0.30", 6544), timeout=5) as client:
    # Ask to use SOCKS5 without authentication.
    client.sendall(b"\x05\x01\x00")
    if read_exact(client, 2) != b"\x05\x00":
        raise SystemExit("The proxy did not accept no authentication")

    # Ask the proxy to connect to the chosen IPv4 address and port.
    request = (
        b"\x05\x01\x00\x01"
        + socket.inet_aton(args.host)
        + args.port.to_bytes(2, "big")
    )
    client.sendall(request)
    reply = read_exact(client, 10)
    if reply[0] != 5 or reply[2:4] != b"\x00\x01":
        raise SystemExit("The proxy sent an invalid IPv4 reply")
    if reply[1] != 0:
        raise SystemExit(f"SOCKS5 CONNECT failed (reply code {reply[1]})")
    print(f"Connected to {args.host}:{args.port} through the proxy")

    # The target speaks first. Read its greeting before sending any data.
    greeting = bytearray()
    while not greeting.endswith(b"\n"):
        greeting.extend(read_exact(client, 1))
    print(greeting.decode().strip())

    message = b"Hello through SOCKS5!"
    client.sendall(message)
    client.shutdown(socket.SHUT_WR)

    # Finishing our writes must still let us read the target's reply.
    response = read_exact(client, len(message))
    if response != message:
        raise SystemExit("The echoed message differs from what we sent")
    print("Received:", response.decode())
    if client.recv(1) != b"":
        raise SystemExit("Unexpected data after the echo")
    print("Half-close completed")

Run it in the client terminal:

cd ~/socks5
python3 client.py

With a working proxy, you should see:

Connected to 172.16.0.10:6543 through the proxy
HELLO 6543 FROM 172.16.0.30
Received: Hello through SOCKS5!
Half-close completed

The target sees the proxy's IP address, just as it did in the tutorial. Now ask for the other target port, leaving the proxy running:

python3 client.py --host 172.16.0.10 --port 6545

The connection message and greeting should now show port 6545. The --host and --port arguments choose the destination that the client asks the proxy to reach. This example expects the playground's greeting-and-echo service at that destination.

Hint 1: Negotiate no authentication

The greeting starts with a version and a method count. Read exactly that many methods before replying. A helper for reading a whole field can begin like this:

def read_exact(sock, count):
    data = bytearray()
    while len(data) < count:
        chunk = sock.recv(count - len(data))
        # Handle a closed connection and append the chunk here.

Use the helper for both the greeting and the later request.

Hint 2: Use the requested destination

Once you have checked the request type, turn its address and port fields into values for connect():

destination_ip = socket.inet_ntoa(address_bytes)
destination_port = int.from_bytes(port_bytes, "big")
# Use (destination_ip, destination_port) for the upstream connection.

address_bytes and port_bytes must come from this client request, not from constants in the starter relay.

Hint 3: Build the reply

After connecting upstream, the bound endpoint comes from that socket:

bound_ip, bound_port = upstream_sock.getsockname()
address_field = socket.inet_aton(bound_ip)
port_field = bound_port.to_bytes(2, "big")

Combine those fields with the RFC's reply header. Even a failure reply needs the complete reply shape. Read the full request frame before rejecting a known address type, so unread bytes do not cause a TCP reset that hides your reply.

Hint 4: Relay in both directions

The tutorial reads the entire client request before reading upstream. The test server sends a greeting first, so those two loops can deadlock. Give each direction its own copy loop:

def copy_stream(source, destination):
    while True:
        data = source.recv(4096)
        if not data:
            break
        destination.sendall(data)
    # Half-close destination's write side when source reaches EOF.

Run one copy loop for client-to-server and another for server-to-client.