从入门 .NET 到分析金蝶反序列化漏洞学习笔记
2023-7-24 15:11:57 Author: govuln.com(查看原文) 阅读量:40 收藏

作者: [email protected]知道创宇404实验室
日期:2023年7月10日

1.前言

由于金蝶云星空能够使用 format 参数指定数据格式为二进制,攻击者可以通过发送由 BinaryFormatter 恶意序列化后的数据让服务端进行危险的 BinaryFormatter 反序列化操作。反序列化过程中没有对数据进行签名或校验,导致攻击者可以在未授权状态下进行服务器远程代码执行。

刚接触 .NET 不久,正巧遇上了金蝶反序列化漏洞,本篇文章将从入门学习如何调试——分析金蝶反序列化漏洞。

2.影响范围

金蝶云星空 < 6.2.1012.4
7.0.352.16 < 金蝶云星空 <7.7.0.202111
8.0.0.202205 < 金蝶云星空 < 8.1.0.20221110

3.环境准备

3.1 金蝶云星空

https://www.heshuyun.com/265.html

本文选择漏洞版本 7.6,安装就不用说,下载地址里面都有。

安装完成后访问能打开就行,如图 1 所示:

image-20230630181758751

图1 安装完成成功访问

3.2 dnSpy

dnSpy 是一个调试器和 .NET 程序集编辑器。即使没有任何可用的源代码,你也可以使用它来编辑和调试程序集。

https://github.com/dnSpy/dnSpy

3.3 Process Hacker

Process Hacker是一款免费、强大的多用途工具,可帮助你监控系统资源、调试软件和检测恶意软件。

https://processhacker.sourceforge.io/

4.调试准备

已知漏洞的路径如下,现在需要通过该URL定位找到对应的代码位置。

http://192.168.87.133/K3Cloud/Kingdee.BOS.ServiceFacade.ServicesStub.DevReportService.GetBusinessObjectData.common.kdsvc

这是一个 .NET 程序,所以直接打开 IIS 管理器,右击 K3Cloud—— 浏览,找到源码的位置,如图 2 所示。

image-20230630182012849

图2 IIS管理器

在 WebSite 目录下找到并打开 Web.config,如图3所示:

image-20230630182140473

图3 WebSite目录

在 Web.config 的 handlers 中可以看到,其中定义了让路径为 kdsvc 结尾的请求去使用 Kingdee.BOS.ServiceFacade.KDServiceFx.KDServiceHandler 类进行处理,所以接下来寻找一下这个类的代码所在位置,如图 4 所示。

image-20230630182434898

图4 Web.config文件

这里根据漏洞的 URL 推测,涉及的dll大概是 Kingdee.BOS* 这样的文件。从 WebSite\bin 目录下复制出 dll 文件,载入到 dnsPy 中,然后搜索:Kingdee.BOS.ServiceFacade.KDServiceFx.KDServiceHandle,如图 5,定位到代码具体位置:

image-20230703110909213

图5 dnsPy搜索

根据搜索已经知晓了是哪一个 dll 文件处理了(Kingdee.BOS.ServiceFacade.KDServiceFx.dll),接下来使用 Process Hacker 定位到该 dll 被调用时所在的位置,然后右击 Open file location。

这一步有一个小坑,要先访问一遍漏洞路径,不然 Process Hacker 只能搜索出一个,并且这一个不能正确的进行调试。搜索出两个则选择包含 k3cloud 路径的那一个,如图 6。

image-20230703134428481

图6 Process Hacker

打开该 dll 的位置后,在该位置文件下新建一个同名 .ini 文件,如图 7 所示。

image-20230703112918116

图7 dll文件位置

文件内容如下,这里的作用是禁用编译优化 [1](之后打开 cmd 使用 iisreset 命令重新 IIS 服务器,否则禁用编译优化不生效!)。

[.NET Framework Debugging Control]
GenerateTrackingInfo=1
AllowOptimize=0

重启完 IIS 服务器后,进程 ID 会改变,所以再次使用 Process Hacker 搜索到相应的进程 ID(打开文件夹验证同级目录下是否有刚刚创建的 .ini 文件),如图 8 所示。

image-20230703150019494

图8 Process Hacker

接下来将这个目录下的 Kingdee.BOS.ServiceFacade.KDServiceFx.dll 文件加载到 dnsPy 中,调试——>附加到进程,选择刚刚得到的进程号 ID,如图 9 所示。

image-20230703150101645

图9 dnsPy附加到进程

接下来在 Kingdee.BOS.ServiceFacade.KDServiceFx的KDServiceHandler 中打上断点,稍等几秒看见断点变为实心红圈表示可以调试了,如图 10 所示。

image-20230703134935336

图10 dnsPy断点

5.漏洞分析

参考网上公开的 PoC[2],将其中 PAYLOAD 位置替换为 ysoserial 生成的内容,先简要跟一下这个漏洞:

POST /K3Cloud/Kingdee.BOS.ServiceFacade.ServicesStub.DevReportService.GetBusinessObjectData.common.kdsvc HTTP/1.1
Host: example.com
Content-Type: text/json

{
    "ap0":"PAYLOAD",
    "format":"3"
}

这里直接发上面的数据包进行调试。如果之前配置的 dnSpy 没错,就可以成功断到点了,如图 11 所示。

image-20230703141610937

图11 断点成功

这里可以手动跳过几个系统的处理逻辑,ctrl+ 鼠标点击进入 return new KDSVCHandler();——this.ExecuteRequest(webCtx, requestExtractor);——RequestExcuteRuntime.StartRequest(requestExtractor, ctx);——RequestExcuteRuntime.BeginRquest(requestExtractor, context);,此时来到 RequestExcuteRuntime 类。断点断到 69 行 的 string localFile = webCtx.Context.Server.MapPath(path);,如图 12 所示。

image-20230707112332009

图12 断点localFile

这里的 path 就为我们传递的 url ,然后通过 webCtx.Context.Server.MapPath(path); 生成一个 localFile,BuidServiceType 方法根据 localFile 包含common.kdsvc,继续跳转到其他逻辑,如图 13 所示。

image-20230707112717945

图13 判断包含common.kdsvc

通过处理赋值给 text 提取出类名和方法名等,再先通过缓存去查找类,没找到再调用 BuildServiceType 方法,如图 14 所示。

image-20230707114816658

图14 通过缓存查找

BuildServiceType 方法就是根据 strtype 定位到具体的程序集,然后再在程序集中寻找对应的类和方法等,如图 15 ,这里就不再细说。

image-20230707113125350

图15 寻找对应方法

继续跟进,最终到达了 ExcuteRequest 方法内部,这里通过遍历几个 Modules 来处理这个请求,如图 16 所示。

image-20230703163002326

图16 ExcuteRequest方法

差不多遍历到第 4 个 Modules ,进入到 OnProcess 方法中,如图 17 所示。

image-20230703162543352

图17 OnProcess方法

再继续进入到 Execute() 方法内部,可以看到 DeserializeParameters() 方法,如图 18 所示。

image-20230703163614593

图18 Execute方法

继续跟进,如图 19。image-20230703163746591

图19 DeserializeParameters方法

直到最后跟进到 BinaryFormatterProxy 的 Deserialize 方法中,这里可以看出代码使用了 BinaryFormatter 进行了 Deserialize操作[2],微软已经将 BinaryFormatter 的反序列化标注为不安全的[4]。

    public object Deserialize(string content, Type type)
    {
        BinaryFormatter binaryFormatter = new BinaryFormatter();
        object result;
        try
        {
            byte[] array = this.encoder.Decoding(content);
            if (this.Compressor != null)
            {
                array = this.Compressor.Uncompress(array);
            }
            using (MemoryStream memoryStream = new MemoryStream(array))
            {
                result = binaryFormatter.Deserialize(memoryStream);
            }
        }
        catch (FormatException)
        {
            throw new KDException("#####", "服务器返回内容不能被解码,请检查服务器地址是否正确。");
        }
        return result;

最后调用栈如图 20 所示。

image-20230703164415508

图20 调用栈

5.1 为什么要赋值format=3?

因为 Create 方法中的 requestExtractor = new JQueryRequestExtractor(request, isGet);,其内部会根据 request 传递的值来进行属性的赋值给 this.form,如图 21 所示。

image-20230706112142018

图21 Create方法

待后续调用到 this.Format 时,则会自动触发 Format 定义,如图 22 所示。

image-20230706124736057

图22 Format定义

如图 23,传递 format 参数为 3。

image-20230706125929500

图23 调用到this.ExtractForm

接下来根据这个属性值来进行匹配,为3正好能匹配到 Binary(当然这里 format 赋值为 Binary 也是可以的),如图 24 所示。

image-20230706124036595

图24 format的取值

5.2 为什么使用ap0作为参数?

一开始以为 ap0 是 GetBusinessObjectData 其中一个参数,后来发现其使用了如下代码逻辑:

    public string[] GetServiceParameters(string[] paras)
        {
            string[] array = new string[paras.Length];
            if (this.form.AllKeys.Contains("parameters"))
            {
                string parameters = this.form["parameters"];
                JSONArray jsonarray = new JSONArray(parameters);
                int num = Math.Min(jsonarray.Count, array.Length);
                for (int i = 0; i < num; i++)
                {
                    if (jsonarray[i] == null)
                    {
                        array[i] = string.Empty;
                    }
                    else
                    {
                        Type type = jsonarray[i].GetType();
                        if (type.IsValueType || type == typeof(string))
                        {
                            array[i] = jsonarray[i].ToString();
                        }
                        else
                        {
                            array[i] = jsonarray.GetJsonString(i);
                        }
                    }
                }
            }
            else
            {
                int num2 = 0;
                for (int j = 0; j < paras.Length; j++)
                {
                    array[j] = this.form[paras[j]];
                    if (array[j] == null)
                    {
                        array[j] = this.form["ap" + num2++];
                    }
                }
            }
            return array;
        }

这意味着 array 只会接收 "ap+ 数字"和 parameters 中的值,否则 array 为 null 。此外,parameters 的值需要符合 JSON 格式。例如:

{"ap0":"payload","parameters":["payload"]}

6.继续探索

分析到反序列化执行点发现,这里是先进行反序列化,之后 Invoke 再执行方法内部再进行参数类型判断。这就意味着不管调用哪个类或者方法,只要该类或者方法存在并且可以传入值(至少一个),那么都会调用到 this.DeserializeParameters(serializeProxy, svcType, paraValues) 代码里面,如图 25 所示。

image-20230704201545373

图25 反序列化顺序

此外还有个限制,svcType.MapToCLRType 的构造函数需要支持传递 context(KDServiceContext)类型或者继承该类型的参数。只有确保传递给 CreateInstance 方法的参数与所需的构造函数参数类型兼容,且符合构造函数的参数约束,才能成功创建对象,否则会在创建对象时报错,导致跳不到反序列化的步骤中去,如图 26 所示。

image-20230705101029804

图26 obj创建

综上所述,只要任意一个类型的构造函数支持传递 KDServiceContext 类型或者继承该类型的参数,并且其中的方法可以传入参数(至少一个),那么都可以进入反序列化的代码逻辑里去。

例举几个命名空间,他们下面的类的构造函数都支持传递 context 的类型:

Kingdee.BOS.ServiceFacade.ServicesStub
Kingdee.BOS.ServiceFacade.ServicesStub.Account
Kingdee.BOS.ServiceFacade.ServicesStub.Workflow
Kingdee.BOS.ServiceFacade.ServicesStub.AppDesigner
Kingdee.BOS.ServiceFacade.ServicesStub.BaseData
Kingdee.BOS.ServiceFacade.ServicesStub.BusinessData
Kingdee.BOS.ServiceFacade.ServicesStub.BusinessFlow
Kingdee.BOS.ServiceFacade.ServicesStub.Computing
Kingdee.BOS.ServiceFacade.ServicesStub.DataMigration
Kingdee.BOS.ServiceFacade.ServicesStub.DB
Kingdee.BOS.ServiceFacade.ServicesStub.DynamicForm
Kingdee.BOS.ServiceFacade.ServicesStub.Metadata
......

调试到这里,成功跳到了反序列化步骤中去了,本以为可以准备收尾文章了,但是进入后发现 SerializerProxy 的 Deserialize 方法依旧对参数类型进行了判断。

        public object Deserialize(string content, Type type)
        {
            if (string.IsNullOrEmpty(content))
            {
                if (type.IsValueType)
                {
                    return Activator.CreateInstance(type);
                }
                if (type.Equals(typeof(string)))
                {
                    return content;
                }
                return null;
            }
            else if (type == typeof(string))
            {
                if (this.proxy.RequireEncoding)
                {
                    byte[] array = this.proxy.Encoder.Decoding(content);
                    return this.encoding.GetString(array, 0, array.Length);
                }
                return content;
            }
            else
            {
                if (type.IsEnum)
                {
                    return Enum.Parse(type, content, true);
                }
                if (type == typeof(int))
                {
                    return int.Parse(content);
                }
                if (type == typeof(byte))
                {
                    return byte.Parse(content);
                }
                if (type == typeof(float))
                {
                    return float.Parse(content);
                }
                if (type == typeof(double))
                {
                    return double.Parse(content);
                }
                if (type == typeof(long))
                {
                    return long.Parse(content);
                }
                if (type == typeof(DateTime))
                {
                    return DateTime.Parse(content);
                }
                if (type == typeof(decimal))
                {
                    return decimal.Parse(content);
                }
                if (type == typeof(bool))
                {
                    return bool.Parse(content);
                }
                return this.proxy.Deserialize(content, type);
            }
        }

这里又出现了一层限制,因此正确的利用条件应该为:任意一个类型的构造函数支持传递 KDServiceContext 类型或者继承该类型的参数。该构造函数中的方法需要传入至少一个参数,并且参数不能为上述类型(string、int、byte、float...)。

在我刚刚提供的命名空间里面还是能找到不少符合条件的,例如图 27。

image-20230705151352095

图27 符合条件的方法

6.1 构造其他PoC

这里只举了一个较为经典的案例,除此之外还有很多。

Kingdee.BOS.ServiceFacade.ServicesStub.BusinessData.BusinessDataService.Audit 传递的第三个参数为 object[](这里满足不为int、string等类型),且 ProcInstService 的构造函数支持传递 KDServiceContext 类型,满足条件,如图 28 所示。

image-20230706143956165

图28 符合条件方法举例

之前提到的,传入 "ap+ 数字" 或者parameters,就可以给array赋值,这里Audit方法的第三个参数为object[],所以就需要使array[2]为payload,前两个值用ap0和ap1进行占位,ap2为PAYLOAD。

所以构造的PoC 大致为:

POST /K3Cloud/Kingdees.BOS.ServiceFacade.ServicesStub.BusinessData.BusinessDataService.Audit.common.kdsvc HTTP/1.1
Host: example.com
Content-Type: text/json

{
    "ap0":"1",
    "ap1":"1",
    "ap2":“PAYLOAD”,
    "format":"Binary"
}

图 29 进行验证(这里PAYLOAD使用的是ysoserial生成的ActivitySurrogateSelectorFromFile攻击链)。

image-20230706144131663

图29 漏洞验证

7.总结

本篇文章算是我从.NET入门到调试分析第一个漏洞,虽然一路上踩得坑还是不少,但是收获还是挺多的。本文主要讲了用 dnsPy 进行附加进程调试,至于VSstudio 调试以及一些编译优化入门可以看一下这篇文章:https://paper.seebug.org/1894/

8.参考链接

[1]https://learn.microsoft.com/zh-cn/dotnet/framework/debug-trace-profile/making-an-image-easier-to-debug

[2]https://mp.weixin.qq.com/s__biz=Mzg2ODYxMzY3OQ==&mid=2247498468&idx=2&sn=309198cc5bd645d5f7252288b5e629af

[3]https://paper.seebug.org/901/

[4]https://learn.microsoft.com/zh-cn/dotnet/standard/serialization/binaryformatter-security-guide


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2089/


文章来源: https://govuln.com/news/url/KrGv
如有侵权请联系:admin#unsafe.sh