Buffer Overflow의 이해 (2)

옥영진·2021년 5월 13일
0

BOF

목록 보기
4/4

쉘 코드 만들기

쉘 코드는 바이너리 형태의 기계어 코드로, 쉘 코드를 만들어야 하는 이유는 실행중인 프로세스에 어떠한 동작을 하도록 코드를 넣어 실행 흐름을 조작할 것이므로 실행 가능한 상태의 명령어를 만들어야 하기 때문이다.
기계어에 능통하다면 기계어로 바로 작성하면 되지만 그렇지 않기 때문에 C로 프로그램 작성 후 컴파일러가 변환시켜준 어셈블리 코드를 최적화 시켜 쉘 코드를 생성할 것이다.

쉘 코드를 만들기 위해서 우선 쉘 실행 프로그램을 작성한다. 그 후 어셈블리 코드를 얻고, 불필요한 부분을 빼고 라이브러리에 종속적이지 않도록 일부 수정한 후에 바이너리 형태의 데이터를 구할 것이다.

쉘 실행 프로그램

쉘 상에서 쉘을 실행시키려면 /bin/sh 와 같은 명령어를 입력해야 한다. 쉘 실행 프로그램 역시 이러한 명령을 내리는 것과 똑같이 작성하면 된다.


쉘을 실행시키기 위해 execve() 함수를 사용했는데, 이 함수는 바이너리 형태의 실행 파일이나 스크립트 파일을 실행하는 함수이다. 첫 번째 인자는 파일이름, 두 번째 인자는 함께 넘겨줄 인자들의 포인터, 세 번째 인자는 환경 변수 포인터이다. 이 프로그램을 컴파일하여 바이너리 코드를 얻어야 하는데, execve() 함수 때문에 컴파일 될 때 linux libc와 링크되게 된다. 따라서 static library 옵션을 주어 컴파일해야 한다.

응용 프로그램 실행 시 실제 프로그램 동작에는 많은 명령어들이 사용된다. 그 중에서는 공통적으로 사용되는 명령어들이 있다. 공통적으로 사용되는 명령어들이 각 프로그램에 기계어 코드로 포함된다면 저장 공간의 낭비가 심할 것이다. 그래서 운영체제가 많이 사용되는 함수들의 기계어 코드들을 가지고 있고, 다른 프로그램들이 코드를 빌려서 사용한다.
프로그래머들은 기능을 직접 구현하지 않고 호출만 하면 되고, 컴파일러도 호출하는 기계어 코드만 생성하면 된다. 이러한 기능들은 라이브러리라고 하는 형태로 존재하며 리눅스에서는 libc라는 라이브러리에 들어 있고, .so 또는 .a 확장자를 가진 파일로 존재한다. 윈도우즈에서는 DLL(Dynamic Link Library) 파일로 존재한다.

하지만 운영체제의 버전이나 라이브러리 버전에 따라 호출 형태나 링크 형태가 달라질 수 있는데, 이러한 영향을 받지 않기 위해 실행 파일에 직접 기계어 코드를 포함시키는 방법이 Static Link Library이다. 물론 실행 파일의 크기는 커지게 될 것이다.

이제 sh.c 프로그램에서 호출하는 execve() 함수의 내부를 살펴보기 위해 static link library 형태로 컴파일을 할 것이다.

-static 옵션을 사용하여 컴파일하고 objdump로 기계어 코드를 출력했는데, grep으로 execve() 함수 부분만 필터링한 결과이다. 덤프된 코드는 세 개의 column으로 출력되는데, 왼쪽부터 주소, 기계어 코드, 어셈블리 코드이다.

execve() 함수 내에서 보면 함수 프롤로그 과정을 거친 후, 함수 호출 이전에 stack에 쌓인 인자값들을 검사하고 이상이 없으면 인터럽트를 발생시켜 시스템 콜을 한다.


execve() 함수는 인터럽트를 발생시키기 이전에 범용 레지스터에 각 인자들을 집어넣어줘야 한다. 위 그림 중에서 아래와 같은 명령들을 수행하는 것이다.

mov 0x8(%ebp),%edi
mov 0xc(%ebp),%ecx
mov 0x10(%ebp),%edx

각각 ebp 레지스터가 가리키는 곳의 +8 byte 지점의 값을 edi 레지스터에, +12 byte 지점의 값을 ecx 레지스터에, +16 byte 지점의 값을 edx 레지스터에 넣으라는 의미이다.

ebp +0 byte 지점은 이전 함수의 ebp(base pointer)가 들어가 있을 것이고, +4 byte 지점은 return address가, 그리고 +8, +12 +16 byte 지점은 각각 execve() 함수가 호출되기 이전 함수에서 execve() 함수의 인자들이 역순으로 PUSH되어 들어가 있을 것이다.

그런 다음 eax 레지스터에 11을 넣고 int $0x80을 하였다. 이 과정이 system call 과정이다. int $0x80은 OS에 할당된 인터럽트 영역으로 system call을 하라는 뜻으로, 이를 호출하기 이전에 eax 레지스터에 시스템 콜 벡터를 지정해 줘야 하는데, execve() 함수에 해당하는 값이 11(0xb)인 것이다.

즉, 11번 시스템 콜을 호출하기 위해 각 범용 레지스터에 값들을 채우고 시스템 콜을 위한 인터럽트를 발생시킨 것.

main() 함수에서는 execve() 함수를 호출하기 전에 세 번의 PUSH를 하는데, 이는 execve() 함수의 인자로 넘겨주는 값이라고 짐작할 수 있다.

  • movl $0x808ef88,0xfffffff8(%ebp) : /bin/sh 라는 문자열이 있는 곳의 주소(0x8083f88)를 ebp 레지스터가 가리키는 곳의 -8 byte 지점(0xfffffff8)에 넣는다.
  • movl $0x0,0xfffffffc(%ebp) : ebp -4 byte 지점(0xfffffffc)에는 0을 넣는다.

위 두 과정은 sh.c에서

shell[0] = "/bin/sh";
shell[1] = NULL;

에 해당한다. 그리고 이 값들을 PUSH하기 시작한다.

  • push $0x0 : NULL PUSH
  • lea 0xfffffff8(%ebp),%eax push %eax : ebp+8의 주소를 eax 레지스터에 넣은 다음, eax 레지스터를 PUSH한다.
  • pushl 0xfffffff8(%ebp) call 804d9f0 <__execve> : ebp+8의 값을 PUSH하고 execve() 함수를 호출한다.


위 과정이 끝난 후 segment 내의 모습이다.

shell 변수는 char 형의 배열 이름으로 char 들이 위치한 곳을 가리키고 있을 것이다. ebp-4와 ebp-8이 바로 포인터들이 모여 있는 곳이다.

쉘을 띄우기 위한 과정이 아래와 같이 된다.

  1. stack에 execve() 함수를 실행하기 위한 인자들을 배치.
  2. NULL과 인자값의 포인터를 stack에 넣기.
  3. 범용 레지스터에 이 값들의 위치를 지정함.
  4. interrupt 0x80을 호출하여 system call 12를 호출.

buffer overflow 공격 시점에서는 /bin/sh가 어느 지점에 저장되어 있다는 것을 기대하기도 어렵고 있다고 하더라도 저장되어 있는 메모리 공간의 주소를 찾기도 어렵기 대문에 직접 넣어주어야 한다. 이와 같은 역할을 하는 코드는 아래와 같다.

push $0x0		// NULL을 넣어준다
push '/sh\0'		// /sh\0 문자열의 끝을 의미하는 \0
push '/bin'		// /bin 문자열. 위와 합쳐서 /bin/sh\0가 된다.
mov %esp, %ebx		// 현재 stack 포인터는 /bin/sh\0를 넣은 지점이다.
push $0x0		// NULL을 PUSH
push %ebx		// /bin/sh\0의 포인터를 PUSH
mov %esp, %ecx		// esp 레지스터는 /bin/sh\0의 포인터의 포인터다
mov $0x0, %edx		// edx 레지스터에 NULL을 넣어 줌
mov $0xb, %eax		// system call vector를 12번으로 지정. eax에 넣는다
int $0x80		// system call을 호출하라는 interrupt 발생

push '/sh\0'push '/bin'은 개념적으로 나타낸 것이기 때문에 실제 어셈블리 코드로 만들려면 아래와 같이 해 줘야 한다.

push $0x0068732f
push $0x6e69622f

문자를 16진수로 바꾸고 little endian 순서로 나타낸 것이다. 이 코드를 작성한 c 프로그램은 아래와 같다.

NULL의 제거

c언어에서는 char형 변수에 바이너리 값을 넣을 수 있다. char c = "\x90" 과 같은 형태로 값을 넣어주면 컴파일러는 "\x90"을 문자열이 아닌 16진수 90으로 인식하여 1byte 데이터로 저장한다. 그래서 기계어 코드로 만들어진 쉘 코드를 char형 문자열로 전달할 것이다.
그런데, push 0x0 과 같은 어셈블리 코드는 기계어 코드로 6a 00 이다. 이것을 문자열 형태로 전달하려면 "\x6a\x00"으로 전달해야 하는데, 문자열에서는 0의 값을 만나면 그것을 문자열의 끝으로 인식하게 되므로, \x00인 기계어 코드가 생기지 않도록 만들어줘야 한다.

이러한 문제점을 해결하여 위의 어셈블리 코드를 다시 작성하면 아래와 같다. 차이점은 주석에 * 표시를 해놓았다.

xor %eax, %eax		// *같은 수를 XOR하면 0이 된다. 즉 NULL이다.
push %eax		// *NULL을 PUSH
push $0x68732f2f		// /sh\0 문자열의 끝을 의미하는 \0
push $0x6e69622f		// /bin 문자열. 위와 합쳐서 /bin/sh\0가 된다.
mov %esp, %ebx		// 현재 stack 포인터는 /bin/sh\0를 넣은 지점이다.
push %eax		// *NULL을 PUSH
push %ebx		// /bin/sh\0의 포인터를 PUSH
mov %esp, %ecx		// esp 레지스터는 /bin/sh\0의 포인터의 포인터다
mov %eax, %edx		// *edx 레지스터에 NULL을 넣어 줌
mov $0xb, %al		// *system call vector를 12번으로 지정. al에 넣는다.
int $0x80		// system call을 호출하라는 interrupt 발생

덤프한 코드를 확인해보면 xor %eax, %eax(8048304) 이후 부터 int $0x80(804831b) 사이의 기계어 코드에는 00이 없어서 NULL로 인식될 염려가 없게 되었다.

이제 남은 것은 이것을 문자열화 시켜서 char형 배열에 16진수 형태의 바이너리 데이터를 전달할 것이다. 그러기 위해서는 \x90 형식으로 바꾸어야 한다. 필요한 기계어 코드는 아래와 같다.

위 기계어 코드를 문자열 배열에 넣기 위해 가공한 sh03.c 프로그램은 아래와 같다.

disassemble해 보면 위와 같은 코드를 확인할 수 있는데, 함수 프롤로그 수행 후 다음과 같은 코드를 수행한다.

먼저 ebp -4 byte 지점의 address를 eax 레지스터에 넣고 8을 더한다. 이 과정은 sh03.c에서 ret = (int *)&ret + 2;에 해당한다. ret 포인터 변수의 address를 8바이트 상위의 주소로 만드는 것인데, return address가 들어 있는 곳이다. (*ret, sfp, ret 순서) $0x80493d4 에는 char sc[] 데이터가 있는 지점이다. 따라서 main() 함수가 종료되고 EIP는 return address가 가리키는 지점에 있는 명령을 가리키게 되면서 삽입한 쉘 코드를 수행하게 되는 것이다.

쉘을 획득했지만, root 권한을 얻지 못한 상태이다. root 권한은 setuid 비트가 set되어 있는 프로그램을 오버플로우시켜 쉘 코드를 실행시키면 root의 쉘을 얻어낼 수 있다.

이와 같이 setuid를 set해도 root 권한의 쉘을 획득하진 못한다. 왜냐하면 root 소유의 프로그램의 권한을 그대로 상속받지 못했기 때문인데, 쉘 코드에 소유자의 권한을 얻어내는 기능이 필요하다.

코드를 보면 setreuid() 함수를 사용했는데, 이는 프로그램 소유자의 권한을 얻어올 수가 있게 되는 것이다. 따라서 쉘 코드에 setreuid() 함수가 하는 기계어 코드를 추가해 줘야하는데, 앞에서 execve() 함수 기계어 코드를 찾는 방법과 동일하게 static으로 컴파일하여 인터럽트를 호출하는 부분을 찾으면 된다. 기계어 코드와 어셈블리 코드는 아래와 같다.

"\x31\xc0"   // xorl %eax,%eax 
"\x31\xdb"   // xorl %ebx,%ebx 
"\xb0\x46"   // movb $0x46,%al 
"\xcd\x80"   // int $0x80 

이 코드를 이전 쉘 코드 앞부분에 추가해주기만 하면 된다.

수정한 sh03-1.c 프로그램을 컴파일한 후에 root 권한과 setuid 설정 후 일반 사용자에서 프로그램을 실행 시키면 root 권한 쉘을 획득할 수 있게 된다.

profile
안녕하세요 함께 공부합시다

0개의 댓글