Reverse Engineering SA:MP's Packet Obfuscation
Last night I made huge progress on reversing SA:MP’s netcode for SAMP 0.3.7-R4. I figured out three things: 1.) how packets are encrypted and decrypted on both client/server. 2.) how packets are signed and ultimately verified on the serverside and 3.) the 256 byte encryption and decryption keys used. This post goes into comprehensive detail as to how I accomplished this.
Why is this desirable?
If we can figure out how and where packets are encrypted/decrypted we can easily figure out a lot of the game’s netcode just from sniffing packet data and then decrypting it ourselves. We can also manipulate the values of packets, sign them ourselves and send them off to accomplish basically anything hack-wise in multiplayer. It’s definitely the holy grail for hacking in any game and also serves as a starting point for revealing a lot about other structures in the game’s code.
The reason I actually bothered with doing this is simply to get some practice and out of sheer curiosity though. SA:MP turns out to be a nice target for stuff like this.
Reversing the client
Firstly I started off by trying to reverse how the client packs and ultimately sends packets to the server. Using windbgx86
I hooked a couple of send functions while hosting my own local multiplayer server and found that sendto
from winsock.h
was the function used to send data to the server. This helped me tremendously because the signature reveals a lot about the data that gets passed around to it, most importantly where all the packet data is in buf
.
int sendto(
SOCKET s,
const char *buf,
int len,
int flags,
const sockaddr *to,
int tolen
);
From there, I looked at the stack to see where the return address was for this function call to find out where exactly I needed to look at for any packet manipulation routines. Bingo, sendto
is called from FUNC_10053a60
which seems to be manipulating DAT_10119a38
before it sends it off to the server.
int FUN_10053a60(SOCKET param_1,undefined4 param_2,int param_3,undefined4 param_4,undefined4 param_5) {
int iVar1;
ADDRESS_FAMILY local_10;
u_short local_e;
undefined4 local_c;
if (param_1 == 0xffffffff) {
return 0xffffffff;
}
local_e = htons((u_short)param_5);
local_c = param_4;
local_10 = 2;
FUN_1001f660(&DAT_10119a38,param_2,¶m_3);
do {
iVar1 = sendto(param_1,&DAT_10119a38,param_3,0,(sockaddr *)&local_10,0x10);
} while (iVar1 == 0);
if (iVar1 != -1) {
return 0;
}
iVar1 = WSAGetLastError();
return iVar1;
}
Looking at FUN_1001f660
we see a lot of interesting stuff. Since param_1
is our buffer, it looks like first we are writing a byte using uVar2
into param_1[0]
. We can guess that DAT_10119a38
is actually the packet
buffer we’re writing to so we get the signature with an output and input parameter in addition to a pointer to packet_len
.
// aka encrypt_and_apply_signature_to_packet_buffer (byte *output_packet_buffer,int *input_packet_buffer,uint *packet_len)
void FUN_1001f660(undefined *param_1,undefined4 *param_2,uint *param_3) {
uint uVar1;
undefined uVar2;
uint uVar3;
uint uVar4;
undefined4 *puVar5;
uVar1 = (uint)DAT_10118b74;
uVar2 = FUN_1001f5e0(param_2,*param_3);
*param_1 = uVar2;
uVar4 = *param_3;
uVar3 = uVar4 >> 2;
puVar5 = (undefined4 *)(param_1 + 1);
while (uVar3 != 0) {
uVar3 = uVar3 - 1;
*puVar5 = *param_2;
param_2 = param_2 + 1;
puVar5 = puVar5 + 1;
}
uVar4 = uVar4 & 3;
while (uVar4 != 0) {
uVar4 = uVar4 - 1;
*(undefined *)puVar5 = *(undefined *)param_2;
param_2 = (undefined4 *)((int)param_2 + 1);
puVar5 = (undefined4 *)((int)puVar5 + 1);
}
FUN_1001f610(0,uVar1,param_1 + 1,*param_3);
*param_3 = *param_3 + 1;
return;
}
Looking at FUN_1001f5e0
it turns out this is the signature function. The function returns 1 byte
into EAX
which is the result of repeated XORs against the data in buffer param_1
offset by iVar2
many bytes at each iteration of the loop. We can deduce that param_2
must be packet length here as a result and FUN_1001f5e0
is compute_signature
.
byte FUN_1001f5e0(int param_1,int param_2) {
byte bVar1;
int iVar2;
bVar1 = 0;
iVar2 = 0;
if (param_2 != 0) {
do {
bVar1 = bVar1 ^ *(byte *)(iVar2 + param_1) & 0xaa;
iVar2 = iVar2 + 1;
} while (iVar2 != param_2);
}
return bVar1;
}
This means that FUN_1001f660
is actually first writing a signature for the packet, before doing a bunch of pointer manipulation. So far we’ve deduced
uVar2 = compute_signature(buffer,*packet_len);
*param_1 = uVar2;
uVar4 = *packet_len;
uVar3 = uVar4 >> 2;
puVar5 = (undefined4 *)(param_1 + 1);
while (uVar3 != 0) {
uVar3 = uVar3 - 1;
*puVar5 = *buffer;
buffer = buffer + 1;
puVar5 = puVar5 + 1;
}
uVar4 = uVar4 & 3;
while (uVar4 != 0) {
uVar4 = uVar4 - 1;
*(undefined *)puVar5 = *(undefined *)buffer;
buffer = (undefined4 *)((int)buffer + 1);
puVar5 = (undefined4 *)((int)puVar5 + 1);
}
FUN_1001f610(0,uVar1,param_1 + 1,*packet_len);
*packet_len = *packet_len + 1;
but what is all this loop stuff? If you look closely this is simply copying 4 bytes at a time from buffer
into puVar5
which is a pointer to another buffer in memory (the buffer containing the signature). uVar3
is equal to packet_len
divided by 4 (right shift by 2) and so we only iterate over 4 bytes at a time, copying the data over and then copying over the remaining bytes in uVar4 & 3
.
Great, now what does FUN_1001f610
do? It turns out this is the encryption routine.
void FUN_1001f610(byte param_1,byte param_2,int param_3,int param_4) {
byte bVar1;
byte bVar2;
int iVar3;
int iVar4;
iVar4 = 0;
iVar3 = 0;
if (param_4 != 0) {
do {
bVar1 = (&DAT_100fe500)[*(byte *)(iVar3 + param_3)];
*(byte *)(iVar3 + param_3) = bVar1;
if (iVar4 == 0) {
iVar4 = 1;
bVar2 = param_1;
}
else {
iVar4 = iVar4 + -1;
bVar2 = param_2;
}
*(byte *)(iVar3 + param_3) = bVar1 ^ bVar2;
iVar3 = iVar3 + 1;
} while (iVar3 != param_4);
}
return;
}
The encryption routine is pretty simple. It looks up a key for each byte in DAT_100fe500
, indexes it by the byte in the buffer and then uses param_1
and param_2
as seeds, sort of, for the encryption routine as an additional level of “security”. The funny thing is, the only way this encryption routine is decryptable is if each byte in the key DAT_100fe500
is unique. Looking at DAT_100fe500
we see that the encryption key is the following 256 bytes at samp.dll + FE500
.
I wrote a Python script to generate the decryption key. I then looked this up in the server binary to confirm my hunch about how the decryption scheme works.
The way the decryption scheme works is based on inverting the index of each encrypted byte back to its original value. So each index of the decryption key maps back to the data that was there. For instance the byte 0xB4
maps to 0x00
in the encryption key, so at index 0 in the decryption key we put 0xB4
. We need it to be a bijection so no two bytes can repeat in the key. It’s basically a mapping and I think the XOR is there just to throw people off while packet sniffing.
key = "27 69 FD 87 60 7D 83 02 F2 3F 71 99 A3 7C 1B 9D 76 30 23 25 C5 82 9B EB 1E FA 46 4F 98 C9 37 88 18 A2 68 D6 D7 22 D1 74 7A 79 2E D2 6D 48 0F B1 62 97 BC 8B 59 7F 29 B6 B9 61 BE C8 C1 C6 40 EF 11 6A A5 C7 3A F4 4C 13 6C 2B 1C 54 56 55 53 A8 DC 9C 9A 16 DD B0 F5 2D FF DE 8A 90 FC 95 EC 31 85 C2 01 06 DB 28 D8 EA A0 DA 10 0E F0 2A 6B 21 F1 86 FB 65 E1 6F F6 26 33 39 AE BF D4 E4 E9 44 75 3D 63 BD C0 7B 9E A6 5C 1F B2 A4 C4 8D B3 FE 8F 19 8C 4D 5E 34 CC F9 B5 F3 F8 A1 50 04 93 73 E0 BA CB 45 35 1A 49 47 6E 2F 51 12 E2 4A 72 05 66 70 B8 CD 00 E5 BB 24 58 EE B4 80 81 36 A9 67 5A 4B E8 CA CF 9F E3 AC AA 14 5B 5F 0A 3B 77 92 09 15 4E 94 AD 17 64 52 D3 38 43 0D 0C 07 3C 1D AF ED E7 08 B7 03 E6 8E AB 91 89 3E 2C 96 42 D9 78 DF D0 57 5D 84 41 7E CE F7 32 C3 D5 20 0B A7"
key = key.split(' ')
decode_key = ""
for i in range(0, 256):
h = format(i, '02X') # hex format of search index
idx = key.index(h)
decode_key += format(idx, '02X')
print(decode_key)
assert(decode_key == "B46207E59DAF63DDE3D0CCFEDCDB6B2E6A40AB47C9D153D52091A50E4ADF1889FD6F2512B713770065366D49EC572AA9115FFA7895A4BD1ED97944CDDE81EB093EF6EEDA7FA31AA72DA6ADC14693D21B9CAAD74E4B4D4CF3B834C0CA88F494CB04393082D673B0BF2201416E482CA875B10AAE9F278010CEF02928850D05F735BBBC1506F56071031FEA5A33928DE7905BE9CF9ED35DED311C0B5216510F86C5689B210C8B4287FF4FBEC8E8C7D47AE0552F8A8EBA9837E4B238A1B632833A7B843C61FB8C143D433B1DC3A296B3F8C4F2262BD87CFC232466EF6964505459F1A074ACC67DB5E6E2C27E67175EE1B93F6C700899455676F99A9719725C028F58")
And voila, we are able to find the decryption key in the server binary
We can do an XREF against the decryption key to find where packets are decrypted too, nothing special
void __cdecl decrypt_packet(byte param_1,byte param_2,int param_3,int param_4) {
int iVar1;
int iVar2;
iVar2 = 0;
iVar1 = 0;
if (param_4 != 0) {
do {
if (iVar2 == 0) {
*(byte *)(iVar1 + param_3) = *(byte *)(iVar1 + param_3) ^ param_1;
iVar2 = 1;
}
else {
*(byte *)(iVar1 + param_3) = *(byte *)(iVar1 + param_3) ^ param_2;
iVar2 = iVar2 + -1;
}
*(undefined *)(iVar1 + param_3) = decryption_key[*(byte *)(iVar1 + param_3)];
iVar1 = iVar1 + 1;
} while (iVar1 != param_4);
}
return;
}
As you can see there isn’t much to say here. It’s simply taking off the first layer of encryption against param_1
and param_2
, our seeds, and then inverting the decryption with the decryption key.
Edit (12/31/19): I found out that some of the SAMP code has been leaked for a while, so here’s the original code for packet encryption.
Conclusion
I’ve attached both keys here for anyone who wants to use them for their own projects. Using these keys we can easily write our own packets, send them to the server and sign them so they are valid if we want. I looked up both keys and I could not find anyone that had previously cracked them for 0.3.7-R4.
Back