ACS 2023: Maze
let's play a maze game
Last updated
let's play a maze game
Last updated
Simple maze game that can be beaten repeatedly by manipulating game logic to either move through walls or trick the game into thinking we have reached the destination coordinate.
This is a maze mini-game that challenges the player to solve it 111 times to receive the flag. Beating it manually is obviously time-consuming and not feasible during the CTF.
Within the game logic, there are 3 large switch cases that are easily identifiable under Graph View. The 3rd and last switch statement controls player's movement by reading neighbouring elements of the maze layout. For example, when attempting to move in any direction, it will determine whether there is a wall boundary blocking it or an actual traversable path.
After some trial and error, we realized that it is possible to apply a Never Branch
patch on the jne
instruction for all four directions to allow free roam and movement through walls.
Now that there are no restrictions on player movement, we can play the game 111 times to get the flag, but this will take a long time (~10 minutes).
Flag: ACS{3e88fc35ac5b6011b6e7e32afd9552666db7bb21d30e83859665ea5e2cae99bc_I7s_funny_M@ze_Gam3!_C0n9r@tu1ation$_On_C13ar!}
Taking a further look at how the game decides to end a round, we observed that there are 2 possible paths that loop around wgetch
- a function to receive button presses from the user. We came to a conclusion that the game would keep calling wgetch
as long as our player location is not on the END
pixel.
With that in mind, we can apply an Invert Patch
on both je
instruction, so that we can proceed to the next round without moving our player at all (essentially without playing the game!). This is possible because our starting position (X: 1, Y:1) will never be on the END pixel.
After 110 rounds, a stack trace was thrown, indicating that the flag array wasn't decoded properly despite us beating the game. This is perhaps due to certain conditions that weren't met.
Taking a wider look, we now understand that the flag is decoded character-by-character in the following order:
Finish Maze -> Prints SUCCESS -> Decode 1 Character -> Prints NEXT STAGE -> Generate New Maze
Taking a closer look at what the program is doing after printing the SUCCESS banner, we realized it is once again checking our player coordinates for a second time. Just like before, apply an Invert Patch
on 3 of the jump statements and it should now bypass all the checks.
Finally, let the game run for 111 rounds and the flag will appear at the end. In conclusion, we applied 5 patches that alter the game logic to decode the flag without actually beating the game.
Flag: ACS{3e88fc35ac5b6011b6e7e32afd9552666db7bb21d30e83859665ea5e2cae99bc_I7s_funny_M@ze_Gam3!_C0n9r@tu1ation$_On_C13ar!}
The encrypted flag is stored in DAT_001477a8
. This allows us to decode it manually by replicating the bitwise and arithmetic operations.
After back-and-forth trial & error with some missing values, we managed to port the decompiled code from Ghidra over to Python:
Running the python script gives us the complete flag.
Flag: ACS{3e88fc35ac5b6011b6e7e32afd9552666db7bb21d30e83859665ea5e2cae99bc_I7s_funny_M@ze_Gam3!_C0n9r@tu1ation$_On_C13ar!}
This method is similar to Solution 2, which involves tricking the program into thinking we have reached the END coordinates. (Credits to Team Trailblazers for this neat trick)
To achieve this, we will inject the END coordinates (X: 1F, Y: 1F) into the relevant registers just before the comparison is made at this specific address.
Doing this manually by hand:
To automate the entire process with GDB:
Flag: ACS{3e88fc35ac5b6011b6e7e32afd9552666db7bb21d30e83859665ea5e2cae99bc_I7s_funny_M@ze_Gam3!_C0n9r@tu1ation$_On_C13ar!}
Be careful not to input too many keystrokes or you might over-click the message prompt and miss the flag. (I made this rookie mistake during the CTF and didn't manage to solve it )