- 상위 문서 : 버퍼 오버플로
버퍼 오버플로의 종류에는 스택과 힙이 있지만 여기서는 스택에 관하여 설명한다.
본 문서는 어셈블리어와 C언어, 메모리구조에 대한 기초적인 지식이 있는 전공자를 대상으로 한 문서입니다.
1 설명
우선 간단한 C언어 프로그램을 생각해보자.
#include #include void main(int argc, char** argv) { char buf[16]; strcpy(buf, argv[1]); printf("%s\n", buf); }
0x0804849d <+0>: push ebp 0x0804849e <+1>: mov ebp, esp 0x080484a0 <+3>: and esp, 0xfffffff0 0x080484a3 <+6>: sub esp, 0x30 0x080484a6 <+9>: mov eax, DWORD PTR [ebp+0xc] 0x080484a9 <+12>: mov DWORD PTR [esp+0xc], eax 0x080484ad <+16>: mov eax, gs:0x14 0x080484b3 <+22>: mov DWORD PTR [esp+0x2c], eax 0x080484b7 <+26>: xor eax, eax 0x080484b9 <+28>: mov eax, DWORD PTR [esp+0xc] 0x080484bd <+32>: add eax, 0x4 0x080484c0 <+35>: mov eax, DWORD PTR [eax] 0x080484c2 <+37>: mov DWORD PTR [esp+0x4], eax 0x080484c6 <+41>: lea eax, [esp+0x1c] 0x080484ca <+45>: mov DWORD PTR [esp], eax 0x080484cd <+48>: call 0x8048360 <strcpy@plt> 0x080484d2 <+53>: lea eax, [esp+0x1c] 0x080484d6 <+57>: mov DWORD PTR [esp], eax 0x080484d9 <+60>: call 0x8048370 <puts@plt> 0x080484de <+65>: mov eax, DWORD PTR [esp+0x2c] 0x080484e2 <+69>: xor eax, DWORD PTR gs:0x14 0x080484e9 <+76>: je 0x80484f0 <main+83> 0x080484eb <+78>: call 0x8048350 <__stack_chk_fail@plt> 0x080484f0 <+83>: leave 0x080484f1 <+84>: ret
위 C언어 코드로 제작된 실행파일의 main 함수를 gdb로 뜯어낸 상태이다. 지금 필요한 행만 뜯어서 보자.
0x0804849d <+0>: push ebp 0x0804849e <+1>: mov ebp, esp 0x080484a3 <+6>: sub esp, 0x30 (코드 실행중...) 0x080484f0 <+83>: leave 0x080484f1 <+84>: ret
알다시피, 모든 프로그램 코드가 실행될 때 메모리 공간에 스택의 형태로 자리잡는다. 0번은 이러한 스택의 바닥이 될(여기서의 스택은 위에서 아래로 자란다) ebp를 먼저 스택에 삽입한다. 뒤이어 esp의 값을 ebp에 집어넣는데, 지금 상황에서는 ebp와 esp가 겹쳐져 있고, 현재 ebp의 값은 본 함수를 호출한 곳의 값이기 때문이다. 따라서 1번 과정을 통해서 ebp가 본격적으로 main함수의 코드를 담는 스택으로써의 베이스포인터 값을 할 수 있게 된다. 그리고 버퍼(본 코드에서는 16으로 잡았다)를 스택 안에 넣기 위해서 스택의 상위를 가리키는 esp값을 아래로 내려줄 필요가 있다. 6번의 코드가 그러하다. esp의 값을 0x30만큼 내림으로써 이제 그 안에서 버퍼라는 char형 배열이 존재할 수 있게 된다. 83번과 84번은 간단한데 이러한 방법으로 만들어진 스택을 해체하는 작업이다.
그런데 어디서 문제가 생기느냐, 바로 ebp위의 ret 값에서 문제가 생긴다. 모든 함수는 자신의 코드가 끝났을때(이러한 작업을 위 어셈블리코드에서 84번 함수가 진행한다) 다시 돌아가야 할 주소를 ebp에서부터 4바이트 위(ebp-4)에 저장한다. 우리는 앞선 예제에서 buf에 프로그래머가 예상한 값(16)보다도 많은 값을 넣을 수 있다는것을 알게 되었다.(strncpy가 아닌 strcpy로 썼다) 아까 흘러넘친 코드가 ret값을 채워버려 알 수 없는 미지의 세계로 프로그램 루틴을 점프시켰기 때문에 일어난 일이다. 당연히 단순한 프로그램이 자신의 영역이 아닌 메모리를 읽을 수는 없으므로 에러 당첨. 프로그램은 터지게 된다.
물론 단순히 프로그램 하나 다운시키는 것보다도 더 끔찍한 일은 발생할 수 있다. 가령 그것이 타인의 SetUID가 설정된(대표적으로 root)프로그램일때가 그러하다. 알다시피, SetUID가 설정된 프로그램을 실행하면 그 프로그램의 루틴동안에 한하여 EUID(일시적인 UID)가 SetUID를 설정한 유저의 것으로 변한다. 이제부터 저 위의 C코드를 root가 짜고 컴파일한 프로그램이라고 생각해보자. 그리고 단순히 ret주소를 오염시키는데에서 그치지 말고 bash의 실행주소를 끼워넣어보자.
적절한 bash의 주소를 적절히 ret값에 끼워넣었다면 그 프로그램은 bash를 실행시킬 것이다. 그리고 조용히 whoami를 쳐보자. root의 권한을 취득했다는 것을 알 수 있을 것이다이미 프롬프트 바뀐 걸로도 알 수 있겠지만 넘어가자
왜냐하면 모든 프로그램의 함수는 위 어셈블리어 코드의 84, 85번줄을 실행함으로써 정상적으로 끝이 난다(그중에서도 85번줄이 ret값의 주소를 실행시키는 것이다). 이렇게 끝이나면 SetUID의 임시 권한부여도 끝이 난다. 그렇기때문에 이러한 임시 권한부여를 끝내지 않고, 즉 프로그램을 끝내지 않고 그 진행을 탈취하여 프롬프트를 얻어낸다면? 아까 말했다시피 프로그램은 끝나지 않았으니 root 권한이다. 따라서 ret값을 실행시키므로써 끝나야될 SetUID 프로그램을 강제로 다른 프로그램(여기서는 bash)로 점프시켜버리므로써 제한된 권한을 충분히 활용할 수 있게 되는 것이다.
2 위 설명대로 했는데 안 되는데요?
당연하다. 위 공격 이론은 1988년도부터 발명된 이론이다. 혹시 다음과 같은 에러가 뜨지는 않았는가?
*** stack smashing detected ***: ./a.out terminated 중지됨 (core dumped) |
그렇다면 다시 위의 어셈블리어 코드 중 일부를 잠시 가져와보자.
0x080484ad <+16>: mov eax,gs:0x14 0x080484b3 <+22>: mov DWORD PTR [esp+0x2c],eax 0x080484b7 <+26>: xor eax,eax 0x080484de <+65>: mov eax,DWORD PTR [esp+0x2c] 0x080484e2 <+69>: xor eax,DWORD PTR gs:0x14 0x080484e9 <+76>: je 0x80484f0 <main+83> 0x080484eb <+78>: call 0x8048350 <__stack_chk_fail@plt> 0x080484f0 <+83>: leave
코드 실행에 앞서 gs:0x14에서 적절한 값을 업어와서 ebp와 다른 변수들이 자리잡는 공간(가령 앞선 buf 배열) 사이에 배치한다. 그리고 모든 코드의 실행이 끝날 무렵 다시금 배치해두었던 값과 다시 gs:0x14의 원본 값을 비교(xor) 해봐서 그 값이 오염되었으면 버퍼 오버플로가 발행한걸로 간주하고 프로그램 자체를 그자리에서 종료시켜버린다. 이 공격에서 가장 중요한 ret 구문을 실행조차 시키기 못하게 되는 것이다. 여기서 중간에 배치된 값을 canary 값이라고도 부르며 이를 Stack Smashing Protector라고도 부른다. 컴파일러에서 제공하는 방어책이다.
이것 말고도 아예 bash를 비롯한 프로그램의 주소값의 앞자리에 \00을 필수적으로 배치한다든지(앞서 말했듯 널이 검출되면 자동으로 거기까지가 문자열의 끝이다. 뒤에 뭘 써두던 배치되지 않는다) 아예 스택에서는 bash등의 프로그램이 실행되지 않게 한다든지[1] 또는 성공적으로 트리거해서 쉘을 땄음에도 불구하고 SELinux 로 인해 권한이 그대로 자신의 권한인 경우도 있는 등 방어책들이 무궁무진하고 뚫을 수 있는 방법도 무궁무진하다. 이러한 오버플로 공격과 방어의 발전과정은 보안이 창과 방패의 대결이라는 아주 단적인 증거이기도 하다.
3 그래도 한번 시도는 해보고 싶은데요
뭘 좋아할지 몰라서 예제를 준비했다.
#include #include void target() { printf("OverFlow!\n"); } void main(int argc, char** argv) { char buf[16]; strcpy(buf, argv[1]); printf("%s\n", buf); }
이제 다음 코드를 gcc로 컴파일할 때 -fno-stack-protector 옵션을 맨 뒤에 삽입해보자. 위에서 나왔던 스택보호장치를 배제하는 명령이다. 준비물은 다음과 같다. vi, gcc, gdb. 기본적으로 리눅스에 깔려있는 것들이니 준비할 필요는 없고 gdb에 경우에는 intel문법을 쓰고 싶으면 set disassembly-flavor intel을 입력하면 된다.
그리고 gdb로 저 타겟의 주소를 구하고 프로그램을 실행할때 인수로 적당한 길이의 더미값 + 타겟의 주소를 주면 오버플로라는 문자열이 등장할 것이다. 인수 입력에는 Perl이나 Python과 같은 스크립트 언어를 추천한다.
자세한 원리 등은 이 문서를 참조하면 도움이 될 것이다.[2]
4 return to Libc
리턴 투 라이브러리 기법이란, RET를 쉘코드의 주소 등 프로그램 외부에 있는 주소로 덮어 씌우지 않고, 프로그램 자체의 함수 주소로 덮어 씌우는 방법이다. 주로 시스템함수들인 system, evev*(execl, execv, execle, execve, execlp, execvp) 함수가 쓰인다.
공격 방법은 버퍼 + SFP(4byte) + RET(4byte) + 4byte(함수 프롤로그 우회) + 인자의 주소 (4byte)로 쓰인다. 예를 들면, RET주소에 system함수의 주소를 넣고, 인자로 "/bin/sh"라는 문자열이 존재하는 주소를 넣으면 쉘이 실행된다.