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.
- 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.
After completing this lab you will be able to:
- Build a TCP server that accepts and serves multiple concurrent clients.
- Explain why
SO_REUSEADDRis needed and whatTIME_WAITmeans. - 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.
- [[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.
| Item | Detail |
|---|---|
| Python | 3.8+ |
| Modules | socket, threading, argparse (standard library) |
| Terminals | 3+ (one server, two or more clients) |
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- Bind and listen. Create a TCP server socket, set
SO_REUSEADDR, bind to127.0.0.1, and listen. - Accept one client. Accept a connection, echo whatever it sends, and close cleanly.
- Go multi-client. Spawn a thread per accepted connection so several clients can connect at once.
- Track clients safely. Keep the client list in a structure guarded by a
threading.Lock. - Broadcast. Relay each received message to every other connected client.
- Frame the messages. A stream socket does not preserve boundaries — two
send()calls can arrive as onerecv(). Add newline delimiting or a length prefix, and buffer partial reads. - Handle disconnects. Treat an empty
recv()as a closed connection and remove the client under the lock. - Shut down cleanly. Handle
KeyboardInterrupt, close every client socket, and join the threads.
# ---- 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()# 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
- One thread per client lets the server serve many connections at once.
daemon=Truemeans these threads die with the main process, so Ctrl-C actually exits. SO_REUSEADDRlets the server rebind the port immediately after restart instead of waiting out the kernel'sTIME_WAIT— essential during iterative development.- A
Lockaround the client list prevents two threads mutating it simultaneously (one broadcasting while another removes a dead socket), which would otherwise race and crash. recvreturningb""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.
# 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.
- Add a length-prefix framing header so messages longer than 1024 bytes and partial reads are handled correctly.
- Broadcast join/leave notices ("alice joined") to all clients.
- Add
/nick,/list, and/quitcommands parsed server-side. - Wrap the sockets in TLS with
ssl.wrap_socketand a self-signed cert. - Rewrite the server with
selectors(single-threaded, event-driven) and compare complexity.
| 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 |
- Bind to
127.0.0.1, never0.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
asyncioor 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.
# Ctrl-C the server and each client, then:
deactivate
rm -rf ~/labs/socket-chat- Python docs —
socket - Python docs — Socket Programming HOWTO
- Python docs —
socketserver - Python docs —
ssl - RFC 793 — Transmission Control Protocol
- [[TCP-Server]] — Mini-Project version of this tool
- [[Readme|Python for Security Professionals]] — course home