정수 편집 버퍼 EditBuffer. 내용이 커지면 eb_grow() 가 realloc 으로 버퍼를 키운다.
"실행 취소(undo)"를 위해 eb_snapshot() 이 현재 상태를 undo[] 에 저장한다.
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 순서라는 것까지 확인함.
처음엔 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 자체가 문제라기보다는 그 값을 사용하는 코드가 어떻게 해석하느냐가 중요하다는 걸 다시 확인함.
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하려고 한 것이라고 확인함.
그렇다면 누가 먼저 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했나?
소스를 다시 뜯어봄.
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()에서는 그 주소를 그냥 저장하고 있었음.
realloc 전후의 값을 비교해봄.
특히 eb_grow()의 e->data = p; 실행 전후를 확인함.
| 시점 | e->data | e->undo[0] |
|---|---|---|
| 스냅샷 직후 | 0x...92a0 | 0x...92a0 |
e->data = p 이후 | 0x...92e0 | 0x...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]은 옛 주소를 그대로 가지고 있음
지금까지 알아낸 걸 표로 하면 머리가 안 돌아가서 그림으로 정리해봄.
스냅샷 직후
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.
∴ 원인은 스냅샷에서 필요했던 건 "그 시점의 내용"인데, 코드는 "그 시점의 주소"를 저장하고 있었기 때문.
그럼 어떻게 고쳐야 하는지 생각해봄.
처음에는 e->undo[e->undo_n++] = e->data; 를 보고 "그럼 e->data의 주소를 저장하면 되는 건가?"라고 생각했는데, 여기서 e->data와 &e->data를 제대로 구분하지 못하고 있었음.
e->data
→ data가 가지고 있는 값, 즉 실제 데이터 블록의 주소
&e->data
→ EditBuffer 구조체 안에서 data 포인터 변수 자체가 있는 주소
둘 다 이번 문제에서 원하는 "내용 저장"과는 맞지 않음.
스냅샷이 필요한 건 그 시점의 데이터 내용이기 때문.
그래서 스냅샷은 data와 별개의 새로운 배열을 만들어서 내용을 복사하고, 그 복사본을 undo[]가 소유하도록 해야 한다고 생각함.
최종적으로는:
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처럼 정상적인 특수 상황과 실제 오류를 구분하는 것도 함수 설계에서 고려해야 할 부분임.