Hacking ETPro qagame for fun and (no) profit
Few weeks back, I came with an idea of extending ETPro's voting capabilities. We lived under an impression that it's not possible without giving it too much thought. Recently, I spent quite some time reading ET source code and I realised that the whole voting mechanism isn't special from anything else the game does.
- Client sends
callvote
, so we can intercept that. - Server then updates config strings, namely
CS_VOTE_TIME
,CS_VOTE_STRING
and few others. - Client, based on what's in the config strings displays the voting interface.
- Depending on what button player presses, client sends
vote yes
orvote no
. Again, we can intercept that.
ETPro's Lua implementation not only offers altering the actual config strings, it can also send server commands to individual clients, one of which is cs
- yes, we can easily set config strings per client. That's all you need for implementing team-specific votes (or, more generally, votes that only some can see).
Vote++ was born, deployed and we were happy (or at least I was), because we could implement our own voting handlers, like kick-vote that results in a PunkBuster kick (ensuring the player can't return instantly by changing his net_port
) or PUTSPEC
. But then one day, x0rnn (who helped me a lot with testing the silly thing) came and forwarded me a message from Liquid (who apparently does bug hunting for living) pointing out that the yellow voting interface doesn't reflect the fact it was casted (after receiving the vote
command).
So this is what you could see after calling a vote:
And this is what you were supposed to see:
Well, this isn't a big deal as it's only an interface problem (actual vote
was reflected in the result), but I was annoyed that:
- I didn't notice this at all.
- I couldn't really fix it.
Why couldn't I fix it, you ask?
Let's take a look on how client decides what to render (cg_draw.c
):
if ( !( cg.snap->ps.eFlags & EF_VOTED ) ) {
s = va( CG_TranslateString( "VOTE(%i): %s" ), sec, cgs.voteString );
CG_DrawStringExt( 8, 200, s, color, qtrue, qtrue, TINYCHAR_WIDTH, TINYCHAR_HEIGHT, 80 );
if ( cgs.clientinfo[cg.clientNum].team != TEAM_AXIS && cgs.clientinfo[cg.clientNum].team != TEAM_ALLIES ) {
s = CG_TranslateString( "Cannot vote as Spectator" );
} else {
s = va( CG_TranslateString( "YES(%s):%i, NO(%s):%i" ), str1, cgs.voteYes, str2, cgs.voteNo );
}
CG_DrawStringExt( 8, 214, s, color, qtrue, qtrue, TINYCHAR_WIDTH, TINYCHAR_HEIGHT, 60 );
return;
} else {
s = va( CG_TranslateString( "YOU VOTED ON: %s" ), cgs.voteString );
CG_DrawStringExt( 8, 200, s, color, qtrue, qtrue, TINYCHAR_WIDTH, TINYCHAR_HEIGHT, 80 );
s = va( CG_TranslateString( "Y:%i, N:%i" ), cgs.voteYes, cgs.voteNo );
CG_DrawStringExt( 8, 214, s, color, qtrue, qtrue, TINYCHAR_WIDTH, TINYCHAR_HEIGHT, 20 );
return;
}
The first line is the important one: If EF_VOTED
flag is set in ps.eFlags
, the desired interface (You voted on...) is displayed.
ETPro has et.gentity_get()
function that you can use to read various entity state fields. I couldn't believe it for a while, but it was a sad fact that ps.eFlags
field simply wasn't exposed. ETPro enumerates all the fields somewhere in its source code, it doesn't automagically expose everything. That's kinda obvious, as during compilation, these fields become just numbers (offsets).
Let's hack this for fun and (no) profit!
Let's deep dive into this. The et.gentity_get()
offers just a limited set of fields we can work with and there's no way around that. Or is there? Take a look at et.gentity_get()
's third argument arrayindex
. This is supposed to be used for reading array fields like ps.ammo
:
local bulletsInClip = et.gentity_get(clientNum, "ps.ammo", 9)
This might doesn't seem too exciting, but stay with me. What exactly is it that ETPro does when this code is executed? We can assume it's something like this:
// Let's assume we already know the field name is "ps.ammo" at this point.
int GetPSAmmo(lua_State *l) {
int clientNum, arrayindex, value;
gentity_t *ent;
// 1st argument is clientNum (entityNum, effectively the same),
// 3rd argument is array index.
clientNum = luaL_checkinteger(l, 1);
arrayindex = luaL_checkinteger(l, 3);
ent = &g_entities[clientNum];
value = ent->client->ps.ammo[arrayindex];
// Put the value on the stack.
lua_pushinteger(l, value);
// This tells the Lua how many arguments are being returned.
return 1;
}
This is C, right? All the names like ps
or ammo
are lost during compilation, they have no meaning to the computer. Computer works with numerical addresses. Compiler knows:
- what the address of
g_entities
is, - how long
gentity_t
is, - how far
client
(gclient_t
) pointer is from the beginning ofgentity_t
and where the actual struct is placed in memory, - how far
ps
is from the beginning of thegclient_t
, - how far
ammo
is from the beginning of theplayerState_s
, - how many bytes each element of
ammo
takes (it's 4, because that's how long integer is on x86).
So, when the Lua code above executes, this is how the exact memory location of the value we're interested in is computed:
- Read the starting address of
g_entities
array (it's a pointer to an array, but that's not that important). - Add
sizeof(gentity_t) * clientNum
to it, so we're now at the beginning of the entity we're interested in. - Get the address stored in the
client
field (it's another pointer togclient_t
). - Add distance of
ps.ammo
from the beginning ofgentity_t
. - Add
sizeof(int) * arrayindex
.
You just computed the exact address in memory of the value we're trying to access.
Now this is where the fun begins. Notice the 5. step: sizeof(int) * arrayindex
. And take a look at the playerState_s
:
int damageCount;
int stats[MAX_STATS];
int persistant[MAX_PERSISTANT];
int powerups[MAX_POWERUPS];
int ammo[MAX_WEAPONS]; // <- ps.ammo
int ammoclip[MAX_WEAPONS];
Let's just assume for a while that all the MAX
constants equal to 3
, this is how memory would look like:
Address | Description |
---|---|
0x3039 |
damageCount |
0x303D |
stats[0] |
0x303D |
stats[1] |
0x3045 |
stats[2] |
0x3049 |
persistant[0] |
0x304D |
persistant[1] |
0x3051 |
persistant[2] |
...and so on.
So, tell me, what would the following code do?
local x = et.gentity_get(clientNum, "ps.stats", -1)
Notice it's ps.stats
(which is at our hypothetical address 0x303D
) and we're trying to read minus 1st element. To C, arrayindex
is just an integer it adds to the address at which the supposed value should be.
0
would return value at address 0x303D
, 1
would return address at 0x303D
. And -1
would return 0x3039
(damageCount
), which is pretty cool, because we were never supposed to have access to that field! It also means that we can access arbitrary field in the whole gclient_t
struct. We can now break the game in a completely different manner!
Not so fast. ETPro has a boundary check preventing us from reading outside of the array field. So to make this work, we need to patch it.
We need an already exposed array field. Those are:
ps.stats
ps.persistant
ps.powerups
ps.ammo
ps.ammoclip
sess.medals
sess.skill
The ps
is playerState_t
struct, sess
is clientSession_t
. Both are part of gclient_t
and we can access any of its fields by hacking any of the listed fields. The gentity_t
is elsewhere in the memory and the client
(the gclient_t
) field is actually a pointer, so we aren't going to get access to the gentity_t
struct itself (not that easily, at least).
The ps.stats
is closest to the beginning of gclient_t
, so let's use that. The string itself must be hardcoded somewhere in ETPro (we'll work with Windows binary):
.rdata:200E7264 aPsStats db 'ps.stats',0
And this is where it's referenced:
.data:200DAD00 dd offset aSessAweaponsta ; "sess.aWeaponStats"
.data:200DAD04 db 50h ; P
.data:200DAD05 db 1
.data:200DAD06 db 0
.data:200DAD07 db 0
.data:200DAD08 dd offset sub_200173B0
.data:200DAD0C dd offset sub_20017450
.data:200DAD10 dd offset aPsStats ; "ps.stats"
.data:200DAD14 db 0D0h ; Đ
.data:200DAD15 db 0
.data:200DAD16 db 0
.data:200DAD17 db 0
.data:200DAD18 dd offset sub_200163A0
.data:200DAD1C dd offset sub_20016950
.data:200DAD20 dd offset aPsPersistant ; "ps.persistant"
In the source code, there's probably enumeration of fields and their handlers (getters, setters):
{
...
{"sess.aWeaponStats", FLAGS, GetSessWeaponStats, SetSessWeaponStats},
{"ps.stats", FLAGS, GetPSStats, SetPSStats },
{"ps.persistant", FLAGS, GetPSPersistent, SetPSPersistent },
...
}
The FLAGS
could be whatever... And since several fields refer the same function, it's probably an offset or something like that. We don't really care.
All the ps
getters lead to sub_200163A0
:
The 0Fh
(decimal 15
) and the ps array index 0-%d
shows we're on the right track. There are two branches, the left one gets the value from given index and pushes it into the stack, the right one yields an error. We always want to take the left one, so we're going to simply NOP
(90
) the JNZ
(75 60
) instruction.
All it takes are two bytes changed:
Instead of conditionally jumping to the error branch, we just do nothing twice and continue execution.
...and there we go!
This is basically everything we need to accomplish our goal. The boring stuff remains, such as computing the offset of every accessible field and of course patching the setter in the same manner.
Note that the struct layout of gclient_t
could be a little bit different from etmain. However, at least the distance of ps.eFlags
and ps.stats
seems to be the same:
And here's the patch for Vote++. Using pcall
, it checks whether the unlocked functions are available and if so, it uses them to reflect the state in the voting interface.
In the next lesson, we're going to teach our server fly.