나무모에 미러 (일반/어두운 화면)
최근 수정 시각 : 2024-08-04 03:18:22

버퍼 오버플로/스택

파일:상위 문서 아이콘.svg   상위 문서: 버퍼 오버플로
1. 개요2. 설명3. 위 예제가 실행 안 되는 이유4. 실행 가능한 예제5. Return to libc6. Return Oriented Programming

1. 개요

버퍼 오버플로의 종류에는 스택이 있지만 여기서는 스택에 관하여 설명한다.

본 문서는 x86 어셈블리어C언어, 메모리구조에 대한 기초적인 지식이 있는 전공자를 대상으로 한 문서다.

2. 설명

우선 간단한 C언어 프로그램을 생각해 보자.
#!syntax cpp
#include <string.h>
#include <stdio.h>
void main(int argc, char** argv)
{
	char buf[16];
	strcpy(buf, argv[1]);
	printf("%s\n", buf);
}

단순히 실행시 인수로 받은 문자열을 버퍼에 옮기고 버퍼를 출력하는 코드이지만 여기에는 심각한 보안적 결함이 있다. 가령 ./a.out 1, ./a.out 12, ./a.out 123, 하는식으로 점차 그 인수를 늘려보자, 분명 버퍼는 16칸을 잡아놨지만 그걸 넘어서도 출력이 되고, 어느 순간부터는 에러를 뿜으며 프로그램이 중단된다. 이는 C언어가 문자열의 끝을 무조건 \0(NULL 문자)까지로 인식한다는 점과 strcpy 함수가 얼마나 문자열을 버퍼로 복사시킬지를, 즉 문자열의 길이를 제한하지 않는다는 점에서 발생한 문제이다. 이렇게 넘처 흐른 버퍼가 왜 에러를 불러일으키는지 아래 코드를 보자.
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만큼 내림으로써 이제 그 안에서 배열이 존재할 수 있게 된다. 83번과 84번은 간단한데 이러한 방법으로 만들어진 스택을 해체하는 작업이다.

그런데 어디서 문제가 생기느냐, 바로 ebp위의 ret 값에서 문제가 생긴다. 우리가 작성한 코드는 컴파일 시 바이트 단위의 기계어로 치환되어 메모리에 자리잡게 된다. 모든 함수는 호출될 때 자신의 코드가 끝나고(이러한 작업을 위 어셈블리코드에서 84번 줄이 진행한다) 다시 돌아가야 할 주소를 스택에 저장한다. 그 주소는 이 함수를 호출한 함수에서 함수 호출 바로 다음 주소이다. 우리는 앞선 예제에서 buf에 프로그래머가 예상한 값(16)보다도 많은 값을 넣을 수 있다는 것을 알게 되었다.(strncpy가 아닌 strcpy로 썼다) 아까 흘러넘친 코드가 ret값을 채워버려 알 수 없는 미지의 세계로 프로그램 루틴을 점프시켰기 때문에 일어난 일이다. 당연히 단순한 프로그램이 자신의 영역이 아닌 메모리를 읽을 수는 없으므로 에러 당첨. 프로그램은 터지게 된다.

그러나 ret값을 적절히 조작하여 원하는 코드를 실행시키는 이른바 코드 인젝션(Code Injection)을 할 수 있다! 만약 이 함수에서 반환값을 저장하는 레지스터인 rax에 "hello"를 집어넣고 싶다면 기존 ebp에 있던 기존 함수의 특정 위치를 esp에 저장하고, rax에 "hello" 값을 저장한 다음, ret를 하면 코드 주입이 완료된다. 이를 구현하는 기계어 코드는 다음과 같다.

48 89 ec		mov rsp, rbp
48 b8 00 00 00 6f 6c	movabs rax, 0x68656c6c6f000000
6c 65 68
c3			ret

rax에 들어가는 16진수 값은 단순히 "hello"를 아스키 코드로 치환한 값이다. ret 명령어는 스택에서 pop을 하여 pop한 값이 가리키고 있는 주소로 이동한다. 첫 줄에서 rsp에 기존 함수의 주소인 rbp를 저장했으니 이 코드가 끝나고 나면 원래 주소로 돌아가게 된다. rax에 "hello"가 저장된 것도 모른 채 말이다. 이 사이트에서 어셈블리어를 기계어로, 또는 역방향으로 간단히 변환이 가능하다.

물론 단순히 프로그램 하나 다운시키는 것보다도 더 끔찍한 일은 발생할 수 있다. 가령 그것이 타인의 SetUID가 설정된(대표적으로 root)프로그램일때가 그러하다. 알다시피, SetUID가 설정된 프로그램을 실행하면 그 프로그램의 루틴동안에 한하여 EUID(일시적인 UID)가 SetUID를 설정한 유저의 것으로 변한다. 이제부터 저 위의 C코드를 root가 짜고 컴파일한 프로그램이라고 생각해보자. 그리고 단순히 ret주소를 오염시키는데에서 그치지 말고 bash의 실행주소를 끼워넣어보자.

적절한 bash의 주소를 적절히 ret값에 끼워넣었다면 그 프로그램은 bash를 실행시킬 것이다. 그리고 조용히 whoami를 쳐보자. root의 권한을 취득했다는 것을 알 수 있을 것이다이미 프롬프트 바뀐 걸로도 알 수 있겠지만 넘어가자

왜냐하면 모든 프로그램의 함수는 위 어셈블리어 코드의 84, 85번줄을 실행함으로써 정상적으로 끝이 난다(그중에서도 85번줄이 ret값의 주소를 실행시키는 것이다). 이렇게 끝이나면 SetUID의 임시 권한부여도 끝이 난다. 그렇기때문에 이러한 임시 권한부여를 끝내지 않고, 즉 프로그램을 끝내지 않고 그 진행을 탈취하여 프롬프트를 얻어낸다면? 아까 말했다시피 프로그램은 끝나지 않았으니 root 권한이다. 따라서 ret값을 실행시키므로써 끝나야될 SetUID 프로그램을 강제로 다른 프로그램(여기서는 bash)로 점프시켜버리므로써 제한된 권한을 충분히 활용할 수 있게 되는 것이다.

3. 위 예제가 실행 안 되는 이유

다음과 같은 에러가 뜨면서 실행되지 않는다.
*** stack smashing detected ***: ./a.out terminated
중지됨 (core dumped)

왜냐하면 이 문제는 1988년에 제기된 문제라서 이미 컴파일러에서 대응하기 때문이다. 위의 어셈블리어 코드 중 일부를 잠시 가져와보자.
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등의 프로그램이 실행되지 않게 한다. DEP(Data Execution Prevention/데이터 실행 방지) 라고 하며 해당 영역의 실행권한을 제거한다. 자세히 말하자면, 실행 가능한 코드가 있는 메모리는 편집을 금지하도록, 편집 가능한 메모리는 실행을 금지하도록 하게끔 CPU 차원에서 막는다. 이를 W^X policy 라 한다.(Write XOR eXecute policy, 즉 권한을 1, 무권한을 0으로 표현했을 때, 쓰기 권한과 실행 권한을 XOR을 취하면 1이 나와야 한다는 보호 정책이다.) 또는 성공적으로 트리거해서 쉘을 땄음에도 불구하고 SELinux 로 인해 권한이 그대로 자신의 권한인 경우도 있는 등 방어책들이 무궁무진하고 뚫을 수 있는 방법도 무궁무진하다. 이러한 오버플로 공격과 방어의 발전과정은 보안이 창과 방패의 대결이라는 아주 단적인 증거이기도 하다.

카나리를 우회하는 방법에는 크게 두 가지가 있다. 첫 번째 방법은 그냥 카나리 값을 얻어내거나 때려맞추거나 하는 등 그 값을 알아내는 것이다. 때려맞추는건 무슨 소린가 싶겠지만 리눅스에서는 포크된 프로세스가 부모 프로세스와 카나리 값을 공유하기 때문에 가능한 것으로, 포크 기반으로 멀티스레딩을 구현하는 서버 프로그램을 공략할 때 많이 사용한다. 한 바이트씩 오버플로우시켜가면서 크래시가 나지 않는 값을 찾는 것을 워드 길이번 반복해 지금 돌고 있는 서버의 카나리 값을 찾아낼 수 있고, 이 이후로 값을 집어넣으면 카나리는 일단 넘어갈 수 있다. 단 이는 프로그램이 카나리에 무조건 들어가는 널 바이트(0x00)를 쓰고도 중지되지 않는 종류의 취약한 함수를 사용할 때만 쓸 수 있는 방법이다.

두 번째 방법은 아예 카나리를 한참 넘어서서 stack smashing detected를 띄우는 위 예외 처리기의 주소 자체를 바꿔버리는 것이다. 주로 윈도를 대상으로 하는 익스플로잇에서 이런 식으로 카나리를 뚫어버리는 공략법이 성행한다.

4. 실행 가능한 예제


#!syntax cpp
#include <string.h>
#include <stdio.h>

void target()
{
	printf("OverFlow!\n");
}

int 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과 같은 스크립트 언어를 추천한다.

이원리 설명[1]

5. Return to libc

NX bit가 설정되어 있고 버퍼 오버플로를 사용하여 할 수 있는 방법 중 하나인 Return to libc 는 call stack에 있는 return address 와 여러 데이터를 변경하여 PC를 이미 실행권한을 가지고 있고 여러가지 C 에서 제공해주는 함수들이 정의가 되어있는 라이브러리(libc) 공간에 있는 코드로 가리키게끔 해서 공격자가 원하는 코드를 실행시킬 수 있는 공격 기법이다. 이 방법을 사용해 주로 부르는 함수는 다른 프로그램을 실행시킬 수 있는 함수들인 system, evev*(execl, execv, execle, execve, execlp, execvp) 함수들이다.
공격할때 payload 의 구조는 dummy(버퍼) + SFP(Stack Frame Pointer) + return address + next RET [+ arg1 [+ arg2 [...]]] + ... 의 구조로 되어있다.
예를 들면, system("/bin/sh")를 실행시키고 싶을 경우에는 dummy + SFP + &system + dummy address + &"/bin/sh"(&는 해당하는 주소) 식으로 구성을 해주면 된다.
이를 사용해서 RTL Chaining 를 하여 하나의 함수 뿐만 아니라 여러 함수를 차례차례 실행을 시켜나갈수 있고 더 응용해서 ROP (Returned Oriented Programming)를 할수가 있다.

6. Return Oriented Programming

Return Oriented Programming은 위에서 설명한 코드 인젝션을 막는 DEP를 우회하는 공격 기법이다. DEP가 활성화되어있는 경우에는 DEP에 의해 스택에 실행가능한 코드를 집어넣어봤자 편집이 가능한 코드는 실행이 불가능하기 때문에 코드 인젝션은 통하지 않는다. 이를 우회하기 위해 실행 가능한 코드에서 공격자가 원하는 코드를 만드는 기법이다. 모든 함수는 반드시 ret 명령어가 마지막에 존재하는데, 이를 이용하여 공격 대상 함수의 스택 프레임에 적절한 주소를 차례대로 입력하면 그 주소에 있는 코드들이 차례대로 실행된다. 비유를 하자면, 신문에서 한글자 한글자 가져와서 문장을 만드는 것에 비유할 수 있겠다. 앞에서 서술한 대로 코드들은 각자 주소를 가지고 있는데, 이 주소로만 이용해서 코드를 만든다고 한다면 기존에 선언된 함수들을 온전히 사용해야 하고, 공격자가 원하는 코드를 못 만들 수도 있는데, 함수들의 시작 주소로만 가지고 만들어야 한다는 제한은 없다. 즉 함수들의 중간지점의 주소로 간다고 해도 문제는 없다. 예를 들자면, 다음과 같은 기계어가 있다고 치자.
8d 4c 24 04		lea    ecx,[rsp+0x4]
83 e4 f0		and    esp,0xfffffff0
c3 			ret

이런 함수가 있다 치면, 이 함수의 시작 주소는 8d를 가리키고 있을 것이다. 그런데 주소를 일부러 +2바이트 (+0x10) 지점의 주소를 스택 프레임에 입력하면, 즉 24를 가리키는 주소를 입력하면, 컴퓨터는 이를 어떻게 해석할까?
24 04 			and    al,0x4
83 e4 f0 		and    esp,0xfffffff0
c3			ret

첫번째 줄의 명령어가 완전 딴판이 되었다! 그러나 실행이 가능하다는 점은 변함없다. 이런 식으로 주소를 신중히 조작해서 얻은 유용한 코드를 '가젯'이라 부르고, 이 가젯들을 모아 적절히 나열해서 스택 프레임에 차례대로 입력하면 공격자가 원하는 코드를 완성할 수 있다. 리눅스 환경에서 이를 돕기 위한 peda, gef 등 디버깅 어시스턴트 플러그인이 있다. 이런 플러그인의 도움 없이 하기 위해서는 폰 노이만 급의 기계어 독해 실력을 보유하고 있어야 한다.

[1] 와우해커의 달고나 라는 사용자가 작성한 문서로 기초부터 설명되어 있다.