A couple of months ago I took a crack at the Maze challenges in the CSCG 2020 CTF and thought a few of the challenges were really interesting, so I wanted to share how I solved them.

I found out about the Maze challenge while watching LiveOverflow’s Pwn Adventure 3 video series. The Maze challenge was created by LiveOverflow, so I figured there would be some similarities to the Pwn Adventure 3 CTF and thought it would be a good opportunity to try some online game hacking (without the risk of getting accounts banned :p).

This gave me a chance to get into some Unity game hacking, as well as Cheat Engine. I was familiar with the basic concepts of Cheat Engine, such as dynamic memory scanning and pointer scanning, but I hadn’t used it much before.

I was able to solve the first few challenges just using standard Cheat Engine techniques. The first was the “secret emoji” challenge.

Emoji

When you first start the game and make an account you’re only able to use two emojis:

Emoji bar

This game was built with Unity, which is based on the Mono development framework. Using Cheat Engine’s Mono dissector, which is a built-in feature for analyzing metadata about object classes in a Mono binary, I found that there was a sendEmoji method on the server class. By setting a breakpoint on this function I could intercept calls to it and change the first argument, a 16-bit emoji ID.

I solved it by pressing the button for one of the available emojis and changing the argument register (RDX) to a new value each time using the debugger, and then observing what showed up. Eventually I reached the ID value of 0x0D, which resulted in the flag emoji being triggered. Here’s what it looked like:

Cheat Engine debugger window and solution

And here’s how the emoji looks, since I didn’t capture it in the first screenshot:

Emoji flag graphic

Flying & Teleporting (across very short distances)

The next two challenges, “The Floor is Lava” and “Tower”, involved getting to hard to reach locations. I figured that in order to attempt this I should try to use Cheat Engine to figure out my player character’s coordinates in memory and see how I could tamper with them.

First I used the memory scanning feature to discover the player’s coordinates, and then I used pointer scanning to discover a reliable way to reference the player object in memory so that I wouldn’t need to find it again every time. There are a bunch of videos online about how to do this, here’s the one from the Pwn Adventure 3 series: https://youtu.be/yAl_6qg6ZnA.

There’s a fence in the maze at the very beginning of the path to these locations that blocks you from proceeding. This was good for testing out a simple teleport hack.

The fence

Once I had the coordinates visible in the Cheat Engine address view, I could watch how they changed as I approached the fence. I noticed that the Z axis coordinate increased as I approached it, so once I got stuck I edited that value and increased it by 1.

This didn’t cause any issues, the server accepted the small change in location despite there being an obstacle in the way in the game client. I also tried setting my coordinates to 0, 0, 0, but that caused the server to teleport me back to my previous location. There was clearly some sort of distance limit on how far you could go between position updates.

To do a simple fly hack I used the “Memory that writes to this location” feature on the Y coordinate, which corresponds to altitude, and NOP’d out the instructions that updated that coordinate one by one. Then I was able to set my altitude to about 30 or 40 and not fall back to the ground, giving me an overview of the map:

Floating above the map

By freezing updates to all of the coordinates I could teleport myself anywhere in the map and see myself there in the game client, but the server clearly did not accept the position my game client was reporting. As long as I remained in the new location, it would keep trying to teleport me back to my last legitimate position. While blocking this allowed me to explore the map, any interactions with the world that depended on my location would not be recognized by the server, so it was unlikely that I’d be able to get any flags this way.

The Floor is Lava

The floor is actually lava

While I was able to move around using the fly hack, I still couldn’t go over the maze walls even though I wasn’t colliding with them. It was also difficult to pass through them using the short-distance teleport hack. It seemed like the server was enforcing the maze wall barriers rather than just relying on the game client to block movement this time. However, if I positioned myself just right, I could do a short teleport of about 5 units in a given direction and pass through the wall without the server forcing me back.

To make the hack a little easier to use, I set up some key bindings in Cheat Engine to increase or decrease the X or Z coordinate by 5.0, giving me an arrow-key style way to move through the air and through walls. This allowed me to head right for the lava pit, over the lava, and to the treasure chest island:

Getting the lava flag

Tower

Using the same fly hack solution, I was able to head over to the tower as well:

Getting the tower flag


For the rest of the challenges I decided to dig deeper into the game disassembly and network protocol. This game was built with IL2CPP, making it a little trickier to work with than a Mono/.NET binary (which could be decompiled into something that looks much like the original source code using a tool like ILSpy or dnSpy).

I used Ghidra to disassemble the game binary and Il2CppDumper to extract its metadata and recreate function symbols.

To work with the network protocol I used the example network proxy script from the Pwn Adventure 3 video series as a base. The main modification I had to make was to convert it from using TCP connections to UDP packets - the Maze game uses UDP, but otherwise works similar to Pwn Adventure 3.

When it first connects to the server it’s actually hitting some HTTP endpoints to get information about the game servers, such as the range of ports available to connect to. To intercept this traffic I used Burp Suite and redirected all traffic for maze.liveoverflow.com to my proxy VM.

Proxying HTTP traffic through Burp Suite

The original range of UDP ports used by the game servers was 1337 to 1357, but to simplify things in the network proxy I eventually added an auto-replace rule for the /api/max_port response so that the game would always connect to the same port.

Once I had the basic network proxy for the UDP game server traffic working it was clear that the packets were encrypted. Using Ghidra I found two suspect pieces of code in the send and receive packet functions for the server class, which performed XOR operations on the packet data. The recreated encryption routine looks like this:

def encrypt_data(data):
    key_x = random.randint(0, 255)
    key_y = random.randint(0, 255)

    encrypted = [key_x, key_y] + [0 for x in data]

    for i in range(len(data)):
        encrypted[i + 2] = key_x ^ ord(data[i])
        new_key = key_x + key_y
        key_x = (new_key + (new_key / 0xff)) & 0xff

    return ''.join([chr(x) for x in encrypted])

With encryption and decryption routines added to the proxy I could begin to analyze and tamper with the network protocol, using the disassembled binary as an aid for figuring out what the packets meant.

Map Radar

“There are rumours of a player who found a secret place and walks in a weird pattern. A radar map could be useful.”

The Map Radar hack was one of the first challenges that was going to require more than manual Cheat Engine hacks. The two possibilities I saw were doing a DLL injection hack to extract player locations and add an actual GUI radar to the game, which would be awesome but time consuming, or reading player position information out of the network packets.

Using the network proxy I could see that periodically I’d receive an “I” packet containing a bunch of player names, and a “P” packet with a big chunk of data in it - much more than any of the other packet types.

[1337] <- 5787493713ffff00051054686520576869746520526162626974
00000000: 57 87 49 37 13 FF FF 00  05 10 54 68 65 20 57 68  W.I7......The Wh
00000010: 69 74 65 20 52 61 62 62  69 74                    ite Rabbit

Player info packet (server to client)

The “I” packet contains a 32-bit player ID, 16-bit unlocked abilities value, and finally the length of the player name and the player name string. One interesting “player” that kept showing up was “The White Rabbit” with an ID of 0xFFFF1337.

These player IDs corresponded to values in the “P” packets, which I could tell contained position data based on the disassembly. Each entry in the position packet contains the player ID, timestamp, coordinates, “trigger” (whether they are jumping or landing, as far as I can tell), and animation blend values (show running vs. jumping/falling animation).

[1337] <- 048a50110000001f27b5000...
00000000: 04 8A 50 11 00 00 00 1F  27 B5 00 00 00 00 00 11  ..P.....'.......
00000010: 45 2B 00 00 00 00 00 BA  5F 23 00 00 00 00 00 D9  E+......_#......
00000020: FE 30 00 00 00 00 00 00  00 00 00 00 50 37 13 FF  .0..........P7..
00000030: FF 63 0C 3F 00 00 00 00  00 B4 F8 21 00 48 77 FF  .c.?.......!.Hw.
00000040: FF F3 86 03 00 D7 EB 36  00 64 EE 1D 00 00 00 00  .......6.d......
00000050: 00 00 C8 00 00 00 50 8E  01 00 00 00 E3 59 8F 01  ......P......Y..
00000060: 00 00 00 30 38 1F 00 20  4E 00 00 A8 AD 1D 00 00  ...08.. N.......
00000070: 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................

Position packet (server to client)

Using this data I could figure out the position of “The White Rabbit”: they were somewhere underneath the map! Using the fly/teleport hack I moved underground (which works the same as “flying” - just freeze the altitude with a negative value), and found the area where the character was running around. It’s actually accessible through a special wall in the spaceship area of the map:

This character continuously navigates an unusual path through this underground space, so at this point I figured I should record its coordinates to get an overhead view of what was happening.

By adding a hook to the position data packet parser I recorded all coordinates received for this character and saved them to a text file. Then I used matplotlib to plot the points on a graph, which revealed the flag:

Radar flag

Maze Runner

The timed maze race challenges were the most interesting to me in this set. The first challenge was to simply complete the race without timing out, which was impossible without hacking the game. You needed to reach each consecutive checkpoint in the maze race within 10 seconds of the last or you would fail, and it was only possible to get to the third checkpoint before timing out.

Timeout

I noticed that there was one type of packet the server would send to the client when a checkpoint was reached, which only contained the checkpoint ID. This packet would trigger the gotCheckpoint method of the RaceManager class with the provided waypoint ID. The first thing I tried was to send one of these packets for each checkpoint to the client in quick succession - it did not trigger the flag, so all of the race handling code must implemented server-side. (I also tried the same attack via DLL injection before I had the network proxy set up, directly invoking the function for each checkpoint ID, but the result was the same.)

That didn’t mean that the checkpoint packets were useless, though - they were still valuable for telling me when the server believed my character had reached a waypoint.

Since the server needed to believe I reached each of the checkpoints legitimately in order to send me the flag, I first wanted to trace out a path from the start to the finish by extracting data from the client position packets, similar to the solution for the Map Radar challenge. I added “record”, “save recording”, “load recording”, and “play recording” commands to the network proxy to achieve this.

The “record” command would enable the logic for collecting the outgoing position coordinates, and the save command would save it into a JSON file. The load command would load coordinates from a JSON file so I could restore the recording later.

I disabled the in-game timeout logic so that I could see where all of the glowing checkpoints were within the maze, and then ran through it while recording my position. The resulting recording had 448 coordinates. Here’s what it looks like plotted on a graph:

Recorded coordinates

The play command used server teleport packets to replay recorded positions. This was unusual but easier than implementing the client position packets, which I wasn’t able to get working right away.

The teleport packet type is what the server used to block illegitimate movements, as described earlier. It’s also used for the magic teleport gates found in the courtyard you land in after logging in. By injecting this type of packet I could force the game client to update the character’s position, causing the client to send out a position update to the server. Because the positions recorded were legitimate and played back with their original timing, the updated positions were accepted by the server.

It looks a little choppy, but it worked:

To beat the first challenge, I decreased the time between position updates in the playback. Reducing the interval from the original timing of about 0.2 seconds per packet to 0.16 seconds, I was able to beat the race without timing out.

However, this took about a minute to complete, and the next challenge was to complete the race within five seconds. Going any lower than the 0.16 second interval would cause the server to start rejecting the position packets, resulting in rubber banding.

M4z3 Runn3r

This was the hardest challenge, but the most fun to figure out. Here are the key points about how the maze race works, based on the previous solutions:

  • The race progress is kept track of server-side. In order to beat the race, the server must believe the player character has reached each of the checkpoints in succession. (Verified this by trying to teleport to each of the checkpoint coordinates in order and see if the server thought the race was completed anyway.)
  • There is a limit on how far the player can travel between position updates.
  • Trying to play back the path recording too quickly failed.

Also, until this point my attempts to directly send position packets to the server resulted in the server kicking me from the game. There was one important part of the position packet that I wasn’t handling properly yet, which was the timestamp. I was trying to just set it to 0 or a value similar to the last seen timestamp in a genuine position packet, but that didn’t work.

Before trying to handle timestamps correctly, I wanted to figure out why the position packets were getting rejected. I used Cheat Engine to disable the usual rate limit on position updates (one per 0.2 seconds), as well as the logic for only sending an update when the position in the client changes. This caused the game to constantly send out position updates. This never resulted in a kick, so I knew the problem was not how frequently I was sending the packets; it had to be something related to the distance.

Another quick hack I tried was to decrease the rate limit and artificially set a distance value to an amount I had seen work before. Normally, while sprinting and using the default rate limit of 0.2 seconds, I observed that the character would move just about 1 unit of distance. I tried turning the rate limit down to 0.01 while keeping the distance travelled at 1.0 units per packet, which also failed.

Based on all of this information, it seemed likely the server was enforcing a distance check based on the player’s speed, i.e., distance over time. Based on the observed 1 unit of movement per 0.2 seconds, the default sprint speed appeared to be 5 units per second. It seemed that defeating this check would require handling the position packet timestamps correctly.

In order to send the correct timestamp, I’d have to synchronize the time value with the game client’s time. The game client keeps track of time as the number of seconds that have passed since launching the game, and the number of seconds that have passed since the player last connected to a game server.

There is a “heartbeat” packet type constantly being sent back and forth between client and server. The game client reports its current time, and the server responds back with the same timestamp as well as the real date time as a Unix epoch timestamp, presumably the real time that it received the heartbeat. Each position update from the client to the server also includes a timestamp value based on the game client’s time. Once I understood how this worked, I could use the proxy to keep track of the time values and inject new values if needed.

Having control over the timestamp values enabled me to do some more checks. For one, I was able to determine that the timestamp value always had to be increasing. Going “back in time” or trying to freeze time would cause the server to boot me.

While running these tests a possibility occurred to me: what if I artificially sped up time so that I could pass the distance over time check? For example, if I wanted to move 100 units I could pretend that 20 seconds had passed by artificially increasing the timestamp value.

To implement this I added some more logic to the recording replay code. Based on a given units per second speed value, I would calculate a new scaled time interval between two positions that satisfied a distance over time check for a maximum speed of ~5 units per second. I rewrote the timestamps for all heartbeats and position packets, and made sure to keep track of how much extra time was accrued so that I never went backwards on the timestamp value and got kicked.

Changing timestamps with the proxy

This turned out to be the solution! By tweaking this code I was able to get a finish time of 0.969 seconds using the original recording with 448 points.


Update (July 10th, 2020): I’ve uploaded the challenge solution code to GitHub at https://github.com/jamchamb/cscg2020-maze-proxy.