시스템 해킹

[시스템] Buffer overflow 공격 54~84p - 달고나

ruming 2021. 2. 4. 10:30

 

 

해커 지망자들이 알아야 할 Buffer Overflow Attack의 기초

Buffer overflow 공격

 

Buffer overflow 공격

실제 공격 방법을 시험해 보기 위해 vul.c 프로그램을 살펴보자.

 

그림 34. buffer overflow 취약점이 있는 vul.c

실행 시 주어지는 첫 번째 인자를 buffer라는 char형 배열에 복사한다. bound check를 하지 않는 strcpy()함수를 이용하고 있다. 1024바이트의 버퍼공간에 쉘 코드와 NCP로 채우고 4바이트는 main함수의 base pointer이므로 NCP로 채우고 다음 만나는 4바이트가 return address이므로 이곳에 쉘 코드가 있는 곳의 address를 넣어주면 쉘 코드를 실행시킬 수 있다. 컴파일 후 setuid bit를 걸어준다.

 

고전적인 방법

쉘 코드가 있는 곳의 address를 추측하는 것이다. vul의 실행 시점에 buffer 배열의 정확한 address를 알 수 없다. 시행착오를 거치며 쉘이 떨어질 때까지 계속 공격을 시도해야 한다. 쉘 코드가 실행되는 확률을 좀 더 높이기 위해서 또는 buffer를 채우기 위해서 NOP를 사용하는데 보통 0x90값을 많이 쓴다.

 

NOP

No Operation의 약자. 아무런 실행을 하지 않는다. 기계어 코드가 다른 코드와 섞이지 않게 하는 역할을 한다. 예를 들어 하나의 함수가 0xab로 끝나고, 다음에 나오는 함수가 0xcdef로 시작한다고 할 때, 0xab가 뒤에 나오는 0xcd를 만나 0xabcd라는 instruction과 0xef라는 두 개의 instruction으로 오해가 될 수 있다. CPU는 instruction의 값을 보고 instruction set에 정의된 연산을 수행한다. 이 instruction의 길이는 일정하지가 않아 그 값을 보고 해당 instruction이 몇 바이트짜리인지 인지한다. 따라서 instruction set에 해당 값이 있다면 하나의 instruction 단위를 거기서 잘라 인지하게 된다. instruction이 섞이지 않게 끊기 위한 목적으로 NOP가 사용된다. CPU는 NOP를 만나면 유효한 instruction을 찾기 위해 한 바이트씩 이동한다.

 

그래서 고전적인 방법에서는 쉘 코드 앞을 NOP로 채우고 return address를 NOP로 채워져 있는 영역 어딘가의 주소로 바꾸면 operation의 흐름은 NOP를 타고 쉘 코드가 있는 곳까지 흘러 들어갈 수 있게 된다.

 

그림 35,. return address에 쉘 코드의 주소 넣기

 

위 그림과 같이 스텍이 만들어져 있을 경우 return address에 쉘 코드가 위치한 정확한 주소 0xbffffa4c를 넣어준다면 좋을 것이다. 그러나 이 주소를 정확하게 찾기 힘들어 return address에는 NOP로 채워져 있는 0xbffffa30~0xbffffa4c사이의 값을 넣어주면 EIP는 return address가 가리키는 지점으로 가지만 NOP가 있기 때문에 한 바이트씩 증가해 쉘 코드를 만나는 0xbffffa4c에까지 자동으로 이동하게 된다.

 

그림 36. NOP 영역으로의 return

지금은 효과적이고 쉬운 방법이 많이 나와서 거의 사용되지 않는다.

 


 

환경변수를 이용하는 방법

*nic 계열의 쉘에서 환경변수는 포인터로 참조된다. 그래서 환경변수가 메모리 어딘가에 항상 저장되어 있다. 환경변수는 응용프로그램에서 참조하여 사용할 수 있기 때문에 putenv(), getenv() 같은 API 함수들도 많이 사용된다. 이런 특성을 이용해 공격자는 환경 변수를 하나 만들고 이 환경 변수에다 쉘 코드를 넣은 다음에 취약한 프로그램에 환경변수의 address를 return address에 넣어줌으로써 쉘 코드를 실행하게 할 수 있다. 이 방법은 overflow 되는 버퍼의 크기가 쉘 코드가 들어갈 만큼 넉넉하지 못할 경우에 매우 유용하게 사용된다.

 

그림 37. eggshell.c

 

malloc()로 만들어진 메모리 공간은 힙에 만들어진다. 힙은 스텍과 달리 낮은 메모리 주소에서 높은 메모리 주소 방향으로 할당된다. get_esp()함수는 어셈블리 코드를 이용해 ESP 레지스터가 가리키는 곳의 주소를 EAX레지스터에 넣는 역할을 하는데 이것 만으로 EAX 레지스터의 값이 리턴된다.

 

이 방법의 키 포인트는 대부분의 프로그램들의 스텍 시작점은 같다는 것이다. main()함수가 실행될 때 스텍 포인터는 이전 함수의 스텍 아래에 만들어질 것이다. 그리고 이전 함수의 base pointer를 저장하고 스텍 포인터가 main함수의 base pointer가 된다. main함수의 지역 변수들이 스텍에 쌓이기 시작한다. 같은 쉘 환경에서 프로그램이 실행되면 main함수에서 만나는 스텍 포인터는 같을 수 밖에 없다. 공격자는 스텍 포인터의 주소값을 알아내 거기서부터 return address를 유추한다.

 

eggshell.c는 egg 배열에 들어있는 데이터를 EGG라는 환경 변수로 등록한다. putenv()함수가 이 역할을 한다. 프로그램을 실행시킨 쉘이 가진 환경변수가 프로그램이 할당 받은 세그먼트에 저장되어 있다. EGG라는 환경 변수가 새로 생성됨으로 해서 스텍 세그먼트의 상단에 등록된 환경 변수들의 크기가 늘어나게 되고 main 함수의 base pointer는 그만큼 낮은 곳에 자리잡게 된다.

 

그림 38. eggshell을 실행하기 이전의 main함수의 base pointer

그림 38에서는 eggshell을 실행시키지 않았다. 이 쉘에는 EGG라는 환경변수가 없다. main함수가 시작되고 함수 프롤로그가 끝난 시점에 break point를 설정한 후에 vul을 실행시켰다. break point에 도달했을 때 ebp 값을 확인해보니 0xbffffa88에 자리잡았다는걸 알 수 있다.

 

그림 39. eggshell을 실행시킨 후 main함수의 base pointer

 

그림 39를 보면 base pointer가 0x800만큼 아래로 내려갔다. 0x800은 2048이고 eggshell.c에서 egg배열의 크기이다. 따라서 main함수에서 사용되는 스텍 역시 2048바이트 아래에서 시작될 것이다.

그림 40은 eggshell이 실행되기 이전의 main함수 실행시의 세그먼트 상태와 실행 후의 세그먼트 상태를 보여준다.

 

그림 40. eggshell 실행 전후의 세그먼트 상태

따라서 eggshell 실행 후 보여주는 stack pointer 값은 EGG라는 환경 변수가 위치한 범위 내의 어딘가를 가리키고 있게 된다. EGG 안에는 많은 NOP들이 들어있는데 이로 인해 구해진 stack pointer가 쉘 코드의 정확한 시작점을 가리키지 않더라도 instruction pointer가 흘러서 쉘 코드 시작점까지 도달할 수 있다. 이 값이 유용한 return address의 대체값으로 활용될 수 있다. 

 

그림 41. 환경 변수 EGG 확인

환경변수가 잘 들어가 있다. 이제 vul에서 어느 지점에 return address가 있는지 확인하고 거기에 구해진 stack pointer의 값을 넣어야 한다. 공격을 해보자. 우선 버퍼의 크기부터 가늠한다.

 

그림 42. vul의 buffer 크기 확인

vul.c 소스코드에서는 1024바이트의 배열을 만들었지만 gcc가 8바이트 dummy를 추가해 총 1032(0x428)바이트만큼 스텍이 확장되었다. 따라서 이전 함수의 base pointer(sfp)가 저장되는 4바이트를 고려한다면 1036바이트를 채우고 그 이후 4바이트에 return address가 들어가 있을 것이다.

 

그림 43. buffer overflow 공격 과정

root쉘이 떨어졌다. 쉘 스크립트 언어 perl을 이용했다. perl에 -e 옵션을 주면 one line command를 실행하라는 뜻이다. perl 전체를 `를 이용해 감싸고 one line command는 '를 이용해 감싼다. print 명령은 "안에 지정한 문자열을 출력하라는 뜻이다. 그래서 A를 1036개 쓰고 return address를 덮어쓸 주소 값 0xbffffa58을 쓴다. 바이너리 값을 쓰기 위해 \x를 1바이트 단위로 지정해준다. little endian방식 

 

그림 44-1. buffer overflow 공격 과정의 디버그

 

eggshell이 출력한 stack pointer는 return address를 덮어 쓸 EGG이 위치를 나타내고 있다. gdb를 이용해 취약 프로그램 cul을 실행하고 두 개의 breakpoint를 지정한다. breakpoint 1은 main함수의 프롤로그가 끝난 직후 ebp를 알아보고 EGG가 환경 변수 위치에 들어 있는지를 확인한다. breakpoint 2는 strcpy가 끝난 직후 overflow가 발생하고 난 다음 return address가 제대로 덮어씌워 졌는지를 알아본다. ebp를 확인해보니 eggshell이 출력한 0xbffffa58보다 작은 값이므로 EGG가 환경 변수 영역에 들어 있다는 것이 확인되었다.

 

그림 44-2. 메모리 상의 shellcode 확인

eggshell이 출력한 주소값 0xbffffa58에 0x90909090이 가득하다. NOP가 위치하고 있다. 이곳으로 EIP를 가리키게 하면 instruction pointer는 흘러가기 시작할 것이다. 0xbffffb98부터 64 word를 확인해보니 쉘 코드가 시작되는 지점이 확인됐다. 따라서 0xbffffa58을 return address에 대체시키면 쉘 코드가 실행될 것이다.

 

그림 44-3. return address값 확인

그림44-1에서 확인한 ebp(0xbfffee78)에는 이전 함수의 base pointer가 저장되어 있을 것이다. 0xbfffeea8이 들어있다. return address는 ebp보다 4바이트 위에 있을 것이므로 ebp+4(0xbfffee7c)를 확인해보니 0x4003456d가 있다. return address가 확인됐다. 그리고 실행을 계속해 buffer를 overflow시킨다. 그런 다음 return address를 확인한다. 0xbfffa58로 바뀌었다. 0xvfff58은 return address를 덮어쓰기 위해 구해낸 EGG의 위치이다. 덮어쓴 것을 확인했다. 따라서 main함수가 실행을 마치고 return될 때 EIP는 EGG가 있는 지점을 가리키게 될 것이고 쉘 코드가 수행되어 root권한의 쉘이 뜬다.

 


Return Into libc 기법

이 기법은 스텍 영역의 코드를 실행하지 못하게 하는 non-executable stack 보호 기법이나 일부 IDS에서 네트워크를 통해 쉘 코드가 유입되는 것을 차단하는 보호 기법을 뚫기 위한 방법으로 제안되었다. non-executable stack 기법은 스텍 영역에 있는 코드를 실행하지 못하게 하는 것이다. 앞에서 살펴본 stack overflow 기법들은 stack segment에 쉘 코드를 넣고 EIP 레지스터에 쉘 코드가 있는 지점의 주소를 넣음으로써 쉘 코드가 실행되도록 하는 기법들이었다. 따라서 non-executable stack 기법은 EIP 레지스터에 stack segment 영역의 주소가 들어가게 되면 에러 메시지를 출력하고 실행을 종료시켜버리거나 에러 메시지 없이 실행을 멈춰버리는 것이다.

일부 IDS는 네트워크 인터페이스에서 수신되는 데이터에 쉘 코드가 포함되어 있거나 혹은 비정상적으로 많은 양의 (0x90 같은) NOP가 포함되어 있다면 침입으로 간주하여 네트워크 연결을 종료시켜 버린다. 혹은 네트워크 상에 돌아다니는 데이터를 모니터링하여 침입을 탐지해 내기도 한다.

이런 공격 방어 기법들이 나오면서 막힌 고전적인 방법 대신에 나온 것이 이 기법이다.

 

버퍼를 overflow 시켜 return address를 조작하여 실행의 흐름을 libc 영역으로 돌려서 원하는 libc 함수를 수행하게 한다.

 

그림 45. Return-into-libc 동작 원리

buffer overflow 취약점이 있는 버퍼를 공격한다. 버퍼를 overflow시켜 buffer 위에 있는 return address 영역에 실행시키고자 하는 libc 함수의 주소를 넣어주는 것이다. libc 함수들은 공유메모리 영역(dynamic link)에 존재할 수도 있고 segment 내에 (static link) 존재할 수도 있다. return address 가 libc 함수의 주소로 바뀌었기 때문에 함수가 리턴되면서 지정된 libc 함수가 실행될 것이다. 지정된 libc 함수에 따라 다르지만 대부분의 함수들이 호출될 때 인자들을 필요로 하게 되는데 그 인자는 buffer나 이전 함수의 base pointer, argument가 있는 영역 어디든 될 수가 있다. 함수에 맞는 위치에 원하는 인자를 넣어주기만 하면 된다.

 

그림 46. overflow 취약점이 있는 vul.c

Return-into-libc 기법을 이요하기 위해서는 필요한 libc 함수의 주소와 필요한 argument의 구성을 알아야 한다. system()함수의 주소와 argument 구성을 알아내기 위해 다음 코드를 작성해 컴파일 한 후 disassemble 해보자.

 

그림 47. system()함수의 address

그림에서 보는 바와 같이 system()함수의 시작점은 0x4005ca4c이다. main()함수를 disassemble 했을 때 system 호출 지점은 0x8048258이지만 실행 시점에 공유 라이브러리를 로딩한 후의 system()함수의 시작점을 찾아야 한다. 따라서 실행 후 system()함수의 address를 찾았다. 이제 system()함수의 argument 구조를 알아보기 위해 system.c를 static link로 컴파일 해 disassemble해보자.

 

그림 48. system()함수의 내부 구조

유의할 점은 system()함수가 실행되는 시점이다. 일반적으로 system()함수를 수행할 때는 system()함수를 호출하여 수행할 것이다. call instuction은 return address를 스텍에 PUSH한 다음에 해당 함수의 시작점으로 이동한다. 하지만 여기서는 main()함수가 수행을 마치고 return 할 때 return 지점이 system()함수의 시작 지점이 된다. system()함수르 보면 함수 프롤로그가 끝나고 난 다음에 mov    0x8(%ebp),%eax를 수행한다. 이것이 바로 system()함수의 argument 처리 과정이다. argument가 있는 곳의 address는 ebp+8 byte 지점에 있고 이것을 eax 레지스터에 넣은 후 do_system을 호출한다. 따라서 "/bin/sh"가 있는 곳의 주소는 이 시점에서의 ebp+8지점이 되어야 한다.

그림 49. system("/bin/sh") 수행을 위한 스텍의 구조

따라서 0x80485e6에 있는 명령을 수행할 시점의 스텍 구조는 위처럼 되어 있으면 된다.

그림 50. vul.c의 main()함수의 스텍 구조

vul.c의 main()함수의 메모리 구조를 보여주는 그림50을 보면 main()함수가 수행을 마치고 return address를 따라 system()함수의 시작점으로 가게 될 것이고 argument를 얻어서 do_system을 호출하게 될 것이다. 그러므로 main()함수의 메모리 구조에 system()함수가 필요로 하는 데이터를 채워주면 된다. base pointer와 stack pointer의 변동을 고려해 두 메모리 구조를 겹쳐보면 위치가 파악이 된다. 

그림 51. overflow에 사용할 데이터 구조

마지막으로 "/bin/sh"를 메모리상에 올리고 그 주소를 알아와야 한다. 환경변수를 이용하면 된다.

그림 52. 환경변수 등록 및 address 알아내기

MYSHELL이라는 환경변수를 만든다. env.c를 이용해 해당 환경 변수가 위치한 메모리의 address를 알아낸다. MYSHELL이라는 환경변수가 등록된 상태의 쉘에서는 이 address가 항상 유효하다. 끝으로 버퍼의 크기를 파악해야 되는데 main()함수를 disassemble해서 계산해도 되고 시행착오를 겪어가며 알아낼 수도 있다.

그림 53. vul에 대한 공격 과정

쉘이 떨어졌다. 채워야할 버퍼의 크기를 바꿔가며 찾아냈다. main()함수를 정확하게 disassemble해 가면서 해도 찾을 수 있다. 공격에 사용된 데이터는 "ABDC"를 7번 반복해 총 28byte의 dymmy값과 system()함수의 address(0x4005ca4c), 4 byte dummy ("KKKK"), "/bin/sh"가 환경 변수로 등록되어 있는 곳의 address(0xbfffff7a)를 연결해 생성했다.

system()함수 내에서의 return address가 "KKKK"로 조작되어 있기 때문에 exit를 해서 쉘을 빠져나오면 Segmentation fault가 뜨는 것을 볼 수 있다.

 


belst's execl 방법

컴파일된 vul이 setuid 비트가 set되어 있지만 root의 쉘이 떨어지지 않았다. 이것은 system()함수가 단순히 호출만 하는 역할을 하기 때문이다. root의 권한을 얻어오려면 execl()함수를 사용한다.

buffer overflow 취약점을 가진 프로그램을 Return-into-libc 기법으로 overflow 시켜 공격한다. main()함수 return 시에 return 할 libc 함수는 execl이다. execl은 세 개의 argument를 가진다.

 

int execl(const char *path, const char *arg, ...);

 

첫 번째 인자는 실행할 프로그램의 full path와 실행파일 이름으로 구성된  문자열의 address, 실행 파일 실행시에 주어질 argument들, 그리고 NULL을 넣어주면 된다. 쉘을 띄우는 프로그램은 shell.c이다.

 

그림 54. shell.c

shell.c는 setreuid()와 setregid()를 이용해 소유자의 권한을 얻어오는 역할을 해준다. 이제 컴파일된 shell을 execl()을 이용해 실행시키면 된다.

 

공격에 사용될 데이터의 구조 - vul.c의 버퍼 구조는 그림53을 바탕으로 아래와 같이 추측할 수 있다.

그림 55. vul.c의 버퍼구조

이 구조의 버퍼에 다음과 같은 형식의 공격 코드를 집어 넣는다.

그림 56. vul.c의 공격 코드의 구성

이제 execl()함수가 있는 곳의 address를 찾고 argv[2]의 주소를 찾으면 된다. argv[2]는 그림54에서 본 쉘을 띄우는 프로그램의 실행명령이다. 

 

그림 57. execl()의 주소 찾기

execl의 주소가 0x400be520이라는 것을 알아냈다. execl+3은 0x400be523이 된다. 

 

쉘을 띄우는 프로그램

그림 58. vul을 디버깅하여 argv[2]의 주소를 찾음

main함수를 disassemble해 strcpy()를 호출하기 직전에 break point를 설정하고 실행시 argument를 넣어주었다. argument는 버퍼를 채울 크기와 두 번째 argument로 쉘을 띄울 프로그램 실행 명령을 주었다. " ./shell"에서 앞에 한 칸 띄어주어야 argument가 분리된다.

main()함수의 base pointer, ebp를 기점으로 argv[0]의 주소(0xbffffab4)를 팢았다. argv[0]의 주소를 따라가 각 포인터들이 argument를 확인해보니 각 지점이 각각의 argument를 가리키고 있다는 것을 알 수 있다. argv[0]의 주소로부터 argv[2]의 주소는 계산해보면 argv[0]의 주소 뒤 8byte 지점(0xbffffabc)에 있다는 것을 알수있다. 따라서 argv[2]의 주소 -8은 argv[0]의 주소와 같은 값(0xbffffab4)이다.

 

그림 59. vul을 execl()로 공격하기

루트 쉘이 떨어졌다. 

 

공격 원리

그림 60-1. execl()의 dissassemble
그림 60-2. eecl()의 dissassemble

libc내에서 execl함수의 이름을 찾기 위해 main()함수를 disassemble해보니 execl이란 이름이어서 disassemble했다. execl+3 하는 이유는 execl()함수가 시작되고 함수 프롤로그 작업을 하지 않게 하기 위해서이다. 따라서 <execl+3>이 있는 지점의 주소를 return address로 지정해 주었다.

&argv[2]-8을 하는 이유는 이 값이 들어가는 위치가 이전 함수의 base pointer가 들어가는 지점이다. vul의 main()함수가 return하면 여기에 넣어 둔 &argv[2]-8 값이 ebp 레지스터에 들어간다. 그리고 execl+3부터 실행이 시작되기 때문에 함수 프롤로그가 수행되지 않아 ebp가 esp값을 가지지 않고 그대로 실행된다. 따라서 execl()함수는 &argv[2]-8 = 0xbffffab4를 base pointer로 삼고 실행을 한다.

 

execve()를 호출하기 직전에 다음 그림을 볼 수 있다.

ebp 레지스터가 가리키는 곳의 8 byte 뒤의 값을 스텍에 집어 넣고 execve()를 호출한다. 실행시킬 명령이 들어가는 것이다. execl함수는 base pointer + 8 지점에 실행할 명령이 들어가 있다. 그 구조는 다음과 같다.

argv[2]-8에 있는 주소를 넣어뒀으므로 +8을 하면 argv[2]의 주소 0xbffffabc를 가리키게 되고 여기에는 "./shell"이라는 쉘 프로그램 실행 명령이 들어있으므로 shell을 실행하게 된다.

 

장점

이 기법은 non-executable stack 보호 기법을 회피해 특정 명령을 수행할 수 있다. 스텍 영역에 쉘 코드를 집어넣지 않고 실행을 할 수 있게 된다. buffer 크기에 제약을 받지 않기 때문에 쉘 코드를 넣을 충분한 buffer를 가지지 않은 취약한 프로그램도 공격할 수 있다.