HACK@AC 2024 - 安心 Impact!
Source and relevant files can be found here
Libc version: 2.35
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.
struct player { |
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.
void shop(struct player *p) { |
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!
void use_item(struct player *p) { |
Lastly, in the inventory, we can simply see all the currently bought potions.
int view_inventory(struct player *p) { |
The program uses the read_str
function to read input.
int read_str(char *buf, int len) { |
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
0x00: 0x0000000000000000 0x0000000000000021 |
When we rebrew, we can read 0x19 characters in.
0x00: 0x0000000000000000 0x0000000000000021 |
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:
0x00: 0x0000000000000000 0x0000000000000021 |
After:
0x00: 0x0000000000000000 0x0000000000000021 |
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.
struct malloc_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.
0x00: 0x0000000000000000 0x0000000000000021 |
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 thesize
of the first chunk. - “corrupted double-linked list”:
p->fd->bk == p
andp->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:
0x00: 0x0000000000000000 0x0000000000000061 |
We can see that 0x60 - 0x50
points to 0x10
, the start of fake chunk.
After freeing, we’ve now got consolidation, then what happens?
0x00: 0x0000000000000000 0x0000000000000061 |
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.
0x00: 0x0000000000000000 0x0000000000000061 |
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]
.
p.sendline(b"a"*32) |
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.
# setup the heap |
if (p->hp <= 0) { |
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).
purchase(p, b"a"*0x1f8) # 7 - our controlled chunk |
if (nextchunk != av->top) { |
Now, we perform House of Einherjar.
# the off-by-one will change chunk_8 PREV_INUSE bit to 0 cos of the appended null byte |
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)
wish(p) |
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:
def encrypt(pos, ptr): |
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.
# extra 1 chunk per tcache bin so that we can overwrite an existing chunk ptr |
Lastly, we free the poisoned tcache chunks and get our write primitives! Upon exit, we will get our shell.
purchase(p, b"b"*0x10) |