When writing this challenge, I wanted to create a more subtle heap challenge than the usual “notepad” kind of challenge.
At the same time, I wanted to make it more fun by incorporating a game into it, quite similar to RPS
2.0. So I decided to create a Genshin
Impact clone just for fun :)
Analysis
Source code is provided in the repo. Its pretty long so I’ll only analyze the important functions here.
Player has a struct to keep track of stats.
In the main loop, we can do the following actions: Enter Level, Purchase Item, View Inventory or Use Item.
The level isn’t so important as we just need sufficient coins to brew items. In the shop, we can brew custom potions and
assign it a name. The potions are stored using malloc(len), where len is the length returned by the read_str
function. We can rebrew potions which is just editing the data stored in the chunk.
We also have a wishing well that just does malloc(0) and stores it in the inventory. This will be important later on.
When we use a potion, the data in the potion is read to see what effects it can give according to some preset potions,
and subsequently it is freed. Following best practices, the pointer to it is also set to NULL. So secure!
Lastly, in the inventory, we can simply see all the currently bought potions.
The program uses the read_str function to read input.
Vulnerability
We see in the above read_str function an off-by-one vulnerability! The len field is used to specify how many
characters to read. However, the loop actually reads len + 1 characters, which makes it vulnerable.
This can be exploited in the potion rebrewing functionality, since length is determined by the length of user input
earlier during brewing. Let’s say we input a potion of name length 0x18. The chunk would be
When we rebrew, we can read 0x19 characters in.
Thus, we have control over the size field of the adjacent chunk. From here, we can use the well-known House of
Einherjar to proceed.
House of Einherjar
In the earlier example, the adjacent chunk was forest. What if it were another regular chunk?
Before:
After:
As we know, the size field contains some metadata in the smallest nibble. We see what flags it corresponds to in libc
source code:
So a change of size from 0x101 to 0x100 will unset the PREV_INUSE flag. This flag is referenced when freeing an
unsorted-sized chunk.
So we see it consolidates the chunk with the previous (supposedly freed) chunk, by reading the prev_size field, which
according to freed chunk structure, is the address just before the size field.
So, with control over prev_size and prev_inuse, we can trick libc into consolidating the chunk that’s about to be
freed with a region we control in the initial chunk. However, there are 2 checks we need to bypass:
“corrupted size vs. prev_size while consolidating”: the prev_size field of the escond chunk must match the size
of the first chunk.
“corrupted double-linked list”: p->fd->bk == p and p->bk->fd == p.
prev_size is also used as the negative offset from second chunk to first chunk. So, where we place our fake chunk
relative to second chunk will become the size field of fake chunk, as well as prev_size field of second chunk.
Here, p refers not to second chunk but to fake chunk. So the fake chunk must have the correct fd and bk pointers
that fulfill the condition. One easy way to do this with a heap leak is to make it point to itself.
Our fake chunk and second chunk should look like this:
We can see that 0x60 - 0x50 points to 0x10, the start of fake chunk.
After freeing, we’ve now got consolidation, then what happens?
Our first chunk now overlaps the fake chunk. Using the rebrew function, we can now view and edit fd and bk of the
fake unsorted chunk!
Note the following behaviour of unsorted chunks:
So, the unsorted chunk is like a temporary “forest” - it wants to be serviced in place of forest. This means some
malloc() operations on top also happen to unsorted chunks, allowing it to even do stuff like splitting and servicing
smaller chunks, while retaining its unsortedness without going into smallbin. This chunk is known as the last remainder
chunk.
Also, we can leak libc through a few tricks. The fake chunk contains main_arena which we can use to leak libc.
However, since we’re printing using printf and %s, we can’t view the fake chunk directly from view_inventory
because of the null bytes. However, if we can get a chunk that aligns with the fake chunk without overwriting the fd
ptr, we can then leak libc.
In the wishing well, the behaviour of malloc(0) is just to allocate the smallest chunk available, 0x21. Therefore,
this allows us to allocate a chunk at the top of our fake chunk without overwriting anything, hence allowing us to leak
libc.
From here, exploitation is simple. On 2.35, without __free_hook, we can either
attack exit_funcs or perform House of Apple . I chose to do exit_funcs here.
Exploitation
First, we need heap leak for the above. This can be gotten from (again!) the off-by-one in read_str, now on
username, because in the player struct, name is right before the inv[] array. So by making sure name has no null
byte, we can leak the pointer to inv[0].
Before doing House of Einherjar, we need to first block off tcache for our second chunk, because tcache chunks aren’t
“freed” in the usual sense (it bypasses a lot of the logic in free()), and so consolidation won’t occur. We can also
use these chunks to “poison” our hp to 0, so we can exit smoothly later when we enter the next level.
Now, we can setup our target chunks and free the previous tcache chunks. Chunk 7 is just any big chunk so we can play
more with target size later. Chunk 8 is the second chunk to overwrite, and chunk 9 prevents consolidation with the
forest so our chunk is placed in unsorted bin instead of becoming new forest (forward consolidation is ok).
Now, we perform House of Einherjar.
Here we want to use wishing well to leak libc. We need to do it twice, because the very first chunk for heap leak was
also size 0x21 (we could have avoided this if we made the first chunk part of the tcache setup step)
We can now exploit last remainder chunk mechanism to do 2 small writes, which is necessary for exit_funcs route.
(House of Apple only needs 1 large write, it’s a viable alternative). These are the targets we’re interested in:
Here, we don’t mind using tcache chunks anymore. In fact, it’s even easier to exploit than fastbin precisely because it
skips so much logic. However, make sure to alloc at least 2 chunks, if not the fd we overwrite is just null and won’t
be placed at target address.
Lastly, we free the poisoned tcache chunks and get our write primitives! Upon exit, we will get our shell.