Step 201. Race Conditions & HTTP Request Smuggling — Attacks of the Instant and the Misalignment

Step 201. Race Conditions & HTTP Request Smuggling — Attacks of the Instant and the Misalignment

Level 3 — Practical CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 4 hours

Prerequisites: you’ve reproduced threads and race conditions in Step 78, and you know the structure of an HTTP request (headers and body) from the Step 131 stretch.

  • What you need: Python 3 (standard library only), two terminals, and your memory of race2.py from Step 78.
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. Today’s race-condition exercise happens 100% inside your own computer (127.0.0.1).

If the web attacks so far were battles over "what to send," today’s two attacks are battles over "when to send it" and "where to read the boundary." A race condition slips concurrent requests into the instant between a check and its use; HTTP request smuggling is an advanced attack that exploits the gap where a proxy and a web server read the end of a request differently.

Neither can be explained by a single payload — they’re vulnerabilities of structure. So today the concepts are the protagonists, and the hands-on work focuses on one local reproduction of a race condition. For smuggling, we pin down the principle with diagrams and leave the practice to PortSwigger’s official labs.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain the structure of TOCTOU (time-of-check to time-of-use) with a diagram
  • Reproduce a race condition locally by firing concurrent requests at a single-use coupon server
  • State the factors that raise a race attack’s success rate (request count, processing delay)
  • Explain the CL.TE / TE.CL forms of HTTP request smuggling as interpretation mismatches
  • State the defense principles for both attacks (atomic processing, unified interpretation)

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 standard library (http.server, threading, urllib) — nothing to install
Today’s code ThreadingHTTPServer (a thread per request), 20 threads firing concurrent requests, widening the gap with time.sleep
Concepts needed TOCTOU, atomic processing, Content-Length vs. Transfer-Encoding, proxy-server interpretation differences
Today’s deliverable A race-condition reproduction record + understanding of the smuggling structure diagram

2-1. TOCTOU — The Instant Between Check and Use

TOCTOU (time-of-check to time-of-use) is a vulnerability born from the fact that "the moment you check" and "the moment you use" are different. The code looks like this:

① Check: has this coupon been used yet? → No
   ← time passes here
② Use: mark the coupon as used

What happens if another request slips in between ① and ②? If both requests pass ① and then execute ②, a single-use coupon gets applied twice. The same skeleton as the vanishing counter additions in Step 78 — the problem of another execution flow slipping between a read and a write.

2-2. Recapping the Step 78 Measurements

We already saw this phenomenon in Step 78 (measured 2026-09-09):

  • race.py — ten threads each added 100,000 times, yet the result stayed at the correct 1000000. The collision window was too narrow for the collapse to show.
  • race2.py — once we widened the gap between read and write with time.sleep(0), the results collapsed to 20575, 20489, 20607 (measured 2026-09-09).

Two laws from that experiment apply verbatim today: a race condition’s signature is that "it barely reproduces," and widen the gap and it blows up. Today we build that gap inside an HTTP server.

2-3. Races on the Web — Why They’re Dangerous

A web server handles each request in a separate execution flow (thread or process). Every feature that splits check and use — "check balance → withdraw," "check coupon → redeem," "check stock → order" — is a candidate. The attacker sends the same request dozens of times at once, getting all of them through the check.

The defense is "make check and use inseparable" — bundle the two steps into one lump with atomic database operations (transactions, conditional UPDATEs) or locks.

2-4. HTTP Request Smuggling — Two Rulers

HTTP/1.1 has two ways to mark the end of a request: the Content-Length header (the body’s byte count) and Transfer-Encoding: chunked (transmission in chunks). By spec, when both arrive together, Transfer-Encoding wins — but not every server follows this rule the same way.

The problem is that a request usually passes through two gates: the proxy (front door) → the web server (the core). If the front door and the core use different rulers, the request boundary slips out of alignment.

[CL.TE form] the front door trusts Content-Length, the core trusts Transfer-Encoding
POST / HTTP/1.1
Content-Length: 13        ← front door: "the body is 13 bytes"
Transfer-Encoding: chunked ← core: "let's read it as chunked"

0                        ← the core decides the request ends here
                          ← the next request hidden after it sneaks into the core's queue
[TE.CL form] the front door trusts Transfer-Encoding, the core trusts Content-Length
The front door reads all the chunks and forwards them, but the core reads only
Content-Length bytes and misreads the remainder as "the start of the next request"

2-5. The Accidents Smuggling Opens

A request hidden at a misaligned boundary becomes, from the core’s perspective, "glued to the front of the next user’s request." With this you can intercept other users’ requests, poison caches, or bypass the front door’s access control. Since the attack point is boundary interpretation itself, not a payload, even defenses like a WAF fall when the front door and the core disagree.

The defense principle is simple: unify the two sides’ interpretation (use HTTP/2, reject ambiguous requests), or simply never accept requests whose headers conflict.


3. Follow Along

3-1. The Test Rig — A Single-Use Coupon Server

Create race_coupon.py: a standard-library-only server with a 0.2-second gap between check and use.

Input (race_coupon.py)

"""Race condition measurement: fire 20 concurrent requests at a single-use coupon server."""
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

state = {"coupon_used": False}   # the coupon must be usable exactly once
DELAY = 0.2                      # artificial gap between check → use


class CouponHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path.startswith("/use-coupon"):
            # 1) Check: has the coupon been used yet?
            if not state["coupon_used"]:
                time.sleep(DELAY)   # ← other requests pass the check in this gap
                # 2) Use
                state["coupon_used"] = True
                body = b"OK coupon applied"
            else:
                body = b"FAIL already used"
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, *args):
        pass


def attack(port, n=20):
    results = []
    threads = []

    def fire():
        with urllib.request.urlopen(f"http://127.0.0.1:{port}/use-coupon") as r:
            results.append(r.read().decode())

    for _ in range(n):
        t = threading.Thread(target=fire)
        threads.append(t)
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return results


if __name__ == "__main__":
    server = ThreadingHTTPServer(("127.0.0.1", 0), CouponHandler)
    port = server.server_address[1]
    threading.Thread(target=server.serve_forever, daemon=True).start()

    results = attack(port, n=20)
    ok = sum(1 for r in results if r.startswith("OK"))
    fail = sum(1 for r in results if r.startswith("FAIL"))
    print(f"Concurrent requests: 20")
    print(f"Coupon applied (OK): {ok}")
    print(f"Rejected (FAIL): {fail}")
    print(f"Conclusion: single-use coupon applied {ok} times" + (" — RACE CONDITION" if ok > 1 else ""))
    server.shutdown()

How to read it: ThreadingHTTPServer spawns a new thread per request. The if not state[...] inside do_GET is ① the check; the state[...] = True after it is ② the use. The time.sleep(0.2) between them is the TOCTOU window — in a real service, a DB lookup or an external API call creates this gap.

3-2. Twenty Concurrent Requests — The Race Fires

Run it. Here are the results of running the same command three times (measured 2026-09-09, Python 3.12.14):

python race_coupon.py
Concurrent requests: 20
Coupon applied (OK): 11
Rejected (FAIL): 9
Conclusion: single-use coupon applied 11 times — RACE CONDITION

(Measured 2026-09-09; all three runs gave OK 11 / FAIL 9.)

How to read it: a single-use coupon was applied eleven times. Twenty threads launched nearly simultaneously, and eleven of them passed ① the check inside the 0.2-second window. The server logic checked "only once," yet the outcome was eleven. The check code didn’t lie — the time between check and commit betrayed it.

Why: this is the miniature model of duplicate coupon redemption at a shopping mall, overdrawing a balance, double-booking a seat. In the field, Burp’s single-packet attack makes twenty requests truly arrive at the same instant.

3-3. The Control Experiment — No Gap, No Bang

Let’s confirm Step 78’s law. Same code, with only the delay set to 0.

import race_coupon, threading
from http.server import ThreadingHTTPServer

race_coupon.DELAY = 0.0   # remove the gap between check and use
server = ThreadingHTTPServer(("127.0.0.1", 0), race_coupon.CouponHandler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
results = race_coupon.attack(port, n=20)
ok = sum(1 for r in results if r.startswith("OK"))
fail = sum(1 for r in results if r.startswith("FAIL"))
print(f"No gap (DELAY=0): OK {ok} / FAIL {fail}")
No gap (DELAY=0): OK 1 / FAIL 19

(Measured 2026-09-09.)

How to read it: with zero gap, the coupon is applied exactly once. Check-and-commit ran faster than Python’s thread-switch interval, so no collision occurred. The lesson this control teaches is the same as Step 78’s — "it doesn’t reproduce" is not the same as "it’s safe." A vulnerability whose window is merely narrow passes testing and blows up in the field.

Why: from the attacker’s side you read it in reverse. The conditions that widen the gap — more requests, targeting hours when the server slows, picking features with heavy processing — are exactly the knobs that raise the success rate.

3-4. The Smuggling Diagram — Misalignment Made by Two Rulers

We skip the local reproduction (the PortSwigger labs are recommended for practice) and examine the structure precisely with a diagram. Here is the request flow of the CL.TE form.

One request chunk sent by the attacker:

POST / HTTP/1.1
Host: example.com
Content-Length: 13          ← the ruler the front door (proxy) sees: body ends at 13 bytes
Transfer-Encoding: chunked  ← the ruler the core (web server) sees: chunked rules

0                           ← core: "chunk terminator. Request over!"  (the front door counts this as part of the body)

GET /admin HTTP/1.1         ← core: this gets processed as "the start of the next request"
Host: example.com

How to read it: the front door reads per Content-Length and hands this whole thing to the core as "one harmless request." The core reads per the chunked rules, decides the request ended at 0, and processes the following GET /admin as a new request. The front door’s access control (/admin blocked) never inspects it, because by the front door’s accounting that request doesn’t exist.

TE.CL is this trust flipped — the front door reads everything as chunks and forwards it all, but the core reads only Content-Length bytes, and the leftover bytes glue onto the next request.

3-5. Looking Again Through the Defender’s Eyes

One line each for defending against the two attacks.

  • Race condition: bind check and use into an atomic operation. With a DB, one statement like UPDATE ... WHERE coupon_used = 0 combining condition and change. With a file, a lock. With Python, wrap ①② in threading.Lock().
  • Smuggling: unify the front door’s and the core’s interpretation (both HTTP/2, or both the same parser). Reject ambiguous requests where Content-Length and Transfer-Encoding coexist. Configuring the front door not to reuse connections to the core also acts as a buffer.

4. Missions & Exercises

Mission — Reproduce the Race, Explain the Smuggling

  1. Run the 3-1 server, reproduce a scene where the coupon is applied 2 or more times, and record the run results
  2. Vary DELAY from 0.2 → 0.05 → 0 and record in a table how the OK count changes
  3. Also record what changes when you raise the request count from 20 → 40
  4. Draw and explain CL.TE smuggling yourself as a two-line diagram: "what the front door saw / what the core saw"

Exercises

Exercise 1. What are the two moments that TOCTOU’s four words refer to, and what happens as the time between them grows?

Exercise 2. In 3-2, a single-use coupon was applied 11 times. Which part of the server code tried to guarantee "only once," and why did it fail?

Exercise 3. In the 3-3 control experiment, DELAY=0 gave OK 1. What’s wrong with the claim "our service showed no duplicate application in testing, so it’s safe"?

Exercise 4. In CL.TE smuggling, which header does the front door (proxy) trust and which does the core (web server) trust, and what does that mismatch produce?


5. Model Answers & Completion Criteria

Mission Model Answer

An example record table (the measured values from 2026-09-09 and your values may differ — this is probability, so that’s normal):

[Race condition reproduction record]
DELAY=0.2, 20 requests → OK 11, FAIL 9 (same across 3 repetitions)
DELAY=0,   20 requests → OK 1,  FAIL 19
→ Confirmed: the wider the gap and the more requests, the higher the success rate

The heart of the smuggling diagram is two lines: "the front door trusted Content-Length: 13 and saw the whole thing as one body / the core trusted Transfer-Encoding: chunked, cut at 0, and processed the rest as a new request."

How to verify: ① does the reproduction record show OK 2 or more? ② does the table reveal the correlation between DELAY changes and the OK count? ③ does the smuggling diagram state explicitly "who trusted which header"?

Exercise Answers

Answer 1. The time of check and the time of use. The longer the interval, the higher the chance another execution flow slips in, so the check result is already stale information by the time of use. In 3-2, OK 11 is the result of eleven flows slipping into the 0.2-second window.

Answer 2. The if not state["coupon_used"] check tried to guarantee "only once." But between the check and the state["coupon_used"] = True commit there is a time gap (expressed as time.sleep), and since each request runs on its own thread, multiple requests pass the check simultaneously. The check was honest — but not atomic.

Answer 3. In the test environment the collision window is narrow, so it merely happened not to blow up. In the field, a slow DB, many concurrent users, and network delays widen that window. The difference between Step 78’s race.py (a million, correct) and race2.py (collapse) is exactly this story — "doesn’t reproduce" is not "safe."

Answer 4. The front door trusts Content-Length; the core trusts Transfer-Encoding. As a result, the core decides the request ended at the chunk terminator (0) in the middle of the body, and processes the remaining bytes — which the front door saw as body — as a new request. A hidden request slipping into the core’s queue is smuggling.

Completion Criteria Checklist

  • [ ] I can explain the TOCTOU structure (check → gap → use) with a diagram
  • [ ] I reproduced a race condition by firing concurrent requests at the coupon server
  • [ ] I confirmed experimentally the relationship between gap size / request count and success rate
  • [ ] I can explain the difference between CL.TE and TE.CL as "who trusts what"
  • [ ] I can state the race defense (atomic processing) and the smuggling defense (unified interpretation)
  • [ ] I can explain with an example that "it doesn’t reproduce ≠ it’s safe"
  • [ ] Mission: I completed the reproduction record table and the smuggling diagram

6. Common Pitfalls & Fixes

Wall 1. The race won’t reproduce (only OK 1 comes out)

Symptom: you sent twenty requests but the coupon applied only once.
Cause: the gap is narrow, or the threads are being processed sequentially. Python switches threads only at very short intervals (same principle as Step 78’s Wall 5).
Fix: raise DELAY (0.2 → 0.5) and increase the request count (measured 2026-09-09: DELAY=0.2, 20 requests gave OK 11). Widening the gap in an experiment isn’t cheating — it’s a magnifying glass for seeing the structure.

Wall 2. Someone told me it’s not truly concurrent because of the Python GIL

Symptom: the doubt "doesn’t the GIL mean threads don’t run at the same time?"
Cause: it’s half true — CPU computation runs one thread at a time.
Fix: but during time.sleep and network waits the GIL is released and other threads run. Today’s race happens exactly during that "while waiting." Real servers are also mostly I/O waits, so the structure is the same (the OK 11 in the 2026-09-09 measurement is the evidence).

Wall 3. I changed the server code but nothing changed

Symptom: you changed DELAY but the results are the same.
Cause: an old process is still running instead of the edited file, or you’re looking at an import cache.
Fix: kill the server process completely and run it again. If you’re experimenting via module import, import again in a fresh Python process.

Wall 4. I want to reproduce smuggling locally but the structure won’t come together

Symptom: building a proxy-core structure at home is overwhelming.
Cause: that’s normal. A two-layer proxy structure is beyond the scope of an introductory lab.
Fix: for today, drawing the 3-4 diagram by hand is enough. Leave the practice to PortSwigger Web Security Academy’s request smuggling labs (free), and just note that the detection tool http-request-smuggler (a Burp extension) exists.

Wall 5. I don’t get what the 0 in chunked encoding is

Symptom: you can’t understand why the single line 0 in the diagram means "the end."
Cause: chunked transfer is a convention of writing each chunk’s size in hex before it, and size 0 is the promise of "no more chunks."
Fix: just remember that 0\r\n\r\n is the period at the end of a chunked message. The core sees that period, cuts the request, and starts reading the bytes after it as the next request.


7. Summary

Today’s Concepts

Concept One-line explanation
Race condition A vulnerability where the outcome changes with the timing of execution order
TOCTOU The difference between time-of-check and time-of-use — the attack window lies between
Atomic processing The defense of binding check and use into an inseparable lump
Content-Length A header that marks the end by the body’s byte count
Transfer-Encoding: chunked A transmission method sending chunks, each preceded by its size
HTTP request smuggling An attack exploiting the request-boundary interpretation difference between front door and core (CL.TE / TE.CL)

Today’s Code and Tools

Code/tool What it does
ThreadingHTTPServer An experimental web server spawning a thread per request
time.sleep(DELAY) A magnifying glass that artificially widens the TOCTOU window
N threads + urllib.request.urlopen Concurrent-request attack simulation
threading.Lock() A defense device binding the check-commit stretch
Burp single-packet attack A field technique making requests arrive at the same instant
http-request-smuggler A Burp extension detecting smuggling interpretation mismatches

An Instinct More Important Than Commands

Today’s two attacks share something. Both are not one-line code bugs but structural gaps — one a gap in time, the other a gap in interpretation. So discovery starts not from a "payload" but from questions: "does this feature split check and commit?" and "does this request pass through two gates?" And the final lesson from the control experiment: a bug that doesn’t blow up in testing is under no obligation to stay quiet in the field. An eye that knows the gap exists comes before timing.


Once every box is checked, Step 201 is complete. Click the checkbox in the sidebar to save your progress.