9 minutes
PokeMMO Packet Snooper 1
Intro
Before starting and writing our packet snooper we wanna set up our workspace. We will need to write tooling that can extract the core PokeMMO.jar so we can analyze it with a bytecode inspector of our choice.
Extracting the Jar
Upon installing PokeMMO on any OS you will end up with a file called PokeMMO.exe
. This is just a wrapped Jar file that is used on Mac OS, Linux, and Windows. A Jar file is just a fancy Zip archive containing all classes and resources. So we just need to find the magic value of the Zip archive (\x50\x4b\x03\x04
), calculate its file size, and then just save the chunk of data to a new file.
Here is a little Python script that does exactly that:
import io
# const values such as input/output file
IN = "PokeMMO.exe"
# Binary data representing a zip archive
data = open(IN, "rb").read()
# Find the start of the zip archive
zip_start = data.find(b'\x50\x4b\x03\x04')
zip_eocd = data.find(b'\x50\x4b\x05\x06')
# read comment length from end of central directory
zip_end = zip_eocd + int.from_bytes(data[zip_eocd+20:zip_eocd+22], byteorder='little') + 22
# Extract the zip archive from the binary data
zip_bytes = data[zip_start:zip_end]
# Create a bytes object from the binary data
zip_bytes = io.BytesIO(zip_bytes)
new_zip = IN.replace(".exe", ".jar")
open(new_zip, "wb").write(zip_bytes.read())
More about the Zip file structure
bytecode, Bytecode, BYTECODE
Java code is compiled into bytecode which is then executed by the JVM. This bytecode is not OS-specific and can easily be decompiled back to somewhat valid Java code by decompilers like Jadx, Fernflower, or Procyon. If the decompilation fails we can always fall back to reading the bytecode. It would be good to understand the basic concepts of Java bytecode you don’t need to know every single instruction, you can always look them up here or here.
I will use Cole-E’s Recaf which is a really good program that includes many decompilers and extra features that may come in handy.
Let’s start our journey by looking around. We will find many known libraries like logback
, LibGDX
, and lwjgl
.
But let’s start at the beginning, where is our entry point? We need to take a look at /META-INF/MANIFEST.MF
to get the Main Class.
Manifest-Version: 1.0
Main-Class: com.pokeemu.client.Client
Multi-Release: true
com.pokeemu.client.Client
only contains the main
method. In the main
method, we can see many short-named classes, methods, and fields. This means PokeMMO uses name obfuscation but luckily no other obfuscation methods. Any name you may see in this post will 100% change after updates. You can rename these obfuscated names in Recaf, which will make our lives a lot easier. Now that you know how to navigate the code in Recaf we can start searching for the packet manager.
Networks and more
Like most games, PokeMMO uses a TCP connection to communicate with its login server and its game server. If we want to intercept packets we need to find where PokeMMO writes to its TCP Sockets. PokeMMO uses Java NIO, you can check for Commen Network APIs like NIO or NIO2 by searching for references to these apis. Since we know its using NIO we just need to check for methods that invoke the SocketChannel.write(Ljava/nio/ByteBuffer;)I
method. By searching for references for this method we find a method called x40
. This method is called by the NetworkManager when the socket is ready to write.
Here is a clean version of the method that calls SocketChannel.write(Ljava/nio/ByteBuffer;)I
:
public final void x40(final SelectionKey selectionKey) {
final SocketChannel socketChannel = (SocketChannel)selectionKey.channel();
final jm0 socketHandler = (jm0) selectionKey.attachment();
final ByteBuffer buffer = jm0.ki0;
// send remaining data
if (buffer.hasRemaining()) {
try {
if (socketChannel.write(buffer) == 0) return;
// if there is still data to send exit
if (buffer.hasRemaining()) return;
}
catch (final IOException ex) {
this.yN(socketHandler);
return;
}
}
while (true) {
buffer.clear();
// check if there is data to send or not
boolean hasNoData = false;
try {
// write data to buffer
hasNoData = socketHandler.cOm8(buffer);
}
catch (final Exception ex2) {
S9.mE0.error(op.kT("Write Error: ").append(socketHandler.cp).toString(), ex2);
hasNoData = true;
}
if (hasNoData) {
buffer.limit(0);
synchronized (socketHandler.Wd0) {
if (!socketHandler.kW()) {
// remove OP_WRITE from interestOps
selectionKey.interestOps(selectionKey.interestOps() & 0xFFFFFFFB);
}
return;
}
}
// send actual data
try {
if (socketChannel.write(buffer) == 0) return;
if (buffer.hasRemaining()) return;
continue;
}
catch (final IOException ex3) {
this.yN(jm0);
}
}
}
The really interesting part here is hasNoData = jm0.cOm8(buffer);
which writes data to our buffer. c0m8
is an abstract method let’s look at one implementation. I skipped renaming some local variables and commented the decompiled code. You can find all that out by looking at some implementations of Jk0
and Lx0
.
@Override
public final boolean cOm8(ByteBuffer byteBuffer) {
synchronized (this.Wd0) {
if (this.DT == 1) {
return super.cOm8(byteBuffer);
}
KN packet = (KN) this.eE0.pollFirst();
if (packet == null) return false;
// write dummy packet size
byteBuffer.putShort(0);
// write packet ID
byteBuffer.put((byte) packet.ql0);
// write packet data
packet.Jk0(this, byteBuffer);
// set position to 0 and limit to old position
byteBuffer.flip();
// skip packet size again
byteBuffer.putShort(0);
// encrypt data and get new data size + 2
short packetSize = Lx0(byteBuffer) + 2;
// write the actual packetSize to position 0
byteBuffer.putShort(0, packetSize);
// correct limit
byteBuffer.position(0).limit(packetSize);
return true;
}
}
We don’t want to understand the whole encryption scheme and packet protocol. For now, we are fine with just finding the object representation of packets.
KN packet = (KN) this.eE0.pollFirst();
gets the packet from a queue so let’s see where packets are added to the queue. Just by looking around in this class, we find this gem:
public final void Mz0(final KN packet) {
if (El.Tn > 0) {
// schedule future task
wn.Py.Rm0(() -> {
synchronized (super.Wd0) {
if (!this.Lv()) {
this.eE0.addLast(packet);
this.B20();
}
}
}, El.Tn);
return;
}
// add packet to queue
synchronized (super.Wd0) {
if (this.Lv()) return;
this.eE0.addLast(packet);
this.B20();
}
}
We can hook this method, and print its argument to log packets that get sent. But wait, this is only one packet manager the game has more. There are 2 other packet managers, you can find them by getting the super-class of this packet manager and looking into all classes that use this super-class.
Writing the Snooper
I will be using Kotlin as a programming language and IntelliJ IDEA with Gradle as a build system. You can also use Java but I prefer Kotlin.
Gradle
This is the Gradle build file, please remember to include the PokeMMO.jar
we extracted in the beginning. We also use ByteBuddy to hook our target method. It’s just easier than writing an agent and transforming them ourselves. We also create a new task named runSnooper
which sets our main class to our class as well as our working directory to the PokeMMO installation folder. This is needed since PokeMMO won’t run otherwise. If you want to start our Snooper you will need to run this new task.
build.gradle.kts
:
plugins {
kotlin("jvm") version "1.8.0"
}
group = "de.fiereu"
version = "1.0"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
// ByteBuddy
implementation("net.bytebuddy:byte-buddy:1.14.4")
implementation("net.bytebuddy:byte-buddy-agent:1.14.4")
// PokeMMO
implementation(files("libs/PokeMMO.jar"))
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(8)
}
// create task which runs the Snooper.kt main function and sets its working directory to "C:\Program Files\PokeMMO"
val runSnooper = tasks.register<JavaExec>("runSnooper") {
mainClass.set("SnooperKt")
classpath = sourceSets["main"].runtimeClasspath
workingDir = file("C:\\Program Files\\PokeMMO")
}
Running PokeMMO
To start PokeMMO from our code we just make a main method and call the PokeMMO main method, like this:
Snooper.kt
import com.pokeemu.client.Client
fun main() {
Client.main(emptyArray())
}
Just run runSnooper
and PokeMMO should start just fine.
Placing Hooks
Firstly we need a list of the methods we wanna hook. I will just hardcode them. You could add a scanner that scans for the right methods to hook.
val SEND_PACKET_METHODS = arrayOf(
"f.el#Mz0(Lf/KN;)V",
"f.Nh#Qy0(Lf/Qm;)V",
"f.Ip#Lu0(Lf/hr0;)V",
)
Next, we initialize ByteBuddy and install its Agent. ByteBuddy needs a static intercept
method to call from our hook. Creating an object and marking the intercept
method as @JVMStatic
will do the trick. @Advice.OnMethodEnter
specifies the hook location and @Advice.Argument(0)
which argument from the original method to pass to the hook.
object SendInterceptor {
@JvmStatic
@Advice.OnMethodEnter
fun intercept(@Advice.Argument(0) packet: Any) {
println(packet)
}
}
fun initHooks() {
ByteBuddyAgent.install()
val bb = ByteBuddy()
// Send Hooks
for (method in SEND_PACKET_METHODS) {
val clazzName = method.substringBefore('#')
val methodName = method.substringAfter('#').substringBefore('(')
val clazz = Class.forName(clazzName)
bb.rebase(clazz)
.visit(
Advice.to(SendInterceptor::class.java).on(ElementMatchers.named(methodName))
)
.make()
.load(clazz.classLoader, ClassReloadingStrategy.fromInstalledAgent())
}
}
At this point, we can log packet names but that alone is…
Making it cooler™
We can make it cooler by printing all fields of the object. If we encounter another object print its fields -> recursion YEAH.
Keep in mind that we don’t want to end up in an endless loop so we need to keep track of which objects we already visited. It can only be cool if it somewhat looks good so I added the indent parameter so everything gets moved in by 4 spaces times the indent. We check for the java.lang package because primitives can also be represented as java.lang.Boolean
, java.lang.Integer
, etc. Also we don’t need the fields of java.lang.String
.
private fun recursivePrint(obj: Any, sb: StringBuilder, indent: Int, visitedObjects: MutableList<Any> = mutableListOf()) {
if (visitedObjects.contains(obj)) {
return
}
visitedObjects.add(obj)
val indentStr = " ".repeat(indent * 4)
val fields = obj.javaClass.declaredFields
for (field in fields) {
try {
field.isAccessible = true
} catch (e: Exception) {
continue
}
val value = field.get(obj)
if (value != null) {
sb.append("\n")
sb.append(indentStr)
sb.append(field.name)
sb.append(": ")
if (value.javaClass.isArray) {
sb.append("[")
// we need to write java.lang.reflect... here bc of name shadowing
for (i in 0 until java.lang.reflect.Array.getLength(value)) {
if (i != 0) {
sb.append(", ")
}
sb.append(java.lang.reflect.Array.get(value, i))
}
sb.append("]")
} else if (!value.javaClass.isPrimitive && !value.javaClass.name.startsWith("java.lang") && !value.javaClass.name.startsWith("java.util")) {
sb.append(value.javaClass.name)
sb.append(" {")
recursivePrint(value, sb, indent + 1, visitedObjects)
sb.append("\n")
sb.append(indentStr)
sb.append("}")
} else {
sb.append(value)
}
}
}
}
fun printObj(obj: Any) {
val sb = StringBuilder()
sb.append(obj.javaClass.name)
sb.append(" {")
recursivePrint(obj, sb, 1)
sb.append("\n}")
println(sb.toString())
}
If you want to get the full code you can find it here. The code may get updated after future blog posts.
The End… Or not?
I hope this gave you a little insight into how to reverse PokeMMO and how you can hook methods using ByteBuddy. In the next part, we will find and hook the receive method. If you got any questions you can find my contact info on my About page.