Reversing O2Jam Network

Recently, I have successfully released a major version of Mozart.Encore project with code name: CrossTime, which is to support one of the major O2Jam versions, particularly the most radical one: O2Jam X2.

In turn, I shared this in a couple of places on the internet, and people are showing interest in the reversing process. So today, on this occasion, I’d like to share my reversing journal about O2Jam Networking. Please bear in mind that my reversing skills consist solely of guesswork plus trial and error; what do you expect? I’m pretty new to reversing myself!

In this post, we will focus on the v3.10 client and I’d like to stick with static analysis as much as possible. Thanks to community efforts, some of the client inner working have already been discovered; such as how to start the game, and the fact that the game uses TCP instead of UDP. There are many other small and subtle details that I won’t mention here one by one. As trivial as they may sound, they’re still helpful, so we don’t have to figure them out by ourselves from scratch, which means I can focus on the network part. Nice!

Before reading further, I recommend you read Welcome to CXO2 if you are unfamiliar with O2Jam.

Starting the game

Seriously, before we start anything, we need to understand how to launch the game because it’s not like I can double-click the executable to run it. It’s definitely something you won’t see in many old games.

When the official service was still around, you had to log into the web portal via Internet Explorer, then click Start game, which would launch the game via ActiveX. The e-Games distribution included a game launcher which essentially baked an Internet Explorer window into the program; behind the scenes, it involve ActiveX to launch the game too.

Anyway, the ActiveX script would also pass an encoded base64 string along with a couple of pairs of game server IP and port. The decoded base64 string is essentially a random GUID encoded in UTF16BE and used as an opaque auth token. Keep in mind the decoded token is opaque, meaning the game will decode the base64 string, but it will not try to process this GUID, and thus any encoded base64 from a random UTF16BE string will work just fine to launch the game.

The launch argument will look like this:

OTwo.exe ADAAOQBEADUAQgBBADcAQwAtADUARQAyAEEALQA0ADYANQBBAC0AOQA4AEUARAAtADgAMgBDADYAOAA1AEEARgAxAEYAMQA4 
www.download.e-games.web.id:1234 O2Jam 2 127.0.0.1 15010 127.0.0.1 15010

Let’s break it down:

  • OTwo.exe is the game client
  • The second parameter is the authentication code
  • The third parameter is the FTP server
  • The fourth parameter is presumably the service name, always equal to O2Jam. In some newer versions, it is a web server URL instead.
  • The rest of the parameters are the number of servers, followed by the game server IP and port pairs.

If we decode the base64 string above, we will get the following string:

09D5BA7C-5E2A-465A-98ED-82C685AF1F18

The game will send this string to authenticate the player session and expect a response from the server. With all that said, let’s finally get started.

Intercepting the network request

The first thing that comes to my mind is to do this backward: let’s see what the actual data being sent looks like. There are a couple of reasons, but the idea is that locating where the network message is being sent to the server sounds generally faaaaar easier.

Another thing is that OTwo.exe is massive. I could easily see myself getting mixed up between graphics rendering, asset processing, input handling and so on. I could think of some alternatives, such as looking up the socket function from the import table. But in this case, I would prefer to see the packet data to get the full picture.

With all that said, let’s observe the network messages sent by the client. Fortunately, this should be easy even without a network monitoring program such as Wireshark. We can simply set up a TCP Server to listen on port 15010, or any port of our choice as we can adjust it from the launch argument above, which allows us to route the game’s network traffic into our dummy server app.

To make it even simpler, I asked ChatGPT to write the TCP server for me in C#. Very convenient indeed!

using System.Net;
using System.Net.Sockets;

// Set the listener IP address and port
var ipAddress = IPAddress.Parse("127.0.0.1");
var port = 15010;

// Create TcpListener
var tcpListener = new TcpListener(ipAddress, port);

try
{
    // Start listening for incoming connection requests
    tcpListener.Start();
    Console.WriteLine($"Listening on {ipAddress}:{port}");

    // Accept the pending client connection
    var tcpClient = tcpListener.AcceptTcpClient();
    Console.WriteLine("Connection accepted.");
    Console.WriteLine();

    // Get the network stream from the connected client
    var networkStream = tcpClient.GetStream();

    // Read data from the client as a byte array
    byte[] buffer = new byte[1024];
    int bytesRead;

    while ((bytesRead = networkStream.Read(buffer, 0, buffer.Length)) > 0)
    {
        Console.WriteLine("Received data as byte array:");
        Print(buffer);
        
        Console.WriteLine(); // Add a newline for better formatting
    }
}
catch (Exception ex)
{
    Console.WriteLine($"Exception: {ex.Message}");
}
finally
{
    // Stop listening and close the TcpListener and TcpClient
    tcpListener.Stop();
}

private static void Print(Memory<byte> memory)
{
    var span = memory.Span;
    const int bytesPerLine = 16;

    string hexes = string.Empty;
    string decoded = string.Empty;
    for (int i = 0; i < span.Length; i += bytesPerLine)
    {
        var lineSpan = span.Slice(i, Math.Min(bytesPerLine, span.Length - i));

        // Hex
        var hex = string.Join(" ", lineSpan.ToArray().Select(b => b.ToString("X2")));

        // ASCII
        var ascii = new string(lineSpan.ToArray().Select(b => b >= 32 && b <= 126 ? (char)b : '.').ToArray());

        hexes += $"{hex,-48}" + Environment.NewLine;
        decoded += ascii + Environment.NewLine;
    }

    Console.WriteLine(hexes);
    Console.WriteLine(decoded);
}

Alright! Let’s fire up the server and see what our client sends to us!

Listening on 127.0.0.1:15010
Connection accepted.
Received data as byte array:

29 00 E8 03 30 39 44 35 42 41 37 43 2D 35 45 32 
41 2D 34 36 35 41 2D 39 38 45 44 2D 38 32 43 36 
38 35 41 46 31 46 31 38 00                      

)...09D5BA7C-5E2
A-465A-98ED-82C6
85AF1F18.

Thankfully, as you can see above, the data seems to be simple. I made several attempts using different sets of input strings to see the variation of the first 4 bytes in the packet. For example:

Received data as byte array:

09 00 E8 03 54 45 53 54 00                 

)...TEST.

After a couple of seconds, the game shows an in-game error saying it encountered a network error or something along those lines (the wording is awkward, I know).

Network error
Figure 1. Network error. Most likely due to no response after a certain period of time.

From here, I made the assumption that the payload looks like this:

  • int16 packet_size
  • int16 opcode
  • string token (null-terminated)

In the above case, the opcode is 1000 (0x03E8).

Static analysis

This is a great start. We haven’t fired up a disassembler or debugger, yet we’ve been able to make a solid guess about the request payload. But now, we have to figure out what response the client expects in order to proceed, so this is where the main dish starts.

First, if my assumption above is correct, I believe the first 4 bytes are common across requests and responses. So we need to discover the opcodes used by both client and server. Let’s start with 1000, 1001, and maybe 1002; time to fire up our disassembler.

Looking up the immediate value of 1000 (0x03E8) gives me dozens of results and I don’t think it’s practical to verify every single one of them. In our case, we are more interested in other opcodes anyway, so let’s try our luck with 1001 (0x03E9).

Search of 1001
Figure 2. Immediate value search of value 1001 (0x03E9).

This looks promising as it only returns 3 results. By the looks of it, sub_429610 handles 3 opcodes: 1001, 1003, and 1005. But it doesn’t call any function at all for 1001; it just sets one random global variable to 0. On the other hand, sub_456290 has this huuuge switch/case table that handles dozens of opcodes!

sub_456290
Figure 3. A portion of the `sub_456290` `switch`/`case` table.

Following the 1001 branch led me to sub_456D20, which interestingly appears in the immediate value search above. After carefully looking at it, it contains auth token validation. This is it! This is the function where it processes the auth response from the server!

0045720E loc_45720E:                             ; CODE XREF: sub_456D20+70↑j
0045720E                                         ; DATA XREF: jpt_456D90↓o
0045720E                 push    ebx             ; jumptable 00456D90 case -1
0045720F                 push    offset aEitherLoginNam ; "Either login name or password is incorr"...
00457214                 jmp     loc_4572B4
00457219 ; ---------------------------------------------------------------------------
00457219
00457219 loc_457219:                             ; CODE XREF: sub_456D20+70↑j
00457219                                         ; DATA XREF: jpt_456D90↓o
00457219                 push    ebx             ; jumptable 00456D90 case -2
0045721A                 push    offset aUserIsNowBeing ; "User is now being connected to the Game"...
0045721F                 jmp     loc_4572B4
00457224 ; ---------------------------------------------------------------------------
00457224
00457224 loc_457224:                             ; CODE XREF: sub_456D20+70↑j
00457224                                         ; DATA XREF: jpt_456D90↓o
00457224                 push    ebx             ; jumptable 00456D90 case -5
00457225                 push    offset aYouHaveBeenBan ; "You have been banned! \nPlease enquire "...
0045722A                 jmp     loc_4572B4
0045722F ; ---------------------------------------------------------------------------
0045722F
0045722F loc_45722F:                             ; CODE XREF: sub_456D20+70↑j
0045722F                                         ; DATA XREF: jpt_456D90↓o
0045722F                 push    ebx             ; jumptable 00456D90 case -101
00457230                 push    offset aPleaseInquireO ; "(Please inquire of Administrator.) DB e"...
00457235                 jmp     short loc_4572B4
00457237 ; ---------------------------------------------------------------------------
00457237
00457237 loc_457237:                             ; CODE XREF: sub_456D20+70↑j
00457237                                         ; DATA XREF: jpt_456D90↓o
00457237                 push    offset aNetworkErrorHa ; jumptable 00456D90 case 3
0045723C                 jmp     short loc_45727A
0045723E ; ---------------------------------------------------------------------------
0045723E
0045723E loc_45723E:                             ; CODE XREF: sub_456D20+70↑j
0045723E                                         ; DATA XREF: jpt_456D90↓o
0045723E                 lea     edx, [esp+21E4h+Buffer] ; jumptable 00456D90 cases 10,11,33
00457245                 push    offset aYouHaveInsuffi ; "You have insufficient points to play. P"...
0045724A                 push    edx             ; Buffer
0045724B                 call    _sprintf
00457250                 add     esp, 8
00457253                 jmp     short loc_4572AB
00457255 ; ---------------------------------------------------------------------------
00457255
00457255 loc_457255:                             ; CODE XREF: sub_456D20+70↑j
00457255                                         ; DATA XREF: jpt_456D90↓o
00457255                 lea     ecx, [esp+21E4h+Buffer] ; jumptable 00456D90 case 17
0045725C                 push    offset aYouHaveAlready ; "You have already connected another game"...
00457261                 push    ecx             ; Buffer
00457262                 call    _sprintf
00457267                 add     esp, 8
0045726A                 lea     edx, [esp+21E4h+Buffer]
00457271                 push    ebx
00457272                 push    edx
00457273                 jmp     short loc_4572B4

Let’s carefully trace back a little bit to see the entire jump table and find out where the value comes from. Looking back at the top of sub_456D20, there are 2 function calls. The first one seems to be a constructor of an object, but the other one looks more interesting.

00456D65  push    4
00456D67  push    eax
00456D68  mov     ecx, esi
00456D6A  mov     [esp+21ECh+var_4], ebx
00456D71  call    sub_455900
00456D76  mov     ecx, [esp+21E4h+var_21B8]
00456D7A  lea     eax, [ecx+65h]  ; switch 135 cases
00456D7D  cmp     eax, 134
00456D82  ja      def_456D90      ; jumptable 00456D90 default case, cases -100--6,-4,-3,1,2,4-9,12-16,19-32
00456D88  xor     edx, edx
00456D8A  mov     dl, ds:byte_457320[eax]
00456D90  jmp     ds:jpt_456D90[edx*4] ; switch jump

As you can see from the above, it seems eax holds the value that determines the switch/case jump. And the value seems to be coming from sub_455900.

sub_455900 looks like a copy function from some sort of container object; presumably the response payload class!

00455900 ; const void **__thiscall sub_455900(const void **this, void *, unsigned int)
00455900 sub_455900      proc near               ; CODE XREF: sub_44C380+A↑p
00455900                                         ; sub_44C3B0+A↑p ...
00455900
00455900 arg_0           = dword ptr  4
00455900 arg_4           = dword ptr  8
00455900
00455900                 mov     edx, [esp+arg_4]
00455904                 mov     eax, ecx
00455906                 mov     ecx, edx
00455908                 push    ebx
00455909                 push    esi
0045590A                 mov     esi, [eax+0Ch]
0045590D                 mov     ebx, ecx
0045590F                 push    edi
00455910                 mov     edi, [esp+0Ch+arg_0]
00455914                 shr     ecx, 2
00455917                 rep movsd
00455919                 mov     ecx, ebx
0045591B                 and     ecx, 3
0045591E                 rep movsb
00455920                 mov     ecx, [eax+0Ch]
00455923                 pop     edi
00455924                 add     ecx, edx
00455926                 pop     esi
00455927                 mov     [eax+0Ch], ecx
0045592A                 pop     ebx
0045592B                 retn    8
0045592B sub_455900      endp

The function could look like this:

response->sub_455900(&eax, 4); // where 4 is the number of bytes to read

Before we start verifying our current findings, let’s rename the following symbols:

  • sub_456290dispatch_response_opcode
  • sub_456D20process_auth_response
  • sub_455900read_response_payload

This is great! We’ve made a lot of progress with static analysis alone. But we need to see if we got everything right. So let’s do that by triggering the -1 branch, which should yield the Either login name or password is incorrect. error:

0045720E loc_45720E:                         ; CODE XREF: sub_456D20+70↑j
0045720E                                     ; DATA XREF: jpt_456D90↓o
0045720E             push    ebx             ; jumptable 00456D90 case -1
0045720F             push    offset aEitherLoginNam ; "Either login name or password is"...
00457214             jmp     loc_4572B4

Verifying analysis

Let’s start by modifying the C# code to return the following payload when we receive the 1000 opcode request:

08 00 E9 03 FF FF FF FF

Surprise, surprise! It works! 🎉

Test error
Figure 4. Testing expected error.

Let’s goo! But we need to make this actually work. Not to mention, we have yet to locate the request code; so I’m hoping that we will find a similar huge switch/case table for the request as well in the subsequent steps. So let’s try to make the auth validation pass.

Looking at branch 0, there are a lot of moving parts here and it would be extremely hard to understand what’s happening without debugging, especially with so little context. But I managed to get some details out of it:

  • There is a check against a global that gets set when receiving another opcode (remember sub_429610 above? It gets set in 1005)
  • There are a bunch of string initializations: FM, DB, FD, LE, TH, TB, LD, and LM. They could be just random binaries, but they’re likely strings.
  • The most interesting part: sub_4559A0. It calls our read_response_payload and seems to be related to the strings.
004559A0 sub_4559A0      proc near               ; CODE XREF: sub_44C3A0+8↑p
004559A0                                         ; sub_4569B0+11F↓p ...
004559A0
004559A0 arg_0           = dword ptr  4
004559A0
004559A0                 push    esi
004559A1                 mov     esi, ecx
004559A3                 push    edi
004559A4                 or      ecx, 0FFFFFFFFh
004559A7                 mov     edi, [esi+0Ch]
004559AA                 xor     eax, eax
004559AC                 repne scasb
004559AE                 mov     eax, [esp+8+arg_0]
004559B2                 not     ecx
004559B4                 push    ecx
004559B5                 push    eax
004559B6                 mov     ecx, esi
004559B8                 call    read_response_payload
004559BD                 mov     eax, esi
004559BF                 pop     edi
004559C0                 pop     esi
004559C1                 retn    4
004559C1 sub_4559A0      endp

Additionally, there’s a validation that checks whether the result of sub_4559A0 is null or not. Here, I’d like to make another attempt at brute-forcing, where I assume:

  • sub_4559A0 is presumably a function to read a null-terminated string from the response payload. (Renamed to read_string_response_payload)
  • The result is compared against one of those 2-character strings

Let’s start verifying this idea by adjusting the C# code to return the following payload when we receive the 1000 opcode request:

0B 00 E9 03 00 00 00 00 46 4D 00 

// ASCII: ........FM.

The game seems to be stuck, but we’ve made progress here: the game sent another request!

Received data as byte array:

04 00 EA 03                                     

....

Okay, there are a bunch of things running through my head right now. For starters, I’m beginning to see the pattern here. The request with opcode 1000 is paired with the response with opcode 1001. Now the client sends 1002 (0x03EA), so I could assume that it expects 1003 next. Although, the server may send messages to the client without being prompted by the client. For example, when the room master updates the music selection, the server will need to broadcast that information to the rest of the room members. So incrementing the opcode by 1 shouldn’t always be the answer, as it might be occupied by such event messages.

Going back to our test, there are no execution branches out of these strange 2-character arrays. The game seems happy to accept any incoming string as long as it matches one of those weird codes, or we don’t send the string at all. However, if the server sends a string that is not in the defined set, we get a Billing Error code, very cryptic indeed.

Unfortunately, I don’t see xrefs for those strings. Figuring this out from the client alone might be impossible. They could be referenced via a pointer at runtime, in which case we’d need to use the debugger. But I think that’s very unlikely, and I don’t think it’s important right now.

Locating the request code

It’s probably already past due time to find the request code. We can try searching 1002 (0x03EA) to get a better result now.

Search of 1002
Figure 5. Immediate value search of value 1002 (0x03EA).

Interesting! The value was hiding under our nose all along. Looking at the search results, the code looks extremely similar in all places, which more or less resembles something like this:

00456EC1  push    ebx
00456EC2  lea     eax, [esp+21E8h+var_2008]
00456EC9  push    1
00456ECB  mov     word ptr [ecx], 1002
00456ED0  mov     edx, [esp+464]
00456ED7  lea     ecx, [esp+484]
00456EDE  mov     word ptr [edx], 4
00456EE3  lea     edx, [esp+460]
00456EEA  mov     [esp+21ECh+var_2014], ecx
00456EF1  mov     ecx, dword_48C408
00456EF7  push    edx
00456EF8  mov     [esp+21F0h+var_2010], eax
00456EFF  call    sub_455C60

sub_455C60 doesn’t look like our read_response_payload; it’s much more complex, and it calls a function pointer which we would need to debug if we want to go further. But looking at the xrefs of this function, it strongly suggests that it is indeed closely related to the request, as I have found many calls around the opcodes handling outside the dispatch_response_opcode. I assume this is where the actual packet sending happens. I’d like to stay with static analysis for now; we can come back later with our debugger if needed. For now, I have renamed sub_455C60 to maybe_send_request_packet.

xrefs of maybe_send_request_packet
Figure 6. xrefs to maybe_send_request_packet (sub_455C60).

Anyway, looking back at the xrefs of maybe_send_request_packet once again, I found the code responsible for handling opcode 1000 (0x03E8) in sub_429480.

004294D2                 xor     edi, edi
004294D4                 sub     eax, 1000
004294D9                 mov     [esp+2030h+var_4], edi
004294E0                 jz      loc_42956B

; ...

0042956B loc_42956B:                             ; CODE XREF: sub_429480+60↑j
0042956B                 mov     ecx, dword_48C3E4
00429571                 call    sub_45FF70
00429576                 test    eax, eax
00429578                 jnz     short loc_4295A9
0042957A                 mov     ecx, dword_48C3D8
00429580                 push    edi
00429581                 push    offset aFailedInConnec ; "Failed in connecting to the server."
00429586                 push    edi
00429587                 push    1
00429589                 call    sub_41EA20
0042958E                 mov     ecx, esi
00429590                 mov     dword_49398C, edi
00429596                 mov     dword_49D2DC, edi
0042959C                 mov     dword_49D2E0, edi
004295A2                 call    sub_429B00
004295A7                 jmp     short loc_4295F6
004295A9 ; ---------------------------------------------------------------------------
004295A9
004295A9 loc_4295A9:                             ; CODE XREF: sub_429480+F8↑j
004295A9                 mov     eax, [esp+2030h+var_2018]
004295AD                 mov     dword_49398C, 3
004295B7                 lea     edx, [esp+2030h+var_2008]
004295BB                 push    offset unk_48C44C
004295C0                 mov     word ptr [eax], 1000
004295C5                 mov     ecx, [esp+2034h+var_201C]
004295C9                 lea     eax, [esp+2034h+var_2008]
004295CD                 mov     word ptr [ecx], 4
004295D2                 lea     ecx, [esp+2034h+var_2020]
004295D6                 mov     [esp+2034h+var_2010], edx
004295DA                 mov     [esp+2034h+var_2014], eax
004295DE                 call    sub_455970

sub_45FF70 appears to be a class function that checks one of the dword_48C3E4 fields. When the check fails, the game will likely show the error Failed in connecting to the server.. But loc_4295A9 is where the interesting thing happens: it calls sub_455970, and inside that, it calls sub_455930. Both look extremely similar to read_string_response_payload and read_response_payload respectively. I also noticed from the xrefs that sub_455930 is used similarly to read_response_payload, but for the opcodes outside the dispatch_response_opcode function.

This strongly suggests that sub_455930 and sub_455970 are write_request_payload and write_string_request_payload respectively! To verify this idea, let’s fire up the debugger to see if unk_48C44C contains our token!

My favorite toolkit for debugging is Cheat Engine. It’s a Swiss Army knife for reverse engineering, and I highly recommend adding it to your toolkit; particularly if you find x86dbg overwhelming. Anyway, in this scenario, we don’t even have to attach the debugger in Cheat Engine; we simply have to observe the memory, so no debugger is actually involved!

Value in 48C44C
Figure 7. The address 48C44C stores our auth token.

Bingo! We can conclude that this is the write function for the request payload. Unfortunately, this mean request code are fragmented unlike the response handler.

Rinse and repeat

We have successfully located both the request and response handlers. We can simply check the xrefs of read_response_payload or write_request_payload to pinpoint the request and response payload logic for each network message, plus we can look at the surrounding constants to associate them with an opcode. Let’s put it to the test by implementing the response message for opcode 1002 (0x03EA).

This will be significantly harder since we don’t have as much context as we had with the auth token stuff. On the other hand, as we observed before, the client does not include any information other than the packet size and the request opcode itself. So let’s start by looking at the 1003 branch of dispatch_response_opcode to get a better idea.

004573B0                 push    ecx
004573B1                 push    ebx
004573B2                 push    esi
004573B3                 push    edi
004573B4                 mov     edi, [esp+10h+arg_0]
004573B8                 push    4
004573BA                 push    offset dword_48C8E4
004573BF                 mov     ecx, edi
004573C1                 call    read_response_payload
004573C6                 mov     eax, dword_48C8E4
004573CB                 xor     ebx, ebx
004573CD                 test    eax, eax
004573CF                 jle     short loc_457437
004573D1                 mov     esi, offset word_48C8EA
004573D6
004573D6 loc_4573D6:                             ; CODE XREF: sub_4573B0+85↓j
004573D6                 lea     eax, [esp+10h+arg_0]
004573DA                 push    2
004573DC                 push    eax
004573DD                 mov     ecx, edi
004573DF                 call    read_response_payload
004573E4                 lea     ecx, [esp+10h+var_2]
004573E8                 push    2
004573EA                 push    ecx
004573EB                 mov     ecx, edi
004573ED                 call    read_response_payload
004573F2                 mov     dx, word ptr [esp+10h+arg_0]
004573F7                 mov     ax, [esp+10h+var_2]
004573FC                 lea     ecx, [esi+2]
004573FF                 push    4
00457401                 push    ecx
00457402                 mov     [esi-2], dx
00457406                 mov     ecx, edi
00457408                 mov     [esi], ax
0045740B                 call    read_response_payload
00457410                 lea     edx, [esi+6]
00457413                 push    4
00457415                 push    edx
00457416                 mov     ecx, edi
00457418                 call    read_response_payload
0045741D                 lea     eax, [esi+0Ah]
00457420                 push    1
00457422                 push    eax
00457423                 mov     ecx, edi
00457425                 call    read_response_payload
0045742A                 mov     eax, dword_48C8E4
0045742F                 inc     ebx
00457430                 add     esi, 10h
00457433                 cmp     ebx, eax
00457435                 jl      short loc_4573D6

It looks like it expects an array. The read_response_payload was used on dword_48C8E4, and then it’s used in the loop as the bound. In other words, it stores the number of items in the array. Each item in the array consists of 2 + 2 + 4 + 4 + 1 bytes (13 bytes).

We could try to find out which piece of code accesses the array at 48C8EA and carefully trace how it’s used, especially when it involves sprites, which would mean it’s being displayed rather than serving as a mere internal state. But the fact that it’s an array gives us enough context to make an educated guess and brute-force our way through.

You see, both 1000 and 1002 are triggered after I clicked one of the servers in the server selection. Apart from the authentication, the game is supposed to display a list of available channels to enter. So based on this assumption, the above payload should include:

  • Channel number
  • The number of active users in the channel
  • The maximum capacity of the channel

This assumption is also based on one of the findings from the 9you private server: the channel listing could be configured to appear out of order, which means the channel number must be included somewhere! Turns out we’re not completely out of clues with this one.

So after multiple brute-force attempts (about 3 to 4 attempts), I was able to figure out the struct completely:

  • int32 page (0 means the channel is displayed on the first page; in the private server, this could be controlled by additional channel server instances)
  • int32 channel_number
  • int16 max_users
  • int16 active_users
  • bool is_active (it won’t be displayed if it’s false)

Here’s a snippet of our dummy server app to build our dummy channel list:

var stream = new MemoryStream();
var writer = new BinaryWriter(stream);

writer.Write((short)0); // we write this later
writer.Write((short)1003);

writer.Write((int)7); // Say, we have 7 channels

// Put the first 5 channels normally
for (int i = 0; i < 5; i++)
{
    writer.Write((short)0);
    writer.Write((short)i); // Channel number start from 0 but presented with + 1
    writer.Write((int)100); // I don't think we can go past 100
    writer.Write((int)(i * 25));
    writer.Write((byte)1); // true
}

// Let's have 2 stray channels out of order and 1 channel padding, one of them belong to different server id (next page)
writer.Write((short)1);
writer.Write((short)6);
writer.Write((int)100);
writer.Write((int)25);
writer.Write((byte)1);

writer.Write((short)0);
writer.Write((short)0);
writer.Write((int)0);
writer.Write((int)0);
writer.Write((byte)0); // padding to give 1 empty slot for the last channel

writer.Write((short)0);
writer.Write((short)5);
writer.Write((int)100);
writer.Write((int)25);
writer.Write((byte)1);

stream.Flush();
stream.Seek(0, SeekOrigin.Begin);
writer.Write((short)(stream.Length)); // write the packet size

stream.Flush();

var buffer = stream.ToArray();
Print(buffer);

networkStream.Write(buffer);

Which yields the following payload:

70 00 EB 03 08 00 00 00 00 00 00 00 64 00 00 00 00 00 00 00 01 00 00 01 00 64 00 00 00 19 00 
00 00 01 00 00 02 00 64 00 00 00 32 00 00 00 01 00 00 03 00 64 00 00 00 4B 00 00 00 01 00 00 
04 00 64 00 00 00 64 00 00 00 01 01 00 06 00 64 00 00 00 19 00 00 00 01 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 05 00 64 00 00 00 19 00 00 00 01 

And, the moment of truth 🥁

O2Jam Channel list
Figure 8. We made the channel list work!

Jackpot! We made it! That’s a wrap for today.

What’s next?

That was a lot, yet I still haven’t brought out the Cheat Engine debugger into the picture. So on the next occasion, I’d like to cover a more complex scenario where debugging is necessary to reverse the network messages.

Although, it will be tricky because such scenarios often involve internal state that is not displayed, or at least not immediately observable after the response is received by the client. Not to mention it may require a deep understanding of how the game works in general; for example, it might involve how the game populates installed music files, or we may need to be familiar with the file format of certain assets.

But I’ll try my best to cover them when the time comes. Until then, see you around!