Skip to content

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 and y 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

  1. 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
  2. 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).
  3. 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.
  4. 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.
  5. 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 of 172.16.101.10

  • The server.py script is on another VM with the IP of 172.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.

base64 /dev/urandom | head -c 10000 > file.txt

A few things to note

  • After the client closes the connection (FIN) it starts listening for the FIN from the server. This takes a short amount of time so note the time.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.

    # Add the iptables rule to block outgoing RST packets
    os.system(f"sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST --sport {server_port} -j DROP")
    
    # Remove the iptables rule
    os.system(f"sudo iptables -D OUTPUT -p tcp --tcp-flags RST RST --sport {server_port} -j DROP")
    

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.

scapy/scapy/packet.py

Resources

Comments