Reverse Engineering Coin Hunt World’s Binary Protocol
We are going to walk through the process we took to reverse engineer parts of the Android game Coin Hunt World. Our goal was to identify methods and develop tooling to cheat at the game. Most of the post covers reverse engineering the game’s binary protocol and using that knowledge to create tooling for converting the binary protocol into something more human readable. With the ability to decode and replay packets from this protocol, we will then look into how we can cheat at the game. From this post you should get a sense of the process we took to reverse engineer the game and how to use that knowledge to develop tooling that will assist in understanding the game.
Coin Hunt World is an Android/iOS free-to-play and play-to-earn Geolocation game. The players walk around the real world searching for vaults to unlock. Once unlocked, the player will be asked a question from various categories such as mathematics, entertainment, etc. and if this question is answered correctly they will receive a small amount of cryptocurrency. Unlocking vaults requires keys, which can be obtained by unlocking vaults or completing daily walking challenges. To receive the cryptocurrency, you first must obtain 10,000 Resin to use for connecting Coin Hunt World with Uphold, a digital trading platform. After this has been done, your cryptocurrency will be automatically transferred to your Uphold account every Tuesday.
Lets walk through the normal game flow now, to visually demonstrate what the gameplay looks like. After creating an account, and logging into the game, you are presented as a player in this virtual world.
You can then move around this virtual world looking for vaults to unlock.
Once you find one, a key needs to be used to unlock the vault. At this point the game will let you choose a category and ask you a question related to that category.
If the question is answered correctly the user will receive a reward such as a small amount of cryptocurrency.
Each unlock of a Vault will consume a key but new keys can be earned by completing walking goals each day. There are various other aspects to the game but this describes the core functionality.
From a point of attacking the game, it would be interesting to somehow be able to directly modify the amount of cryptocurrency we have, spoof how much we have walked to earn keys, or to modify our location to open Vaults that we are not physically close to.
Intercepting Network Traffic
Before cheating at the game we first need to understand how it works, and most importantly how the game manages state, such as cryptocurrency or how much we have walked. Before doing any reverse engineering, we wanted to play the game to see how the various functionality works and what the network traffic looks like. To do this we routed our traffic through Burp, and started to use the application. We were kind of surprised at how little network traffic this was giving.
None of these requests appeared to contain details for tracking the state of the game. So it seemed likely that there was another channel for communication.
With no interesting traffic being sent to the proxy, there must be some other communication happening outside of our view currently. So we spun up Wireshark to get a better view of what was going on. This can be done by pushing an ARM version of tcpdump to the Android device, and executing (on MacOS):
adb shell 'su root -c /data/local/tmp/tcpdump -i wlan0 -w -" | /Applications/Wireshark.app/Contents/MacOS/Wireshark -k -i
From looking at the traffic, a few interesting things come up. There is unencrypted HTTP traffic on port 8000 and some unknown protocol being used over TCP on port 9933. The HTTP traffic is easy to intercept by setting up a rule in iptables to reroute it to our proxy.
iptables -t nat -A OUTPUT -p tcp -m tcp --dport 8000 -j REDIRECT --to-ports 3000
This gives a little bit more details but not what we are looking for. It seems like the HTTP traffic is just used to track the user with a POST request to /api/v1/user/tracking containing the longitude and latitude coordinate of the user.
Now lets investigate what is happening on port 9933.
Investigating port 9933
We started by looking at the captured traffic in Wireshark, looking for any patterns in the protocol to help understand it.
From comparing several different packets, we can start to understand the header to some extent. Most of the packets start with the byte 0x80 but some of them have 0xA0. In binary it would look like this:
0x80 – 1000 0000
0xA0 – 1010 0000
There is a noticeable difference between the traffic sent with a 0x80 and 0xA0 headers.
For the 0x80 header, ASCII encoded strings can be observed in the traffic and there are some patterns to how the data is structure but the 0xA0 traffic looks much more confusing and difficult to parse out any meaningful information. We can get an idea of whats happening by calculating the entropy of the traffic. This can easily be done with a free data analysis tool like CyberChef.
From this we can see that the data is very random, which clues us in that this is probably encrypted or compressed traffic. Taking a look at the binary representations of 0x80 and 0xA0 from before, we can make the assumption that the 6th bit of the header is used as a flag to send data in this way.
The meaning of the next two bytes were pretty easy to determine. For large packets this value would be high, and for small packets it would be low. From taking the value and counting out the bytes in a small packet, it ended up matching perfectly. So these 2 bytes are used for the length of the data being transmitted over this protocol.
Other than this, it was difficult for us to understand what the rest was doing. We will need to jump into the code to understand the rest of the protocol data.
Initial Binary Investigation
First we need to get the APK from the device. The location the APK is stored at can be determined with
pm path <package_name>.
It turns out that the application is split between 3 APKs. So we will need to inspect each one to get an understanding of the application. Based on the APK names, it looks like we are dealing with a game built with the Unity framework. In order to reverse engineer it, we will need to understand how it works and use the appropriate tooling to get readable code. For a cursory look, we just ran
apktool d apk_file.
This will extract the contents of the APK, decode the AndroidManifest.xml file and decode the classes.dex files into Smali code. Smali is the intermediate representation for the Dex format that is used by the Dalvik Virtual Machine (DVM), or more recently, the Android Run Time (ART). Usually, developers will write applications in Java which gets compiled and converted into Dex byte code. Decompiling the Dex byte code back to Java is more error prone and will likely not recompile correctly. Whereas decompiling to Smali will give a more exact representation, which can then be modified and compiled back into a working Dex file. Now lets take a look at the interesting things contained in each APK file.
This file contains some assets, resources and the Smali code. The most important part is
the file located at base/assets/bin/Data/Managed/Metadata/global-metadata.dat. This file contains strings and function names, which are necessary for reversing the game. For more details on how this file is loaded and used, check out Katy’s blogpost at https://katyscode.wordpress.com/2020/12/27/il2cpp-part-2/. We would also recommend reading the other il2cpp posts on her site if you are interested in understanding these type of games better.
This APK pretty much just contains some assets. These assets will be useful for later.
This APK contains all of the shared libraries (.so files). In here the most interesting to us is the libil2cpp.so file. This shared library will be used with the global-metadata.dat file to help with analysis.
Unity and IL2CPP
Unity games for Android can be distributed in 2 different ways. The standard way is it
that is is built with various .dll’s which can be decompiled back into C# code. The other way
is by using il2cpp. This essentially compiles the game to native code, which should
improve performance of the game. There are various tools that can help with analysis, but we will be using cpp2il. The advantage of this tool over others is that it gives pseudo-code for each of the functions. However, take the output with caution (at least for ARM binaries), since the pseudo-code is not always accurate. You can also use il2cppdumper to modify the disassembly in Ghidra to help with analysis.
There is just one issue to solve before running cpp2il. The tool accepts 1 APK as input, yet we have 3 APKs, each with different parts of the full application. To handle this, we just created a new APK and copied in all of the necessary components. The main things that cpp2il needs to run are the libil2cpp.so, global-metadata.dat and the Unity assets that are referenced. So we just copied all of the assets from base.apk and split_UnityDataAssetPack.apk into split_config.arm64_v8a.apk. This does not produce a runnable application but it gets an APK that can be analyzed properly by cpp2il. The output is various text files that contain pseudo-code. Here is an example of what some of the output looks like.
Looking for Network Communication
In order to connect over the network, the application must be making use of sockets. So we will use this as an entry point. We used ag to search through the previously decompiled pseudo-code for uses of Socket. The tool ag is pretty similar to grep but it is quite a bit faster and has better default printing. Results from running
It seems that 2 libraries are making use of Sockets, those are System and SmartFox2X. Since System is included in all Unity games, it makes sense to start looking into what SmartFox2X is.
From searching SmartFox2X, we can find the website https://www.smartfoxserver.com/. To quote the website “SmartFoxServer is a comprehensive SDK for rapidly developing multiplayer games and applications”. From navigating the website, we can find a section about the SmartFoxServer 2X protocol.
It shows on this page that it has a default port of 9933, which matches our observations of the network from earlier. There are also some details about the types of data that can be transmitted using this protocol. The website has some details on what the protocol is used for but to be able to fully understand it, we will need to reverse the code. Luckily, they share the client libraries on their website. This will be a lot easier for reversing since the tooling for decompiling a JAR file is much better than for decompiling il2cpp code. The client library is written in other languages but we are most comfortable with reversing Java, so that is where we will begin.
Starting Point to Reverse
The first thing to be done is decompile the SmartFox2X client library. To do this we just used jadx. Luckily for us, the library still has all of the symbols, which will be very helpful for reverse engineering. The goal is to understand the protocol being used on port 9933. From looking at the class names,
DefaultSFSDataSerializer seems like a good place to start since the protocol likely does some serialization that it sends out over the wire. This class contains a lot of interesting methods but
decodeObject() seems to be a promising starting point since it takes a byte array as input. We’ll come back to describing this function later, but for now just know that it is used to parse objects such as integers, arrays, etc. from the byte array. We will first traverse up the call chain to reach a spot that reads the header of the data being transmitted then reverse engineer back toward the
decodeObject() function and a few more relevant functions that it calls.
Finding the PacketHeader
From traversing up the call chain, we get to a function called
onDataRead() in the class
SFSIOHandler. This appears to be the code that handles the data for each packet sent over the network.
To understand the flow, we must understand what
getReadState() does. The
getReadState() function returns the variable
currentState from the
FiniteStateMachine class. The
SFSIOHandler class actually has an initialize function which initializes the state and transitions of the
So there are 5 states (0,1,2,3 and 4) and 7 transitions between those states. So for example, applying transition 4 will cause the state to change from 3 to 0. The function
getReadState() will get one of the 5 states and during the parsing of the data from the TCP packets, the state transitions will be applied to move through the states. The diagram below makes visualizing this flow a bit easier.
The parser is initialized with state 0. So when a new packet is received by the
onDataRead() function, it will first call the
It first does an error check on the header then calls
createPacketHeader(), which creates a
This is essentially just checking if various bits are set in the header and passing them as a boolean values into the
PacketHeader constructor. From there we can easily tell what each bit represents.
From earlier, we had sniffed traffic for this protocol and saw both 0x80 and 0xA0 headers. These breakdown as follows:
0x80 = 1000 0000 – Binary flag is set which seems to always be the case
0xA0 = 1010 0000 – Binary and compression flags are set
At this point the
PacketHeader object is incomplete since it does not contain the length of the data that will be received. Looking at the end of
handleNewPacket(), we see that transition 0 is applied which causes a state change from 0 to 1. So on the next iteration of the while loop,
handleDataSize() is called.
Based on the flag for
bigSized, the code will read the length in as either an integer or a short and set the length for the
PacketHeader object. If the
dataSize of the packet is not -1, then the packet contains all of the data needed for the deserialization and will apply transition 1 to move to the
handlePacketData() function. Otherwise, we are dealing with a fragmented packet and transition 2 will be applied to move to the function
As more packets are received, this function will append those bytes to the buffer of the
pendingPacket. This is done until a packet is received with a length greater than or equal to the remaining bytes. After, transition 3 will be applied, which will cause the code to jump to
handlePacketData(). At this point, all of the data has been received and it can now be deserialized into an object.
Decoding the Messages
At this point, the payload header has been parsed which gives details about the length and format of the data to be received. Using that information the entire payload can be placed into a buffer decompressed/decrypted so that it can be converted into an object that can be used by the application. The starting point for this is
This function will first decrypt the payload (Not used in this app), decompresses the payload if necessary and then it passes it into
From here we can see the start of where the payload gets deserialized into an
SFSObject through the call to
SFSObject is an object that contains other objects or types such as integers or strings. When deserializing a data stream, it will always begin with an
SFSObject and may contain several more nested
SFSObjects. There are few function calls but the next area that gets interesting is the function
The first byte is for the
headerBuffer, which is checked to ensure that it is of type
SFSObject. So only payloads starting with an
SFSObject are accepted. A size is read from the next 2 bytes (short) and then used in a for loop. On each iteration another size is read from the buffer, which is used to read a key. The value for this key is decoded from the buffer from the call to
decodeObject(). So this essentially goes through each key in the data stream and decodes the data that follows it. At this point we are back at the function we initially started to reverse engineer from.
This is another important function and coordinates a lot of the deserializing. There is a huge if-else clause which essentially acts as a switch statement. A header byte is obtained from the buffer which is used in this switch statement to determine how to deserialize the next data element. The function
getTypeID() returns an integer that corresponds with the given type. This seems to be used to decode all the primitive types (int, String, double, etc.) but also an
SFSArray (an array that can contain different types of data) type. Most of the specific decoding functions are in the form
binDecode_TYPE(buffer). Each type then corresponds to a value between 0 and 20 and the following values are defined in the
So if the
headerByte is equal to 4, then the code in
decodeObject() will call
binDecode_INT() to decode an integer.
This is a very simple function that just reads an integer (4 bytes) from the buffer and wraps it in the
SFSDataWrapper to be returned. This returns back to the
decodeSFSObject() function where it is set as the value for the key that was previously read.
So an integer with the value of 147 would look like the following in the TCP packet.
04 00 00 00 93 – x04 is the integer type ID and x93 is 147 in hexadecimal
This call chain is recursive for
SFSObjects which allows for nesting
SFSObjects within each other. Similarly,
SFSArrays make calls to
decodeObject() allowing for nesting. With these details in mind, we now have all the necessary info for converting the binary payload sent over TCP back into an object used in the game.
Example Object Decoding
To summarize what we have learned so far, lets manually walk through decoding a message. The following is the hexadecimal representation of a message:
This message can be broken down like the following:
First up is the header byte which is 0x80. So we are just dealing with binary data with no compression or encryption used. We also know from the header that the next thing to do is read a short from the buffer, 0x0042, which represents the size of the payload. At this point is when the buffer is passed into
decodeSFSObject(). The first step was grabbing the object header byte, 0x12 and making sure that it represents the
SFSObject data type, which it does. Then for each of the 3 elements in the
SFSObject, a key is read then the buffer is passed into
decodeObject to read the object (i.e.
SFSObject, int, byte, etc.). For the
SFSObjects in the data stream,
decodeSFSObject() will be called recursively to parse nested
SFSObjects. The final result is an object that looks like this:
(sfs_object) p: (sfs_object) p: (bool) success: true (utf_string) c: user.updateUserActivity (short) a: 13 (byte) c: 1
Hooking Functions to Decode Traffic
Now that we understand how the protocol works, lets look at how we can utilize the
JAR file to facilitate decoding the traffic. To do this we will import the JAR into a Java project and make calls to the necessary functions that we reversed earlier. The first step is reading in a pcap file and iterating over each TCP message on port 9933 and extracting the data section of the packet.
Previously, we saw that the
SFSIOHandler was the starting point for processing packets. So we need to instantiate the class. The constructor takes a
BitSwarmClient object as its only argument.
Initially, we tried to instantiate the
BitSwarmClient with its empty constructor and pass that object into the
SFSIOHandler‘s constructor but this gave an error. So we looked into how
SFSIOHandler was being instantiated.
This is ran within the initialize() function of the
SmartFox class. this is a Java keyword used to reference the current object, so in this case, the
SmartFox object. So we should be able to get a reference to a correctly initialized
BitSwarmClient by instantiating the
SmartFox class first, then using that to grab a reference to the
BitSwarmClient. First we need to check to see if the
SmartFox constructor takes any non primitive data types that may cause further issues for us.
Great! This only takes a boolean value. This value looks to be used for debugging, so may even be helpful to set as true as we develop the code to get insight into any issues we run into. We also see that at the end of the constructor, the initialize() function is called which will initialize the
BitSwarmClient. So after instantiating the
SmartFox class, we can utilize the
getSocketEngine() function from the
SmartFox class to obtain a reference to the
So now we have a reference to the
BitSwamClient object. Now to get a
SFSIOHandler object we could initialize a new instance but from earlier we saw that the
BitSwarmClient sets the
this.bitSwarm.setIoHandler(new SFSIOHandler(this.bitSwarm));)). So we can actually just use the following function from the
BitSwarmClient to obtain a reference to a correctly initialize
Putting this all together we can get a reference to an
SFSIOHandler object which we can then utilize for decoding.
With the handler set up, we can start working on parsing the data from the TCP packets.
From reversing earlier, we discovered that the entry point for handling the data from the TCP packets was the
onDataRead() function of the
SFSIOHandler class. So we can now call that directly using our handler.
From earlier, recall that the handler used a Finite State Machine for handling each step in the parsing process. This is relevant when instrumenting this functionality since the game messages may span 2 or more TCP packets. So we need to be able to know when an entire message has been parsed so that we can extract the
SFSObject to print its contents in a more human readable form. To begin, we need to obtain a reference to the
FinitStateMachine (fsm) variable from the
SFSIOHandler class. Unfortunately, the fsm variable is private and the
SFSIOHandler class does not have any getters for accessing this variable. Luckily, Java has some convenient functionality where you can take private variables and make them accessible. This can be done with the following code.
Now that we have a reference to the
FiniteStateMachine, we can check the state after each packet has been processed to see if the entire message has been received. The
FiniteStateMachine exposes the
getCurrentState() function to do this. Recall from the finite state machine diagram from earlier that the once the data has been deserialized into a
SFSObject the state is set back to 0. So we can use this to signal when all of the data has been received for a given message.
Lastly, we need to get the
SFSObject. Unfortunately, we could not extract it directly from the handler, but we can get the buffer of the pending packet and use that to deserialize the
SFSObject. This buffer is a private variable so the same technique used for the
FiniteStateMachine was needed to access it.
Now we can make use of the
getDump() to print out a readable version of the
SFSObject. Putting all of these pieces together (forgoing error catching for simplicity) looks like the following.
Now lets test it out and see what the output looks like.
With this program we can start analyzing the game traffic and begin thinking of ways to attack it.
Putting all of that together, we created a tool which can be used to decode pcap files or used as a proxy to analyze and replay requests. Using the tool we analyzed the network traffic to better understand how the application worked. From that analysis it was discovered that the server sets the state of most of the interesting things to attack. So when going through the vault unlocking process, the server will send to the client how much cryptocurrency the user has acquired. Since there did not seem to be anyway to set this from the client, that attack did not seem possible. There are a couple of things that the server cannot set for the client. These are the GPS coordinates and the walking pattern used for acquiring more keys.
Spoofing the GPS coordinates is easy to do and allows you to open vaults without being physically close to them. This can done fairly easily on Android by enabling Developer mode and choosing a mock location app.
From within the mock location app, set your location to near a vault. Then when you start up Coin Hunt World, you will be right next to the vault. All of the vault locations nearby can be captured from a request from the server. A single entry of what this looks like is below.
(sfs_object) (double) lng: -79.57888 (long) tier: 1 (bool) action: true (long) id: 317744 (int) state: 0 (utf_string) type: reg_v (null) custom_data: null (double) lat: 43.581177 (long) key: 1
This can be used with the GPS spoofing app to easily navigate to available vaults.
Spoofing walking is a bit more complicated. While walking, the application will make requests like the following.
(sfs_object) p: (sfs_object) p: (int) running: 10 (long) start_time: 1659970831567 (double) lng: -79.5733108520508 (int) stationary: 10 (int) walking: 10 (int) in_vehicle: 10 (int) cycling: 10 (int) steps: 8 (double) lat: 43.5827903747559 (int) unknown: 10 (int) r: -1 (utf_string) c: user.updateUserActivity (short) a: 13 (byte) c: 1
This request is used to tell the server that the user has been walking and give data associated with that. The client will also periodically make the following request to check in on how close they are to completing the next milestone.
(sfs_object) p: (sfs_object) p: (int) r: -1 (utf_string) c: user.validateUserSteps (short) a: 13 (byte) c: 1
and the server will respond with something like this.
(sfs_object) p: (sfs_object) p: (int) prev_milestone: 0 (bool) milestone_reached: false (bool) success: true (int) steps: 79 (int) next_milestone: 500 (utf_string) c: user.validateUserSteps (short) a: 13 (byte) c: 1
This is mostly used to update the UI so that the user knows how far until they reach the next milestone. From observing this behavior, we noticed that the
user.updateUserActivity command would increase the number of steps taken by the value for the steps parameter in that command. So with this information we began doing some tests to try and increase our step count. Initially, we attempted to replay a single request, but unfortunately the step count stayed the same. So they are doing some validation on the parameters sent to try and prevent cheating. Next the GPS coordinates were modified slightly but still the result was the same. The last single replay test was done by including updated timestamps in the replay but this did not work either.
With no success from replaying individual requests, it was time to attempt replaying a sequence of captured packets to try and achieve the walking milestones. This was meant to mimic walking as to bypass the validation that is done on the server to see if a user is actually walking. To get the requests to repeat, we first walked around and captured the sequence of packets to be used. After, we modified the code for the proxy slightly so that we could replay those requests with a push of a button. For each request, the timestamp was modified to the current time and a delay was set for the difference in time between each packet to mimic how the requests were originally sent out. Finally, we were able to complete a walking milestone without walking!
So now we can play the game without moving physically. We can obtain new keys by replaying previous captures of walking and can open vaults by spoofing our GPS coordinates to the positions that the vaults are located.
The methods for cheating discussed in this post are interesting since it is not possible to completely stop them. GPS coordinates are controlled by the user’s device so spoofing them is always a possibility. The best measures to protect against this type of attack is through anti-reversing protections and cheat detection.
Encryption should be used for all network communication. Not only does this make intercepting and modifying traffic easier, it also leaves the user’s data open to an attacker passively listening on the network. The application can also perform checks on the device to see if it is running in a safer environment. Some of these checks could be to see if the device is rooted, emulated or currently spoofing the location. If any of these checks are true, return a message to the user and do not execute the application. It may also be worth recording a unique device identifier to identify users that working toward bypassing these checks.
To detect spoofed locations, some heuristics can be employed. Calculating the distance a user has traveled in a given period of time can be used to determine if a user is moving around the world faster than possible. The variation of the GPS coordinates could also be used. The exact location should vary slightly due to the nature of the GPS technology. If a user is consistently reporting the same GPS coordinates, it could indicate that they are replaying previous requests. On top of the previous heuristics, machine learning models can be trained and used to identify anomalies in location data. These models should be trained on known good data. All of these should be targeting long term patterns for a user and not single suspicious instances. This should help distinguish between active cheaters and errors in reporting due to equipment.
Throughout this post we went through the process of reverse engineering the game Coin Hunt World and most importantly how the game tracks state. To do this, we identified all network communication then reverse engineered and developed tooling to properly decode a third party binary protocol. We then looked into methods of cheating by spoofing GPS coordinates and replaying packets to complete the walking milestones. Researching games in the play-to-earn space can be interesting, because cheating can turn virtual assets directly into real money. More work can be done looking into other play-to-earn games to identify weaknesses in their earning model. NCC Group regularly provides security testing against games and gaming ecosystems to help developers find flaws in their systems and patch them.
- February 14th, 2023 – Initial disclosure of the unencrypted communication and location spoofing.
- February 14th, 2023 – Responded that they will look into and fix the unencrypted communication and that there is a team that monitors movement and removes cheaters every day. These algorithms are constantly being refined.
- May 11th, 2023 – Received confirmation that the unencrypted communication was fixed as of the end of April.
About NCC Group
NCC Group is a global expert in cybersecurity and risk mitigation, working with businesses to protect their brand, value and reputation against the ever-evolving threat landscape. With our knowledge, experience and global footprint, we are best placed to help businesses identify, assess, mitigate respond to the risks they face. We are passionate about making the Internet safer and revolutionizing the way in which organizations think about cybersecurity.