[sans-io] Add SOCKS5 proxy mode (#4482)

* [sans-io] add SOCKS5 proxy mode

* fixup coverage
This commit is contained in:
Maximilian Hils 2021-03-07 22:37:50 +01:00 committed by GitHub
commit 7345daf3f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 374 additions and 113 deletions

View File

@ -168,7 +168,7 @@ class NextLayer:
return layers.modes.ReverseProxy(context) return layers.modes.ReverseProxy(context)
elif ctx.options.mode == "socks5": elif ctx.options.mode == "socks5":
raise NotImplementedError("Mode not implemented.") return layers.modes.Socks5Proxy(context)
else: # pragma: no cover else: # pragma: no cover
raise AssertionError("Unknown mode.") raise AssertionError("Unknown mode.")

View File

@ -1,4 +1,7 @@
import socket
import struct
from abc import ABCMeta from abc import ABCMeta
from typing import Optional
from mitmproxy import platform from mitmproxy import platform
from mitmproxy.net import server_spec from mitmproxy.net import server_spec
@ -16,18 +19,19 @@ class HttpProxy(layer.Layer):
class DestinationKnown(layer.Layer, metaclass=ABCMeta): class DestinationKnown(layer.Layer, metaclass=ABCMeta):
"""Base layer for layers that gather connection destination info and then delegate."""
child_layer: layer.Layer child_layer: layer.Layer
def finish_start(self): def finish_start(self) -> layer.CommandGenerator[Optional[str]]:
if self.context.options.connection_strategy == "eager": if self.context.options.connection_strategy == "eager":
err = yield commands.OpenConnection(self.context.server) err = yield commands.OpenConnection(self.context.server)
if err: if err:
yield commands.CloseConnection(self.context.client) self._handle_event = self.done # type: ignore
self._handle_event = self.done return err
return
self._handle_event = self.child_layer.handle_event self._handle_event = self.child_layer.handle_event # type: ignore
yield from self.child_layer.handle_event(events.Start()) yield from self.child_layer.handle_event(events.Start())
return None
@expect(events.DataReceived, events.ConnectionClosed) @expect(events.DataReceived, events.ConnectionClosed)
def done(self, _) -> layer.CommandGenerator[None]: def done(self, _) -> layer.CommandGenerator[None]:
@ -47,7 +51,9 @@ class ReverseProxy(DestinationKnown):
else: else:
self.child_layer = layer.NextLayer(self.context) self.child_layer = layer.NextLayer(self.context)
yield from self.finish_start() err = yield from self.finish_start()
if err:
yield commands.CloseConnection(self.context.client)
class TransparentProxy(DestinationKnown): class TransparentProxy(DestinationKnown):
@ -62,4 +68,135 @@ class TransparentProxy(DestinationKnown):
self.child_layer = layer.NextLayer(self.context) self.child_layer = layer.NextLayer(self.context)
yield from self.finish_start() err = yield from self.finish_start()
if err:
yield commands.CloseConnection(self.context.client)
SOCKS5_VERSION = 0x05
SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED = 0x00
SOCKS5_METHOD_NO_ACCEPTABLE_METHODS = 0xFF
SOCKS5_ATYP_IPV4_ADDRESS = 0x01
SOCKS5_ATYP_DOMAINNAME = 0x03
SOCKS5_ATYP_IPV6_ADDRESS = 0x04
SOCKS5_REP_HOST_UNREACHABLE = 0x04
SOCKS5_REP_COMMAND_NOT_SUPPORTED = 0x07
SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08
class Socks5Proxy(DestinationKnown):
buf: bytes = b""
greeted: bool = False
def socks_err(
self,
message: str,
reply_code: Optional[int] = None,
) -> layer.CommandGenerator[None]:
if reply_code is not None:
yield commands.SendData(
self.context.client,
bytes([SOCKS5_VERSION, reply_code]) + b"\x00\x01\x00\x00\x00\x00\x00\x00"
)
yield commands.CloseConnection(self.context.client)
yield commands.Log(message)
self._handle_event = self.done
@expect(events.Start, events.DataReceived, events.ConnectionClosed)
def _handle_event(self, event: events.Event) -> layer.CommandGenerator[None]:
if isinstance(event, events.Start):
pass
elif isinstance(event, events.DataReceived):
self.buf += event.data
if not self.greeted:
# Parse Client Greeting
if len(self.buf) < 2:
return
if self.buf[0] != SOCKS5_VERSION:
if self.buf[:3].isupper():
guess = "Probably not a SOCKS request but a regular HTTP request. "
else:
guess = ""
yield from self.socks_err(guess + "Invalid SOCKS version. Expected 0x05, got 0x%x" % self.buf[0])
return
n_methods = self.buf[1]
if len(self.buf) < 2 + n_methods:
return
if SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED not in self.buf[2:2 + n_methods]:
yield from self.socks_err("mitmproxy only supports SOCKS without authentication",
SOCKS5_METHOD_NO_ACCEPTABLE_METHODS)
return
# Send Server Greeting
# Ver = SOCKS5, Auth = NO_AUTH
yield commands.SendData(self.context.client, b"\x05\x00")
self.buf = self.buf[2 + n_methods:]
self.greeted = True
# Parse Connect Request
if len(self.buf) < 4:
return
if self.buf[:3] != b"\x05\x01\x00":
yield from self.socks_err(f"Unsupported SOCKS5 request: {self.buf!r}", SOCKS5_REP_COMMAND_NOT_SUPPORTED)
return
# Determine message length
atyp = self.buf[3]
message_len: int
if atyp == SOCKS5_ATYP_IPV4_ADDRESS:
message_len = 4 + 4 + 2
elif atyp == SOCKS5_ATYP_IPV6_ADDRESS:
message_len = 4 + 16 + 2
elif atyp == SOCKS5_ATYP_DOMAINNAME:
message_len = 4 + 1 + self.buf[4] + 2
else:
yield from self.socks_err(f"Unknown address type: {atyp}", SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED)
return
# Do we have enough bytes yet?
if len(self.buf) < message_len:
return
# Parse host and port
msg, self.buf = self.buf[:message_len], self.buf[message_len:]
host: str
if atyp == SOCKS5_ATYP_IPV4_ADDRESS:
host = socket.inet_ntop(socket.AF_INET, msg[4:-2])
elif atyp == SOCKS5_ATYP_IPV6_ADDRESS:
host = socket.inet_ntop(socket.AF_INET6, msg[4:-2])
else:
host_bytes = msg[5:-2]
host = host_bytes.decode("ascii", "replace")
port, = struct.unpack("!H", msg[-2:])
# We now have all we need, let's get going.
self.context.server.address = (host, port)
self.child_layer = layer.NextLayer(self.context)
# this already triggers the child layer's Start event,
# but that's not a problem in practice...
err = yield from self.finish_start()
if err:
yield commands.SendData(self.context.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00")
yield commands.CloseConnection(self.context.client)
else:
yield commands.SendData(self.context.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
if self.buf:
yield from self.child_layer.handle_event(events.DataReceived(self.context.client, self.buf))
del self.buf
elif isinstance(event, events.ConnectionClosed):
if self.buf:
yield commands.Log(f"Client closed connection before completing SOCKS5 handshake: {self.buf!r}")
yield commands.CloseConnection(event.connection)
else:
raise AssertionError(f"Unknown event: {event}")

View File

@ -78,8 +78,7 @@ class TestNextLayer:
assert isinstance(nl.make_top_layer(ctx), layers.modes.ReverseProxy) assert isinstance(nl.make_top_layer(ctx), layers.modes.ReverseProxy)
tctx.configure(nl, mode="socks5") tctx.configure(nl, mode="socks5")
with pytest.raises(NotImplementedError): assert isinstance(nl.make_top_layer(ctx), layers.modes.Socks5Proxy)
nl.make_top_layer(ctx)
def test_next_layer(self): def test_next_layer(self):
nl = NextLayer() nl = NextLayer()

View File

@ -3,14 +3,14 @@ import copy
import pytest import pytest
from mitmproxy import platform from mitmproxy import platform
from mitmproxy.proxy.context import Context
from mitmproxy.proxy.layers.http import HTTPMode
from mitmproxy.proxy.commands import CloseConnection, OpenConnection, SendData, GetSocket, Log
from mitmproxy.connection import Client, Server from mitmproxy.connection import Client, Server
from mitmproxy.proxy.events import DataReceived, ConnectionClosed from mitmproxy.proxy.commands import CloseConnection, GetSocket, Log, OpenConnection, SendData
from mitmproxy.proxy.context import Context
from mitmproxy.proxy.events import ConnectionClosed, DataReceived
from mitmproxy.proxy.layer import NextLayer, NextLayerHook from mitmproxy.proxy.layer import NextLayer, NextLayerHook
from mitmproxy.proxy.layers import http, modes, tcp, tls from mitmproxy.proxy.layers import http, modes, tcp, tls
from mitmproxy.proxy.layers.tcp import TcpStartHook, TcpMessageHook from mitmproxy.proxy.layers.http import HTTPMode
from mitmproxy.proxy.layers.tcp import TcpMessageHook, TcpStartHook
from mitmproxy.proxy.layers.tls import ClientTLSLayer, TlsStartHook from mitmproxy.proxy.layers.tls import ClientTLSLayer, TlsStartHook
from mitmproxy.tcp import TCPFlow from mitmproxy.tcp import TCPFlow
from test.mitmproxy.proxy.layers.test_tls import reply_tls_start from test.mitmproxy.proxy.layers.test_tls import reply_tls_start
@ -274,3 +274,111 @@ def test_reverse_eager_connect_failure(tctx: Context):
<< CloseConnection(tctx.client) << CloseConnection(tctx.client)
>> ConnectionClosed(tctx.client) >> ConnectionClosed(tctx.client)
) )
def test_transparent_eager_connect_failure(tctx: Context, monkeypatch):
"""Test that we recover from a transparent mode resolve error."""
tctx.options.connection_strategy = "eager"
monkeypatch.setattr(platform, "original_addr", lambda sock: ("address", 22))
assert (
Playbook(modes.TransparentProxy(tctx), logs=True)
<< GetSocket(tctx.client)
>> reply(object())
<< OpenConnection(tctx.server)
>> reply("something something")
<< CloseConnection(tctx.client)
>> ConnectionClosed(tctx.client)
)
CLIENT_HELLO = b"\x05\x01\x00"
SERVER_HELLO = b"\x05\x00"
@pytest.mark.parametrize("address,packed", [
("127.0.0.1", b"\x01\x7f\x00\x00\x01"),
("::1", b"\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"),
("example.com", b"\x03\x0bexample.com"),
])
def test_socks5_success(address: str, packed: bytes, tctx: Context):
tctx.options.connection_strategy = "eager"
playbook = Playbook(modes.Socks5Proxy(tctx))
server = Placeholder(Server)
nextlayer = Placeholder(NextLayer)
assert (
playbook
>> DataReceived(tctx.client, CLIENT_HELLO)
<< SendData(tctx.client, SERVER_HELLO)
>> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata")
<< OpenConnection(server)
>> reply(None)
<< SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
<< NextLayerHook(nextlayer)
)
assert server().address == (address, 0x1234)
assert nextlayer().data_client() == b"applicationdata"
def test_socks5_trickle(tctx: Context):
playbook = Playbook(modes.Socks5Proxy(tctx))
for x in CLIENT_HELLO:
playbook >> DataReceived(tctx.client, bytes([x]))
playbook << SendData(tctx.client, b"\x05\x00")
for x in b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34":
playbook >> DataReceived(tctx.client, bytes([x]))
assert playbook << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
@pytest.mark.parametrize("data,err,msg", [
(b"GET / HTTP/1.1",
None,
"Probably not a SOCKS request but a regular HTTP request. Invalid SOCKS version. Expected 0x05, got 0x47"),
(b"abcd",
None,
"Invalid SOCKS version. Expected 0x05, got 0x61"),
(b"\x05\x01\x02",
b"\x05\xFF\x00\x01\x00\x00\x00\x00\x00\x00",
"mitmproxy only supports SOCKS without authentication"),
(CLIENT_HELLO + b"\x05\x02\x00\x01\x7f\x00\x00\x01\x12\x34",
SERVER_HELLO + b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00",
r"Unsupported SOCKS5 request: b'\x05\x02\x00\x01\x7f\x00\x00\x01\x124'"),
(CLIENT_HELLO + b"\x05\x01\x00\xFF\x00\x00",
SERVER_HELLO + b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00",
r"Unknown address type: 255"),
])
def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context):
playbook = (
Playbook(modes.Socks5Proxy(tctx), logs=True)
>> DataReceived(tctx.client, data)
)
if err:
playbook << SendData(tctx.client, err)
playbook << CloseConnection(tctx.client)
playbook << Log(msg)
assert playbook
def test_socks5_eager_err(tctx: Context):
tctx.options.connection_strategy = "eager"
server = Placeholder(Server)
assert (
Playbook(modes.Socks5Proxy(tctx))
>> DataReceived(tctx.client, CLIENT_HELLO)
<< SendData(tctx.client, SERVER_HELLO)
>> DataReceived(tctx.client, b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34")
<< OpenConnection(server)
>> reply("out of socks")
<< SendData(tctx.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00")
<< CloseConnection(tctx.client)
)
def test_socks5_premature_close(tctx: Context):
assert (
Playbook(modes.Socks5Proxy(tctx), logs=True)
>> DataReceived(tctx.client, b"\x05")
>> ConnectionClosed(tctx.client)
<< Log(r"Client closed connection before completing SOCKS5 handshake: b'\x05'")
<< CloseConnection(tctx.client)
)

View File

@ -0,0 +1,17 @@
from hypothesis import given
from hypothesis.strategies import binary
from mitmproxy import options
from mitmproxy.connection import Client
from mitmproxy.proxy.context import Context
from mitmproxy.proxy.events import DataReceived
from mitmproxy.proxy.layers.modes import Socks5Proxy
opts = options.Options()
tctx = Context(Client(("client", 1234), ("127.0.0.1", 8080), 1605699329), opts)
@given(binary())
def test_socks5_fuzz(data):
layer = Socks5Proxy(tctx)
list(layer.handle_event(DataReceived(tctx.client, data)))