[AllesCTF2021] Monstrosity writeup
Locating and analyzing the main function:
We are given an exe file. Using Detect-it-easy
, we know that this is a .NET binary. My go-to tool for such binaries is Dnspy, so obviously, I put the file in there. But with such a huge number of functions in the binary, it’s really hard to figure out where to start. If you are using Dnspy, you can search for a function by its name by pressing Ctrl + Shift + K
. Just type the word main
in there and we can locate our main function.
main
function by loading it into IDA. This is because IDA automatically takes you to the entry point, while DnSpy doesn’t.Go to Entry Point
in Dnspy will take you to its entry point. With this binary, the entry point is at the mainCRTStartupStrArray
function, and you can see main
is called there.The main
function first load a binary resource using GetByteResourceContent()
. Taking a look at the resource section using CFF explorer
, we can quickly spot an embed dll file (Let’s just dump that out, it will be useful later). The program then goes on to create an instance of class Maze.Maze
and call the method Maze.MazeWalker.startWalking()
of the hidden dll.
Analyzing the dll:
This dll is pretty straightforward. It has the Maze
class, which is used to represent our maze. Each cell in this maze is represented by a number of type CellState
. This number indicates which direction we can’t go to from that cell. The whole maze is created randomly using the current timestamp as a seed.
The MazeWalker
class is where our input gets processed. It has 100 functions, one for each cell in the maze. The structure of those functions are as following
- Check if the last move is valid or not using the
CellState
value. In other words, it makes sure that we didn’t hit a wall. - Get the key pressed and transfer control to another function, a.k.a move to another cell.
w
,s
,a
,d
are used to move up, down, left, right respectively. - If we move out of the boundary of the maze, the program will print out some messages and quit immediately.
- The handler for cell
(9, 9)
, which isvisitField_9_9
will check if all our moves from the very start are valid. The sequence of input to create the shortest valid path to(9,9)
is our flag.
This made me confused for a while. If the maze is random, then how can the content of the flag be the same for every run? Moreover, some input keys don’t work as I expected. For example, when we start out at (0, 0)
, if we go up by pressing w
, the program is supposed to print the out-of-bound message, but it didn’t. At this point, I suspected that the code is somehow changed at runtime, so I returned to main
to check if I missed something.
Identifying JIT hook:
It turns out that the program also starts a second thread.
|
|
But the name of the start function for the thread is a little bit weird, and we can’t see its content in Dnspy. That’s because this is not managed code. To put it simply, in .NET, managed codes are compiled into IL opcode, then get executed by .NET CLR, while unmanaged codes (or unsafe code) are compiled directly into machine code and handled by the OS itself. And since this function contains unmanaged code, we have to use a tool that can disassemble machine code to view its content.
If we click the function name in Dnspy, we can find out that the RVA of the function inside this binary is 0xB648
. So I loaded the file into IDA, jumped to that address, and was able to see the content of our function.
The function contains 2 statements that caught my attention.
|
|
This is the classic JIT hooking technique. As you know, .NET IL opcodes are compiled to machine code at runtime, so by hooking the function that handles this compilation process, we can modify our code while the program is running.
The hook is located at 0x1950
. It identifies the needed methods by their metadata token, then changes their opcodes.
|
|
v8
is a pointer to CORINFO_METHOD_INFO structure, sov8[2]
is actually pointing at the method’s IL opcode. Knowing this, we can manually patch the code ourselves. 0x6000007
is the token of Maze.GetRandomSeed()
, and the result after patching is:
|
|
So the maze is not random. Great, we only need to solve 1 maze to get the flag.
The next modification we need to look at is this:
|
|
This is the reason why we get weird behaviors from our input. The hook actually modifies all the action keys for each cell handling method (you can see this by checking the metadata and the patch offset). But that’s fine, we can easily calculate the correct keys for every direction.
Constructing the solution:
So now, there are 3 things we need to do:
- Get the content of the maze.
- Get the shortest path to solve the maze
- Calculate the correct key sequence for that path.
Since the maze is not randomly created, we can just debug the program and dump its content using Dnspy.
Next, we need to solve this maze using the shortest path possible. The best option that I came up with was using Breath First Search
.
|
|
Finally, we need the sequence of keys. Since the direction keys for each method are calculated from its metadata token, we first have to get all the tokens of those handlers. Luckily, the tokens follow a specific pattern, so we can just calculate them.
|
|
Finally, putting it all together
|
|
And we have the flag: ALLES!{vfpzjtwmcdlvygjzabcsmlkjefgwmndxwrstucmwzhrucylowsfi}
Embed dll: Maze.dll
Full script: maze_solve.py, maze.bin