Challenge 10 - realloc dangling

silver ·4일 전

크래프톤 정글

목록 보기
24/26

시나리오

정수 편집 버퍼 EditBuffer. 내용이 커지면 eb_grow() 가 realloc 으로 버퍼를 키운다.
"실행 취소(undo)"를 위해 eb_snapshot() 이 현재 상태를 undo[] 에 저장한다.

문제 풀이

1.

pwndbg> bt
#0  __pthread_kill_implementation (no_tid=0x0, signo=0x6, threadid=<optimized out>) at ./nptl/pthread_kill.c:44
#1  __pthread_kill_internal (signo=0x6, threadid=<optimized out>) at ./nptl/pthread_kill.c:78
#2  __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=0x6) at ./nptl/pthread_kill.c:89
#3  0x00007ffff7dec27e in __GI_raise (sig=sig@entry=0x6) at ../sysdeps/posix/raise.c:26
#4  0x00007ffff7dcf8ff in __GI_abort () at ./stdlib/abort.c:79
#5  0x00007ffff7dd07b6 in __libc_message_impl (fmt=fmt@entry=0x7ffff7f768f0 "%s\n") at ../sysdeps/posix/libc_fatal.c:134
#6  0x00007ffff7e500d5 in malloc_printerr (str=str@entry=0x7ffff7f79c10 "free(): double free detected in tcache 2")
    at ./malloc/malloc.c:5775
#7  0x00007ffff7e5263f in _int_free (av=0x7ffff7fabac0 <main_arena>, p=<optimized out>, have_lock=0x0)
    at ./malloc/malloc.c:4541
#8  0x00007ffff7e54eae in __GI___libc_free (mem=0x5555555592a0) at ./malloc/malloc.c:3398
#9  0x0000555555555483 in eb_free (e=0x7fffffffdda0) at challenges/10_realloc_dangling/bug.c:85
#10 0x0000555555555573 in main () at challenges/10_realloc_dangling/bug.c:104
#11 0x00007ffff7dd11ca in __libc_start_call_main (main=main@entry=0x5555555554ac <main>, argc=argc@entry=0x1, 
    argv=argv@entry=0x7fffffffdf38) at ../sysdeps/nptl/libc_start_call_main.h:58
#12 0x00007ffff7dd128b in __libc_start_main_impl (main=0x5555555554ac <main>, argc=0x1, argv=0x7fffffffdf38, 
    init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffdf28)
    at ../csu/libc-start.c:360
#13 0x0000555555555165 in _start ()

run 해보니 abort. bt를 보니 eb_free가 free(mem=0x5555555592a0)를 호출하다가 죽었고, 에러 메시지는:

free(): double free detected in tcache 2

main → eb_free → free 순서라는 것까지 확인함.

2.

처음엔 e->undo를 찍었는데 뭔 소린지 몰라서 하나씩 읽어봄.

pwndbg> p e->undo
$4 = {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff7fe5af0 <dl_main>}

마지막 칸의 <dl_main>은 우리 코드의 함수가 아니라 동적 링커 쪽 심볼이었음.

그냥 초기화되지 않은 칸에 남아있던 값이 우연히 심볼 주소와 일치해서 pwndbg가 이름을 붙여준 것 같음. 08번에서도 비슷하게 초기화되지 않은 메모리에서 이상한 값이 보이는 패턴을 확인했었음.

그리고 eb_free는 undo_n까지만 순회하니까, 이번 크래시와는 관계없다는 것도 확인함.

처음엔 "중간에 0이 있는 게 문제 아닌가?" 했는데, NULL 자체가 문제라기보다는 그 값을 사용하는 코드가 어떻게 해석하느냐가 중요하다는 걸 다시 확인함.

3. undo[0]이 가리키는 메모리 확인

undo[0]이 가리키는 곳을 x/8gx로 까봄.

0x5555555592a0: 0x0000000555555559      0x6d6a3a75bca4b482
0x5555555592b0: 0x0000000000000000      0x0000000000000021

앞 16바이트가 내가 넣은 데이터처럼 보이지 않아서 bins, tcache를 확인했다.

tcachebins
0x20 [  1]:  0x5555555592a0 ◂— 0

→ 이 청크는 이미 free되어 tcache에 들어가 있는 상태였음.

위에서 본 이상한 값들은 사용자 데이터가 아니라 할당기가 free된 청크를 관리하면서 써둔 값들이라는 걸 확인함.
tcache의 next 포인터나 key 같은 값이 들어갈 수 있음.

. . .
이미 free된 걸 또 free하려고 해서 죽은 것 같은디?

bt에서 확인한 에러 메시지 double free detected in tcache 2 와 free하려던 주소 0x5555555592a0가 앞에서 확인한 주소와 일치하는 걸 보고 실제로 같은 청크를 다시 free하려고 한 것이라고 확인함.

4.

그렇다면 누가 먼저 0x...92a0을 free했을까?

eb_free()는 다음 순서로 free함.

free(e->data);
free(e->clipboard);

for (int i = 0; i < e->undo_n; i++) {
    free(e->undo[i]);
}

세 포인터의 주소를 찍어봄:

e->data      = 0x5555555592e0
e->clipboard = 0x5555555592c0
e->undo[0]   = 0x5555555592a0

전부 다른 주소였다.

그래서 처음에는

"같은 포인터를 두 번 free하는 게 아닌데 뭐가 문제지?"

하고 한참 헤맴.

그러다 질문을 바꿔서 생각해봄.

eb_free에 들어오기 전에 누가 undo[0]이 가리키는 메모리를 먼저 free했나?

5. eb_snapshot() 확인

소스를 다시 뜯어봄.

static void eb_snapshot(EditBuffer *e) {
    if (e->undo_n < MAX_UNDO)
        e->undo[e->undo_n++] = e->data;
}

여기서 e->data를 그대로 undo[]에 대입하고 있었음.

⇒ 새로운 메모리를 만드는 게 아니라 e->data에 들어있는 주소값을 그대로 복사하는 것!!

처음엔 undo[]에 뭔가 새로운 데이터가 저장되는 줄 알았는데, 실제로 새로운 malloc은 eb_init()에서 이미 이루어졌고, eb_snapshot()에서는 그 주소를 그냥 저장하고 있었음.

6. realloc 전후 추적

realloc 전후의 값을 비교해봄.
특히 eb_grow()의 e->data = p; 실행 전후를 확인함.

시점e->datae->undo[0]
스냅샷 직후0x...92a00x...92a0
e->data = p 이후0x...92e00x...92a0

→ realloc이 버퍼를 다른 주소로 이동시키면서 e->data는 새 주소로 바뀌었는데, undo[0]은 옛 주소를 그대로 가지고 있었음.

여기서 삽질 두 개.ㅋㅋ

첫 번째

두 번째 grow에서 nc = 0x20일 때

"p랑 data가 똑같은데 이동 안 한 거 아님?" 이라고 생각했음.

근데 그 화면은 이미 e->data = p; 가 실행된 이후였음.

그러니까 그 시점에는 이동했든 안 했든 p와 e->data가 같을 수밖에 없음.

→ realloc이 이동했는지 확인하려면 대입 전의 기존 주소와 realloc이 반환한 주소를 비교해야 한다는 것을 확인함.

디버거에서 값을 볼 때는 값 자체뿐만 아니라 어느 줄에서 멈춰 있는지도 같이 봐야겠다고 생각함.

두 번째

처음에는 eb_grow()의

if (!p) {
    perror("realloc");
    free(e->data);
    exit(1);
}

여기 있는 free(e->data)가 realloc으로 이동한 뒤의 옛 블록을 free해주는 코드인 줄 알았음.

근데 이건 realloc이 실패했을 때만 실행되는 코드임.

realloc이 성공해서 다른 주소로 이동한 경우에는 옛 블록의 해제까지 realloc 내부에서 처리함.

즉,

realloc 성공 + 주소 이동
        ↓
기존 블록은 realloc이 처리
        ↓
e->data는 새 주소로 변경
        ↓
그런데 undo[0]은 옛 주소를 그대로 가지고 있음

7.

지금까지 알아낸 걸 표로 하면 머리가 안 돌아가서 그림으로 정리해봄.

스냅샷 직후

e->data    ──┐
             ├──▶ [ 0x...92a0 블록 ]
e->undo[0] ──┘

이때는 두 포인터가 같은 블록을 가리키고 있음.

그런데 undo[0]은 별도의 복사본을 가진 게 아니라 그냥 같은 주소를 하나 더 가지고 있는 상태임.

realloc 이동 직후

e->data    ─────▶ [ 0x...92e0 블록 ]
                    ↑
                    새 주소

e->undo[0] ─────▶ [ 0x...92a0 블록 ]
                    ↑
                    realloc에 의해
                    더 이상 유효하지 않은 옛 블록

→ undo[0]은 댕글링 포인터가 됨.

eb_free()에서 free(e->data); 한 뒤,free(e->undo[0]); 을 하면 undo[0]은 이미 realloc 과정에서 해제된 옛 블록을 가리키고 있기 때문에 다시 free하려고 하게 됨.

→ glibc가 이를 감지하고 double free로 abort.

∴ 원인은 스냅샷에서 필요했던 건 "그 시점의 내용"인데, 코드는 "그 시점의 주소"를 저장하고 있었기 때문.

8.

그럼 어떻게 고쳐야 하는지 생각해봄.

처음에는 e->undo[e->undo_n++] = e->data; 를 보고 "그럼 e->data의 주소를 저장하면 되는 건가?"라고 생각했는데, 여기서 e->data와 &e->data를 제대로 구분하지 못하고 있었음.

e->data
→ data가 가지고 있는 값, 즉 실제 데이터 블록의 주소

&e->data
→ EditBuffer 구조체 안에서 data 포인터 변수 자체가 있는 주소

둘 다 이번 문제에서 원하는 "내용 저장"과는 맞지 않음.

스냅샷이 필요한 건 그 시점의 데이터 내용이기 때문.

그래서 스냅샷은 data와 별개의 새로운 배열을 만들어서 내용을 복사하고, 그 복사본을 undo[]가 소유하도록 해야 한다고 생각함.

9.

최종적으로는:

static void eb_snapshot(EditBuffer *e) {
    if (e->undo_n >= MAX_UNDO)
        return;

    int *p = malloc(e->len * sizeof(int));
    if (p == NULL) {
        perror("malloc");
        exit(1);
    }

    memcpy(p, e->data, e->len * sizeof(int));
    e->undo[e->undo_n++] = p;
}

로 수정함.

exit(1)은 이번 구현에서는 메모리 할당 자체가 실패하면 정상적으로 스냅샷을 만들 수 없다고 판단해서 프로그램을 종료하는 방식으로 선택함.

* 스냅샷은 부가 기능이라, 스냅샷 하나를 못 찍었다고 프로그램 전체를 종료하는 게 꼭 최선인 건 아님.

원칙적으로는 eb_snapshot()이 int를 반환하게 해서,

0       → 성공
0이 아님 → 실패

처럼 호출자에게 실패를 알려주고, 실제로 프로그램을 종료할지는 main이 결정하도록 만들 수도 있음.

이렇게 하면 eb_snapshot()은 "스냅샷 만드는 데 실패했음" 까지만 알려주고, main이 "그럼 프로그램을 종료할지, 경고만 띄우고 계속할지" 를 결정하게 됨.

이번에는 구현을 단순하게 하기 위해 exit(1)을 선택했지만, 함수의 책임과 호출자의 결정권까지 생각하면 반환값을 사용하는 설계도 가능하다는 걸 확인함.

그리고 len == 0인 경우도 생각해봄.

빈 버퍼의 스냅샷 자체는 꼭 실패라고 볼 수 없는데, malloc(0)은 환경에 따라 NULL을 반환할 수도 있어서 malloc 실패와 구분하기 어려워질 수 있음.

그래서 빈 스냅샷을 어떻게 표현할 것인지는 별도의 설계 문제라고 생각함. 이번 챌린지에서는 실제로 len이 0인 상태에서 스냅샷을 찍는 상황까지는 다루지 않았으므로, 여기서는 이 정도만 확인해둠.

이번 문제에서 가져갈 것

  • 같은 주소를 가리키는 포인터가 여러 개 있다면 누가 그 메모리를 소유하고 free할 책임이 있는지를 명확하게 해야 함. 이번에는 data와 undo[0]이 같은 블록을 가리키고 있었고, undo가 독립적인 복사본을 소유하고 있는 구조가 아니었음.

  • realloc이 다른 주소로 이동하면 기존 블록은 realloc이 처리함. 그런데 기존 주소를 가지고 있던 다른 포인터의 값까지 자동으로 바뀌는 건 아님. 그래서 그 포인터가 댕글링 포인터가 될 수 있음.

  • "그 시점의 상태를 저장한다"는 말에서 주소를 저장하는 것과 내용을 복사하는 것은 완전히 다름. 나중에 독립적으로 사용하거나 free해야 한다면 별도의 메모리를 만들고 내용을 복사해야 함.

  • malloc과 memcpy에서 크기를 잡을 때는 개수와 바이트 수를 구분해야 함. int 배열이라면 개수 × sizeof(int).

  • 스냅샷에 저장할 데이터의 양은 cap이 아니라 실제로 사용 중인 len을 기준으로 함.

  • 실패 가능성이 있는 작업에서는 상태를 바꾸기 전에 실패 여부를 확인하는 것이 중요함. malloc이 성공했는지 확인한 뒤 undo_n++를 하는 이유도 같음.

  • if 조건은 항상 "언제 이 블록이 실행되는가?"를 직접 따라가 봐야 함.

  • 디버거에서는 값만 보는 게 아니라 현재 어느 줄에서 멈춰 있는지도 같이 봐야 함. e->data = p 이후에는 당연히 p와 e->data가 같기 때문에, 그걸 보고 realloc이 이동하지 않았다고 판단하면 안 됨.

  • exit(1)과 반환값의 차이도 확인함.
    exit(1)은 함수가 직접 프로그램 전체를 종료시키지만, 반환값을 사용하면 호출자에게 실패를 알리고 종료 여부를 호출자가 결정할 수 있음.

  • len == 0처럼 정상적인 특수 상황과 실제 오류를 구분하는 것도 함수 설계에서 고려해야 할 부분임.

0개의 댓글