back to research
LARP-2026-021firmware7 min read

Record length underflow to a stack overwrite in a firmware parser

A firmware packet parser subtracted its four-byte header from a one-byte length before checking that the length was at least four. Lengths 0 to 3 wrap to copies of about 64 KiB into a stack buffer, and in an emulated run a marker carried in the packet landed in the saved return address.

We were reviewing an embedded network service and followed one record from the receive buffer into its packet parser. The parser reads a one-byte length, subtracts the size of a fixed header, widens the result and uses it as the length of a copy into a buffer on the stack. Nothing checks that the length is at least as large as the header before the subtraction.

Disclosure is still in progress, so the product isn’t named, and the C below is our reconstruction of the logic. The machine code further down is transcribed from the real binary.

Length arithmetic

ca reconstruction from the disassembly
uint8_t wire_len = record[1];
uint16_t copy_len = (uint16_t)(wire_len - 4);

if (copy_len == 0) {
    return PARSE_ERROR;
}

memmove(stack_buffer, record + 6, copy_len);

wire_len is a uint8_t, and C promotes both operands of wire_len - 4 to int before it subtracts. For a length of 0 to 3 the result is a small negative int, -4 to -1. Converting a negative value to an unsigned type is defined in C: it’s taken modulo 2^16, so the cast to uint16_t turns those four values into the largest lengths the type can hold.

wire_len wire_len - 4 as int copy_len
0 -4 0xfffc, 65,532
1 -3 0xfffd, 65,533
2 -2 0xfffe, 65,534
3 -1 0xffff, 65,535
4 0 0, rejected
255 251 251

The one check the parser has looks for copy_len == 0. That catches a record that is exactly a header, length 4, and nothing else. It tests the result of the subtraction, and by then the wrap has already happened, so every wrapped value is nonzero and goes through. Widening doesn’t help either: the wrap happened in the conversion, and a wider type only carries the wrapped value further.

The other end of the table matters too. The largest length a well-formed record can claim is 255, which copies 251 bytes.

Disassembly

The compiled code does exactly what the C says. Registers and instruction order are as they appear in the ARM disassembly; addresses and unrelated parser work are left out:

asmthe length path, transcribed from the disassembly
LDRB    R6, [R1, #1]       ; read the one-byte length
SUB     R6, R6, #4         ; subtract fixed header size
UXTH    R6, R6             ; keep low 16 bits: underflow survives
CMP     R6, #0
BNE     copy_path          ; wrapped values are nonzero
...
copy_path:
ADD     R0, SP, #12        ; stack destination
ADD     R1, R4, #6         ; input source
MOV     R2, R6             ; attacker-influenced length
BL      memmove

LDRB zero-extends the byte into a 32-bit register. SUB then works on all 32 bits, so a length of 0 becomes 0xfffffffc. UXTH is the uint16_t cast: it keeps the low half, 0xfffc. CMP and BNE are the zero check, and the wrapped value takes the branch to the copy. The three memmove arguments are a stack address at SP + 12, the record six bytes in, and the wrapped length.

Stack layout

In the frame we tested, the saved return address sits 272 bytes after the start of the copy destination. A valid length copies at most 251 bytes, so no well-formed record can reach it. A wrapped length copies 65,532 bytes or more and goes straight through.

wire_len 5 to 255: 1 to 251 bytes251
destination, then the rest of the framecaller frames, more stack
0272 saved return addressthe marker landed here
wire_len 0 to 3: 65,532 to 65,535 bytes, through the slot and on up the stack
fig. 1Offsets from the copy destination at SP + 12, in the tested frame. A length that passes as valid copies at most 251 bytes and stops 21 short of the saved return address. Only the four wrapped lengths reach it. The first 300 bytes are to scale.

Because the copy starts at record + 6, the byte that lands at destination offset 272 is the record’s byte 278. Whatever the sender puts there, and in the next three bytes, becomes the saved return address.

A copy that long does more than reach one slot. memmove reads as many bytes as it writes, so it also reads about 64 KiB starting six bytes into the record, and it writes the same amount up the stack, far past this frame and into the frames of the callers. Whether it finishes without faulting depends on what memory sits around both buffers. In the emulator it did finish, and the parser ran on to its return.

Emulation

Static analysis said the overwrite could reach the saved return address. To check it, we ran the extracted executable in a local user-mode emulator. The harness stubbed out the hardware-facing dependencies, and a debugger write got the service past a startup gate. Then we sent one crafted record to its local network listener, with a recognizable address in the four bytes that land on the saved return address.

At the memmove call the debugger showed the wrapped length in R2. After the copy, the saved return address slot held the marker. Continuing, the function returned through it and the process faulted at the marker’s address. So in this setup the packet controls the program counter, which is a stronger result than a crash.

Limitations

We haven’t run it on the physical device. The emulator had stubs where hardware should be, and a debugger write to get past the startup gate, so the device may behave differently before the parser is ever reached.

We haven’t established how the service is deployed: which interfaces the listener is exposed on, and whether anything authenticates a peer before its records reach this parser.

We haven’t built a code-reuse chain or run a command. Control of the saved program counter is the starting point for code execution, and we stop there: this is memory corruption with potential code execution, confirmed in emulation and nowhere else.

Review notes

The arithmetic is short, and it reads as reasonable when you know the length field is supposed to include a fixed header. The mistake sits between validation and arithmetic. The lower bound is never checked, and the subtraction runs before the value is held to the destination’s capacity.

The outer transport record had its own length checks. Those don’t make the inner length safe, because the inner parser takes a new length from inside the record and trusts it. Every nested length field has to be checked against the bytes actually available at its own layer.

Fix

Reject a record shorter than its header before subtracting anything, then check the result against both the bytes available and the destination’s capacity before copying:

cthe checks, in order
bool copy_record_data(uint8_t wire_len,
                      const uint8_t *source, size_t source_available,
                      uint8_t *dst, size_t dst_capacity)
{
    const size_t header_size = 4;

    if (wire_len < header_size)
        return false;

    size_t copy_len = (size_t)wire_len - header_size;
    if (copy_len > source_available || copy_len > dst_capacity)
        return false;

    memmove(dst, source, copy_len);
    return true;
}

The caller has to pass the number of bytes actually available at the source pointer, which in this parser starts at record + 6. A real fix should keep the exact field semantics and source offset; what matters is the lower-bound check before the subtraction and the upper-bound checks before the copy. Boundary tests should cover lengths below the header size, exactly the header size, one above it, and lengths around the destination’s limit.

Disclosure

We found this during a firmware review and reported it to the vendor. Coordinated disclosure is in progress, so product and vendor details, addresses and the original source are left out. They’ll be added once disclosure allows. The evidence so far is static analysis and one emulated run with a marker in the saved return address.

zaizr
back to research