Reversing a simple cryptosystem
From a technical standpoint, video games are really interesting. Not only they impose the players rules that virtually can’t be broken, but they also heavily rely on parsing, AI, networking, external execution units (scripts) and others interesting stuff. The biggest games are massive though. Yet, I managed to find a small French “amateur-ish” MMORPG that I thought would be quick to study. It was actually a quite interesting project.
Online games are usually interesting to reverse for their network protocols. Digging around in Wireshark, I was unable to correlate in-game behaviours and network packets though. Sure there was some traffic. But there was no plaintext (that could be player messages or whatever) and repeating actions were never duplicate packets; suggesting encryption or at least modification of the original data.
Discovery
The game is divided in two binaries: the launcher in charge of updating the local files; and the game client. On startup, the launcher welcomes the player with a Message of the Day containing the number of online players: data only known by the game server. Having both the plaintext (shown by the launcher) and the ciphertext (seen on Wireshark) is a good starting point.
IDA could find a bunch of stripped symbols (the binary is neither packed or obfuscated, phew) but it has also fingerprinted a Delphi standard library. A few breakpoints on symbols containing the string “read” highlighted one in particular: Idtcpconnection::ReadFromStack
, part of the Delphi library.
This method returns exactly the number of bytes of each packet seen on Wireshark.
Another call to TIdManagedBuffer::Memory(void)
fills eax with a pointer to the raw data .
Decryption
Using a watch rule on the pointer, I ended up in a method that was overwriting the buffer with the exact MOTD shown on the launcher. Here is the most important of it with my comments:
sub_4852E0 proc near ; sub_4852E0(key, packet)
; FASTCALL convention:
; - param_1 (eax)
; - param_2 (edx)
; [...]
mov eax, [ebp+var_8] ; var_8 contains the encrypted packet
mov al, [eax] ; al = packet[0]
xor al, 21h ; al = al ^ 0x21
mov esi, eax
and esi, 0FFh
mov ebx, esi ; ebx = esi & 0xFF
; => ebx stores first index
mov eax, [ebp+var_8] ; var_8 contains the encrypted packet
call unknown_libname_82 ; unknown_libname_82(packet) - returns packet length
mov edx, [ebp+var_8]
mov al, [edx+eax-1] ; al = packet[packet.length() - 1]
xor al, 42h ; al = al ^ 0x42
and eax, 0FFh
mov [ebp+var_10], eax ; var_10 = eax & 0xFF
; => var_10 stores second index
mov eax, [ebp+var_8] ; var_8 contains the encrypted packet
cmp byte ptr [eax], 0FFh ; if packet[0] == 0xFF
setz [ebp+var_11] ; then var_11 = 1 else var_11 = 0
mov eax, [ebp+var_8] ; var_8 contains the encrypted packet
call unknown_libname_82 ; unknown_libname_82(packet) - returns packet length
dec eax ; packet length - 1
sub eax, 2 ; packet length - 2
jl short loc_4853B7 ; jump after main loop if packet is empty
inc eax
mov [ebp+var_18], eax
mov edi, 2
; this is the main loop
loc_48535D:
cmp [ebp+var_11], 0 ; if var_11 == 0
jz short loc_485369 ; then jump to decryption process
mov [ebp+var_11], 0 ; otherwise set var_11 to 0
jmp short loc_485394 ; and jump just after the decryption process
loc_485369:
mov eax, [ebp+var_8] ; var_8 contains the encrypted packet
cmp byte ptr [eax+edi-1], 0FFh ; if packet[edi - 1] == 0xFF
setz [ebp+var_11] ; then var_11 = 1 else var_11 = 0
lea eax, [ebp+var_8]
call j_unknown_libname_83_0 ; somekind of a string conversion function, probably used to escape weird characters, let's ignore it
; the decryption process happens here
mov edx, [ebp+var_8] ; var_8 contains the encrypted packet
mov dl, [edx+edi-1] ; dl = packet[edi -1]
mov ecx, [ebp+var_4] ; var_4 contains the decryption key
xor dl, [ecx+esi+360h] ; dl = dl ^ key[esi] (static decryption key)
xor dl, bl ; dl = dl ^ bl (first index)
mov [eax+edi-1], dl ; packet[edi - 1] = dl
loc_485394:
add esi, [ebp+var_10] ; esi += var_10 (second index)
sub ebx, [ebp+var_10] ; ebx -= var_10 (second index)
add ebx, 3 ; ebx += 3 (first index)
cmp esi, 100h ; if esi >= 0x100
jl short loc_4853AA
mov esi, 1 ; then ebx = 1
loc_4853AA:
cmp ebx, 1 ; if ebx < 1
jge short loc_4853B1
mov ebx, esi ; then ebx = esi
loc_4853B1:
inc edi ; edi++
dec [ebp+var_18] ; var_18-- ; if var_18 != 0
jnz short loc_48535D ; then jump back beginning of loop
; [...]
The decryption method can be broken down in two distinct operations:
- a xor between the packet byte and the key byte at position esi
- a xor with the value of ebx
Both esi and ebx are calculated from the first packet byte xor-ed to a static value of 0x21. A step is calculated from the last packet byte xor-ed to a static value of 0x42. The latter is added to esi and substracted to ebx for each loop cycle, making the decryption key to “slide” along the packet bytes.
It can be noted that each packet ends by [0xFF,0xFF]
so a packet of size 2 is considered empty.
Encryption
At first I thought it would be easier to “deduce” the encryption method from the decryption assembly. It ain’t no fun though and I wanted to know how the two indexes were generated. So instead, by backtracing the decryption function, I went back to the caller and found a symbol overwriting the string “version:6”. It looks like this:
sub_485034 proc near ; sub_485034(key, "version:6")
; FASTCALL convention:
; - param_1 (eax)
; - param_2 (edx)
; [...]
mov eax, 0FCh
call unknown_libname_47 ; unknown_libname_47(0xFC) - returns first index
mov ebx, eax
inc ebx ; (first index)++
mov esi, ebx
mov eax, 14h
call unknown_libname_47 ; unknown_libname_47(0x14) - returns second index
mov [ebp+var_C], eax
mov edi, ebx
xor edi, 21h ; edi = (first index) ^ 0x21
lea eax, [ebp+var_18]
mov edx, edi
call unknown_libname_77 ; unknown_libname_77(edi) - returns a string containing first index
push [ebp+var_18]
push [ebp+var_8]
lea eax, [ebp+var_1C]
mov dl, byte ptr [ebp+var_C]
xor dl, 42h ; dl = (second index) ^ 0x42
call unknown_libname_77 ; unknown_libname_77(edx) - returns a string containing second index
push [ebp+var_1C]
lea eax, [ebp+var_8]
mov edx, 3
call @System@@LStrCatN$qqrv ; strcat() - returns (first index) + "version:6" + (second index)
cmp edi, 0FFh
setz [ebp+var_D]
mov eax, [ebp+var_8] ; var_8 = strcat string result
call unknown_libname_82 ; strlen(var_8) - returns 11
dec eax
sub eax, 2 ; if length < 2
jl short loc_48512A ; then jump after loop
inc eax
mov [ebp+var_14], eax
mov edi, 2
; this is the main loop
loc_4850D4:
cmp [ebp+var_D], 0 ; if var_D != 0
jnz short loc_4850FB ; then jump after decryption process
lea eax, [ebp+var_8] ; var_8 = string to encrypt
call j_unknown_libname_83_0 ; ignored by me
mov edx, [ebp+var_8]
mov dl, [edx+edi-1] ; dl = string[edi - 1]
mov ecx, [ebp+var_4]
xor dl, [ecx+ebx+360h] ; dl = dl ^ key[ebx]
mov ecx, esi ; cl = (esi & 0xFF)
xor dl, cl ; dl = dl ^ cl
mov [eax+edi-1], dl ; string[edi - 1] = dl
loc_4850FB:
mov eax, [ebp+var_8] ; eax = string
cmp byte ptr [eax+edi-1], 0FFh ; if var_8[edi - 1] == 0xFF
setz [ebp+var_D] ; then var_D = 1 else var_D = 0
add ebx, [ebp+var_C] ; ebx += var_C
sub esi, [ebp+var_C] ; esi -= var_C
add esi, 3 ; esi += 3
cmp ebx, 100h ; if ebx >= 256
jl short loc_48511D
mov ebx, 1 ; then ebx = 1
loc_48511D:
cmp esi, 1 ; if esi < 1
jge short loc_485124
mov esi, ebx ; then esi = ebx
loc_485124:
inc edi ; edi++
dec [ebp+var_14] ; var_14-- ; if var_14 != 0
jnz short loc_4850D4 ; then jump back beginning of loop
; [...]
No mystery here, encryption is pretty similar to decryption in the other way around. The two indexes used as a step are calculated by unknown_libname_47
which looks like this:
unknown_libname_47 proc near ; unknown_libname_47(value)
; FASTCALL convention:
; - param_1 (eax)
push ebx
xor ebx, ebx ; ebx = 0
imul edx, ds:dword_489008[ebx], 8088405h ; edx = dword_489008[0] * 0x8088405
inc edx ; edx++
mov ds:dword_489008[ebx], edx ; dword_489008[0] = edx
mul edx ; edx = edx * value
mov eax, edx ; returns edx
pop ebx
retn
Interesting one. Each call to unknown_libname_47
is a multiplication of a value stored at dword_489008 and the constant 0x8088405. The result is then increased by one and stored back to dword_489008. This is probably done because the initial value of dword_489008 is zero and the multiplication would be zero every single time otherwise.
Indexes are the multiplication of dword_489008 and the first argument value. That means index calculations are deterministic and rely only on unknown_libname_47
’s last calls.
Extracting the key
Using a debugger is a pretty straightforward way to export the symmetric-key. A breakpoint on the right function (encryption or decryption) and a copy/paste of the buffer at eax is enough. I found it boring and wanted to “automate” the process with Frida. A simple script for a simple problem:
var decryptor = resolveAddress("0x4852E0");
console.log("Intercepting function at address " + decryptor);
Interceptor.attach(decryptor, {
onEnter: function (args) {
var offset = ptr("0x360");
var arg = ptr(this.context["eax"]).add(offset);
console.log(hexdump(arg, { offset: 0, length: 256, header: true, ansi: true}));
},
});
(Full script available here).
Note: FASTCALL prevents me from using args
directly. I believe Frida works with stdcall by default and the calling convention cannot be changed from the JS API.
In the end
For the encryption and decryption functions, it would have probably been easier to use emulators like Unicorn or miasm. I took the time to understand how the functions were implemented and decided to reimplement them in Python. First because of course it was a lot of fun. But I also think it will help me to have a standalone code I can manipulate. From this, I can start analysing the network protocol writing a MITM tool, or even try to replace a game server or game client. Next step: understanding the network protocol.