[Python] How to write a TCP proxy for inspection and fuzzing
by Riley MacDonald, January 24, 2019

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()
Open the comment form

Leave a comment:

Comments will be reviewed before they are posted.

User Comments:

anonymous on 2021-05-28 05:02:21 said:
LOL!, copied from the book blackhat python,