본문 바로가기
System Hacking/해커스쿨 F.T.Z

해커스쿨 F.T.Z 쉘코드(Shell Code) 실습

by En_Geon 2020. 2. 22.

level 11을 하기에 앞서 쉘코드에 대해 배우고 간단한 실습을 하는 시간을 가진다.

쉘코드에 대해서 처음 알았고 구글링을 통한 실습을 했다.

어셈블리 언어에 대해서는 Break Time에서 아주 조금이나마 들여다보았고 나중에 해야 할 것으로 생각하고 검색조차 해보지 않은 상태에서 하는 실습이므로 이해한 만큼 작성한 것이니 이해하고 읽어주길 바란다.

 

 

쉘코드(Shell Code)

 

쉘코드란 쉘을 실행시키는 기계어 코드다. 주로 BOF, FSB 공격에서 메모리에 쉘코드를 올리고 Return Address를 쉘코드가 저장된 메모리의 주소로 덮어씌우면 쉘을 실행하게 한다.

 

많은 사람이 쉘코드를 만들어 놓아서 검색만으로도 쉘코드를 얻을 수 있다. 하지만 공격할 때 쉘코드가 없고 검색을 하지 못하는 상황이라면 쉘코드를 만들어야 한다.

 

쉘코드를 만들기 위해서는 어셈블리 언어를 알아야 한다. 어셈블리어는 기계어와 1:1 대응을 한다. 그래서 쉘코드를 어셈블리어로 작성하고 그에 대응하는 기계어 코드를 뽑아내면 쉘코드가 된다.

 

기계어 코드를 뽑아내기 위해서는 그나마 쉽게 배운 C언어로 코드 작성 후 컴파일 하고 디스어셈블 하여 사용한 라이브러리 함수가 어떤 시스템 콜을 호출하고 어떤 방식으로 인자를 넘기는지 분석해야 한다. 그리고 어셈블리 언어로 시스템 콜을 구현하고 어셈블리 언어에 해당하는 기계어 코드를 뽑아낸다.

 

 

helloworld 쉘코드 실습

 

쉘코드를 만들기 전 helloworld로 간단한 실습과 함수가 어떤 시스템 콜을 호출하고 어떤 방식으로 인자를 넘기는지 분석해본다.

 

hello world

 

helloworld 코드를 입력하고 컴파일한다.

 

 

strace

 

strace는 프로그램이 실행되는 동안 호출하는 system call을 추적할 수 있는 툴이다. 여기에 더불어 프로세스가 받은 signal에 대한 정보도 얻을 수 있다. strace를 이용하는 가장 간단한 경우는 strace 다음에 실행시킬 프로그램을 명시하는 것으로 그러면 프로그램이 종료될 때까지의 시스템콜 혹은 시그널 정보를 얻을 수 있게 된다.

 

리눅스 상에서 작동하는 C언어 코드들은 모두 system call을 주어진 임무를 수행한다. 그러므로 프로그램의 시스템 콜이 호출되는 것을 추적할 수 있다면, 프로그램을 디버깅하거나 제대로 작동되는지에 대해 중요한 정보를 얻을 수 있다.

 

strace로 helloworld의 system call을 추적해 어떤 system call을 사용하는지 알아본다.

 

strace 실행

 

위에서 가장 간단하게 사용하는 strace 프로그램을 적어 실행한다.

 

strace 실행 결과

 

이렇게 실행되고 여기서 필요한 건 printf 함수는 write 시스템 콜을 호출한다는 것이다.

 

system call write

 

system call의 번호가 기재되어 있는 "unistd.h"를 찾아서 열어보고 write의 시스템 콜 번호를 알아낸다.

 

system call numbers

 

write의 시스템 콜 호출 번호는 4다.

write 시스템 콜을 호출하는 어셈블리 코드를 작성한다.

 

write 시스템 콜

 

초기화된 전역변수를 저장하는 segment인 data segment에 "hello world\n" 문자열을 선언한다.

 

eax에 write 함수의 시스템 콜 호출 번호인 4를 넣고 ebx에 write 함수의 첫 번째 인자인 fd(file descriptor)에 1(stdout)을 넣고 ecx에 write 함수의 두 번째 인자인 문자열 버퍼 주소를 넣는다.

strings에 문자열을 저장했으니 strings를 넣어주면 된다. 마지막으로 edx에 문자열 길이를 넣어주고 int $0x80을 하여 시스템 콜 호출 인터럽트를 한다.

 

프로그램을 정상적으로 종료시키기 위해 exit 함수도 호출한다.

eax에 exit 함수의 시스템 콜 호출 번호 1을 넣고 ebx에 정상 종료를 나타내는 0을 넣고 인터럽트를 한다.

 


인터럽트(Interrupt)

인터럽트란 현재 프로그램이 실행 중인 상태에서 인터럽트의 조건을 만족하는 어떤 변화가 감지되었을 경우 현재 실행되는 프로그램을 중단시키고 인터럽트 처리 프로그램을 실행하는 것을 인터럽트라고 한다.

가장 쉬운 예를 들자면 컴퓨터를 사용하다가 갑자기 뜨는 블루스크린을 들 수 있다.
블루스크린은 OS가 실행되는 영역인 커널모드에서 치명적인 오류가 발생했을 경우 인터럽트를 발생시켜 현재 프로그램을 중지시키고 블루스크린을 띄워 오류를 알려준다.
 
여기서 int $0x80은 명시적으로 예외 조건을 생성하는 instruction을 말하며 발생 시점이 일정한 동기적 인터럽트다.

 

gcc로 컴파일하고 실행하면 "hello world"가 잘 나온다. 이제는 objdump를 사용해 기계어 코드를 분석한다.

 

objdump 실행

 

gdb로 분석할 때와 비슷하지만, 중간에 기계어 코드가 있다.

기계어 코드는 원래 이진수 코드이지만 이진 코드보다 보기 쉽고 짧은 16진수 코드를 사용한다.

 

여기서 기계어 코드를 복사하면 helloworld1을 실행하는 쉘코드가 된다.

"\xb8\x04\x00\x00\x00\xbb\x01x\00x\x00\x00\xb9\xd4\x93\x04\x08\xba\x0c\x00\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x08"

 

하지만 이 코드는 NULL 바이트 때문에 공격에 사용할 수 없다. 버퍼 오버플로나 포맷 스트링 버그는 문자열 함수의 취약점을 이용한 공격인데 문자열 함수는 NULL 바이트를 만나면 문자열이 끝났다고 인생하고 입력을 NULL 바이트까지만 받는다.

 

NULL 바이트 제거와 문자열을 스택에 저장해 인자로 넣는다.

 

NULL 제거

 

스택에 "hello world\n" 문자열을 리틀 엔디안으로 push 한다.

"hello world\n"은 hex 값으로 "68656c6c6f20776f726c640a"다. 리틀 엔디안으로 4byte(32bit)씩 끊어서 스택에 push 한다.

 

esp가 가리키는 주소를 ecx에 넣고 사용할 레지스터들을 xor 하여 0으로 만든다.

al 레지스터에 write 함수의 시스템 콜 호출 번호 4를 넣고 ebx 레지스터값을 1 증가시킨다. (stdout)

dl 레지스터에 문자열의 길이인 12를 넣고 인터럽트를 한다.

 

objdump 실행

 

컴파일 후 실행하면 "hello world" 잘 나오고 objdump 실행하여 기계어를 보면 "\x00"인 NULL이 사라진 걸 볼 수 있다.

 

C언어 포인터 함수를 이용해 쉘코드를 실행한다.

 

C언어 쉘코드

 

쉘코드를 넣고 포인터를 사용해 쉘코드를 입력한다.

 

C언어 쉘코드 실행

 

실행이 잘 된다. 35byte 길이의 쉘코드를 만들었다.

 

이제 우리가 필요한 쉘을 실행하는 진짜 쉘코드 만들기를 배운다.

 

 

/bin/sh 쉘코드 실습

 

외부 프로그램을 실행시키는 함수 중 사용법이 간단한 execve 함수를 사용한다.

 

man 2 execve

 

execve() 함수는 세 개의 인자들이 모두 "const char *"형 인자들을 요구하고 있다.

첫 번째 인자는 파일 이름, 두 번째 인자는 함께 넘겨줄 인자들의 포인터, 세 번째 인자는 환경 변수 포인터다.

 

C언어 쉘

 

위 조건들을 만족하게 해 주기 위해 char *shell[2]를 만들고 각 인자를 채워준다. 두 번째 인자인 인자들의 포인터는 C 프로그램의 main() 함수에 argv 인자를 떠올리면 된다. argv[0]은 해당 프로그램의 실행 파일 이름을 나타내고 argv[1]은 실행 시 주어진 첫 번째 인자... 이런 식으로 나간다. 마찬가지로 execve()의 두 번째 인자는 argv[0]부터 들어가는 값을 가리키는 포인터가 되어야 한다.

 

여기서 컴파일할 때 그냥 컴파일하면 objdump가 실행되지 않는다. execve() 함수 때문에 이 프로그램은 컴파일되면서 Linux libc와 링크되게 된다. execve()의 실제 코드는 libc에 들어 있기 때문이다. 따라서 execve()가 어떤 일을 하는지도 알아보기 위해 static library 옵션을 주어 컴파일해야 한다.

 

gcc -static -g -o 옵션을 주고 컴파일 후 objdump를 사용해 execve를 살펴본다.

 

gcc -static -g -o 옵션

 

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

그래서 위 분석을 아주 간단하게 정리해서 보게 되면 밑 코드를 하는 것이다.

 

mov 0x8(%ebp), %ebx

mov 0xc(%ebp), %ecx

mov 0x10(%ebp), %edx

 

이 코드는 ebp 레지스터가 가리키는 곳의 +8byte 지점의 값을 ebx 레지스터에 넣고, +12byte 지점의 값을 ecx 레지스터에 넣고, +16byte 지점의 값을 edx 레지스터에 넣으라는 뜻이다. 위 dump를 보면 함수 프롤로그에 의해 execve()가 호출되고 이전 함수의 base pointer를 push하고 난 다음의 esp가 가리키던 곳을 가리키고 있다. 따라서 ebp +0byte 지점은 이전 함수의 ebp(base pointer)가 들어간다. 그리고 ebp +4byte 지점은 return address가 들어있고, ebp +8, ebp +12, ebp +16지점은 execve() 함수가 호출되기 이전 함수에서 execve() 함수의 인자들이 역순으로 push 되어 들어간다.

 

그다음 eax 레지스터에 11을 넣고 int \x80으로 인터럽트 한다.

 

execve()를 호출하기 이전에 main()에서는 어떤 처리를 했는지 본다.

 

main dump

 

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

 

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

이것은 sh.c.에서 (shell[0] = "/bin/sh";", shell[1] = NULL;)와 같은 역할을 한다. 그리고 다음부터 push 하기 시작한다.

 

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

 

 

쉘을 실행하는 과정

 

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

 

위 코드에서는 "/bin/sh"가 data segment에 저장되어 있어서 data segment의 주소를 이용할 수 있었지만, BOF 공격 시점에서는 "/bin/sh"가 어디에 저장되어 있다는 것을 기대하기 어렵다. 또한, 어딘가 저장되어 있다 하더라도 저장된 메모리 공간의 주소를 찾기도 어렵다. 따라서 직접 넣어주어야 한다.

 

이제는 위 helloworld 실습에서 했던 것처럼 에셈블리로 코드를 작성해야 한다.

여기서도 문자열인 "/bin/sh"를 16진수로 바꾸고 리틀 엔디안으로 입력해주어야 한다. 이때 "/bin/sh"를 넣어주는 것이 아니라 "/bin//sh"를 넣어주어야 한다. 경로에 "/"가 몇 번 들어가도 쉘을 실행하므로 4byte를 맞추기 위해서 "/" 두 번 넣는 것이다. 그래서 16진수로 바꿀 때도 "/"를 하나 더 추가해야 한다.

 

쉘코드

 

쉘코드를 분석해본다.

 

xor %eax, %eax       // 기계어에서 \x00에 해당하는 NULL 지우기 위해 사용한다.
push %eax             // NULL을 push
push $0x68732f2f    // /bin//sh를 넣는다. //sh 문자열.
push $0x6e69622f   // /bin//sh를 넣는다. /bin 문자열.
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을 호출하는 인터럽트를 발생한다.

 

이제 컴파일 하고 objdump를 실행해 기계어를 보면 helloworld 실습처럼 두 번에 할 것을 한 번에 했기에 \x00 NULL이 빠진 채로 나오게 된다.

 

sh1 dump

 

위 helloworld 실습과 같이 이제 기계어를 복사해 쉘코드를 실행할 수 있는 프로그램을 작성하면 된다.

 

C언어 쉘코드

 

printf는 길이를 측정하는 코드다. 실전에서는 빼서 써도 상관없다.

 

쉘코드 실행

 

쉘코드를 실행하면 쉘이 실행된다.

 

이로써 쉘코드를 직접 만들어 보았고 실습은 끝이 났고 level 11로 이어진다.

댓글