翻译简介:
正文:
在最近的一次内部评估中,我们遇到了一个问题。在对一台内部Windows主机进行低权限访问时,我们意识到该主机上的软件正在通过HTTPS与一个远程API终端进行通信。然而,远程终端正在使用客户的SSL证书强制认证。
通常情况下,通过从主机上导出本地客户端SSL证书并将其导入到Burp Suite或Postman中,上述情况就很容易解决。在Burp Suite中,当你想使用客户端SSL证书时,你必须手动加载证书和私钥到其中。这意味着(至少在Windows上),你需要导出客户端SSL证书。然而,这只有在你对证书及其私钥持有适当的权限并且允许导出的情况下才有可能。
不幸的是,对目标主机的仔细检查显示,客户的SSL证书(特别是其私钥)已被标记为不可导出。因此,我们必须首先在主机上实现权限升级,或者用Mimikatz入侵私钥,这样就可以绕过不可导出的标志。然而,在评估的背景下,我们想隐蔽地留下尽可能小的足迹。更不用说,客户表示,我们必须减少部署在主机上的工具。因此,我们开始寻求另一种解决方案。
在与同事的交谈中,我们认为一个认证的SOCKS代理可以作为一个变通的办法。可以在主机上安装一个SOCKS代理,使用客户证书(利用Windows Crypto API来使用客户证书)来建立与远程终端的HTTP/S连接,而不是导出客户证书。简单地说,SOCKS代理将:
- 监听客户端连接。
- 协商SOCKS以获得远程终端的IP地址和端口。
- 确定提交的是TLS还是明文通信。
- 如果是TLS,使用自签的证书与发起的客户端协商建立连接
- 使用SNI数据(或其他方式)与远程终端协商连接--加入额外的客户证书。
虽然这个概念看起来很简单,但问题是大多数现有的SOCKS代理并没有提供这个功能--如果有的话。因此,我们决定创建我们自己的SOCKS代理实现。由于目标主机上已经有了.NET框架,所以选择了C#来减少占用的空间。
这篇博文探讨了SOCKS4/4A/5是如何工作的,以及我们如何创建自定义的C#代理。首先,文章根据公开的RFC研究了SOCKS的内部工作原理,为讨论奠定了基础。然后伴随着简短的代码片断,解释C#实现是如何工作的。然后,我们将探讨如何在代理中加入客户端SSL证书和TLS处理逻辑。
对SOCKS协议的简要探索
SOCKS是由David Koblas首先开发并在1992年提出的一个互联网协议。它的目的是在客户端和服务器之间充当一个代理服务器,允许网络数据包的交换。在pentesting中,我们经常使用它来访问我们无法访问的客户端的资源--也就是说,使用一台被破坏的机器作为跳板。
在阅读公开的RFC时,人们注意到SOCKS代理可以被配置为支持不同的实现方式,称为:
SOCKS4
SOCKS4 是由Ying-Da Lee开发的,是一个广泛使用的SOCKS实现,基于David Koblas的工作。
就其核心而言,SOCKS4的数据交换是非常简单的。首先,一个客户端连接到SOCKS代理。然后,在客户端连接后,它发送一个SOCKS连接请求。这个请求遵循一个一致的格式:
- VER是SOCKS协议的版本号,对于SOCKS4的实施,它总是0x04。
- CMD是SOCKS命令代码,可以是0x01(CONNECT)或0x02(BIND)。
- DSTPORT是一个2字节的端口号(如80,445)。
- DSTIP是4字节的IP地址
- ID是用户ID字符串,以空值(null)结尾。
一旦SOCKS服务器(或者说,代理)收到请求,它需要检查该请求是否应该被批准。如果请求被批准,SOCKS服务器将建立一个与目标主机的指定端口的连接。
无论远程连接如何,SOCKS服务器都必须转发一个回复给始发客户端。这个回复同样遵循一个一致的格式:
- VN是回复代码的版本,应该是0x0。
- REP是具有以下值之一的结果代码::
90: 请求被批准
91: 请求被拒绝或失败
92: 请求被拒绝,因为SOCKS服务器不能连接到客户端的identd
93: 请求被拒绝,因为客户端程序和identd报告了不同的用户ID
注意:DSTPORT和DSTIP字段通常会被客户端忽略,而且大多数情况下会以空参数返回。
虽然不是很明显,但SOCKS交换将在每个客户端连接到上游终端时重新发生。简单地说,如果客户端发送了2个或更多的GET请求,SOCKS交换将为每个请求重复进行。
SOCKS4a
SOCKS4要求客户端在本地进行DNS解析。特别是,当收到最初的SOCKS连接请求时,它要求客户端提交一个有效的IP地址。当客户端必须提前到达一个不知道IP地址的远程终端时,这就出现了一个问题--即通过一个被攻击的工作站瞄准一个内部资源时。
SOCKS4a是由Ying-Da Lee引入的SOCKS4扩展,以解决上述问题。与SOCKS4不同,SOCKS4a允许客户提交一个目标域名而不是IP地址。
SOCKS4a连接请求的格式与SOCKS4(见上文)完全相同,只是有一个小的区别。对于SOCKS4a来说,如果客户端不能提前解析目标主机的域名,DSTIP的前三个字节将被设置为NULL,最后一个字节为非零值。在实践中,这意味着DSTIP通常会采取这样的格式。
0.0.0.x
当SOCKS4a被实现时,代理服务器负责1)检测域名和2)在与目标终端建立连接之前执行自己的DNS解析。
SOCKS5
虽然SOCKS4和4a有许多相似之处,但SOCKS5是一个完全不同的野兽。SOCKS5协议被定义在RFC 1928中。它与SOCKS4不兼容;但确实采用了某种类似的方法,同时提供了对认证、IPv6和UDP的支持。
当客户端第一次连接到SOCKS5代理时,它要经历一次初始握手。这个握手的格式如下:
- VER is the SOCKS protocol version number and will always be 0x05 for SOCKS5 implementations.
- NAUTH is an indicator of how many authentication methods are supported
- AUTH lists the authentication method:
收到初始握手请求后,SOCKS5代理必须选择其首选的身份验证机制,并将其报告给发起客户端。此回复采用以下格式:
- VER是SOCKS协议的版本号,对于SOCKS5的实现,它总是0x05。
- CAUTH是选择的认证机制(在上述例子中是0x00)。
注意:下一节假设未选择任何身份验证。如果代理选择用户名/密码身份验证,则会发生单独的请求和响应序列,如 RFC 1929中所定义.
完成初始握手并选择身份验证机制(即,无身份验证)后,客户端现在必须向代理提交远程终端详细信息。后续请求的格式与SOCKS4/4a交换机中观察到的格式几乎相同:
虽然数据包格式相同,但有一个主要区别。在SOCKS4/4a中,DSTADDR将是一个IP地址。在SOCKS5中,DSTADDR字段包含嵌套的数据结构:
该类型可以是以下三个可能的值之一:
- 0x01: IPv4地址
- 0x03: 域名
- 0x04: IPv6地址
同时,ADDR字段的长度将根据所选类型而有所不同:
- 4个字节为IPv4地址
- 1个字节的名称长度,然后是1-255个字节的域名
- 16字节为IPv6地址
收到数据包后,SOCKS5代理需要建立与指定远程终端的连接,然后向发起的客户端返回一个响应。这个回复与SOCKS4/4a不同:
响应包中的关键字段是STATUS字段。这个字段可以取九个可能的值之一:
- 0x00:请求被批准
- 0x01:一般失败
- 0x02: 规则集不允许连接
- 0x03: 网络无法到达
- 0x04: 主机无法到达
- 0x05: 目标主机拒绝连接
- 0x06: TTL已过期
- 0x07: 不支持的命令/协议错误
- 0x08: 不支持地址类型
BNDADDR和BNDPORT是非选择字段,必须包含代理连接的远程终端的详细信息。
一旦客户端收到回复(假设它返回0x00),代理服务器和远程终端的流量中继就会开始。
在C#中实现自定义SOCKS代理
在简单了解了SOCKS4/4a/5之后,我们现在准备探索开发的自定义C# SOCKS代理。
正如在文章开头提到的,我们选择了C#,因为目标主机已经有了.NET框架。作为一个起点,我们在互联网上寻找代码片段和C#代理实现的例子。
研究最终使我们找到了Daniel Duggan(通常被称为@rastamouse)的SOCKS4/4a实现,他在他的博客上写了部分内容,并在Twitter上发布了推文:
最初的SOCKS4/4a逻辑
我们自定义的C#实现从Daniel Duggan的实现中得到了很大的启发。事实上,代理的大部分基础工作都紧跟他在网上发布的几个代码片断。
我们的实现使用了一个SocksProxy类,它需要一个绑定地址(0.0.0.0)和端口(1080)。然后定义了一个Start()方法,它在指定的地址/端口上绑定了一个TCP监听器 (TcpListener)。
代理收到的每个连接都会调用一个处理函数(HandleClient),然后异步处理SOCKS交换。
处理函数首先从收到的客户端连接中获取原始的TCP网络流。使用异步读/写操作,读取收到的数据包的内容,以便获得具体的SOCKS版本(即0x04用于SOCKS4或0x05用于SOCKS5)。
如果收到一个SOCKS4/4a请求,就会调用一个辅助函数(FromBytes)。这个函数获取收到的SOCKS4请求包的原始字节,并对其进行解释,以获得发端客户端试图连接的目标主机和端口。
请注意,恢复的目标IP地址是如何被检查以验证是否收到了SOCKS4或SOCKS4a请求的。如果目标IP地址以 "0.0.0. "开头,这就意味着是一个SOCKS4a请求,然后代码逻辑会继续从请求包的末尾恢复主机名。反过来,该主机名被用于传统的IPv4 DNS查询。
在获得目标IP地址和端口后,代理逻辑试图与远程终端建立连接:
根据远程连接的成功或失败,代理必须转发一个回复给客户端。正如前面SOCKS4讨论中提到的,回复是90(0x5a)或91(0x5b)。
随着最初的SOCKS4请求/响应的处理,代理现在需要处理客户端和远程终端之间的数据传输,反之亦然。
在Daniel Duggan的代码片断中,没有显示确切的数据传输逻辑。当我们最初考虑数据传输时(并查看了在线资源),我们的理解是,可以简单地将客户端流中收到的数据(如果有数据的话)转发到目标流中--见上面的代码片段。同样,对于从目标终端收到的数据,反过来也可以。但是...
当我们开始测试这个逻辑时(即用SMB请求),我们注意到代理会挂起和超时。这似乎很奇怪,尤其是HTTP流量处理得非常好。
经过许多小时的Wireshark转储、调试和大量的打印语句(又称Console.WriteLine),我们发现了问题的症结所在。等待它.... RST数据包。
Daniel Duggan发布的代码片断假定与目标终端的连接总是成功的。现在,是的,在最初连接时,这将是真的。然而,许多协议(即SMB请求)可能会收到来自目标终端的RST/ACK响应。这可能有很多原因,比如客户端试图通过SMBv1通信,而远程终端只允许SMBv2。
由于代理代码只是期待一个数据响应,它不知道远程终端拒绝转发的数据包。这把我们带入了一个新的兔子洞,因为(起初)C#似乎没有办法:1)检测RST数据包;2)返回一个RST数据包给始发客户端。
在搜索了网络之后,我们终于在StackOverflow的一篇文章中看到了这个说明。
事实证明,检测RST数据包的方法是尝试向流写入。如果流被断开了(也就是收到了RST包),写操作就会失败并引发一个异常。
实现这个检查的代码相对简单,因为它与目标流上现有的写操作非常吻合:
在这个阶段,代理可以检测到RST数据包。很好,但我们如何将RST数据包转发到始发客户端?
排列另一个几个小时的时间,只是为了让我们意识到RST数据包是 "断开 "连接的同义词--咄!因此,代理应该断开客户端连接,而不是 "转发 "RST。因此,代理应该断开客户端的连接,而不是 "转发 "RST数据包。
在实践中,这意味着在数据处理循环(ProcessData)中必须有一个标志(或标记),这样当收到RST数据包时,客户端就可以断开连接了。幸运的是,C#中的CancellationTokenSource 在这方面做得非常好。
该令牌可以在数据处理循环和RST检查(DataAvailableDestination)之间共享。如果RST检查失败,令牌可以被取消(tokenSource.Cancel()),从而停止主循环并终止客户端的连接。相当整洁!
在这个阶段,我们有了一个完全可操作的SOCKS4/4a代理(主要是由于Daniel Duggan的代码片段而实现的)。
我们测试了本地机器和远程Windows虚拟机之间的代理实现,流量被完美地路由,甚至在通过代理使用CrackMapExec时的SMB流量。
我们对过渡到定制的TLS逻辑感到兴奋,于是与我们的同事再次聚在一起,围绕实施进展情况进行交流。谈话中我们很快发现了一个疏忽。在理想情况下,我们希望使用Burp Suite或Postman,通过被攻击的主机与远程终端建立连接。因此,我们在本地启动了Burp Suite并测试了代理。结果发现Burp Suite只支持SOCKS5代理--哦,不!
添加SOCKS5支持
由于大部分的代理逻辑已经开发完成,添加SOCKS5支持相对容易。最初引起一些问题的唯一方面是SOCKS5的认证握手。
在SOCKS4逻辑中,收到的第一个请求包含所有远程终端的细节。相比之下,SOCKS5使用两个独立的数据包(1个认证请求,1个连接请求)。由于没有完全意识到这一点,我们最初试图将认证请求解释为连接请求。不用说,这是个愚蠢的错误。
在第二次阅读RFC 1928之后,我们意识到了我们的错误,并正确地实现了auth/reply + connect/reply的序列:
为了快速开发,我们决定代理只支持 "无认证"。这意味着认证回复可以通过总是返回0x00的回复来实现静态化:
注意:我们最初想到的是支持用户名/密码认证。检测它作为客户端认证方法的代码逻辑在代码片断的顶部有注释。欢迎提出拉动请求!
对来自源客户的后续连接请求的解释也被简化。我们决定只支持IPv4、IPv6和DNS,而不是支持IPv4--主要是因为Burp Suite默认情况下是这样使用代理的:
注意:回过头来看,DNS支持很可能通过重用SOCKS4a的逻辑而轻松添加。也可以添加IPv6支持,但需要改变主代理逻辑中的TcpClient调用。
在处理了连接请求后,最后一项工作是向发起的客户端返回回复。根据SOCKS5的RFC,我们需要返回一个0x00(请求被批准)或0x05(连接被拒绝)的状态,以及BNDADDR和BNDPORT字段:
注意:我们的理解可能有误,但我们理解BNDADDR和BNDPORT字段包含上游终端的详细信息。
在这个阶段,我们有一个完全可操作的SOCKS4/4a和SOCKS5代理。回到Burp套件的支持,我们将Burp配置为使用SOCKS5代理(在一个Windows虚拟机上运行),并提交了一个网络请求:
curl http://httpbin.org/ --proxy 127.0.0.1:8080
Eureka! 流量通过了SOCKS5代理,Burp套件显示了HTTP流量,符合预期。
添加自定义TLS逻辑
虽然我们现在有了一个工作的SOCKS代理,但它还没有满足我们的最终要求。特别是,我们想要一个 "认证 "的SOCKS代理,可以使用目标主机的本地Windows证书存储中的指定客户证书来建立TLS/SSL连接。
目前的SOCKS实现只是在发起客户端和上游终端之间传递TCP流量。为了满足我们的要求,SOCKS代理逻辑必须进行调整,使TLS/SSL连接在代理上终止,客户证书被添加,然后继续转发。
为了让TLS/SSL连接在代理上终止,代理必须向始发客户端提供一个服务器证书。为了实现这一点,我们首先在一个测试的Windows虚拟机上创建并安装了一个自签名的证书,如下所示:
Makecert -r -pe -n "CN=MySslSocketCertificate" -b 01/01/2015 -e 01/01/2025 -sk exchange -ss my
MakeCert工具是构成Windows SDK安装一部分的不推荐使用的实用程序,它允许您创建由指定密钥签名的X.509 证书,由指定的密钥签名,将提供的CN与密钥对的公共部分绑定。
通过上面的调用,我们要指定以下内容:
- 我们需要一个自签名证书(-r),
- 使私钥可导出(-p),
- 给它一个CN(-n),
- 给它一个开始和结束有效日期(-b/-e)
- 主体的密钥容器的位置,它持有私钥(-sk)
- 主体的证书存储空间的名称,生成的证书将存储在这里(-ss)。
在较新的Windows安装中,微软建议使用New-SelfSignedCertificate PowerShell Cmdlet而不是MakeCert:
New-SelfSignedCertificate -Type Custom -Subject "CN=MySslSocketCertificate" -KeyAlgorithm RSA -KeyLength 2048 -CertStoreLocation "Cert:\CurrentUser\My"
为了在C#逻辑中使用服务器证书,我们编写了一个小的辅助函数,从Windows证书库中检索证书。:
注意:我们根据对生成的证书的通用名称(CN)进行查询来检索服务器证书。
接下来,我们更新代理,以便能够检测到TLS/SSL连接。我们进行了一些迭代,但最终决定只检查目标端口是否为TCP/443:
注意:这是一个快速破解,并不理想,因为TLS流量会在许多其他端口上观察到。理想情况下,可以通过检查接收到的数据流的前几个字节来检测TLS连接——然而,我们这样做有问题;因为流在C#中默认不可播放。简单地说,当您从流中读取时,数据会丢失。
注2:网上有一些peakable 流解决方案,很可能可以解决上述问题。(article 1, article 2).。和以前一样,欢迎大家提出拉取请求!
假设收到一个TLS/SSL连接,数据处理逻辑需要对TCP流进行 "升级"。在C#中,这涉及到将收到的TCP流包裹在一个SSLStream对象中,并将代理认证给发起的客户端:
重要的是要注意这里与自签名证书的联系。当代理向客户进行 "认证 "时,它提出了自签名证书。反过来,客户可以决定接受或拒绝它。
到目前为止,很好。然而,上面的逻辑变化只是在客户端和代理之间创建了SslStream。该逻辑仍然必须处理上游连接--代理到远程终端。
为了满足我们的要求,必须在上游连接中添加一个客户端SSL证书。为了达到这个目的,我们首先写了一个辅助函数,可以从用户的Windows证书存储中获取一个证书:
注意:这个辅助函数与服务器证书检索几乎相同。例外的是,该函数需要一个名称参数,在CN比较时使用。
侧边栏:私钥可导出标志
你可能想知道为什么上面的代码片段可以访问客户证书,即使可导出标志可能被设置。
将证书的私钥标记为可导出,用户就有权限导出密钥(即导出到PFX文件中)。然而,读取和导出密钥的能力是两码事。
使用私钥进行认证、签名或解密与可导出性没有任何关系。相反,Windows 公开了 Crypto API,人们可以调用这些 API 来使用私钥进行各种操作,这些操作只受制于调用的用户是否有权限使用该密钥。这与出口无关。
随着证书的加载,现在要建立一个与远程终端的连接。一旦建立,TCP流再次被包裹在一个SslStream对象中。然而,如果代理被配置为使用客户证书(如我们的例子),那么证书将在SSL交换过程中加入:
这意味着每当代理收到一个TLS连接时,指定的客户证书会被添加到SslStream中,然后数据处理就会恢复。基本上,它复制了Burp Suite和Postman内置的客户证书逻辑。
唯一需要改变的是关于数据的读写逻辑。对于非TLS连接,C#使用TcpStream进行读和写。通过调用.GetStream()函数可以获得对该流的访问。
相比之下,代理逻辑必须被更新以处理SslStream。SslStream在C#中没有一个.GetStream()函数。相反,你必须直接在对象上调用读/写操作:
通过这些更改,代理可以正常传递非TLS连接,而TLS流量现在通过添加的客户端SSL证书进行了修改。
添加了客户端证书处理的C#SOCKS代理的源代码;可以在这里找到 https://github.com/sensepost/sockstlsproxy
概念的最终证明
现在,我们确信您想知道代理是否真的有效?
测试API终端
出于演示目的,我们将使用BadSSL客户端证书终端:
https://client.badssl.com/
在不使用客户端SSL证书浏览终端时,将显示以下错误消息:
被攻击Windows主机
为了模拟评估期间遇到的受害者主机,我们预先创建了一个Windows 10虚拟机。在VM上,我们下载BadSSL客户端证书(.p12):
https://badssl.com/download/
客户端证书已安装到用户证书存储中,同时确保未选中“可导出”标志:
通过查看用户的个人证书来确认安装:
我们可以通过尝试导出证书来确认私钥被标记为不可导出:
使用自定义的C#代理
现在我们把重点转移到攻击场景上。让我们想象一下,我们入侵了受害者主机,现在注意到机器上的客户证书。我们打算与BadSSL终端进行通信,但我们不能导出证书和它的私钥
我们首先创建我们自己的自签名证书,并将其安装在受害者的机器上:
New-SelfSignedCertificate -Type Custom -Subject "CN=MySslSocketCertificate" -KeyAlgorithm RSA -KeyLength 2048 -CertStoreLocation "Cert:\CurrentUser\My"
[注意:上述证书将促进C#代理和受害者主机之间的TLS\SSL连接。]
接下来,我们将C#代理转移到受害者主机并执行它,指定我们要利用客户证书(true)和证书的通用名称(BadSSL客户证书)。
注意我们如何在最后一个参数中指定客户证书的CN。这指示代理使用该证书进行TLS/SSL连接。
随着代理的运行,我们将假设网络流量可以以某种方式传递到TCP/1080的受害者主机(端口可以在代理调用中配置)。例如,也许我们已经提前设置了一个SSH端口转发:
sh -R1080:localhost:1080 -p 11121 jumpbox
注意:你如何访问代理端口对我们的演示并不关键。我们在剩下的部分中选择直接连接到桥接的VirtualBox网络上的端口。
在本地Burp Suite实例中,我们有一个HTTP请求,希望转发到BadSSL终端。如果没有客户端证书,终端将返回HTTP 400响应(正如前面所看到的):
为了评估C#代理,我们配置Burp Suite,使其在受害者主机上运行时使用它:
现在,关键时刻......请求是否能成功允许访问上游终端?我们在Burp中点击发送请求,注意到运行中的代理上有一些互动:
回到Burp Suite,我们收到了来自上游终端的有效响应。Eureka!
总结
这篇博文研究了SOCKS4/4a/5协议的基本原理,并分享了一个自定义的C# SOCKS代理实现。
这篇博文的主要内容是我们有一个自定义的SOCKS代理,据此可以使用受害者主机上本地安装的客户SSL证书来建立远程TLS连接。也就是说,这一切都不需要导出客户端SSL证书
我们相信并希望这个代理在未来的类似情况下能为你提供良好的服务。在处理红队行动时,拥有一个C# SOCKS代理本身可能被证明是有用的,因为它可能提供灵活性,而不是使用Metasploit或Cobalt Strike提供的内置代理。
参考资料
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课