导语:本文描述了我在Fuzzing Windows RDP 客户端和服务端方面所做的一些尝试和挑战,以及Crsahs分析。 Microsoft 的远程桌面协议 (RDP) 持续受到安全社区的关注。从 2019 年发现的几个可能危及数百万面向互联网的服务端的关键漏洞,到RDP 被攻击者用作主要的初始访问向量之一。
0x01 基本介绍
本文描述了我在Fuzzing Windows RDP 客户端和服务端方面所做的一些尝试和挑战,以及Crsahs分析。
Microsoft 的远程桌面协议 (RDP) 持续受到安全社区的关注。从 2019 年发现的几个可能危及数百万面向互联网的服务端的关键漏洞,到RDP 被攻击者用作主要的初始访问向量之一。
我对这个目标最初的兴趣是与 VM 相关的。因为连接到 Azure Windows 机器或 Hyper-V 虚拟机的默认方式是 RDP,所以我认为 RDP 是比较重要的目标。
我很快就发现了 Park、Jang、Kim 和 Lee在 BlackHat Europe 2019 上发表的关于 RDP Fuzzing的精彩分享。演讲者在短短几个小时内使用一个并不高效的 fuzzer 发现了几个漏洞,因此我决定在他们的工作基础上进行构建新的挖掘工具,将 fuzzing 能力扩展到其他方面,改进其性能并挖掘到我自己的 RDP 远程代码执行 (RCE)漏洞。
不幸的是,并不是所有的Fuzzing工具都能挖到严重的漏洞。我目前还没有挖到 RDP 的RCE漏洞,但我确实设法找到了一些bugs,并更好地了解了协议、其组件以及Fuzzing过程和工具。我开发的Fuzzing框架足够通用,有助于对其他目标进行Fuzzing。
在这篇文章中,我将分享我执行上述所有操作的过程。首先,我将概述 RDP 和开发的Fuzzing设置,然后将分享我临的挑战以及如何处理的方法,最后,我将回顾在此过程中发现的几个漏洞。
1.RDP 协议
远程桌面协议是用于远程访问 Windows 计算机的通用协议,Malwaretech 最近将其述为“协议的协议”。RDP 允许在每个连接中运行多个通道,并且每个通道都有不同的用途。这意味着每个通道都有自己的代码来处理其数据、自己的结构定义和数据流。这实质上意味着 RDP 中确实存在多种协议。
RDP 连接中的通道
RDP 通道可以是静态的,也可以是动态的,但对于我们的目的而言,两者之间的区别并不重要。如果你想了解更多关于RDP连接的内部运作原理,我建议你阅读下文。
2.RDP Fuzz的挑战
在“标准”Fuzzing场景中,有一个程序读取由Fuzzer控制的输入,输入可以是文件或任何类型的数据流。然后程序处理数据,同时Fuzzer监视生成的数据的代码覆盖率。基于该覆盖范围,Fuzzer对输入进行变异,再次将变异的输入发送到程序,然后重复该过程。
RDP Fuzzing是不同的,因为我必须始终有一个 RDP 连接处于活动状态。Fuzzer可以输入到程序中的数据需要作为协议数据单元 (PDU) 发送到特定通道(在Fuzzing期间也应处于活动状态)的顶部,并在开放连接中进行。如前所述,每个通道都有自己的协议,因此需要逐个通道进行Fuzzing。这给Fuzzing过程带来了以下挑战(这也可能适用于其他协议/网络相关的Fuzzing):
客户端-服务端架构——在传统的Fuzzing中,Fuzzer可以简单地运行目标应用程序并提供其输入。在客户端-服务端场景中,目标应用程序在连接的一侧运行,而输入从另一侧发送。在RDP的情况下,双方通常在不同的机器上。
状态性- RDP 是一种有状态协议,这意味着你必须在对测试用例进行Fuzzing时考虑连接的状态。这会严重影响Fuzzing的稳定性。
多输入Fuzzing——当对接受文件作为输入的目标进行Fuzzing(文件格式Fuzzing)时,Fuzzer对目标的所有输入都包含在单个文件中。相反,当你对协议进行Fuzzing时,你可能需要发送一些连续的消息才能到达有效的代码路径。
寻找目标代码——当你使用覆盖率引导的Fuzzing时,你通常需要向Fuzzer表明它需要在什么时候开始监控代码覆盖率(即,处理你输入的目标函数是什么?)。RDP 有许多负责其操作的组件,在某些情况下,找到正确的位置可能是一项艰巨的任务。
这四个挑战是我预计Fuzzing RDP的主要挑战。在文中,我将讨论如何克服这些挑战,以及在工作后期出现的其他挑战。
0x02 Fuzzing技术细节
在本节中,我将介绍技术细节,以及在工作过程中遇到的挑战。还将解释如何解决它们以实现有效的Fuzzing设置。我在 GitHub 上创建了一个工作存储库,其中包含我在此工作中编写的所有代码。
1.客户端-服务端架构
在这个工作中,我想要Fuzzing Windows 的 RDP 服务端及其 RDP 客户端。对 RDP 服务端进行Fuzzing的动机很明显:攻击者可以使用它来远程入侵 Windows 服务端并获得对其的访问权限。对 RDP 客户端进行Fuzzing的动机是不同的,如果攻击者已经获取了 RDP 服务端权限的场景,然后等待受害 RDP 客户端连接过来。一旦受害者连接上,攻击者也可以通过 RDP 客户端获取受害者的机器权限。当管理员连接到他们管理的服务端时可能会发生这种情况,由于 Hyper-V 利用 RDP 访问其虚拟机,甚至可以用作 VM 逃逸。
RDP 的 fuzzer 需要具有以下基本组件:
◼检测引擎跟踪代码覆盖并检测崩溃
◼变异引擎产生新输入
◼输入馈送器通过适当的目标通道发送fuzzer的测试用例
◼目标二进制文件状态由检测引擎跟踪
客户端和服务端的Fuzzing配置不同,但也有一些相似之处。
基本Fuzzing配置
在目标方面,有以下内容:
◼Fuzzer – 定制的 afl-fuzz.exe
◼检测引擎——定制的 winafl.dll,使用定制的 DynamoRIO 和in_app检测模式
◼输入 ——被写入目标和输入发送者共享目录中的中间文件的变异 PDU
在输入发送方,有一个组件:
◼代理——读取中间文件并在目标通道上发送输入
将输入的生成与向目标的传输分离,允许输入在被目标处理之前从 RDP 连接的一侧移动到另一侧。此外,我使用 WinAFL 的应用内检测模式来不中断正常的执行流程。
为了使覆盖引导的Fuzzing工作,输入和它们触发的代码路径之间必须存在一一对应的关系。为了实现这一点,我开发了“background fuzzing”,它将Fuzzer PDU 与常规 PDU 区分开来,并且只跟踪前者的代码路径。这是必不可少的,因为我只希望Fuzzer跟踪我自己测试用例的覆盖范围,而不是通过连接发送的随机 PDU。
为了说明这一点,让我看看在Fuzzing将音频从 RDP 服务端重定向到客户端的RDPSND虚拟通道时,类似的事情会是什么样子。根据官方文档,每个 PDU 的第一个字节表示发送的消息类型。
资料来源:[ MS-RDPEA ]
msgType字段支持的值为0x01到0x0D。在这种情况下,可以通过以下方式使用第一个字节的最高有效位作为fuzz标记:
◼在发送 PDU 之前,代理会转换第一个字节的最高有效位。
◼WinAFL 在处理消息之前检查第一个字节的最高有效位。如果该位打开,WinAFL 将关闭该位并跟踪此消息的代码覆盖率。如果该位关闭,WinAFL 将忽略该消息并且不跟踪任何覆盖范围。
在了解了客户端设置和服务端设置之间的相似之处之后,让我看看它们之间的区别,先从客户端开始。
2.客户端Fuzz配置
Windows RDP 客户端是mstsc.exe,但处理虚拟通道数据的大部分逻辑都在客户端加载的mstscax.dll中。
Hyper-V 虚拟机的远程访问客户端vmconnect.exe也使用mstscax.dll作为其核心功能。
Windows RDP 客户端
为了简单和高效,我在同一台机器上同时执行客户端和服务端(目标和代理)(使用客户端连接到 localhost/127.0.0)。为了允许并行Fuzzing,我还使用了mimikatz来修补服务端,使其允许并发 RDP 连接。
这些是对客户端进行Fuzzing时的设置组件:
◼目标 - mstsc.exe和mstscax.dll的目标模块
◼检测引擎 – 创建客户端进程的 DynamoRIO 和 winafl.dll,报告代码覆盖率的 DynamoRIO 客户端
◼变异引擎——在同一台机器上运行的 AFL-Fuzz 并将新的测试用例写入文件
◼输入馈送器——我的 RDPFuzzAgent,它打开服务端的句柄并在选定的虚拟通道上发送 PDU。代理从 AFL-Fuzz 创建的文件中获取每个测试用例,并将它们发送到目标通道上。
使用这些组件,我能够实现大约每秒 50-100 次执行的执行速度。
3.服务端Fuzz配置
为了找到包含 RDP 服务端主要逻辑的目标二进制文件,可以简单地查看远程桌面服务服务。
PS C:\> gci HKLM:\SYSTEM\CurrentControlSet\Services\$((Get-Service -Name "Remote Desktop Services").Name) Hive: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\TermService Name Property ---- -------- Parameters ServiceDll : C:\Windows\System32\termsrv.dll . . .
RDP 服务端的主要逻辑确实在termrv.dll 中,它通过以下命令加载到svchost.exe进程中:C:\Windows\System32\svchost.exe -k NetworkService -s TermService。
最初对服务端进行Fuzzing的计划与客户端类似,即在需要运行多个TermService实例的同一台机器上并行地对目标的几个实例进行Fuzzing。结果证明这是一项非常具有挑战性的任务,因为 Windows 默认不支持此操作。当我尝试手动执行此操作时,我在termrv.dll中看到了一些指向TermService及其注册表项的硬编码字符串,因此我决定将精力集中在其他地方,只使用多个 VM 并行fuzz服务端。
在客户端Fuzzing设置中,我使用服务端API 调用WTSVirtualChannelWrite()将Fuzzing输入发送到目标模块。不幸的是,我找不到类似的 API 来让通过 RDP 连接向服务端发送输入。
因此,我选择使用 Ubuntu 机器的定制FreeRDP,是一个开源 RDP 客户端工具,将输入发送到fuzz服务端。请注意,这不是Fuzzing的理想配置,这些限制导致服务端Fuzzing的速度大约是客户端Fuzzing的 1/10。
服务端fuzz统计
这些是对服务端进行Fuzzing时的设置组件:
◼目标文件——取决于被Fuzzing的通道。可以是termrv.dll、audiodg.exe、rdpinput.exe等。
◼检测引擎 - 在此设置中,Fuzzer无法控制目标进程的初始化。因此,为了跟踪代码覆盖率,必须检测一个正在运行的目标。WinAFL Fuzzer 有几个可以使用的检测引擎,我选择 DynamoRIO 是因为它的可扩展性和稳健性。
◼DynamoRIO(包括WinAFL)不支持直接连接到活动进程。
◼值得注意的是,此附加功能为之前使用 WinAFL 无法fuzz的进程打开了大门——用户无法控制其创建的进程。特别是,它允许对 Windows 服务进行Fuzzing。
◼变异引擎——AFL-Fuzz 在 Windows 机器上运行,并将新的测试用例写入共享文件夹中的文件。
◼输入馈送器——我们构建的FreeRDP连接到Windows计算机上的服务器,监视共享文件夹中的新测试用例,并在目标RDP通道上发送每个测试用例。
4.状态性
在运行第一个通道(对客户端进行Fuzzing)时,我遇到了一个问题:只要在客户端检测到两条无效消息,它就会立即终止连接。
为了避免这个问题,我在代理的逻辑中引入了一些强制协议语法,即限制允许输入的空间。
◼消息大小限制
-最小值、最大值
-分片(例如,如果消息包含一个由 4 字节元素组成的可变长度数组,则它应该可以被 4 整除)
◼数据值限制
-只允许特定值
-最小值和最大值
-值必须是 PDU 大小
-每个 PDU 中的值必须不同
我从 Microsoft 的RDP 及其扩展文档中提取了一些 RDP 语法,一些从相关二进制文件的逆向中提取,其余从跟踪失败的目标执行中提取。
例如,此逻辑可用于仅允许以受支持的msgTypes之一开头的消息,后跟 PDU 的大小和唯一标识符,并且其总大小介于 22 和 122 之间,其余为 2 模 4 .
值得一提的是,通过执行这些强制措施,实际上限制了变异引擎随心所欲更改测试用例的能力,因此可能会遗漏某些变异。出于这个原因,我尝试尽可能少地强制执行,同时仍然确保连接不会经常关闭。
这里的另一个重点是,在处理此类问题时,这些语法强制并不是唯一的选择。在一个特定通道 (GFX) 的情况下,我转而patch正在Fuzzing的实际目标函数,以便在一组无效消息的情况下它不会关闭连接。这使我能够继续fuzz无效消息并始终保持连接打开。在这里,你也有可能发现不会在原始代码中重现的crashs。这是一个很好的例子,说明Fuzzing需要在确保获得足够的执行速度的同时仍然保持目标程序的原始功能以及变异引擎的自由之间的微妙平衡。
5.多输入Fuzzing
大多数bug都依赖于一系列消息而不是单个消息。我发现的大多数bugs都涉及至少两条消息,这并非巧合。
为了发现这些bugs,我引入了多输入Fuzzing。我使用了一个Fuzzer字典来识别新消息的开始及其类型。然后,代理将根据这些字典单词将输入拆分为多个 PDU,并一个接一个地发送。
因此,多输入输入可能如下所示:
___cmd07 < 1st PDU data> ___cmd02 < 2nd PDU data> ___cmd03 < 3rd PDU data>
代理将其转换为msgType 7、2和 3 的三个消息,以及它们各自的内容。
为了保持Fuzzer创建的输入与其触发的代码覆盖率之间的一一对应关系,我引入了第二个标记来标识序列中的最后一条消息。只有当 WinAFL 识别出带有“最后一个序列”标记的调用结束时,它才会完成循环并创建下一个输入。
虽然多输入Fuzzing对当前工作至关重要并且富有成效,但我还发现有必要限制每个测试用例的 PDU 数量。因为Fuzzer重复相同的消息 100 次会导致与发送一次不同的代码序列。
6.Crashs重现问题
经过大约一周的Fuzzing,第一次崩溃出现了。但是,当我再次尝试运行相同的输入时,崩溃并没有重现。这种情况经常发生,很可能是由于协议的状态性质。换句话说,一个测试用例让客户端进入一个特定的状态,然后被后续的测试用例“利用”来使目标崩溃。
为了理解不可重现的崩溃,我修改了 WinAFL 以在检测到崩溃时创建目标进程的内存转储。
7.Crashs自动化分析
在崩溃时创建转储解决了一个问题,但造成了另一个问题:一旦发现崩溃,很可能会重复遇到。通常,WinAFL 会尝试检测相同的崩溃并仅通知“特殊的崩溃”,但是我的多消息Fuzzing使这种检测变得非常困难。考虑单个消息导致目标崩溃的情况。fuzzer 可以在最后创建任何一组消息。这些消息集中的每一个都会使目标崩溃,并且还会导致不同的覆盖位图,因为消息及其处理方式不同,这将导致 WinAFL 每次都报告一个特殊的崩溃。
出于两个原因,我必须自动分析崩溃。首先,手动分析每个崩溃的工作很繁琐,其次,磁盘很快被内存转储填满。
为了克服这个问题,我编写了一个 WinDBG 脚本来分析崩溃并从中提取崩溃堆栈。然后,我运行了一个 PowerShell 脚本,该脚本定期分析崩溃并仅保留那些包含新堆栈的崩溃,并通过电子邮件向我发送消息。
在客户端Fuzzing设置中,从Fuzzer创建目标(mstsc.exe)的那一刻,到建立连接并可以发送第一条消息的那一刻,花费了 10 多秒钟。因此,在不重新启动目标的情况下执行尽可能多的迭代至关重要。我通过使用AFL-Fuzz的-fuzz_iterations参数并提供尽可能多的迭代来实现这一点。
8.多通道Fuzzing
与多输入Fuzzing一样,某些逻辑需要不同通道上的一系列消息。例如,如下面文档中所述,使用多个通道支持从客户端向服务端发送相机数据。
资料来源:[ MS-RDPECAM ]
因此,如果希望通过发送输入来fuzz服务端,必须至少在两个不同的通道上这样做。
我的解决方案也很相似:fuzzer 字典确定了要发送消息的通道。
9.定位相关代码
由于 RDP 在 Windows 中有许多不同的组件,甚至定位需要Fuzzing的目标函数都具有挑战性。
PS C:\> gci -Include *.exe, *.dll, *.sys -Recurse C:\Windows\ -ErrorAction SilentlyContinue | ?{[System.Diagnostics.FileVersionInfo]::GetVersionInfo($_).FileDescription -match "RDP|Remote Desktop"} | Measure-Object | select count Count ----- 191
为了快速做到这一点,我创建了一个小型数据库,其中包含可能与我的工作相关的所有符号文件。
我的想法是下载与我的 Windows 版本相关的所有 PDB,从中提取所有函数名称并将它们转储到一个文件中(链接回 exe/sys/dll),以便可以快速搜索函数名称和定位与我当前目标通道相关的功能。
由于几乎所有动态通道接收函数都匹配以下模式C< class-name >::OnDataReceived,我们可以快速查看这些函数的列表并找出可能与我们所针对的通道相关的内容。
0x03 Crashs漏洞分析
在本节中,我将分享我在该工作中发现的两个bugs的技术细节。
1.AUDIO_PLAYBACK 通道(服务端→客户端)
该AUDIO_PLAYBACK_DVC虚拟通道用于输出从客户端上输入的服务端数据。它的正常流程包括两个序列:初始化和数据传输。在协议的正常使用中,初始化序列在开始时发生一次,然后是许多数据传输序列。
◼初始化序列——用于建立以下数据序列中使用的版本和格式
资料来源:[ MS-RDPEA ]
◼数据传输序列——来自服务端的数据将在客户端输出
资料来源:[ MS-RDPEA ]
Wave和WaveInfo PDU包含初始化序列中交换的格式数组的索引,用于确定传输音频数据的格式。
资料来源:[ MS-RDPEA ]
当格式发生变化时ーー例如,Wave 或 WaveInfo PDU 的索引与上次使用的索引不同,客户端会验证新索引是否有效。
// in mstscax!CRdpAudioController::OnNewFormat if ( (unsigned int)new_format_index >= this->formatArray_size )
但是,只要格式索引保持不变,就会跳过此验证,下面是相关部分的伪代码。
// in mstscax!CRdpAudioController::OnWaveData last_format_index = this->last_format_index; format_index_from_pdu = *((_WORD *)pdu + 3); //pdu is controlled by the server if ( last_format_index != format_index_from_pdu ) { CRdpAudioController::OnNewFormat(this, (__int64 *)format_index_from_pdu); // this is where the bound check is being made // but only if the format index is different than the last index last_format_index = *((unsigned __int16 *)pdu + 3); this->last_format_index = last_format_index; } formats_array = (AUDIO_FORMAT **)this->formatArray; current_format = formats_array[last_format_index]->wFormatTag; // crashes here
在客户端触发此漏洞的流程如下:
漏洞流程
◼服务端向客户端发送具有0x1A格式的服务端格式 PDU,客户端分配具有此大小的格式数组。
◼服务端向客户端发送一个Wave2 PDU,该PDU使用数组中的格式0x5和数据。
-客户端检查此格式是否与上次发送的格式相同。
-如果是,它使用最后一个解码器;如果不是,它将从格式数组加载新的解码器函数指针。
◼服务端再次向客户端发送服务端音频格式 PDU——这次只有0x2,导致客户端释放之前的格式数组并分配一个具有新大小的新格式。
◼服务端最终使用最后使用的格式0x5发送另一个Wave2 PDU。
-由于格式没有改变,客户端不执行任何有效性检查。
-然后,客户端执行越界读取,尝试从 2 格式数组中读取第6种格式造成崩溃。
这允许攻击者使用额外的服务端音频格式 PDU 重新分配格式数组,然后指定以前有效和使用的无效索引,导致客户端读取格式数组的边界并崩溃。
请注意,此bugs在很大程度上依赖于多输入Fuzzing,如果没有此功能,我们将无法找到它。
2.AUDIO_INPUT 通道(客户端→服务端)
所述AUDIO_INPUT虚拟信道用于从所述客户端发送的声音输入到服务端。在服务端端,音频输入数据由提升的audiodg.exe进程处理。
在AUDIO_PLAYBACK_DVC通道中,客户端和服务端首先交换它们支持的一系列声音格式。
资料来源:[ MS-RDPEAI ]
所述声音格式的PDU开始的9个字节的报头,其包括指令,格式编号,和该分组的大小,接着格式的阵列,每个可变长度,加上附加数据的一个可选的字段。
资料来源:[ MS-RDPEAI ]
处理声音格式 PDU的代码在rdpendp.dll 中。它首先验证数据包大小至少为九个字节,然后读取头部并验证头部的大小不大于数据包的大小。
// in rdpendp!CAudioInputHandler::OnFormatsReceived if ( size < 9 ) { // ... } // ... size_from_msg = *(_DWORD *)(data + 5); if ( size_from_msg > size ) { // ... }
然后相同的函数从header读取的大小中减去 9,并读取header中指定的格式数,只要剩余长度足够大。header的大小不受整数下溢保护,这可能会导致减法溢出,并导致程序从数据包末尾读取“formats”。
// in rdpendp!CAudioInputHandler::OnFormatsReceived underflowed_size = size_from_pdu - 9; format_definition_offset = (unsigned __int16 *)(pdu + 9); if ( num_formats ) { while ( underflowed_size >= 0x12 ) { format_definition_size = format_definition_offset[8]; total_format_size = format_definition_size + 18; if ( underflowed_size < (unsigned __int64)(format_definition_size + 18) ) break; (*class_fomats_array)[format_index] = (struct SNDFORMATITEM *)operator new[](total_format_size); local_format = (*class_fomats_array)[format_index]; if ( !local_format ) { status = E_OUTOFMEMORY; goto CLEAN_AND_RETURN; } memcpy_0(local_format, format_definition_offset, total_format_size); format_definition_offset = (unsigned __int16 *)((char *)format_definition_offset + total_format_size); underflowed_size -= total_format_size; if ( ++format_index >= num_formats ) goto LABEL_50; } goto INVALID_ARG_EXIT; }
0x04 研究总结
在这篇文章中,我记录了我尝试解决具有挑战性的Fuzzing目标的过程:Windows 的 RDP 客户端和服务端。我想分享我的过程有几个原因。
首先,我认为即使你无法实现最初的目标(例如 RCE),分享流程也很重要。这可以帮助你反思自己的流程——哪些看起来运作良好,哪些可以改进。
其次,尽管设置Fuzzing环境可能是一个复杂的过程,但我认为这是一个值得追求的目标——即使是像我在这里提出的更具挑战性的目标。RDP 是一个非常复杂的协议,具有许多组件和不同的代码库。Shodan.io搜索超过 400 万台使其成为攻击者非常有利可图的目标。
来源:shodan.io
尽管 Microsoft 近年来在保护其产品方面做了一些出色的工作,但我认为 RDP 组件中仍有很多漏洞可以进行挖掘。凭借我在此过程中获得的理解以及此处共享的信息,我可以推动未来的研究。未来的工作可能是提高Fuzzing的性能,将其扩展到我没有机会进行Fuzzing的其他通道和函数,使用基于模拟器的Fuzzing,甚至对代码的部分进行手动逆向分析。
最后,为了展示我的Fuzzing的通用性,我已经将其应用于几个 RPC 服务端,并取得了一些初步成果。
本文翻译自:https://www.cyberark.com/resources/threat-research-blog/fuzzing-rdp-holding-the-stick-at-both-ends如若转载,请注明原文地址