~16 min read
CS:GO: From Zero to 0-day
TL;DR
We identified three independent remote code execution (RCE) vulnerabilities in the popular Counter-Strike: Global Offensive game. Each vulnerability can be triggered when the game client connects to our malicious python CS:GO server. This post details our journey through the CS:GO binary and conducts a technical deep dive into various identified bugs. We conclude by presenting a proof of concept (POC) exploit that leverages four different logic bugs into remote code execution in the game’s client, triggered when a client connects to the server.
Introduction
The CS:GO patch dated 04/28/2021 fixed several critical vulnerabilities, including three critical bugs from us. This post describes our approach and how we discovered three critical vulnerabilities. We present a single bug chain, consisting of four logic bugs, and explain how these led to a remote code execution (RCE) on the client by cleverly combining them. Although the post does explain the four logic vulnerabilities, its focus is on the methodology of our research.
First we look at existing research for the CS:GO game and give a general introduction to make reverse engineering of the complex client less painful. The post then introduces basic concepts of the CS:GO network protocol like fast_dl
and Cvars
and detail four different logic bugs. Combining the bugs leads to the proof of concept that exploits a CS:GO client by only connecting to a malicous, attacker controlled server.
CS:GO
The free-to-play game Counter Strike: Global Offensive (CS:GO) continues to experience great popularity with 21 million players per month, not least because of the wide variety of game modes offered by the many community-hosted servers. The game from 2012 is based on the even older source engine (2004), known for games such as Portal, Half-Life 2 and Left 4 Dead. The source engine in turn uses components from its predecessors, GoldSrc (1998) and the Quake engine (1996). This history already indicates that the powerful and complex source engine possesses some components, for which security did not yet stand in the foreground while programming.
The many game modes, community servers and modding support take a toll: a large attack surface. The many file formats such as textures, 3D models and AI navigation points go through a wide variety of parsers with completely attacker-controlled data as the data is shipped directly from the CS:GO server. In addition, the source engine implements its own TCP-like network stack based on UDP with all the associated problems in such a complex implementation. The network implementation has already been exploited in other attacks.
Know your target
Security research is not about blindly poking around and looking for security gaps. Because: Only when you have fully understood a target, you are in a position to break through the technical restrictions. The first step should therefore be to obtain as much information about the target as possible. The following sections provide ideas for this “recon” phase:
Software Development Kits
Games with modding support often provide an official software development kit (SDK). While the SDK does not contain the target’s source code, the structures defined there provide valuable information on network packages and class definitions that help to understand the engine. For Valve games in particular, there have also been several source code leaks of the engine or complete games (2003, 2007, and 2020). Although the source code is often outdated and contains many, now fixed, security holes, these leaks are very helpful. Mostly because source code is simply more pleasant to read than compiler-optimized assembly.
Public Research
CS:GO is well known, thus we were not the first researchers looking for bugs in this game. Therefore, we searched the Internet for helpful blogposts and presentations at conferences. The information described in this public research is often reduced to the essentials and makes it easier to find one’s way around a new, complex target.
Cheating Communities
Super annoying in the game, loved by security researchers: Cheater communities like UnknownCheats exist. These forums provide detailed reverse engineering posts and internals to the engine. In this case, Felipe had already written a Network Cheat that contributed a lot to the understanding of the network protocol.
Debug Symbols
Debug symbols contain the otherwise unrecognizable function names and class structures that make reverse engineering much more convenient. Sometimes versions of the game are also intentionally shipped with debug symbols to generate better error reports. However, sometimes programmers forget to remove the debug symbols from the final binaries of the game. Programmers are humans, and humans make mistakes.
The CS:GO version for macOS from April 2017 (shown below) contained full debug symbols. Game files with symbols are many times larger than without and can therefore be identified automatically using SteamDB and old repositories.
2017-04-26T00:15:42+00:00 [M:8167272392035836136]
csgo/bin/osx64/server.dylib (+9.30 MiB)
bin/osx64/engine.dylib (+5.17 MiB)
bin/osx64/scaleformui.dylib (+3.23 MiB)
csgo/bin/osx64/client.dylib (+12.13 MiB)
bin/osx64/materialsystem.dylib (+2.18 MiB)
While in 2021 it was still possible to specifically download old versions using SteamCMD
, the feature seems to have been disabled by Valve in the meantime.
Fuzzing
Despite all the information, you have to invest many hours in reverse engineering the target. Only once you have fully understood which buffer processes the network data in which virtual function with which arguments you can start doing exciting things. But the effort is worth it: we found instant client crashes using Hongfuzz, the public protobuf network structures, and libprotobuf-mutator. These crashes directly provided instruction pointer
control and were thus very likely exploitable! To test the full extent and develop exploit strategies, we decided to implement our own early-stage server in Python.
The discovery of four logic bugs
For a target like CS:GO, due to years of development and public bug bounty program, simple bugs are most likely fixed by now. If you are only looking for stack overflows in random methods of the huge engine.dll
, you will quickly give up in frustration. But it is true: every little anomaly can prove to be valuable in combination with other gaps. During the weeks of staring at the CS:GO disassembly and source-code leaks, we constantly asked ourselves the following questions:
- What primitives do we already have?
- What can we do by combining them?
- What security mechanisms are there?
- What weird edge cases might a developer not have considered?
Memory corruption exploitation is hard. Although two of the three full-chain exploits submitted by us to Valve were memory corruptions, that meant extremely high overhead and always the risk that the client would crash because of an unfavorable memory allocation. Starting CS:GO and connecting to a server loading the map took several minutes each time, which made development very tough.
In this post, rather then explaining weird heap feng shui mechanisms, focus on four logic bugs that together led to our goal of remote code execution on the client. The order of discovery was as follows.
Bug 1: Execution of privileged commands from the server
To verify that our custom python CS:GO server is actually working, we sent the command echo Hello World!
to the client via CNETMsg_StringCmd
and, as expected, received the output Hello World!
on the game console. Randomly, we also tried sending the quit
command. And the game closed! We couldn’t believe that a server is allowed to do that. As it turns out, it is usually not allowed to do so: With the help of SourceMod, a source engine modding framework that can also send messages to the client, we recreated the same setup with an official and modded server. The result: FCVAR_SERVER_CAN_EXECUTE prevented server running command: quit
. Did we find our entry bug? How exactly does the bug occur?
Source engine single-player games internally use a locally hosted source engine server. The single-player client then connects to its own server to join the game. This single-player server should of course have far-reaching rights, e.g., to change the keyboard layout on the client or to take screenshots.
A multi-player server is recognized as a local, and thus privileged, single player server if only a maximum of one client can connect to the server. The vulnerability is in the determination of the server type: The maximal number of clients that can connect to the server is controlled by the variable m_nMaxClients
and is received by the client when connecting to a server. By chance, our Python server had set the variable m_nMaxClients
to 1. And with this we could execute privileged commands on the client!
Bug 2: Arbitrary file download due to extension stripping
Source engine servers can send additional game files such as maps or player models to the client. The data transfer can be done either via the source network protocol or HTTP fast_dl
. To prevent malicious files from being sent to the client, certain file extensions like *.exe
, *.dll
, *.ini
are blocked.
If the fast_dl
option is set, additional content is loaded from a specified HTTP server rather then from the CS:GO server directly. The URL is dynamically generated from the server name and the full file name by the snprintf(p_cResult, 256, "%s/%s", p_cServerName, p_cFileName)
function. The snprintf
function limits the length of the resulting string to 256 characters, thus truncating unnecessary characters from the file name. But both p_cServerName
and p_cFileName
can have a length of 256 characters each! A file name like ././[..]/file.AAA.BBB
can be terminated specifically after the .AAA
extension, as the .BBB
part is truncated by the snprintf
function. The filter for potentially dangerous files can thus be bypassed completely!
The following source snipped illustrates that the extension is stripped:
#include <stdio.h>
int main()
{
unsigned char p_cResult[32];
// String fits into 32 byte and includes the `.bsp` part
snprintf(p_cResult, 32, "%s/%s", "AAAAAAAAAAAAAAAA", "evil.dll.bsp");
printf("%s\n", p_cResult); // Output: AAAAAAAAAAAAAAAA/evil.dll.bsp
// Long enough string to truncate the `.bsp` part
snprintf(p_cResult, 32, "%s/%s", "AAAAAAAAAAAAAAAAAAAAAA", "evil.dll.bsp");
printf("%s\n", p_cResult); // Output: AAAAAAAAAAAAAAAAAAAAAA/evil.dll
return 0;
}
This vulnerability was found through code analysis of the fast_dl
protocol, which has not changed much in recent years.
Bug 3: Arbitrary text file write in game directory
At this point, we were not sure how to combine the two previous bugs. Therefore, we searched the CS:GO binary for helpful privileged commands. With the con_logfile
command, we surprisingly discovered that this command could write arbitrary *.log
files to arbitrary game folders. Due to a similar extension stripping bug by snprintf
it was also possible to specify an arbitrary file extension and thus write text files with arbitrary contents and an arbitrary extension.
Specifically, this bug could be used to create a new configuration file cfg/leak.log
with arbitrary CS:GO commands. The leak.log
“config” file could then the loaded by the exec leak.log
command, reading the file from the cfg
folder.
Bug 4: Fallback to disabled signature checks
When starting the CS:GO client, the integrity of the game DLLs is verified via matching hash values. Only after this verification it is possible to play on official servers. If the DLL verification fails, a fallback to the insecure
mode occurs. This can also be achieved by the additional command line argument -insecure
. Only in this mode, additional DLLs not located in the bin/
game path can be loaded. If the attacker succeeds in making the DLL verification fail, they can create their own DLLs, refer to these DLLs in the configuration and achieve command execution. On Windows, an attacker can specify code that is executed when the DLL is loaded into a process. Thus, the attacker can execute arbitrary code on the client system.
Windows prevents the overwriting of DLLs, which are loaded in a running process. Therefore, we had to find a DLL that is verified at game start but is not loaded into the process. Fortunately, we found that the client.dll
had been replaced by the client_panorama.dll
and is therefore no longer loaded, but is still verified! Overwriting client.dll
with arbitrary text (bug 3) thus caused the verification to fail.
Full logic bug chain
The full bug chain uses all four bugs to:
- execute privileged commands on the client
- download a malicious DLL to the game directory
- replace the
gameinfo.txt
so that the malicious DLL is loaded on game startup - corrupt the
client.dll
to achieve a fallback to theinsecure
mode
To understand the following steps, we still need to introduce two elements typical for source engines: the gameinfo.txt
and CVars
:
Gameinfo.txt
All source engine based games are actually “add-ons” to the basic Half-Life game. Assets and DLLs for the game are loaded from a special path defined in the file gameinfo.txt
:
"GameInfo"
{
game "Counter-Strike: Global Offensive"
title "COUNTER-STRIKE'"
title2 "GO"
type multiplayer_only
[ ...]
FileSystem
{
SteamAppId 730 // This will mount all the GCFs we need (240=CS:S, 220=HL2).
ToolsAppId 211
SearchPaths
{
Game |gameinfo_path|/exploit // NOTE: Added by our exploit
Game |gameinfo_path|.
}
}
}
By setting |gameinfo_path|/exploit
as first in the FileSystem
array, the engine tries to load missing DLLs from this path. Only if the element to be loaded is not found there, the original game path is used. One DLL that is loaded at game start is matchmaking.dll
. This means that we can place a new matchmaking.dll
and invoke arbitrary code when the CS:GO client loads the DLL.
CVars
CVars
are a fundamental concept in SourceEngine games and appear everywhere. These variables control pretty much everything there is to set up in the game: paths, key-binds, the appearance of crosshairs, the game mode, etc. Also the legendary sv_cheats
variable, which many Counter Strike players probably have already heard of, is a CVar
. Depending on CVar
, the settings can also be set by the server and thus override local options.
Upon connecting, the client tells the server which local CVars
are set at the client, so that the server can react accordingly. For example, the server can kick the client if sv_cheats
is set to 1
at the client. As an attacker, we need to know the installation directory from the CS:GO client so that we can exploit bug 2
and bug 4
by taking a path that is just the right length. Unfortunately, by default, the client does not send along a CVar
that contains the current game directory. We therefore use a trick to set the new CVAR GAMEBIN
and have it sent back to the attacker-controlled server. The basic idea:
- Execute a “script”
leak.log
to set theCVar GAMEBIN
- Instruct the client to reconnect to the malicious server
- Upon reconnection, all
CVars
and set back to the malicious server
The details involve invoking the path
command from a config file to set the CVAR GAMEBIN
to the installation path of the game. We leverage the attacker-written config file leak.log
, which includes the path
command. The client has to execute the config file, otherwise the CVar
is not stored persistently during the next server connect. The leak.log
file is executed with the exec
command. Afterwards the malicous server instructs the client to reconnect. Upon reconnection, the CVar
is leaked back to the server.
Exploit flow
Component | Command | Result | Bug |
---|---|---|---|
→ | connect | Client connects to malicious, attacker controlled server | |
→ | m_nMaxClients = 1 | The server can now execute privileged commands on the client | Bug 1 |
→ | sv_downloadurl = | The client has fast_dl http downloads enabled to download missing assets | |
→ | con_logfile cfg/leak.log | The client executes the path command and stores the result in GAMEBIN | |
→ | reconnect | The client reconnects and sends all CVars to the server, leaking the GAMEBIN .
The server then creates the downloadtables with a precisely long filename size such that
the extension is stripped | Bug 2 |
→ | <fast_dl download code> | The client downloads the malicious exploit/bin/matchmaking.dll and
gameinfo.txt from the HTTP server | Bug 2 |
→ | con_logfile ././././[…]/bin/client.dll.log | The bin/client.dll is overwritten with a logfile entry (not a valid DLL anymore) | Bug 3 |
→ | crash | The client crashes. The user restarts the client. | |
<startup> | Invalid signature check for overwritten bin/client.dll . Fallback to insecure and load of overwritten gameinfo.txt | Bug 4 | |
<startup> | Search in SearchPaths for matchmaking.dll results in DLL found in
exploit/bin/matchmaking.dll . LoadLibraryA of malicious, attacker
controlled DLL and RCE |
Video
We provide a video of the above outlined chain of the four logic bugs (see below). If you stop the video at 00:29 seconds you can notice interesting output in the CS:GO console and in the exploit server:
- The leaked
GAMEBIN: f:\spiele\steam\steamapps\common\couter-strike global offensive\csgo\bin
is retrieved from the exploit server - The CS:GO console shows the very long downloaded files, which succeed for the
././[..]/bin/matchmaking.dll.stf
././[..]/gameinfo.txt.stf
files. As described above, the.stf
extension is stripped during the download, resulting in the download ofmatchmaking.dll
andgameinfo.txt
.
Closing Thoughts
Often people ask us how much time we spent on building this exploit chain. Unfortunately, we can not determine the total time spent. For weeks, we met on Discord in the evening to exchange ideas, programm together and analyze our findings until late in the morning. Alain at that time had roughly 250 hours of gameplay in CS:GO and had not played a single online match. We found the bugs “relatively” quickly, but for their bug bounty program, Valve requires a full-chain exploit demonstrating RCE impact. Without the elaborate demonstration, the research would have been completed after 30% of the time. Hence, we invested quite some time in our RCE demonstration.
Speaking of Valve: We became aware of Valve’s high payouts for CS:GO through various and simple looking HackerOne reports. The reports at the time only needed to demonstrate memory corruption to get the full payout. Our initial euphoria quickly sank after our three different reports were quickly declared valid, but still not fixed even after 13 months and multiple requests. After a lot of pressure and the threat of full disclosure, the bugs were finally fixed. The payout was 7.5k per bug, less than we expected. All in all a sobering experience.
For us the CS:GO bug bounty journey was the first time we invested weeks of time into a project together. The takeaways for us personally were mainly:
- Don’t look for cricitial bugs and quick wins only.
- Chain your bugs to unveal their full potential.
- Keep your eyes open for edge cases and things devs didn’t think about.
- Try harder! If run against a wall search for the hole and don’t give up early.
Timeline
Date | Action |
---|---|
01.03.2020 | We send the initial Report with PoC video and exploit setup |
01.03.2020 | H1 has troubles to reproduce the issue |
03.03.2020 | We provide an exploit Docker setup for easier reproducability |
06.03.2020 | H1 still has troubles to reproduce the issue |
21.03.2020 | We provide a full server setup with OpenVPN for even easier reproducability |
21.03.2020 | H1 successfully reproduces the issue(s) and marks the report as triaged |
01.06.2020 | We ask for an update |
03.06.2020 | H1 states they are still looking into the report |
18.09.2020 | We ask for an update, as a total of half a year has passed by |
22.10.2020 | We ask again for an update |
27.10.2020 | H1 states that Valve is still looking into the reports |
01.03.2021 | We say “Happy Anniversary” and ask for an update |
March 2021 | We contact other researchers who submitted bugs to Valve and think about complaining in our reports as collective |
22.04.2021 | We write a statement about our dissatisfaction with the process and “reserve the right to disclose the findings in the upcoming weeks” |
26.04.2021 | H1 states that they flagged the report to “internal managers” and try to speed up the process |
30.04.2021 | We notice that the issues have been fixed and ask for coordinated disclosure with Valve |
01.05.2021 | H1 says “Thanks for the report” and we receive our bounty |
29.03.2022 | We request report disclosure, no response so far |