Proven C BookEnglish GitHub

85 50년째 출하되는 다섯 가지 버그

먼저 알아야 할 것

51장 오류와 계약 · 오류와 계약
42장 문자열 · 문자열의 위험
61장 표준 라이브러리의 지형 · 표준 라이브러리의 함정

돌아보기

42장에서 문자열을 다루는 함수들이 “그릇의 크기를 모른다”고 했고, 51장에서는 “실패를 확인하지 않으면 없던 일이 된다”고 했다. 그러면 이런 문제들은 왜 아직도 남아 있는가 — 반세기면 고치고도 남을 시간이 아닌가?

답. 고칠 수 없어서가 아니라, 고치면 이미 쓰인 코드가 전부 깨지기 때문이다. strcpy의 시그니처에 크기 매개변수를 하나 더 넣는 순간 세상의 모든 C 코드가 컴파일되지 않는다. 표준은 기존 코드를 지켜야 하는 기관이라(61장의 “얇고 오래된” 이유가 여기서도 나온다), 위험한 함수를 없애는 대신 더 나은 함수를 옆에 두는 길을 택해 왔다. 그래서 선택은 프로그래머에게 넘어온다 — 무엇이 위험한지 알고 고르는 일이 곧 실력이 되는 언어인 셈이다.

이 장의 필요성과 맥락

12부는 저자의 라이브러리를 소개하는 부인데, 그 첫 장에 라이브러리가 나오지 않는다. 이것이 이 부의 가장 중요한 배치다 — 문제를 먼저 세우고, 다섯 장 뒤에야 도구를 꺼낸다. 그리고 이 장을 여기 두려면 51장(계약)과 61장(표준 라이브러리의 함정)이 먼저 지나가야 했다. 앞의 여든세 장이 이 다섯 가지 버그의 재료였던 셈이다.

이 장이 끝나면

이 부의 문제 제기다. C가 반세기 동안 같은 다섯 부류의 버그를 계속 출하해 온 이유를 — 프로그래머의 부주의가 아니라 API의 모양 때문임을 — 실제로 도는 코드로 확인한다. 다섯을 다 본 뒤에야 proven이라는 이름이 다시 나온다. 도구를 먼저 소개하지 않는 것이 이 부의 원칙이다.

문. 그러면 이 다섯 가지는 초보자가 저지르는 실수 목록인가?

답. 아니다. 이것들은 숙련자도 계속 저지르는 실수이고, 그 점이 중요하다. 문법을 다 아는 사람도 같은 자리에서 미끄러진다면 원인은 사람이 아니라 도구의 모양이다. 크기를 받지 않는 함수는 크기를 검사할 방법이 없고, 반환값을 버려도 아무 일 없는 함수는 언젠가 버려진다. 이 장은 그 “모양”을 하나씩 뜯어보는 장이다.

이 장에서 답할 질문

  1. 이런 문제를 피하려고 아예 다른 언어를 쓰는 것이 낫지 않은가?

85.1 하나 — 문자열 함수는 그릇의 크기를 모른다

가장 오래되고 가장 많이 악용된 부류다. 42장에서 본 그대로다.

char buf[64];
strcpy(buf, name);            /* name 이 얼마나 긴가? strcpy 는 묻지 않는다 */
strcat(buf, ", welcome!");    /* 그리고 이제 남은 자리는 얼마인가? */

strcpy의 시그니처에는 목적지의 크기가 없다. 없는 정보는 검사할 수 없으므로, 이 함수는 64바이트 그릇의 200번째 바이트에도 기꺼이 쓴다. 무엇이 망가지는가는 컴파일러가 그 뒤에 무엇을 놓았느냐에 달렸고, 흔히 그것은 함수의 반환 주소다(44장의 스택 그림을 떠올리면 된다).

현대의 처방은 크기를 받는 판을 쓰는 것이다 — snprintf가 대표다. 그런데 여기에 두 번째 함정이 있다. 크기를 받는 함수는 넘칠 때 조용히 자른다.

examples/ch85/truncate.c

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

/* 흔한 방식: 고정 버퍼에 경로를 조립한다.
   컴파일러는 이 함수 안에서 dir/name 의 길이를 알 수 없다. */
static int build_path(char *out, size_t cap, const char *dir, const char *name) {
    return snprintf(out, cap, "%s/%s", dir, name);   /* 넘치면 조용히 자른다 */
}

int main(void) {
    char path[24];

    build_path(path, sizeof path, "/var/log", "app.log");
    printf("fits      : %s\n", path);

    build_path(path, sizeof path, "/var/log/service/http", "access.log");
    printf("truncated : %s\n", path);
    printf("            length=%zu, buffer=%zu\n", strlen(path), sizeof path);

    /* 잘렸는지 알아내려면 반환값을 보아야 한다 */
    int need = build_path(path, sizeof path, "/var/log/service/http", "access.log");
    if (need >= (int)sizeof path)
        printf("detected  : needed %d bytes, had %zu\n", need + 1, sizeof path);
    return 0;
}

실행 결과

fits      : /var/log/app.log
truncated : /var/log/service/http/a
            length=23, buffer=24
detected  : needed 33 bytes, had 24

세 번째 줄을 보라. 만들려던 것은 /var/log/service/http/access.log인데 손에 남은 것은 /var/log/service/http/a다. 프로그램은 멈추지도, 경고하지도 않았다. 이 문자열이 파일 경로라면 엉뚱한 파일을 열고, 명령이라면 다른 명령이 되며, 로그라면 사고 기록이 소리 없이 잘린다. 잘린 경로는 짧은 경로가 아니라 틀린 경로다.

반례. 잘림을 성공으로 취급하기

snprintf(path, sizeof path, "%s/%s", dir, name);
open_file(path);          /* 잘렸는지 아무도 묻지 않았다 */

snprintf는 사실 답을 준다 — 필요했던 길이를 반환한다. 그 값이 그릇 크기 이상이면 잘린 것이다(예제의 마지막 줄이 그 검사다). 문제는 이 검사가 선택 사항이라는 데 있다. 반환값을 버려도 컴파일러는 아무 말도 하지 않는다. 이것이 곧바로 두 번째 버그로 이어진다.

실제 사례. 컴파일러는 자기가 볼 수 있는 것만 잡는다

이 예제를 만드는 동안 실제로 겪은 일이다. 처음에는 snprintfmain 안에서 리터럴 인자로 직접 불렀는데, gcc가 그것을 잡아냈다.

error: ‘%s’ directive output truncated writing 10 bytes
       into a region of size 2 [-Werror=format-truncation=]
note: ‘snprintf’ output 33 bytes into a destination of size 24

훌륭한 진단이다. 그런데 같은 호출을 build_path라는 함수 안으로 옮기자 경고가 사라졌다. 함수 경계를 넘는 순간 컴파일러는 dirname의 실제 길이를 알 수 없기 때문이다. 실제 프로그램에서 문자열은 파일이나 네트워크에서 오므로 컴파일러가 도울 수 있는 경우가 오히려 드물다. 경고는 공짜 검토이지 보증이 아니다(17장).

85.2 둘 — 실패를 확인하게 만드는 장치가 없다

char *p = malloc(n);
p[0] = 'x';                   /* malloc 은 실패하면 널을 준다 */

C의 실패 통보 방식은 두 가지다. 센티널 값(널, -1, EOF)을 돌려주거나, 전역 변수 errno에 이유를 남기거나. 둘 다 확인을 강제하지 못한다. 반환값을 버리는 코드는 완벽하게 합법이고, errno는 다음 호출이 덮어쓰기 전에 정확한 순간에 읽어야 하는 전역 상태다(61장).

examples/ch85/unchecked.c

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

/* 설정 줄에서 포트 번호를 읽는다 — 실패를 확인하지 않는 판 */
static int read_port_careless(const char *line) {
    int port = 8080;              /* 기본값 */
    sscanf(line, "port=%d", &port);   /* 반환값을 보지 않는다 */
    return port;
}

/* 같은 일 — 실패를 확인하는 판 */
static int read_port_checked(const char *line, int fallback) {
    int port;
    if (sscanf(line, "port=%d", &port) != 1) {
        printf("  (parse failed, keeping %d)\n", fallback);
        return fallback;
    }
    return port;
}

int main(void) {
    const char *good = "port=9000";
    const char *typo = "prot=9000";     /* 오타 */

    printf("careless good: %d\n", read_port_careless(good));
    printf("careless typo: %d\n", read_port_careless(typo));
    printf("checked  typo: %d\n", read_port_checked(typo, 8080));

    /* strtol 도 마찬가지다: 실패를 값으로 알리지만 아무도 강제하지 않는다 */
    long n = strtol("abc", nullptr, 10);
    printf("strtol(\"abc\") = %ld\n", n);
    return 0;
}

실행 결과

careless good: 9000
careless typo: 8080
  (parse failed, keeping 8080)
checked  typo: 8080
strtol("abc") = 0

careless typo가 8080을 돌려준 것이 이 절의 핵심이다. 설정에 오타가 있었는데 프로그램은 기본값으로 조용히 돌아갔다. 겉으로는 아무 일도 없었고, 몇 달 뒤 “왜 설정이 안 먹지?”라는 물음만 남는다. strtol("abc")가 0을 주는 것도 같은 무늬다 — 실패와 “진짜 0”이 같은 값으로 돌아온다.

흔한 오해. “실패는 예외적인 일이니 나중에 처리하면 된다”

실패가 드물다는 전제부터 틀렸다. 파일은 없을 수 있고, 입력에는 오타가 섞이며, 디스크는 차고, 네트워크는 끊긴다 — 프로그램이 바깥 세계와 닿는 모든 자리가 실패 지점이다. 그리고 “나중에”가 위험한 진짜 이유는 따로 있다. 실패를 무시한 코드는 멈추지 않고 계속 달린다. 잘못된 값이 다음 계산으로, 그다음 함수로 흘러 들어가서, 마침내 문제가 드러날 무렵에는 원인에서 멀리 떨어진 자리에서 터진다. 51장의 “일찍 실패하라”가 여기서 되돌아온다.

85.3 셋 — printf는 여러분이 말하는 것을 그대로 믿는다

61장에서 서식 문자열의 문법을 뜯어보았다. 그 문법에는 구조적 약점이 하나 있다 — 타입을 두 번 적는다는 것이다. 한 번은 서식에서(%d), 한 번은 인자에서(변수의 타입). 둘이 어긋나도 언어는 막지 못한다. 58장에서 본 대로 가변 인자에는 타입 정보가 실려 가지 않기 때문이다.

요즘 컴파일러는 이것을 잡아 준다. 실제 메시지는 이렇다.

warning: format ‘%d’ expects argument of type ‘int’,
         but argument 2 has type ‘double’ [-Wformat=]
    3 |     printf("%d\n", 3.0);
      |             ~^     ~~~
      |              |     |
      |              int   double

다만 여기까지다. 서식이 변수가 되는 순간 — 다국어 메시지를 표에서 꺼내 쓰거나 로그 형식을 설정에서 받는 순간 — 컴파일러는 더 이상 볼 것이 없다.

반례. 서식을 변수로 받는 코드

const char *fmt = load_message("greeting");   /* 표에서 꺼낸 서식 */
printf(fmt, count);                           /* 경고 없음. 검사도 없음 */

이 코드에는 어떤 경고도 나오지 않는다. 서식과 인자가 맞는지 아는 방법이 컴파일 시간에 존재하지 않기 때문이다. 최악은 서식이 사용자 입력인 경우로, 61장에서 본 형식 문자열 취약점이 된다.

85.4 넷 — 이건 누가 해제하나

char *s = build_message();    /* 이걸 free 해야 하나? 타입은 말이 없다 */

45장에서 배운 대로 동적으로 잡은 기억은 누군가 정확히 한 번 놓아 주어야 한다. 그런데 함수가 돌려준 char *는 네 가지 중 무엇이든 될 수 있다.

네 경우의 타입이 전부 같다. 답은 문서에 있고, 문서는 코드와 어긋나기 마련이다. 여기서 45장의 사고 셋이 나온다 — 누수(아무도 해제하지 않음), 이중 해제(둘 다 해제함), 사용 후 해제(해제한 뒤에도 누군가 가리키고 있음).

반례. 타입이 소유권을 말하지 않는 API

const char *lookup(int code);        /* 리터럴? 할당? 정적 버퍼? */
char       *format_time(time_t t);   /* free 해야 하나? */

이름과 타입만 보고는 알 수 없다. 이 API를 쓰는 모든 곳이 문서를 기억해야 하고, 한 곳이라도 잊으면 앞의 사고 셋 중 하나가 된다. 규율을 사람의 기억에 맡기는 설계라는 점이 문제의 본질이다.

85.5 다섯 — 아무도 타입을 검사해 줄 수 없는 콜백

qsort(a, n, sizeof *a, cmp);   /* cmp 는 const void* 를 받는다 */

qsort는 어떤 타입이든 정렬하기 위해 비교 함수를 void * 인터페이스로 받는다. 제네릭이 없는 언어에서 이것은 거의 유일한 방법이지만, 대가는 타입 검사의 완전한 포기다. 비교자 안에서 무엇으로 캐스트하든 컴파일러는 믿는다.

examples/ch85/cmp_bad.c

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

/* 반례: 첫 글자만 비교한다. 타입은 완벽하게 맞고, 경고도 없다 */
static int cmp_first_char(const void *a, const void *b) {
    const char *const *x = a;
    const char *const *y = b;
    return (*x)[0] - (*y)[0];
}

/* 정례: 문자열 전체를 비교한다 */
static int cmp_full(const void *a, const void *b) {
    const char *const *x = a;
    const char *const *y = b;
    return strcmp(*x, *y);
}

static void show(const char *label, const char **v, size_t n) {
    printf("%s:", label);
    for (size_t i = 0; i < n; i++) printf(" %s", v[i]);
    printf("\n");
}

int main(void) {
    const char *src[] = {"pear", "apple", "peach", "apricot"};
    const char *v[4];
    size_t n = sizeof src / sizeof src[0];

    memcpy(v, src, sizeof src);
    qsort(v, n, sizeof v[0], cmp_first_char);
    show("first-char", v, n);

    memcpy(v, src, sizeof src);
    qsort(v, n, sizeof v[0], cmp_full);
    show("full      ", v, n);
    return 0;
}

실행 결과

first-char: apple apricot pear peach
full      : apple apricot peach pear

first-char 비교자는 타입이 완벽하게 맞고, 경고 하나 없이 컴파일되며, 죽지도 않는다. 그저 peachpear의 순서가 틀렸을 뿐이다 — 첫 글자만 보았으니 둘은 “같다”고 판정됐고 나머지는 운에 맡겨졌다. 이 부류의 버그는 조용히 잘못된 답을 내놓기 때문에 가장 늦게 발견된다.

실제 사례. 자료구조의 최악을 노리는 공격

같은 자리에 성능의 함정도 있다. 널리 쓰인 정렬·해시 구현들은 평균적 으로는 빠르지만 특정 입력에서 급격히 느려지는데, 공격자가 그런 입력을 일부러 만들어 서버를 마비시키는 수법(algorithmic complexity attack)이 2003년 논문으로 정리되며 실제 공격에 쓰였다.1 해시 충돌을 노린 같은 계열의 공격은 2011년 여러 웹 프레임워크를 한꺼번에 주저앉혔다. 자료구조의 최악 케이스가 곧 보안 문제라는 것을 보여 준 사건들이고, 그래서 오늘의 라이브러리는 최악에도 보장이 있는 정렬(introsort)과 무작위 씨앗을 쓰는 해시를 기본값으로 삼는다 — 92장에서 실물을 본다.

85.6 여섯 번째 — 바이트에는 타입이 있다

다섯이라 했지만 하나를 더 붙여야 공평하다. 13장에서 본 엄격한 앨리어싱 이다. 바이트 버퍼를 폭이 다른 포인터로 들여다보는 손수 짠 파서는 -O0에서 완벽히 돌고 -O2에서 조용히 다른 답을 낸다. 17장에서 본 “릴리스에서만 나는 버그”의 대표이자, 리눅스 커널이 플래그 하나로 항복한 그 조항이다.

여섯을 한 표로 모으면 이 부의 밑그림이 된다.

복습 정리

문제C가 주는 것필요한 것
버퍼 넘침·잘림크기를 모르는 문자열 함수길이를 지니고 다니는 문자열, 자르지 않는 쓰기
확인되지 않은 실패센티널 값과 errno값으로 오는 에러, 버리면 컴파일 거부
서식 불일치서식 문자열을 믿는 printf타입을 인자에서 가져오는 자리표시자
불분명한 소유권네 가지를 뜻하는 char *소유와 빌림이 다른 타입
검사되지 않는 콜백void * 인터페이스문서화된 계약과 최악 보장 알고리즘
바이트의 숨은 타입앨리어싱 규칙 위반의 UB규칙이 면제하는 바이트 타입

표 85.1

문. 이런 문제를 피하려고 아예 다른 언어를 쓰는 것이 낫지 않은가?

답. 그것도 하나의 답이고, 실제로 많은 곳이 그 길을 갔다(1장). 다만 C를 써야 하는 자리는 여전히 남아 있다 — 운영체제, 펌웨어, 다른 언어들이 기대는 바닥층, 그리고 이미 수십 년치 코드가 쌓인 프로젝트들. 그런 자리에서 할 수 있는 일은 언어를 바꾸는 것이 아니라 API의 모양을 바꾸는 것이다. 위 표의 오른쪽 열은 새 언어가 필요한 항목이 아니라 C 안에서 설계로 만들 수 있는 것들이다. 다음 장부터 그 설계를 본다.

표의 오른쪽 열을 그대로 구현한 라이브러리가 이 책이 기대 온 proven이다. 43장에서 처음 만났고 그 뒤로 몇 번 이름이 나왔지만, 정면으로 다루는 것은 지금부터다. 다음 장은 설치와 첫 프로그램 — 그리고 이 라이브러리가 왜 “설치할 것이 없는” 모양을 하고 있는지다.

  1. Scott A. Crosby and Dan S. Wallach. 2003. Denial of Service via Algorithmic Complexity Attacks. In Proceedings of the 12th USENIX Security Symposium. USENIX Association, Washington, DC. usenix.org/conference/12th-usenix-security-symposium