t3ssel
who am i

Reversing a simple cryptosystem

- reverse, games

 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.