Fuzzing CS:GO BSP Files

We fuzzed BSP map files for Counter Strike: Global Offensive leading to the discovery of a stack-based buffer overflow. Through some reverse engineering and source code analysis, we discovered that the vulnerability can lead to remote code execution.

Fuzzing CS:GO BSP Files

In collaboration with Digital Cold ( @digital_cold) we fuzzed BSP map files for Counter Strike: Global Offensive leading to the discovery of a stack-based buffer overflow. We used the Basic Fuzzing Framework (BFF) to instrument and fuzz the game process and to perform initial crash triage with !exploitable. One of the many crashes we discovered was particularly exploitable. BFF generated a malformed .BSP file that triggered a Data Execution Prevention (DEP) exception in the csgo.exe process, meaning the instruction pointer of the process had been corrupted to execute a non-executable memory region. Through some reverse engineering and source code analysis, we discovered that the vulnerability corrupts an arbitrary amount of the stack based off a length field. By carefully controlling the amount of corruption and the corrupting data, we could overflow an on-stack vtable pointer. After being corrupted, the vtable can be redirected to achieve arbitrary code execution. This vulnerability is also remotely exploitable given that a game server could host a malicious map file and deliver it to a remote client.

From Crash to Code

Let's dig into the crash and show how we were able to triage from start to finish. We started from this following crash output:

---- Host_NewGame ----
(9738.99ac): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
** ERROR: Symbol file could not be found.  Defaulted to export symbols for filesystem_stdio.dll - 
18ff191b ??              ???
0:000:x86> !analyze -v
...
BUGCHECK_STR:  APPLICATION_FAULT_SOFTWARE_NX_FAULT_INVALID_POINTER_EXECUTE

PRIMARY_PROBLEM_CLASS:  APPLICATION_FAULT

ADDITIONAL_DEBUG_TEXT:  Followup set based on attribute [Is_ChosenCrashFollowupThread] from Frame:[0] on thread:[PSEUDO_THREAD]

LAST_CONTROL_TRANSFER:  from 00000000 to 00000000

STACK_TEXT:  
WARNING: Frame IP not in any known module. Following frames may be wrong.
005bcd14 6612090a 0000002e 00000000 005bcfb4 0x18ff191b
005bcd30 6611580f 005bcf64 6617ee10 66167b98 filesystem_stdio+0x1090a
005bd0d4 19ff2521 1aff1e1b 14ff1f1d 23ff1816 filesystem_stdio+0x580f
005bd118 1eff2925 1aff2521 1eff201d 16ff2421 0x19ff2521
005bd11c 1aff2521 1eff201d 16ff2421 17ff1a18 0x1eff2925
005bd120 1eff201d 16ff2421 17ff1a18 15ff1b19 0x1aff2521
...

Notice that 0x18ff191b is not in an executable region (hence the BUGCHECK_STR mentioning NX_FAULT). This indicates we have likely overflown some control flow structure, be that a saved return address or function pointer.

With this crash likely due to stack corruption, the previous stack frames may not be reliable. In fact, visiting filesystem_stdio+0x1090a in IDA leads no where. Luckily, visiting filesystem_stdio+0x580f leads to a real code location:

IDA Stack frame reference

It appears that our crash is occurring in the sub_10010950 function. Great, now we have a crash location! Let's save some reverse engineering and try to correlate this assembly to source code. Further down in the function containing 0x580f, we find these string references:

IDA String Reference

Searching for one the "pack file" string on GitHub leads to the CZipPackFile::Prepare function in the Source engine. Reading this function and matching it to the IDA graph, we start to see some indications of a lack of bounds checking:

bool CZipPackFile::Prepare( int64 fileLen, int64 nFileOfs )
{
    ...
    // Parse out central directory and determine absolute file positions of data.
    // Supports uncompressed zip files, with or without preload sections
    bool bSuccess = true;
    char tmpString[MAX_PATH];		
    CZipPackFile::CPackFileEntry lookup;

    m_PackFiles.EnsureCapacity( numFilesInZip );

    for ( int i = firstFileIdx; i < numFilesInZip; ++i )
    {
	zipDirBuff.GetObjects( &zipFileHeader );
	if ( zipFileHeader.signature != PKID( 1, 2 ) || zipFileHeader.compressionMethod != 0 )
	{
		Msg( "Incompatible pack file detected! %s\n", ( zipFileHeader.compressionMethod != 0 ) ? " File is compressed" : "" );
		bSuccess = false;
		break;	
	}

	Assert( zipFileHeader.fileNameLength < sizeof( tmpString ) );
	zipDirBuff.Get( (void *)tmpString, zipFileHeader.fileNameLength );
	tmpString[zipFileHeader.fileNameLength] = '\0';
	Q_FixSlashes( tmpString );

But wait, the zipFileHeader.fileNameLength is bounds checked with the Assert statement! How are we getting a crash? Well the src/public/tier0/dbg.h file reveals the answer:

// Assert macros
// Assert is used to detect an important but survivable error.
// It's only turned on when DBGFLAG_ASSERT is true.

#ifdef DBGFLAG_ASSERT

#define  Assert( _exp )  _AssertMsg( _exp, _T("Assertion Failed: ") _T(#_exp), ((void)0), false )

It so happens that DBGFLAG_ASSERT is not defined at compile time. Therefore, this assert is not present in release builds and fails to prevent a buffer overflow.

For the triage we performed above, Valve awarded us a bonus of $2,500. Source code greatly helped us with the root-cause analysis and saved hours of reverse engineering. Additionally, the interesting story of a compiled out Assert could have never been told without it.

Vulnerability Timeline

  • May 2018 -- Crash found
  • May 12th, 2018 -- Reported to Valve
  • July 10th, 2018 -- Issue marked as resolved
  • July 19th, 2018 -- Public disclosure

To see the entire transcript check out the HackerOne disclosure.

The fix took roughly two months from the initial report to the public fix. This is a great turnaround time and further demonstrates Valve's commitment to fixing bugs in their core properties. For our efforts, Valve awarded us with a generous $12,500 bounty. Payouts like this motivate us to dig deeper to find more high impact security vulnerabilities.

Moving Forward

The attack surface on Gold Source and Source game engines is vast. We've only scratched the surface and plan to focus more on the network aspects of the games. Given the current mitigations (DEP + ASLR) it's difficult to create a working exploit for one-shot file-format exploits. A more interactive exploitation platform, such as a custom server, would allow us to perform multi-stage exploits incorporating an information leak to break ASLR.

If this peaked your interest, keep up-to-date with the latest discussion and research from path.network on our Telegram.