The length-prefix attack and the maximum frame size
Break it on purposeNeeds Python
Problem
Length-prefixed framing sends each message as a length followed by that many bytes. It is simple and fast, and it trusts the sender completely: a server that allocates whatever the header claims can be made to reserve gigabytes with four bytes, then wait forever for data that never comes. And framing has no way to resynchronise: if one length is wrong, every later frame is read from the wrong offset and is garbage until the connection is closed.
Write a small TCP server that reads a 4-byte length and then allocates and reads that many bytes. Connect with a client that sends the header FF FF FF FF (about 4 GB) and then sends nothing. Watch the server's memory (ps or Activity Monitor) and see that the connection thread hangs. Then add a MAX_FRAME check before the allocation, repeat the test, and confirm that the server rejects the frame at once. Also send a header with a wrong length in the middle of a valid stream, and observe that all later frames are read as garbage until the connection is closed.
The question. What does a 4-byte header claiming 4 GB do to a server that trusts it, what does a MAX_FRAME check do, and what happens after one wrong length?
Predict
The trusting server reserves about 4 GB for one connection and its thread hangs; with MAX_FRAME it rejects the frame at once; after one wrong length, all later frames are read as garbage.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
the-length-prefix-attack-and-the.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The length-prefix attack, and the frame that never resyncs. A TCP server reads a 4-byte length and then that many bytes. A client sends FF FF FF FF (about 4 GB) and nothing else: the naive server reserves a buffer of that size for one connection and its thread hangs waiting. With a MAX_FRAME check it rejects the frame at once. Then one wrong length in the middle of a valid stream turns every later frame into garbage. """ import argparse, json, socket, struct, threading, time, tracemalloc p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--max-frame", type=int, default=1 << 20, help="MAX_FRAME for the checked server, in bytes.") p.add_argument("--wait", type=float, default=2.0, help="Seconds to watch the server before calling it hung.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def serve(check, results, frames_out=None): srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 0)); srv.listen(1) def handle(): conn, _ = srv.accept() f = conn.makefile("rb") try: while True: head = f.read(4) if len(head) < 4: break n = struct.unpack(">I", head)[0] if check and n > args.max_frame: results["rejected"] = n; conn.close(); return results["claimed"] = n tracemalloc.start() buf = bytearray(n) # the naive server trusts the header results["reserved"] = tracemalloc.get_traced_memory()[1]; tracemalloc.stop() got = f.readinto(memoryview(buf)) # ... and waits for n bytes that never come if frames_out is not None: frames_out.append(bytes(buf[:got])) del buf except Exception as e: results["error"] = type(e).__name__ threading.Thread(target=handle, daemon=True).start() return srv.getsockname()[1] rows = [] for check in (False, True): res = {} port = serve(check, res) c = socket.create_connection(("127.0.0.1", port)); c.sendall(b"\xff\xff\xff\xff") time.sleep(args.wait) label = f"MAX_FRAME = {args.max_frame:,} B" if check else "trusts the header" if check: rows.append({"server": label, "outcome": f"rejected a {res.get('rejected', 0):,}-byte frame at once", "reserved_mb": 0.0}) else: rows.append({"server": label, "outcome": f"reserved {res.get('reserved', 0) / 1e6:,.0f} MB for one connection; thread still waiting after {args.wait:g} s", "reserved_mb": res.get("reserved", 0) / 1e6}) print(f"{label:<26} {rows[-1]['outcome']}") c.close() # A valid stream with one wrong length in the middle frames = [b"alpha", b"bravo", b"charlie", b"delta", b"echo"] res, out = {}, [] port = serve(True, res, out) c = socket.create_connection(("127.0.0.1", port)) for i, fr in enumerate(frames): n = len(fr) + (2 if i == 1 else 0) # frame 2 claims two bytes more than it has c.sendall(struct.pack(">I", n) + fr) time.sleep(0.5); c.close(); time.sleep(0.3) print(f"sent {[f.decode() for f in frames]}; frame 2's length is off by 2") print(f"server read {[f.decode('latin-1') for f in out]}") garbage = sum(1 for a, b in zip(frames, out) if a != b) + (len(frames) - len(out)) rows.append({"server": "one wrong length mid-stream", "outcome": f"{garbage} of {len(frames)} frames wrong or lost after it", "reserved_mb": 0.0}) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"servers": rows, "frames": [{"sent": a.decode(), "read": out[i].decode('latin-1') if i < len(out) else "(nothing)"} for i, a in enumerate(frames)]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run the-length-prefix-attack-and-the.py uv run the-length-prefix-attack-and-the.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --max-frame1 << 20 | MAX_FRAME for the checked server, in bytes. |
| --wait2 | Seconds to watch the server before calling it hung. |
Our run
One malicious header
| Server | Outcome |
|---|---|
| trusts the header | reserved 4,295 MB for one connection; thread still waiting after 2 s |
| MAX_FRAME = 1,048,576 B | rejected a 4,294,967,295-byte frame at once |
| one wrong length mid-stream | 4 of 5 frames wrong or lost after it |
Five frames, the second with a length 2 bytes too long
| Sent | Read by the server |
|---|---|
| alpha | alpha |
| bravo | bravo |
| charlie | arlie delta echo |
| delta | (nothing) |
| echo | (nothing) |
trusts the header reserved 4,295 MB for one connection; thread still waiting after 2 s
MAX_FRAME = 1,048,576 B rejected a 4,294,967,295-byte frame at once
sent ['alpha', 'bravo', 'charlie', 'delta', 'echo']; frame 2's length is off by 2
server read ['alpha', 'bravo\x00\x00', 'arlie\x00\x00\x00\x05delta\x00\x00\x00\x04echo']What we found
The trusting server reserved 4,290 MB for a single connection from four bytes, and its thread was still waiting for the data, as expected. With a 1 MB MAX_FRAME it rejected the frame at once. One length two bytes too long made the server read the second frame with two bytes of the third, and every later frame from the wrong offset: everything after the error was garbage until the connection closed.
Read it
Every length field is input, and input is untrusted: check it against a maximum before allocating. Framing gives no way to recover from a wrong length, so a protocol either validates each frame (a checksum or magic number) and closes the connection on the first error, or it uses delimiters that can be found again after an error.