Having used proxy applications such as Charles Proxy and Burp Suite I’ve always been curious how it’s functionality is accomplished. The basic proxy functionality can be accomplished using python sockets. This blog post describes writing a simple Python script which allows users to read and modify network request/response data on a local machine before sending it along to a remote host.
Prerequisites
Prerequisites for this blog post include:
- Python 2.7.15+
- An FTP client such as Filezilla(for testing)
- A working FTP endpoint
Main Method
Implement a main method which collects the required arguments. The required arguments are:
[localhost]
: The localhost IP address to listen for requests on[localport]
: The localhost port to listen for requests on[remotehost]
: The remotehost (destination) to forward the request to.[remoteport]
: The remoteport to forward the request to.[receive_first]
:True|False
if the request should be received first for modification before forwarding.
Additionally the main method will invoke server_loop()
which loops infinitely to listen for any incoming connections (see implementation below).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | def main(): # argv[1:] - get all arguments after the script name if len(sys.argv[1:]) != 5: print "Usage: python tcp_proxy.py [localhost] [localport] [remotehost] [remoteport] [receieve_first]" print "Example: python tcp_proxy 127.0.0.1 21 target.host.ca 21 True" sys.exit(0) # Store the arguments local_host = sys.argv[1] local_port = int(sys.argv[2]) remote_host = sys.argv[3] remote_port = int(sys.argv[4]) # connect and receive data before sending to the remote host? receive_first = sys.argv[5] if "True" or "true" in receive_first: receive_first = True else: receive_first = False # Start looping and listening for incoming requests (see implementation below) server_loop(local_host, local_port, remote_host, remote_port, receive_first) |
Server Loop (server_loop()
)
Define a method named server_loop
. This method accepts all the provided command args and begins looping infinitely. It starts by binding to the local socket and hands the accepted data off to another method on another thread (proxy_handler
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def server_loop(local_host, local_port, remote_host, remote_port, receive_first): # Define a server socket to listen on server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: # Bind the socket to the defined local address and port server.bind((local_host, local_port)) except: print "[!!] Failed to connect to {0}:{1}".format(local_host, local_port) print "[!!] Check for other listening sockets or correct" sys.exit(0) print "Successfully listening on {0}:{1}".format(local_host, local_port) # Listen for a maximum of 5 connections server.listen(5) # Loop infinitely for incoming connections while True: client_socket, addr = server.accept() print "[==>] Received incoming connection from {0}:{1}".format(addr[0], addr[1]) # Start a new thread for any incoming connections proxy_thread = threading.Thread(target=proxy_handler, args=(client_socket, remote_host, remote_port, receive_first)) proxy_thread.start() |
Implement the proxy handler (proxy_handler()
)
Define a method named proxy_handler
which will handle the incoming connections on another thread (as invoked above). Within this method are additional methods receive_from()
, send_data()
, hexdump()
, request_handler()
and response_handler()
which will be defined in the next few sections.
This method defines a remote socket similar to the local socket defined above. This remote socket is used to listen to responses and forward requests from the client socket. Once connected it (if receive_first
is enabled it receives any response data returned and allows an opportunity to read or write it. It then begins a new infinite loop which handles all of the following request/responses. The loop continues until there’s no data left. Once there’s no more data it closes the the connections.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | def proxy_handler(client_socket, remote_host, remote_port, receive_first): # Define the remote socket used for forwarding requests remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Establish a connection to the remote host remote_socket.connect((remote_host, remote_port)) # intercept the response before it's received if receive_first: # receive data from the connection and return a buffer remote_buffer = receive_from(remote_socket) # Convert the buffer from hex to human readable output hexdump(remote_buffer) # Handle the response (an opportunity for read/write of the response data) remote_buffer = response_handler(remote_buffer) # If data exists send the response to the local client if len(remote_buffer): print "[<==] Sending {0} bytes from localhost".format(len(remote_buffer)) client_socket.send(remote_buffer) # Continually read from local, print the output and forward to the remotehost while True: # Receive data from the client and send it to the remote local_buffer = receive_from(client_socket) send_data(local_buffer, "localhost", remote_socket) # Receive the response and sent it to the client remote_buffer = receive_from(remote_socket) send_data(remote_buffer, "remotehost", client_socket) # Close connections, print and break out when no more data is available if not len(local_buffer): client_socket.close() remote_socket.close() print "[*] No more data. Connections closed" break |
Receiving Data (receive_from()
)
This convenience method accepts a socket connection and receives data 4096
butes at a time until no further data is left. It returns the represented data as bytes
when finished.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def receive_from(connection): buffer = "" # use a 2 second timeout connection.settimeout(2) try: while True: data = connection.recv(4096) if not data: break buffer += data except: pass return buffer |
Handling Requests and Responses (request_handler()
and response_handler()
)
These convenience methods give you a chance to read and or modify the request/response data before it’s sent. For this example these methods only print out the buffered data as it passes through.
1 2 3 4 5 6 7 8 | def response_handler(buffer): print "response_handler: {0}".format(buffer) return buffer def request_handler(buffer): print "request handler: {0}".format(buffer) return buffer |
Convert the Hex Data (hexdump()
)
The data coming in off the wire is in hexadecimal format. It needs to be converted in order to be human readable. To This method was borrowed from ActiveState Code Recipes.
1 2 3 4 5 6 7 8 9 10 | def hexdump(src, length=16): result = [] digits = 4 if isinstance(src, unicode) else 2 for i in xrange(0, len(src), length): s = src[i:i+length] hexa = b' '.join(["%0*X" % (digits, ord(x)) for x in s]) text = b''.join([x if 0x20 <= ord(x) < 0x7F else b'.' for x in s]) result.append( b"%04X %-*s %s" % (i, length*(digits + 1), hexa, text)) print b'\n'.join(result) |
Example Usage
The following is an example usage of the script which listens to an attempted FTP connection (to a real endpoint) using incorrect credentials. Several requests and responses are made until the server finally returns a 530 “login incorrect” response. Start by running the python script with the following arguments:
# Sudo is required as listening on port 21 requires root priveledges $ sudo python tcp_proxy.py 127.0.0.1 21 working.ftp.endpoint 21 True Successfully listening on 127.0.0.1:21 |
Open Filezilla and attempt an FTP connection to 127.0.0.1
port 21
. For this example I used a known username and an incorrect password. Observe the following output below:
- Initial response from the vsFTPd server
- An initial
AUTH TLS
request to the server - A 530 response for TLS “Please login with USER and PASS” from the server
- A
AUTH SSL
request to the server - The 530 response for SSL “Please login with USER and PASS”
USER
rileymacdonald sent to the server- The server accepts the username and requests a password “331 Please specify the password”
PASS
badpass sent to the server- “530 Login incorrect” returned from the server because the password is incorrect
[==>] Received incoming connection from 127.0.0.1:45028 0000 32 32 30 20 28 76 73 46 54 50 64 20 33 2E 30 2E 220 (vsFTPd 3.0. 0010 33 29 0D 0A 3).. response_handler: 220 (vsFTPd 3.0.3) [<==] Sending 20 bytes from localhost [==>] Received 10 bytes from localhost 0000 41 55 54 48 20 54 4C 53 0D 0A AUTH TLS.. request handler: AUTH TLS [==>] Sent to remote [<==] Received 38 bytes from remote. 0000 35 33 30 20 50 6C 65 61 73 65 20 6C 6F 67 69 6E 530 Please login 0010 20 77 69 74 68 20 55 53 45 52 20 61 6E 64 20 50 with USER and P 0020 41 53 53 2E 0D 0A ASS... response_handler: 530 Please login with USER and PASS. [<==] Sent to localhost [==>] Received 10 bytes from localhost 0000 41 55 54 48 20 53 53 4C 0D 0A AUTH SSL.. request handler: AUTH SSL [==>] Sent to remote [<==] Received 38 bytes from remote. 0000 35 33 30 20 50 6C 65 61 73 65 20 6C 6F 67 69 6E 530 Please login 0010 20 77 69 74 68 20 55 53 45 52 20 61 6E 64 20 50 with USER and P 0020 41 53 53 2E 0D 0A ASS... response_handler: 530 Please login with USER and PASS. [<==] Sent to localhost [==>] Received 16 bytes from localhost 0000 55 53 45 52 20 73 69 6C 65 6E 63 65 63 6D 0D 0A USER silencecm.. request handler: USER rileymacdonald [==>] Sent to remote [<==] Received 34 bytes from remote. 0000 33 33 31 20 50 6C 65 61 73 65 20 73 70 65 63 69 331 Please speci 0010 66 79 20 74 68 65 20 70 61 73 73 77 6F 72 64 2E fy the password. 0020 0D 0A .. response_handler: 331 Please specify the password. [<==] Sent to localhost [==>] Received 14 bytes from localhost 0000 50 41 53 53 20 62 61 64 70 61 73 73 0D 0A PASS badpass.. request handler: PASS badpass [==>] Sent to remote [<==] Received 22 bytes from remote. 0000 35 33 30 20 4C 6F 67 69 6E 20 69 6E 63 6F 72 72 530 Login incorr 0010 65 63 74 2E 0D 0A ect... response_handler: 530 Login incorrect. [*] No more data. Connections closed
Without using any third party dependencies we were able to gain great insight to how Filezilla handles sending FTP requests and how the server handles responding to those requests.
The entire script
Here is the entire script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | import sys import socket import threading def server_loop(local_host, local_port, remote_host, remote_port, receive_first): # Define a server socket to listen on server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: # Bind the socket to the defined local address and port server.bind((local_host, local_port)) except: print "[!!] Failed to connect to {0}:{1}".format(local_host, local_port) print "[!!] Check for other listening sockets or correct" sys.exit(0) print "Successfully listening on {0}:{1}".format(local_host, local_port) # Listen for a maximum of 5 connections server.listen(5) # Loop infinitely for incoming connections while True: client_socket, addr = server.accept() print "[==>] Received incoming connection from {0}:{1}".format(addr[0], addr[1]) # Start a new thread for any incoming connections proxy_thread = threading.Thread(target=proxy_handler, args=(client_socket, remote_host, remote_port, receive_first)) proxy_thread.start() def main(): # argv[1:] - get all arguments after the script name if len(sys.argv[1:]) != 5: print "Usage: python tcp_proxy.py [localhost] [localport] [remotehost] [remoteport] [receieve_first]" print "Example: python tcp_proxy 127.0.0.1 21 target.host.ca 21 True" sys.exit(0) # Store the arguments local_host = sys.argv[1] local_port = int(sys.argv[2]) remote_host = sys.argv[3] remote_port = int(sys.argv[4]) # connect and receive data before sending to the remote host? receive_first = sys.argv[5] if "True" or "true" in receive_first: receive_first = True else: receive_first = False # Start looping and listening for incoming requests (see implementation below) server_loop(local_host, local_port, remote_host, remote_port, receive_first) def proxy_handler(client_socket, remote_host, remote_port, receive_first): # Define the remote socket used for forwarding requests remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Establish a connection to the remote host remote_socket.connect((remote_host, remote_port)) # intercept the response before it's received if receive_first: # receive data from the connection and return a buffer remote_buffer = receive_from(remote_socket) # Convert the buffer from hex to human readable output hexdump(remote_buffer) # Handle the response (an opportunity for read/write of the response data) remote_buffer = response_handler(remote_buffer) # If data exists send the response to the local client if len(remote_buffer): print "[<==] Sending {0} bytes from localhost".format(len(remote_buffer)) client_socket.send(remote_buffer) # Continually read from local, print the output and forward to the remotehost while True: # Receive data from the client and send it to the remote local_buffer = receive_from(client_socket) send_data(local_buffer, "localhost", remote_socket) # Receive the response and sent it to the client remote_buffer = receive_from(remote_socket) send_data(remote_buffer, "remotehost", client_socket) # Close connections, print and break out when no more data is available if not len(local_buffer): client_socket.close() remote_socket.close() print "[*] No more data. Connections closed" break def send_data(buffer, type, socket): if len(buffer): print "[<==] Received {0} bytes from {1}.".format(len(buffer), type) hexdump(buffer) if "localhost" in type: mod_buffer = request_handler(buffer) else: mod_buffer = response_handler(buffer) socket.send(mod_buffer) print "[<==>] Sent to {0}".format(type) def receive_from(connection): buffer = "" # use a 2 second timeout connection.settimeout(2) try: while True: data = connection.recv(4096) if not data: break buffer += data except: pass return buffer def response_handler(buffer): print "response_handler: {0}".format(buffer) return buffer def request_handler(buffer): print "request handler: {0}".format(buffer) return buffer def hexdump(src, length=16): result = [] digits = 4 if isinstance(src, unicode) else 2 for i in xrange(0, len(src), length): s = src[i:i+length] hexa = b' '.join(["%0*X" % (digits, ord(x)) for x in s]) text = b''.join([x if 0x20 <= ord(x) < 0x7F else b'.' for x in s]) result.append( b"%04X %-*s %s" % (i, length*(digits + 1), hexa, text)) print b'\n'.join(result) main() |