Understanding the Source2 engine | Section 1
Prelude
Around September 2023, Valve released Counter-Strike:2 a successor to their popular FPS game Counter-Strike Global Offensive. With this came new visual updates, UI changes, and other various edits to the game. However, to many people’s disappointment, the game overall did not feel that different.
Under the hood, however, the game was now using Valve’s “new” engine Source 2. This engine had actually been used for Dota 2 since June 2015; however, it hadn’t gotten much attention until the release of CS:2. I decided to take a look at it to see what had changed from the original Source engine, and to my surprise, quite a lot was different. Since I knew that this engine was going to be used for future games, such as s&box, I knew it was worth looking into. That brings us to this writeup, in which I will be diving into how parts of the Source2 Engine work and how we can abuse certain features to produce some pretty interesting results.
Setup
For the purposes of this writeup, I will be using the CS:2 version of Source2, since it is quite easy to access and easy to disable any anti-cheat issues using the --insecure
flag.
In order to execute my code, I will be using a Dll which will be injected using LoadLibraryA due to its ease of use. Do note that this will be easily detectable by VAC. For more information on this: LoadLibrary Injection Dll Entry
If you are confused about any part of the project, a working version will be available on my GitHub.
Runtime Inspection
Inspecting the modules from cs2.exe
, there are many modules signed by Valve Corp.
. Looking at these modules, they all have a common export CreateInterface
If we inspect the disassembly at any of these exports, we see this
As you can see there is a pointer moved into the r9
register. To deduce the layout of this memory region, we can use a structure dissection tool such as ReClass.NET. Reading the pointer in ReClass shows a dissection of the memory region, the hex values, and any pointers / heap allocated memory regions. The first entry is shown as a pointer, which points to what looks like an interface entry. Looking at this interface entry address we see
The first entry is a code section, which is probably a callback returning the actual interface address. The second entry is an ASCII string pointer suggesting the interface name, and the third entry is a pointer to another Interface struct. This is clearly suggesting a linked list.
If we change the types of all of these entries in ReClass, we get this This is great, because we now have a linked list which we can use to iterate through each interface. Here’s what the first few iterations of the interface list look like in ReClass
Implementation
To implement these findings in the code, we can use GetModuleHandle alongside GetProcAddress to get the CreateInterface
export addresses. Then offset it by 3 bytes to end up at our pointer. Since we are working in x64 assembly, you need to account for RVA.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool Interfaces::Generate(const char* HostModule)
{
auto& Console = Console::Get();
const HMODULE Module = GetModuleHandleA(HostModule);
if(!Module)
return false;
const std::uintptr_t InterfaceExport = reinterpret_cast<std::uintptr_t>(GetProcAddress(Module, "CreateInterface"));
if(!InterfaceExport)
return false;
InterfaceEntry** InterfaceEntries = Utils::RVA<InterfaceEntry**>(reinterpret_cast<std::uint8_t*>(InterfaceExport), 3, 7);
for(InterfaceEntry* Entry = *InterfaceEntries; Entry; Entry = Entry->Next)
{
Console.Write("Interface -> %s", Entry->InterfaceName);
}
return true;
}
This will dump the interface list for any given module, in my case I have chosen client.dll
Usage
In order to make a useful implementation for this in any tool, caching is essential. This is because the interface addresses never actually change throughout the lifetime of the executable. So, if we cache each module and their corresponding interfaces, it can allow for great performance improvements, since the interface callback function will only be executed once. To implement this we can use std::map
In terms of map structure, here is what it looks like
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
"client.dll": {
"LegacyGameUI001": 0,
"Source2ClientUI001": 0,
"Source2ClientPrediction001": 0,
"ClientToolsInfo_001": 0,
"Source2Client002": 0,
"GameClientExports001": 0,
"EmptyWorldService001_Client": 0,
"Source2ClientConfig001": 0,
},
"engine2.dll": {
"SimpleEngineLoopService_001": 0,
"ClientServerEngineLoopService_001": 0,
"KeyValueCache001": 0,
"HostStateMgr001": 0,
"GameEventSystemServerV001": 0,
"GameEventSystemClientV001": 0,
"EngineServiceMgr001": 0,
"VProfService_001": 0,
"ToolService_001": 0,
"StatsService_001": 0,
"SplitScreenService_001": 0,
"SoundService_001": 0,
"ScreenshotService001": 0,
"RenderService_001": 0,
"NetworkService_001": 0,
"NetworkServerService_001": 0,
"NetworkP2PService_001": 0,
"NetworkClientService_001": 0,
"MapListService_001": 0,
"InputService_001": 0,
"GameUIService_001": 0,
"GameResourceServiceServerV001": 0,
"GameResourceServiceClientV001": 0,
"BugService001": 0,
"BenchmarkService001": 0,
"VENGINE_GAMEUIFUNCS_VERSION005": 0,
"EngineGameUI001": 0,
"INETSUPPORT_001": 0,
"Source2EngineToServerStringTable001": 0,
"Source2EngineToServer001": 0,
"Source2EngineToClientStringTable001": 0,
"Source2EngineToClient001": 0,
},
}
In which the corresponding memory addresses would be fetched from each interface’s callback.
Implementing this in our code is pretty simple
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
InterfaceMap Interfaces::Generate()
{
const auto& Modules = Utils::FetchModules();
for(const auto& Module : Modules)
{
const InterfaceEntries& Entries = Generate(Module.c_str());
if(Entries.size())
{
this->m_Interfaces[Module] = Entries;
}
}
return this->m_Interfaces;
}
InterfaceEntries Interfaces::Generate(const char* HostModule) const
{
auto& Console = Console::Get();
InterfaceEntries Entries = {};
const HMODULE Module = GetModuleHandleA(HostModule);
if(!Module)
return Entries;
const std::uintptr_t InterfaceExport = reinterpret_cast<std::uintptr_t>(GetProcAddress(Module, "CreateInterface"));
if(!InterfaceExport)
return Entries;
InterfaceEntry** InterfaceEntries = Utils::RVA<InterfaceEntry**>(reinterpret_cast<std::uint8_t*>(InterfaceExport), 3, 7);
for(InterfaceEntry* Entry = *InterfaceEntries; Entry; Entry = Entry->Next)
{
const std::uintptr_t Interface = Entry->InterfaceCallback();
Entries[Entry->InterfaceName] = Interface;
Console.Write("%s -> %s ~ %p", HostModule, Entry->InterfaceName, Interface);
}
return Entries;
}
We now have an overloaded Generate
function which maps every interface to its address, while also indexing the module in which the interface lies. Testing this in-game shows a dump of all the interfaces with their addresses.
What’s also great is that when attaching a debugger to the game to view our interface map, we see a map containing all modules and their corresponding interfaces.
From this, we can now fetch any interface from its name and its corresponding module without ever having to call an engine function. This greatly improves performance since the interfaces only need to be cached in our map once. Finally, lets implement this function in our Interfaces
class.
1
2
3
4
5
6
7
8
9
10
template<typename T>
T* GetInterface(const char* Module, const char* Interface)
{
const auto& Interfaces = this->m_Interfaces.at(Module);
const auto& Entry = Interfaces.find(Interface);
if(Entry == Interfaces.end())
return nullptr;
return reinterpret_cast<T*>(Entry->second);
}
This fully completes our Interfaces
class. Let’s move onto analysis.
If you have any questions or queries, feel free to contact me on discord just.cabbage
!