본문으로 건너뛰기 C++ Use After Free | '해제 후 사용' 가이드

C++ Use After Free | '해제 후 사용' 가이드

C++ Use After Free | '해제 후 사용' 가이드

이 글의 핵심

C++ Use After Free: "해제 후 사용" 가이드. Use After Free란?·발생 원인.

Use After Free란?

Use After Free(UAF)deletefree로 이미 해제된 메모리 블록을 계속 참조하고 접근하는 버그입니다. 해제된 순간부터 그 주소는 더 이상 여러분의 것이 아니며, 힙 할당자가 언제든 그 공간을 다른 용도로 재사용할 수 있는 자유로운 상태가 됩니다. 문제는 해제 직후에는 데이터가 그대로 남아 있어 코드가 “우연히” 정상 동작하는 것처럼 보일 수 있다는 점입니다. 그래서 UAF는 개발 중에는 티가 나지 않다가 프로덕션에서 다른 할당이 그 주소를 재사용하는 순간 크래시나 데이터 손상으로 터져 나오는, 재현하기 까다로운 버그의 대표 격입니다.

// ❌ Use After Free
int* ptr = new int(10);
delete ptr;
*ptr = 42;  // 위험!

발생 원인

UAF는 겉보기에 서로 다른 코드에서 튀어나오지만, 뿌리를 따라가 보면 결국 “메모리의 수명을 잘못 추적했다”는 하나의 원인으로 수렴합니다. 아래 네 가지는 실무에서 가장 자주 나타나는 패턴으로, 단순한 해제 후 접근부터 컨테이너 내부 버퍼가 재할당되면서 생기는 간접적인 무효화까지를 포함합니다.

// 1. 해제 후 접근
int* ptr = new int(10);
delete ptr;
std::cout << *ptr << std::endl;  // UAF

// 2. 댕글링 포인터
int* getPointer() {
    int* ptr = new int(10);
    delete ptr;
    return ptr;  // 댕글링
}

// 3. 이중 해제 후 사용
int* ptr = new int(10);
delete ptr;
delete ptr;  // 이중 해제
*ptr = 42;  // UAF

// 4. 컨테이너 무효화
std::vector<int> vec = {1, 2, 3};
int* ptr = &vec[0];
vec.clear();
*ptr = 42;  // UAF

실전 예시

이제 각 원인별로 문제가 있는 코드와 안전한 대안을 나란히 비교하면서, 어떤 패턴이 위험하고 무엇으로 대체해야 하는지 구체적으로 살펴보겠습니다.

예시 1: 기본 UAF

가장 단순한 형태의 UAF는 delete 직후 같은 포인터를 그대로 역참조하는 경우입니다. 아래 useAfterFree()는 해제 직후 값을 다시 출력하려 하는데, 이 시점에 메모리가 아직 재사용되지 않았다면 우연히 이전 값(10)이 그대로 보일 수도 있어 버그를 놓치기 쉽습니다. safeUse()처럼 해제 직후 포인터를 nullptr로 만들어두면 최소한 실수로 재사용하는 경우를 조건문으로 걸러낼 수 있고, smartPointer()처럼 애초에 std::unique_ptr을 쓰면 이런 실수 자체가 발생할 여지가 없어집니다.

#include <iostream>

// ❌ Use After Free
void useAfterFree() {
    int* ptr = new int(10);
    std::cout << *ptr << std::endl;  // 10
    
    delete ptr;
    std::cout << *ptr << std::endl;  // UAF (위험!)
}

// ✅ nullptr 설정
void safeUse() {
    int* ptr = new int(10);
    std::cout << *ptr << std::endl;
    
    delete ptr;
    ptr = nullptr;
    
    if (ptr) {
        std::cout << *ptr << std::endl;
    }
}

// ✅ 스마트 포인터
void smartPointer() {
    auto ptr = std::make_unique<int>(10);
    std::cout << *ptr << std::endl;
    // 자동 해제
}

예시 2: 댕글링 레퍼런스

지역 변수에 대한 참조를 함수 밖으로 반환하면, 함수가 반환되는 순간 그 지역 변수는 소멸되고 참조는 이미 존재하지 않는 대상을 가리키는 댕글링 레퍼런스가 됩니다. 아래 getName()은 지역 std::string name에 대한 참조를 반환하는데, 함수가 끝나는 즉시 name은 소멸되므로 호출자가 받은 참조는 처음부터 무효합니다. 참조나 포인터 대신 값을 그대로 반환하면 반환값 최적화(RVO)나 이동 생성자 덕분에 큰 성능 손실 없이도 안전하게 값을 전달할 수 있습니다.

#include <string>

// ❌ 댕글링 레퍼런스
const std::string& getName() {
    std::string name = "Alice";
    return name;  // name 소멸
}

int main() {
    const std::string& ref = getName();
    std::cout << ref << std::endl;  // UAF
}

// ✅ 값 반환
std::string getName() {
    std::string name = "Alice";
    return name;  // 복사 또는 이동
}

int main() {
    std::string name = getName();
    std::cout << name << std::endl;
}

예시 3: 컨테이너 무효화

std::vector는 용량이 부족해지면 내부적으로 더 큰 메모리 블록을 새로 할당하고 기존 원소를 그리로 옮긴 뒤 이전 블록을 해제합니다. 이 재할당이 일어나면 이전 블록을 가리키던 포인터나 반복자는 모두 무효가 되는데, 문제는 push_back 한 번으로 재할당이 일어날지 여부를 호출부에서 미리 알 수 없다는 점입니다. 포인터 대신 인덱스를 저장해 두면 재할당이 일어나도 vec[index]로 항상 올바른 원소에 접근할 수 있고, 포인터가 꼭 필요하다면 재할당 가능성이 있는 연산 이후에 다시 얻어와야 합니다.

#include <vector>

// ❌ 반복자 무효화
void iteratorInvalidation() {
    std::vector<int> vec = {1, 2, 3};
    int* ptr = &vec[0];
    
    vec.push_back(4);  // 재할당 가능
    std::cout << *ptr << std::endl;  // UAF 가능
}

// ✅ 인덱스 사용
void useIndex() {
    std::vector<int> vec = {1, 2, 3};
    size_t index = 0;
    
    vec.push_back(4);
    std::cout << vec[index] << std::endl;  // 안전
}

// ✅ 재할당 후 다시 참조
void reacquirePointer() {
    std::vector<int> vec = {1, 2, 3};
    
    vec.push_back(4);
    int* ptr = &vec[0];  // 재할당 후 다시 얻기
    std::cout << *ptr << std::endl;
}

예시 4: 객체 수명

unique_ptr::get()으로 얻은 원시 포인터는 어디까지나 “빌린” 포인터일 뿐, 소유권을 나타내지 않습니다. 아래 ownershipTransfer()에서 rawptr이 관리하는 Widget을 가리키고 있지만, ptr.reset()이 호출되는 순간 Widget은 소멸되고 raw는 즉시 댕글링 포인터가 됩니다. 이런 원시 포인터를 스코프 밖으로 내보내거나 오래 들고 있어야 한다면, 소유권을 공유할 수 있는 std::shared_ptr을 사용해 원본이 사라져도 참조하는 쪽이 객체를 계속 살아있게 만드는 편이 안전합니다.

#include <memory>

class Widget {
public:
    int value = 42;
    
    ~Widget() {
        std::cout << "Widget 소멸" << std::endl;
    }
};

// ❌ 소유권 이전 후 사용
void ownershipTransfer() {
    auto ptr = std::make_unique<Widget>();
    Widget* raw = ptr.get();
    
    ptr.reset();  // Widget 소멸
    std::cout << raw->value << std::endl;  // UAF
}

// ✅ 소유권 유지
void keepOwnership() {
    auto ptr = std::make_unique<Widget>();
    std::cout << ptr->value << std::endl;
    
    ptr.reset();  // 사용 후 해제
}

// ✅ shared_ptr
void sharedOwnership() {
    auto ptr1 = std::make_shared<Widget>();
    auto ptr2 = ptr1;
    
    ptr1.reset();
    std::cout << ptr2->value << std::endl;  // 안전
}

자주 발생하는 문제

아래 네 가지 문제는 앞서 다룬 기본 패턴들이 실무 코드에서 조금 더 복잡한 형태로 나타나는 경우입니다. 이중 해제, 함수 반환값, 람다 캡처, 클래스 멤버라는 서로 다른 맥락에서도 근본 원인은 동일하게 “수명이 끝난 메모리를 계속 붙잡고 있다”는 것입니다.

문제 1: 이중 해제

같은 포인터에 대해 delete를 두 번 호출하면 힙 할당자의 내부 메타데이터가 손상되며, 그 직후의 *ptr = 42 대입은 이미 해제된(그리고 손상되었을 수 있는) 메모리에 쓰기 작업을 하는 UAF로 이어집니다. 이중 해제는 단일 UAF보다 더 위험한데, 할당자 내부 구조가 깨지면 이후의 다른 할당·해제 작업까지 예측 불가능해지기 때문입니다. delete 직후 포인터를 nullptr로 설정해두면, C++ 표준이 delete nullptr을 안전한 무연산으로 규정하고 있어 실수로 두 번째 delete가 호출되어도 아무 일도 일어나지 않습니다.

// ❌ 이중 해제 후 사용
void doubleFree() {
    int* ptr = new int(10);
    delete ptr;
    delete ptr;  // 이중 해제
    *ptr = 42;  // UAF
}

// ✅ nullptr 체크
void safeFree() {
    int* ptr = new int(10);
    delete ptr;
    ptr = nullptr;
    
    delete ptr;  // 안전 (무시됨)
    
    if (ptr) {
        *ptr = 42;
    }
}

문제 2: 반환된 포인터

함수가 자원을 할당하고 스스로 해제한 다음 그 포인터를 반환하는 것은 앞서 본 댕글링 레퍼런스 문제의 포인터 버전입니다. 아래 createAndDelete()new로 메모리를 할당하고 delete로 즉시 해제한 뒤 이미 무의미해진 주소값을 반환하는데, 컴파일러는 이를 문법적으로 전혀 문제 삼지 않으므로 오직 코드 리뷰나 정적 분석 도구만이 이런 실수를 잡아낼 수 있습니다. 함수가 자원을 만들어 소유권째로 넘겨줘야 한다면, 애초에 std::unique_ptr을 반환 타입으로 사용해 소유권 이전을 타입 시스템 차원에서 명확히 하는 것이 안전합니다.

// ❌ 해제된 포인터 반환
int* createAndDelete() {
    int* ptr = new int(10);
    delete ptr;
    return ptr;  // 댕글링
}

// ✅ 유효한 포인터 반환
std::unique_ptr<int> createSafe() {
    return std::make_unique<int>(10);
}

문제 3: 람다 캡처

람다가 원시 포인터를 캡처하면, 그 람다가 나중에(다른 함수 안에서, 혹은 비동기 콜백으로) 실행될 때 캡처된 포인터가 여전히 유효하다는 보장이 전혀 없습니다. 아래 createGetter()는 힙에 값을 하나 할당해 포인터로 캡처한 람다를 만든 다음, 정작 그 포인터가 가리키는 메모리는 반환하기 직전에 delete해 버려서 반환된 람다를 나중에 호출하는 순간 UAF가 발생합니다. 값 자체를 캡처하거나 std::shared_ptr을 캡처하면, 람다가 그 값(또는 참조 카운트)의 수명을 함께 연장시켜 주므로 이런 문제가 사라집니다.

#include <functional>

// ❌ 댕글링 캡처
std::function<int()> createGetter() {
    int* ptr = new int(10);
    auto getter = [ptr]() { return *ptr; };
    delete ptr;
    return getter;  // UAF
}

// ✅ 값 캡처
std::function<int()> createGetter() {
    int value = 10;
    return [value]() { return value; };
}

// ✅ shared_ptr 캡처
std::function<int()> createGetter() {
    auto ptr = std::make_shared<int>(10);
    return [ptr]() { return *ptr; };
}

문제 4: 멤버 포인터

클래스가 내부 자원에 대한 원시 포인터를 반환하는 getter를 제공하면, 그 반환된 포인터의 수명은 사실상 클래스 인스턴스의 수명과 묶여버립니다. 아래 Container::getData()가 반환한 포인터를 호출자가 Container 객체보다 오래 들고 있으면, 객체가 스코프를 벗어나 소멸자가 delete data를 실행하는 순간 그 포인터는 댕글링 상태가 됩니다. 내부 자원을 값으로 감싸서 반환하거나(ContainerSafe처럼), 포인터 대신 참조 카운트가 있는 스마트 포인터 멤버를 사용하는 것이 이런 구조적 위험을 없애는 방법입니다.

class Container {
private:
    int* data;
    
public:
    Container() : data(new int(10)) {}
    
    ~Container() {
        delete data;
    }
    
    // ❌ 소멸 후 사용 가능
    int* getData() {
        return data;
    }
};

void useContainer() {
    int* ptr;
    {
        Container c;
        ptr = c.getData();
    }  // c 소멸
    
    *ptr = 42;  // UAF
}

// ✅ 값 반환
class ContainerSafe {
private:
    int data;
    
public:
    ContainerSafe() : data(10) {}
    
    int getData() const {
        return data;
    }
};

탐지 방법

UAF는 코드를 눈으로 읽는 것만으로 잡아내기 어려운 버그입니다. 해제된 메모리가 재사용되기 전까지는 증상이 전혀 드러나지 않을 수 있기 때문에, 실제로는 아래와 같은 동적·정적 도구를 조합해서 탐지하는 것이 훨씬 효율적입니다. AddressSanitizer는 컴파일 시점에 계측 코드를 삽입해 해제된 메모리에 접근하는 즉시 오류를 보고하고, Valgrind는 별도의 재컴파일 없이 바이너리를 가상 머신 위에서 실행하며 검사하며, clang-tidycppcheck 같은 정적 분석 도구는 실행 없이도 의심스러운 패턴을 미리 짚어줍니다.

# AddressSanitizer
g++ -fsanitize=address -g program.cpp
./a.out

# Valgrind
valgrind --tool=memcheck ./program

# Static Analysis
clang-tidy program.cpp
cppcheck --enable=all program.cpp

방지 방법

지금까지 다룬 다양한 UAF 패턴을 예방하는 방법을 종합하면, 결국 “메모리 수명을 명시적인 코드 구조로 표현한다”는 하나의 원칙으로 모입니다. 아래 다섯 가지는 실무에서 가장 효과적으로 검증된 접근법으로, 스마트 포인터 사용을 기본값으로 삼고 원시 포인터는 소유권이 없는 경우에만 제한적으로 쓰는 것이 핵심입니다.

// 1. 스마트 포인터
auto ptr = std::make_unique<int>(10);

// 2. nullptr 설정
delete ptr;
ptr = nullptr;

// 3. RAII 패턴
class Resource {
    int* data;
public:
    Resource() : data(new int(10)) {}
    ~Resource() { delete data; }
    
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
};

// 4. 값 반환
int getValue() {
    int value = 10;
    return value;
}

// 5. 소유권 명확화
std::unique_ptr<int> createOwned();
int* createBorrowed();  // 소유권 없음

디버깅 팁

ASan 같은 전문 도구를 쓸 수 없는 환경이라면, 직접 계측 코드를 넣어 문제를 좁혀갈 수도 있습니다. 아래 두 기법은 각각 “해제된 메모리를 알아보기 쉬운 패턴으로 오염시키는 방법”과 “포인터 접근 자체를 감시하는 래퍼를 만드는 방법”으로, 대규모 ASan 도입이 부담스러운 레거시 코드베이스에서 국지적으로 적용하기 좋습니다.

// 1. 디버그 빌드에서 메모리 패턴
#ifdef _DEBUG
void* operator new(size_t size) {
    void* ptr = malloc(size);
    memset(ptr, 0xCD, size);  // 할당 패턴
    return ptr;
}

void operator delete(void* ptr) {
    if (ptr) {
        memset(ptr, 0xDD, sizeof(ptr));  // 해제 패턴
    }
    free(ptr);
}
#endif

// 2. 가드 패턴
class GuardedPointer {
    int* ptr;
    bool valid;
    
public:
    GuardedPointer(int* p) : ptr(p), valid(true) {}
    
    ~GuardedPointer() {
        valid = false;
    }
    
    int& operator*() {
        if (!valid) {
            throw std::runtime_error("UAF 탐지");
        }
        return *ptr;
    }
};

UAF가 생기는 대표 원인 (정리)

  1. 명시적 해제 후 raw 포인터 유지: delete/free 이후 같은 주소를 역참조. nullptr 대입은 “실수로 쓰는 것”만 일부 막을 뿐, 다른 경로에서 같은 블록이 재할당되면 여전히 논리 버그가 될 수 있습니다.
  2. 소유권과 빌림 혼동: 함수가 “내부 버퍼 포인터”를 실행하여주고 호출자가 그것을 오래 들고 있는 패턴. 컨테이너 재할당·객체 소멸 후 포인터는 즉시 무효입니다.
  3. 반복자·포인터 무효화: vectorpush_back, string의 비const 연산 등으로 재할당이 일어나면 기존 &vec[0]·반복자는 무효일 수 있습니다.
  4. 비동기·콜백: 나중에 실행되는 람하다가 스택 변수나 이미 닫힌 연결의 raw 포인터를 캡처한 경우.
  5. 이중 해제와 댕글링: double free는 힙 메타데이터를 망가뜨려 이후 할당/해제에서 UAF·크래시로 이어지기 쉽습니다.

AddressSanitizer로 조기 탐지하기

컴파일러에 ASan을 켜면 대부분의 UAF가 즉시 보고되며, “할당 스택”과 “해제 스택”, “접근 위치”를 같이 보여 줍니다.

g++ -std=c++17 -O1 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf
ASAN_OPTIONS=abort_on_error=1:detect_stack_use_after_return=1 ./uaf
  • detect_stack_use_after_return=1: 스택 변수를 잘못 캡처한 경우 등도 더 공격적으로 잡습니다(오버헤드 증가).
  • 크래시 시 Shadow memory 설명과 함께 소스 줄이 나오므로, 먼저 가장 가까운 delete/free와 그 이후 접근을 대조합니다.

Valgrind Memcheck도 UAF를 잡지만 느리므로, CI에서는 ASan 빌드 잡을 두는 방식이 일반적입니다.

스마트 포인터와 소유권 규칙으로 예방

  • std::unique_ptr: 단일 소유. 소유권 이전 후에는 원본을 쓰지 않는 것이 규칙입니다. 필요하면 std::exchange(ptr, nullptr)로 의도를 드러냅니다.
  • std::shared_ptr + std::weak_ptr: 공유 수명. 캐시·그래프처럼 순환 참조가 생기면 weak_ptr로 끊습니다.
  • 관찰만 할 때: 소유하지 않는다면 gsl::not_null<T*> 같은 표현이나, 문서화된 non-owning 포인터 관례를 팀에서 통일합니다. C++ Core Guidelines의 owner/non-owner 구분을 참고하면 좋습니다.

핵심: “누가 delete하는가?”가 한 곳으로 모일수록 UAF는 줄어듭니다. raw new/delete를 라이브러리 경계에서만 쓰고 내부는 스마트 포인터·RAII로 통일하는 편이 안전합니다.

실전 사례 (패턴 위주)

  1. 이벤트 루프·타이머: 콜백이 등록된 뒤 객체가 먼저 파괴되면, 콜백에서 멤버에 접근하며 UAF가 납니다. 해결: weak_ptr로 객체 존재 여부 확인, 또는 콜백 등록 해제를 소멸자에서 보장.
  2. C API 연동: free된 핸들을 래퍼가 또 쓰는 버그. 래퍼 클래스 소멸자 한 곳에서만 해제하며, 복사·이동을 = delete 또는 명시적으로 정의합니다.
  3. 문자열 뷰 수명: string_view가 소유한 string보다 오래 살아 있으면 UAF입니다. 뷰는 짧은 구간에서만 쓰며, 저장이 필요하면 string을 소유하세요.

디버깅 워크플로 (gdb + ASan)

  1. ASan 로그를 먼저 읽는다: ERROR 줄 근처의 소스 위치가 1차 의심 지점.
  2. gdb에서 bt full: 인라인 최적화로 줄이 어긋나면 -O0 또는 해당 함수만 최적화 끄기.
  3. 의심 포인터의 수명: 같은 주소를 할당한 쪽·해제한 쪽을 코드 검색(grep/IDE)으로 연결합니다.
  4. 재현 입력 최소화: 퍼저나 단위 테스트로 실패 케이스를 고정해 두면 회귀 방지에 유리합니다.

ASan이 없는 환경이라면 디버그 힙(플랫폼별)이나 전역 할당자 후킹으로 할당 ID를 찍는 방법도 있지만, 비용이 크므로 가능하면 ASan 빌드를 기본으로 두는 것이 좋습니다.

FAQ

Q1: Use After Free는 언제?

A:

  • 해제 후 접근
  • 댕글링 포인터
  • 컨테이너 무효화

Q2: 탐지 방법은?

A:

  • AddressSanitizer
  • Valgrind
  • Static Analysis

Q3: 방지 방법은?

A:

  • 스마트 포인터
  • nullptr 설정
  • RAII 패턴

Q4: 증상은?

A:

  • 크래시
  • 예측 불가능한 동작
  • 메모리 손상

Q5: nullptr 체크 충분?

A: 부분적. 스마트 포인터가 더 안전.

Q6: Use After Free 학습 리소스는?

A:

  • “Effective C++”
  • AddressSanitizer 문서
  • CWE-416

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, use-after-free, memory, safety, debugging 등으로 검색하시면 이 글이 도움이 됩니다.