Skip to main content

Four Hours, Four Bytes: Hunting Down CVE-2026-18649

13 min read
gstreamer rtp dos vulnerability-research cve

I found a vulnerability in GStreamer’s H.264 RTP depayloader that lets anyone crash your IP camera viewer from across the internet, with no authentication and no clever tricks. Just a stream of ordinary looking packets that never ends the way it promises to.

It got assigned CVE-2026-18649. Finding it took an afternoon of reading source code and about four hours of fighting a proof of concept that refused to work, almost all of which came down to a four byte mistake in my own script. This is the whole story, including the parts where I was wrong.

INFO

The bug is fixed in GStreamer 1.28.6 (commit 65712529). The record lives at cve.org and the NVD. The full proof of concept is on GitHub.

What we are even talking about

GStreamer is the multimedia framework underneath a huge amount of Linux video software. If you have ever watched an RTSP camera feed, joined a WebRTC call on a Linux box, or run a media server, there is a good chance GStreamer moved the bytes.

RTP is the protocol that carries the actual audio and video. A single video frame is usually too big for one network packet, so H.264 has a way to chop one chunk of video across several RTP packets and reassemble them on the other side. That mechanism is a Fragmentation Unit type A, or FU-A. Each fragment has a tiny header with a start bit and an end bit. The sender sets the start bit on the first piece, sends the middle pieces, and sets the end bit on the last one. The receiver holds every piece in a buffer until it sees the end bit, then glues them back together.

Hold that last sentence in your head. It is the entire bug.

Picking a target

I did not go straight for RTP. I started by mapping the attack surface, and GStreamer has a lot of it: more than four hundred plugins. So I looked at the project’s own security advisory history to see where the bodies were buried. Across 2025 and 2026 there were over seventy advisories, and the RTP depayloaders kept showing up:

  • The QDM2 depayloader had a heap overflow (SA-2026-0008).
  • The CELT depayloader had an out of bounds read that was bad enough the maintainers just disabled the element (SA-2026-0044).
  • The SBC depayloader had an out of bounds read that could turn into a use after free (SA-2026-0051).

Three separate depayloaders, three separate memory bugs, in a bit over a year. That is a pattern, not a coincidence. These parsers all take untrusted bytes off the network and they clearly had not been hammered on much.

Then I found the thing that turned RTP from “interesting” into “this is where I am spending my weekend.” I opened the OSS-Fuzz build script that GStreamer uses for continuous fuzzing, and there it was:

-Dgood=disabled \

The entire gst-plugins-good package is switched off before fuzzing runs. Every RTP depayloader lives in gst-plugins-good. So all of that network facing parsing code, the code with the worst track record in the advisory history, had zero fuzz coverage. A pile of untrusted input parsers that the project’s own automated testing never touches. That was the target.

Reading the code

I opened gstrtph264depay.c and went looking for the FU-A handler. It sits in a big switch statement inside gst_rtp_h264_depay_process(), in the case for NAL type 28. The shape of it jumped out almost immediately.

Every fragment, the start one and every continuation, gets pushed into a GstAdapter, which is GStreamer’s queue for accumulating bytes:

gst_adapter_push (rtph264depay->adapter, outbuf);

And the adapter only gets drained in one place:

if (E)
  gst_rtp_h264_finish_fragmentation_unit (rtph264depay);

E is the end bit. And where does E come from? Right here:

E = (payload[1] & 0x40) == 0x40;

It is a single bit, pulled straight out of a byte the attacker controls. There was no size check on the adapter. No timeout. No maximum number of fragments. Nothing that said “this reassembly has gone on too long, give up.” Push, push, push, and only stop when a bit you own tells it to stop.

So in theory: send one start fragment, then send continuation fragments forever, and never set that bit. The adapter should grow until the process dies. Simple. I wrote a quick script and pointed it at a test pipeline, fully expecting to watch memory climb.

It did not climb. That is where the real work started.

Attempt 1: the adapter would not grow

My first script sent a start fragment with the start bit set every hundred packets or so, trying to imitate a real stream with lots of fragmented units. Memory stayed flat.

Back to the source. I had skimmed past a guard near the top of the start branch:

if (G_UNLIKELY (rtph264depay->current_fu_type != 0)) {
  gst_rtp_base_depayload_delayed (depayload);
  gst_rtp_h264_finish_fragmentation_unit (rtph264depay);
}

If a new start fragment shows up while one is already open, the code assumes the sender is buggy and flushes everything it has gathered so far. My “every hundredth packet is a start” idea was resetting the buffer over and over. The attack needs exactly one start fragment, ever, and then nothing but continuations. I fixed the script to do that.

Attempt 2: the adapter grew but memory did not

With one start and pure continuations, I turned on GStreamer’s adapter debug (GST_DEBUG=adapter:6) and finally saw the size climbing: 105 bytes, 205, 305, 405. It was working. Except ps showed the process resident memory sitting flat at about 12 MB and not moving.

This one took a while to accept. The adapter was clearly holding more data, but RSS said nothing was happening. The answer is that RSS measures resident physical pages, and it is a liar for this purpose. GStreamer’s default allocator rounds small allocations up for alignment, the adapter’s data is scattered across many small buffers, and the OS reclaims and remaps pages underneath you. RSS was fluctuating for reasons that had nothing to do with the leak.

The number that does not lie is VmData, the size of the process data segment, which you can read from /proc/<pid>/status. It grows monotonically with the heap. I rewrote my monitoring to watch VmData instead of RSS and never looked at RSS again.

Attempt 3: 1.4 million fragments, almost no growth

Now measuring the right thing, I let it rip: no pause between sends, over a million fragments fired as fast as the socket would take them. VmData barely twitched.

The culprit was the kernel, not GStreamer. On loopback, sending packets back to back with no delay overruns the UDP receive buffer. With net.core.rmem_max at a few megabytes and roughly 1400 bytes per packet, only a few thousand packets fit before the kernel silently drops the rest. I was firing a firehose and almost none of it reached the depayloader. Going faster was making things worse. It needed pacing.

Attempt 4: RSS bouncing 12, 19, 12

I briefly went back to watching RSS during a run with batched sends and a few milliseconds of pause, and saw it climb from 12 MB to 19 MB and then fall back to 12. That looked like the leak was being cleaned up somehow, which would have killed the whole theory.

It was the same RSS lesson from attempt 2, just wearing a different hat. Resident memory rises and falls with page reclamation. VmData over the same window was climbing steadily and never dropping. I made peace with the fact that RSS is simply the wrong instrument here and stopped letting it scare me.

Attempt 5: the breakthrough, “Type 0”

The buffer was growing now, but slower and less cleanly than the code made me expect. So I turned on the depayloader’s own debug output, GST_DEBUG=rtph264depay:6, and read what it actually thought it was receiving:

NRI 0, Type 28      <- first packet, FU-A, correct
S 1, E 0            <- start bit set, good
queueing 105 bytes  <- start fragment pushed to the adapter

NRI 0, Type 0       <- every packet after this one
NRI 0, Type 0       <- Type 0 is "undefined"
NRI 0, Type 0

There it was. My start fragment parsed correctly as type 28. Every single continuation parsed as type 0. The depayloader classifies type 0 as undefined and hits this:

case 0:
case 30:
case 31:
  /* undefined */
  goto undefined_type;

which lands on an error path that logs “Undefined packet type” and drops the packet. GStreamer was throwing away every continuation I sent. Worse, since a type 0 packet is a different type from the open FU (type 28), it was also triggering the flush that clears the adapter. My continuations were not being buffered. They were being discarded and cleaning up after themselves on the way out.

Why? The depayloader reads the NAL type like this:

nal_unit_type = payload[0] & 0x1f;

The low five bits of the first payload byte. And in my continuation packets, payload[0] was zero, which is why every one of them came out as type 0.

The reason payload[0] was zero was the mistake I had been carrying the whole time. My continuation fragments used a four byte RTP header, packed as struct.pack('!BBH', ...), when a real RTP header is twelve bytes: struct.pack('!BBHII', ...). I had left off the 32 bit timestamp and the 32 bit SSRC, eight bytes in total. My start packet happened to have the right header, which is why it parsed fine, but the continuations were short by eight bytes. The depayloader read eight bytes of what I thought was payload as the rest of the RTP header, and by the time it got to payload[0], it was pointing at my zero filled data instead of my FU indicator byte.

Four bytes of format string. BBH instead of BBHII. That one line cost me most of an afternoon.

Attempt 6: it works

I rebuilt the packets with the full twelve byte header and patched the sequence number into bytes 2 and 3 of each one with a bytearray so the continuity check upstream would always pass. This time the debug output showed Type 28 on every packet, the fragments were accepted, and VmData climbed in a clean straight line:

FragmentsVmData growth
5,000+8 MB
10,000+17 MB
15,000+26 MB
20,000+35 MB
30,000+53 MB

With the target capped at 256 MB of virtual memory (ulimit -v 262144), the process died at around 32,000 fragments. That is roughly twelve seconds of sending at half a millisecond per packet. No memory limit at all, and it just keeps eating until the machine does.

EXPLOIT

The working packet, in the end, was almost boring. A full 12-byte RTP header, the byte 28 for the FU-A indicator, the byte 0x01 for the FU header (start bit off, end bit off), a sequence number that ticks up by one each time, and 1400 bytes of zeros. Send one start packet like it, then send the continuation forever. The end bit is never set, so the reassembly never finishes.

Attempt 7: the 22 gigabyte red herring

One more embarrassing detour. I left a flood running overnight and came back to a log claiming it had sent “22 GB” with no crash, which briefly made me think I had the whole thing wrong.

I had not. The script was printing total bytes transmitted, not the target’s memory. And the process it was aimed at was a stale one from an earlier broken test, sitting on the end of a dead pipe. I killed everything, started fresh, and the clean numbers from attempt 6 held on every run after that. The lesson: measure the thing you actually care about, and make sure you are measuring the process you think you are.

The bug, stated plainly

The FU-A reassembly buffer had no upper bound. gst_adapter_push() in gstadapter.c just adds to a running size and appends the buffer:

size = gst_buffer_get_size (buf);
adapter->size += size;

It has no maximum, by design, because it is a generic building block and bounding it is the caller’s job. The depayloader was the caller, and it never set a bound. Every guard in the FU-A path exists to recover from packet loss or a broken sender: a missing start bit, a gap in sequence numbers, a new start mid stream. None of them was built for someone who sends a perfectly ordered, perfectly valid, endless stream on purpose. So the buffer grew until the process died.

The same code lived in rtph265depay, so it had the same bug.

The fix

The maintainers added a max-fragmentation-unit-size property to both depayloaders and a check in the FU-A path: before continuing an open reassembly, compare the accumulated size against the limit, and if it is over, warn, clear the adapter, and reset. The default cap is 32 MB. Setting the property to 0 means “auto”, which resolves to that same 32 MB, so there is no way to accidentally ask for unlimited. A stuck reassembly now costs 32 MB at most instead of all of memory.

What I took away from it

  • Trust the target’s own debug output over any memory metric. GST_DEBUG=rtph264depay:6 told me the exact truth (“Type 0”) while ps had me chasing ghosts for two attempts.
  • Know which number you are watching. RSS fluctuates with page reclamation and allocator rounding. VmData grows with the heap. For a slow leak, RSS will lie to you.
  • Verify your own packets before you doubt the target. Most of my “the bug does not work” time was actually “my PoC is malformed” time. A four byte error in a format string looked exactly like a nonexistent vulnerability.
  • Fuzz coverage gaps are a map. The single most useful thing I did was notice that gst-plugins-good was switched off before fuzzing. That one line in a build script pointed straight at the code most likely to still be broken.

None of this was exotic. It was reading the source, sending packets, watching the right counter, and being wrong six times before being right. The vulnerability had been sitting in plain view in a switch statement. It just needed someone to send the one stream the code was never written to expect.


Back to Blog