[how2heap] fastbin_dup

날짜
Apr 18, 2025
속성
how2heap

링크

코드

#include <stdio.h> #include <stdlib.h> #include <assert.h> int main() { setbuf(stdout, NULL); printf("This file demonstrates a simple double-free attack with fastbins.\n"); printf("Fill up tcache first.\n"); void *ptrs[8]; for (int i=0; i<8; i++) { ptrs[i] = malloc(8); } for (int i=0; i<7; i++) { free(ptrs[i]); } printf("Allocating 3 buffers.\n"); int *a = calloc(1, 8); int *b = calloc(1, 8); int *c = calloc(1, 8); printf("1st calloc(1, 8): %p\n", a); printf("2nd calloc(1, 8): %p\n", b); printf("3rd calloc(1, 8): %p\n", c); printf("Freeing the first one...\n"); free(a); printf("If we free %p again, things will crash because %p is at the top of the free list.\n", a, a); // free(a); printf("So, instead, we'll free %p.\n", b); free(b); printf("Now, we can free %p again, since it's not the head of the free list.\n", a); free(a); printf("Now the free list has [ %p, %p, %p ]. If we malloc 3 times, we'll get %p twice!\n", a, b, a, a); a = calloc(1, 8); b = calloc(1, 8); c = calloc(1, 8); printf("1st calloc(1, 8): %p\n", a); printf("2nd calloc(1, 8): %p\n", b); printf("3rd calloc(1, 8): %p\n", c); assert(a == c); }

코드 흐름

  • 코드의 흐름은 아래와 같이 진행된다.
      1. tcache 대신 fastbin을 쓰게 하려고 tcache를 채운다.
      1. calloc을 3번 한다.
      1. a를 free 한다.
      1. b를 free 한다.
      1. a를 free 한다. ← DFB
      1. 다시 3개의 힙 공간을 할당한다.

실행 결과

This file demonstrates a simple double-free attack with fastbins. Fill up tcache first. Allocating 3 buffers. 1st calloc(1, 8): 0x4053a0 2nd calloc(1, 8): 0x4053c0 3rd calloc(1, 8): 0x4053e0 Freeing the first one... If we free 0x4053a0 again, things will crash because 0x4053a0 is at the top of the free list. So, instead, we'll free 0x4053c0. Now, we can free 0x4053a0 again, since it's not the head of the free list. Now the free list has [ 0x4053a0, 0x4053c0, 0x4053a0 ]. If we malloc 3 times, we'll get 0x4053a0 twice! 1st calloc(1, 8): 0x4053a0 2nd calloc(1, 8): 0x4053c0

디버깅

  • gdb를 통해 자세히 살펴보자

tcache 채우기

  • 8번 할당하고 7번 free해서 tcache를 전부 채운 모습이다.
⚠️
calloc은 tcache에 넣을 수는 있지만 tcache에서 가져오지는 않는다.
notion image

a를 free 했을 때

  • fastbin에 들어간다.
notion image

b를 free 했을 때

  • fastbin에 하나 더 들어간다.
notion image

a를 다시 free 했을 때 fastbin 상태

  • 이를 통해서 fastbin에서는 연속적으로 free하지 않는다면 DFB가 가능하다는 것을 알 수 있다.
  • fastbin에서 마지막에 loop detacted라고 뜬다.
    • 첫번째 청크와 세번째 청크의 주소가 같다.
notion image

DFB 후 첫번째 calloc

  • fastbin에 있던 첫번째 freed chunks가 반환되어 bin에서 사라진다.
notion image

DFB 후 두번째 calloc

  • fastbin에 있던 두번째 freed chunks가 반환되어 bind에서 사라진다.
notion image

DFB 후 세번째 calloc

  • fastbin의 세번째에 loop로 있던 청크가 반환된다.
1st calloc(1, 8): 0x4053a0 2nd calloc(1, 8): 0x4053c0 3rd calloc(1, 8): 0x4053a0

여기서 알 수 있는 점

  • fastbin은 연속적으로 free하지 않는 경우에 대한 검사 없다. → 마음놓고 free할 수 있다.

의문점

  1. 연속적으로 free를 2번하면 어떻게 되는가?
  1. 방금 코드 마지막에 malloc을 넣으면 어떻게 되는가?

연속적으로 free를 2번하면 어떻게 되는가?

  • 33번째 줄에 있던 free(a) 의 주석을 제거했다.
notion image
  • __libc_free에서 _int_free로 넘어간다음에 위 사진에서 오류 처리하는 부분으로 넘어간다.
  • __libc_free 함수를 분석해보자.

__libc_free 함수

코드
void __libc_free (void *mem) { mstate ar_ptr; mchunkptr p; /* chunk corresponding to mem */ if (mem == 0) /* free(0) has no effect */ return; /* Quickly check that the freed pointer matches the tag for the memory. This gives a useful double-free detection. */ if (__glibc_unlikely (mtag_enabled)) *(volatile char *)mem; int err = errno; p = mem2chunk (mem); if (chunk_is_mmapped (p)) /* release mmapped memory. */ { /* See if the dynamic brk/mmap threshold needs adjusting. Dumped fake mmapped chunks do not affect the threshold. */ if (!mp_.no_dyn_threshold && chunksize_nomask (p) > mp_.mmap_threshold && chunksize_nomask (p) <= DEFAULT_MMAP_THRESHOLD_MAX) { mp_.mmap_threshold = chunksize (p); mp_.trim_threshold = 2 * mp_.mmap_threshold; LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2, mp_.mmap_threshold, mp_.trim_threshold); } munmap_chunk (p); } else { MAYBE_INIT_TCACHE (); /* Mark the chunk as belonging to the library again. */ (void)tag_region (chunk2mem (p), memsize (p)); ar_ptr = arena_for_chunk (p); _int_free (ar_ptr, p, 0); } __set_errno (err); } libc_hidden_def (__libc_free)
  • 디버깅을 해보면 조건 몇개 검사하고 바로 _int_free함수로 넘어가는 것을 볼 수 있다.

_int_free 함수

코드
static void _int_free (mstate av, mchunkptr p, int have_lock) { INTERNAL_SIZE_T size; /* its size */ mfastbinptr *fb; /* associated fastbin */ size = chunksize (p); /* Little security check which won't hurt performance: the allocator never wraps around at the end of the address space. Therefore we can exclude some size values which might appear here by accident or by "design" from some intruder. */ if (__builtin_expect ((uintptr_t) p > (uintptr_t) -size, 0) || __builtin_expect (misaligned_chunk (p), 0)) malloc_printerr ("free(): invalid pointer"); /* We know that each chunk is at least MINSIZE bytes in size or a multiple of MALLOC_ALIGNMENT. */ if (__glibc_unlikely (size < MINSIZE || !aligned_OK (size))) malloc_printerr ("free(): invalid size"); check_inuse_chunk(av, p); #if USE_TCACHE { size_t tc_idx = csize2tidx (size); if (tcache != NULL && tc_idx < mp_.tcache_bins) { /* Check to see if it's already in the tcache. */ tcache_entry *e = (tcache_entry *) chunk2mem (p); /* This test succeeds on double free. However, we don't 100% trust it (it also matches random payload data at a 1 in 2^<size_t> chance), so verify it's not an unlikely coincidence before aborting. */ if (__glibc_unlikely (e->key == tcache_key)) { tcache_entry *tmp; size_t cnt = 0; LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx); for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR (tmp->next), ++cnt) { if (cnt >= mp_.tcache_count) malloc_printerr ("free(): too many chunks detected in tcache"); if (__glibc_unlikely (!aligned_OK (tmp))) malloc_printerr ("free(): unaligned chunk detected in tcache 2"); if (tmp == e) malloc_printerr ("free(): double free detected in tcache 2"); /* If we get here, it was a coincidence. We've wasted a few cycles, but don't abort. */ } } if (tcache->counts[tc_idx] < mp_.tcache_count) { tcache_put (p, tc_idx); return; } } } #endif /* If eligible, place chunk on a fastbin so it can be found and used quickly in malloc. */ if ((unsigned long)(size) <= (unsigned long)(get_max_fast ()) #if TRIM_FASTBINS /* If TRIM_FASTBINS set, don't place chunks bordering top into fastbins */ && (chunk_at_offset(p, size) != av->top) #endif ) { if (__builtin_expect (chunksize_nomask (chunk_at_offset (p, size)) <= CHUNK_HDR_SZ, 0) || __builtin_expect (chunksize (chunk_at_offset (p, size)) >= av->system_mem, 0)) { bool fail = true; /* We might not have a lock at this point and concurrent modifications of system_mem might result in a false positive. Redo the test after getting the lock. */ if (!have_lock) { __libc_lock_lock (av->mutex); fail = (chunksize_nomask (chunk_at_offset (p, size)) <= CHUNK_HDR_SZ || chunksize (chunk_at_offset (p, size)) >= av->system_mem); __libc_lock_unlock (av->mutex); } if (fail) malloc_printerr ("free(): invalid next size (fast)"); } free_perturb (chunk2mem(p), size - CHUNK_HDR_SZ); atomic_store_relaxed (&av->have_fastchunks, true); unsigned int idx = fastbin_index(size); fb = &fastbin (av, idx); /* Atomically link P to its fastbin: P->FD = *FB; *FB = P; */ mchunkptr old = *fb, old2; if (SINGLE_THREAD_P) { /* Check that the top of the bin is not the record we are going to add (i.e., double free). */ if (__builtin_expect (old == p, 0)) malloc_printerr ("double free or corruption (fasttop)"); p->fd = PROTECT_PTR (&p->fd, old); *fb = p; } else do { /* Check that the top of the bin is not the record we are going to add (i.e., double free). */ if (__builtin_expect (old == p, 0)) malloc_printerr ("double free or corruption (fasttop)"); old2 = old; p->fd = PROTECT_PTR (&p->fd, old); } while ((old = catomic_compare_and_exchange_val_rel (fb, p, old2)) != old2); /* Check that size of fastbin chunk at the top is the same as size of the chunk that we are adding. We can dereference OLD only if we have the lock, otherwise it might have already been allocated again. */ if (have_lock && old != NULL && __builtin_expect (fastbin_index (chunksize (old)) != idx, 0)) malloc_printerr ("invalid fastbin entry (free)"); } /* Consolidate other non-mmapped chunks as they arrive. */ else if (!chunk_is_mmapped(p)) { /* If we're single-threaded, don't lock the arena. */ if (SINGLE_THREAD_P) have_lock = true; if (!have_lock) __libc_lock_lock (av->mutex); _int_free_merge_chunk (av, p, size); if (!have_lock) __libc_lock_unlock (av->mutex); } /* If the chunk was allocated via mmap, release via munmap(). */ else { munmap_chunk (p); } }
  • 이 함수는 _int_free(av=0x7ffff7fa6ac0 <main_arena>, p=0x405390, have_lock=0x0) 와 같이 호출된다.
  • 코드에서 이 부분이 DFB를 검증하는 로직이다.
if ((unsigned long)(size) <= (unsigned long)(get_max_fast ()) /* ... */) { // fastbin 관련 다른 검증 로직 ... /* Atomically link P to its fastbin: P->FD = *FB; *FB = P; */ mchunkptr old = *fb, old2; if (SINGLE_THREAD_P) { /* Check that the top of the bin is not the record we are going to add (i.e., double free). */ if (__builtin_expect (old == p, 0)) malloc_printerr ("double free or corruption (fasttop)"); p->fd = PROTECT_PTR (&p->fd, old); *fb = p; } else do { /* Check that the top of the bin is not the record we are going to add (i.e., double free). */ if (__builtin_expect (old == p, 0)) malloc_printerr ("double free or corruption (fasttop)"); old2 = old; p->fd = PROTECT_PTR (&p->fd, old); } while ((old = catomic_compare_and_exchange_val_rel (fb, p, old2)) != old2);
  • 단일 스레드와 멀티 스레드 모두에서 다음과 같은 로직을 실행한다.
      1. old = *fb 로 fastbin의 첫번째 chunk를 가져온다.
      1. 조건문으로 인자로 받은 free할 청크의 주소와 fastbin 첫번째 청크의 주소랑 같은지 확인하다.
      1. 같다면 malloc_printerr를 실행한다. → 프로그램 종료

배운 점

  • 이를 통해서 fastbin은 연속으로 free하지만 않으면 DFB를 검증 로직을 우회할 수 있다는 사실을 알 수 있다.

방금 코드 마지막에 malloc을 넣으면 어떻게 되는가?

  • 마지막에 이런 코드를 넣어봤다.
void *ptrs2[7]; for (int i=0; i<7; i++) { ptrs2[i] = malloc(8); } int *debug = malloc(8); printf("last malloc addr: %p", debug);
  • 이 코드를 실행하면 아래와 같은 출력이 마지막에 추가되어 나온다.
malloc(): unaligned fastbin chunk detected 2 [1] 54354 IOT instruction (core dumped) ./out
  • fastbin이 더럽혀진 후에 malloc을 하면 프로그램이 멈춘다는 사실을 알 수 있다.

결론

  • tcache를 꽉 채워서 fastbin에 들어가게 한 다음에 더블 프리를 간격을 두고 해주면 똑같은 주소를 2번 할당 받을 수 있다.