As many have noticed, my focus on this blog heavily involves Linux. However, I haven’t found much content related to Linux and gaming. Whenever I have some free time, I enjoy playing a bit of CS:GO (Counter-Strike: Global Offensive) and decided to take a look at how the game works under the hood. This article aims to reconstruct the CS:GO VTable, or at least a small part of the CS:GO client VTable. To follow along with this article, I recommend having knowledge of the C++ language and some familiarity with using the IDA tool.
To proceed with the article, you will need to have CS:GO installed on your machine to analyze the binary alongside me. If you cannot install CS:GO on your machine, I will provide this repository with the shared libraries used by CS:GO that we will analyze in this article.
To reconstruct the CS:GO VTable, I will use the SDK provided by Valve itself. I will utilize the GitHub repository called “source-sdk-2013” available at https://github.com/ValveSoftware/source-sdk-2013.
Content topics
- Content topics
- Analyzing the csgo_linux64 Binary
- Analyzing the client_client Binary
- Hooking the CreateMove Function
- Running CS:GO
- Conclusion
Analyzing the csgo_linux64 Binary
Let’s start by analyzing the main CS:GO executable, located at ~/.steam/steam/steamapps/common/Counter-Strike Global Offensive/csgo_linux64
. We will use IDA for this analysis.
After loading our binary into IDA, I recommend adjusting the settings as follows:
-
Go to
Options -> General -> Disassembly
:- Check the
[x] Functions Offset
option. - Check the
[x] Auto Comments
option. - Set the number of opcode bytes (graphical) to
16
.
- Check the
Now, let’s begin analyzing the binary starting from main
, our main function. We can notice that the code is relatively small, but it contains an exported function called dlopen
, which is used to obtain a handle to a shared library, load it into memory, and use it. We can check which library it is passing as a parameter and which function it is using by employing dlsym
to retrieve the symbol and then calling the function.
It is passing the path bin/linux64/launcher_client.so
as a parameter to dlopen
and then making a call to the LauncherMain
function. Essentially, this acts as a loader. By analyzing the code, we can identify this as a potential entry point.
Let’s create a shared library so we can load our own function instead of the original one, using the C++ language.
compile: g++ -shared -fpic launcher_client.cpp -o launcher_client.so
launcher_client.cpp:
#include <iostream>
extern "C"
{
void LauncherMain(int argc, const char **argv)
{
std::cout << "[*] Loading LauncherClient.so" << std::endl;
}
}
Great! Now that we have the library with the LauncherMain
function, let’s move the generated library to bin/linux64/launcher_client.so
. Then, we can run the game via the command line.
execute: mv launcher_client.so bin/linux64/launcher_client.so
To run CS:GO via the command line, simply execute:
execute: ~/.local/share/Steam/ubuntu12_32/steam-runtime/run.sh ~/.steam/steam/steamapps/common/Counter-Strike\ Global\ Offensive/csgo.sh
output:
[*] Loading LauncherClient.so
Great, now that we’re inside the code, we have our entry point to proceed with the necessary changes or customizations. We can continue analyzing and modifying the code as needed.
Let’s start by making some changes to the code, loading the LauncherMain
function from the original library.
compile: g++ -shared -fpic launcher_client.cpp -o launcher_client.so -ldl
launcher_client.cpp:
#include <dlfcn.h>
#include <iostream>
extern "C"
{
void *dl = dlopen("<BACKUP_TO_LAUNCHER>", RTLD_NOW); // Opening the dynamic library and loading it using the second parameter `RTLD_NOW`
if (dl)
{
void *LauncherMain = dlsym(dl, "LauncherMain"); // Obtaining the original symbol from LauncherClient
if (LauncherMain)
{
LauncherMain_o = reinterpret_cast<void (*)(int argc, const char **argv)>(LauncherMain); // Performing a reinterpret_cast on the symbol to match the function signature
LauncherMain_o(argc, argv); // Calling the original function
}
dlclose(dl); // Closing the library after the process is complete
}
}
Don’t forget to provide the path to the backup of the original launcher_client
library in <BACKUP_TO_LAUNCHER>
.
execute: ~/.local/share/Steam/ubuntu12_32/steam-runtime/run.sh ~/.steam/steam/steamapps/common/Counter-Strike\ Global\ Offensive/csgo.sh -steam
output:
[*] Loading LauncherClient.so
SDL video target is ‘x11’
This system supports the OpenGL extension GL_EXT_framebuffer_object.
This system supports the OpenGL extension GL_EXT_framebuffer_blit.
This system supports the OpenGL extension GL_EXT_framebuffer_multisample.
This system DOES NOT support the OpenGL extension GL_APPLE_fence.
This system DOES NOT support the OpenGL extension GL_NV_fence.
This system supports the OpenGL extension GL_ARB_sync.
This system supports the OpenGL extension GL_EXT_draw_buffers2.
This system DOES NOT support the OpenGL extension GL_EXT_bindable_uniform.
This system DOES NOT support the OpenGL extension GL_APPLE_flush_buffer_range.
This system supports the OpenGL extension GL_ARB_map_buffer_range.
This system supports the OpenGL extension GL_ARB_vertex_buffer_object.
This system supports the OpenGL extension GL_ARB_occlusion_query.
This system DOES NOT support the OpenGL extension GL_APPLE_texture_range.
This system DOES NOT support the OpenGL extension GL_APPLE_client_storage.
This system DOES NOT support the OpenGL extension GL_ARB_uniform_buffer.
This system supports the OpenGL extension GL_ARB_vertex_array_bgra.
This system supports the OpenGL extension GL_EXT_vertex_array_bgra.
<…>
After loading our binary into IDA, search for a text string.
- Go to
Search -> Text...
.- Search for
-steam
. - Use the
F5
key to generate pseudocode to aid understanding.
- Search for
Analyzing the client_client Binary
Now, our dynamic library is loading the original library and invoking the genuine CS:GO LauncherMain
function. Great! Now CS:GO is running. Let’s start analyzing the client, located at ~/.steam/steam/steamapps/common/Counter-Strike Global Offensive/csgo/bin/linux64/client_client.so
. Our goal is to reconstruct part of a class used by CS:GO. Load it into IDA, and let’s begin the analysis.
ClientModeShared Class
Let’s restructure the ClientModeShared
class, located in the “client” folder of the SDK. In the code, we can observe that the class has two interfaces/inheritances called IClientMode
and CGameEventListener
. These interfaces play an important role in the class’s functionality.
Let’s analyze the string table generated by IDA to gain more insights into the code.
- Go to
View -> Open subviews -> Strings
:- Use the key combination
CTRL+F
or go to the search option in the menu. - Search for
ClientModeShared
in the search box.
- Use the key combination
Great! We found a reference to the class in the .rodata
section. This section typically contains read-only data that contributes to a non-writable segment in the process image. Now, let’s follow these steps to explore this reference in more detail:
- Left-click on the reference found in the section.
- In the dropdown menu, look for and select the option “List cross references to…”.
By following these steps, you can explore the cross-references to the class found in the .rodata
section.
Great, we found the class! Now, to facilitate analysis and ensure better understanding in the future, let’s rename the offset referring to the class. To do this, follow these simple steps:
- Left-click on the offset.
- In the dropdown menu, click on the first option
rename
. - Rename it to
ClientModeShared
.
Great, let’s start analyzing the functions related to the class to rename them according to the name found in the SDK. One of the functions we found is called CreateMove
and belongs to the IClientMode
class. Let’s check the source in the SDK iclientmode.
Let’s look for references within the CreateMove
function to identify it in our virtual function table (vtable) found during the IDA analysis. One thing I often look for in an analysis is strings, as they greatly help in identifying functions. Let’s take a look at the implementation of the CreateMove
function. We found it in the clientmode_shared.cpp file.
By analyzing the code, we can see that it performs a check to obtain the local player. If this check is successful, the CreateMove
function is called from the object associated with the local player. It’s worth mentioning that this CreateMove
function of the pPlayer
object refers to the function in the C_BasePlayer
class.
Notice that we don’t have strings referring to the function we want to find in IDA. However, below the CreateMove
function, we found a function called LevelInit
. Let’s take a look.
We can find the string LevelInit
in our IDA’s Strings
table, and game_newmap
is the mapname
.
So, let’s now explore the cross-references of this string.
We landed directly on the LevelInit
function. To confirm if this is indeed the function we’re looking for, we can examine other strings and even analyze the parameters identified by IDA.
A note: the function takes two parameters. The first refers to this
, and the second refers to the newmap
parameter. We can reconstruct the function by pressing the F5
key and then renaming the parameters and the function name.
Now that we have a “better” version of our constructed function, by analyzing the SDK code, it’s clear that the CreateMove
function is positioned above our LevelInit
function. Therefore, logically, we can infer that the CreateMove
function is located at this point.
Let’s analyze the function to confirm it is indeed the CreateMove
function.
Indeed, there is a strong similarity between our CreateMove
function and the function reconstructed by IDA. I’ve already updated the variable names and renamed the function to reflect these changes. Now everything is properly adjusted.
This way, we can fully reconstruct our ClientModeShared
class vtable.
CHLClient Class
Now, shall we hook the CreateMove
function? We need to find an instance of the IClientMode
class object to perform the hook and redirect execution to our custom function. To give you a complete understanding of what we’re going to do, I’ll provide a video tutorial from Guided Hacking
that illustrates the process step by step. You’ll find the video at the end of the article to help you follow along and better understand the steps involved.
In the CHLClient
class, we have a method called HudProcessInput
, which is derived from the IBaseClientDLL
interface.
Using a more recent SDK I found on GitLab, the HudProcessInput
function has access to an instantiated pointer called g_pClientMode
, but it can only be accessed by calling the GetClientMode()
function. Let’s use the same strategy as before to reconstruct the vtable and determine precisely which index our function is located at in the class.
When we find our vtable, the image summarizes exactly what we did at the beginning of the article when analyzing the ClientModeShared
vtable.
Now we found the function we want to analyze, located above the HudUpdate
method.
We can observe that the HudProcessInput
function is calling the mentioned method, GetClientMode()
, and the return value is being stored in the rax
register. Then, the value in memory pointed to by rax
is being “dereferenced” and stored in the rdx
register via the mov rdx, [rax]
instruction. The vtable method is called at index 13
, but why rdx+68h
? We are analyzing a 64-bit binary, and the indices of the pointer array in our vtable advance by 8 bytes. To determine the index value, simply calculate 0x68/8 = 13
, thus determining the index it is accessing in the vtable.
That said, in my repository, in the Start
function, in summary, I obtain the pointer to the CHLClient
class and then dereference this pointer to access our vtable. Next, I navigate to the offset of the HudProcessInput
function, which is located at index 10
.
1: In summary, I’m obtaining the pointer to the interface that CS:GO creates using a macro
EXPOSE_SINGLE_INTERFACE_GLOBALVAR( CHLClient, IBaseClientDLL, "VClient018", gHLClient );
.2: I’m using pointer arithmetic to access the vtable of our
VClient018
interface.3: Accessing index
10
in the vtable, which contains the pointer to theHudProcessInput
function.4: Adjusting the page permissions to allow writing, including the
HudProcessInput
function in the code.5: Adding a function to handle the SIGTRAP interrupt.
6: Writing an interrupt at the end of the
HudProcessInput
function so our thread can obtain the value ofrax
through the CS:GO thread context.
Summary of How Interfaces Work
I’ll provide a summary of the code I wrote to obtain the pointer to the CHLClient
class. In the CS:GO SDK, there’s a source file called interface.h
, which contains a class called InterfaceReg
. This class includes a pointer to a method called CreateInterfaceFn
, accessible by retrieving the CreateInterface
symbol. Looking at the CreateInterface
code, it calls another function called CreateInterfaceInternal
. Let’s check what it does exactly:
Notice that it returns an m_CreateFn
if the interface exists, meaning we have an object pointing to the interface passed as the pName
parameter. How can we identify these interfaces? Simply check the macro called EXPOSE_SINGLE_INTERFACE_GLOBALVAR
, which contains the class and the interface name that points to the class. This macro is responsible for establishing the relationship between the class and the interface, thus enabling access to and use of the functionalities provided by the interface. Example: EXPOSE_SINGLE_INTERFACE_GLOBALVAR( CHLClient, IBaseClientDLL, "VClient018", gHLClient );
. In summary, my code is searching for these interfaces.
Summary of How Breakpoint Injection Works
I’ll explain how I obtained the value of rax
, pointing to IClientMode -> ClientModeShared
. My idea was to add a breakpoint using the INT3
(CC
) opcode, thus forcing a SIGTRAP
and then handling it through my own thread initialized alongside CS:GO. Then, by capturing the entire context of the thread that triggered the SIGTRAP
, I can obtain the value of rax
and other registers, though that’s not our focus here.
The offset I chose to write our breakpoint in memory was precisely the last two bytes of the HudProcessInput
method.
The function responsible for collecting information from the thread that triggered the SIGTRAP
.
We can observe the collection of the context of the thread that generated the SIGTRAP
signal and then obtain the value of the rax
register. This allows using pointer arithmetic to obtain the ClientSharedMode
vtable. Next, it’s necessary to rewrite the original bytes that were overwritten to place the breakpoint.
Hooking the CreateMove Function
Now that we have our ClientSharedMode
vtable, let’s hook the CreateMove
function.
1: We have our signature generated by IDA to hook the function.
2: I’m storing the real offset of the CreateMove function in a function pointer, so we can call the real function within our hook function.
3: Our function responsible for the hook simply logs and then calls the real function.
Running CS:GO
Let’s start running CS:GO to collect the information obtained during the static analysis and verify the effectiveness of our hook…
Excellent! Everything worked perfectly! I’ll make the code available in my repository GHInterfacesCSGO
.
Conclusion
I plan to continue analyzing and improving the code from our analysis in this article. To follow these analyses, you can subscribe to receive notifications about new posts on the remoob blog.