Forty-Six Defects in One Streaming Stack, Where the Bounds Checks Are Assertions

One C library collection parses MP4, Matroska, MPEG-TS, FLV, RTMP, RTSP, RTP, SDP, SIP, HLS and DASH. All eleven surfaces were tested and 46 defects came out. The validation that exists is written as assertions, and the build configuration anyone ships compiles them out.

Most media stacks are assembled rather than written. A product that plays video pulls in one library for containers, another for transport, another for signalling, and the seams between them are where nobody is quite responsible.

This article is about a library collection that removes those seams by covering all of it: the MP4 and Matroska and MPEG-TS and FLV containers, the RTMP and RTSP and SIP servers, the RTP and RTCP media path, SDP, and the HLS and DASH manifests. Eleven parse surfaces, one vendored dependency, one set of habits running through all of them.

All eleven were tested and uncovered 46 defects. One of the eleven came back clean. Five of the wire-facing surfaces accept bytes before anything authenticates, so for those the attacker is whoever can open a socket.

The bounds checks are assertions

Five surfaces carry the same defect, and it is the one that explains most of the others.

The validation on the error paths is written with assert. The project ships two build configurations: a default one, and a release one that defines NDEBUG. On the default build those assertions are live, so roughly fifty input-validation paths in the media path alone turn a malformed packet into a reachable abort. On the release build the same assertions compile to nothing, so the paths they were guarding carry no check at all.

Neither configuration is safe, and they fail in opposite directions. The default build gives an attacker a remote abort. The release build gives the attacker whatever the missing check would have caught, which in the container parsers is a heap write.

The default configuration also does not compile. Two separate source files break the build on a current toolchain, one of them globbed in by the Makefile, which means the configuration where the assertions are live is one most integrators never successfully built. The release configuration is what gets vendored.

A number in the file decides how much memory gets written

This is the largest group, and the MP4 container holds most of it.

The pattern repeats almost verbatim. A 32-bit count is read from a box, an array is allocated from it, and a loop then writes one element per unit of some other count read from somewhere else in the same file. The sample table is the clearest case: the allocation is sized from one box and the loop that fills it is driven by the sum of the per-entry counts in a second box, with nothing requiring the two to agree. Three separate boxes drive that same overflow, each through its own loop.

A related pair sizes an allocation from a count that was never compared against anything: not the size of the box that declared it, not the size of the enclosing structure, and not the size of the file. A four-byte field asks for as much memory as it likes.

The Matroska parser has its own version. A timestamp element writes an attacker-chosen number of bytes into a fixed eight-byte field, because the element's declared length is used as the copy length and the field's size is used nowhere.

The arithmetic that makes a buffer too small for its own contents

Ten defects come from an integer that wraps, and they are more dangerous than the plain overflows because the allocation succeeds and looks reasonable.

In the MP4 sample-size box, the count is incremented before being multiplied by the element size. A count at the top of the range wraps that product down to a single byte, the allocation succeeds, and the loop that follows writes the full untruncated count of elements into it. The fragment-run box has the identical shape through a different field.

The box header offers a cleaner version still. A declared size between two and seven underflows when the header length is subtracted from it, producing a box that claims to be larger than the file. Chained into the codec configuration path, that oversized box turns an eight-byte string copy into a write past a two-byte allocation.

The memory reader shipped with the library checks offset + length against the buffer size, in arithmetic that wraps, so a large enough length passes the check it was written to fail.

The media path has the most surprising one: a count plus seven wraps in 32 bits, which selects a 32-byte array on the stack rather than the heap allocation the large branch would have taken, and then fills it. The HLS byte-range tag and the DASH content-length handling underflow the same way, one of them through an embedded NUL.

A box in the wrong place, and a pointer nobody checks

Six defects need no arithmetic at all. They need a structure to appear somewhere the parser did not expect.

The MP4 dispatch table records which parent each box belongs under, and then dispatches codec boxes from any parent at all. The track pointer those handlers write through is NULL until a track header has been seen, so a codec box placed outside a track writes through it. A track with no header box, and a sample table with no sample-to-chunk box, each reach a write through the same NULL by a different route.

Matroska gets there twice: an element placed shallower than the table allows reaches a parse callback with a NULL parent, and a cue position that wraps restarts the parse one byte off and lands in the same place. The DASH manifest parser confuses its parent pointer once nesting runs past the element stack, and reaches a string length on a NULL when a segment template omits one attribute.

Loops with no budget

Six more defects are a small input that costs the receiver far more than it cost the sender.

The AMF0 object decoder recurses on nesting with no depth limit. That decoder is reached from the FLV container and, more usefully to an attacker, from the RTMP connect command, which arrives before anything authenticates. A Matroska numeric element loops once per byte of its declared size whether or not the read succeeded, so a 134-byte file costs about eight tenths of a second.

Two are unbounded in a way that never recovers. The RTCP member list has no cap and is scanned linearly per packet, so a session degrades until the process stops responding. An extended-report block walk advances by a length field taken from the wire, so a twelve-byte packet with that field at zero never advances and never terminates: full CPU, forever, with no crash and no memory growth to notice it by.

The RTMP chunk reader reserves the declared length plus a kilobyte for every chunk stream, uncapped, and turns four kilobytes on the wire into 131 megabytes of address space.

What a single missing check gives away

One defect deserves separating out because it produces no crash at all.

An audio unpacker reads an access-unit size from the packet, finds it overruns the packet, and forwards it anyway. The bytes past the end of the packet are heap memory, and they leave the process as audio. On a stock release build there is nothing visible: no abort, no sanitizer report, no log line. Whoever is listening to the stream receives the contents of the heap.

The surface that came back clean

The RTSP server produced nothing across a full run.

It is worth naming, because it sits on the same pre-authentication TCP port as the RTMP server that produced four, parses a text protocol of the kind that usually rewards this sort of testing, and is written by the same hands using the same idioms. It held.

Reproducing this with Penzzer

The container defects are reachable from a file, and the wire defects from the position the protocol already grants an attacker.

| Module | The shape it provokes | | --- | --- | | Header and extension bounds | A declared payload length that overruns the packet | | Report count and compound packet bounds | A block count and a block length that disagree | | Request and transport header abuse | Transport and session headers at their field boundaries | | Media description field abuse | A session description whose field lengths run past the buffer | | Content length wrap | A declared body length that wraps the arithmetic reading it | | Per message allocation growth | Memory reserved per message and kept after it is answered |

‍

For the containers, select MP4 or Matroska as a file format target. Both are parsed from a format grammar, so the cases come from the box and element structure itself: counts that disagree with the data they index, declared sizes near the boundaries where the header arithmetic wraps, and structures placed under parents that do not own them. For SIP, select the protocol-aware target in the server direction.

MPEG-TS, FLV, HLS and DASH have no file-format grammar, and RTMP has no protocol-aware target. The findings on those five surfaces are a product of the same defect classes above, and the way to reach them today is the attack modules on the transports that carry them.

What this article is, and is not

The 46 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's media coverage. They are what eleven surfaces in one library collection happened to get wrong.

The attack library spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. Across the 107 protocols with a protocol-aware target, and the binary formats with a grammar, a run also generates its test cases from the structure of the input rather than from a corpus.

The same limit runs in the other direction. What a test looks for is a property of the test, not a verdict on your product. One of the eleven surfaces here came back clean, and that is what a clean result means.

If you vendor a media library

A library has no attacker of its own. It inherits yours, and the direction every surface faces is decided by where you put it. The same MP4 parser is a low-risk component in a local player and a pre-authentication surface in an ingest server.

Three questions worth asking of anything you have vendored. Does the shipping build keep its input validation, or does NDEBUG remove it, and did anyone check which configuration the bounds live in? Where a count in the input sizes an allocation, is the loop that fills it driven by that same count, or by a different number from elsewhere in the same input? And does the dispatch table that records which structures may appear where actually enforce it, or only document it?

The eleven surfaces here were written carefully enough that the checks are nearly all present. They are present as assertions, in a build that defines NDEBUG.

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.