Lead researcher Tom Kort from Context, an information security company, talks about how he managed to detect a potentially dangerous bug in Steam client code.Carefully monitoring security PC players noticed that Valve recently released a new update to the Steam client.
In this post, I want to
justify myself
for playing games at work and telling the story of the associated bug that existed in the Steam client for at least ten years, which until July last year could lead to remote code execution (RCE) in all 15 million active clients.
Since July, when Valve (finally) compiled its code with the included modern exploit defenses, it could only lead to client failure, and RCE was possible only in combination with a separate information leakage vulnerability.
We announced Valve about the vulnerability on February 20, 2018, and, to the credit of the company, it eliminated it in the beta branch less than 12 hours later. The fix was transferred to a stable branch on March 22, 2018.
Short review
The basis of the vulnerability was damage to the heap inside the Steam client library, which could be called remotely, in the part of the code that was engaged in restoring a fragmented datagram from several received UDP packets.
The Steam client performs data exchange through its own protocol (Steam protocol), which is implemented over UDP. There are two areas in this protocol that are particularly interesting due to the vulnerability:
- Packet length
- Total Length of Restored Datagram
The error was caused by the absence of a simple check. The code did not verify that the length of the first fragmented datagram is less than or equal to the total length of the datagram. This seems to be a common oversight, given that for all subsequent packets that transmit fragments of the datagram, the check is performed.
Without additional data leakage bugs, heap corruption on modern operating systems is very difficult to control, so remote code execution is difficult to implement. However, in this case, thanks to Steam's own memory allocator and the ASLR that was not in the binary steamclient.dll (before last July), this bug could be used as the basis for a very reliable exploit.
Below is a technical description of the vulnerability and its associated exploit until
implement code execution.
Vulnerability Details
The information you need to understand
Protocol
Third parties (for example
https://imfreedom.org/wiki/Steam_Friends ) based on an analysis of the traffic generated by the Steam client, performed reverse engineering and created detailed documentation of the Steam protocol. The protocol was initially documented in 2008 and has not changed much since then.
The protocol is implemented as a transmission protocol with a connection established over a UDP datagram stream. Packages, in accordance with the documentation for the link above, have the following structure:
Important aspects:
- All packets start with 4 bytes " VS01 "
- packet_len describes the length of the payload (for non-fragmented datagrams, the value is equal to the data length)
- type describes the type of the package, which can have the following values:
- 0x2 Authentication Call
- 0x4 Accept Connection
- 0x5 Reset Connection
- 0x6 A packet is a fragment of a datagram.
- 0x7 Package is a separate datagram
- The source and destination fields are identifiers that are assigned to correctly route packets across multiple connections within the Steam client.
- In case the packet is a fragment of the datagram:
- split_count denotes the number of fragments into which the datagram is split.
- data_len stands for the total length of the recovered datagram.
- The initial processing of these UDP packets occurs in the CUDPConnection :: UDPRecvPkt function inside steamclient.dll
Encryption
The useful information of the datagram packet is encrypted with AES-256 using a key negotiated between the client and the server in each session. Key reconciliation is performed as follows:
- The client generates a 32-byte random AES key, and RSA encrypts it with the Valve public key before sending it to the server.
- The server, having a private key, can decrypt this value and accept it as the AES-256 key that will be used in the session.
- After the key is negotiated, all useful information in the current session is encrypted with this key.
Vulnerability
Vulnerability is present inside the
CvDPConnection class
RecvFragment method . In the release version of the steamclient library, the characters are missing, however, when searching through the binary strings in the function of interest, we find a link to "
CUDPConnection :: RecvFragment ". Entry to this function is performed when a client receives a UDP packet containing a Steam datagram of type 0x6 (“datagram fragment”).
1. The function begins by checking the connection status to make sure that it is in the "
Connected " state.
2. Then the
data_len field in the Steam datagram is checked to ensure that it contains less than
0x20000060 bytes (it looks like this value is chosen arbitrarily).
3. If the test is passed, the function checks whether the connection collects fragments of a datagram, or is it the first packet of the stream.
4. If this is the first packet in the stream, then the
split_count field is then checked to find out how many packets this stream will stretch.
5. If the stream is divided into several packets, then the
seq_no_of_first_pkt field is
checked to ensure that it matches the sequence number of the current packet. This ensures that the packet is first in the stream.
6. The
data_len field is
checked again against the limit of
0x20000060 bytes. It also checks that
split_count is less than
0x709b packets.
7. If these conditions are met, then a boolean value is specified, indicating that we are now collecting fragments. It also checks that we do not yet have a buffer allocated for storing fragments.
8. If the pointer to the fragment collection buffer is not zero, then the current fragment collection buffer is freed and a new buffer is allocated (see the yellow rectangle in the figure below). This is where the error occurs. It is expected that the fragment collection buffer is allocated in the size of
data_len bytes. If everything is completed successfully (and the code does not perform the check — a minor error), then the useful information of the datagram is copied into this buffer using a
memmove , trusting that the number of bytes to be copied is indicated in the
packet_len .
The most important developer's oversight was that the " packet_len is less than or equal to data_len " check is not performed. This means that it is possible to transfer data_len less than packet_len and have up to 64 KB of data (due to the fact that the packet_len field is 2 bytes wide) copied into a very small buffer, which leads to the possibility of heap damage exploit.Exploitation of Vulnerability
This section assumes that there is an ASLR bypass method. This leads to the fact that before the start of operation, the starting address of the steamclient.dll is known.
Packet Spoofing
In order for an attacker’s UDP packets to be received by the client, it must examine the outgoing (client -> server) datagram, which is sent in order to know the client / server connection identifiers, as well as the sequence number. Then, the attacker must spoof the IP addresses and source / destination ports along with the client / server IDs and increment the learned sequence number by one.
Memory management
To allocate more than 1024 (0x400) bytes of memory, a standard system allocator is used. To allocate memory less than or equal to 1024 bytes, Steam uses its own allocator that works the same on all supported platforms. This article will not discuss in detail this distributor, except for the following key aspects:
- From the system distributor, large blocks of memory are requested, which are then divided into fragments of a fixed size for use under requests for the allocation of memory by the Steam client.
- The selection is performed sequentially, there is no metadata between the fragments used.
- Each large block stores its own list of free memory, implemented as a single-linked list.
- The top of the free memory list points to the first free fragment in memory, and the first 4 bytes of this fragment point to the next free fragment (if it exists).
Memory allocation
When allocating memory, the first free block is detached from the top of the free memory list, and the first 4 bytes of this block, corresponding to
next_free_block , are copied to the
freelist_head member
variable inside the distributor class.
Memory release
When a block is freed, the
freelist_head field
is copied to the first 4 bytes of the block to be released (
next_free_block ), and the address of the block to be released is copied to the member variable
freelist_head of the distributor class.
How to get a primitive record
A heap buffer overflow occurs in the heap, and depending on the size of the corrupted packets, memory allocation can be controlled either by a standard Windows allocator (when allocating more than 0x400 bytes of memory) or its own Steam distributor (when allocating less than 0x400 bytes). Due to the lack of security measures in my own Steam distributor, I decided that it was easier for an exploit to use it.
Let us return to the section on memory management: it is known that the top of the free list of blocks of a given size is stored as a member variable of the distributor class, and a pointer to the next free block in the list is stored as the first 4 bytes of each free block in the list.
If there is a free block next to the block in which the overflow occurred, damage to the heap allows us to overwrite the
next_free_block pointer. If we consider that the heap can be prepared for this, then the rewritten
next_free_block pointer can be set to the address for recording, after which the subsequent memory allocation will be written to this place.
What to use: datagrams or fragments
A memory corruption error occurs in the code responsible for processing fragments of datagrams (type 6 packets). After the occurrence of damage, the
RecvFragment () function is in a state in which it waits to receive further fragments. However, if they arrive, then a check is performed:
fragment_size + num_bytes_already_received < sizeof(collection_buffer)
But it is obvious that this is not the case, because our first packet has already violated this rule (the existence of an error may skip this check) and an error will occur. To avoid this, you need to avoid the
CUDPConnection :: RecvFragment () method after memory
corruption .
Fortunately,
CUDPConnection :: RecvDatagram () can still receive and process type 7 packets (datagrams) being
sent , while
RecvFragment () is not in effect, and this can be used to start the record primitive.
Encryption issues
The packets received by
RecvDatagram () and
RecvFragment () are expected to be encrypted. In the case of
RecvDatagram (), decryption is performed almost immediately after receiving. In the case of
RecvFragment (), it occurs after receiving the last fragment in the session.
There is a problem of exploiting the vulnerability, because we do not know the encryption key that is created in each session. This means that any OP code / shell code we send will be “decrypted” using AES256, which will turn our data into garbage. Therefore, it is necessary to find a way of exploitation, possible almost immediately after receiving a packet, before the decryption procedure will be able to process the useful information contained in the packet buffer.
How to achieve code execution
Considering the decryption limitation described above, the operation must be performed before the decryption of the incoming data. This imposes additional restrictions, but the task is still executable: you can overwrite the pointer so that it points to the
CWorkThreadPool object stored in a predictable place inside the data section of the binary file. Although the details and the internal functionality of this class are unknown, it is possible to assume from its name that it supports a pool of threads that can be used when it is necessary to perform “work”. After examining several debug strings in a binary file, one can understand that among such jobs there is encryption and decryption (
CWorkItemNetFilterEncrypt ,
CWorkItemNetFilterDecrypt ), so when these tasks are queued, the
CWorkThreadPool class is
used . By rewriting this pointer and writing the right place to it, we will be able to simulate the vtable pointer and its associated vtable, which allows us to execute code, for example, when calling
CWorkThreadPool :: AddWorkItem () , which always takes place before any decryption process.
The figure below shows the successful exploitation of the vulnerability up to the stage of gaining control over the EIP register.
From this point on, you can create a chain of ROP, leading to the execution of arbitrary code. The video below shows how an attacker remotely launches a Windows calculator in a fully patched version of Windows 10.
Summing up
If you get to this part of the article, thank you for your perseverance! I hope it became clear to you that this is a very simple bug that was fairly easy to exploit due to the lack of modern means of protection against exploits. The vulnerable code was probably very old, but otherwise it worked well, so the developers did not see the need to examine it or update the build scripts. The lesson here is that it is important for developers to periodically review old code and build systems to ensure that they meet modern security standards, even if the code functionality itself remains unchanged. It was amazing to find in 2018 such a simple bug that has such serious consequences on a very popular software platform. This should be an incentive to search for such vulnerabilities for all researchers!
Finally, it’s worth talking about the process of responsible disclosure. We reported this bug to Valve in a letter to its security service (
security@valvesoftware.com ) at about 4 pm GMT and just 8 hours later, a fix was created and launched into the beta thread of the Steam client. Thanks to this, Valve is now in first place in our (imaginary) table of the contest “Who will fix the vulnerability faster” - a pleasant exception compared to revealing the mistakes of other companies, which often often results in a lengthy approval process.
A page that describes the details of all client updates.