A length you did not check¶
Level: 301 · deep dive
One line: A length-prefixed frame is two decisions — how many bytes the length field is, and whether to believe it — and the second is the one everyone forgets, because the length comes from the other end, which may be an attacker; a reader that trusts it copies as many bytes as the sender claims out of a buffer that holds far fewer, and the fix is one comparison the claimed length against what actually arrived and against a ceiling of your own.
Most binary protocols are length-prefixed: a field says how many bytes follow, then that many bytes follow. It is a good design. The failure is not in the design but in one missing line: the length was read off the wire, so it is not a fact, it is a claim by whoever is on the other end. Believe it and you have told memcpy to read sixty thousand bytes out of a buffer that holds four. This is the shape of Heartbleed and of a large fraction of remote-code-execution bugs, and it is defeated by comparisons that take longer to describe than to write.
The parser, and the one comparison¶
framing_length_c.c reads a [length][payload] frame, with the checks that make the copy provably safe, and shows what each of three frames does:
framing_length_c.c in full — pasted here by tools/run_examples.py from the file CI runs.
/* A length-prefixed frame is two decisions: how many bytes the length field
is, and -- the one everyone forgets -- whether to believe it. The length
comes from the other end, which may be an attacker. A reader that trusts it
copies as many bytes as the sender claims, out of a buffer that holds far
fewer. The fix is one comparison: the claimed length against what actually
arrived, and against a ceiling of your own. */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define MAX_PAYLOAD 1024
/* A frame is [len: 2 bytes big-endian][payload: len bytes]. Return the payload
length on success, or -1 with a reason, having copied nothing on failure. */
static long read_frame(const unsigned char *buf, size_t available,
unsigned char *out, size_t out_cap, const char **why)
{
if (available < 2) { *why = "not even a length field arrived"; return -1; }
uint16_t claimed = (uint16_t)(buf[0] << 8 | buf[1]);
if (claimed > out_cap) { *why = "claimed length exceeds our buffer"; return -1; }
if (2u + claimed > available) { *why = "claimed length runs past the bytes we have"; return -1; }
memcpy(out, buf + 2, claimed); /* now provably safe */
*why = NULL;
return claimed;
}
static void try_frame(const char *name, const unsigned char *buf, size_t available)
{
unsigned char out[MAX_PAYLOAD];
const char *why;
long n = read_frame(buf, available, out, sizeof out, &why);
if (n < 0)
printf("%-24s rejected: %s\n", name, why);
else
printf("%-24s accepted: %ld-byte payload \"%.*s\"\n", name, n, (int)n, out);
}
int main(void)
{
/* A good frame: length 5, then five bytes. */
unsigned char good[] = { 0x00, 0x05, 'h','e','l','l','o' };
try_frame("length 5, 5 present:", good, sizeof good);
/* The hostile one: the field claims 60000 bytes; three arrived. A trusting
reader would memcpy 60000 bytes out of this 5-byte array. */
unsigned char lie[] = { 0xea, 0x60, 'h','i' };
uint16_t claimed = (uint16_t)(lie[0] << 8 | lie[1]);
printf("hostile frame claims %u bytes; %zu arrived\n", claimed, sizeof lie - 2);
printf(" a trusting memcpy would read %u bytes from a %zu-byte buffer\n",
claimed, sizeof lie);
try_frame("claims 60000, 2 present:", lie, sizeof lie);
/* Truncated: a length field promising more than a header's worth, cut off
before its payload. */
unsigned char cut[] = { 0x00, 0x08, 'a','b','c' };
try_frame("length 8, 3 present:", cut, sizeof cut);
return 0;
}
Verified output of framing_length_c.c — regenerated by tools/run_examples.py, never hand-typed.
length 5, 5 present: accepted: 5-byte payload "hello"
hostile frame claims 60000 bytes; 2 arrived
a trusting memcpy would read 60000 bytes from a 4-byte buffer
claims 60000, 2 present: rejected: claimed length exceeds our buffer
length 8, 3 present: rejected: claimed length runs past the bytes we have
The good frame — a length of 5, then five bytes — is accepted, and its payload comes back. The hostile one is the lesson: its two-byte length field says ea 60, which is 60000, and only two bytes of payload actually arrived. A reader that trusted the field would call memcpy for 60000 bytes out of a four-byte buffer — the program prints the number it would copy without doing it, because doing it is undefined behaviour with no answer key. The two comparisons in read_frame stop it: the claimed length is checked against the output buffer's capacity, and against the bytes actually available, and on failure nothing is copied. The truncated frame — a length of 8 with three bytes present — is caught by the second comparison.
Two details carry the safety. The length is read into a uint16_t, so it cannot exceed 65535 no matter what the bytes say — a narrow type is a bound. And the check 2 + claimed > available is written with the addition on the side that cannot overflow, because 2 + claimed as unsigned is safe here while available - 2 would underflow if fewer than two bytes arrived. Getting the comparison itself right is part of the job.
Rust makes the check the only way through¶
The same frame in Rust. A length off the wire is a usize like any other, but the slice it indexes is bounds-checked, so the check you must remember to write in C is the only way to get a payload out at all. framing_length_rs.rs:
framing_length_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.
// The same frame in Rust. A length off the wire is a `usize` like any other,
// but the slice it indexes is bounds-checked: `get(..len)` returns None instead
// of reading past the buffer, so the check you must remember to write in C is
// the only way to get a payload out at all here.
fn read_frame(buf: &[u8], max_payload: usize) -> Result<&[u8], &'static str> {
if buf.len() < 2 {
return Err("not even a length field arrived");
}
let claimed = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if claimed > max_payload {
return Err("claimed length exceeds our ceiling");
}
// get() is the check: too-long a claim yields None, never an over-read.
buf.get(2..2 + claimed).ok_or("claimed length runs past the bytes we have")
}
fn main() {
let good = [0x00, 0x05, b'h', b'e', b'l', b'l', b'o'];
let lie = [0xea, 0x60, b'h', b'i'];
let cut = [0x00, 0x08, b'a', b'b', b'c'];
for (name, frame) in [("length 5, 5 present", &good[..]),
("claims 60000, 2 present", &lie[..]),
("length 8, 3 present", &cut[..])] {
match read_frame(frame, 1024) {
Ok(p) => println!("{name:24} accepted: {:?}", std::str::from_utf8(p).unwrap()),
Err(e) => println!("{name:24} rejected: {e}"),
}
}
}
Verified output of framing_length_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
length 5, 5 present accepted: "hello"
claims 60000, 2 present rejected: claimed length exceeds our ceiling
length 8, 3 present rejected: claimed length runs past the bytes we have
buf.get(2..2 + claimed) returns None when the range runs past the slice — it cannot over-read — so the hostile frame and the truncated one both come back as Err, and the accepted one yields its payload. The C program had to write the comparison and be trusted to write it correctly; the Rust one gets None from the standard library whether or not the author was thinking about attackers that day. That is the difference the C and C++ chapter ↗ is about, on the exact bug this page is about.
If you are coming from another language¶
Python. Slicing past the end of a bytes returns a shorter slice rather than raising, so an over-long length does not over-read — but it also does not error, so you still check the length against what you have before trusting the payload is complete. The struct module's unpack raises if the buffer is too small for the format, which is the check made for you.
ABAP. (Not machine-checked — CI cannot run ABAP.) Reading past the end of an xstring raises CX_SY_RANGE_OUT_OF_BOUNDS rather than reading arbitrary memory, so the C form of the exploit does not exist; the check that remains is the logical one — a claimed length that does not match the data is a malformed message, and treating it as valid is still a bug even when it cannot corrupt memory.
See also¶
- A record on the wire — the layer inside this one: what the payload bytes mean once you have safely got them
- The functions that do not check — the same missing bound one layer in, on a string rather than a frame
- A record has to say what it is, how long it is, and whether it arrived ↗ — the framing design in full, with the length field as one of its three parts
- The byte that means something to somebody else ↗ — the other way framing goes wrong, when a delimiter turns up inside the data
- The bugs Rust is a reply to ↗ — the memory-safety family this bug belongs to, run and measured