[Debugging]C 디버깅 챌린지 1번풀이
C 디버깅과 친해지기
Challenge 01. Use After Free 디버깅하기#
이번 문제에서는 작은 GUI 시스템을 흉내 낸 코드에서 발생한 Use After Free(UAF) 버그를 디버깅했다.
처음에는 free()를 호출하는 것 자체가 문제라고 생각했다. 그런데 코드를 따라가 보니 free() 이후에도 Screen이 해당 Widget의 주소를 계속 가지고 있었고, 나중에 그 주소를 다시 사용하면서 문제가 발생하고 있었다.
이번 문제를 풀면서 struct, 포인터, 함수 포인터와 vtable, malloc과 free, 댕글링 포인터가 서로 어떻게 연결되는지 같이 볼 수 있었다.
처음 생각한 것#
처음 코드를 봤을 때는 Dialog가 닫힐 때 호출되는 free()가 가장 의심스러웠다.
static void dialog_on_event(Widget *self, int code) { if (code == 1) { self->closed = 1; widget_destroy(self); }}widget_destroy()에서는 바로 메모리를 해제하고 있었다.
static void widget_destroy(Widget *w) { free(w);}처음에는 “Dialog를 닫았으니까 free()하는 게 당연한 것 아닌가?”라는 생각이 들었다. 그런데 free() 이후에도 이 Widget을 다른 곳에서 사용하고 있는지를 따라가 보니 문제가 보였다.
왜 어려웠나#
이번 문제가 어려웠던 이유는 free()가 실행되는 곳과 실제 크래시가 발생하는 곳이 달랐기 때문이다.
처음 Widget이 생성되면 Screen은 Widget 자체를 가지고 있는 것이 아니라 Widget의 주소를 포인터로 가지고 있다.
struct Widget { const VTable *vtbl; int id; int closed; char label[24];};
typedef struct { Widget *items[MAX_WIDGETS]; int count;} Screen;예를 들어 Widget이 이렇게 저장되어 있다고 생각할 수 있다.
Screen ├── items[0] ──→ Label ├── items[1] ──→ Button ├── items[2] ──→ Dialog └── items[3] ──→ Button여기서 Dialog가 닫히면서 free(self)가 실행된다.
s->items[2] ──→ Dialog │ ▼ free()Dialog의 메모리는 해제됐지만 s->items[2]에 들어 있던 주소값까지 자동으로 없어지는 것은 아니다.
결과적으로 Screen에는 이런 포인터가 남게 된다.
s->items[2] ──→ 이미 해제된 메모리이 상태의 포인터가 댕글링 포인터(Dangling Pointer)다.
문제 풀이#
먼저 Widget이 어떻게 사용되는지부터 따라갔다.
main()에서는 Widget을 만들고 Screen에 추가한다.
screen_add(&s, widget_new(&LABEL_VT, 10, "Welcome"));screen_add(&s, widget_new(&BUTTON_VT, 11, "OK"));screen_add(&s, widget_new(&DIALOG_VT, 12, "Are you sure?"));screen_add(&s, widget_new(&BUTTON_VT, 13, "Cancel"));그러면 Screen은 각각의 Widget 주소를 가지고 있게 된다.
그리고 이벤트를 전달할 때는 Screen에 저장된 포인터를 꺼내서 vtable의 함수를 호출한다.
Widget *w = s->items[i];w->vtbl->on_event(w, code);Widget의 첫 번째 멤버인 vtbl에는 해당 Widget이 사용할 함수들이 들어 있다.
Widget ├── vtbl │ ├── render │ └── on_event ├── id ├── closed └── label따라서
w->vtbl->render(w);는 w가 가리키는 Widget의 vtable에서 render 함수를 찾아 호출하는 코드다.
문제는 Dialog의 이벤트가 발생했을 때였다.
screen_dispatch() ↓s->items[2]에서 Dialog 주소 가져옴 ↓dialog_on_event() ↓self->closed = 1 ↓free(self)여기서 Dialog는 해제됐지만 s->items[2]는 그대로였다.
s->items[2] │ ▼[해제된 메모리]그리고 이후 screen_render()에서 다시 이 포인터를 사용한다.
Widget *w = s->items[i];w->vtbl->render(w);원래라면 다음과 같아야 한다.
w ↓Dialog Widget ↓vtbl ↓dialog_render()하지만 이미 w가 가리키는 메모리는 free()된 상태다.
여기서 문제가 더 복잡해진다. free()된 메모리 주소를 가지고 있다고 해서 그 공간이 바로 다른 값으로 바뀌는 것은 아니다. 이후 malloc()이 실행되면서 해당 메모리 공간이 다른 용도로 재사용될 수 있다.
이번 문제에서는 이후 새로운 메모리가 할당되면서 해제된 공간이 재사용될 수 있었고, 그 상태에서 w->vtbl을 읽으려고 했다.
결국 원래 Widget의 vtbl이 있어야 할 위치에서 엉뚱한 값을 읽게 되고, 잘못된 함수 주소를 사용하면서 SIGSEGV가 발생할 수 있었다.
흐름으로 정리하면 다음과 같다.
Dialog 생성 ↓Screen에 Dialog 주소 저장 ↓닫기 이벤트 발생 ↓dialog_on_event() ↓free(self) ↓Screen은 해제된 주소를 계속 가지고 있음 ↓다른 malloc()에서 메모리 재사용 ↓screen_render() ↓w->vtbl->render(w) ↓잘못된 메모리 접근 ↓SIGSEGV여기서 중요한 점은 크래시가 발생한 줄과 실제 버그가 만들어진 줄이 다르다는 것이었다.
크래시는 screen_render()의
w->vtbl->render(w);에서 발생했지만, 그 원인은 훨씬 앞의 free(self)였다.
그래서 이런 포인터 관련 버그를 찾을 때는 크래시가 난 코드만 보는 것이 아니라 포인터가 어디에서 만들어지고, 어디에 저장되고, 언제 해제되고, 해제된 뒤 다시 사용되는지를 순서대로 따라가야 한다.
해결 방법#
처음에는 free()를 없애야 하나 생각했지만, Widget을 사용하지 않게 됐다고 해서 메모리를 계속 가지고 있을 수도 없다.
문제는 free() 자체보다 누가 Widget을 해제하고, 해제한 뒤 다른 포인터를 어떻게 처리할 것인가였다.
기존에는 Dialog가 스스로 메모리를 해제하고 있었다.
static void dialog_on_event(Widget *self, int code) { if (code == 1) { self->closed = 1; widget_destroy(self); }}이렇게 하면 Dialog는 자신이 free()된 사실을 Screen에 알리지 못한다.
그래서 이벤트 함수에서는 Widget을 직접 해제하지 않고 닫혔다는 상태만 기록하도록 변경했다.
static void dialog_on_event(Widget *self, int code) { if (code == 1) { self->closed = 1; }}그리고 실제 메모리 해제는 Widget을 관리하고 있는 Screen에서 담당한다.
static void screen_dispatch(Screen *s, int code) { for (int i = 0; i < s->count; i++) { if (s->items[i] == NULL) { continue; }
Widget *w = s->items[i];
w->vtbl->on_event(w, code);
if (w->closed) { free(w); s->items[i] = NULL; } }}이제 흐름은 다음과 같다.
이벤트 전달 ↓dialog_on_event() ↓closed = 1 ↓screen_dispatch()로 돌아옴 ↓w->closed 확인 ↓free(w) ↓s->items[i] = NULL여기서 free(w)와 s->items[i] = NULL은 서로 다른 일을 한다.
free(w);는 Widget이 사용하던 메모리를 해제한다.
s->items[i] = NULL;은 Screen이 더 이상 그 해제된 주소를 가지고 있지 않도록 포인터를 정리한다.
그래서 해제 이후에는 다음과 같은 상태가 된다.
items[0] ──→ Labelitems[1] ──→ Buttonitems[2] ──→ NULLitems[3] ──→ Button그리고 screen_render()에서도 NULL인 슬롯을 건너뛰도록 해야 한다.
static void screen_render(Screen *s) { for (int i = 0; i < s->count; i++) { if (s->items[i] == NULL) { continue; }
Widget *w = s->items[i]; w->vtbl->render(w); }}이렇게 하면 items[2]가 NULL인 상태에서 해당 포인터를 다시 사용하지 않는다.
결국 Widget의 생명주기는 다음과 같이 바뀌었다.
widget_new() ↓malloc() ↓Screen에 주소 저장 ↓정상적으로 사용 ↓닫기 이벤트 ↓closed = 1 ↓Screen이 closed 확인 ↓free() ↓Screen의 포인터 = NULL ↓이후 NULL 슬롯은 건너뜀새롭게 알게 된 것#
이번 문제를 풀면서 가장 먼저 이해한 것은 free()가 포인터까지 없애주는 것은 아니라는 것이다.
Widget *w = malloc(...);
free(w);이후에도 w에는 기존 주소값이 남아 있을 수 있다.
w ─────→ 해제된 메모리이런 포인터를 그대로 사용하면 댕글링 포인터가 되고, 해제된 메모리를 다시 사용하면 Use After Free가 된다.
또 하나 헷갈렸던 부분은 free()와 NULL 처리가 같은 것이라고 생각했던 점이다.
free(w);와
w = NULL;은 목적이 다르다.
free()는 메모리를 해제하고, NULL 대입은 해당 포인터가 더 이상 그 주소를 가리키지 않도록 만든다.
그래서 여러 곳에서 같은 객체의 주소를 가지고 있다면 한 곳에서 free()했다고 끝나는 것이 아니다.
누가 이 객체를 가리키고 있는가?누가 이 객체를 해제하는가?해제한 뒤 다른 포인터는 어떻게 되는가?를 같이 확인해야 한다.
이번 문제에서는 Screen이 Widget 포인터들을 가지고 있기 때문에 Screen에서 Widget의 해제와 슬롯 정리를 함께 처리하는 방식으로 수정했다.
그리고 struct와 함수 포인터가 같이 사용된 코드에서는 포인터를 따라가는 과정도 중요했다.
w->vtbl->render(w);를 단순히 함수 호출로만 보면 크래시 원인을 찾기 어렵지만,
w ↓Widget 구조체 ↓vtbl ↓render 함수 주소로 따라가 보면 w 자체가 이미 잘못된 메모리를 가리키고 있다는 것을 확인할 수 있다.
다음에 비슷한 문제를 보면#
앞으로 C에서 malloc()과 free()가 같이 나오는 코드를 보면 메모리를 언제 해제하는지만 확인하지 않고 해제한 뒤 그 주소를 가지고 있는 포인터가 남아 있는지 먼저 확인할 것 같다.
특히 문제가 발생했을 때는 다음 순서로 포인터를 따라가려고 한다.
생성 ↓어디에 주소를 저장했는가? ↓어디에서 전달되는가? ↓어디에서 free되는가? ↓free된 뒤 다시 사용하는 곳이 있는가?그리고 크래시가 발생한 위치가 포인터가 잘못된 위치라고 바로 판단하지 않고, 그 포인터가 언제부터 잘못된 상태가 되었는지를 거꾸로 추적해봐야 한다.
이번 문제에서는 screen_render()에서 크래시가 발생했지만 실제로 포인터가 잘못된 상태가 된 것은 dialog_on_event()에서 free(self)를 실행한 순간이었다.
결국 이번 문제를 한 줄로 정리하면 다음과 같다.
free()한 뒤에도 그 주소를 다른 곳에서 가지고 있다면, 그 포인터가 다시 사용되지 않도록 함께 정리해야 한다.