관리 메뉴

개발자비행일지

모의해킹 쉘코드 만들기 기본 본문

▶ 모의해킹 공부

모의해킹 쉘코드 만들기 기본

Cyber0946 2020. 2. 21. 10:22

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"

다음과 같은 쉘 코드를 실행하여, 루트 권한을 탈취 하는 것을 목표로 한다. 이 때, 해킹이 친숙하지 않은 본인의 경우도

이를 해결하기 위해 학습 내용을 정리하며 진행 하고자 한다. 

쉘코드는 어떤 과정으로 만들어 질까?

먼저 쉘코드란 기계어들로 구성되어서 바로 실행 될 수 있도록 되어 있는 부분이다. 즉, 메모리에 올리면 바로 그 명령어가 실행된다. 

자 그럼 참고한 url의 글 처럼 Hello, Studen!를 출력하는 기계어 코드를 만들어 보자. 

화면에 문자열을 출력하려면 c 언어의 printf() 함수나, python의 print() 등을 쓰면된다. 

하지만 실제적으로 프로그래밍 언어로 해당 함수를 실행하면, 이는 시스템 콜을 호출해서 운영체제 커널에서 제공하는 

기능을 통해 화면을 출력할 수 있는 명령으로 바뀌어서 화면에 출력이 된다. 

즉, 이런 printf 함수는 시스템콜인 write() 함수를 조금 더 편리하게 쓰기 위해 만들어진 라이브러리 함수이기 때문이다. 

편리한 사용을 위해 printf() 함수는 write() 함수보다 훨씬 더 복잡한 구조로 구성 되어 있다. 우리는 기계어 코드를 작성

할 때, 보다 간결하게 의도한 목적만 달성하도록 작성하기 위해서 wirte()함수를 사용한다. 

#여기에는 쉘코드의 용량을 줄이려는 목적도 있다. 우리가 해킹을 위해 사용할 수 있는 메모리 공간은 한정되어 있기 때문이다. 

자 그럼 wirte() 함수로 화면에 문자열을 출력하는 c 코드를 작성해 보자. 

int main() {

write(1, "Hello, Students!\n", 17);

}

write() 함수의 첫 번째 인자는 출력 대상을 지정하는 것이며, 여기서 1은 표준출력을 의미한다. 

즉, 우리가 보고 있는 터미널의 화면이다. 두 번째 인자는 이 화면에 출력할 문자열 그 자체이다.

마지막으로는 그 문자열의 길이를 의미한다.

#아마도 printf 함수는 그 문자열의 길이를 작성해주는 부분을 Parsing을 통해서 문자열만 입력하면 그 길이를 반환하는 부분을 따로 구현 했을 것 같다. 

vim 사용법을 모르는 사람은 따로 검색해서 따라해 보면 될 것 같다. vim 편집기를 사용해서 c 코드를 생성한다. 

소스코드는 아래와 같다. 해당 글에서는 #include에 대해 일말의 언급이 없으나, 고수분들의 이러한 "이정도는 알겠지"라는 태도는 초보들의 엄청난 시간의 뻘짓을 유발한다.  

이제 위 코드를 컴파일 해 보자. 그리고 실행을 하면 위 문자열이 화면에 출력 될 것이다. 

컴파일 명령은 gcc -o 실행파일이름(지정) 소스코드이름.c 이다. 

이제 이 코드를 기계어로 변경해 보자. 기계어는 0과 1만 해석할 줄 아는 컴퓨터가 사용하는 언어로 사람이 다루기엔 큰 번거러움과 어려움이 있다. 이러한 번거러움과 어려움을 해결하기 위해 개발된 것이 바로 어셈블리어이다. 어셈블리어는 C언어나 PHP, JAVA와 같은 컴퓨터 언어의 한 종류이다. 그리고 인간이 사용하는 컴퓨터 언어들 중 기계어에 가장 근접한 언어이기도 하다.

우리는 C언어를 바로 기계어로 바꾸는 무리한 짓을 하지 않고, C언어를 일단 어셈블리어로 표현하고, 그 다음 그것을 기계어로 바꿀것이다.

그렇다면 어떻게 어셈블리어로 C 언어 파일을 바꿀 수 있을 까? 다음과 같은 옵션을 사용해서 컴파일 해보자. 이건 우리가 호출하는 wirte() 함수의 내부구조 까지 어셈블리어로 보기 위해서이다.  

자 이제 GDB 라는 툴을 이용해서 컴파일 후 기계어로 변환된 바이너리를 분석해 보자. gdb 툴은 디버깅(프로그램의 문제점을 분석하는 작업)은 물론 기계어 코드를 어셈블리어로 보여주는 기능을 가지고 있다. 

gdb 실핼파일명으로  확인해 보자.

이제 disas main으로 확인해 본다. 

참, 필자와 같은 intel 방식의 어셈블리로 보기 위해선 

set disassambly 

어셈블리어에어 우리가 주목할 부분이다. 

0x11이 스택에 push 되고 , 그다음 0x80bb28이 push되고  마지막으로 0x1이 push된다. 그다음 write() 함수가 호출된다. 

그럼 이 3가지의 정체가 무엇일까 우리가 앞서 소스코드에 넣어 주었던 함수 인자들을 확인해 보자. 

write(1, "Hello, Students!\n", 17);

그다믕 순서대로 어셈블리어를 확인해 보면 1. 0x11 -> 17, 2. 0x80bbb28, 3. 0x1 ->1 이 들어 감을 알 수 있다. 

여기서 2번째로 들어간 것은 주소이다. 이 주소에 들어간 값을 stirng으로 확인하는 x/s 주소 의 명령으로 보면 다음을 확인할 수 있다.  

write 함수의 3 인자가 차례대로 push 된 것이다. push 됨이라 하면, 스택에 그 값이 저장되는 것을 말한다. 다시 말해 push는 스택에 값을 집어 넣는 어셈블리어 명령이다. 그런데 이 때, "1, 문자열, 길이" 순서가 아닌 "길이, 문자열, 1" 순서. 즉, 반대로 값들을 넣은 것에 주목하라.

이처럼 함수(여기에선 write)의 인자는 스택에 반대 순서로 저장되게 되어 있다.

자, 그럼 이제 이렇게 write() 함수의 인자를 차례대로 스택에 push 한 다음 마지막으로 위처럼 write() 함수만 call 하면 되는 것일까? 다시 말해서 지금 설명한 이 과정만 기계어로 만들면 되는 것일까? 아쉽지만, 이렇게 간단하면 얼마나 좋았을까. 우리는 write() 함수가 하는 내용까지 모두 기계어로 만들어 주어야만 한다.

의심스러운 부분은 다음이다. 

CALL 명령어를 확인해 보자 다음과 같다. 

위 코드 중 가장 마지막 부분에 있는 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이 들어가게 된다는 사실을 쉽게 유추해 낼 수 있다.

mov라는 어셈블리어 명령은 mov A, B 라고 명령했을 때, A를 B로 복사하는 역할을 한다. 즉, mov $0x4, %eax 는 eax라는 레지스터(CPU 내의 저장 공간)로 4라는 값을 저장하는 명령이다.

실제로 int 0x80 명령을 실행하면, CPU의 레지스터들 중 eax, ebx, ecx, edx 등의 값들을 불러와서 사용하는데, 이 때 가장 첫번째 레지스터인 eax에서 어떤 함수를 호출할지를 알게 되고, 그 다음 ebx, ecx, edx 레지스터들의 값은 차례대로 이 함수의 인자로 적용이 된다.

자 지금까지의 과정을 정리하면 다음과 같다. 

* 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()를 실행.

자 이러한 과정중 우리가 wirte() 함수를 사용하기 위해 꼭 필요한 부분은 다음과 같다. 

먼저 write 함수를 불러서 출력한 뒤, exit 함수로 종료하는 어셈블리다. 

vim 편집기로 확장자는 .s 로 작성한다. 

자 이제 실행해 보자.

실행결과는 다음과 같다. 

이제 거의 완벽한 문자열 출력 프로그램이 되었다. 그럼 이제 위 어셈블리어 코드를 기계어로 만드는 일만 남아있다.

일단, 컴파일러를 이용해서 위 코드를 기계어로 변환하도록 만든다.

이미 앞서 입력한 gcc 명령이 바로 이 작업을 했다. 따라서 컴파일된 write 명령에서 기계어를 추출해 내야 하는데, 이번엔 /usr/bin/ objdump라는 툴을 사용하면 된다.

중간의 내용들은 생략하고 최종 결과는 다음과 같이 나온다.

우리가 만들었던 어셈블리어 코드가 그대로 출력됨과 동시에 바로 왼쪽 부분에 이 어셈블리어들이 기계어로 변환되어 출력되었다. 아니, 정확히 말하면 왼쪽에 있는 기계어가 변환되어 오른쪽에 어셈블리어로 출력된 것이다. 그리고, 원래 기계어는 2진수로 표현되지만, 2진수로 출력하면 길이도 길어지고, 우리가 알아보기도 힘들기 때문에 최대한 보기 쉽게 16진수 형태로 출력되었다. 이제 이 16진수를 쭈욱 하나로 이어 붙이면 비로소 기계어가 완성된다. 하지만, 위 코드를 자세히 보고 있자면, 이상한 부분이 하나 있다. 그것은 바로 두 번째 인자에 해당하는 "문자열의 시작 주소"가 정작 그 문자열은 어디에도 보이지 않고, 달랑 주소 값만 사용되고 있는 것이다.

우리가 만들었던 어셈블리어 코드가 그대로 출력됨과 동시에 바로 왼쪽 부분에 이 어셈블리어들이 기계어로 변환되어 출력되었다. 아니, 정확히 말하면 왼쪽에 있는 기계어가 변환되어 오른쪽에 어셈블리어로 출력된 것이다. 그리고, 원래 기계어는 2진수로 표현되지만, 2진수로 출력하면 길이도 길어지고, 우리가 알아보기도 힘들기 때문에 최대한 보기 쉽게 16진수 형태로 출력되었다. 이제 이 16진수를 쭈욱 하나로 이어 붙이면 비로소 기계어가 완성된다. 하지만, 위 코드를 자세히 보고 있자면, 이상한 부분이 하나 있다.

그것은 바로 두 번째 인자에 해당하는 "문자열의 시작 주소"가 정작 그 문자열은 어디에도 보이지 않고, 달랑 주소 값만 사용되고 있는 것이다. mov $0x80483db,%ecx 이는, 컴파일될 때 문자열의 주소 값이 지정되고, 실제 명령 부분에서는 그 미리 정해진 주소. 다시 말해서 절대 주소를 가져와 사용하고 있는 것이다. 따라서 이 상태로 기계어 코드를 만들면, 실제 실행 할 때에도 위 0x80483db 에서 문자열 값을 가져오려고 할 것이고, 당연히 그 환경에서는 "Hello..." 라는 문자열이 그 주소 부분에 존재할 가능성이 ZERO에 가깝기 때문에 이 주소 값을 사용하는 것은 전혀 무의미한 짓이다. 그럼 어떤 방법으로 "Hello..." 문자열이 위 기계어 코드에 포함되고, 또 그 문자열의 주소를 %ecx 레지스터에 저장하도록 만들 수 있을까? 그 방법을 설명하면 다음과 같다. 일단 문자열의 시작 주소가 스택에 저장되도록 하고, 그 다음 스택에서 그 주소 값을 꺼내 %ecx 레지스터에 저장하면 되는 것이다. 이 과정을 다음과 같이 어셈블리어로 표현해 보겠다.

다시 작성한 어셈블리를 동일한 방식으로 수행해 준다. 

이제 또 어디 잘못된 점이 없는지 유심히 살펴보자. 어, 근데 "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언어에서 위 기계어를 사용할 수 있도록 위 코드가 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 드디어 완성되었다. 이제 다음과 같은 방법으로 프로그램 내에서 위 코드가 정상적으로 실행되는 지를 확인해 보자.

정상실행 됨을 알 수 있다. 이제 거의 다 왔다. 

이제 Hello, Students!문자열만 다른 것으로 바꾸면 어떻게 될까?? 

\xe8 \x12 \x00 \x00 \x00 여기서 가장 앞의 \xe8은 call을 의미한다. 그리고 그 다음의 \x12는 10진수로 18이며, 이는 곧 18 바이트 뒤 떨어진 곳으로 call 한다는 의미이다. 따라서 정확히 18 바이트 떨어진 부분에 문자열 다음에 해당하는 "\x59\xb8..."이 있는 것을 볼 수 있다. 이는 즉, 문자열의 길이가 바뀌면 call 되는 위치 또한 바뀌어 버린다는 것을 의미한다. 가장 단순한 방법은 바뀐 문자열 길이에 해당하는 값을 \xe8 \x12 부분에 알맞게 적용시키는 것이다. 하지만, 다음과 같은 방법을 사용 하면 훨씬 깔끔하게 원하는 문자열로 바꿔 사용할 수 있게 된다. 여기에선 단순히 문자열을 바꾸는 것에 불과하지만, 이것을 쉘을 실행시키는 기계어 코드에 적용시키면, /bin/sh 외의 원하는 명령들을 마음대로 실행시킬 수 있게 될 것이다.

 

그럼 이번에는 앞서 배운 내용들을 활용하여 쉘을 실행하는 기계어 코드. 즉, 쉘코드를 한 번 만들어 보도록 하자. 지금까지 배운 바와 같이, 먼저 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의 약자로, 해당 프로그램이 사용하는 시스템콜 목록을 화면에 실시간으로 출력해주는 기능을 가지고 있다.

간단하게 이 코드를 test라는 이름으로 컴파일 한 후, strace test를 입력하면, "execve("/usr/bin/test", ["test"], [/* 22 vars */])" 부분이 지난가는 것을 확인할 수 있다. 사실 거의 모든 쉘 상의 프로그램들이 결국에는 커널에서 제공 하는 시스템 콜들을 사용한다. 예를들어 우리가 가장 많이 사용하는 ls 명령 또한 내부적으로는 open(), close(), read(), write() 등의 시스템 콜을 사용한 다는 사실을 strace 명령으로 확인해 볼 수 있다. 그럼 이제 답은 나왔다. 가장 간단한 기계어를 만들기 위해 필요한 C언어 코드는 바로 execve() 함수를 사용한 다음과 같은 모습이다.

main() 함수가 호출되면, 기존의 base point 값을 스택에 임시 저장하고, 그 다음 새로운 base point 값을 설정한다. 이 부분에 대해서는 "프레임 포인터 오버프로우" 강좌에서 자세히 설명한다. 그 다음엔 변수를 위한 공간을 sub 명령을 이용하여 할당하는데, 위에서는 8바이트를 할당 받았다. 그 이유는 포인터 1개의 용량이 4 바이트인데, char *str[2] 와 같이 2개를 선언했기 때문이다. 이제, str[0]에 "/bin/sh" 문자열이 담긴 메모리 주소를 대입하고, str[1]에는 NULL을 의미하는 0을 대입했다. 여기까지의 과정을 주석을 달아 설명하면 다음과 같다.

여기서 eax에 담는 ebp-0x14를 추적해 보면 0x80bbba8가 담기는 걸 알 수 있다. 그럼 여기엔 무슨 값이 있을까?

그 다음엔 또 다시 4 바이트의 용량을 할당 받는데, 이 것은 아무런 의미도 없는 DUMMY 값이다. 바로 뒤쪽 부분을 보면 총 3개의 변수를 push하는 모습을 볼 수 있는데, 이 것들이 총 12 바이트이기 때문에 깔끔하게 16바이트로 맞춰주기 위해 4 바이트를 추가한 것이다. 첫 번째 push는 execve()함수의 마지막 인자인 0을 스택에 집어 넣은 것이다. 그리고 두 번째 push는 str[0]의 주소 값으로서, *str[2]으로 선언된 포인터 배열의 시작 주소를 스택에 저장한 것이다. 마지막 push는 str[0]에 저장된 주소 즉, "/bin/sh"의 주소 값을 스택에 저장한다. 그리고 이제 execve() 함수를 호출함으로서, main() 함수의 분석은 끝난다.

이제 main() 함수가 호출한 execve() 함수를 disassemble 해보자. 

다소 긴 어셈블리어 코드가 출력되지만, 중요한 부분은 다음에 불과하다.

일단, 상대 주소의 내용을 확인하기 위해 현새 스택의 모습을 상상해 보자. 

이제 execve의 내용을 보면, 가장 먼저 새로운 base point가 설정되며, 그 다음 첫 번째 인자에 해당하는 값이 %edi 레지스터에 저장된다. 그리고 거기서 3줄 아랫 부분을 보면, 그 값을 다시 %ebx에 저장하는 모습을 볼 수 있다. 그 다음엔 두 번째 인자인 str[0]의 주소 값이 %ecx에 저장된다. 마지막으로 %edx에는 세 번째 인자인 0이 저장된다. 이를 정리하면 다음과 같다.

%eax = 11 : execve 시스템 콜 번호 %ebx = str[1] : "/bin/sh" %ecx = str : 포인터 배열의 시작 주소 %edx = 0 : NULL

이제 다음 다섯가지를 도전해보자.

  • (1) %eax에 11을 넣기
  • (2) %ebx에 "/bin/sh"의 주소를 넣기
  • (3) %ecx에 포인터 배열 ["/bin/sh"의 주소][0]의 주소를 넣기
  • (4) %edx에 0을 넣기
  • (5) 시스템 콜 인터럽트 발생

여기서 어려운 부분은 3번이다. 

보다시피 (3)번을 제외하고는 모두 간단한 작업이다. (3)번을 구현하는 과정만 유심히 살펴보면 전체 코드를 이해하는 것이 무난할 것이다. ==============================================================

==============================================================

그냥 레지스터 명을 사용하면, 그것은 레지스터 자체를 의미하지만, 레지스터 명에 괄호 ()를 넣으면, 레지스터에 저장되어있는 주소 값을 의미하게 된다는 점에 유의하며 코드를 이해하기 바란다. 즉, 만약 movl 0x0, %eax 라고 명령하면, %eax 레지스터에 0을 대입하라는 것이지만, movl 0x0, (%eax) 라고 명령하면, %eax에 저장되어 있는 주소에 0을 대입하라는 명령이다.

즉, 만약 movl 0x0, %eax 라고 명령하면, %eax 레지스터에 0을 대입하라는 것이지만, movl 0x0, (%eax) 라고 명령하면, %eax에 저장되어 있는 주소에 0을 대입하라는 명령이다. 이제 위 코드를 컴파일하여, 정상적으로 쉘이 실행되는지 확인해보자.

위 쉘코드에 언뜻 보기엔 아무런 문제가 없어 보이지만, 사실 아주 치명적인 문제가 존재한다. 그것은 바로 쉘 코드 중간 중간에 \x00 이라는 문자가 있다는 점이다. 만약, 이 쉘코드가 strcpy() 등의 문자열을 다루는 함수에 사용된다면 쉘코드 내용이 중간에 짤려나가 버릴 것이다. 왜냐햐면, 대부분의 문자열을 다루는 함수들이 \x00(NULL) 문자를 만나면 그것이 문자열의 끝으로 인식하여 값을 읽어 들이는 작업을 중단하기 때문이다. 그럼 어떤 방법으로 \x00 값을 없앨 수 있을까? 가장 많이 사용되는 간단한 트릭 하나를 소개하겠다. 어셈블리어 명령 중에는 xor 이라는 배타적 논리합을 의미하는 것이 있다. 배타적 논리합이란, A와 B값이 주어졌을 때, A와 B가 서로 다를 때만 참이 되는 연산 방법이다. 다음의 예를 보자.

자, 그럼 이제 특정 레지스터의 값을 모두 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이 존재하는 나머지 명령들도 위와 같은 방법을 사용하여 수정해 보자

이제 100% 완벽한가? 아니다. /bin/sh 바로 뒤에 \x00이 딱 하나 남아있다. 이것 역시 0x4(%esi)를 바꾼 것과 같은 방법으로 해결할 수 있다. 일단, .string에 있는 \00을 없앤 후, 다음과 같이 코드를 수정한다.

보다시피, 단 한 개의 NULL(0x00)도 존재하지 않는다. 이제 거의 완벽한 쉘 코드가 만들어진 듯 하지만, 지금까지 쉬운 이해를 목적으로 설명하며 쉘코드를 만들었기 때문에 소스가 다소 비효율적이고, 반복된 부분도 있다. 따라서, 앞서 만든 코드를 조금 더 깔끔하게 수정해 보도록 하겠다. 참고로, 쉘코드는 취약 프로그램의 한정된 버퍼 안으로 저장되야 하는 경우가 많이 때문에 쉘코드의 사이즈가 적으면 적을 수록 더욱 공격에 유리하다. 실제 국외 유명 Exploit 사이트인 hack.co.za에선 가장 짧은 쉘코드 만들기 컨테스트가 열렸을 정도로 해커들 사이에서 짧은 쉘코드 만들기 기술은 흥미로운 주제가 되기도 한다. 참고로, 현재까지 발표된 쉘코드들 중 가장 짧은 것은 약 22바이트이다.

약, 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 이것이 완성된 쉘코드이다. 이제 실제 다른 프로그램 안에서도 정상적으로 작동 하는지 확인을 해보자.

성공이다. 이로써 쉘코드 만들기가 끝났다. 하지만, 위 쉘코드로 /bin/sh를 실행 시키면 한 가지 문제가 생긴다. 왜냐하면 레드햇 버젼 7.0 이후에, /bin/sh(bash)가 백도어로 사용되는 것을 방지하기 위해 /bin/sh이 실행될 때 프로그램의 실행 권한이 아닌, 프로그램을 실행시킨 사용자의 권한으로 실행되기 때문이다. 따라서 mirable이라는 사용자가 root 권한의 파일을 해킹하여 /bin/sh을 실행하면, root가 아닌, mirable 권한의 쉘을 얻게 된다. 하지만, 이 문제는 쉘을 실행시키기 전에 setreuid(0,0);을 호출함으로써 아주 쉽게 해결할 수 있다. 이처럼, /bin/sh의 방어나 chroot(), 쉘코드 문자 필터링 등을 우회하는 쉘코드. 혹은, 리모트 환경 상에서 사용할 수 있는 bindshell, reverse telnet 쉘코드가 추가로 필요하다.