ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SystemHacking] 간단한 쉘 코드(ShellCode) 제작하기
    미사용/##Security 2015. 7. 28. 14:51

    [리눅스 쉘 코드 제작]

    - 쉘을 실행시키는 간단한 쉘 코드를 만들어 봄으로써 쉘 코드를 만드는 원리에 대해 이해한다.

     

    1. /bin/sh 혹은 /bin/bash 쉘을 실행시키는 프로그램 작성
    2. gdb 및 objdump 등의 툴을 이용해 쉘을 실행 시키는 시스템 함수 분석, 어셈블리 확인
    3. inline asm 을 이용해 시스템 함수의 주요 어셈블리어로 코딩
    4. objdump 를 이용해 바이너리값 추출
    5. NULL byte 제거 및 쉘 코드 다이어트 작업

     

    [그림 1]

    execve 함수를 이용해 간단한 쉘을 실행시키는 프로그램을 코딩한다.

    흔히 사용하는 system 함수는 fork() + execve() 형태로 동작하므로 간단한 execve() 로 쉘 코드를 제작해 본다.

     

    주의! gcc로 컴파일 할 때 –static –g 옵션을 주고 컴파일 해야한다.

    -g : 컴파일 시 디버거에게 함수명이나 변수명 등의 정보를 전달하도록 컴파일을 한다. 만약 –g 옵션을 주지 않는다면 단순히 어셈블리어로만 이루어진 코드를 디버깅 해야하기 때문에 디버깅이 어려워진다.

    [그림 2] –g option


    [그림 2]와 같이 –g 옵션을 추가할 경우 'info functions' 에 코드에 있는 함수 명이 출력이 된다.

    [그림 3] Non -g

     

    [그림 3]을 보면 –g 옵션을 빼고 컴파일 했을 때 'info functions'에 함수 목록이 출력이 되지 않는 것을 확인할 수 있다.

    -static : 컴파일시 기본적으로 공유 라이브러리를 이용해서 dynamic linking 을 하게 된다. 이렇게 되면 해당 라이브러리에 있는 함수를 사용 할 때 해당 함수 코드가 프로그램 내에 포함되는게 아니기 때문에 해당 라이브러리에 있는 함수를 디버깅할 수 없다. 따라서 static linking 을 강제로 하게 하기 위해 해당 옵션을 꼭 넣어야 한다.

     

    [그림 4]


    gdb로 프로그램을 열어서 main 함수 어셈블리어를 확인하면, push 가 연속으로 3번 동작 하고, call execve 가 되는 것을 확인할 수 있다. c언어는 함수 호출 규약 중 cdecl 방식으로 함수를 호출하는데

     

    [그림 5]


    [그림 5]의 화살표 방향으로 인자값이 함수로 전달된 것 이다.

    우리는 execve 함수가 실행될 때 동작하는 코드가 필요하므로 gdb에서 execve 함수를 어셈블리어로 봐야한다. (이 때문에 gcc로 컴파일시 –static 옵션이 필요하다)

     

    [그림 6]

    [그림 6]은 execve 함수를 gdb로 디버깅 한 화면인데 우리가 주목해야할 부분은 빨간색 네모 박스에 있는 내용이다. 그 앞부분은 프롤로그 과정과 메모리 할당, 레지스터 백업 과정이므로 크게 중요하지 않다.

     

    박스 부분을 보면 EDI, ECX, EDX 레지스터에 순서대로 [EBP+8], [EBP+12], [EBP+16] 이 복사된다 이 값들은 앞에 main 함수에서 execve 함수를 호출할 때 전달했던 인자값으로 [EBP+8]에는 "/bin/bash" 문자열 주소값이 있고, +12, +16 에는 NULL(0) 값이 저장되어 있다.

     

    그런 다음 ebx 레지스터 값을 push 하면서 백업하고, ebx에 edi 값을 복사시킨다. 이어서 eax에 0xb 값을 복사시킨 다음 int 0x80 작업을 하는데 이 부분에 대해서 따로 설명을 하겠다.

     

    우선 시스템 호출(System Call)에 대해서 알아야 하는데 이는 User Mode에 있는 프로그램이 커널에 접근해서 특정 루틴을 호출해 이용하는 것을 말한다. 본래 사용자 프로그램은 User Mode로 실행이 되고 커널 영역은 접근할 수 없으나 System Call 통해 순간적으로 Kernel Mode로 바뀌게 되고, 커널 영역에서 프로그램 루틴을 수행 한다.

     

    리눅스에서 이 System Call 을 호출 하기 위한 방법은 크게 int 0x80, sysenter 로 나뉜다.

     

    리눅스 버전에 따라 그리고 설정에 따라 다를 수 있지만 현재 환경에서는 System Call을 위해 int 0x80 을 사용한다. 그래서 edi, ecx, edx, 에 인자값을 저장하고, eax 에 0xb 를 저장한 다음 int 0x80을 통해 System Call 을 수행한다. eax에 0xb를 넣은 이유는 System Call 에서 실행할 execve 루틴에 대한 번호가 0xb 이기 때문이다. 그럼 우리는 int 0x80 까지가 execve 가 실제로 동작되는 부분이고, 나머지 실질적인 작업은 int 0x80을 통해 커널 영역에서 실행된다는 것을 알 수 있다.

     

    우리가 쉘 코드를 제작해야하는 어셈블리어는 다음과 같다.

    mov ebx, "/bin/bash"의 주소

    mov ecx, 0x00

    mov edx, 0x00

    mov eax, 0xb

    int 0x80

     

    이렇게 어셈블리어를 작성하고 컴파일 후 기계어만 추출해 내면 우리가 원하는 쉘 코드가 완성 된다. 지금 위 어셈블리어에서 해결해야할 부분은 "/bin/bash"의 주소값을 알아야 한다는 것인데 이 주소값은 매 실행시마다 바뀌게된다. 따라서 어셈블리어를 조금 변형할 필요가 있다.

     

    [그림 7]

     

     

    [그림 7]과 같이 어셈블리어로 코딩을 하면 "/bin/bash" 에 대한 문자열 주소값을 ebx 레지스터에 저장시킬 수 있다. call 명령어는 실행되면서 call 이 수행된 다음 실행 될 명령어의 주소값을 PUSH 하게 되는데 이를 RET 값이라고 부른다. 이 때 스택의 ESP는 RET 값을 가리키게 되고 하로 pop을 해주면 ESP가 가리키고 있는 값인 RET 값, 즉 "/bin/bas" 문자열이 있는 곳의 주소값이 ebx 에 저장 되는 것이다. 그리고 이어서 ecx, edx, eax에 앞에서 본 값들을 차례로 저장시키고, int 0x80을 실행 시키면 execve 루틴이 실행된다.

     

    ※ [그림 7]은 순수 어셈블리어로 코딩된 파일이기 때문에 .s 확장자로 저장해야 컴파일이 된다.

    ※ .global main 은 main 부분을 global 영역으로 만들어 외부에서 호출 가능하도록 지정한 것이다.

    ※ gcc는 컴파일 시 AT&T 문법을 사용하므로 Intel 기반 어셈블리어로 작성시 컴파일이 안된다.

     

    이렇게 작성하고 실행을 시켜 보았으나 Segmentation Fault 가 발생하였다. 그 이유를 몰라서 꽤 많이 헤맺는데 우선적으로 결론은 execve 의 두번째 파라미터의 데이터 타입이

    char * const argv[] 형태로 더블 포인터라 전달 되어야 했다. 아마 이렇게 전달받은 주소값을 두 번의 역참조를 통해서 값을 참조하는 것 같은데 나는 NULL 을 넣어줬기 때문에 NULL을 참조하려다 보니 Segmentation Fault 가 발생 한 것 같다.

     

    ※ Centos 6.6 에서는 [그림 5]와 같이 코딩을 하고 실행을 시켜도 정상적으로 실행이 된다. 리눅스 버전에 따라서 혹은 gcc 의 버전에 따라서 동작이 다르게 되는 것 같은데 좀 더 정확한 execve 동작 방식을 알아야 할 필요가 있는 것 같다. gdb나 원형을 보고 좀 더 분석을 해 봐야 할 것 같다.

     

    [그림 8]

     

    따라서 [그림 8]과 같이 코딩을 하고 실행을 시키면 정상적으로 동작이 되는데 두 번째 인자값은 문자열 배열 형태로 전달이 되어야 하며, 반드시 [0] 에 값이 있어야 하고, 마지막에는 NULL이 있어야 한다. [그림 8]에서 보면 (char *)0 을 넣어줬는데 결국은 NULL 이다. 다만 char * 타입으로 캐스팅 해준 것 밖에 없다.

     

    ※ execve 함수의 첫 번째 인자값과 str 의 [0] 의 문자열이 달라도 동작이 되는 것 같긴 한데 실제 쉘코드로 정상적인 쉘을 실행시키는지는 추가적으로 연구해 봐야할 사항인 것 같다. 이 역시 execve의 내부 동작에 대한 정확한 방법을 알야아 문제가 해결 될 듯 싶다.

     

    다시 gdb로 분석 후, 어셈블리어로 코딩을 하는데 프로그램 코드가 바뀌어도 execve 가 동작하는 것 똑같기 때문에 어셈블리어만 수정하면 된다.

     

    [그림 9]

     

    아까와 달라진 점은 push가 두 번 추가 되었고, ecx 레지스터에 넣는 값이 달라졌다. execve 함수의 두 번째 인자는 문자열 배열형태로 들어가야 하기 때문에 스택을 배열 형태로 만들어 주기 위해 push 를 두 번 했으며 0x0 을 push 한 이유는 문자열의 끝인 NULL 값을 push 해준 것이고 ebx 레지스터 값을 push 한 이유는 "/bin/sh" 문자열의 주소를 push 해주기 위함이다. 그리고 ecx 레지스터에는 배열의 시작주소가 들어가는데 그 시작 주소는 현재 esp 레지스터에 있는 주소이므로 esp 레지스터에 있는 값을 ecx 레지스터에 넣어주었다.

     

     

     

    [그림 10]

     

    이렇게 작성하고 gcc –o shellcode_asm shellcode.s 로 컴파일 한 다음 실행시키면 [그림 10]과 같이 쉘이 정상적으로 실행된 것을 확인할 수 있다.

     

    ※ TERM environment variable not set. 이 뜨는 이유는 환경변수가 설정이 제대로 되어 있지 않기 때문인데 execve 함수에서 3번째 인자값이 실행할 프로그램에 넘겨줄 환경변수인데 우리가 NULL을 넣어줬기 때문에 제대로 환경변수 설정이 되지 않았다.

     

    이제 어셈블리어로 코딩을 했으니 최종적인 바이너리 16진수 값만 추출하면 완성된다.

     

    [그림 11]

     

    [그림 11]은 objdump 라는 프로그램으로 우리가 코딩했던 프로그램을 열어본 화면이다. gdb와는 다르게 한번에 어셈블리어와 바이너리 값을 함께 보여준다. 저기서 바이너리 부분을 그냥 순서대로 나열하면 완성이긴 한데 중간중간에 00(NULL) 값이 있어서 나중에 사용할 때 NULL을 보고 쉘코드가 종료되버리는 경우가 생기기 때문이다. 따라서 이제는 저 NULL 값들을 제거하는 작업을 해야 한다.

     

    [그림 12]

     

    [그림 12]가 NULL Byte를 제거하기 위해 어셈블리어를 다시 수정한 결과이다. 우선 0x0을 mov 하지 않고 XOR을 이용해 대체했으며, 0xb 값을 eax에 저장할 때 eax 레지스터가 아닌 al 레지스터에 저장을 해서 NULL Byte 가 생기지 않도록 했다. al 레지스터는 eax 레지스터에서 1/4 만큼을 지칭하는 레지스터이다. 주의할 점은 al 레지스터는 크기가 1byte 기 때문에 movl이 아닌 movb 명령어를 사용해야한다.

     

    ※ movl에서 l은 long 을 의미하는데 정확히 4byte를 복사하기 위한 레지스터이며 movb는 1byte를 복사하기 위한 명려어이다.

     

    [그림 13]

     

    그리고 다시 objdump로 열어 보면 이제 NULL Byte가 모두 사라진 것을 확인할 수 있다. 이제 위에서부터 순서대로 바이너리 값을 나열하면 된다.

     

     

    \xeb\x0d\x5b\x31\xc0\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80\xe8\xee\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68

     

    최종적으로 위와 같은 쉘 코드가 완성 되었다.

     

     

    [그림 14]

     

    테스트 해보기 위해서는 [그림 14]와 같이 코딩을 하면 되는데 간단하게 코드를 설명하면

    int * ret 가 선언되면 왼쪽의 스택 그림처럼 EBP 위에 ret 변수가 할당이 되고 현재 esp 는 ret의 주소값을 가리키고 있다. ret의 주소값에 2를 더하면 2*4 만큼 주소값이 증가해서 RET 부분의 주소값이 된다. RET 주소값을 역참조해서 buf배열의 주소값을 저장한다. 그러면 프로그램이 종료될 때 RET 로 돌아가면서 결국 쉘 코드가 있는 곳부터 실행이 된다. 따라서 최종적으로는 쉘 코드가 실행이 된다.

     

     

    [그림 15]

      참조 :

    http://ezbeat.tistory.com/150

    eits1st 님의 pdf 문서

    http://kaspyx.tistory.com/4

    http://semi.jnu.ac.kr/~mirr1004/jinho/07.%20lecture-06.txt

    http://onestep.tistory.com/19

    http://ca6on.blogspot.kr/2014/07/sysenter-system-call.html

    http://libsora.so/posts/system-prog-system-call/

    https://kldp.org/node/152442

    댓글

Designed by Tistory.