Simple TCP Setup with the Python Scapy Library¶
Estimated time to read: 12 minutes
- Originally Written: March, 2024
A quick TCP overview¶
This post provides some example client and server Python scripts to setup a TCP session and "upload" data from the client to the server.
It is very basic and does not provide features such as congestion control or error correction.
Here are the high level stages in the flow.
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: Connection Establishment
C->>+S: SYN (seq=x)
S-->>-C: SYN-ACK (seq=y, ack=x+1)
C->>+S: ACK (seq=x+1, ack=y+1)
Note over C,S: Data Transfer
loop Sending Payload in Chunks
C->>+S: PSH-ACK with Data (seq=x+chunk_size, ack=y+1)
S-->>-C: ACK (seq=y+1, ack=x+chunk_size)
end
Note over C: Connection Teardown by Client
C->>+S: FIN-ACK (seq=x, ack=y+1)
S-->>-C: ACK (seq=y+1, ack=x+1)
Note over S: Server initiates its own FIN
S->>+C: FIN-ACK (seq=y+1, ack=x+1)
C-->>-S: ACK (seq=x+1, ack=y+1)
Note over C,S: Connection Closed
A few notes¶
- The variables
x
andy
are placeholders for the initial sequence numbers of the client and server, respectively chunk_size
is a placeholder for the size of the data being transferred during the communication
High level steps¶
-
Connection Establishment
- The client initiates the TCP handshake by sending a SYN packet with its initial sequence number (
seq=x
) to the server - The server responds with a SYN-ACK packet, acknowledging the client's SYN (
ack=x+1
) and providing its own initial sequence number (seq=y
) - The client sends an ACK packet to acknowledge the server's SYN-ACK (
ack=y+1
), which completes the three-way handshake and establishes the connection
- The client initiates the TCP handshake by sending a SYN packet with its initial sequence number (
-
Data Transfer
- The client sends chunks of data to the server using PSH-ACK packets. Each packet has an updated sequence number (
seq=x+n
) and acknowledgment number (ack=y+1
) - The server acknowledges each chunk of data received by sending back an ACK packet with an acknowledgment number incremented by the chunk size (
ack=x+chunk_size
).
- The client sends chunks of data to the server using PSH-ACK packets. Each packet has an updated sequence number (
-
Connection Teardown by Client
- The client initiates the connection teardown by sending a FIN-ACK packet with its final sequence number (
seq=x
) and acknowledgment number (ack=y+1
). - The server responds with an ACK packet to acknowledge the client's FIN-ACK (
ack=x+1
), indicating that it has received the client's request to terminate the connection.
- The client initiates the connection teardown by sending a FIN-ACK packet with its final sequence number (
-
Server Initiates Its Own FIN
- Once the server has finished sending data, it initiates its own FIN by sending a FIN-ACK packet with its final sequence number incremented by any data length it sent (
seq=y+1
) and the client's acknowledgment number (ack=x+1
). - The client acknowledges the server's FIN-ACK by sending a final ACK packet (
ack=y+1
), completing the four-way handshake.
- Once the server has finished sending data, it initiates its own FIN by sending a FIN-ACK packet with its final sequence number incremented by any data length it sent (
-
Connection Closed
- Both the client and server have now completed the connection termination process, and the connection is considered closed.
Setting up the environment¶
-
I have the
client.py
script running on one Linux Ubuntu VM with the IP of172.16.101.10
-
The
server.py
script is on another VM with the IP of172.16.102.10
-
The client and server VMs have reachability (i.e. they can
ping
each other) -
Running the following command on the client will create a file filled with random data which is used to send to the server.
A few things to note
-
After the client closes the connection (
FIN
) it starts listening for theFIN
from the server. This takes a short amount of time so note thetime.sleep(1)
line in the server script which gives the client time to setup (sniff
).- Alternatively you could use the following on the client side to listen asynchronously.
AsyncSniffer(iface="ens224",filter=f"tcp and dst port {self.local_port}", prn=self.handle_packet, store=0)
-
You may see Linux send reset (RST) packets before you're able to establish the TCP connection. In that case I configured an IPTables rule to drop RST packets on the server port I was using. The following was added to the server VM. Don't forget to remove the rule at the end of the script.
The client script¶
More details on Scapy
If you're confused about the use of the slash or divide sign in the scripts below then see the Some more details on Scapy section below.
client.py
from scapy.all import *
import random
class TCPClient:
def __init__(self, server_ip, server_port, chunk_size=512):
self.server_ip = server_ip
self.server_port = server_port
self.local_port = random.randint(1024, 65535)
self.client_seq_num = 1000
self.client_ack_num = None
self.server_seq_num = None
self.chunk_size = 1400
def initiate_connection(self):
print(f"SENDING SYN PACKET")
syn_packet = IP(dst=self.server_ip) / TCP(sport=self.local_port, dport=self.server_port, flags='S', seq=self.client_seq_num)
syn_ack_response = sr1(syn_packet, verbose=False)
self.client_seq_num += 1
print(f"RECEIVING SYN ACK")
if syn_ack_response and syn_ack_response.haslayer(TCP) and syn_ack_response[TCP].flags == 'SA': # SYN-ACK flags
print(f"SENDING ACK TO SYN-ACK")
self.server_seq_num = syn_ack_response[TCP].seq
self.client_ack_num = self.server_seq_num + 1
ack_packet = IP(dst=self.server_ip) / TCP(sport=self.local_port, dport=self.server_port, flags='A', seq=self.client_seq_num, ack=self.server_seq_num + 1)
send(ack_packet, verbose=False)
print(f"THREE-WAY HANDSHAKE COMPLETE")
return True
return False
def send_payload(self, payload):
if not self.initiate_connection():
print("FAILED TO ESTABLISH A CONNECTION WITH THE SERVER")
return
bytes_sent = 0
current_packet_index = 0
total_packets = int(len(payload)/self.chunk_size)
while bytes_sent < len(payload):
print(f"SENDING PACKET {current_packet_index + 1} OF {total_packets}")
print(f"TOTAL BYTES SENT: {bytes_sent} PAYLOAD {len(payload)}")
chunk = payload[bytes_sent:bytes_sent + self.chunk_size]
psh_ack_packet = IP(dst=self.server_ip) / TCP(sport=self.local_port, dport=self.server_port, flags='PA', seq=self.client_seq_num, ack=self.client_ack_num) / Raw(load=chunk)
ack_response = sr1(psh_ack_packet, verbose=False)
print(f"UPDATING MY SEQUENCE NUMBER")
self.client_seq_num += len(chunk)
print(f"RECEIVING AN ACK FOR THIS CHUNK OF DATA")
if ack_response and ack_response.haslayer(TCP) and ack_response[TCP].flags == 'A': # ACK flag
bytes_sent += len(chunk)
current_packet_index += 1
else:
print("FAILED TO SEND PAYLOAD")
return
print("PAYLOAD SENT SUCCESSFULLY")
def terminate_connection(self):
# AsyncSniffer(iface="ens224",filter=f"tcp and dst port {self.local_port}", prn=self.handle_packet, store=0)
print(f"SENDING CLIENT FIN")
fin_packet = IP(dst=self.server_ip) / TCP(sport=self.local_port, dport=self.server_port, flags='FA', seq=self.client_seq_num, ack=self.client_ack_num)
fin_ack_response = sr1(fin_packet, verbose=False)
if fin_ack_response and fin_ack_response.haslayer(TCP) and fin_ack_response[TCP].flags == 'A':
print(f"RECEIVED AN ACK FOR CLIENT FIN: MY CONNECTION IS CLOSED")
print("STILL LISTENING FOR A SERVER FIN")
sniff(iface="ens224",filter=f"tcp and dst port {self.local_port}", prn=self.handle_packet, store=0)
def handle_packet(self, packet):
if packet.haslayer(IP) and packet.haslayer(TCP) and packet[TCP].flags == 'FA':
print(f"RECEIVED A FIN FROM SERVER")
print(f"SENDING ACK TO FIN-ACK ")
self.server_seq_num = packet[TCP].seq
fin_packet = IP(dst=packet[IP].src) / TCP(sport=packet[TCP].dport, dport=packet[TCP].sport, flags='A', seq=self.client_seq_num + 1, ack=self.server_seq_num + 1)
send(fin_packet, verbose=0)
print(f"EXITING")
exit()
def send_file(self, file_path):
with open(file_path, 'rb') as f:
file_data = f.read()
self.send_payload(file_data)
self.terminate_connection()
if __name__ == "__main__":
client = TCPClient(server_ip="172.16.102.10", server_port=12345)
client.send_file("file.txt")
The server script¶
server.py
from scapy.all import *
import time
class TCPServer:
def __init__(self, host='0.0.0.0', port=12345):
self.host = host
self.port = port
self.server_seq_num = 5000
self.client_seq_num = None
self.client_ack_num = None
def start(self):
print(f"STARTING TCP SERVER ON {self.host}:{self.port}")
sniff(iface="ens224",filter=f"tcp and dst port {self.port}", prn=self.handle_packet, store=0)
def handle_packet(self, packet):
if packet.haslayer(IP) and packet.haslayer(TCP):
self.client_seq_num = packet[TCP].seq
self.client_ack_num = packet[TCP].ack
if packet[TCP].flags == 'S': # SYN flag
print(f"RECEIVED A SYN")
print(f"SENDING A SYN-ACK")
syn_ack_packet = IP(dst=packet[IP].src) / TCP(sport=self.port, dport=packet[TCP].sport, flags='SA', seq=self.server_seq_num, ack=self.client_seq_num + 1)
send(syn_ack_packet, verbose=0)
print(f"UPDATING MY SEQUENCE NUMBER")
self.server_seq_num += 1
elif packet[TCP].flags == 'A': # ACK flag
print(f"RECEIVED AN ACK FOR SYN-ACK")
print(f"THREE-WAY HANDSHAKE COMPLETE")
elif packet[TCP].flags == 'PA': # PSH and ACK flags
print(f"RECEIVED SOME DATA: {packet[TCP].payload.load.decode()}")
print(f"SENDING AN ACK FOR THIS CHUNK OF DATA")
ack_packet = IP(dst=packet[IP].src) / TCP(sport=self.port, dport=packet[TCP].sport, flags='A', seq=self.server_seq_num, ack=self.client_seq_num + len(packet[TCP].payload.load))
send(ack_packet, verbose=0)
elif packet[TCP].flags == 'FA': # FIN flag
print(f"RECEIVED A FIN FROM CLIENT")
print(f"SENDING ACK TO FIN-ACK ")
fin_packet = IP(dst=packet[IP].src) / TCP(sport=packet[TCP].dport, dport=packet[TCP].sport, flags='A', seq=self.server_seq_num, ack=self.client_seq_num + 1)
send(fin_packet, verbose=0)
# print(f"UPDATING MY SEQUENCE NUMBER")
# self.server_seq_num += 1
print(f"SENDING SERVER FIN")
time.sleep(1)
fin_packet = IP(dst=packet[IP].src) / TCP(sport=packet[TCP].dport, dport=packet[TCP].sport, flags='FA', seq=self.server_seq_num, ack=self.client_seq_num + 1 )
fin_ack_response = sr1(fin_packet, verbose=False)
if fin_ack_response and fin_ack_response.haslayer(TCP) and fin_ack_response[TCP].flags == 'A': # FIN-ACK flags
print(f"RECEIVED AN ACK FOR SERVER FIN: MY CONNECTION IS CLOSED")
print(f"EXITING")
exit()
if __name__ == "__main__":
server = TCPServer()
server.start()
Verify¶
Packet capture on the client VM
Packet capture on the server VM
Flow graph of the TCP session
Some more details on Scapy¶
I was confused when I first saw the /
character used to construct packets.
syn_packet = IP(dst=self.server_ip) / TCP(sport=self.local_port, dport=self.server_port, flags='S')
Python operator overloading¶
It turns out the Scapy library is overloading the division operator to create the packet structure.
Operator overloading in Python allows you to define custom behavior for how operators (e.g. +
, -
, *
, /
) work with objects of user-defined classes. You do this by redefining the methods associated with these operators.
Each operator has an associated special method (you might also see it called a "magic" method) convention (__methodname__
) and by defining these methods in your own class, you can change how operators work with instances of the class.
Some common operators are:
__add__(self, other)
for addition +__sub__(self, other)
for subtraction -__mul__(self, other
) for multiplication *__truediv__(self, other)
for division /__mod__(self, other)
for modulus %__lt__(self, other)
for less than <__eq__(self, other)
for equality ==__gt__(self, other)
for greater than >
An example: overloading the addition operator¶
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
else:
raise ValueError("Operand must be an instance of Vector")
# Usage
v1 = Vector(2, 4)
v2 = Vector(1, -1)
v3 = v1 + v2 # Vector(3, 3)
print(v3)
Back to Scapy¶
The Scapy library implements the __div__
method to combine the layers (IP
and TCP
) to form a new packet.