← All Posts

[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] ──→ Label
items[1] ──→ Button
items[2] ──→ NULL
items[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()한 뒤에도 그 주소를 다른 곳에서 가지고 있다면, 그 포인터가 다시 사용되지 않도록 함께 정리해야 한다.