|
|
@@ -0,0 +1,238 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""
|
|
|
+Reproduces a zombie session bug in the TOC server.
|
|
|
+
|
|
|
+The bug: there is a race window between RegisterBOSSession (which creates a
|
|
|
+session and makes it visible to concurrent logins) and the OnSessionClose
|
|
|
+callback registration. If a second login evicts the session during this window,
|
|
|
+the default no-op OnSessionClose runs, RemoveSession is never called, the
|
|
|
+session slot's `removed` channel is never closed, and the user is permanently
|
|
|
+locked out until server restart.
|
|
|
+
|
|
|
+Prerequisites:
|
|
|
+ - Server running on 127.0.0.1:9898 (TOC) and 127.0.0.1:8080 (API)
|
|
|
+ - User "toctest1" exists (create via: curl -X POST
|
|
|
+ http://127.0.0.1:8080/user -d '{"screen_name":"toctest1","password":"testpass1"}')
|
|
|
+
|
|
|
+Usage:
|
|
|
+ python3 scripts/repro_zombie_session.py
|
|
|
+"""
|
|
|
+
|
|
|
+import json
|
|
|
+import socket
|
|
|
+import struct
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
+import threading
|
|
|
+import time
|
|
|
+
|
|
|
+HOST = "127.0.0.1"
|
|
|
+TOC_PORT = 9898
|
|
|
+API_PORT = 8080
|
|
|
+USER = "toctest1"
|
|
|
+PASS = "testpass1"
|
|
|
+ROAST = "Tic/Toc"
|
|
|
+
|
|
|
+
|
|
|
+def roast_password(password):
|
|
|
+ result = []
|
|
|
+ for i, ch in enumerate(password):
|
|
|
+ xored = ord(ch) ^ ord(ROAST[i % len(ROAST)])
|
|
|
+ result.append(f"{xored:02x}")
|
|
|
+ return "0x" + "".join(result)
|
|
|
+
|
|
|
+
|
|
|
+def send_flap(sock, frame_type, seq, payload):
|
|
|
+ data = payload.encode("ascii") if isinstance(payload, str) else payload
|
|
|
+ if frame_type == 2:
|
|
|
+ data += b"\x00"
|
|
|
+ header = struct.pack("!BBHH", 0x2A, frame_type, seq, len(data))
|
|
|
+ sock.sendall(header + data)
|
|
|
+ return seq + 1
|
|
|
+
|
|
|
+
|
|
|
+def recv_flap(sock, timeout=5):
|
|
|
+ sock.settimeout(timeout)
|
|
|
+ header = b""
|
|
|
+ while len(header) < 6:
|
|
|
+ chunk = sock.recv(6 - len(header))
|
|
|
+ if not chunk:
|
|
|
+ raise ConnectionError("connection closed")
|
|
|
+ header += chunk
|
|
|
+ _, frame_type, seq, length = struct.unpack("!BBHH", header)
|
|
|
+ payload = b""
|
|
|
+ while len(payload) < length:
|
|
|
+ chunk = sock.recv(length - len(payload))
|
|
|
+ if not chunk:
|
|
|
+ raise ConnectionError("connection closed")
|
|
|
+ payload += chunk
|
|
|
+ return frame_type, seq, payload
|
|
|
+
|
|
|
+
|
|
|
+def recv_all(sock, timeout=2):
|
|
|
+ msgs = []
|
|
|
+ while True:
|
|
|
+ try:
|
|
|
+ ft, _, payload = recv_flap(sock, timeout=timeout)
|
|
|
+ if ft == 2:
|
|
|
+ msgs.append(payload.decode("ascii", errors="replace").rstrip("\x00"))
|
|
|
+ except (socket.timeout, ConnectionError, OSError):
|
|
|
+ break
|
|
|
+ return msgs
|
|
|
+
|
|
|
+
|
|
|
+def toc_login(user, pw):
|
|
|
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
+ sock.connect((HOST, TOC_PORT))
|
|
|
+ sock.settimeout(5)
|
|
|
+ sock.sendall(b"FLAPON\r\n\r\n")
|
|
|
+ recv_flap(sock)
|
|
|
+ sn = user.lower().replace(" ", "").encode("ascii")
|
|
|
+ signon_payload = struct.pack("!IHH", 1, 1, len(sn)) + sn
|
|
|
+ seq = send_flap(sock, 1, 0, signon_payload)
|
|
|
+ roasted = roast_password(pw)
|
|
|
+ cmd = f'toc_signon login.oscar.aol.com 5190 {user} {roasted} english "TIC:Test"'
|
|
|
+ seq = send_flap(sock, 2, seq, cmd)
|
|
|
+ msgs = recv_all(sock, timeout=2)
|
|
|
+ return sock, seq, msgs
|
|
|
+
|
|
|
+
|
|
|
+def check_sessions():
|
|
|
+ r = subprocess.run(
|
|
|
+ ["curl", "-s", f"http://{HOST}:{API_PORT}/session"],
|
|
|
+ capture_output=True,
|
|
|
+ text=True,
|
|
|
+ )
|
|
|
+ return json.loads(r.stdout)
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ print("=" * 60)
|
|
|
+ print("Zombie Session Reproduction Script")
|
|
|
+ print("=" * 60)
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Verify server is reachable
|
|
|
+ try:
|
|
|
+ s = socket.create_connection((HOST, TOC_PORT), timeout=2)
|
|
|
+ s.close()
|
|
|
+ except OSError:
|
|
|
+ print(f"ERROR: Cannot connect to TOC server at {HOST}:{TOC_PORT}")
|
|
|
+ print("Start the server first: make run")
|
|
|
+ sys.exit(1)
|
|
|
+
|
|
|
+ # Verify user exists
|
|
|
+ sessions = check_sessions()
|
|
|
+ print(f"Server reachable. Active sessions: {sessions['count']}")
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Step 1: Establish a fully-initialized session
|
|
|
+ print("[Step 1] Login as toctest1, fully initialize session...")
|
|
|
+ sock_a, seq_a, msgs_a = toc_login(USER, PASS)
|
|
|
+ sign_on = any(m.startswith("SIGN_ON:") for m in msgs_a)
|
|
|
+ if not sign_on:
|
|
|
+ print(f" ERROR: Login failed. Response: {msgs_a}")
|
|
|
+ print(" Make sure user 'toctest1' exists with password 'testpass1'.")
|
|
|
+ sys.exit(1)
|
|
|
+ seq_a = send_flap(sock_a, 2, seq_a, "toc_add_buddy toctest2")
|
|
|
+ seq_a = send_flap(sock_a, 2, seq_a, "toc_init_done")
|
|
|
+ time.sleep(2)
|
|
|
+ recv_all(sock_a, timeout=1)
|
|
|
+
|
|
|
+ sessions = check_sessions()
|
|
|
+ print(f" Session A online. Active sessions: {sessions['count']}")
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Step 2: Abruptly close the socket (no SIGNOFF frame)
|
|
|
+ print("[Step 2] Abruptly closing socket (no SIGNOFF frame)...")
|
|
|
+ sock_a.close()
|
|
|
+ print(" Socket closed.")
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Step 3: Immediately launch two parallel logins
|
|
|
+ print("[Step 3] Launching two parallel logins for the same user...")
|
|
|
+ print(" (Login B and Login C race to evict the stale session)")
|
|
|
+ results = {}
|
|
|
+
|
|
|
+ def login_worker(name):
|
|
|
+ try:
|
|
|
+ sock, seq, msgs = toc_login(USER, PASS)
|
|
|
+ results[name] = ("ok", msgs)
|
|
|
+ sock.close()
|
|
|
+ except Exception as e:
|
|
|
+ results[name] = ("error", str(e))
|
|
|
+
|
|
|
+ t_b = threading.Thread(target=login_worker, args=("B",))
|
|
|
+ t_c = threading.Thread(target=login_worker, args=("C",))
|
|
|
+ t_b.start()
|
|
|
+ t_c.start()
|
|
|
+ t_b.join(timeout=15)
|
|
|
+ t_c.join(timeout=15)
|
|
|
+
|
|
|
+ for name in sorted(results):
|
|
|
+ status, data = results[name]
|
|
|
+ if status == "error":
|
|
|
+ print(f" Login {name}: ERROR - {data}")
|
|
|
+ else:
|
|
|
+ got_sign_on = any(m.startswith("SIGN_ON:") for m in data)
|
|
|
+ print(f" Login {name}: {'SIGN_ON' if got_sign_on else 'empty/failed'} - {[m[:50] for m in data]}")
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Step 4: Wait for any cleanup, then verify the zombie
|
|
|
+ print("[Step 4] Waiting 3 seconds for any pending cleanup...")
|
|
|
+ time.sleep(3)
|
|
|
+ sessions = check_sessions()
|
|
|
+ print(f" Active sessions: {sessions['count']}")
|
|
|
+ print()
|
|
|
+
|
|
|
+ # Step 5: Try a fresh login — if the bug was triggered, this will fail
|
|
|
+ print("[Step 5] Attempting a fresh login (should succeed if no zombie)...")
|
|
|
+ try:
|
|
|
+ sock, seq, msgs = toc_login(USER, PASS)
|
|
|
+ sign_on = any(m.startswith("SIGN_ON:") for m in msgs)
|
|
|
+ if sign_on:
|
|
|
+ print(f" Login succeeded: {[m[:50] for m in msgs]}")
|
|
|
+ print()
|
|
|
+ print(" RESULT: Bug NOT triggered this run (race was won by the")
|
|
|
+ print(" correct ordering). Re-run the script to try again.")
|
|
|
+ sock.close()
|
|
|
+ else:
|
|
|
+ print(f" Login returned empty/error: {msgs}")
|
|
|
+ print()
|
|
|
+ # Confirm it's permanent
|
|
|
+ print("[Step 6] Waiting 10 seconds and trying once more...")
|
|
|
+ time.sleep(10)
|
|
|
+ sock2, _, msgs2 = toc_login(USER, PASS)
|
|
|
+ sign_on2 = any(m.startswith("SIGN_ON:") for m in msgs2)
|
|
|
+ if sign_on2:
|
|
|
+ print(f" Second attempt succeeded (cleanup was slow but finished)")
|
|
|
+ sock2.close()
|
|
|
+ else:
|
|
|
+ print(f" Second attempt also failed: {msgs2}")
|
|
|
+ print()
|
|
|
+ print(" *** ZOMBIE SESSION CONFIRMED ***")
|
|
|
+ print()
|
|
|
+ print(" The user 'toctest1' is permanently locked out.")
|
|
|
+ print(" The /session endpoint shows 0 sessions, but AddSession")
|
|
|
+ print(" keeps finding a stale session slot whose `removed`")
|
|
|
+ print(" channel will never close.")
|
|
|
+ print()
|
|
|
+ print(" Root cause: Login C evicted Login B's session before B")
|
|
|
+ print(" registered its OnSessionClose callback. The default")
|
|
|
+ print(" no-op ran, RemoveSession was never called, and the")
|
|
|
+ print(" session slot is stuck forever.")
|
|
|
+ print()
|
|
|
+ print(" Only a server restart will fix this.")
|
|
|
+ sock2.close()
|
|
|
+ except Exception as e:
|
|
|
+ print(f" Login attempt failed with exception: {e}")
|
|
|
+ print()
|
|
|
+ print(" *** ZOMBIE SESSION CONFIRMED ***")
|
|
|
+
|
|
|
+ print()
|
|
|
+ print("Check the server log for [DEBUG-RACE] lines (if using the")
|
|
|
+ print("instrumented build) or 'context deadline exceeded' errors.")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|