Skip to content
About
Main content

The length-prefix attack and the maximum frame size

Break it on purposeNeeds Python

Problem

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 has no way to resynchronise: if one length is wrong, every later frame is read from the wrong and is garbage until the connection is closed.

Write a small 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

After one length two bytes too long, how many of the following frames does the server read correctly?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    the-length-prefix-attack-and-the.py82 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

One malicious header
ServerOutcome
trusts the headerreserved 4,295 MB for one connection; thread still waiting after 2 s
MAX_FRAME = 1,048,576 Brejected a 4,294,967,295-byte frame at once
one wrong length mid-stream4 of 5 frames wrong or lost after it

Five frames, the second with a length 2 bytes too long

Five frames, the second with a length 2 bytes too long
SentRead by the server
alphaalpha
bravobravo
charliearliedeltaecho
delta(nothing)
echo(nothing)
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-length-prefix-attack-and-the.py
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']
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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.