C++ Use After Free | '해제 후 사용' 가이드
이 글의 핵심
C++ Use After Free: "해제 후 사용" 가이드. Use After Free란?·발생 원인.
Use After Free란?
해제된 메모리를 사용하는 오류
// ❌ Use After Free
int* ptr = new int(10);
delete ptr;
*ptr = 42; // 위험!
발생 원인
C/C++ 예제 코드입니다.
// 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: 반환된 포인터
C/C++ 예제 코드입니다.
// ❌ 해제된 포인터 반환
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++ Use After Free | ‘해제 후 사용’ 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ Use After Free | ‘해제 후 사용’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, use-after-free, memory, safety, debugging 등으로 검색하시면 이 글이 도움이 됩니다.