90 50년째 출하되는 다섯 가지 버그
먼저 알아야 할 것
돌아보기
43장에서 문자열을 다루는 함수들이 “그릇의 크기를 모른다”고 했고, 53장에서는 “실패를 확인하지 않으면 없던 일이 된다”고 했다. 그러면 이런 문제들은 왜 아직도 남아 있는가 — 반세기면 고치고도 남을 시간이 아닌가?
답. 고칠 수 없어서가 아니라, 고치면 이미 쓰인 코드가 전부 깨지기 때문이다. strcpy의 시그니처에 크기 매개변수를 하나 더 넣는 순간 세상의 모든 C 코드가 컴파일되지 않는다. 표준은 기존 코드를 지켜야 하는 기관이라(66장의 “얇고 오래된” 이유가 여기서도 나온다), 위험한 함수를 없애는 대신 더 나은 함수를 옆에 두는 길을 택해 왔다. 그래서 선택은 프로그래머에게 넘어온다 — 무엇이 위험한지 알고 고르는 일이 곧 실력이 되는 언어인 셈이다.
이 장의 필요성과 맥락
이 장이 끝나면
문. 그러면 이 다섯 가지는 초보자가 저지르는 실수 목록인가?
답. 아니다. 이것들은 숙련자도 계속 저지르는 실수이고, 그 점이 중요하다. 문법을 다 아는 사람도 같은 자리에서 미끄러진다면 원인은 사람이 아니라 도구의 모양이다. 크기를 받지 않는 함수는 크기를 검사할 방법이 없고, 반환값을 버려도 아무 일 없는 함수는 언젠가 버려진다. 이 장은 그 “모양”을 하나씩 뜯어보는 장이다.
이 장에서 답할 질문
- 이런 문제를 피하려고 아예 다른 언어를 쓰는 것이 낫지 않은가?
90.1 하나 — 문자열 함수는 그릇의 크기를 모른다#
가장 오래되고 가장 많이 악용된 부류다. 43장에서 본 그대로다.
char buf[64];
strcpy(buf, name); /* name 이 얼마나 긴가? strcpy 는 묻지 않는다 */
strcat(buf, ", welcome!"); /* 그리고 이제 남은 자리는 얼마인가? */strcpy의 시그니처에는 목적지의 크기가 없다. 없는 정보는 검사할 수 없으므로, 이 함수는 64바이트 그릇의 200번째 바이트에도 기꺼이 쓴다. 무엇이 망가지는가는 컴파일러가 그 뒤에 무엇을 놓았느냐에 달렸다 — 이웃 지역 변수일 수도, 스택 보호 장치가 심어 둔 카나리일 수도 있고, 더 멀리 넘치면 함수의 반환 주소까지 닿는다(45장의 스택 그림을 떠올리면 된다).
현대의 처방은 크기를 받는 판을 쓰는 것이다 — 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는 사실 답을 준다 — 필요했던 길이를 반환한다. 그 값이 그릇 크기 이상이면 잘린 것이다(예제의 마지막 줄이 그 검사다). 문제는 이 검사가 선택 사항이라는 데 있다. 반환값을 버려도 컴파일러는 아무 말도 하지 않는다. 이것이 곧바로 두 번째 버그로 이어진다.
실제 사례. 컴파일러는 자기가 볼 수 있는 것만 잡는다
이 예제를 만드는 동안 실제로 겪은 일이다. 처음에는 snprintf를 main 안에서 리터럴 인자로 직접 불렀는데, 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라는 함수 안으로 옮기자 경고가 사라졌다. 함수 경계를 넘는 순간 컴파일러는 dir과 name의 실제 길이를 알 수 없기 때문이다. 실제 프로그램에서 문자열은 파일이나 네트워크에서 오므로 컴파일러가 도울 수 있는 경우가 오히려 드물다. 경고는 공짜 검토이지 보증이 아니다(18장).
90.2 둘 — 실패를 확인하게 만드는 장치가 없다#
char *p = malloc(n);
p[0] = 'x'; /* malloc 은 실패하면 널을 준다 */C의 실패 통보 방식은 두 가지다. 센티널 값(널, -1, EOF)을 돌려주거나, 전역 변수 errno에 이유를 남기거나. 둘 다 확인을 강제하지 못한다. 반환값을 버리는 코드는 완벽하게 합법이고, errno는 다음 호출이 덮어쓰기 전에 정확한 순간에 읽어야 하는 전역 상태다(66장).
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”이 같은 값으로 돌아온다.
흔한 오해. “실패는 예외적인 일이니 나중에 처리하면 된다”
90.3 셋 — printf는 우리가 말하는 것을 그대로 믿는다#
66장에서 서식 문자열의 문법을 뜯어보았다. 그 문법에는 구조적 약점이 하나 있다 — 타입을 두 번 적는다는 것이다. 한 번은 서식에서(%d), 한 번은 인자에서(변수의 타입). 둘이 어긋나도 언어는 막지 못한다. 63장에서 본 대로 가변 인자에는 타입 정보가 실려 가지 않기 때문이다.
요즘 컴파일러는 이것을 잡아 준다. 실제 메시지는 이렇다.
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); /* 경고 없음. 검사도 없음 */-Wall -Wextra 로는 이 코드에서 경고가 하나도 나오지 않는다. -Wformat-nonliteral (-Wformat=2 에 들어 있다)을 켜면 「서식이 리터럴이 아니라 인자 타입을 검사하지 못했다」는 경고는 받는다 — 그러나 그것은 검사를 못 했다는 고백이지 검사가 아니다. 서식과 인자가 맞는지 아는 방법이 컴파일 시간에 존재하지 않기 때문이다. 최악은 서식이 사용자 입력인 경우로, 66장에서 본 형식 문자열 취약점이 된다.
90.4 넷 — 이건 누가 해제하나#
char *s = build_message(); /* 이걸 free 해야 하나? 타입은 말이 없다 */46장에서 배운 대로 동적으로 잡은 기억은 누군가 정확히 한 번 놓아 주어야 한다. 그런데 함수가 돌려준 char *는 네 가지 중 무엇이든 될 수 있다.
- 방금 할당한 것 — 해제해야 한다.
- 호출자가 준 버퍼를 가리키는 것 — 해제하면 안 된다.
- 읽기 전용 자리에 있는 문자열 리터럴 — 해제하면 사고다.
- 다음 호출이 덮어쓸 정적 버퍼 — 해제해서도 안 되고 오래 들고 있어도 안 된다(66장의
strtok이 그랬다).
네 경우의 타입이 전부 같다. 답은 문서에 있고, 문서는 코드와 어긋나기 마련이다. 여기서 46장의 사고 셋이 나온다 — 누수(아무도 해제하지 않음), 이중 해제(둘 다 해제함), 사용 후 해제(해제한 뒤에도 누군가 가리키고 있음).
반례. 타입이 소유권을 말하지 않는 API
const char *lookup(int code); /* 리터럴? 할당? 정적 버퍼? */
char *format_time(time_t t); /* free 해야 하나? */이름과 타입만 보고는 알 수 없다. 이 API를 쓰는 모든 곳이 문서를 기억해야 하고, 한 곳이라도 잊으면 앞의 사고 셋 중 하나가 된다. 규율을 사람의 기억에 맡기는 설계라는 점이 문제의 본질이다.
90.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 비교자는 타입이 완벽하게 맞고, 경고 하나 없이 컴파일되며, 죽지도 않는다. 그저 peach와 pear의 순서가 틀렸을 뿐이다 — 첫 글자만 보았으니 둘은 “같다”고 판정됐고 나머지는 운에 맡겨졌다. 이 부류의 버그는 조용히 잘못된 답을 내놓기 때문에 가장 늦게 발견된다.
실제 사례. 자료구조의 최악을 노리는 공격
90.6 여섯 번째 — 바이트에는 타입이 있다#
다섯이라 했지만 하나를 더 붙여야 공평하다. 14장에서 본 엄격한 앨리어싱 이다. 바이트 버퍼를 폭이 다른 포인터로 들여다보는 손수 짠 파서는 -O0에서 완벽히 돌고 -O2에서 조용히 다른 답을 낸다. 18장에서 본 “릴리스에서만 나는 버그”의 대표이자, 리눅스 커널이 플래그 하나로 항복한 그 조항이다.
여섯을 한 표로 모으면 이 부의 밑그림이 된다.
복습 정리
| 문제 | C가 주는 것 | 필요한 것 |
|---|---|---|
| 버퍼 넘침·잘림 | 크기를 모르는 문자열 함수 | 길이를 지니고 다니는 문자열, 자르지 않는 쓰기 |
| 확인되지 않은 실패 | 센티널 값과 errno | 값으로 오는 에러, 버리면 컴파일 거부 |
| 서식 불일치 | 서식 문자열을 믿는 printf | 타입을 인자에서 가져오는 자리표시자 |
| 불분명한 소유권 | 네 가지를 뜻하는 char * | 소유와 빌림이 다른 타입 |
| 검사되지 않는 콜백 | void * 인터페이스 | 문서화된 계약과 최악 보장 알고리즘 |
| 바이트의 숨은 타입 | 앨리어싱 규칙 위반의 UB(undefined behaviour) | 규칙이 면제하는 바이트 타입 |
표 90.1 — 다섯 버그 — C가 주는 것과 필요한 것
문. 이런 문제를 피하려고 아예 다른 언어를 쓰는 것이 낫지 않은가?
답. 그것도 하나의 답이고, 실제로 많은 곳이 그 길을 갔다(1장). 다만 C를 써야 하는 자리는 여전히 남아 있다 — 운영체제, 펌웨어, 다른 언어들이 기대는 바닥층, 그리고 이미 수십 년치 코드가 쌓인 프로젝트들. 그런 자리에서 할 수 있는 일은 언어를 바꾸는 것이 아니라 API의 모양을 바꾸는 것이다. 표 90.1의 오른쪽 열은 새 언어가 필요한 항목이 아니라 C 안에서 설계로 만들 수 있는 것들이다. 다음 장부터 그 설계를 본다.
표의 오른쪽 열을 그대로 구현한 라이브러리가 이 책이 기대 온 proven이다. 44장에서 처음 만났고 그 뒤로 몇 번 이름이 나왔지만, 정면으로 다루는 것은 지금부터다. 다음 장은 설치와 첫 프로그램 — 그리고 이 라이브러리가 왜 “설치할 것이 없는” 모양을 하고 있는지다.
주
- 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↩