CSGO:可靠的远程代码执行

robots

 

可以说能够造就反恐精英:全球攻势(这里简称“CS : GO”)如此火爆的因素之一就是它允许玩家对自己的社区服务器进行托管。这些社区服务器支持免费下载和安装,并且还拥有高自由度的定制功能。服务器的管理员们可以创建并部署应用像是地图这样的自定义资产,从而向玩家们提供更多富有创意的游戏模式。

然而,这样的一种设计选择也同时打开了一个更大的攻击面。玩家们有可能无意间就连接到了潜在的恶意服务器,这些恶意服务器可能会对游戏中复杂的报文消息以及像是游戏皮肤纹理这样的虚拟资产进行掉换。

我们设法找到并利用了其中的两处漏洞,通过将二者结合,当有玩家连接到我们的恶意服务器时,就可以在玩家的电脑上触发一个有效的远程代码执行。第一处漏洞是一个能够让我们破解绕过客户端游戏进程中ASLR(地址空间分配随机)的信息泄露漏洞。第二处漏洞是一个位于游戏已加载模块中.data部分的全局数组越界访问,该漏洞可以让我们控制任意指令的指针。

 

#社区服务器列表

玩家们可以通过使用游戏中内置的一个用户友好型服务器浏览器来选择加入其中的社区服务器:

一旦用户进入了其中的一个服务器,他们的游戏客户端和其所在的社区服务器彼此间就开始进行交互。作为安全研究人员,我们的职责就是去了解CS:GO中所使用的网络协议并且明白其中传送的是哪种报文,以便于我们从中发现漏洞。

事实证明,CS:GO使用的是基于UDP设计的自有协议来对客户端与服务器之间交互的数据进行序列化、压缩、分段以及加密。在这里我们不会对该网络协议的代码进行详细分析,毕竟这与我们将要介绍的漏洞无关。

更关键的是,这种基于UDP的自定义协议会携带由Protobuf序列化的payload。Protobuf是一项有谷歌开发的技术,它可以实现对报文的定义并提供了序列化和反序列化这些报文的API。

这里是CS:GO开发人员定义和使用protobuf报文的示例:

message CSVCMsg_VoiceInit {
    optional int32 quality = 1;
    optional string codec = 2;
    optional int32 version = 3 [default = 0];
}

在了解到CS:GO使用了Protobuf之后,我们通过Google搜索还找到了有关此报文的定义。同时还发现了存放着Protobuf报文定义列表的 SteamDatabase Github 仓库。

正如上述报文名称所表示的那样,它用于对玩家客户端和服务器之间传输的语音消息进行初始化。该报文主体中还携带着例如解释这些语音数据的编解码器及其版本号。

 

#开发一个CS:GO代理程序

在获取了该报文列表并明确其定义之后,我们就可以继续深入了解客户端与服务器之间传输的数据类型了。但是,我们还是不知道这些报文是以何种顺序来进行发送的,更不知道我们要期望得到的值是什么。就好比,我们知道这里传送的是一些包含经过编码器初始化过的语音消息报文,但是我们并不知道在CS:GO中支持的是什么样的编码器。

为此,我们着手开发设计了一个用在CS:GO上的代理程序,这样我们就可以看到这些实时的报文通信了。大体想法是这样的,我们可以先启动CS:GO客户端,然后通过代理程序连接到任意一台服务器,并且将客户端接收到的以及发往服务器的所有报文转储下来。为了实现这一想法,我们对该网络协议的代码进行了逆向,以此来对报文进行解密与解包。

同时我们还在代理程序中添加了可以任意修改接收/发送报文中值的功能。也正是因为攻击者最终可以控制客户端与服务器之间发送的由Protobuf序列化报文中的值,所以才会导致存在一个如此大的攻击面。我们可以在那些负责对连接进行初始化的代码中发现该漏洞,而无需通过调换报文中感兴趣的字段来对其进行逆向。

下面这张动图中展示的是这些报文是如何被游戏客户端发送并由代理程序实时转储下来的,这些报文中包含了诸如射击、切换武器以及人物移动所对应的事件:

在搭配上我们设计的代理程序后,是时候让我们去研究一下protobuf报文中的一些位数并发现其中的漏洞了。

 

#CSVCMsg_SplitScreen中的数组OOB(越界)访问

我们发现在CSVCMsg_SplitScreen报文中存在这样一个字段,它可以经(恶意)服务器发送到客户端,并可以导致OOB访问,该访问随即便会触发一个可控的虚拟函数调用。

报文定义信息如下:

message CSVCMsg_SplitScreen {
    optional .ESplitScreenMessageType type = 1 [default = MSG_SPLITSCREEN_ADDUSER];
    optional int32 slot = 2;
    optional int32 player_index = 3;
}

CSVCMsg_SplitScreen报文看上去还挺有趣的,因为报文中存在一个叫做player_index的字段且该字段是由服务器所控制的。然而,与直觉相反的是,player_index字段并不会参与到数组的访问中(原以为字段命名中使用了index,就应该是起到一个索引作用),相反起到索引作用的是slot字段。事实证明,slot字段用作位于engine.dll文件的.data部分中分屏玩家对象数组的索引,并且在数组对象调用中没有进行数组下标边界的检查。

通过查看以下崩溃信息,我们可以发现一些有趣的现象:

  1. 1.该数组存储在engine.dll文件的.data部分中
  2. 2.该数组被访问后,会对被访问对象进行间接地函数调用

以下关于反编译代码的截图中展示了player_splot参数是如何在未经任何检查的情况下用作数组索引的。如果该对象的第一个字节不为1的话,则进入一个分支:

这个漏洞的发现还是非常有价值的,因为进入分支中的一些指令取消了对vtable对象的引用,并调用了函数指针。截图如下:

在存在信息泄露的情况下,该漏洞似乎是极易被利用的,我们对此感到兴奋不已。因为指向该对象的指针是从engine.dll文件中的全局数组中获取的,在编写本文时,该数组是一个6MB大小的二进制文件,因此我们非常确定我们是能够找到那个指向我们所控制数据的指针的。通过将上述对象指向给攻击者控制的数据就会触发任意代码执行。

不过,我们还是必须要在一个已知位置上伪造一个vtable对象,然后将函数指针指向到其他有用的地方。受限于这一点,我们决定寻找另一个可能导致信息泄露的漏洞。

 

#HTTP下载中未初始化内存导致的信息泄露

如之前提到的那样,服务器管理员可以创建带有任意数量自定义资产的服务器,这些自定资产可以包括自定义地图还有音效等。每当有玩家进入这些具有自定义设置的服务器时,那些用于支持这些自定义设置的文件就会被传输。服务器管理员可以为服务器列表中的每一张地图都创建一个所需下载支持文件的列表。

在连接阶段,服务器会向客户端发送一个所需下载文件所在HTTP服务器的URL。针对每一个自定义文件,都会发起一个cURL请求。这里有两个为每个请求所设置的参数引起了我们的注意:CURLOPT_HEADERFUNCTIONCURLOPT_WRITEFUNCTION。前者将会允许寄存一个针对每个HTTP响应中的HTTP标头调用的回调。后者允许寄存一个当主体数据接收完毕时触发的回调。

这里是这些参数设置的信息,如图所示:

我们对Valve的开发者们处理这些传入的HTTP标头信息的的方式还是很感兴趣的,于是我们便对一个叫做CurlHeaderCallback()的函数进行了逆向。

事实证明,CurlHeaderCallback()函数就仅仅只是解析了Content-Length的HTTP标头信息,并且在相应的堆上分配了一个未初始化的缓冲区,因为这里的Content-Length对应于所下载文件的文件大小。

然后CurlHeaderCallback()函数将接收到的数据写入该缓冲区。

最后,一旦HTTP请求结束并且没有接收到其他的额外数据,缓冲区的内容就会被写入磁盘。

我们随即注意到了在解析Content-LengthHTTP头部时存在的一个缺陷:如下所示,这里竟然对“Content-Length”做了大小写匹配。

在检索Content-Length标头信息时进行了大小写敏感匹配。

这种缺陷还是比较明显的,因为HTTP的标头字段毕竟也可以是小写格式的。上述的这种情况只适用于Linux客户端,因为在Linux系统中会使用cURL并且会对检索内容进行全匹配。然而在Windows客户端中,客户端就只能使单纯地假设Windows API返回的值是正确的。这就会导致存在一个非常明显的bug,我们完全可以只发送任意一个带有小写格式Content-Length标头信息的响应主体。

我们利用Python脚本创建了一个HTTP服务器,并使用了一些HTTP标头值。最后,我们提供了一个能够触发该漏洞的HTTP响应主体。

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 1337
content-length: 0
Connection: closed

当客户端接收到这样的一个下载文件对应的HTTP响应后,它只会识别出第一个Content-Length标头字段并且会分配缓冲区的大小为1337。然而下面第二个Content-Length标头字段所带的参数大小是0。尽管CS:GO代码中由于大小写敏感会忽略掉第二个Content-Length标头字段,并且仍然认为数据的大小为1337字节,但是cURL却会使用最后一个识别出的标头字段信息并且会立即结束请求。

在Windows上,即便是响应格式不正确,API也只会返回第一个标头值。CS:GO的代码然后就会将分配的缓冲区以及缓冲区中包含的所有未初始化的内存内容(包括指针等)写入磁盘中。

尽管CS:GO使用Windows API来处理HTTP下载,但是像这样相同的HTTP响应信息还是会触发相同的漏洞,并且允许我们在玩家机器上去创建一个任意大小的包含未初始化内存内容的文件。

然后服务器就可以通过CNETMsg_File报文来请求这些文件。当客户端接收到该报文时,它们就会向服务器上传被请求的文件。该报文如下定义:

message CNETMsg_File {
    optional int32 transfer_id = 1;
    optional string file_name = 2;
    optional bool is_replay_demo_file = 3;
    optional bool deny = 4;
}

一旦文件上传完成,攻击者控制的服务器就可以检索文件内容来找到可以破解绕过ASLR的engine.dll文件中的指针,或是堆指针。我们将在附录部分对Breaking ASLR(破解绕过ASLR)这一步进行详细说明。

 

#综上:并使用ConVars作为辅助工具

为了进一步实现游戏的定制化,我们将会在服务器与客户端之间使用ConVars,本质上这就是一些配置选项。

每个ConVar都由一个存储在engine.dll中的全局对象管理。以下代码片段中展示了此类对象的简化定义,用来解释为什么ConVars能够作为实现OOB访问强有力的工具:

struct ConVar {
    char *convar_name;
    int data_len;
    void *convar_data;
    int color_value;
};

社区服务器还可以在游戏匹配期间更改ConVar的值并且向客户端发送CNETMsg_SetConVar报文来向客户端通知:

message CMsg_CVars {
    message CVar {
        optional string name = 1;
        optional string value = 2;
        optional uint32 dictionary_name = 3;
    }

    repeated .CMsg_CVars.CVar cvars = 1;
}
message CNETMsg_SetConVar {
    optional .CMsg_CVars convars = 1;
}

这些报文由一对简单的键值对构成。当报文定义与struct ConVar定义进行比较时,就可以推断出ConVar报文中受攻击者控制的value字段被复制到了客户端的堆中,并且指向它的指针存储在ConVar对象中convar_value字段的这一点也是成立的。

正如我们之前讨论到的,CSVCMsg_SplitScreen中的OOB访问发生在一个指向对象的指针数组中。这里是OOB访问发生时作为提示的反编译代码:

由于数组和所有的ConVars都位于engine.dll文件的.data部分中,我们完全可以设置player_slot参数,使得ptr_to_object指向我们预设的ConVar值。说明如下:

我们之前也提到了在OOB访问一个该对象的虚拟方法后会有一些指令被调用。通过取消调用vtable对象后这一点仍会发生。这里是再次提示的代码:

由于我们通过ConVar控制了对象的内容主体,我们就可以将指向vtable对象的指针设为任意值。为了让漏洞的利用100%有效,利用信息泄露将可控数据指向engine.dll中的.data部分就变得非常有意义了。

幸运的是,一些ConVars会被解释为颜色值,并且是(红蓝绿Alpha)可被攻击者控制的4字节的值。该值直接存储在上述struct ConVar定义中的color_value字段中。由于CS:GO在Windows上的进程是32位的,我们就可以利用ConVar的颜色值去伪造一个指针。

如果我们利用这个伪造的vtable指针对engine.dll.data部分进行指向,使得被调用的方法与color_value产生重叠,我们最终就可以劫持EIP寄存器并实现任意重定向控制流。该解引用链的解释如下:

 

#ROP(返回导向编程)链到RCE

随着ASLR被破解绕过并且我们取得了任意指令指针的控制权,剩下的就是去构建一个ROP链,来让我们调用ShellExecuteA执行任意系统命令。

视频地址

 

#附录:破解绕过ASLR

在本文的HTTP下载中未初始化内存导致的信息泄露小节中,我们展示了HTTP下载是如何让我们在客户端的游戏进程中查看任意大小的未初始化内存块的。

我们发现了另一条对我们来说似乎很有趣的报文:CSVCMsg_SendTable。每当客户端接收到这样的报文时,它就会在堆上分配一个受攻击者控制的整型对象。更关键的是,该对象的前四个字节包含了一个指向engine.dll文件的vtable指针。

def spray_send_table(s, addr, nprops):
    table = nmsg.CSVCMsg_SendTable()
    table.is_end = False
    table.net_table_name = "abctable"
    table.needs_decoder = False

    for _ in range(nprops):
        prop = table.props.add()
        prop.type = 0x1337ee00
        prop.var_name = "abc"
        prop.flags = 0
        prop.priority = 0
        prop.dt_name = "whatever"
        prop.num_elements = 0
        prop.low_value = 0.0
        prop.high_value = 0.0
        prop.num_bits = 0x00ff00ff

    tosend = prepare_payload(table, 9)
    s.sendto(tosend, addr)

然而在Windows堆中还是有那么一点的不确定性。也就是说,malloc -> free -> malloc这样的组合将会产生不同的块。值得庆幸的是,研究人员Saar Amaar针对Windows堆的问题发表了一篇非常棒的研究报告,我们参考了这些研究报告以便于我们可以更好的了解漏洞的利用。

当我们将文件上传回服务器上时,我们想到可以利用溢出 来对分配一些带有标记的SendTable对象数组来进行扫描。由于我们可以决定数组的大小,所以我们选择了一个不太常见的分配大小,来避免干扰正常的游戏代码。如果我们现在一次性地释放所有被溢出的数组,然后让客户端下载文件,那么其中一个文件命中先前溢出的块的机会相对较高。

在实践中,我们几乎总是在第一个文件中出现泄露,而当我们没有发生泄露时,我们可以将连接重置并再次尝试,因为我们还没有破坏掉程序的状态。为了将成功率最大化,我们创建了四个文件来利用该漏洞。这样就能确保至少能有一个文件能够成功触发该漏洞,否则就再进行重试即可。

下列代码中展示了我们是如何对接收内存中的溢出对象进行扫描的,以找到指向engine.dll文件的SendTablevtable对象。

files_received.append(fn)
pp = packetparser.PacketParser(leak_callback)

for i in rahttps://secret.club/2021/05/13/source-engine-rce-join.html#putting-it-all-together-convars-as-a-gadgetnge(len(data) - 0x54):
    vtable_ptr = struct.unpack('<I', data[i:i+4])[0]
    table_type = struct.unpack('<I', data[i+8:i+12])[0]
    table_nbits = struct.unpack('<I', data[i+12:i+16])[0]
    if table_type == 0x1337ee00 and table_nbits == 0x00ff00ff:
        engine_base = vtable_ptr - OFFSET_VTABLE 
        print(f"vtable_ptr={hex(vtable_ptr)}")
        break
(完)