信息安全公司首席研究员Tom Court of Context谈到了他如何设法检测Steam客户端代码中潜在的危险错误。注重安全的PC播放器已经注意到Valve最近发布了新的Steam客户端更新。
在这篇文章中,我想
借口在工作中玩游戏,以讲述Steam客户端中存在至少十年的相关错误的故事,直到去年7月,这可能导致远程代码执行(远程代码执行,RCE)所有1500万活跃客户中。
自7月起,当Valve(最终)在启用现代漏洞保护功能的情况下编译其代码时,它仅会导致客户端故障,并且RCE仅与单独的信息泄漏漏洞结合使用才可能。
我们于2018年2月20日宣布Valve为漏洞,并且值得赞扬的是,该公司在不到12小时后便将其修复到beta版分支中。 该修复程序已于2018年3月22日移至稳定分支。
简短评论
该漏洞的根源是对Steam客户端库中堆的损坏,可以远程调用该代码,该代码部分涉及从几个接收到的UDP数据包中恢复分段的数据报。
Steam客户端通过自己的协议(Steam协议)交换数据,该协议在UDP之上实现。 由于存在漏洞,该协议中有两个特别有趣的地方:
该错误是由于缺少简单检查引起的。 该代码未验证第一个分段数据报的长度是否小于或等于数据报的总长度。 鉴于对于传输数据报片段的所有后续数据包都执行检查,这似乎是一个常见的疏忽。
如果没有其他数据泄漏错误,就很难控制现代操作系统上的堆破坏,因此很难执行远程代码执行。 但是,在这种情况下,由于Steamclient.dll二进制文件(到去年7月)中缺少Steam自己的内存分配器和ASLR,此错误可以用作非常可靠的利用的基础。
以下是该漏洞及其相关漏洞的技术描述,直到
代码执行实现。
漏洞详情
理解所必需的信息
协议书
第三方(例如
https://imfreedom.org/wiki/Steam_Friends )基于对Steam客户端生成的流量的分析,进行了逆向工程并创建了Steam协议的详细文档。 最初,该协议于2008年形成文档,此后没有太大变化。
该协议通过在UDP数据报流上建立连接来实现为传输协议。 根据上面链接中的文档,软件包具有以下结构:
重要方面:
- 所有数据包均以 “ VS01 ”的4个字节开头
- packet_len描述有用信息的长度(对于无碎片的数据报,其值等于数据的长度)
- type描述程序包的类型,可以具有以下值:
- 0x2呼叫验证
- 0x4接受连接
- 0x5重置连接
- 0x6数据包是数据报的片段
- 0x7包是一个单独的数据报
- 源和目标字段是为在Steam客户端内的多个连接上正确路由数据包而分配的标识符
- 如果数据包是数据报的片段:
- split_count指示数据报被拆分为的片段数
- data_len表示恢复的数据报的总长度
- 这些UDP数据包的初始处理发生在steamclient.dll中的函数CUDPConnection :: UDPRecvPkt中
加密方式
AES-256使用密钥对数据报包的有用信息进行加密,该密钥在每个会话的客户端和服务器之间协商。 密钥协商的执行过程如下:
- 客户端生成一个32字节的AES随机密钥,并且RSA在将其发送到服务器之前使用Valve公钥对其进行加密。
- 具有私钥的服务器可以解密该值并将其接受为AES-256密钥,该密钥将在会话中使用
- 同意密钥后,将使用此密钥对当前会话中的所有有用信息进行加密。
脆弱性
CUDPConnection类的
RecvFragment方法内部存在漏洞。 steamclient库的发行版中没有符号,但是,当在我们感兴趣的功能中搜索二进制
行时,会找到指向“
CUDPConnection :: RecvFragment ”的链接。 当客户端收到包含类型为0x6的Steam数据报(“数据报的片段”)的UDP数据包时,执行此功能。
1.该功能通过检查连接状态以确保其处于“
已连接 ”状态开始。
2.然后,检查Steam数据报中的
data_len字段以确保它包含的字节数少于
0x20000060 (看来该值是任意选择的)。
3.如果通过了检查,该函数将检查连接是否收集某些数据报的片段,或者它是流的第一个数据包。
4.如果这是流中的第一个数据包,则
检查split_count字段以查看此流将扩展多少个数据包
5.如果将流分成几个数据包,则将
检查seq_no_of_first_pkt字段以确保它与当前数据包的序列号匹配。 这样可以确保数据包是流中的第一个。
6.再次对照
0x20000060字节的限制
检查data_len字段。 此外,已验证
split_count小于
0x709b数据包。
7.如果满足这些条件,则将设置一个布尔值以指示我们现在正在收集片段。 它还会检查我们是否尚未分配用于存储片段的缓冲区。
8.如果指向碎片收集缓冲区的指针不为零,则释放当前的碎片收集缓冲区并分配新的缓冲区(请参见下图中的黄色矩形)。 这是错误出现的地方。 片段收集缓冲区应以
data_len字节的大小分配。 如果一切成功(并且代码未检查-一个小错误),则使用
memmove将数据报的有用数据复制到此缓冲区,并相信在
packet_len中指示了要复制的字节数。
开发人员最重要的疏忽是未执行检查“ packet_len小于或等于data_len ”。 这意味着可以传输小于packet_len的 data_len并将最多64 KB的数据(由于packet_len字段为2字节宽)复制到一个很小的缓冲区中,从而可以利用堆损坏。漏洞利用
本部分假定存在针对ASLR的解决方法。 这导致一个事实,即在开始运行之前,steamclient.dll的起始地址是已知的。
数据包欺骗
为了使客户端接收攻击性UDP数据包,它必须检查发送的(客户端->服务器)数据报,该数据报被发送以便找出客户端/服务器连接的标识符以及序列号。 然后,攻击者必须欺骗IP地址和源/目标端口以及客户端/服务器标识符,并将获悉的序列号加1。
记忆体管理
若要分配大于1024(0x400)字节的内存,请使用标准系统分配器。 为了分配小于或等于1024字节的内存,Steam使用其自己的分配器,该分配器在所有受支持的平台上均相同。 本文将不详细讨论此分发服务器,但以下关键方面除外:
- 向系统分配器请求较大的内存块,然后将其划分为固定大小的片段,以在Steam客户端内存分配请求下使用。
- 选择是按顺序执行的,在使用的片段之间没有将它们分开的元数据。
- 每个大块都存储自己的空闲内存列表,该列表实现为单链接列表。
- 空闲内存列表的顶部指示内存中的第一个空闲片段,此片段的前4个字节指示下一个空闲片段(如果存在)。
内存分配
分配内存时,第一个空闲块与空闲内存列表的顶部断开连接,并且该块的与
next_free_block相对应的前4个字节被复制到
分配器类内的
freelist_head成员
变量中 。
可用内存
释放一个块后,
会将 freelist_head字段复制到已释放块的前4个字节(
next_free_block ),并将已释放块的地址复制到分发器类的成员变量
freelist_head 。
如何获得录音原语
堆上会发生缓冲区溢出,根据导致损坏的数据包的大小,可以通过标准Windows分配器(当分配的内存大于0x400字节时)或Steam自己的分配器(当分配的内存小于0x400字节时)来控制内存分配。 由于我自己的Steam发行商缺乏安全措施,因此我决定将其用作漏洞利用程序更容易。
让我们回到有关内存管理的部分:已知的是,给定大小的块的空闲内存列表的顶部存储为分发器类的成员变量,并且指向列表中下一个空闲块的指针存储为列表中每个空闲块的前4个字节。
如果在发生溢出的块旁边有一个空闲块,则对堆的损坏使我们可以覆盖
next_free_block指针。 如果您认为可以为此准备一堆,则可以将重写的
next_free_block指针设置为要写入的地址,然后将随后的内存分配写入该位置。
使用内容:数据报或片段
负责处理数据报片段(类型6的数据包)的代码中发生内存损坏错误。 发生损坏后,
RecvFragment()函数处于期望接收更多片段的状态。 但是,如果到达,则执行检查:
fragment_size + num_bytes_already_received < sizeof(collection_buffer)
但是显然不是这种情况,因为我们的第一个程序包已经违反了此规则(存在错误可能会跳过此检查),并且会发生错误。 为了避免这种情况,您需要在内存
损坏后避免使用
CUDPConnection :: RevvFragment()方法。
幸运的是,在
RecvFragment()有效之前,
CUDPConnection :: :: RecvDatagram()仍可以接收和处理类型为7(数据报)的已发送数据包,并且该数据包可用于启动记录原语。
加密问题
RecvDatagram()和
RecvFragment()接收到的数据包预计将被加密。 对于
RecvDatagram(),接收后几乎立即执行解密。 对于
RecvFragment(),它是在收到会话中的最后一个片段之后发生的。
由于我们不知道在每个会话中创建的加密密钥,因此出现了利用漏洞的问题。 这意味着我们发送的任何OP代码/ shell代码都将使用AES256“解密”,这会将我们的数据变成垃圾。 因此,有必要找到一种操作方法,在解密程序将能够处理包含在分组缓冲器中的有用信息之前,几乎可以在接收到分组之后立即进行操作。
如何实现代码执行
给定上述解密限制,应在解密输入数据之前执行操作。 这施加了附加的限制,但是任务仍然可行:您可以重写指针,使其指向存储在二进制文件的数据部分内可预测位置的
CWorkThreadPool对象。 尽管此类的详细信息和内部功能尚不清楚,但可以通过其名称假定该类支持需要执行“工作”时可以使用的线程池。 在研究了二进制文件中的几行调试行后,您可以了解到,在这样的工作中有加密和解密(
CWorkItemNetFilterEncrypt ,
CWorkItemNetFilterDecrypt ),因此当这些任务排队时,就
使用了
CWorkThreadPool类。 通过覆盖此指针并将其写入所需的位置,我们可以模拟vtable指针和与其关联的vtable,这使我们能够执行代码,例如,在
调用CWorkThreadPool :: AddWorkItem()时 ,该代码必须在任何解密过程之前发生。
下图显示了成功利用此漏洞的过程,直到获得对EIP寄存器的控制权为止。
从现在开始,您可以创建一个导致执行任意代码的ROP链。 以下视频显示了攻击者如何在完全修补的Windows 10版本中远程启动Windows计算器。
总结一下
如果您到达本文的这一部分,则感谢您的坚持! 我希望您理解这是一个非常简单的错误,由于缺乏现代的防范攻击手段,因此很容易利用。 易受攻击的代码可能很旧,但否则运行良好,因此开发人员无需检查或更新其构建脚本。 此处的教训是,即使代码本身的功能保持不变,对于开发人员而言,定期检查旧代码并构建系统以确保它们符合现代安全标准也很重要。 在2018年,在非常流行的软件平台上发现如此简单的bug并带来如此严重的后果,真是令人惊讶。 这应该激励所有研究人员寻找此类漏洞!
最后,值得讨论的是负责任的信息披露过程。 我们在格林尼治标准时间下午4点左右将这个错误报告给Valve的安全
团队 (
security@valvesoftware.com ),仅8小时后,便创建了一个修复程序并将其发布到Beta客户端Steam中。 由于这个原因,Valve现在在我们的“谁将更快地修复漏洞”竞赛的(虚构)表中排名第一-与向其他公司披露错误相比,这是一个令人愉悦的例外,通常这会导致漫长的审批流程。
描述所有客户端更新的详细信息的页面