C언어/잡학사전

[C언어] 댕글링 포인터와 와일드 포인터 (Dangling pointer & Wild pointer)

아무일도없었다 2023. 3. 20. 21:45
와일드 포인터 (Wild Pointer)

Wild Pointer(야생의 포인터).

단어의 의미 그대로 초기화가 안돼서 쓰레기값으로 채워진 포인터를 의미한다.

 

단어의 정의를 처음 볼 수도 있지만 포인터를 배울 때 가장 기본적으로 듣는 말이다.

(포인터 변수는 NULL 초기화를 하는 것이 좋다.)

 

이유는 매우 간단하다.

 

개발자가 의도하지 않은 메모리 영역에 접근하여 SIGSEGV 같은 치명적인 오류가 발생할 수 있기 때문이다.

 

또한 Wild Pointer가 위험한 이유는 복잡한 비즈니스 코드에서 대부분의 포인터 예외처리는 NULL Check를 통해 이루어지는데, Wild Pointer의 경우 쓰레기값으로 채워져 있어 이러한 예외를 무력화하기 때문이다.


와일드 포인터 (Wild Pointer) 예외 처리

Wild Pointer의 예외처리는 매우 간단하다.

 

포인터 변수를 선언과 동시에 NULL 또는 유효한 주소값으로 반드시 초기화하는 것이다.

 

여기서 제일 핵심은 선언과 동시에 초기화이다.

 

사실 어느 정도 경력이 있는 개발자들은 대부분 Wild Pointer를 생성하지 않는다. (절대적이진 않다.)

 

그렇다고 그 사람들이 반드시 Pointer를 선언과 동시에 초기화하는 것은 아니다. (절대 따라 하면 안 된다...)

 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_ROUND 9

int main() {
    int *score;
    int round;
    
    score = calloc(MAX_ROUND, sizeof(int));
    if(score) {
        for(round = 0; round < MAX_ROUND; round++) {
            score[round] = round;
        }
        
        // ... business logic ...
        
        free(score);
    }
    
    return 0;
}

 

위의 예시 코드를 보면 선언과 동시에 초기화는 아니지만 선언직후 값을 할당하기 때문에 Wild Pointer의 문제는 없는 것으로 보인다.

 

하지만 위의 코드가 수 개월 혹은 수 년이 지난 후에도 동일하게 유지가 될지는 아무도 모른다.

 

위의 코드가 아래와 같이 변했다고 가정을 해보자.

 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_ROUND 9

int tatal_score(int *score, int round) {
    int total = 0;

    if(score) {
        for(round = 0; round < MAX_ROUND; round++) {
            total += score[round];
        }
    }
    
    return total;
}

// ... business logic ...

double avg_score(int *score, int round) {
    int total = 0;

    if(score) {
        for(round = 0; round < MAX_ROUND; round++) {
            total += score[round];
        }
    }
    
    return (double)(total/MAX_ROUND);
}

// ... business logic ...

#define MAX_PLAYER 50

int main() {
    char *player[MAX_PLAYER];
    int *score;
    int round;
    double avg;
    long long total;
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    // wild pointer exception !!!
    avg = avg_score(score, MAX_ROUND); 
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    score = calloc(MAX_ROUND, sizeof(int));
    if(score) {
        for(round = 0; round < MAX_ROUND; round++) {
            score[round] = round;
        }
        
        // ... business logic ...
        
        free(score);
    }
    
    // ... business logic ...
    
    
    // ... business logic ...
    
    // ... business logic ...
    
    return 0;
}

 

처음에는 문제가 없었지만 여러 사람의 손을 거치며 추가비즈니스 로직에서 초기화되지 않은 Wild Pointer를 그대로 썼다면 커다란 문제가 발생할 가능성이 있다.

 

각각의 util 함수에서는 Pointer 예외처리를 해놨지만 Wild Pointer는 NULL이 아닐 가능성이 높기 때문SIGSEGV 같은 문제가 발생할 가능성이 매우 높다.

 

따라서 문제가 없는 코드가 아닌 문제가 될 여지가 있는 코드를 통해서 Wild Pointer가 발생할 수 있는 것이고, 이는 포인터변수 선언과 동시에 초기화를 한다면 해결할 수 있으니 반드시 선언과 동시에 초기화하는 습관을 가져야 한다.

 

만약 누군가 고쳐서 쓰겠지라는 생각을 했다면 이것 또한 큰 문제라고 생각한다.
(만약 이렇게 생각한다면 인성 문제가...?)

물론 후임 개발자가 멋지게 리팩토링을 하면서 버그를 수정할 수도 있겠지만, 애초에 문제가 될 여지가 있는 코드를 넘겨주는 것이 너무 부끄럽다고 생각한다.

 


댕글링 포인터 (Dangling Pointer)

Dangling Pointer(위험한 포인터).

여러 가지 원인으로 인해 해제된 메모리 주소를 가리키는 포인터를 의미한다.

 

Dangling Pointer는 Wild Pointer와는 다른 결의 위험함을 내재하고 있다.

Wild Pointer는 초기화되지 않은 쓰레기값으로 인해 다음 상황을 예측하기 어렵지만, Dangling Pointer의 경우 UAF(Use After Free) 공격에 의한 보안 취약점 문제가 발생할 수 있기 때문이다.

 

UAF(Use After Free)는 Heap 영역의 메모리를 재사용할 때 생기는 취약점인데 Zero-Day Exploit에서 심심하면 계속 보이는 공격방법이다. (이 글의 주제에서 벗어난 내용이므로 자세한 내용은 생략한다.)

 


 

댕글링 포인터 (Dangling Pointer) 예외 처리

 

해제된 메모리 주소라 하면 대표적인 케이스가 free 함수로 인해 해제된 메모리 포인터를 그대로 사용하는 것이 있다.

 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUF_SIZE 128

int main() {
    char *buffer = NULL;
    
    buffer = calloc(BUF_SIZE, sizeof(char));
    if(buffer) {
        strcpy(buffer, "Hello Dangling !");
        
        printf("%s(%p)\n", buffer, buffer);
        
        free(buffer);
        
        printf("%s(%p)\n", buffer, buffer);
        
        for(i=0; i<strlen("Hello Dangling !"); i++) {
            printf("%02X ", (unsigned char)buffer[i]);
        }

        printf("(%p)\n", buffer);
    }
    
    return 0;
}

 

< 결과 >

Hello Dangling !(0x25392a0)
(0x25392a0)
00 00 00 00 00 00 00 00 10 90 53 02 00 00 00 00 (0x25392a0)
Hello Dangling !(0x25392a0)

 

포인터가 가리키는 Heap 영역의 메모리가 free 되었지만 여전히 사용 가능한 것을 확인할 수 있다. (Windows에서는 Crash가 발생한다 !)

 

차라리 Dangling Pointer 접근 즉시 죽으면 다행인데 linux와 같은 OS에서는 바로 죽지 않고 계속 동작하는 경우가 많이 있으며 이러다가 어디선가 죽어버리면 디버깅도 참 난감한 경우가 발생하게 된다. (이럴 때는 gdb, valgrind 같은 디버깅 툴을 적극 활용해야 한다.)

 

따라서 보통 Dangling Pointer의 예외처리로 free 이후 Pointer 변수에 NULL을 할당하는 방법을 선호한다.

이후 해당 포인터 사용 시 NULL 체크를 하는 방식으로 예외처리를 진행한다.

(만약 NULL 체크를 안하고 NULL Pointer에 접근해서 SIGSEGV가 발생하더라도 Dangling Pointer로 인해 예상하지 못한 곳에서 죽는 거보다 10000000배 마음이 편안하다.)

 

매번 free 이후 NULL을 넣는게 귀찮은 경우 매크로를 사용하는 경우도 많이 있으니 아래의 매크로를 사용하는 방법도 추천한다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUF_SIZE 128
#define MYFREE(a) {free(a);a=NULL;}

int main() {
    char *buffer = NULL;
    
    buffer = calloc(BUF_SIZE, sizeof(char));
    if(buffer) {
        strcpy(buffer, "Hello Dangling !");
        
        printf("%s(%p)\n", buffer, buffer);
        
        MYFREE(buffer);
        
        if(buffer) {
            printf("%s(%p)\n", buffer, buffer);
        
            for(i=0; i<strlen("Hello Dangling !"); i++) {
                printf("%02X ", (unsigned char)buffer[i]);
            }

            printf("(%p)\n", buffer);
        }
        else {
            printf("buffer is NULL\n");
        }
    }
    
    return 0;
}

 

< 결과 >

Hello Dangling !(00000000006C1440)
buffer is NULL

 


 

Dangling Pointer의 원인 두 번째 케이스는 해제된 Stack 메모리를 그대로 사용하는 것이 있다.

 

이는 Process Stack에 대한 이해도가 낮은 경우 발생할 가능성이 있고, 원인을 모르는 경우 한참 삽질을 할 수 있으니 알아두면 좋은 정보이다.

 

Stack은 함수의 인자, 함수의 반환 주소, 지역 변수 등이 저장되는 메모리 영역이다.

함수가 호출될 경우 Stack영역을 사용하여 필요한 데이터를 연산 및 저장하며 리턴될 경우 함수가 사용했던 Stack영역을 정리한다.

 

문제는 함수 내부에서 사용하던 Stack 영역의 포인터를 함수 밖으로 가지고 나가 사용할 경우 Dangling Pointer가 발생하게 된다.

 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUF_SIZE 128

char* util() {
    char buffer[BUF_SIZE] = {0,};

    strcpy(buffer, "Hello Dangling !");

    printf("[util] %s(%p)\n", buffer, buffer);

    return buffer;
}

int main() {
    char *ptr = NULL;
    
    ptr = util();
    if(ptr) {
        printf("[main] %s(%p)\n", ptr, ptr);
    }
    
    return 0;
}

 

< 결과 >

[util] Hello Dangling !(000000000060FD60)
[main] [main] Hello Da六�?q?000000000060FD60)

 

위의 예시 코드를 보면 dangling 함수의 지역변수 buffer의 포인터가 반환되었고, 이를 main 함수에서 받아서 사용하고 있다.

 

하지만 dangling 함수의 지역변수인 buffer의 포인터는 dangling 함수가 리턴된 이후 stack 영역이 유효하지 않기 때문에 main에서 접근하는 ptr의 경우 Dangling Pointer가 된 것이다.

 

이러한 케이스에 대한 예외처리는 특별히 없으며 개발자 스스로 항상 stack에 대한 개념을 생각하며 코드를 작성해야 한다.  (확신이 없다면 꼼꼼한 코드리뷰와 테스트를 병행하며, Clion 혹은 Cppcheck 같은 정적 분석 툴을 적극 활용하여 코드점검을 수시로 하면 이러한 실수를 최소화하는데 도움이 된다.)

 

다만 위의 예시코드처럼 pointer를 변수로 넘겨야하는 경우에 다른 방법을 써서 위의 상황을 우회해야한다.

 

1. heap memory 할당된 pointer를 return 하는 방법

char* util(size_t size) {
    char *buffer = NULL;

    buffer = calloc(size, sizeof(char));
    if(buffer) {
        strcpy(buffer, "Hello Dangling !");
        printf("[util] %s(%p)\n", buffer, buffer);
    }
    
    return buffer;
}

dangling pointer는 피했지만 위의 함수와 같이 heap memory 할당된 pointer를 반환할 경우 호출하는 함수에서 반드시 NULL 체크를 해서 함수가 성공적으로 호출됬는지 체크를 해야하고, 포인터 사용 후 free를 사용하여 메모리를 해제해야한다. 또한 얼마만큼의 메모리를 할당했는지도 반드시 아는 상태로 사용해야한다.

 

2. 전역변수(global) or 정적변수(static) 사용

 

<static 변수>

char* util() {
    static char buffer[BUF_SIZE] = {0,};

    strcpy(buffer, "Hello Dangling !");

    printf("[util] %s(%p)\n", buffer, buffer);

    return buffer;
}

 

<global 변수>

char buffer[BUF_SIZE] = {0,};

char* util() {
    strcpy(buffer, "Hello Dangling !");

    printf("[util] %s(%p)\n", buffer, buffer);

    return buffer;
}

이 역시 dangling pointer는 피했지만 위의 함수와 같이 정적 혹은 전역 변수를 사용할 경우 multi thread 환경에서 변수에 대한 thread safety 하지 않음으로 lock 과 같은 함수 혹은 알고리즘을 사용하여 안전하게 사용해야 한다.

반응형