컴퓨터에 대해서 공부를 하면서 한 번쯤은 buffer overflow라는 말에 대해서 들어본 적이 있을 것입니다. buffer overflow는 out-of-bound array element를 write하는 과정에서 stack에 저장된 data가 corrupted되는 것을 말합니다. stack에는 local variable, return address 등 stack frame에서 필요한 data들을 저장하고 있는데, 해당 data들을 corrupt하면서 process의 동작에 매우 큰 문제를 불러 일으킬 수 있습니다.
아래의 code를 예시로 buffer overflow가 일어나는 이유에 대해 살펴보겠습니다.
char *gets(char *s) {
int c;
char *dest = s;
while ((c = getchar()) != '\n' && c != EOF)
*dest++ = c;
if (c == EOF && dest == s)
/* No characters read */
return NULL;
*dest++ = '\0'; /* Terminate string */
}
/* Read input line and write it back */
void echo() {
char buf[8]; /* Way too small! */
gets(buf);
puts(buf);
}
위의 code에서 gets
function은 예전의 library에 존재하던 standard input을 read하는 역할을 하며, echo
function에서 buf
array에 input을 저장해 다시 출력하는 역할을 하고 있습니다. 여기서 문제가 되는 것은, gets
function에 정해진 input size의 bound가 없어 buf
array의 size를 넘어가는 input을 받을 수도 있다는 점입니다. 아래 그림은 echo
function의 stack frame을 그림으로 나타낸 것입니다. buf
는 local variable이므로 echo
의 stack frame에 존재하며 stack의 top에서부터 순서대로 input char가 저장됩니다. 만약 input size가 buf
의 size보다 매우 크게 들어오면 이전 function의 frame에 존재하는 return address나 local variable을 corrupt하게 됩니다.
위의 code와 그림을 다시 보면 echo
의 stack frame에 buf
를 가지고 있는 address는 caller의 stack frame으로부터 어느 정도 간격을 둔 후에 존재하는 것을 알 수 있습니다. 그림에서는 16byte의 빈 공간을 둔 상태에서 8byte의 buf
array가 있는 것을 볼 수 있습니다. 따라서 23byte input(null character \n
을 포함해 24byte)이 주어지면 return address는 input에 영향을 받지 않을 것이고, 그보다 큰 input이 들어오면 return address를 corrupt할 수 있습니다. 만약 attack을 위해 목표로 하는 return address를 알고 있다면, 임의의 24byte input에 return address를 붙여 input으로 주면 return address를 원하는 대로 바꿀 수 있을 것입니다. 이와 같이 buffer overflow를 통해 stack에 저장된 data를 corrupt하는 방법으로 이루어지는 attack을 stack smashing attack이라 합니다.
여기서 더 나아가서, return address 뒤에는 caller가 가지고 있는 local variable이 저장되어 있거나, echo
를 call하기 이전에 register의 값을 push한 data가 존재하기 때문에, 만약 input string을 더 길게 만들어준다면 이를 modify할 수도 있습니다.
stack smashing을 통해 return address를 바꾸는 attack에 대해서 살펴보았는데, 이를 활용하여 attacker가 원하는 code를 실행할 수 있도록 하는 attack이 가능해집니다. executable code의 binary code를 가지고 있다면, 해당 code를 input string으로 주면서 return address를 code의 시작점을 가리키는 address로 바꿈으로써 executable code를 실행할 수 있게 됩니다. 이렇게 input으로 입력한 code를 exploit code라 하며, exploit code로 jump해 code를 실행하도록 하는 attack을 code injection attack이라 합니다.
앞서 설명한 buffer overflow attack이 있다면 이를 막기 위한 여러 가지 장치들이 존재할 것입니다. 이에 대해서 몇 가지 살펴보도록 하겠습니다.
exploit code를 활용한 code injection attack을 위해서는 exploit code가 쓰여지는 address가 stack 내에 존재하기 때문에 해당 code로 jump하기 위해 stack의 address를 알고 있어야 합니다. 즉, stack address를 user가 모르게 하는 것은 code injection attack을 막기 위한 하나의 방법이 될 수 있습니다. 만약 program이 실행될 때마다 stack address의 위치가 달라진다면 user 입장에서 정확한 address를 찾기 어려울 것이므로 attack을 막을 수 있습니다. 아래의 virtual memory를 나타낸 그림으로 더 자세히 설명하자면, 그림에서는 stack이 kernal 영역 바로 밑에 붙어 address가 정해진 것으로 나타났지만 그 사이 간격을 randomize하여 stack의 address가 매 실행마다 바뀌도록 만들어줄 수 있습니다. stack 뿐만 아니라 나머지 영역들도 사이의 gap을 randomize하여 user가 address를 알지 못하도록 할 수 있습니다. 이는 attacker의 입장에서 정확한 address를 알지 못하도록 만들기 때문에 attack이 더 어려워지도록 할 수 있습니다.
stack randomization을 뚫고 attack이 이루어지도록 한 attack의 방법으로 nop
를 활용한 exploit code를 심어주는 것이 있습니다. nop
는 x86-64 ISA에서 아무 operation도 하지 않는 명령어이지만 1byte의 memory를 차지합니다. stack address가 random한 값이어도 어느 정도는 range가 존재하기 때문에, exploit code를 작성하기에 앞서 무수히 많은 nop
를 추가해준 후(nop sled) 목표로 하는 return address가 nop sled 내에 항상 위치할 수 있도록 설정해주면 exploit code를 항상 실행할 수 있을 것입니다.
이번에는 stack corruption이 일어나는 상황을 detect하기 위한 방법에 대해 살펴보겠습니다. detection을 위한 idea는 stack frame이 변화할 때 그 사이에 canary value(or guard value)라는 특수한 값을 저장해주는 것입니다. canary value는 program이 실행될 때마다 random하게 결정되며 input을 입력받는 array와 기존에 저장된 data 사이에 위치하게 됩니다. 아래 그림은 위의 echo
function의 stack frame에 canary가 추가된 것을 나타낸 것입니다. canary가 중간에 위치하면서 buffer overflow가 일어나게 되면 canary value에 input으로 주어진 string이 overwrite됩니다. canary는 항상 stack에 저장된 data보다 top에 가까운 위치에 있으므로 stack에 저장된 data보다 canary value가 먼저 변화하게 됩니다. program이 진행됨에 따라 stack에 저장된 data를 활용하기 전에 canary를 확인해 canary의 값이 변화한 것을 감지하면 program이 error message를 보내 abort시킬 수 있습니다. 이를 통해 buffer overflow를 통해 stack에 저장된 data들이 corrupt되는 것을 막을 수 있습니다.
마지막으로 살펴볼 방법은 executable code를 가질 수 있는 region을 제한하는 것입니다. code injection attack은 input으로 exploit code를 주어 stack에서 code를 실행하는 것으로 이루어집니다. 이를 막기 위해 실행할 수 있는 code가 저장된 memory region을 제한하는 방법이 있습니다. 위의 virtual memory 그림을 다시 보면, program을 compile했을 때 실제 실행되는 code가 존재하는 영역은 .text
부분입니다. 따라서, 해당 부분을 제외한 나머지 영역을 read, write만 할 수 있도록 flag bit을 추가해 표시한다면 code를 실행하려 할 때 해당 flag를 보면서 실행할 수 있는 영역의 data인지 파악할 수 있습니다. 따라서, stack 영역을 read/write만 가능하도록 제한한다면 code injection attack으로부터 보호할 수 있습니다.
reference
Computer System: A Programmer's Perspective, 3rd ed (CS:APP3e), Pearson, 2016