Proven C Book←↑→

57 메모리 사고 도감

먼저 알아야 할 것

38장 포인터의 규칙 · 정렬과 프로버넌스
49장 공용체와 표현 · 타입 펀닝과 비값 표현
56장 정의되지 않은 동작 · UB·미지정·구현 정의

돌아보기

46장에서 동적 메모리의 세 대표 사고(누수·이중 해제·해제 후 사용)를 보았고, 39장에서 범위 밖 접근을, 49장에서 비값 표현을 보았다. 이것들은 서로 다른 사고인가, 아니면 같은 뿌리의 다른 얼굴인가?

답. 같은 뿌리다. C 의 객체는 크기·수명·타입·출처 네 가지를 가지고, 접근 하나는 그 넷을 모두 지켜야 계약 안에 있다. 넘침은 크기를, 해제 후 사용은 수명을, 펀닝 사고는 타입을, 이웃한 변수로 건너가는 포인터는 출처를 어긴 것이다. 이름이 수십 가지라도 어긴 약속은 넷 가운데 하나다. 이 장은 그 넷으로 사고를 갈라 한자리에 모은다.

이 장의 필요성과 맥락

메모리 사고는 이 책의 거의 모든 장에 한 번씩 나왔다. 흩어져 있으면 「조심할 것 목록」이 되고, 목록은 외워도 잊힌다. 이 자리에서 다시 모으는 까닭은 분류가 있으면 처음 보는 사고도 어느 약속을 어겼는지 물을 수 있기 때문이다. 56장에서 정의되지 않은 동작, 곧 UB(undefined behavior)를 정면으로 다룬 바로 뒤라, 이제 각 사고가 표준의 어느 문장에 걸리는지까지 짚을 수 있다.

이 장이 끝나면

사고를 어긴 약속에 따라 넷으로 가른다 — 크기(버퍼 오버플로), 수명(누수·댕글링·이중 해제), 초기화와 타입(불확정 값·비값 표현·엄격한 앨리어싱·정렬), 출처(프로버넌스·겹침). 사고마다 이름, 무엇이 문제인지, 표준의 자리, 실제로 일어난 사례, 잡는 도구와 막는 설계를 붙인다. 끝에서 메모리 쪽 UB 를 한 표로 모으고, 도구가 못 보는 것도 적는다.

이 장에서 답할 질문

  1. 사고마다 도구가 다르다면, 무엇부터 켜야 하는가?

57.1 메모리 사고란 무엇인가 — 네 가지 약속#

메모리 사고는 객체에 닿는 접근 하나가 그 객체의 약속을 어긴 것이다. 「메모리 오류」, 「메모리 안전 버그」라고도 부른다. 표준에는 이 낱말이 없다 — 표준은 사고마다 따로 「정의되지 않은 동작」이라고만 적는다. 그래서 이 장은 사고를 먼저 무엇을 어겼는가로 가른다.

36장에서 객체를 「값을 담는 기억의 한 구역」이라 했다. 그 구역에는 네 가지가 붙는다.

약속뜻어기면처음 나온 자리
크기구역이 몇 바이트인가 — 그 밖은 남의 자리다버퍼 오버플로, 범위 밖 읽기39장
수명구역이 언제부터 언제까지 있는가누수, 댕글링 포인터, 해제 후 사용, 이중 해제45–46장
타입어떤 눈으로 읽고 써도 되는가(유효 타입·정렬·값의 표현)불확정 값, 비값 표현, 엄격한 앨리어싱 위반, 정렬 위반38·49장
출처포인터가 어느 객체에서 나왔는가프로버넌스 위반, 겹치는 복사38장

표 57.1 — 객체가 가진 네 가지 약속과 그것을 어긴 사고

넷은 서로 독립이다. 크기 안에서 읽었어도 수명이 끝났으면 사고이고, 수명 안에서 읽었어도 타입이 틀리면 사고다. 그래서 「이 접근은 옳은가」를 물을 때는 네 번 묻는다 — 그 바이트가 그 구역 안인가, 그 구역이 아직 살아 있는가, 이 타입의 눈으로 읽어도 되는가, 이 포인터는 그 구역에서 나왔는가.

★ 그리고 넷 모두에 공통된 결과가 있다. 어기면 정의되지 않은 동작이다(56장). 그 자리에서 죽는다는 약속도, 이상한 값이 나온다는 약속도 없다. 그래서 이 장의 사고 도감 시연(「도구가 잡는 것, 못 잡는 것」 절)은 계약 밖 프로그램의 출력 대신 도구가 무엇이라 불렀는지만 싣는다. 계약 밖 프로그램의 출력을 싣는 두 시연(_FORTIFY_SOURCE 와 엄격한 앨리어싱)의 출력은 그 빌드의 그 실행에서 일어난 일일 뿐, 다시 그렇게 된다는 약속이 아니다.

57.2 크기를 어긴 것 — 버퍼 오버플로#

버퍼 오버플로(buffer overflow)는 그릇(데이터를 담으려고 잡아 둔 배열이나 블록, 곧 버퍼)의 끝을 넘어 쓰는 것이다. 넘쳐 쓴 바이트는 사라지지 않고 그 옆에 있던 것을 덮는다. 읽는 쪽으로 넘으면 「범위 밖 읽기」(out-of-bounds read)라 부른다. 한 칸 넘침, 이웃을 덮는 쓰기, 남의 기억이 새어 나가는 읽기는 39장에서 하나씩 보았다. 여기서는 그것을 갈래와 방어의 눈으로 다시 묶는다.

57.2.1 무엇이 넘치는가 — 다섯 갈래#

갈래전형적인 모습덮이는 것
스택 버퍼지역 배열에 strcpy·gets·끝 검사 없는 반복이웃한 지역 변수, 저장된 프레임 정보, 돌아갈 주소
힙 버퍼malloc 으로 빌린 블록의 끝을 넘어 쓰기이웃 블록의 내용, 할당자의 장부(99장)
하나 넘침(off-by-one)<= 하나, 널 종결자 자리를 세지 않은 길이끝 바로 다음 바이트 하나 — 그 하나로 충분하다
크기 계산의 넘침malloc(n * size) 의 곱이 넘쳐 작은 블록이 돌아옴작은 블록 뒤의 모든 것
읽기 넘침길이를 믿고 그 길이만큼 돌려줌아무것도 덮지 않는다 — 대신 남의 기억이 밖으로 나간다

표 57.2 — 버퍼 오버플로의 갈래

넷째 줄은 정수 사고가 메모리 사고로 옮아가는 자리다. n * size 는 size_t 의 산술이라 넘쳐도 UB 가 아니고 조용히 감아 돈다(6장). 그 결과로 할당은 성공하고, 프로그램은 n 개가 들어갈 줄 알고 쓰기 시작한다. 그래서 크기를 곱하는 자리에는 C23 의 검사 산술(96장)이 알맞다.

57.2.2 왜 위험한가 — 덮이는 것이 데이터만은 아니다#

넘침이 덮는 것은 이웃한 변수만이 아니다. 함수의 스택 프레임에는 그 함수가 끝나면 돌아갈 주소가 함께 저장된다(66장). 지역 배열을 넘쳐 쓰면 그 주소에 닿을 수 있고, 그러면 함수가 끝나는 순간 프로그램은 덮어쓴 값이 가리키는 곳으로 간다. 입력이 그 값을 정할 수 있다면, 입력을 보낸 사람이 프로그램이 어디로 갈지를 정하는 셈이다. 1988년의 Morris worm 이 쓴 길이 이것이었다(43장).

이 책은 그 길을 어떻게 걷는지는 다루지 않는다. 알아 둘 것은 하나다 — 버퍼 오버플로는 잘못된 값 하나로 끝나지 않고, 프로그램의 흐름을 남에게 넘겨줄 수 있는 사고다. 아래의 방어들이 겹겹이 쌓인 까닭이다.

57.2.3 방어의 층#

방어는 한 겹이 아니다. 위쪽일수록 넘침이 생기지 않게 하고, 아래쪽일수록 생긴 넘침이 번지지 않게 한다.

층무엇을 하는가못 하는 것
크기를 받는 함수fgets·snprintf·memcpy 에 그릇의 크기를 넘긴다(44장)크기를 틀리게 넘기면 소용없다
길이를 데리고 다니는 설계포인터와 길이를 한 묶음으로 — 모든 쓰기가 한자리에서 검사된다묶음 밖에서 맨 포인터를 꺼내 쓰면 끝이다
컴파일러 경고-Wall -Wextra 가 상수로 드러난 넘침을 알린다실행 때 정해지는 크기는 못 본다
_FORTIFY_SOURCE크기를 컴파일러가 아는 그릇에 대해, 복사 함수를 검사하는 판으로 바꾼다최적화를 켜야 동작하고, 크기를 모르는 그릇은 못 본다
스택 보호자돌아갈 주소 앞에 카나리 값을 두고, 함수가 끝날 때 바뀌었는지 본다카나리 앞의 이웃 변수는 지키지 못한다. 힙은 대상이 아니다
실행 금지 쪽·주소 무작위화데이터를 담은 쪽(page)을 실행하지 못하게(NX, W^X), 주소를 실행마다 바꾸게(ASLR)넘침을 막지 않는다 — 넘침으로 프로그램의 흐름을 빼앗는 다음 걸음을 어렵게 할 뿐이다
하드웨어그림자 스택, 메모리 태깅(MTE), 능력 포인터(CHERI) — 부록 N아직 모든 기계에 있지 않다

표 57.3 — 버퍼 오버플로를 막는 층 — 위에서 아래로

_FORTIFY_SOURCE 와 스택 보호자, 두 층은 같은 프로그램으로 확인할 수 있다. 8바이트 그릇에 열일곱 바이트를 strcpy 하는 프로그램을 세 가지로 빌드해 돌렸다.

examples/memory-errors/guards/overflow.c

/* 버퍼 오버플로 --- 8바이트 그릇에 17바이트(널 포함)를 strcpy 한다.
 * 원본 문자열을 실행 때 고르게 해서, 컴파일러가 미리 알아채지 못하게 했다. */
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv)
{
    (void)argv;
    char name[8];
    const char *src = argc > 5 ? "ok" : "0123456789abcdef";
    strcpy(name, src);              /* 그릇의 크기를 받지 않는 함수 */
    printf("copied %zu bytes into an 8-byte buffer\n", strlen(name) + 1);
    return 0;
}

실행 결과

no protection              ran to the end: copied 17 bytes into an 8-byte buffer
-fstack-protector-strong   stopped: *** stack smashing detected ***: terminated
-D_FORTIFY_SOURCE=3 -O2    stopped: *** buffer overflow detected ***: terminated

첫 줄이 이 절에서 가장 무서운 줄이다. 방어 없이 빌드한 판은 아무 일 없이 끝까지 돌았고, 여덟 바이트 그릇에 열일곱 바이트가 들어갔다고 태연히 찍었다. 넘친 아홉 바이트는 무언가를 덮었지만, 그것이 이번 실행에서 다시 쓰이지 않았을 뿐이다. 스택 보호자를 켠 판은 함수가 끝나는 자리에서 카나리가 바뀐 것을 보고 멈췄다. _FORTIFY_SOURCE 판은 더 일찍 — strcpy 를 부르는 그 자리에서 — 멈췄다. 컴파일러가 name 의 크기가 8 이라는 것을 알고 있어서, strcpy 를 크기를 검사하는 판으로 바꿔 넣었기 때문이다.

플랫폼 노트. 스택 보호자와 _FORTIFY_SOURCE 가 찍는 문구는 glibc 의 것이다

*** stack smashing detected *** 와 *** buffer overflow detected *** 는 GNU C 라이브러리가 찍는 문구다. 위 결과는 x86-64 리눅스(Debian, glibc)에서 GCC 14.2 와 Clang 19.1 로 같게 나왔다. 다른 C 라이브러리나 운영체제에서는 문구도, 기본으로 켜지는 방어도 다르다 — 배포판 대부분은 스택 보호자와 _FORTIFY_SOURCE 를 기본 빌드 설정에 넣지만, 직접 gcc 를 부를 때는 대개 꺼져 있다.

57.2.4 버퍼가 길이를 데리고 다니기#

방어의 둘째 층은 설계다. strcpy(dst, src) 의 문제는 dst 가 포인터 하나라서 받는 쪽이 그릇의 크기를 모른다는 데 있다. 크기를 따로 넘기는 함수(strncpy·snprintf)는 그 크기를 부르는 사람이 맞게 적어 주기를 기대한다. 아예 포인터와 길이와 그릇의 크기를 한 묶음으로 만들면, 모든 쓰기가 그 묶음의 함수 하나를 지나게 되고 검사도 그 자리 하나에 모인다.

examples/memory-errors/span.c

/* 길이를 데리고 다니는 버퍼 --- 오버플로를 *구조로* 막는 한 가지 모양.
 * 포인터 하나만 넘기면 받는 쪽은 그릇의 크기를 모른다(strcpy 의 문제).
 * 포인터와 길이를 한 묶음으로 넘기면, 모든 쓰기가 같은 자리에서 검사된다. */
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char  *data;
    size_t len;     /* 쓴 바이트 수 */
    size_t cap;     /* 그릇의 크기 */
} buf;

/* 들어갈 자리가 없으면 그릇을 키운다. 실패하면 false --- 옛 내용은 그대로 남는다. */
static bool reserve(buf *b, size_t extra)
{
    size_t need;
    if (ckd_add(&need, b->len, extra)) return false;    /* 길이 계산이 넘치면 거절 */
    if (need <= b->cap) return true;

    size_t cap = b->cap ? b->cap : 8;
    while (cap < need)
        if (ckd_mul(&cap, cap, 2)) return false;

    char *p = realloc(b->data, cap);    /* 결과를 먼저 다른 변수에 받는다 */
    if (p == NULL) return false;        /* 실패해도 b->data 는 살아 있다(C23 7.24.3.7p3) */
    b->data = p;
    b->cap  = cap;
    return true;
}

static bool append(buf *b, const char *s)
{
    size_t n = strlen(s);
    if (!reserve(b, n)) return false;
    memcpy(b->data + b->len, s, n);
    b->len += n;
    return true;
}

/* n 개짜리 배열을 빌릴 때의 크기 계산 --- n * size 가 넘치면 작은 블록이 돌아온다. */
static void *alloc_array(size_t n, size_t size)
{
    size_t bytes;
    if (ckd_mul(&bytes, n, size)) return NULL;
    return malloc(bytes);
}

int main(void)
{
    buf b = {0};
    const char *words[] = {"length ", "travels ", "with ", "the ", "pointer"};
    for (size_t i = 0; i < sizeof words / sizeof words[0]; i++)
        if (!append(&b, words[i])) { free(b.data); return 1; }
    printf("%.*s\n", (int)b.len, b.data);
    printf("len %zu, cap %zu\n", b.len, b.cap);
    free(b.data);

    void *huge = alloc_array(SIZE_MAX / 2, 4);  /* 곱이 size_t 를 넘친다 */
    printf("alloc_array(SIZE_MAX / 2, 4): %s\n", huge ? "got a block" : "refused (size overflow)");
    free(huge);
    return 0;
}

실행 결과

length travels with the pointer
len 31, cap 32
alloc_array(SIZE_MAX / 2, 4): refused (size overflow)

세 군데를 보면 된다. reserve 는 「지금 길이 + 더할 길이」를 ckd_add 로, 키울 크기를 ckd_mul 로 계산한다 — 크기 계산의 넘침(표 57.2의 넷째 줄)이 여기서 막힌다. realloc 의 결과는 먼저 다른 변수에 받는다. 실패하면 옛 블록이 그대로 남는데(C23 7.24.3.7), 곧바로 b->data 에 받으면 실패한 순간 옛 블록의 유일한 주소를 잃는다 — 누수다. 시연 출력의 마지막 줄은 SIZE_MAX / 2 개짜리 배열을 요청해 곱이 넘치는 경우를 보인다. 검사가 없었다면 malloc 은 작은 블록을 성공으로 돌려주었을 것이다.

이 모양은 새것이 아니다. 길이를 앞에 둔 문자열(파스칼 문자열), 다른 언어의 슬라이스, C++ 의 std::span 이 모두 같은 생각이다. C 에는 그런 타입이 표준에 없을 뿐이고, 위처럼 구조체 하나로 만들어 쓸 수 있다(101장에서 다루는 라이브러리 proven 이 그 길을 간다).

실제 사례. == 하나 — Cloudbleed(2017)

2017년 2월, Cloudflare 의 HTML 파서가 웹 페이지를 고쳐 쓰는 과정에서 버퍼의 끝을 넘어 읽고, 읽은 것을 응답에 실어 보냈다. 새어 나간 것은 같은 기계를 지나던 다른 사이트의 HTTP 머리말, 쿠키, POST 자료, 인증에 쓰이는 값들이었다. 검색 엔진이 그 응답을 캐시해 두었기 때문에, 회사는 캐시에서 770개 URI 를 지워야 했다. 가장 이른 누출은 2016년 9월로 거슬러 올라갔다.

원인은 파서 생성기(Ragel)가 만들어 낸 코드의 끝 검사 한 줄이었다.

/* generated code */
if ( ++p == pe )
    goto _test_eof;

p 가 끝 pe 와 같아지는 순간을 기다리는 검사다. 페이지가 닫히지 않은 태그로 끝나면 파서는 오류 경로를 탔는데, 그 경로는 p 를 한 칸 되돌리는 처리를 빠뜨렸다. 그래서 p 는 이미 pe 인 채로 이 검사를 만났고, ++p 가 pe + 1 이 되면서 「같은 순간」을 지나쳤다. 그 뒤로 p 는 버퍼 밖을 계속 읽었다. 사고 보고서의 문장이 짧다 — == 대신 >= 로 검사했다면 끝을 뛰어넘는 것을 잡았을 것이다. 보고서는 버그가 Ragel 자체가 아니라 Cloudflare 가 그것을 쓴 방식에 있었다고 밝힌다. 끝 검사는 「도착했는가」가 아니라 「넘지 않았는가」로 적는다.1

실제 사례. 역빗금 하나 — sudo 의 Baron Samedit(2021)

관리자 권한을 빌려주는 sudo 에 힙 버퍼 오버플로가 9년 반 동안 숨어 있었다. 셸 모드로 실행될 때 인자가 역빗금(\) 하나로 끝나면, 인자를 복사하는 반복이 역빗금 다음의 널 종결자를 「이어지는 글자」로 읽고 그 너머까지 복사했다. 보통의 sudo 실행은 역빗금을 미리 이스케이프해서 이 길에 닿지 않았지만, sudoedit -s 로 부르면 이스케이프 없이 셸 모드가 켜졌다. 결과는 어느 로컬 사용자든 — sudoers 에 없어도 — 관리자 권한을 얻을 수 있는 취약점이었다(CVE-2021-3156). 버그는 2011년 7월의 변경에서 들어왔다.2

두 사례의 공통점은 끝을 찾는 규칙이 틀렸다는 것이다. 하나는 ==, 하나는 「역빗금 다음은 늘 글자다」. 길이를 데리고 다니는 설계는 끝을 찾지 않는다 — 처음부터 안다.

57.3 수명을 어긴 것#

객체의 수명은 저장 기간이 정한다(45장). 자동 객체는 블록과 함께, 동적 객체는 free 와 함께 끝난다. 수명을 어긴 사고는 두 방향이다 — 끝난 뒤에 닿는 것(댕글링), 그리고 끝내야 할 것을 끝내지 않는 것(누수).

57.3.1 누수 — 마지막 포인터를 잃는 것#

메모리 누수(memory leak)는 빌린 블록을 가리키는 마지막 포인터를 잃어서 그 블록을 다시는 돌려줄 수 없게 되는 것이다. 표준의 눈으로는 사고가 아니다 — 돌려주지 않는 것은 UB 가 아니고, 프로그램이 끝나면 대개 운영체제가 전부 거둔다. 그래서 짧게 도는 명령줄 도구에서는 해가 없다. 해가 되는 것은 오래 도는 프로그램이다. 서버·데몬·편집기·브라우저에서 누수는 조금씩 기억을 먹다가 결국 기계를 멈춰 세운다.

누수의 대부분은 세 모양이다. 덮어쓰기(p = malloc(...) 를 두 번 하면서 사이에 free 가 없음), 이른 반환(오류 경로가 앞에서 빌린 것을 놓고 나감), 그리고 소유의 혼동(받은 쪽도 준 쪽도 「상대가 돌려주겠지」). 셋째가 46장의 소유 규칙이 필요한 까닭이다.

★ 「누수가 없는데도 메모리가 는다」는 다른 문제다. 단편화는 99장에서 다룬다.

57.3.2 댕글링 포인터와 해제 후 사용#

댕글링 포인터(dangling pointer)는 수명이 끝난 객체를 아직 가리키는 포인터다. 그것으로 읽거나 쓰면 해제 후 사용(use-after-free)이다. 46장에서 본 대로, 할당자는 돌려받은 칸을 곧 다른 요청에 내주므로 해제 후 사용은 지금 그 자리에 있는 남의 객체를 읽거나 덮는다.

여기에 표준이 한 문장을 더 얹는다. C23 6.2.4 의 둘째 문단은 「가리키던 객체(또는 그 바로 뒤)의 수명이 끝난 뒤에 포인터 값을 평가에 쓰면 동작은 정의되지 않는다」고 적고, 이어서 「포인터 객체의 표현은, 그것이 가리키는 객체(또는 그 바로 뒤)의 수명이 끝날 때 불확정이 된다」고 적는다.3

UB 가 되는 데는 읽기도 쓰기도 필요 없다. free(p) 뒤에는 p 의 값을 쓰는 것부터 — 다른 포인터와 == 로 견주는 것, 정수로 바꿔 찍는 것까지 — 계약 밖이다. 비트는 그대로인데 그 값이 더는 「값」이 아니다. 이 규칙에는 별명이 있다 — 포인터 무효화(pointer zap)다. 너무 엄격해 보이는 이 문장이 있는 까닭은 38장에서 본 프로버넌스에 있다. 포인터는 주소라는 수만이 아니라 어느 객체에서 왔는가를 함께 가진 값이고, 그 객체가 사라지면 값의 반쪽이 사라진다. 실무의 결론은 단순하다. 해제한 포인터는 다시 쓰지 않는다 — 견주지도 않는다. free(p); p = nullptr; 관행이 반쪽이나마 쓸모 있는 것은 nullptr 가 「값」이기 때문이다.

57.3.3 이중 해제와 잘못된 해제#

free 의 계약은 두 문장이다. 널 포인터를 넘기면 아무 일도 일어나지 않는다. 받은 적 없는 포인터(블록의 가운데, 스택의 주소, 이미 돌려준 주소)를 넘기면 UB 다.4 할당자는 블록 앞뒤에 장부를 두므로 (99장), 엉뚱한 주소를 돌려주면 장부를 망가뜨리고, 망가진 장부는 그 뒤의 할당을 하나씩 오염시킨다. 사고가 난 자리와 터지는 자리가 멀리 떨어지는 까닭이다.

실제 사례. GIF 한 장 — WhatsApp 의 이중 해제(2019)

안드로이드용 WhatsApp 은 갤러리에서 GIF 를 미리 보여 줄 때 공개 라이브러리 android-gif-drawable 의 디코더를 썼다. 디코더는 프레임의 크기가 같으면 버퍼를 다시 쓰고, 다르면 버퍼를 다시 할당했다. 크기가 다른 프레임을 교묘하게 섞은 GIF 를 주면 같은 버퍼가 두 번 해제되었다(CVE-2019-11932). 사용자가 할 일은 갤러리를 여는 것뿐이었다. 고친 판은 라이브러리 1.2.18, WhatsApp 2.19.244 다.5

이 사례의 교훈은 C 를 직접 쓰지 않는 사람에게도 닿는다. 앱의 코드는 다른 언어로 쓰였어도, 그림 한 장을 푸는 C 라이브러리 하나가 앱 전체의 메모리 안전을 정한다.

57.3.4 끝난 자동 객체의 주소#

함수의 지역 변수는 함수가 끝나면 수명이 끝난다. 그 주소를 돌려주면 받은 쪽은 처음부터 댕글링 포인터를 쥔다.

int *make(void)
{
    int v = 7;
    return &v;          /* 컴파일러가 경고한다 --- 그리고 경고가 옳다 */
}

GCC 와 Clang 모두 이 꼴에 경고한다. 경고를 피하려고 주소를 한 번 변수에 담아 돌려주면 경고는 사라지지만 사고는 그대로다. 같은 함정이 덜 눈에 띄는 모양으로도 온다 — 블록 안의 복합 리터럴 &(struct pt){1, 2} 는 그 블록이 끝나면 수명이 끝나고, 구조체를 돌려주는 함수 호출이 만든 임시 객체의 배열 멤버는 그 식이 끝나면 끝난다.

57.3.5 realloc — 옛 포인터와 크기 0#

realloc 은 옛 객체를 해제하고 새 객체를 돌려준다. 새 주소가 옛 주소와 같을 수도 있지만 그것은 우연이고, 옛 포인터와 그 별칭은 모두 댕글링이 된다. 「크기를 늘렸을 뿐」이라는 생각으로 옛 포인터를 계속 쓰는 것이 가장 흔한 realloc 사고다.

그리고 C23 에서 바뀐 자리가 하나 있다. realloc(p, 0) 은 이제 정의되지 않은 동작이다.6 그 전에는 구현마다 「블록을 풀고 널을 돌려준다」와 「크기 0 짜리 블록을 돌려준다」로 갈렸고, 어느 쪽인지 알 수 없어 누수와 이중 해제가 둘 다 일어났다. 위원회는 둘 중 하나를 고르는 대신 계약 밖으로 내보냈다. 크기를 0 으로 줄이려면 free 를 부른다.

실제 사례. 해제 후 사용의 몫 — 숫자로 본 메모리 안전

두 큰 코드베이스가 자기 취약점을 세어 본 결과가 있다. 세는 방식이 달라서 두 수치를 섞으면 안 된다.

  • Microsoft: 2019년 2월 BlueHat IL 발표에서, 해마다 고친 CVE 의 약 70% 가 메모리 안전 문제였고 그 비율이 12년 동안 거의 그대로였다고 밝혔다.
  • Chromium: 2015년 이후 안정판에 영향을 준 심각도 「높음·치명」 보안 버그 912건을 분석했더니 약 70% 가 메모리 안전 문제였고, 그 절반이 해제 후 사용이었다.7

수치가 비슷한 것은 우연에 가깝다 — 하나는 고친 CVE 전체, 하나는 높은 심각도의 버그다. 둘이 함께 말하는 것은 하나다. C 와 C++ 로 쓴 큰 프로그램에서 가장 큰 보안 문제는 계산이 아니라 기억이다.

57.4 초기화와 타입을 어긴 것#

57.4.1 불확정 값 — 쓴 적 없는 자리를 읽기#

초기화하지 않은 자동 변수와 malloc 이 막 돌려준 블록에는 불확정 값(indeterminate value)이 들어 있다(24장). 그것을 읽으면 어떤 값이 나오는지 약속이 없고, 타입에 따라서는 그 자체로 UB 다. 「아무 쓰레기 값이나 나오겠지」가 아닌 까닭은 최적화다. 컴파일러는 쓴 적 없는 변수를 어떤 값이어도 좋은 것으로 다루고, 같은 변수를 두 번 읽을 때 두 번 다른 답을 낼 수도 있다.

57.4.2 비값 표현 — 그 타입의 값이 아닌 비트#

어떤 타입은 모든 비트 조합이 값이 아니다. bool 은 0 과 1 만이 값이고, 그 자리에 2 가 들어 있으면 그 비트열은 비값 표현(non-value representation)이다. 예전 표준은 이것을 「함정 표현」(trap representation)이라 불렀다 — 읽으면 함정에 빠질 수 있다는 뜻이다. C23 은 이름을 바꿨고 규칙은 그대로다. 문자 타입이 아닌 좌변값으로 그것을 읽으면 UB 다.8 49장에서 공용체로 다른 멤버를 읽을 때 이 함정을 만났다. 바이트를 memcpy 로 써 넣는 것까지는 계약 안이고, 그것을 bool 로 읽는 순간 계약 밖이 된다.

반대로 구조체와 공용체 전체는 비값 표현이 되지 않는다(같은 조 6문단). 패딩 바이트에 무엇이 있든 구조체를 통째로 복사하는 것은 안전하다. 다만 그 패딩 바이트의 값은 미지정이라, 구조체 둘을 memcmp 로 견주면 같은 값의 구조체가 다르다고 나올 수 있다(47장).

실제 사례. 경고를 고쳤더니 — Debian 의 OpenSSL(2006–2008)

초기화하지 않은 읽기를 고쳤더니 사고가 난 기이한 사례가 있다. 2006년, Debian 의 관리자 한 사람이 메모리 점검 도구 Valgrind 가 OpenSSL 의 난수 코드(md_rand.c)에서 「초기화하지 않은 메모리를 쓴다」고 알리는 것을 보았다. 경고가 가리킨 함수를 부르는 줄은 두 군데였다.

MD_Update(&m,buf,j);              /* 247번째 줄 */
...
#ifndef PURIFY
    MD_Update(&m,buf,j); /* purify complains */   /* 467번째 줄 */
#endif

아래 줄은 이미 「점검 도구가 불평한다」는 주석과 함께 끌 수 있게 되어 있었고, 실제로 무해했다 — 초기화하지 않은 버퍼를 덤으로 섞어 넣는 줄이었다. 관리자는 경고를 없애려고 두 MD_Update 호출을 모두 지웠다. 그런데 위의 줄(RAND_add 안)은 호출자가 준 진짜 엔트로피를 난수 풀에 넣는 줄이었다. 그 줄이 사라지자 풀에 남은 예측 불가한 입력은 프로세스 번호뿐이었고, 당시 리눅스의 기본 설정에서 프로세스 번호는 32,768 보다 작았다. 그 결과 Debian 과 그 파생 배포판의 ssh-keygen 은 종류와 크기마다 32,767가지 키만 만들 수 있었다. 이 사실은 2008년 5월 13일에야 공지되었다(DSA-1571, CVE-2008-0166).9

교훈은 도구를 믿지 말라는 것이 아니다. 도구가 가리킨 자리와 사고가 난 자리는 다를 수 있고, 경고를 없애는 것과 원인을 없애는 것은 다른 일이다. 초기화하지 않은 버퍼를 섞는 일 자체가 이미 계약 밖이었으니, 옳은 수선은 덤으로 섞는 아래 줄만 끄는 것이었다.

57.4.3 엄격한 앨리어싱 — 다시 보기#

14장에서 엄격한 앨리어싱 위반을 「컴파일러에게 사실이 아닌 것을 사실이라고 알리는 행위」라 했다. 규칙은 C23 6.5.1 의 일곱째 문단에 있다. 객체의 저장된 값에는 그 객체의 유효 타입과 맞는 좌변값으로만 닿아야 하고, 예외는 부호·한정자를 바꾼 같은 타입, 그것을 멤버로 가진 구조체·공용체, 그리고 문자 타입이다.10 유효 타입은 선언된 객체라면 선언한 타입이고, malloc 의 블록처럼 선언된 타입이 없는 객체라면 마지막으로 써 넣은 좌변값의 타입이다.

이 규칙이 최적화와 만나는 모습은 짧은 프로그램 하나로 볼 수 있다. int * 와 float * 를 받는 함수에 두 인자로 같은 주소를 넘겼다.

examples/memory-errors/aliasing/alias.c

/* 엄격한 앨리어싱 --- int 와 float 의 좌변값이 같은 자리를 가리키면 어떻게 되는가.
 * store_both 의 두 인자는 *서로 다른 타입*이다. 컴파일러는 「int 객체와 float 객체는
 * 겹치지 않는다」고 읽어도 된다(C23 6.5.1p7) --- 그 전제로 *ip 를 다시 읽지 않는다. */
#include <stdio.h>

[[gnu::noinline]] static int store_both(int *ip, float *fp)
{
    *ip = 1;
    *fp = 0.0f;
    return *ip;             /* 컴파일러: 「float 쓰기는 int 를 바꾸지 못한다 → 1」 */
}

int main(void)
{
    int x = 42;
    int r = store_both(&x, (float *)&x);    /* 계약 밖: int 객체를 float 로 쓴다 */
    printf("returned %d, x is now %d\n", r, x);
    return 0;
}

실행 결과

-O0                        returned 0, x is now 0
-O2                        returned 1, x is now 0
-O2 -fno-strict-aliasing   returned 0, x is now 0

최적화를 끈 판과 -fno-strict-aliasing 판은 x 를 두 번째 쓰기 뒤에 다시 읽어 0 을 돌려주었다. -O2 판은 1 을 돌려주었다 — 「float 에 쓰는 것은 int 를 바꿀 수 없다」고 믿고 *ip 를 다시 읽지 않은 것이다. 그런데 같은 줄의 x is now 0 이 보여 주듯, 기억에는 이미 0 이 들어 있다. 함수가 돌려준 값과 기억이 서로 다른 말을 한다. GCC 14.2 와 Clang 19.1 이 이 세 줄을 똑같이 냈다.

-fno-strict-aliasing 은 엄격한 앨리어싱의 전제(타입이 다른 좌변값끼리는 같은 객체에 닿지 않는다)를 끄는 옵션이다. 리눅스 커널은 최상위 Makefile 에서 이 옵션을 켜고 빌드한다(KBUILD_CFLAGS += -fno-strict-aliasing) — 커널 코드에 이 규칙을 어기는 관용구가 많아서, 규칙이 주는 최적화를 포기하는 쪽을 택한 것이다. 같은 곳에 -fno-delete-null-pointer-checks 도 있다(56장의 CVE-2009-1897 이 그 까닭이다). 옵션은 그 빌드에서만 계약을 넓힌다. 코드를 다른 빌드로 옮기면 사고가 돌아온다.

57.4.4 계약 안에서 같은 비트를 다른 눈으로#

같은 비트를 다른 타입으로 읽어야 할 때 — 실수의 비트를 보거나, 네트워크에서 받은 바이트를 정수로 만들 때 — 계약 안의 길은 둘이다.

examples/memory-errors/pun.c

/* 같은 비트를 다른 타입으로 읽는 두 가지 *계약 안의* 길 --- memcpy 와 공용체.
 * 포인터 캐스트(*(uint32_t *)&f)는 엄격한 앨리어싱을 어긴다. 이 둘은 어기지 않는다. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>

static uint32_t bits_by_memcpy(float f)
{
    uint32_t u;
    static_assert(sizeof u == sizeof f, "float must be 32 bits here");
    memcpy(&u, &f, sizeof u);       /* 바이트를 옮길 뿐이다 --- 최적화되면 명령 하나 */
    return u;
}

static uint32_t bits_by_union(float f)
{
    union { float f; uint32_t u; } pun = { .f = f };
    return pun.u;                   /* 다른 멤버로 읽기: C 에서는 허용(C23 6.5.3.4 각주) */
}

int main(void)
{
    const float samples[] = {1.0f, -2.5f, 0.1f};
    for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) {
        float f = samples[i];
        printf("%5.2f  memcpy 0x%08x  union 0x%08x\n",
               (double)f, (unsigned)bits_by_memcpy(f), (unsigned)bits_by_union(f));
    }
    return 0;
}

실행 결과

 1.00  memcpy 0x3f800000  union 0x3f800000
-2.50  memcpy 0xc0200000  union 0xc0200000
 0.10  memcpy 0x3dcccccd  union 0x3dcccccd

memcpy 는 바이트를 옮길 뿐이라 어떤 타입 규칙도 어기지 않는다. 최적화가 켜지면 컴파일러는 이 호출을 명령 하나로 바꾼다 — 비용이 거의 없다. 공용체로 다른 멤버를 읽는 것은 C 에서는 허용된다(C23 6.5.3.4 의 각주가 이것을 「타입 펀닝」이라 부르며 그 결과가 비값 표현일 수 있다고만 적는다).11 C++ 에서는 이것이 허용되지 않으므로, 두 언어에서 함께 쓰는 헤더라면 memcpy 가 안전한 쪽이다. 포인터 캐스트 *(uint32_t *)&f 는 위 앨리어싱 시연과 같은 사고다.

57.4.5 정렬 위반#

포인터를 더 엄격한 정렬이 필요한 타입으로 바꾸는 것은, 결과가 정렬에 맞지 않으면 바꾸는 그 순간 UB 다.12 역참조하지 않아도 그렇다(38장).

이 사고가 오래 살아남는 까닭은 x86 이다. x86 은 정렬이 맞지 않는 정수 읽기를 대개 조용히 처리해 준다. 그래서 바이트 버퍼에서 *(uint32_t *)(buf + 1) 로 읽는 코드가 x86 에서 몇 년을 멀쩡히 돌다가, 정렬을 요구하는 기계(옛 ARM, 일부 RISC-V, 디지털 신호 처리기(digital signal processor, 줄여서 DSP))로 옮기는 날 버스 폴트로 죽는다. x86 에서도 안전하지 않다 — 컴파일러가 「정렬이 맞다」는 전제로 정렬을 요구하는 벡터 명령을 고르면, x86 에서도 그 자리에서 죽는다. 바이트 버퍼에서 값을 꺼낼 때는 위의 memcpy 를 쓴다.

57.5 출처를 어긴 것 — 프로버넌스 다시 보기#

38장에서 프로버넌스(provenance)를 보았다. 포인터는 주소라는 수와 함께 「어느 객체에서 나왔는가」라는 출처를 가지고, 포인터 산술로는 그 객체의 범위(끝 바로 다음까지)를 벗어날 수 없다. 그래서 이웃한 두 변수 x, y 가 기억에서 맞붙어 있어 &x + 1 == &y 가 참으로 나오더라도, &x + 1 로 y 를 고치는 것은 사고다 — 주소가 같아도 출처가 다르다. 그 장에서 본 대로 최적화하는 컴파일러는 「출처가 다른 두 포인터는 겹치지 않는다」고 전제하고 코드를 옮긴다.

출처의 눈으로 보면 이 장의 다른 사고도 다시 읽힌다. 버퍼 오버플로는 한 객체의 출처로 이웃 객체에 닿는 것이고, 해제 후 사용은 사라진 객체의 출처를 쓰는 것이다. 출처를 하드웨어가 직접 들고 다니는 기계 (CHERI)에서는 이 둘이 같은 방식으로 — 포인터에 붙은 범위와 유효 표시로 — 막힌다(부록 N).

출처의 규칙이 한 객체 안에서도 걸리는 자리가 둘 있다.

57.6 한눈에 — 메모리 쪽의 UB, 미지정, 구현 정의#

이 장의 사고를 표준의 자리와 함께 모았다. 56장의 세 갈래(UB·미지정·구현 정의)로 나눴다.

무엇갈래C23
수명이 끝난 객체에 닿기, 그 객체를 가리키던 포인터 값을 쓰기UB — 포인터는 불확정이 된다6.2.4p2
비값 표현을 문자 타입 아닌 좌변값으로 읽기UB6.2.6.1p5
구조체·공용체의 패딩 바이트의 값미지정6.2.6.1p6
정렬이 맞지 않는 포인터로 바꾸기UB6.3.2.3p7
포인터를 정수로 바꾼 결과구현 정의6.3.2.3p6
유효 타입과 맞지 않는 좌변값으로 닿기(엄격한 앨리어싱)UB6.5.1p7
배열 끝 바로 다음 너머의 포인터를 만들기UB — 역참조하지 않아도6.5.7
서로 다른 객체의 포인터를 < 로 견주기UB6.5.9
받은 적 없는·이미 돌려준 포인터를 free 에 넘기기UB7.24.3.3p2
realloc(p, 0)UB(C23 에서 바뀜)7.24.3.7p3
realloc 이 늘린 부분의 바이트미지정7.24.3.7p2
겹치는 두 구역 사이의 memcpyUB7.26.2.1p2

표 57.4 — 메모리 쪽 규칙과 C23 의 자리

표의 「UB」 줄은 부속서 J.2 의 목록에도 하나씩 올라 있다. 그 목록을 다 외울 필요는 없다 (56장). 이 표의 열두 줄은 메모리를 다루는 코드가 실제로 밟는 자리다.

57.7 도구가 잡는 것, 못 잡는 것#

사고마다 작은 프로그램을 하나씩 만들어 새니타이저(18장)를 켜고 돌렸다. 아래 출력은 각 프로그램을 실행했을 때 도구가 붙인 이름이다. 도구가 예상한 이름을 대지 못하면 이 시연 자체가 실패하도록 만들어 두었다 — 본문이 사고마다 적은 「잡는 도구」가 빌드마다 다시 확인된다. 실린 소스는 그중 하나, 해제 후 사용이다.

examples/memory-errors/catalog/use_after_free.c

/* 수명을 어긴 읽기 --- 돌려준 블록을 다시 읽는다. */
#include <stdlib.h>

[[gnu::noinline]] static int peek(const int *p) { return *p; }

int main(void)
{
    int *p = malloc(sizeof *p);
    if (p == NULL) return 1;
    *p = 7;
    free(p);
    return peek(p);         /* p 의 값은 이제 불확정이다(C23 6.2.4p2) */
}

실행 결과

== size
  stack_overflow.c  ASan: stack-buffer-overflow
  heap_overflow.c   ASan: heap-buffer-overflow
== lifetime
  use_after_free.c  ASan: heap-use-after-free
  double_free.c     ASan: attempting double-free
  bad_free.c        ASan: attempting free on address which was not malloc()-ed
  leak.c            LSan: detected memory leaks
  return_local.c    ASan: stack-use-after-return
== initialization
  bad_bool.c        UBSan: load of value 2 into bool
  uninit.c          ASan+UBSan: nothing reported
  uninit.c          MSan: not offered by this compiler
== type
  misaligned.c      UBSan: load of misaligned address
== overlap
  overlap.c         ASan: memcpy-param-overlap

대부분은 ASan(AddressSanitizer)이 잡는다. ASan 은 빌드할 때 모든 기억 접근 앞에 검사를 넣고, 객체마다 양옆에 독을 칠한 구역(red zone)을 둔다. 넘침은 그 구역에 닿아서, 해제 후 사용은 돌려받은 블록을 한동안 격리해 두었다가 거기에 닿아서 잡힌다. 누수는 프로그램이 끝날 때 아직 닿을 수 있는 블록을 훑는 LeakSanitizer 가, bool 의 2 와 정렬 위반은 UBSan 이 잡는다.

★ 주목할 줄은 uninit.c 의 두 줄이다. ASan 과 UBSan 은 초기화하지 않은 읽기를 보지 않는다. 그것을 보는 도구는 MemorySanitizer(MSan)이고, 이 기계에서는 Clang 에만 있었다 — 같은 스크립트를 Clang 19.1 로 돌리면 그 둘째 줄이 MSan: use-of-uninitialized-value 로 바뀐다. MSan 은 프로그램 전체(쓰는 라이브러리까지)를 같은 옵션으로 빌드해야 제대로 동작해서, 실무에서는 Valgrind 같은 다른 도구로 이 빈틈을 메우기도 한다.

도구들의 공통된 한계도 적어 둔다.

흔한 오해. “x86 에서 잘 돌았으니 정렬은 상관없다”

x86 이 정렬이 맞지 않는 읽기를 처리해 주는 것은 그 기계의 친절이지 C 의 약속이 아니다. 같은 코드가 정렬을 요구하는 기계에서는 그 자리에서 죽고, x86 에서도 컴파일러가 정렬을 전제한 명령을 고르면 죽는다. 표준의 눈으로는 정렬이 맞지 않는 포인터를 만든 순간 이미 계약 밖이다. 바이트 버퍼에서 값을 꺼낼 때는 memcpy 를 쓴다 — x86 에서는 그것이 같은 명령 하나로 컴파일되므로 잃는 것도 없다.

문. 사고마다 도구가 다르다면, 무엇부터 켜야 하는가?

답. 시험 빌드에 -fsanitize=address,undefined 를 켜는 것이 첫걸음이다. 위 시연에 실린 사고 가운데 초기화하지 않은 읽기를 뺀 나머지를 한 번에 덮는다. 둘째는 경고 — -Wall -Wextra 를 켜고 경고를 오류로 다룬다. 셋째는 출시 빌드의 방어 — -fstack-protector-strong 과 -D_FORTIFY_SOURCE 를 켠다(배포판의 기본값을 따라가면 된다). 그리고 이 셋이 못 보는 두 가지(엄격한 앨리어싱과 실행하지 않은 경로)는 코드 검토와 시험이 맡는다.

57.8 정리 — 사고마다 한 줄#

사고어긴 약속잡는 도구막는 설계
버퍼 오버플로·범위 밖 읽기크기ASan, _FORTIFY_SOURCE, 스택 보호자길이를 데리고 다니는 버퍼, 크기 계산은 ckd_mul
누수수명(끝내지 않음)LeakSanitizer소유자 하나, 짝 맞춘 생성·해제(58장)
댕글링·해제 후 사용수명(끝난 뒤)ASan세대 번호를 단 핸들, 수명이 같은 것끼리 묶기
이중 해제·잘못된 해제수명ASan소유자 하나, 해제 뒤 nullptr(반쪽)
불확정 값타입(초기화)MSan, Valgrind선언과 함께 초기화, calloc
비값 표현타입UBSan(bool·enum)바이트로 받은 것은 검사 뒤에 그 타입으로
엄격한 앨리어싱 위반타입(거의 없음)memcpy·공용체로 다시 읽기
정렬 위반타입UBSanmemcpy 로 꺼내기, alignas
프로버넌스 위반·겹친 복사출처ASan(겹침)객체 밖으로 포인터 산술을 하지 않기, 겹치면 memmove

표 57.5 — 메모리 사고 도감 — 어긴 약속, 잡는 도구, 막는 설계

표의 마지막 열이 다음 장으로 이어진다. 사고를 잡는 것은 도구가 하지만, 사고가 생기지 않게 하는 것은 기억을 누가, 언제 돌려주는지를 정하는 설계다. 다음 장은 그 설계를 다룬다 — C 에서 수명을 다스리는 요령과 자료 구조, 그리고 다른 언어들이 이 일을 사람에게서 빼앗아 기계에게 맡긴 방법들(참조 계수와 가비지 컬렉터)과 그 비용까지.

주

  1. Cloudflare, 「Incident report on memory leak caused by Cloudflare parser bug」, 2017-02-23. ↩
  2. Qualys, 「Baron Samedit: Heap-based buffer overflow in Sudo (CVE-2021-3156)」, 2021-01-26. ↩
  3. ISO/IEC 9899 (C23) 6.2.4, 문단 2. 초안 N3220. ↩
  4. ISO/IEC 9899 (C23) 7.24.3.3, 문단 2. 초안 N3220. ↩
  5. NVD, CVE-2019-11932. ↩
  6. ISO/IEC 9899 (C23) 7.24.3.7, 문단 3. 초안 N3220. ↩
  7. chromium.org, 「Memory safety」. ↩
  8. ISO/IEC 9899 (C23) 6.2.6.1, 문단 5. 초안 N3220. ↩
  9. Debian 보안 공지 DSA-1571-1(2008-05-13). 바뀐 줄의 자세한 내용은 Russ Cox, 「Lessons from the Debian/OpenSSL Fiasco」. ↩
  10. ISO/IEC 9899 (C23) 6.5.1, 문단 6–7. 초안 N3220. ↩
  11. ISO/IEC 9899 (C23) 6.5.3.4, 문단 3 의 각주. 초안 N3220. ↩
  12. ISO/IEC 9899 (C23) 6.3.2.3, 문단 7. 초안 N3220. ↩
  13. ISO/IEC 9899 (C23) 7.26.2.1, 문단 2. 초안 N3220. ↩