Skip to content

Latest commit

 

History

History
295 lines (232 loc) · 11.2 KB

File metadata and controls

295 lines (232 loc) · 11.2 KB

Lab Socket Chat Client Server

Build a multi-client TCP chat server and client to learn sockets, threading, and message framing — the foundations behind C2 and reverse-shell tooling.

Warning

Run this chat only on localhost or inside an isolated lab network you control. Bind to 127.0.0.1 — never expose an unauthenticated listener to the internet or an untrusted LAN. This is educational network programming, not a covert channel. Only run these labs against systems you own or are explicitly authorized to test.

Objective

  • Create a listening TCP server with socket.
  • Handle multiple clients concurrently with one thread each.
  • Broadcast messages from any client to all others.
  • Handle disconnects and clean shutdown gracefully.

Learning Outcomes

After completing this lab you will be able to:

  • Build a TCP server that accepts and serves multiple concurrent clients.
  • Explain why SO_REUSEADDR is needed and what TIME_WAIT means.
  • Protect shared state across threads with a Lock.
  • Frame messages over a stream protocol that does not preserve message boundaries.
  • Shut a threaded server down cleanly.

Prerequisites

  • [[Socket-Module|Socket Module]] — bind, listen, accept, send, recv.
  • [[Threading-Module|Threading Module]] — one thread per client, and Lock.
  • [[Object-Oriented-Programming/Readme|Object-Oriented Programming]] — structuring the server as a class.
  • [[Error-and-Exception-Handling/Readme|Error & Exception Handling]] — clients disconnect abruptly.

Lab Environment

Item Detail
Python 3.8+
Modules socket, threading, argparse (standard library)
Terminals 3+ (one server, two or more clients)

Setup

mkdir -p ~/labs/socket-chat && cd ~/labs/socket-chat
python3 -m venv .venv
source .venv/bin/activate

nano server.py    # paste the Server code
nano client.py    # paste the Client code

# Run each in its own terminal (all activate the same .venv):
# Terminal 1:  python server.py
# Terminal 2:  python client.py alice
# Terminal 3:  python client.py bob

Tasks

  1. Bind and listen. Create a TCP server socket, set SO_REUSEADDR, bind to 127.0.0.1, and listen.
  2. Accept one client. Accept a connection, echo whatever it sends, and close cleanly.
  3. Go multi-client. Spawn a thread per accepted connection so several clients can connect at once.
  4. Track clients safely. Keep the client list in a structure guarded by a threading.Lock.
  5. Broadcast. Relay each received message to every other connected client.
  6. Frame the messages. A stream socket does not preserve boundaries — two send() calls can arrive as one recv(). Add newline delimiting or a length prefix, and buffer partial reads.
  7. Handle disconnects. Treat an empty recv() as a closed connection and remove the client under the lock.
  8. Shut down cleanly. Handle KeyboardInterrupt, close every client socket, and join the threads.

Complete Example Code

# ---- server.py ----
#!/usr/bin/env python3
"""Multi-client TCP chat server (localhost only)."""
import argparse
import socket
import threading

clients: list[socket.socket] = []
lock = threading.Lock()


def broadcast(message: bytes, sender: socket.socket) -> None:
    with lock:
        for client in list(clients):
            if client is not sender:
                try:
                    client.sendall(message)
                except OSError:
                    clients.remove(client)


def handle(client: socket.socket, addr: tuple[str, int]) -> None:
    print(f"[+] {addr} connected")
    try:
        while True:
            data = client.recv(1024)
            if not data:
                break
            broadcast(data, client)
    except OSError:
        pass
    finally:
        with lock:
            if client in clients:
                clients.remove(client)
        client.close()
        print(f"[-] {addr} disconnected")


def main() -> None:
    parser = argparse.ArgumentParser(description="TCP chat server.")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=5050)
    args = parser.parse_args()

    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((args.host, args.port))
    server.listen()
    print(f"[*] Listening on {args.host}:{args.port} (Ctrl-C to stop)")

    try:
        while True:
            client, addr = server.accept()
            with lock:
                clients.append(client)
            threading.Thread(target=handle, args=(client, addr),
                             daemon=True).start()
    except KeyboardInterrupt:
        print("\n[*] Shutting down.")
    finally:
        server.close()


if __name__ == "__main__":
    main()
# ---- client.py ----
#!/usr/bin/env python3
"""TCP chat client (localhost only)."""
import argparse
import socket
import threading


def receive(sock: socket.socket) -> None:
    while True:
        try:
            data = sock.recv(1024)
            if not data:
                print("\n[!] Server closed the connection.")
                break
            print(data.decode(errors="ignore"))
        except OSError:
            break


def main() -> None:
    parser = argparse.ArgumentParser(description="TCP chat client.")
    parser.add_argument("nick", help="Your display name")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=5050)
    args = parser.parse_args()

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((args.host, args.port))
    threading.Thread(target=receive, args=(sock,), daemon=True).start()

    print("[*] Connected. Type messages, Ctrl-C to quit.")
    try:
        while True:
            text = input()
            if text:
                sock.sendall(f"{args.nick}: {text}".encode())
    except (KeyboardInterrupt, EOFError):
        print("\n[*] Leaving chat.")
    finally:
        sock.close()


if __name__ == "__main__":
    main()

Expected Output

# Terminal 1 (server)
[*] Listening on 127.0.0.1:5050 (Ctrl-C to stop)
[+] ('127.0.0.1', 51122) connected
[+] ('127.0.0.1', 51124) connected
[-] ('127.0.0.1', 51124) disconnected

# Terminal 2 (alice) — after bob sends a line, then leaves
[*] Connected. Type messages, Ctrl-C to quit.
Hi alice
bob: hey there
[!] Server closed the connection.

# Terminal 3 (bob)
[*] Connected. Type messages, Ctrl-C to quit.
alice: Hi alice
hey there

Explanation

  • One thread per client lets the server serve many connections at once. daemon=True means these threads die with the main process, so Ctrl-C actually exits.
  • SO_REUSEADDR lets the server rebind the port immediately after restart instead of waiting out the kernel's TIME_WAIT — essential during iterative development.
  • A Lock around the client list prevents two threads mutating it simultaneously (one broadcasting while another removes a dead socket), which would otherwise race and crash.
  • recv returning b"" is the canonical signal that the peer closed the connection cleanly — the loop must check for it, or it will spin.
  • This is the same skeleton as a reverse shell / C2 channel: accept a connection, run a per-client handler, relay bytes. The difference is intent and authorization. Understanding it defensively helps you recognise the pattern in malicious traffic.

Validation

# Terminal 1
python3 chat_server.py --host 127.0.0.1 --port 9009
[*] Listening on 127.0.0.1:9009
[+] alice connected from 127.0.0.1:52114
[+] bob connected from 127.0.0.1:52118
[-] bob disconnected
# Terminals 2 and 3
python3 chat_client.py --host 127.0.0.1 --port 9009 --nick alice
python3 chat_client.py --host 127.0.0.1 --port 9009 --nick bob
[alice] hello
[bob] hi alice
  • Two clients connect simultaneously and both stay connected.
  • A message from one client appears in the other's terminal.
  • The sender does not receive an echo of their own message.
  • Killing one client leaves the server and the other client running.
  • Restarting the server immediately works — no "Address already in use".
  • Ctrl-C shuts the server down without leaving orphaned threads.

Challenges

  1. Add a length-prefix framing header so messages longer than 1024 bytes and partial reads are handled correctly.
  2. Broadcast join/leave notices ("alice joined") to all clients.
  3. Add /nick, /list, and /quit commands parsed server-side.
  4. Wrap the sockets in TLS with ssl.wrap_socket and a self-signed cert.
  5. Rewrite the server with selectors (single-threaded, event-driven) and compare complexity.

Troubleshooting

Symptom Likely cause Fix
OSError: [Errno 98] Address already in use Previous socket still in TIME_WAIT Set sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) before bind()
Messages arrive concatenated or split TCP is a byte stream with no message boundaries Delimit with newlines or a length prefix, and buffer partial reads
Server exits when a client disconnects Unhandled ConnectionResetError in the client thread Catch it per client and remove that client only
Client list corrupted with several clients Shared state mutated without a lock Guard every read and write with threading.Lock
Server hangs on shutdown Client threads still blocked in recv() Mark threads daemon=True, or close client sockets to unblock them
Client connects but sees nothing Broadcast excludes all recipients, or a missing flush Verify the recipient list and that output is flushed

Security Notes

  • Bind to 127.0.0.1, never 0.0.0.0, unless you deliberately intend to expose the service. Binding to all interfaces on an untrusted network publishes an unauthenticated service to everyone on it.
  • This server has no authentication and no encryption. Anyone who can reach the port can join and read every message. It is a teaching exercise, not a chat system.
  • Never send real credentials or sensitive content through it — traffic is plaintext and trivially captured with tcpdump.
  • Untrusted input from the network is untrusted. Bound the size of anything you read; an unbounded recv() loop into a growing buffer is a memory-exhaustion denial of service.
  • Thread-per-client does not scale and is itself a resource-exhaustion vector — a real service would use asyncio or a bounded pool.
  • To make it real you would need TLS via the [[SSL-Module|ssl]] module, authentication, per-client rate limiting, and message-size limits.

Cleanup

# Ctrl-C the server and each client, then:
deactivate
rm -rf ~/labs/socket-chat

Further Reading

Related

  • [[TCP-Server]] — Mini-Project version of this tool
  • [[Readme|Python for Security Professionals]] — course home