Wire protocol framing (and why it prevents attacks)
How to tell where one message ends and the next begins, and why getting it wrong opens you up to buffer overflows and DoS attacks.
Building a wire protocol for my key-value store, I hit this immediately: how the hell do you know where one message ends and the next begins?
TCP gives you a stream of bytes. Just bytes. One read() might give you half a message, two messages smooshed together, or one and a half, and without framing you can't parse anything.
# The problem (with actual suffering)
Client sends: "SET key value"
Server receives: "SET k" ... "ey val" ... "ue"
You don't know when you have the complete message... My first version just read until it had enough data that looked like a command. Worked great in my toy test, broke immediately when I tried pipelining requests.
# Text framing options
The delimiter approach ends each message with \n:
SET key value\n
GET key\n
Simple and human-readable, you can telnet in and type commands. But what if the value contains \n? Now you need escaping.
Or length-prefixed, where you say how long each piece is:
$3\r\nSET\r\n$5\r\nmykey\r\n$7\r\nmyvalue\r\n
This is how Redis (the RESP protocol) works. $7 means "7 bytes follow". No escaping and it handles binary data. Less readable, but way harder to break.
# Binary framing (the fast way)
The first 4 bytes are the message length, the rest is payload:
[0x00, 0x00, 0x00, 0x0A, ...10 bytes of payload...]
Read 4 bytes, parse the length, then read exactly that many bytes.
var len_buf: [4]u8 = undefined;
try socket.readAll(&len_buf);
const msg_len = std.mem.readInt(u32, &len_buf, .big); // Network byte order
var buffer = try allocator.alloc(u8, msg_len);
try socket.readAll(buffer);
Efficient, handles any size. There's also Type-Length-Value (TLV), where each field has a type byte, a length, then the data. More overhead but self-describing, which was a bit overkill for me.
# Why this matters for security (or: how I learned attackers are creative)
Without proper framing there are a few ways to get wrecked. A buffer overflow is the classic one, the server keeps reading into a fixed buffer until it overflows and you get a crash or worse. This is 90s-era exploitation but people still get it wrong.
Memory exhaustion is even easier. Just claim the message is 4GB:
[LENGTH: 0xFFFFFFFF, ...]
The server goes "okay cool, let me allocate 4GB", runs out of memory and crashes. Fun for the attacker, less fun for you at 2am.
And then Slowloris, which sends data 1 byte at a time and never completes the message:
Send: "S"
Wait 10 seconds
Send: "E"
Wait 10 seconds
...
The server keeps the connection open waiting for the rest, the connection pool runs out and nobody else can connect. Denial of service with minimal bandwidth.
The fix is length-prefixed framing with validation:
var len_buf: [4]u8 = undefined;
try socket.readAll(&len_buf);
const msg_len = std.mem.readInt(u32, &len_buf, .big);
// Validate BEFORE allocating (important!)
if (msg_len > MAX_MESSAGE_SIZE) return error.MessageTooLarge;
// Safe to allocate - we know the size
var buffer = try allocator.alloc(u8, msg_len);
// Read exactly msg_len bytes with timeout
try socket.readAllWithTimeout(buffer, TIMEOUT);
Now you can reject oversized messages, allocate exactly what you need, time out slow clients, and know when the message is complete. Revolutionary, I know.
# My actual implementation (with mistakes)
Initially? Just read until \n. Worked fine for simple tests where I typed the commands by hand. Then, staring at my code at midnight, I realized three things:
- What if a value contains
\n? (spoiler: it will) - What if someone sends a 10GB message? (my laptop has 16GB of RAM and I like using some of it)
- How do I handle partial reads? (TCP doesn't give a shit about your message boundaries)
So I switched to length-prefixed:
// Read 4-byte length header
var len_buf: [4]u8 = undefined;
try socket.readAll(&len_buf);
const msg_len = std.mem.readInt(u32, &len_buf, .big);
// Validate BEFORE allocating
if (msg_len > MAX_MSG_SIZE) return error.MessageTooLarge;
// Now safe to allocate and read
var buffer = try allocator.alloc(u8, msg_len);
try socket.readAll(buffer);
Simple, safe, handles binary data. The kind of code I should've written first but didn't because I was too clever (read: lazy).
# Text vs binary (the eternal debate)
For my learning project I'm using a text-based protocol with length-prefixed framing. Redis does this (RESP with length prefixes), HTTP does this (Content-Length headers), both work fine. Binary protocols like Protocol Buffers are more compact and faster to parse, but for a toy KV store handling thousands of ops/sec, nowhere near millions, the difference is negligible. And I can telnet in to debug, which is worth the performance hit (that I'll never actually hit).
Whatever you pick, validate the message size before you allocate, and make sure you can tell when a message is complete. Binary data support only if you need it. Everything else is preference.
I first learned about framing (the hard way) while building hush, a P2P secret sharing tool where trusting arbitrary peers is... optimistic.