Adventures in Reverse Engineering

in Programming & Dev6 days ago

image.png

Background

Before we can even start, I've to give you some background information. The software I am reverse engineering is a MMOFPS server. I have a client, and this client can talk to the live server. But I do not have the server. This means we have to reverse the client/server communication.

Our goal would be having a server that can match the live server. This game had a private server developed for it but the game has changed its communication infra so these old private servers can't communicate with the newer clients. Old client/servers were talking in ASCII that got XORed. New client/server talk in binary. So we need to reverse engineer this binary communication.


the Beginning

Around this time last year, I started working on this. Capturing and inspecting packets. Trying to find meaningful patterns in it. Reverse Engineering is a bit like that, trying to see if any of things stand out to you and match with what you might have at hand.

Here is what my notes from that time look like;

Establish Connetion ResponsePacket = de 0b 00 00 50 60 b8 0b 00 00 ed


User Channel Switch Packet = de 0b 00 00 01 70 01 00 00 00 ed

Ignore first 4 bytes, 01 70 is in little endian conver to int to see the packet id, next byte is the channel user wants to switch to.

Might be welcome packet = de 32 00 00 00 61 13 00 64 6c 61 23 71 75 64 24 77 6c 72 25 61 6b 73 5e 74 70 26 d2 0b 00 00 0c 00 30 30 35 30 35 36 63 30 30 30 30 31 00 00 00 00 ed
Might be welcome packet = de 32 00 00 00 61 13 00 64 6c 61 23 71 75 64 24 77 6c 72 25 61 6b 73 5e 74 70 26 d2 0b 00 00 0c 00 30 30 35 30 35 36 63 30 30 30 30 31 00 00 00 00 ed

Noooo, me from the past don't ignore those four bytes they are important!!!


Also if you don't know what endianness means this is a good time to talk about that.

Let's say we have the 706A8C3F 32-bit integer. Computers have two ways of storing this 32-bit integer in memory. Big Endian, storing the most significant byte first, or Little Endian, storing the least significant byte first.

So if the program is in Big Endian our bytes basically stay as is, 706A8C3F, but if our program is in Little Endian it will store the last byte of our integer as the first byte in memory. So our bytes in memory gets "flipped" and we get 3F8C6A70.

So this is what happens in our packets. We have a 16-bit integer that is 0170 in little endian, we convert this to big endian 7001 and get the integer 28673. This is our opcode, how do I know this? Because this opcode has not changed between the ASCII and binary versions. This helps us identify it.

Also some keen eyed among you may have noticed that all packet start with DE byte and end with ED byte. These are what we call sentinel values (in this case bytes). These values denote the beginning and end of a packet for our program. (These sentinel values are specific to our program)

Why? Because in one frame we can get multiple packets or a packet can't fit into one frame. In this case sections of the packet that didn't fit will come in the next frame.

We have the 3 of the 4 packets we need to get into the lobby.

Handshake packet, Welcome Packet, Channel Switch Packet.

We create a simple TCP Server in Golang, add some packet handlers. And voila we can enter the game with the packets we captured. But the packets we captured are static. They always give the same data how are we going to make that dynamic?

Simple. (Not so simple) We start to butcher the packet and identify what those mean.

Let's demonstrate this with the Channel Switch Packet.

de 0b 00 00 01 70 01 00 00 00 ed

We get this packet from the Client. We ignore the 4 bytes as my notes instructed. We end up at byte 5 and 6, we know that this is little endian and we know that 7001 is channel switch opcode.

Then we that 01 byte. That byte is the channel the user wants to switch to. How we know this. By capturing more packets and comparing. When user switches to channel 2, it is 02. When user switches to channel 3 it is 3 etc.

Now we know the which channel user wants to switch to, but how are we going to switch them?

package packet_handlers

import (
    "gameserver/packets"
    "log"
)

const (
    ChannelSwitchPacketID = 0x7001
)

func ProcessChannelSwitch(packet []byte) []byte {
    log.Printf("Processing channel switch packet: % x", packet)
    channelID := packet[6]
    responsePacket := CreateChannelSwitchResponse(channelID)
    return responsePacket
}

func CreateChannelSwitchResponse(channelID byte) []byte {
    data := []byte{
        0x01, 0x00,
        0x00, 0x00, channelID, 0x00, 0x00, 0x00,
    }

    packet := packets.NewPacket(ChannelSwitchPacketID, data, 0x00)
    return packet.ToBytes()
}

Naive way of doing it.

At the time of this. I manage to narrow some of the packet structure and the NewPacket function helps building that structure.

As we discussed packets starts with DE the next to bytes are the length of the packet including the sentinels. Then we have an unknown byte. After that we have the packet opcode then we have the payload, then we have the end sentinel. ED

[DE][uint16 len][unkown byte][uint16 opcode][data][ED]

Then we hard-code some of the bigger packets, for sake of checking it it works.

And it does. So we work on the next packet and so on.

But unfortunately when I came to the CharacterInfo packet. Something was wrong, very wrong.

I could see some info on the packet that I could see, and manipulate. But some of the data was clearly missing. There was something going on with this packet and remember that unknown byte it was no longer zero for some reason.

Was it encryption? Was it some sort of byte manipulation?

Well I tried for days and couldn't find an answer, it was beyond my knowledge. So I took a break from the project.


the Second Wind

It has been almost a year since I took a break from the project. But something was tinkling, there was an itch. I released some of the files I had hoarded over the years in RageZone. But that gave me the idea to look into the project again.

And since that time, awesome Laurie Wired released an MCP (Model Context Protocol) plugin for Ghidra. For those who don't know Ghidra is an open source dissasmbler/decompiler developed by NSA. It is the tool for those of us, who can't afford IDA Pro.

Model Context Protocol, allows a large language model to talk to programs/services whatever you name it. Laurie's MCP gives the LLM tools to issue decomplications/disassembles of functions in Ghidra and some other useful tools like searching up labels, functions etc.

I understand some assembly, and I can probably figure out what a short function in assembly does. But when it comes to understanding a function that is hundreds of instructions in assembly well that is beyond me. This is where the LLM, comes in handy. It can better understand it then I would.

I installed the plugin, and setup the MCP config for the Codex CLI.

image.png

So I gave it a task, go look up the how the packets received and sent from the client is processed and write your findings to a file. And it went to town, looking up functions, xrefs, decompiling them, disassembling them. You name it. After that, it reported its findings.

And this gave me a monumental clue. Remember that unknow byte I talked about? That turned 01 for some reason. Well it was a flag that denoted the flag was compressed.

Those 4 bytes we ignored at the start, it seems were very important. Start of the packet, length of the packet, if the packet is compressed or not and without that clue about compression we would have been sitting ducks.

I gave another task to Mr. GPT5. Examine this compression/decompression and write me a script that would decompress/compress these packets. It came up with the conclusion that compression was an LZ4 compression and wrote the scripts that I wanted.

I tested it against the CharacterInfo packet that I had captured.

And what do you know, it worked. The thing that stumped me a year ago was vanquished. All the data was there, I could see them.

I captured more and more packets and ended up with this WIP response packet.

const (
    CharacterInfoPacketID = 0x6200
    nickname = "PrivateTest"
    clanname = "NULL"
    level = uint32(10)
    itemCount = uint32(25)
    killCount = uint32(11)
    deathCount = uint32(28)
    headshotCount = uint32(2)
    dinar = uint32(99999999)
)

// CreateCharacterInfoResponse creates the response packet containing character information
func CreateCharacterInfoResponse() []byte {

    data := []byte{
        0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x56, 0x47, 0x5f, 0x4c, 0x49, 0x56, 0x45, 0x32, 0x5e, 0x00,
        0x00, 0x00, 0x9d, 0x5c, 0xd0, 0x01, 0x5a, 0x00, 0x00, 0x00, 
    }
    
    strLen := uint16(len(nickname))
    data = packets.AppendU16LE(data, strLen)
    
    data = append(data, []byte(nickname)...)
    
    data = append(data, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,}...)
    
    strLen2 := uint16(len(clanname))
    data = packets.AppendU16LE(data, strLen2)
    
    data = append(data, []byte(clanname)...)
    
    data = append(data, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00,}...)
    
    // level is u32 on the wire (little-endian)
    data = packets.AppendU32LE(data, level)
    
    
    data = append(data, []byte{0x86, 0x0f, 0x00, 0x00, 0x49, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,}...)
    
    data = packets.AppendU32LE(data, dinar)
    
    data = append(data, []byte{0x00, 0x00, 0x00, 0x00,}...)
        
    data = packets.AppendU32LE(data, killCount)
    data = packets.AppendU32LE(data, deathCount)
    data = packets.AppendU32LE(data, headshotCount)
    
    data = append(data, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,}...)

        
    // item count is u32 on the wire (little-endian)
    data = packets.AppendU32LE(data, itemCount)

    
    
    data = append(data, []byte{0x20, 0x00, 0x42, 0x41,
        0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00,
        0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
        0x43, 0x41, 0x30, 0x31, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00,
        0x20, 0x00, 0x42, 0x41, 0x30, 0x34, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00,
        0xff, 0xff, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00,
        0x00, 0x00, 0x20, 0x00, 0x43, 0x41, 0x30, 0x34, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68,
        0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x51, 0x30, 0x31, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00,
        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5,
        0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x43, 0x4a, 0x30, 0x31, 0x00, 0x00, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x4a, 0x30, 0x31, 0x00, 0x00,
        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01,
        0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x42, 0x31, 0x36,
        0x00, 0x00, 0xff, 0xff, 0x01, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x47,
        0x30, 0x35, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x02, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00,
        0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
        0x44, 0x46, 0x33, 0x37, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00,
        0x20, 0x00, 0x44, 0x4c, 0x30, 0x31, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00,
        0x00, 0x00, 0x20, 0x00, 0x44, 0x45, 0x35, 0x32, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68,
        0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x38, 0x30, 0x36, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1,
        0xd3, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x42, 0x41, 0x30, 0x33, 0x00, 0x00, 0xff, 0xff,
        0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00,
        0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x4e, 0x30, 0x31, 0x00, 0x00,
        0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x03, 0x00, 0xff, 0xff, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01,
        0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x42, 0x30, 0x31,
        0x00, 0x00, 0x01, 0x00, 0xff, 0xff, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00,
        0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x42, 0x41,
        0x30, 0x32, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00,
        0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
        0x44, 0x52, 0x30, 0x31, 0x00, 0x00, 0x03, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00,
        0x20, 0x00, 0x44, 0x41, 0x30, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00,
        0x00, 0x00, 0x20, 0x00, 0x44, 0x4a, 0x33, 0x33, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68,
        0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x42, 0x41, 0x30, 0x35, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5,
        0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x43, 0x4a, 0x30, 0x32, 0x00, 0x00, 0xff, 0xff,
        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x36, 0x30, 0x34, 0x00, 0x00,
        0xff, 0xff, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0xc4, 0xd1, 0xd3, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x43, 0x30, 0x32,
        0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02, 0x00, 0xff, 0xff, 0x00, 0x00, 0x02, 0x00,
        0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x44, 0x46,
        0x30, 0x31, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00,
        0x02, 0x00, 0x6d, 0x01, 0x00, 0x00, 0x22, 0xf5, 0xce, 0x68, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
    }...)

    packet := packets.NewPacket(CharacterInfoPacketID, data)
    return packet.ToBytes()
}

Big blob of bytes in the end are Items and some other data that I am yet to decipher.

And it worked, when I entered the game's lobby next time. I had the dinar amount I specified, the name I specified, and all the other things. (Other than level, it seems to have more things going on with it.)

There is more to do still to get an actual working server. But this really opened up the gates to reach there.

Honorable Mentions

Using Ghidra's Scalar search for finding functions that had the values of OPCODE's of the packets were very useful. But GPT5 still struggled to get a working packet structure from these functions. It got close but it was not enough. Better models might get these right in the future.

So no packet structure you see above was not crafted by GPT5, it was hand-crafted by me. We humans are still very very good at pattern recognition. Also that thought gave me, another thought.

You know how, these LLM models hallucinate things. We also sorta do this when recognizing patterns, like "Hey, that stains look like a person or dog or whatever." even though it IS just a stain. Fascinating to think about.


Hey, people!!! I hope you enjoyed this little adventure of mine. If you have questions feel free to ask them in the comments.

I will come up with the Pt2, when I have progressed more in the reverse engineering of this server.

Sort:  

Nerd

Impressive use of AI. I didn't know it could do that.

MCPs give many abilities to AIs and personally this was also the first time I used an MCP as well.

Nerd.

Read it first lmao

You don't believe I already have? 😂

Alright, I believe you.

Just go on I like it perfect very good

Congratulations @mrtats! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You made more than 900 comments.
Your next target is to reach 1000 comments.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP