back

buffer overrun 으로 인한 undefined behavior

2년 전 작성

수준이 네이버 지식인보다 못한 Google+ 커뮤니티에서 보게된 질문 중에 정의되지 않은 행동(undefined behavior)와 관련된 간단한 예가 하나 보여 소개해 볼까 합니다.

C 언어의 정의되지 않은 행동은 이제는 워낙 여러 곳에서 소개된 내용이라 이곳에서 다시 설명할 필요는 없어 보입니다. 예전에 제가 유즈넷에서 활동을 시작할 때만 해도 국내에는 그리 널리 알려진 개념이 아니었는데, 이제는 C 언어와 관련된 질문/답변에서 자연스럽게 용어가 쓰이는 것을 보면 격세지감을 느낍니다.

늙은이 같은 소리는 그만하고 본론으로 들어가야겠네요. 커뮤니티에서 만난 질문은 아래 코드의 행동과 이유를 설명해 달라는 것이었습니다.

#include <stdio.h>

int main(void)
{
    int i;
    int a[5];

    for (i = 0; i <= 5; i++)
        a[i] = 0;

    printf("%d\n", i);
}

답변 중 가장 설득력 있는(?) 것은 "직접 실행해보면 되지 왜 물어보냐"였지만(;-), 그래도 전 친절하니까...

C 언어에 조금만 익숙한 프로그래머라면 문제가 자명하게 보입니다. 보통 배열 요소를 도는 루프를 작성할 때는 sizeof와 비대칭 경계를 사용해 i < sizeof(a)/sizeof(a[0])처럼 조건을 써주는 것이 일반적이라 초보가 아니고서는 좀처럼 저지르기 어려운 실수입니다.

예제 코드는 정의되지 않은 행동(이제 줄여서 UB라고 쓰겠습니다. 손가락이 아프네요)에 해당하기 때문에 이 코드의 결과를 묻는다면 이론적인 답변은 "어떤 일도 일어날 수 있다"입니다. 제가 가장 좋아하는 답변은 "옆집 컴퓨터가 꺼질 수도 있다"였고, 외국인들이 가장 좋아했던 답변은 "UB는 물리 법칙을 따를 필요도 없다(물론 영어로 써야겠죠?)"였습니다.

하지만, (실제 질문의 의도를 떠나) 이 프로그램이 UB를 사용해 무언가를 의도한 것이라면, 그 의도가 무엇인지, 또 그 의도가 달성될 수 있는지 살펴보는 것도 재미있을 것 같아 주저리 주저리 적어봅니다.

우리가 사용하는 대부분의 컴퓨터는 함수가 호출될 때마다 스택 프레임(stack frame)이라 부르는 메모리 공간이 할당되고 그 안에 함수 매개변수(parameter)와 지역변수(local variable), 그리고 그외 함수가 실행되는 동안 필요한 정보를 저장해둡니다. 그리고 프레임이 함수가 호출됨에 따라 스택처럼 쌓이는 공간을 호출 스택(call stack)이라고 부릅니다. 정확한 구조와 쓰임새는 하드웨어, 운영체제, 호출 관례(calling convention) 등에 따라 달라지는데 상세한 내용은 여기서 중요하지 않으므로 대충 어떻게 생겼는지만 보겠습니다.

이미지

그냥 인터넷에서 제일 그럴싸한 놈으로 가져왔습니다. 다른 부분은 필요 없고 "스택(stack)" 부분만 보면 됩니다. (유부남은 키가 옆으로 자라고) 스택 영역은 거꾸로("high"에서 "0"으로) 자랍니다. 스택은 매개변수나 지역변수가 저장되는 공간입니다 (참고로, 정상적으로 자라는 힙 heap 은 malloc()같은 동적 할당으로 할당된 메모리를 위한 공간입니다).

중요한 것은 스택이 자라는 방향이 거꾸로(큰 주소에서 작은 주소, 즉 음의 방향으로)라는 것입니다. 다시 말해, 2개의 변수가 스택에 할당되면 첫번째 변수는 (예를 들어) 10번지에, 두번째 변수는 9번지에 저장되는 식이죠. 물론, 정확히 지역변수의 선언 순서를 따라 스택에 할당된다는 보장은 없습니다. 심지어 자주 쓰이는 변수라면 애초에 레지스터(register)에 자리잡기도 하기 때문에 스택에 존재한다는 보장조차 없습니다. 일정 범위 안에서는 그야말로 컴파일러 맘입니다. 이 글에서는 지역변수의 선언 순서에 따라 스택에 차례대로 자리잡는다는 가정을 깔고 이야기를 진행하겠습니다.

배열의 경우 포인터 연산 +를 통해 요소를 차례로 방문할 수 있어야 하기에 배열의 개별 요소는 양의 방향으로 저장됩니다. 예를 들면, array[0]이 1번지에 저장된다면 array[1]은 그보다 큰 주소인 2번지에 저장되는 식이죠.

그러면 이제 문제가 되는 코드는 스택 프레임 안에서 보통은 아래와 같이 구성됩니다.

이미지

(제가 악필이라서 그림이 후진게 아니라 모나미153이 똥이 많아서 그렇습니다!)

자, 이제 잘못된 포인터 연산 a[5]로 접근한 곳이 어딘지 보이기 시작합니다. 바로 i가 저장된 곳에 해당합니다. 그러면 for 루프 안에서 a[5]에 0을 저장하는 순간 실제 값이 바뀌는 녀석은 i가 될 가능성이 있습니다. 그렇게 되면 for 루프는 다시 0에서 시작하게 되어 무한 루프가 되는 셈입니다.

결국 이 코드는 UB에 해당하는 버퍼 오버런(buffer overrun, 혹은 버퍼 오버플로우 buffer overflow라고도 합니다)을 일으켜 무한 루프가 되는 결과를 낳을 거라 예상할 수 있습니다. 하지만, 괜히 UB가 아니지요. 실제 저 코드를 컴파일해 실행해보면 조금 실망스러운 결과가 나오기 시작합니다.

$ gcc -O0 foo.c && ./a.out
6

저의 사랑스런 Gentoo 머신에서는 결과로 그냥 6이 출력되는군요. 왜 그런 걸까요? 레지스터에 할당되기라도 하는 걸까요? 물리 법칙을 따를 필요도 없는 UB인데 "왜"냐는 의문을 갖는 것이 무의미할 수도 있지만, 굳이 이유를 찾자면 제 시스템이 64비트(x86-64)이기 때문입니다. 리눅스(Linux)는 I32LP64라는 모델을 채용하고 있기 때문에 int가 32비트(4바이트)이고 4바이트 단위로 정렬(alignement)됩니다. 하지만, 호출 관례(리눅스는 System V AMD64 ABI라는 관례를 따르고 있으며 Windows와는 차이가 있습니다)에 따라 16바이트 이상의 배열은 16바이트 단위로 정렬을 맞추기 때문에, 배열 ai 사이에 정렬을 만족하기 위한 공간이 생깁니다. 단, 이때 배열 요소는 연속으로 존재해야 하기 때문에 4바이트씩 할당됩니다.

이미지

그러니 a[5]가 접근하는 곳은 i가 아니라 a[4]i 사이의 공간에 해당되어 i 값이 영향을 받지 않습니다.

그러면 32비트 용으로 컴파일을 하거나

$ gcc -O0 -m32 foo.c && ./a.out
6
*** stack smashing detected ***: ./a.out terminated
======= Backtrace: =========
/lib32/libc.so.6(+0x6df41)[0xf75f7f41]
/lib32/libc.so.6(__fortify_fail+0x55)[0xf768e025]
/lib32/libc.so.6(+0x103fcb)[0xf768dfcb]
./a.out[0x80484d9]
/lib32/libc.so.6(__libc_start_main+0xf5)[0xf75a2645]
./a.out[0x8048391]
======= Memory map: ========
...
Aborted

64비트 정수형인 long long int를 사용하면 무한 루프를 볼 수 있을까요?

$ cat fool.c
#include <stdio.h>

int main(void)
{
    long int i;
    long int a[5];

    for (i = 0; i <= 5; i++)
        a[i] = 0;

    printf("%ld\n", i);
}

$ gcc -O0 fool.c && ./a.out
6
*** stack smashing detected ***: ./a.out terminated
======= Backtrace: =========
/lib64/libc.so.6(+0x73769)[0x7f0f669ef769]
/lib64/libc.so.6(__fortify_fail+0x48)[0x7f0f66a78038]
/lib64/libc.so.6(+0xfbfe1)[0x7f0f66a77fe1]
./a.out[0x40061a]
/lib64/libc.so.6(__libc_start_main+0xf1)[0x7f0f6699c7c1]
./a.out[0x4004f9]
======= Memory map: ========
...
Aborted

세상에 뜻대로 되는 게 별로 없습니다. 똘똘한 컴퓨터가 문제가 발생했음을 단박에 잡아 줍니다. 정말 좋은 세상입니다. 이것은 gcc가 스택에서 발생하는 버퍼 오버런을 잡아주는 기능을 지원하기 때문입니다. (사실, 스택 오버런 탐지가 켜지면 gcc는 탐지가 용이하도록 스택 내의 지역변수 배치를 변경합니다. 때문에 i 값이 영향을 받지 않아 결과 6이 출력되고, 애초부터 "지역변수의 선언 순서에 따라 스택에 차례대로 자리잡는다"는 가정이 만족되지 않습니다.)

그럼 그 기능을 꺼보겠습니다.

$ gcc -O0 -m32 -fno-stack-protector foo.c && ./a.out

(아무 결과가 출력되지 않고 무한 루프에 빠져 프로그램이 종료되지도 않았습니다.) 이제서야 원하는 결과가 보이는군요!

32비트 코드만 낼 줄 알고 스택 보호 기능도 없는(아... 속상하다...) 제 컴파일러 beluga는 사실 한방에 기대하는 효과를 보여줍니다 (우아.. 조으다..).

그리고 실제 beluga가 컴파일한 결과는 아래와 같습니다.

01  .globl main
02  .text
03  .align 16
04  .type main,@function
05  main:
06  pushl %ebp
07  pushl %ebx
08  pushl %esi
09  pushl %edi
10  movl %esp,%ebp
11  subl $24,%esp
12  movl $0,-4(%ebp)
13  .LC2:
14  movl -4(%ebp),%edi
15  leal -24(%ebp),%esi
16  movl $0,(%esi,%edi,4)
17  .LC3:
18  incl -4(%ebp)
19  cmpl $5,-4(%ebp)
20  jle .LC2
21  pushl -4(%ebp)
22  pushl $.LC6
23  call printf
24  addl $8,%esp
25  .LC1:
26  movl %ebp,%esp
27  popl %edi
28  popl %esi
29  popl %ebx
30  popl %ebp
31  ret
32  .Lf7:
33  .size main,.Lf7-main
34  .data
35  .align 1
36  .LC6:
37  .byte 37
38  .byte 100
39  .byte 10
40  .byte 0
41  .text
42  .ident "beluga: 0.0.1"

(줄 번호는 설명을 위해 제가 붙였습니다.)

  • 1~11번 줄은 함수마다 공통으로 들어가는 프롤로그(prologue)라는 부분입니다.
  • 25~33번 줄도 공통으로 들어가는 에필로그(epilogue)라는 부분입니다.
  • 34번 줄부터는 printf() 호출에 사용되는 문자열 내용이 정의되어 있는 부분입니다.

갑자기 어셈블리어 코드가 나와 정신 없지만 관련된 부분만 보겠습니다.

12  movl $0,-4(%ebp)

코드에서 i를 0으로 초기화하는 부분에 해당합니다. %ebp는 스택 특정 영역의 주소가 저장되어 있는 레지스터인데 그냥 일종의 포인터 변수(char *p라고 생각하겠습니다)라고 생각하면 편합니다. -4(%ebp)는 C 코드로 치면 p[-4] 정도에 해당합니다. 즉, 지역변수는 p가 가리키는 주소에서 시작해 음의 방향으로 자랍니다. 그러면 저 코드는 p[-4]에 0을 대입하는 코드에 해당합니다.

13  .LC2:
14  movl -4(%ebp),%edi
15  leal -24(%ebp),%esi
16  movl $0,(%esi,%edi,4)
17  .LC3:
18  incl -4(%ebp)
19  cmpl $5,-4(%ebp)
20  jle .LC2

이 부분이 for 루프에 해당합니다. i가 0으로 초기화되고 조건 검사가 i <= 5이기 때문에 최적화가 되어 첫번째 검사인 0 <= 5는 항상 참이므로 생략됩니다. 때문에 for 루프의 본체(body)에 해당하는 14-16번 줄이 일단 실행됩니다. 본체 부분을 C 코드로 비슷하게 풀어보자면, (j, p1이 레지스터라고 생각하여)

int j = p[-4];               // int j = i;
char *p1 = p-24;             // char *p1 = (char *)&a[0];
*(int *)(p1 + (j*4)) = 0;

에 해당합니다. p[-4]i에 해당됨은 이미 확인했고, 배열의 첫 요소인 a[0]p[-24]에 해당함을, 따라서 a[0]의 주소는 p-24에 해당함을 알 수 있습니다. 실제 a[i] 주소를 계산할 때는 aint의 배열이므로 int의 크기인 4를 첨자에 곱해 주소를 찾습니다(p1 + (j*4)). 그리고 해당 주소에 int 값으로 0을 대입합니다.

그리고 i를 증가시키고

18  incl -4(%ebp)

그 값을 5(코드에서 $5)와 비교하고 결과에 따라 분기합니다.

19  cmpl $5,-4(%ebp)
20  jle .LC2

루프를 돌다가 i가 5가 되면, 위의 본체에서 j가 5가 되는 셈이고 j*4는 20이 됩니다. 이를 p1에 더하는데 p1은 어셈블리어 코드에서 -24(%ebp)의 주소, 비유로 든 C 코드에서는 p-24 였습니다. 이에 20을 더하면 -4(%ebp)의 주소, C 코드로는 p-4가 되겠지요. 그런데 이는 위에서 본 i의 주소와 일치합니다. 처음 예측한대로 스택 내에서의 배치에 의해 버퍼 오버런이 i를 오염시키고 있음을 알 수 있습니다.

지금 같은 경우는 코드도 워낙 작고 (의도된) 실수가 눈에 띄기 때문에 수정이 용이하지만, 만약 배열 첨자가 아닌 포인터 연산을 잘못 하여 엉뚱한 곳으로 버퍼 오버런을 냈다면 생각보다 디버깅하기 어려운 실수가 될 가능성이 큽니다 - 그럴 땐 지지 치고 gdb를 소환해야 합니다.

심심한 탓에 UB가 일으키는 일을 차근히 살펴보았지만, 사실 잘못된 코드가 만들어내는 UB에 어떤 의미를 부여하는 것은 매우 매우 위험합니다. 즉, "아! UB이기 때문에 이런 일들이 일어날 수 있구나" 정도의 해석은 가능해도 "대부분의 시스템에서 이렇게 되겠구나"라고 일반화하는 것은 해서는 안 될 일입니다. 여러분이 생각하는 대부분이 실제 대부분이 아닐 수도 있고, 이렇게가 항상 이렇게가 아닐 수 있기 때문입니다. 실제로 컴파일러의 옵션 몇개만 바꿔도 바로 다른 행동들을 관찰할 수 있습니다. 그리고, 이 짧은 설명에 벌써 얼마나 많은 가정을 깔고 있는지 생각해본다면 충분히 설득력 있는 이야기겠지요?