연재의 두 번째 글에서는 미정의 동작이 실제로 얼마나 “위험한”지, 그리고 상호 작용하는 컴파일러 최적화가 얼마나 놀라운 결과를 낳을 수 있는지, 보안과 디버깅 및 도구 관점에서 어떤 문제가 생기는지 다룹니다.
In Part 1 of our series, we discussed what undefined behavior is, and how it allows C and C++ compilers to produce higher performance applications than "safe" languages. This post talks about how "unsafe" C really is, explaining some of the highly surprising effects that undefined behavior can cause. In Part #3, we talk about what friendly compilers can do to mitigate some of the surprise, even if they aren't required to. I like to call this "Why undefined behavior is often a scary and terrible thing for C programmers". :-)
Translation available in: Japanese, and Spanisha>.
현대의 컴파일러 최적화기는 특정한 순서로 실행되는 많은 최적화를 포함하고 있으며, 때로는 반복적으로 수행되고, 컴파일러가 시간이 지나며 발전함에 따라(예: 새 릴리스가 나올 때) 바뀌기도 합니다. 또한 서로 다른 컴파일러들은 상당히 다른 최적화기를 가지는 경우가 많습니다. 최적화가 서로 다른 단계에서 실행되기 때문에, 이전 최적화가 코드를 바꾼 결과로 emergent 효과가 발생할 수 있습니다. 좀 더 구체적으로 보기 위해 (리눅스 커널에서 발견된 exploitable 버그를 단순화한) 엉뚱한 예제를 살펴봅시다:
void contains_null_check(int *P) {
int dead = *P;
if (P == 0)
return;
*P = 4;
}
이 예제에서 코드는 "명백히" 널 포인터를 검사합니다. 만약 컴파일러가 "중복된 널 검사 제거(Redundant Null Check Elimination, RNCE)"보다 먼저 "죽은 코드 제거(Dead Code Elimination, DCE)"를 수행한다면, 코드는 다음 두 단계로 변합니다: void contains_null_check_after_DCE(int *P) {
//int dead = *P; // 최적화기가 삭제함.
if (P == 0)
return;
*P = 4;
}
그리고 나서: void contains_null_check_after_DCE_and_RNCE(int *P) {
if (P == 0) // 널 검사가 중복이 아니어서 유지됨.
return;
*P = 4;
}
하지만 최적화기의 구조가 다르다면 RNCE가 DCE보다 먼저 실행될 수도 있습니다. 그러면 다음 두 단계를 얻게 됩니다: void contains_null_check_after_RNCE(int *P) {
int dead = *P;
if (false) // 이 시점까지 P가 역참조되었으므로 널일 수 없음
return;
*P = 4;
}
그리고 나서 죽은 코드 제거가 실행됩니다: void contains_null_check_after_RNCE_and_DCE(int *P) {
//int dead = *P;
//if (false)
// return;
*P = 4;
}
많은(지극히 타당한!) 프로그래머들에게 이 함수에서 널 검사가 삭제되는 것은 매우 놀라운 일일 것입니다(그리고 아마 컴파일러 버그라고 보고할지도 모릅니다 :). 하지만 표준에 따르면 "contains_null_check_after_DCE_and_RNCE"와 "contains_null_check_after_RNCE_and_DCE"는 모두 "contains_null_check"의 완전히 유효한 최적화 형태이며, 관련된 두 최적화는 다양한 애플리케이션의 성능을 위해 중요합니다. 의도적으로 단순하고 인위적인 예제이지만, 이런 종류의 일은 인라이닝에서 항상 일어납니다. 함수를 인라인하면 보통 2차 최적화 기회가 다수 드러납니다. 즉, 최적화기가 어떤 함수를 인라인하기로 결정하면 다양한 로컬 최적화가 작동하여 코드의 동작을 바꿀 수 있습니다. 이는 표준상 완전히 유효하며 실제로 성능에 중요합니다.
C 계열 프로그래밍 언어는 커널, setuid 데몬, 웹 브라우저 등 다양한 보안 핵심 코드를 작성하는 데 사용됩니다. 이러한 코드는 적대적인 입력에 노출되어 있고, 버그는 온갖 형태의 exploitable 보안 문제로 이어질 수 있습니다. C의 널리 언급되는 장점 중 하나는 코드를 읽을 때 무슨 일이 일어나는지 비교적 쉽게 이해할 수 있다는 점입니다. 그러나 미정의 동작은 이 속성을 빼앗아 갑니다. 대부분의 프로그래머는 위의 "contains_null_check"가 널 체크를 수행한다고 생각할 것입니다. 이 케이스는 그리 무섭지 않습니다(널이 전달되면 저장(store)에서 아마 크래시할 것이고, 이는 비교적 디버깅이 쉽습니다). 하지만 겉보기에 매우 타당해 보이는 C 코드 조각들 중 상당수가 완전히 무효한 경우가 널려 있습니다. 이 문제는 많은 프로젝트(리눅스 커널, OpenSSL, glibc 등)를 물어뜯었고, 심지어 CERT가 GCC에 대해 취약성 노트를 발행하게 만들었습니다(제 개인적인 생각으로는 널리 사용되는 모든 최적화 C 컴파일러가 이 문제에 취약하며, GCC만의 문제가 아닙니다).
예제를 봅시다. 다음은 신중하게 작성된 C 코드입니다:
void process_something(int size) {
// 정수 오버플로를 잡는다.
if (size > size+1)
abort();
...
// 이 코드의 에러 체크는 생략되어 있음.
char *string = malloc(size+1);
read(fd, string, size);
string[size] = 0;
do_something(string);
free(string);
}
이 코드는 malloc이 파일에서 읽은 데이터를 담기에 충분히 크도록(널 종료 바이트를 추가해야 하므로) 확인하고, 정수 오버플로가 발생하면 빠져나오도록 합니다. 하지만 이것은 바로 이전에 제시한 예제입니다. 컴파일러가 이 체크를 (유효하게) 최적화로 제거할 수 있는 경우였죠. 즉, 컴파일러가 다음과 같이 바꿔버릴 수 있다는 뜻입니다: void process_something(int *data, int size) {
char *string = malloc(size+1);
read(fd, string, size);
string[size] = 0;
do_something(string);
free(string);
}
64비트 플랫폼에서 빌드할 때, "size"가 INT_MAX인 경우(아마도 디스크에 있는 파일의 크기) 이는 exploitable 버그가 될 가능성이 큽니다. 이게 얼마나 끔찍한지 생각해봅시다. 코드 감리자가 코드를 읽고 정수 오버플로 체크가 제대로 이뤄지고 있다고 매우 타당하게 판단할 수 있습니다. 누군가가 이 코드의 에러 경로를 특별히 테스트하지 않는 한, 테스트에서도 문제가 드러나지 않을 수 있습니다. 보안적으로 안전해 보이는 코드가 잘 동작하는 듯하다가, 누군가가 취약점을 악용하면 무너져버립니다. 전반적으로 놀랍고 꽤 무서운 종류의 버그입니다. 다행히 이 경우 해결책은 간단합니다. "size == INT_MAX"와 같은 명시적인 체크를 사용하면 됩니다. 결국 정수 오버플로는 여러 이유로 보안 문제입니다. 설령 완전히 정의된 정수 산술을 사용하더라도(-fwrapv를 사용하거나 부호 없는 정수를 사용하는 방식), 전혀 다른 부류의 정수 오버플로 버그가 가능합니다. 다행히 이 부류는 코드에 드러나며, 지식 있는 보안 감리자들은 보통 이 문제를 인지하고 있습니다.
일부 사람들(예: 생성된 기계어를 보고자 하는 저수준 임베디드 프로그래머)은 최적화를 켠 상태로 개발 전 과정을 수행합니다. 개발 중인 코드는 버그가 있을 가능성이 자주 있기 때문에, 이들은 런타임에서 디버깅을 어렵게 만드는 놀라운 최적화를 불균형적으로 자주 마주치게 됩니다. 예를 들어, 첫 번째 글의 "zero_array" 예제에서 "i = 0"을 실수로 빼먹으면, 컴파일러는 초기화되지 않은 변수를 사용했다고 보고 전체 루프를 완전히 버려버릴 수 있습니다(즉, zero_array를 "return;"으로 컴파일함). 최근 누군가를 곤란하게 만든 흥미로운 사례가 또 있습니다. (전역) 함수 포인터를 사용했는데, 단순화한 예제는 다음과 같습니다:
static void (*FP)() = 0;
static void impl() {
printf("hello\n");
}
void set() {
FP = impl;
}
void call() {
FP();
}
clang은 이를 다음과 같이 최적화합니다: void set() {}
void call() {
printf("hello\n");
}
널 포인터 호출은 미정의이기 때문에, 컴파일러는 call()을 호출하기 전에 set()이 호출된다고 가정할 수 있습니다. 이 경우, 개발자는 "set" 호출을 깜빡 잊었지만 널 포인터 역참조로 크래시가 발생하지 않았고, 다른 사람이 디버그 빌드를 했을 때 코드가 깨졌습니다. 결론적으로 이는 고칠 수 있는 문제입니다. 이런 이상한 일이 벌어지고 있다고 의심되면, -O0로 빌드해 보세요. 이 경우 컴파일러는 최적화를 거의 하지 않습니다.
겉보기에 "잘 작동하는" 애플리케이션이 더 최신의 LLVM으로 빌드했을 때 갑자기 깨지거나, GCC에서 LLVM으로 옮겼을 때 깨지는 경우를 많이 보았습니다. 물론 LLVM 자체에도 가끔 버그가 있긴 합니다만 :-), 대부분의 경우 이는 애플리케이션 안에 잠복해 있던 버그가 이제 컴파일러에 의해 드러났기 때문입니다. 이런 일은 여러 가지 방식으로 일어날 수 있는데, 예를 들면 다음과 같습니다:
초기화되지 않은 변수가 예전에는 운 좋게 0으로 초기화되었는데, 이제는 다른 레지스터와 공유하게 되어 0이 아니게 되는 경우. 이는 보통 레지스터 할당이 바뀌면서 드러납니다.
스택에서의 배열 오버플로가, 예전에는 죽은 값을 덮어썼지만 이제는 실제로 중요한 변수를 덮어쓰기 시작하는 경우. 이는 컴파일러가 스택에 값을 배치하는 방식을 재배열하거나, 생존 기간이 겹치지 않는 값들끼리 스택 공간을 더 적극적으로 공유하게 될 때 드러납니다.
중요하고도 무서운 사실은, 미정의 동작에 기반한 거의 모든 최적화가, 버그가 있는 코드에서 미래의 어느 시점에든 갑자기 발동될 수 있다는 점입니다. 인라이닝, 루프 언롤링, 메모리 승격 같은 최적화는 계속해서 더 좋아질 것이고, 이들 존재 이유의 상당 부분은 위와 같은 2차 최적화를 드러내는 데 있습니다.
저에게 이것은 매우 불만족스러운 일입니다. 부분적으로는 컴파일러가 결국 비난을 받게 되기 때문이기도 하고, 또 거대한 양의 C 코드가 언제 터질지 모르는 지뢰밭이기 때문이기도 합니다. 더 나쁜 점은…
이 지뢰밭이 훨씬 더 끔찍해지는 이유는, 대규모 애플리케이션이 미정의 동작으로부터 자유로운지, 즉 미래에 깨지지 않을 것인지 판단할 수 있는 좋은 방법이 전혀 없다는 사실입니다. 각종 버그를 찾는 데 도움이 되는 유용한 도구들이 많긴 하지만, 앞으로도 코드가 절대 깨지지 않을 거라고 완전한 확신을 줄 수 있는 도구는 없습니다. 이러한 선택지들을 살펴보고, 각 장단점을 정리해 보겠습니다:
Valgrind memcheck 도구는 각종 초기화되지 않은 변수와 다른 메모리 버그를 찾는 데 훌륭합니다. 다만 Valgrind는 매우 느리고, 생성된 기계어에 여전히 존재하는 버그만 찾을 수 있으며(따라서 최적화기가 제거한 문제는 찾을 수 없습니다), 소스 언어가 C라는 사실을 알지 못하므로(비트 시프트 범위 초과나 부호 있는 정수 오버플로 버그를 찾지 못함) 한계가 있습니다.
Clang에는 실험적인 -fcatch-undefined-behavior 모드가 있어, 범위를 벗어난 시프트 양, 일부 간단한 배열 범위 초과 오류 등을 찾기 위해 런타임 체크를 삽입합니다. 이 기능은 애플리케이션의 실행 시간을 느리게 만들고, 무작위 포인터 역참조 같은 문제는 잡아주지 못하지만(Valgrind는 가능), 다른 중요한 버그들을 찾을 수 있습니다. Clang은 또한 -ftrapv 플래그를 완전히 지원합니다(-fwrapv와 혼동하지 마세요). 이는 부호 있는 정수 오버플로 버그가 런타임에 트랩을 일으키게 합니다(GCC에도 이 플래그가 있지만, 제 경험상 완전히 신뢰하기 어렵고 버그가 있습니다). 다음은 -fcatch-undefined-behavior의 간단한 데모입니다: $ cat t.c
int foo(int i) {
int x[2];
x[i] = 12;
return x[i];
} int main() {
return foo(2);
}
$ clang t.c
$ ./a.out
$ clang t.c -fcatch-undefined-behavior
$ ./a.out
Illegal instruction
컴파일러 경고 메시지는 초기화되지 않은 변수나 간단한 정수 오버플로 버그 같은 일부 부류의 버그를 찾는 데 좋습니다. 하지만 두 가지 주요 한계가 있습니다. 1) 실행 중인 코드에 대한 동적 정보를 갖고 있지 않으며, 2) 수행하는 모든 분석은 컴파일 시간을 늘리므로 매우 빠르게 동작해야 합니다.
Clang Static Analyzer는 더 깊은 분석을 수행하여(널 포인터 역참조와 같은 미정의 동작의 사용을 포함해) 버그를 찾으려고 합니다. 일반 경고의 컴파일 시간 제약에 묶여 있지 않으므로, 강화된 컴파일러 경고 메시지로 생각할 수 있습니다. 주요 단점은 1) 실행 중인 프로그램의 동적 정보를 갖고 있지 않으며, 2) 많은 개발자들의 일반적인 작업 흐름에 통합되어 있지 않다는 점입니다(다만 Xcode 3.2 이상에 통합된 형태는 훌륭합니다).
LLVM "Klee" 하위 프로젝트는 기호적 분석을 사용하여 코드의 "가능한 모든 경로"를 시도해 버그를 찾아내고, 테스트 케이스를 생성합니다. 매우 훌륭한 프로젝트이지만, 대규모 애플리케이션에 적용하기에는 실용성이 떨어진다는 한계가 있습니다.
저는 사용해 보지는 않았지만, Chucky Ellison과 Grigore Rosu의 C-Semantics 도구는 일부 부류의 버그(시퀀스 포인트 위반 등)를 찾아낼 수 있는 매우 흥미로운 도구입니다. 아직은 연구 단계의 프로토타입이지만, (작고 독립적인) 프로그램의 버그를 찾는 데 유용할 수 있습니다. 더 자세한 내용은 John Regehr의 글을 읽어보시길 권합니다.
결국 우리의 공구함에는 일부 버그를 찾을 수 있는 도구들이 많이 있지만, 애플리케이션이 미정의 동작으로부터 자유롭다는 것을 증명할 좋은 방법은 없습니다. 실제 세계의 애플리케이션에는 버그가 많고 C는 광범위한 핵심 애플리케이션에 사용되므로, 이는 꽤 무서운 일입니다. 마지막 글에서는 C 컴파일러가 미정의 동작을 다룰 때 사용할 수 있는 다양한 선택지를 살펴보고, Clang에 특히 초점을 맞추겠습니다.
-[Chris Lattner]