C++ Use After Free | "해제 후 사용" 가이드
이 글의 핵심
해제된 메모리를 다시 쓰는 UAF의 원인, AddressSanitizer로 조기 탐지, 스마트 포인터·수명 설계로 예방, 그리고 gdb·ASan 로그로 역추적하는 실무 절차까지 정리합니다.
Use After Free란?
해제된 메모리를 사용하는 오류
// ❌ Use After Free
int* ptr = new int(10);
delete ptr;
*ptr = 42; // 위험!
발생 원인
// 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
#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: 댕글링 레퍼런스
#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: 컨테이너 무효화
#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: 객체 수명
#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: 이중 해제
// ❌ 이중 해제 후 사용
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: 반환된 포인터
// ❌ 해제된 포인터 반환
int* createAndDelete() {
int* ptr = new int(10);
delete ptr;
return ptr; // 댕글링
}
// ✅ 유효한 포인터 반환
std::unique_ptr<int> createSafe() {
return std::make_unique<int>(10);
}
문제 3: 람다 캡처
#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: 멤버 포인터
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;
}
};
탐지 방법
# 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
방지 방법
// 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(); // 소유권 없음
디버깅 팁
// 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가 생기는 대표 원인 (정리)
- 명시적 해제 후 raw 포인터 유지:
delete/free이후 같은 주소를 역참조.nullptr대입은 “실수로 쓰는 것”만 일부 막을 뿐, 다른 경로에서 같은 블록이 재할당되면 여전히 논리 버그가 될 수 있습니다. - 소유권과 빌림 혼동: 함수가 “내부 버퍼 포인터”를 돌려주고 호출자가 그걸 오래 들고 있는 패턴. 컨테이너 재할당·객체 소멸 후 포인터는 즉시 무효입니다.
- 반복자·포인터 무효화:
vector의push_back,string의 비const 연산 등으로 재할당이 일어나면 기존&vec[0]·반복자는 무효일 수 있습니다. - 비동기·콜백: 나중에 실행되는 람다가 스택 변수나 이미 닫힌 연결의 raw 포인터를 캡처한 경우.
- 이중 해제와 댕글링:
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로 통일하는 편이 안전합니다.
실전 사례 (패턴 위주)
- 이벤트 루프·타이머: 콜백이 등록된 뒤 객체가 먼저 파괴되면, 콜백에서 멤버에 접근하며 UAF가 납니다. 해결:
weak_ptr로 객체 존재 여부 확인, 또는 콜백 등록 해제를 소멸자에서 보장. - C API 연동:
free된 핸들을 래퍼가 또 쓰는 버그. 래퍼 클래스 소멸자 한 곳에서만 해제하고, 복사·이동을= delete또는 명시적으로 정의합니다. - 문자열 뷰 수명:
string_view가 소유한string보다 오래 살아 있으면 UAF입니다. 뷰는 짧은 구간에서만 쓰고, 저장이 필요하면string을 소유하세요.
디버깅 워크플로 (gdb + ASan)
- ASan 로그를 먼저 읽는다: ERROR 줄 근처의 소스 위치가 1차 의심 지점.
- gdb에서
bt full: 인라인 최적화로 줄이 어긋나면-O0또는 해당 함수만 최적화 끄기. - 의심 포인터의 수명: 같은 주소를 할당한 쪽·해제한 쪽을 코드 검색(
grep/IDE)으로 연결합니다. - 재현 입력 최소화: 퍼저나 단위 테스트로 실패 케이스를 고정해 두면 회귀 방지에 유리합니다.
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++ Heap Corruption | “힙 손상” 가이드
- C++ Memory Leak | “메모리 누수” 가이드
- C++ Buffer Overflow | “버퍼 오버플로우” 가이드
관련 글
- C++ Buffer Overflow |
- C++ Heap Corruption |
- C++ Memory Leak |
- C++ Sanitizers |
- C++ Valgrind |