Programming/Kernel / Driver2008. 12. 3. 11:38

음......

흔히들 디바이스 드라이버를 이용한 커널 레벨 후킹을 할 때에는,

모든 동일한 프로세스가 Kernel32.dll의 API를 호출할 때,

API가 실질적으로 호출하는 내부함수의 주소를

NTOSKRNL의 KeServiceDescriptorTable에서 참조하는 것을 이용하여

KeServiceDescriptorTable.ServiceTableBase[함수 인덱스 번호]

를 수정하여 자신의 함수주소로 바꾸어 놓습니다.


예를 들어, ZwOpenProcess()를 후킹한다 가정하면

그 코드는 아래와 같습니다.


중요한 부분만 올려놓았습니다.


#include <ntddk.h>


#define PROTECT_PROCESS_ID                   456

typedef struct _SERVICE_DESCRIPTOR_TABLE {
         unsigned int *ServiceTableBase;
         unsigned int *ServiceCounterTableBase;
         unsigned int NumberOfServices;
         unsigned char *ParamTableBase;
} SERVICE_DESCRIPTOR_TABLE;


//import 선언

NTKERNELAPI

SERVICE_DESCRIPTOR_TABLE

KeServiceDescriptorTable;


NTKERNELAPI

HANDLE

NTAPI

PsGetProcessId(

         IN PEPROCESS Process);


//함수에 해당하는 주소를 KeServiceDescriptorTable에서 쉽게 알수 있게 해주는 매크로

#define SYSTEMSERVICE(_function)  KeServiceDescriptorTable.ServiceTableBase[*(PULONG)((PUCHAR)_function+1)]

typedef NTSTATUS (NTAPI *ZWOPENPROCESS)(

        OUT PHANDLE ProcessHandle,

        IN ACCESS_MASK DesiredAccess,

        IN POBJECT_ATTRIBUTES ObjectAttributes,

        IN PCLIENT_ID ClientId OPTIONAL);


ZWOPENPROCESS PrevZwOpenProcess; //원래의 함수주소를 담기위한 것


NTSTATUS ZwOpenProcessHandler(

        OUT PHANDLE ProcessHandle,

        IN ACCESS_MASK DesiredAccess,

        IN POBJECT_ATTRIBUTES ObjectAttributes,

        IN PCLIENT_ID ClientId OPTIONAL)

{

         NTSTATUS Status=PrevZwOpenProcess(ProcessHandle, DesiredAccess,

               ObjectAttributes, ClientId);   //원래 함수를 호출

         if(NT_SUCCESS(Status))   //리턴값이 성공을 나타내면 Process Object를 얻음

         {//Process Object=EPROCESS 구조체

                  PEPROCESS Process;   //Process Object

                  NTSTATUS ObjectStatus=ObReferenceObjectByHandle(

                           *ProcessHandle, PROCESS_ALL_ACCESS, NULL, KernelMode,

                           &Process, NULL);  //프로세스 핸들로 EPROCESS의 포인터를 얻음

                  if(NT_SUCCESS(ObjectStatus))  //성공적으로 EPROCESS를 얻었다면

                  {  //EPROCESS의 구조체 내부의 UniqueProcessId를 조사해서

                      //그 프로세스가 보호하려는 프로세스인지 알아낸다.

                      //이러한 일을 해주는 함수가 PsGetProcessId이다.

                      //이 함수는 ddk 헤더파일에 선언되어있지 않으므로 직접 선언해주자.

                      //EPROCESS 구조체 역시 ddk 헤더파일에 선언되어있지 않다.

                      //직접 인터넷을 뒤지면 나올지도 모르나 OS마다 구조체가 다를 수 있으므로

                      //PsGetProcessId()를 호출하는 것이 훨~씬 안정적이다.

                      //PsGetProcessId()를 사용할 수 없다면 직접 프로세스아이디 오프셋을

                      //구해 와야 한다.

                      //참고 : *(ULONG *)((ULONG *)Process+프로세스아이디 오프셋)=

                      //Process->UniqueProcessId

                           if(PsGetProcessId(Process)==(HANDLE)PROTECT_PROCESS_ID)

                           {

                                    //보호하려는 프로세스가 맞으면

                                    //프로세스 핸들을 닫은 후 NULL로 세팅해준다.

                                    ZwClose(*ProcessHandle);

                                    *ProcessHandle=NULL;

                                    //엑세스 거부를 return.

                                    //STATUS_ACCESS_DENIED 값은 LsaNtstatusToWinError()로

                             //ERROR_ACCESS_DENIED로 바뀌어서 SetLastError()의 인자가 된다.

                                    return STATUS_ACCESS_DENIED;

                           }

                  }

         }

         return Status;

}


void SetupHookZwOpenProcess()

{

         //원래의 함수 주소를 저장

         PrevZwOpenProcess=(ZWOPENPROCESS)(SYSTEMSERVICE(ZwOpenProcess));

         //Cr0 Register 연산으로 보호를 해제시킨다.

         __asm
         {
                  cli
                  mov eax, cr0
                  and eax, not 10000h
                  mov cr0, eax
         }

         //SDT 수정부분

         (ZWOPENPROCESS)(SYSTEMSERVICE(ZwOpenProcess))

                  =ZwOpenProcessHandler;

         //Cr0 Register 연산으로 원래대로 보호시킨다.

         __asm
         {
                  mov eax, cr0
                  or eax, 10000h
                  mov cr0, eax
                  sti
         }

}


지금부터 이러한 방법에 대한 장/단점에 대해 알아봅시다.

장점은 쉽다는 것입니다.

그 때문에 루트킷에서도 널리 쓰이고 있으며, 백신 또한 각각의 프로세스나 파일의 보호/감시를 위해서 쓰고 있습니다.

하지만, 이렇게 쉬운만큼 역시나 단점도 존재합니다.

여러가지 방법으로 인해서 무력화 될 수 있다는 것이죠.

1. \Device\PhysicalMemory에 엑세스하여 ntoskrnl의 이미지에서 주소를 구해와 SDT를 원래대로 돌려놓거나

2. 서비스 테이블을 덮어쓰는 것이므로 커널모드 드라이버를 로드하여 ntoskrnl의 이미지에서 주소를 구해와서 원래 주소로 덮어쓰기한다.

3. KeServiceDescriptorTable.ServiceTableBase를 Relocate시킨다.

이 외에도 몇몇가지 방법이 더 있고 대처방법 역시 있습니다.


우선 첫번째 방법에 대한 대처방법은

ZwMapViewOfSection()/ZwOpenSection()을 후킹하여 \Device\PhysicalMemory일 경우 막아버리면 됩니다.

혹은, Admin의 권한이 없을 때에는, 1번 방법은 통하지 않습니다.

두번째 방법에 대한 대처방법은

ZwLoadDriver()를 후킹하거나

3번 방법처럼 하면 됩니다.

세번째 방법은 실제 디스크 이미지로부터 서비스 함수들의 주소를 각각 구해 배열로 NewKiServiceTable을 만든 후,

이것을 DeviceIoControl로 드라이버에게 넘겨준 후, 드라이버로 하여금 NewKiServiceTable의 함수를 사용하게 하면 될 것입니다.

Posted by skensita
Programming/Win32 API2008. 12. 1. 15:40
Win32 Global API Hook - 제3강 Win32 어셈블리 프로그래밍 (2)
===========================================================*/

Win32 어셈블리 두번째 시간입니다. 그동안 좀 바쁜일이 많아서, 오랜만에 강좌를 올리게되었습니다. 오늘
은 지난시간에 말씀드린대로 호출규약에 따른 함수들의 형태가 어셈블리로 어떻게 쓰여지는지와 함께 흐름
제어의 기본인 조건분기와 반복구문이 어떻게 구현되는지에 대해 알아보겠습니다. 먼저 강좌에 많은 관심
과 응원을 보내주신 여러분들께 감사하다는 말씀 드리면서 강좌들어갑니다.

(강좌의 진행상 존칭을 생략하겠습니다.)

1. 프로시저 호출규약 (Procedure Calling Convention)

지난시간에 프로시저(함수)가 어떤 형태로 매개변수와 반환값을 다루고, 지역변수를 관리하는가에 대한 일
반적인 메카니즘을 배웠다. 그러면 조금 더 구체적인 내용으로 들어가서 호출규약에 관한 얘기를 해보자.
호출규약(calling convention)이란 매개변수를 함수에 전달하고 함수가 사용한 스택을 어떻게 정리하는지
에 대한 구체적인 규칙에 따른 분류이다. 호출규약에는 다음과 같은 것들이 있다.

(1) __cdecl        : C 호출규약
(2) __stdcall     : 표준 호출규약
(3) __fastcall    : 빠른 호출규약
(4) __declspec(naked)    : 벗은(?) 호출규약
(5) this        : this 호출규약

참고로, 이것외에 __pascal 호출규약이 있는데 Win32로 넘어오면서 더이상 지원하지 않는다. 자, 그러면 하
나씩 알아보자. 일단 코드를 보면서 눈으로 직접 확인해보자. (진실은 언제나 디버거가 말해주듯이...)

(1) __cdecl    (C 호출규약)

70:       ret = cdecl_Call(3, 4);
0040D588   push        4
0040D58A   push        3
0040D58C   call        @ILT+0(cdecl_Call) (00401005)
0040D591   add         esp,8
0040D594   mov         dword ptr [ebp-4],eax

우리는 이미 지난시간에 많은것(?)을 배웠기에 이정도 어셈블리 코드는 그야말로 껌인것이다. 함수이름은
따로 설명할 필요도 없이 __cdecl로 선언된 C 호출규약 함수이다. 먼저 매개변수가 스택에 푸쉬되는 순서
를 보자. 오른쪽 매개변수부터 거꾸로 푸쉬되는 것을 볼 수 있다. 그리고 함수가 호출되고 난 다음, 호출한
쪽에서 명시적으로 스택을 정리하는것 또한 확실히 알 수 있겠다.

그럼, 실제 함수의 구현을 보자.

19:   // C 호출규약
20:   int __cdecl cdecl_Call(int a, int b)
21:   {
00401040   push        ebp
00401041   mov         ebp,esp
00401043   sub         esp,40h
00401046   push        ebx
00401047   push        esi
00401048   push        edi
00401049   lea         edi,[ebp-40h]
0040104C   mov         ecx,10h
00401051   mov         eax,0CCCCCCCCh
00401056   rep stos    dword ptr [edi]
22:       return a+b;
00401058   mov         eax,dword ptr [ebp+8]
0040105B   add         eax,dword ptr [ebp+0Ch]
23:   }
0040105E   pop         edi
0040105F   pop         esi
00401060   pop         ebx
00401061   mov         esp,ebp
00401063   pop         ebp
00401064   ret

함수가 열리면서("{") 함수시작코드와 함께 에러처리를 위한 0xCC(int 3)이 지역변수공간에 쫙 깔리는걸 보
니 디버그모드인걸 알수 있다. 어쨋든 중요한것은 맨 마지막 ret 문이다. 그냥 반환되는걸 알 수 있다. 왜
냐면 아까 함수를 호출한쪽에서 스택을 정리해주기 때문에 호출되는 함수에서는 그냥 리턴만 하면된다.

(2) __stdcall (표준 호출규약)

72:       ret = stdcall_Call(3, 4);
0040D597   push        4
0040D599   push        3
0040D59B   call        @ILT+5(stdcall_Call) (0040100a)
0040D5A0   mov         dword ptr [ebp-4],eax

보아하니 __stdcall 호출규약 또한 매개변수를 오른쪽에서 왼쪽으로 스택에 푸쉬한다. 그런데? 함수호출이
수행되고 난 다음 아까보았던 스택 정리코드(add esp, 8)가 빠져있는걸 볼 수 있다. 이걸로 보아 호출되는
함수내부에서 스택을 정리한다고 미루어 짐작할 수 있다.

25:   // 표준 호출규약
26:   int __stdcall stdcall_Call(int a, int b)
27:   {
00401070   push        ebp
00401071   mov         ebp,esp
00401073   sub         esp,40h
00401076   push        ebx
00401077   push        esi
00401078   push        edi
00401079   lea         edi,[ebp-40h]
0040107C   mov         ecx,10h
00401081   mov         eax,0CCCCCCCCh
00401086   rep stos    dword ptr [edi]
28:       return a+b;
00401088   mov         eax,dword ptr [ebp+8]
0040108B   add         eax,dword ptr [ebp+0Ch]
29:   }
0040108E   pop         edi
0040108F   pop         esi
00401090   pop         ebx
00401091   mov         esp,ebp
00401093   pop         ebp
00401094   ret         8

실제로 함수구현을 보니까 정말로 마지막 반환코드(ret 8) 스택포인터을 8바이트 올리고 리턴하는걸 볼 수
있다. "ret n" 은 n바이트 만큼 esp를 증가한 후 에 리턴하는 어셈블리 명령이다. 그럼, 여기서 문제 ret 8 
이 수행되고 나면 esp(스택포인터)는 얼마만큼 증가할까? 8바이트라고? 그렇게 간단하면 왜 물어보겠는가.
정답은 12바이트(0xC)이다. 왜냐하면 ret 명령이 스택에서 리턴주소를 꺼내온뒤 그곳으로 점프(jmp)하기 때
문이다. (지난시간에 call 명령과 함께 비교하며 설명했다. 기억해두자, call과 ret명령은 내부적으로 스택
에 리턴주소를 위한 push, pop을 수행한다.)

(3) __fastcall (빠른 호출규약)

__fastcall 호출규약은 이름 그대로 빠른 호출을 위해서 사용되어진다. 메모리보다 상대적으로 빠른 레지스
터를 이용한다고 해서 레지스터 호출규약(Register calling convention)이라고도 한다. 그러나 Win32 사용
자모드에서는 원칙적으로 레지스터 호출규약을 사용하지 않는다. 다음강좌인 VxD 강좌때 보게 되겠지만,
VxD 시스템 함수들은 레지스터 호출규약을 사용하는 것들이 상당수 존재한다. 그러나 Windows NT/2000 커널
모드 드라이버에서는 더이상 레지스터 호출규약을 사용하지 않는데 그 이유는 레지스터는 CPU에 종속적인
저장매체이기때문에 CPU간 호환성을 보장할 수 없기때문이다.

74:       ret = fastcall_Call(3, 4, 5, 6);
0040D5A3   push        6
0040D5A5   push        5
0040D5A7   mov         edx,4
0040D5AC   mov         ecx,3
0040D5B1   call        @ILT+35(fastcall_Call) (00401028)
0040D5B6   mov         dword ptr [ebp-4],eax

호출하는 쪽을 보게 되면, 첫번째와 두번째 매개변수를 ecx, edx 레지스터에 담아서 호출하는것을 볼 수 있
다. 그러나 레지스터의 수는 한정되어 있으므로 모든 매개변수를 레지스터에 담을수는 없다. 따라서 처음
두개의 매개변수만 ecx, edx에 담고 나머지는 스택을 통하는데 이때에도 마찬가지로 오른쪽에서 왼쪽으로
스택에 푸쉬하고, __stdcall과 마찬가지로 호출되는 함수쪽에서 스택을 정리한다.

31:   // 빠른 호출규약
32:   int __fastcall fastcall_Call(int a, int b, int c, int d)
33:   {
004010A0   push        ebp
004010A1   mov         ebp,esp
004010A3   sub         esp,48h
004010A6   push        ebx
004010A7   push        esi
004010A8   push        edi
004010A9   push        ecx
004010AA   lea         edi,[ebp-48h]
004010AD   mov         ecx,12h
004010B2   mov         eax,0CCCCCCCCh
004010B7   rep stos    dword ptr [edi]
004010B9   pop         ecx
004010BA   mov         dword ptr [ebp-8],edx
004010BD   mov         dword ptr [ebp-4],ecx
34:       return a+b+c+d;
004010C0   mov         eax,dword ptr [ebp-4]
004010C3   add         eax,dword ptr [ebp-8]
004010C6   add         eax,dword ptr [ebp+8]
004010C9   add         eax,dword ptr [ebp+0Ch]
35:   }
004010CC   pop         edi
004010CD   pop         esi
004010CE   pop         ebx
004010CF   mov         esp,ebp
004010D1   pop         ebp
004010D2   ret         8

함수의 구현부이다. 그런데 뭔가 좀 이상한것이 있다. 빠른 호출을 위해서 레지스터를 통해서 매개변수를
넘겨준다고 했는데, 매개변수로 넘어온 레지스터를 다시 지역변수 공간(ebp-알파)에 담아서 사용하는것을
볼 수 있다. 이건 예제가 디버그모드로 빌드되었기 때문인데 릴리즈로 빌드된 어셈블리 코드는 다음과 같
다.

00401020   lea         eax,[ecx+edx]
00401023   mov         edx,dword ptr [esp+4]
00401027   mov         ecx,dword ptr [esp+8]
0040102B   add         eax,edx
0040102D   add         eax,ecx
0040102F   ret         8

lea 명령이 처음나온것 같은데, lea 명령은 아주 자주 쓰이는 명령이니까 잘 알아두자.
lea (Load Effective Address)
: 오른쪽 피연산자의 주소(메모리)를 왼쪽 피연산자(레지스터)로 전송한다. 보통 C언어 포인터변수를 설정
하는 문장에서 사용된다.

처음 두개의 매개변수는 eax와 ecx 레지스터로 취급하고 나머지 레지스터는 스택을 이용하는것을 볼 수 있
다.

(3) __declspec(naked) (벗은(?) 호출규약)

해석이 조금 이상한데 의미상으로는 스택프레임을 설정하는 기존의 함수구조를 위한 함수시작코드와 함수종
료코드를 제공하지 않는 다는 의미이다. 그러므로 이것을 __cdecl 형태로 구현해서 사용하던, __stdcall 형
태로 사용하던 그것은 순전히 사용자 맘이다. naked 호출규약은 엄밀한 의미로는 호출규약이라고 할수 없는
데, 왜냐면 이것은 함수의 구현부에서 함수가 구현되는 방법의 문제이지 앞에서의 호출규약처럼 함수의 선
언의 문제가 아니기 때문이다. 어쨋든 코드를 보자.

76:       ret = naked_Call(3, 4);
00401159   push        4
0040115B   push        3
0040115D   call        @ILT+25(naked_Call) (0040101e)
00401162   add         esp,8
00401165   mov         dword ptr [ebp-4],eax

매개변수는 위의것과 동일하게 오른쪽에서 왼쪽으로 푸쉬되며, __cdecl 과 마찬가지로 호출한 쪽에서 스택
을 복원해줌을 알 수 있다. 이것은 naked 호출규약이라서 그런게 아니고 디폴트 호출이 일어난것이다. 아
까 말했듯이 naked 호출규약은 함수구현의 문제이다.

// 벗은(?) 호출규약
__declspec(naked) int naked_Call(int a, int b)
{
   //return a+b;
   __asm
   {
       push     ebp             // 함수 시작코드
       mov        ebp, esp

       mov        eax, a
       add        eax, b

       mov        esp, ebp    // 함수 종료코드
       pop        ebp
       ret
   }
}

디스어셈블된 코드가 아니고 그냥 C 코드를 보였다. 왜냐면 인라인어셈블리를 사용했기 때문이다. 보다시
피 함수시작코드와 함수종료코드를 사용자 스스로 구현해주어야 한다는 것을 알 수 있다.

(5) this : this 호출규약

마땅히 붙일 이름이 애매한데 C++에서 this 포인터가 어떻게 처리되는지를 정의해놓은 규약이라고 보면된
다. 역시 코드를 보면,

78:       This_Call thiscall;
79:       ret = thiscall.Call(3, 4);
00401168   push        4
0040116A   push        3
0040116C   lea         ecx,[ebp-8]
0040116F   call        @ILT+10(This_Call::Call) (0040100f)
00401174   mov         dword ptr [ebp-4],eax

다른것들은 별로 눈여겨볼것이 없는데, 함수를 call 하기 직전에 무언가(지역변수로 추정되는)를 ecx에
lea 명령으로 로드한뒤 call해주는 것을 볼 수 있다. 여기서 ecx 레지스터가 바로 this 포인터이다. 클래스
의 멤버함수를 부를때 C++ 컴파일러는 ecx 레지스터를 클래스 인스턴스 자신을 가리키는 포인터를 담는 용
도로 사용한다는 것이다. 따라서 멤버함수는 자기가 언제 어디에서 호출되었다 하더라도 나를 부른 녀석이
어떤 인스턴스인지를 정확히 알 수 있다. (물론 이  ecx 레지스터를 이용해서 재미있는 장난을 해볼수도 있
을텐데, 시간이 남는 사람은 한번 이것 저것 해보기 바란다.)


56:   // this 호출규약
57:   class This_Call
58:   {
59:   public:
60:       int Call(int a, int b)
61:       {
004011C0   push        ebp
004011C1   mov         ebp,esp
004011C3   sub         esp,44h
004011C6   push        ebx
004011C7   push        esi
004011C8   push        edi
004011C9   push        ecx
004011CA   lea         edi,[ebp-44h]
004011CD   mov         ecx,11h
004011D2   mov         eax,0CCCCCCCCh
004011D7   rep stos    dword ptr [edi]
004011D9   pop         ecx
004011DA   mov         dword ptr [ebp-4],ecx
62:           return a+b;
004011DD   mov         eax,dword ptr [ebp+8]
004011E0   add         eax,dword ptr [ebp+0Ch]
63:       }
004011E3   pop         edi
004011E4   pop         esi
004011E5   pop         ebx
004011E6   mov         esp,ebp
004011E8   pop         ebp
004011E9   ret         8

구현부는 별로 볼게 없다. 단지 this 포인터라고 알려진 ecx 레지스터를 지역변수 공간으로 로드하는것을
볼수 있는데 이것 또한 디버그 모드이기때문에 생기는 코드일것이라고 생각된다. (궁금하면 직접 확인해보
시길...)

자, 모두 5개의 호출규약에 관해서 각각의 호출규약이 실제로 어떤 형태로 구현되는지를 완벽하게 이해했을
거라고 믿는다. 몇가지 덧붙이자면 실제로 대다수의 Win32 API 는 __stdcall 로 지정되어 있다. 우리가 항
상 보는 WinMain() 함수 또한 __stdcall 이다.
마이크로소프트는 추후 확장성을 고려해서 재정의된 데이터타입(WORD, DWORD 등)을 사용하는것을 권장하는
데 같은 맥락으로 함수 호출규약또한 재정의된 형태로 사용하는것을 권장한다. 그럼 Win32 SDK 에서 재정의
된 호출규약이 어떤것인지 잠깐 알아보자.

CDECL        __cdecl
WINAPI        __stdcall
APIENTRY    __stdcall
CALLBACK    __stdcall
PASCAL        __stdcall
WSAAPI        __stdcall
FASTCALL    __fastcall

CDECL을 제외한 우리가 많이 보던 CALLBACK, WINAPI니 하는 것들은 모두 __stdcall 로 처리되는것을 알  수
있다. (사족을 달자면, 꼭 저렇지만은 않다. 어떤 환경이냐에 따라서 약간씩 다르게 전처리 되기는 하지
만, 일반적인 Win32환경에서 특별히 신경쓰지 않는한 저 규칙을 벗어나지 않을 것이다.) 아참, 잊을 뻔 했
는데 VC++에서는 특별히 컴파일옵션을 만지지 않는한 디폴트로 __cdecl 호출규약을 사용한다.

자, 그러면 호출규약에 대해서는 직접 확인해보았으니 더이상 의문이 없을것 같지만, 왜 이렇게 여러가지
호출규약들이 존재해야 하는지는 궁금해할 사람들이 있을지 모르겠다. 일단 크게 __cdecl 과 __stdcall 호
출규약의 커다란 차이점은 스택을 어디에서 정리해줄 것이냐의 차이점이다. 전자는 호출하는 쪽에서 후자
는 호출되는 함수내부에서 스택을 정리해준다. 상식적으로 생각해서 호출받는 쪽에서 스택을 정리하는
__stdcall 쪽이 더 합리적이라고 생각될지 모르지만, __cdecl 이 필요한 이유는 따로 있다.

xprintf 계열의 가변인자 함수에서 그 이유를 찾을 수 있다. __stdcall 함수의 제작자들은 함수가 매개변수
의 개수(정확히 말하면 매개변수를 받는 스택의 크기이다.)를 알고 있다는 가정하에서 함수를 구현하기를
원했다. 함수의 재사용성과 모듈화를 극대화하기 위한 조치였다고 볼 수 있는데, 이런 형태의 구조에서는
가변인자를 구현할 수 없다는 단점이 있었다. 일단 가변인자를 구현하려면 호출받는쪽에서 몇개의 매개변수
가 들어올지 모르기 때문에 호출받는쪽에서 스택을 정리해줄 수가 없었고, 오로지 호출하는쪽에서만이 매개
변수의 수를 알고 있을뿐이었다. 그러므로 당연히 가변인자를 지원하는 함수들은 스택을 호출하는쪽에서 정
리해주어야만 했고 따라서 __cdecl 호출규약이 필요하게 된것이다. 사실, 이러한 호출규약에 따른 분류는
다분히 C언어의 관점에서 보았을때 그렇다는 말이다. 실제 CPU의 관점에서는 우리가 스택을 이용하던 레지
스터를 이용하던 함수를 구현하던 그냥 쭉 흐르는듯이 프로그램이 진행되던 별 상관없다. 그냥 시키는대로
만 할 뿐이다. 단지 운영체제의 많은 부분이 C언어로 구현 되어 있으며, 우리가 후킹하려는 API또한 대부분
C언어로 구현되어 있으므로 C언어가 함수를 어떻게 구성하는지가 중요한것이다.

아, 갑자기 생각났는데 실제 함수의 이름은 내부적으로는(정확하게 말하면 Link시에) 다른이름으로 변환되
어져서 사용되는데 __cdecl 함수는 앞부분에 밑줄이 붙고 __stdcall 함수들은 앞부분에 밑줄과 더불어서 뒤
에 @와 매개변수의 크기(스택의 크기)가 붙는다. 이것만 보더라도 __stdcall 함수들은 함수스스로가 매개변
수의 크기를 지정해두었다는 것을 알 수가 있다. (확인을 위해서는 extern "C" 문장으로 C 타입으로 함수
를 선언해주어야만 한다. C++은 다중정의(오버로드)를 구현하기 위해 함수 이름을 더 복잡하게 만들어버리
기 때문에 사람이 확인하기가 쉽지 않다.)

2. 흐름제어

흐름제어의 가장 중요한 요소인 조건문과 반복문에 대해서 알아보자. 이쯤되면 척하면 척일텐데 벌써 조건
문과 반복문 예제를 만들어서 디스어셈블하고 있는 저 친구를 보라 !! (참, 영특하지 않은가? ^^)

(1) 조건문

67:       if(a > 3)
0040115F   cmp         dword ptr [ebp-4],3
00401163   jle         main+34h (00401174)
68:       {
69:           printf("3 보다 크다. !!\n");
00401165   push        offset string "3 \xba\xb8\xb4\xd9 \xc5\xa9\xb4\xd9. !!\n" (00422f84)
0040116A   call        printf (0040d780)
0040116F   add         esp,4
70:       }
71:       else
00401172   jmp         main+41h (00401181)
72:       {
73:           printf("3 보다 작거나 같다. !!\n");
00401174   push        offset string "3 \xba\xb8\xb4\xd9 \xc0\xdb\xb0\xc5\xb3\xaa \xb0\xb0\xb4
\xd9. !!\n" (0042
00401179   call        printf (0040d780)
0040117E   add         esp,4
74:       }

이제는 이정도만 봐도 알 수 있을것이다.

조건문에서 사용되는 어셈블리 명령어는 매우 여러가지이지만 그 방식은 거의 동일하다. 일단 cmp 명령을
만났다. 딱 보니까 지역변수 a와 정수 3을 비교하는것 같 다. 그 아래 jle 명령을 보자. 이름에서 느껴지는
뉘앙스가 웬지, 작거나 같으면 점프(Jump if less or equal)을 연상시키지 않는가? 우리가 생각한 그대로
다. 정확하게 두번째 블럭안으로 점프하게 된다. 그럼 그렇지 않다면 그 다음 문장, 바로 3보다 큰경우를
수행하는 첫번째 블럭을 수행하고 두번째 블럭을 건너뛰고 진행하게 된다.

자, 전체적인 흐름은 이해가 될것인데 좀더 구체적으로 살펴보자. 단순히 cmp 하나의 명령으로 어떤놈이 작
고, 큰지, 또는 같은지를 알 수 있을까? 이것은 플래그 레지스터라는 놈을 이해하면 된다. 플래그 레지스터
가 각각의 상태를 표현하기 위해 특정한 값들을 갖게된다. 예를 들면 cmp 명령으로 비교한 두개의 값이 같
다면 ZR 플래그(또는 ZF)가 1로 세트된다. 이렇게 cmp나 test등의 비교명령은 플래그 레지스터를 이용해서
그 결과를 나타내는데 우리는 이러한 플래그들을 가지고 원하는 실행위치를 점프하게되는데, 이럴때 사용하
는 것이 바로 jxx 계열의 조건점프명령이다. 우리는 지금까지 jmp 명령만을 보았는데 이것은 무조건 점프하
는 명령이라면 아래에 리스트된 조건점프 명령들은 플래그들의 조합으로 비교의 결과를 인식하고 조건에 맞
는 경우에만 점프하게 된다.

je (jump if equal)    : 같다면 점프한다. ( == )
jne (jump if not equal)    : 같지 않다면 점프한다. ( != )
jl (jump if less)    : 작다면 점프한다. ( < )
jg (jump if greater): 크다면 점프한다. ( > )
jge (jump if greater or equal)    : 크거나 같다면 점프한다. ( >= )
jle (jump if less or equal)        : 작거나 같다면 점프한다. ( <= )

뭐, 별로 어려울건 없을것이다. 실제로 매크로어셈블러 문서를 살펴보면 알게되겠지만 조건점프명령은 저
것 말고도 여러가지가 더 있다. 명령어에서 e가 들어간 명령은 z로 바뀐 다른명령어로도 사용되는데 둘의
의미는 동일하다. 예를 들면 je와 jz는 동일하게 동작한다. 의미는 jz은 zero flag가 설정된 경우에 점프한
다라는 의미이지만 비교결과가 같다면 zero flag가 설정되기 때문에 결과적으로 같은 동작을 수행한다. (실
제 바이트코드를 보면 정말 그런지 알수 있을것이다. 이런것들은 초보자들에게 약간의 혼란을 가져오는데
실제로 Intel CPU 매뉴얼과 매크로어셈블러 매뉴얼이 표기하는 명령어 형식이나 플래그 레지스터의 이름등
에서 약간의 차이가 있다. 사실 같은 의미인데 이름을 조금씩 다르게 지정해놓은것이 몇가지 존재한다.)

그런데, 어셈블리 명령과는 별개로 위의 디스어셈블 코드를 살펴보면 재미있는것을 발견할 수 있을텐데, 그
것은 바로 우리가 제시했던 조건과는 반대되는 조건으로 검사하고 분기한다는 점이다. 우리는 3보다 크다면
을 검사했는데 실제 디스어셈블된 코드를 보면 3보다 작거나 같다면으로 검사한다는 것이다. 이것은 컴파일
러가 어셈블리코드를 생성할때 가급적 jmp명령을 사용하지 않기위해서 최적화를 수행하는 것이다. 실제로
반대로 구현해보면 jmp 명령을 두번 사용해야 한다는것을 알 수 있을것이다. 이것은 실제로 어셈블리로 프
로그래밍할때에는 기본적인 사항인데 우리가 원하는 조건의 반대조건으로 검사하는 것이 프로그램을 더 작
고 빠르게 만든다. jmp명령은 실행위치를 변경하는 상대적으로 비싼대가를 치뤄야 하는 명령이기 때문이다.

(2) 반복문

76:       for(int i=0; i<5; i++)
0040D7F1 C7 45 F8 00 00 00 00 mov         dword ptr [ebp-8],0
0040D7F8 EB 09                jmp         main+53h (0040d803)
0040D7FA 8B 45 F8             mov         eax,dword ptr [ebp-8]
0040D7FD 83 C0 01             add         eax,1
0040D800 89 45 F8             mov         dword ptr [ebp-8],eax
0040D803 83 7D F8 05          cmp         dword ptr [ebp-8],5
0040D807 7D 0B                jge         main+64h (0040d814)
77:       {
78:           a++;
0040D809 8B 4D FC             mov         ecx,dword ptr [ebp-4]
0040D80C 83 C1 01             add         ecx,1
0040D80F 89 4D FC             mov         dword ptr [ebp-4],ecx
79:       }
0040D812 EB E6                jmp         main+4Ah (0040d7fa)

C언어에서 가장 많이 사용되는 for()문이다. 일단 지역변수 i의 값을 0으로 세트하고 바로 점프하는 명령
이 있다. 어디로? 바로 조건식으로 점프한다. 조건식은 정수 5와 i를 비교한후, 크거나 같다면(역시 반대
의 조건으로 검사하는것을 볼 수 있다.) 반복문을 탈출한다. 반복문 내에서는 지역변수 a를 1 증가하고, 지
역변수 i를 1 증가하는 증감식으로 점프한다. 그런다음 다시 조건문으을 실행한다. 뭐 별로 어려울것 없
다. 그럼 다음은 while()문을 보자.

76:       int i = 5;
0040D7F1 C7 45 F8 05 00 00 00 mov         dword ptr [ebp-8],5
77:       while(i--)
0040D7F8 8B 45 F8             mov         eax,dword ptr [ebp-8]
0040D7FB 8B 4D F8             mov         ecx,dword ptr [ebp-8]
0040D7FE 83 E9 01             sub         ecx,1
0040D801 89 4D F8             mov         dword ptr [ebp-8],ecx
0040D804 85 C0                test        eax,eax
0040D806 74 0B                je          main+63h (0040d813)
78:       {
79:           a++;
0040D808 8B 55 FC             mov         edx,dword ptr [ebp-4]
0040D80B 83 C2 01             add         edx,1
0040D80E 89 55 FC             mov         dword ptr [ebp-4],edx
80:       }
0040D811 EB E5                jmp         main+48h (0040d7f8)

위의 for()문과 똑같은 일을 수행하는 while()문이다. 역시 별로 어려울것 없으니 스스로 따라가보자.

여기서 또하나 재미난것은, 실제 어셈블리 명령어중에 loop라는 명령어가 존재한다. loop 명령은 ecx 레지
스터에 지정된 수만큼 반복작업을 수행하는데 실제로 마이크로소프트 C컴파일러는 loop 명령을 사용하지 않
는 경우가 대부분이다. 위의 예처럼 조건분기 명령을 사용해서 반복문을 구현한다. 혹시 디버그모드라서?
그렇다면 릴리즈로 빌드한뒤 디스어셈블된 코드를 살펴보자. 그러나 릴리즈모드에서도 별로 다르지 않다.
(음... 쓰고나서 보니까 위의 예제를 릴리즈로 빌드하면 아마도 반복문이 사라져버릴것이다. 왜냐면 반복문
에서 하는 유일한 작업인 a++; 문장은 프로그램 어디에도 영향을 주지않는 있으나마나한 코드이기 때문에
컴파일러가 릴리즈 최적화를 수행하면서 무시될것이다. 어쨋든 유효한 반복구문을 사용해서 확인해보자.)

또 참고로 얘기하면 일반적으로 while()문이 for()문보다 빠르다고 알려져있는데 위의 코드를 보면 왜 그런
지 확실히 알 수 있을것이다. (코드길이가 작아서? 음... 것도 틀린건 아니지만 보다 중요한것은 for()문보
다 while()문이 jmp 명령을 더 적게 사용하는것을 볼수 있다. 아까도 말했듯이 jmp 명령은 상대적으로 비
싼 명령이다.)

3. 끝으로

이상으로 Win32 어셈블리 프로그래밍에 대한 강좌를 마칠까 한다. 사실 2회의 강좌로 어셈블리를 모두 이해
할수 있을거라고는 생각하지 않지만, 적어도 C언어로 작성한 프로그램이 어떠한 형태로 구현되는지에 대한
감은 잡았을거라고 생각한다. 원래 지난시간에 약속한대로 Win32의 강력한 에러처리 메카니즘인 구조화 예
외처리(SEH)에 대해서도 다루려고 했지만 시간상 미처 다루지 못했다. 실제로 API 후킹을 구현하게 되면 반
드시 예외처리를 해주어야 하는데 그 이유는 시스템 DLL을 건드리는 작업은 매우 조심스러운 작업이기때문
에 문제가 생기면 시스템을 재부팅해야 하는 경우가 비일비재하기 때문이다. 따라서 문제가 발생하면 즉시
에러를 복구하고 정상적으로 종료함으로써 다른 프로그램들에게 영향을 주지 않아야 한다. 구조화 예외처리
에 관해서는 다음번 Hooking Code를 작성하는 부분에서 다시 얘기할 기회가 있으니, 그때 다루기로 하자.

필자가 소개한 어셈블리에 대한 내용은 극히 일부분이거나 기초가 되는 수준이라는것을 잊지말자. Intel
x86 계열 CPU는 오늘 소개한 명령이외에도 문자열처리나 멀티미디어 데이터처리 등에 관한 무수히 많은 명
령어들을 제공하며, 어셈블러 또한 무수히 많은 의사명령과 예약어등을 제공한다. 이 모든것을 모두 알아
야 할 필요는 없다고 해도, 분명 자바나 베이식만을 다루는 사람과 어셈블리까지 아는 사람과는 프로그래밍
을 바라보는 시각에서부터 엄청난 차이가 난다고 믿어 의심치 않는다.

4. 다음강좌에서는

이미 예고한대로 다음강좌에서는 Windows9x에서 지원하는 디바이스드라이버인 VxD에 대해서 얘기하도록 하
겠다. 내용의 방대함으로 모든것을 다룰수 없음에 우리가 필요한 부분만을 구현하는 쪽으로 강좌의 촛점을
맞춰나가겠다. 더불어서 보호모드와 운영체제 실행권한인 Ring0에 관한 설명과 디바이스드라이버를 통하지
않고 Ring0를 얻어내는 Windows의 뒷문(?)에 대해서도 얘기해보자. 디바이스드라이버에 대한 경험이 전혀
없는 독자라면 다음 강좌부터는 실습을 따라가는것조차 버거울텐데 그 이유는 아직도 디바이스드라이버 개
발환경이라는것이 원시적이기 짝이 없기때문이다. 일단 VC++같은 멋진 통합환경과 디버거를 기대한다면 일
찌감치 다른일을 찾아보는것이 빠를것이다. 내용이 내용이니만큼 준비해야할 것들과 다음강좌의 진행내용
을 전체적으로 알아보자.

(1) Window 98 DDK(DeviceDriver Development Kit)
: 마이크로소프트 DDK 홈페이지(http://www.microsoft.com/ddk/)에 가면 구할수 있다. 각 운영체제별로 DDK
를 따로 제공하는데, 우리는 일단 98에서 작업할것이므로 98용 DDK를 다운로드받길 바란다.

(2) Numega SoftIce
: 지난강좌에서 언급했던 John Robbinson이나 Matt Pietrek이 시스템엔지니어로 근무하는 Numega Software
에서 제공하는 시스템 디버거이다. 커널모드와 사용자모드를 둘다 지원하는 거의 유일한 디버거이며, 크래
커들의 필수품으로 애용될만큼 디버거보다는 크래킹 도구로 널리 알려져 있다. 문제는 이것은 상용프로그램
이란점인데(가격또한 만만치 않다.) 회사오너를 졸라서 구입하든 또 다른 경로(?)를 통해서 구하든 각자 알
아서 구해오도록 하자. (필자에게 보내달라고 하지는 말기 바란다. 필자는 아직 딸린식구는 없지만, 앞길
이 구만리같은 젊은이기에... ^^)

(3) 그밖에 익숙한 텍스트편집기
: 정 쓸만한게 없다면 VC++를 사용해도 된다. 그리고 취향에 따라 다르겠지만 노트패드나 DOS용 에디터를
사용해도 무관하다. 필자의 경우는 울트라에디터를 사용하는데 프로그래머를 위한 지원이 잘되어 있는 편집
기라고 생각한다.

<잡설>
서두에도 언급했듯이 많은분들이 관심을 보여주셨는데요. 그 결과 무수히 많은(약간 과장해서... ^^) 질문
메일이 오곤 했습니다. 일일이 답변해드리지 못한점 사과드리고요. 질문들에 대한 답변이나 그밖에 잡다한
것들을 모아서 다음 강좌이전에 가벼운 내용의 쉬어가기 형식으로 한번 올리겠습니다. 그럼 다들 감기조심
하시고 언제나 즐기는 프로그래머가 되시길...
Posted by skensita
Programming/Win32 API2008. 12. 1. 15:39
Win32 Global API Hook - 제3강 Win32 어셈블리 프로그래밍 (1)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

날씨가 많이 쌀쌀해졌죠? 혹시 감기들 안걸리셨는지... 자, 프로그래밍하기에 좋은 계절이 돌아왔습니다.
가을은 프로그래머의 계절이죠. 무더운 여름날, 모니터와 본체에서 뿜어내는 열기를 온몸으로 느끼며, 밤
을 잊던 나날들은 가고 서늘한 바람과 파란하늘이 유난히 서러운 가을이 왔습니다. 지금 이시간에도 모니터
를 연인의 얼굴삼아, 키보드, 마우스를 연인의 손목삼아 밤을 잊고 연구개발에 열중하고 있는 이땅의 수많
은 프로그래머에게 한줌의 소금이 되고자 불타는 사명감(?)에 저도 밤을 잊었습니다. ^^

지난 강좌까지 잘 따라오셨는지 궁금하군요. 지난 예제를 수정해서 다른 프로세스의 API 후킹에 성공하신
분들도 계실테고, 아직도 헤메는 분들도 계실지 모르겠습니다. 어쨋든 지난 강좌까지가 개념적인 내용의 기
초라면 오늘부터 진행할 강좌는 실질적인 기초라고 할수 있겠습니다. 그러나 기초가 제대로 잡히면 그 다음
의 응용은 아주 간단한것(물론, 거짓말이란거 다 아시죠?)들이니까 기초부터 튼튼히(!!)하자구요. ^^ 그
럼, 어느 성인(?)의 말을 인용하면서 강좌나갑니다.

"무릇 프로그래밍이라 하는것은 현자가 우매한 중생들에게 진리를 설파하는것과 같다. 오로지 그 수의 많
고 적음에 상관없이 소유와 무소유만을 기대하는 우매한 자들에게 그들만이 이해할수 있는 방법으로 세상
을 바르게 살아가는 법을 깨우쳐주는것이다."

(강좌의 진행상 존칭을 생략하겠습니다.)

1. 어셈블리, 참을수 없는 구조의 단순함 !!

흔히들 어셈블리를 어렵다고들 한다. 그러나 이것은 정확히 말하면 어셈블리로 프로그래밍하기가 어렵다는
의미이다. 왜냐면 어셈블리 자체는 매우 단순하기 때문이다. 어셈블리 자체가 매우 단순하기때문에 그 단순
한 기능만으로 요즘 추세에 맞는 복잡한 어플리케이션을 작성하기가 매우 번거롭다는것이다. (물론 최근에
나온 어셈블러들은 고급언어에 버금갈만한(물론 이것도 사실은 아니다. 예전에 비해서는 그렇다는 말이
다.) 코딩의 편의를 제공하기는 하지만) 필자가 예전에 처음 프로그래밍을 공부한지 얼마되지 않았을때 어
셈블리와 C언어의 차이점을 선배프로그래머가 이렇게 얘기했다. 여기서는 C언어를 얘기하지만 대부분의 고
급언어로 이해해도 무난할것이다.

"C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어두어라!!"라는 식으로 명령한다면, 어셈블러는 "바나나
를 들고 냉장고 앞으로 이동한다음, 냉장고 문을 열고, 사과를 꺼낸다음, 그 자리에 바나나를 넣고, 냉장
고 문을 닫아라!!" 이런식으로 명령되어져야 한다"

지금 생각하면 정말 명쾌한 설명이 아닐수없다. 실제로 C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어
라"라고만 하면 스스로 냉장고 앞으로 이동해야 한다는것, 냉장고 문을 열어야 한다는것, 사과를 꺼내고,
바나나를 넣어야 하며, 마지막으로 냉장고문을 닫아야 하는것을 이해하지만, 어셈블러는 이러한 일련의 작
업을 일일이 지정해주어야 한다는 것이다. 그렇지만 C언어든 어셈블리든 결국 행동하는 패턴은 같을수밖에
없으며, 단지 명령하는 사람의 명령을 어디까지 이해할것인가의 문제인것이다. 결과적으로 C언어가 어셈블
리보다는 더욱 똑똑하다는 것이다.

모든것은 상대적인 것일수밖에 없다. 그 옛날 어셈블러도 없이 기계어로 프로그래밍하던 세대에게 어셈블러
는 매우 편리한 도구였다. 실제로 DOS용 "LOTUS123"같은 OA프로그램이나, 당시에 엄청난 인기를 끌었던 "페
르시아왕자" 1탄 같은 프로그램들이 순전히 어셈블리로 제작되어었다는 것만 보더라도 그 시대에서는 어셈
블리가 지금처럼 어렵고 생소한 언어가 아니었을것이다. 단지 우리는 어셈블리보다는 똑똑한 C언어나 파스
칼 등의 고급언어에 익숙해져있으니 어셈블리가 생소하고 어려워보이는 것이다. (물론 예전과는 비교도 할
수없을 정도로 지금의 프로그램들은 복잡, 다양해지고 있지만) 서두에 언급했듯이 우매한 어셈블리가 세상
에서 바르게 살아나갈수 있도록 우리는 조금더 현명해져야 하며, 그들을 이해하며 눈높이를 맞출수있는 열
린마음을 가져야할때다.

어셈블리가 별것아니라는 얘기만 했는데 그렇다고 어셈블리가 정말로 별것 아닌것은 아니다. 아마 개발환경
이 아무리 편해지더라도 어셈블리가 세상에서 사라지지는 않을것이라는 필자의 생각이다. 그 이유는 다름아
닌 CPU가 명령을 처리하는 가장 하위단계는 잘 알다시피 기계어코드이다. 그러나 사람이 기계어만으로 프로
그래밍을 하거나, 기계어코드만 가지고 명령을 이해하기는 매우 힘든작업이므로(불가능한것은 아니다. 예전
에는 사람이 이런 작업을 하기도 했었다. 또 우리도 그러한 일을 곧 하게될것이다.) 기계어를 사람이 이해
하는 형식으로 표현해주어야 하는데 이것이 바로 어셈블리이다.

조금 과장해서 말한다면 어셈블리를 알면 프로그램의 흐름을 분석할 수 있으며, 원한다면 프로그램을 원하
는대로 수정할수도 있다. 실제로 디바이스드라이버의 경우, 바이너리를 역어셈블(바이너리파일을 어셈블리
코드로 변환하는것)해서 C코드를 얻어내는 작업을 실제로 행하기도 한다. 이러한 작업들을 통틀어서 역공학
(reverse engineering)이라고 하는데 실제로 쉐어웨어를 크랙하거나 패치하는 작업들도 어찌보면 간단한 수
준의 역공학이라고 부를수 있겠다. 물론 이러한일들이 쉬운일은 아니다. 완벽한 역공학을 위해서는 어셈블
리는 물론 바이너리가 생성된 환경(보통 컴파일러를 예로 들수 있겠다.)에 대한 깊은 이해를 필요로 하며,
디버깅 심볼이 제거된 릴리즈 바이너리일 경우, 데이터와 함수주소등을 오로지 단순한 메모리주소로만 사용
하게 되며, MFC등의 클래스라이브러리를 이용하는 경우에는 그 구조의 복잡함으로 생성되는 바이너리 또한
더더욱 이해하기 힘든구조가 되어버린다. 엎친데 덮친격으로 어떠한 바이너리들은 역공학을 할수없게하거
나 힘들게 만드는 anti-debugging 코드를 삽입하는 경우도 있으니 모든 바이너리를 완벽하게 역공학하기란
정말로 힘든 일이 아닐수 없다.

다시 어셈블리로 돌아와보자. 우리가 이번 강좌에서 배우고자 하는것은 어셈블리로 프로그램을 작성하는 방
법이라기보다는 실제로 컴파일러가 어떠한 기계어코드를 만들어내는지와 CPU가 어떻게 프로그램을 해석하
고 실행하는지를 이해하기 위한 방법으로 어셈블리를 배우고자 한다. 물론 어셈블리만으로 윈도 어플리케이
션을 작성할수는 있다. 그러나 시스템 사양이 높아지고 컴파일러의 성능이 향상됨에 따라 굳이 어셈블리를
통해서 전체 프로젝트를 수행할만한 명분이 점점 사라져가는것 또한 사실이다. 실제로 예전에 많이 사용하
던 인라인 어셈블리의 경우에도 자칫하면 수행속도를 떨어뜨리는 결과를 가져오는 경우도 종종 있다.(인라
인 어셈블리나 레지스터변수의 사용등이 컴파일러의 최적화기능을 방해하는 경우가 발생할수 있기때문이
다.)

실제로 어셈블리로 프로그래밍한다는 것은 어셈블러의 기능을 익히는것이 어셈블리를 익히는것보다 크게 작
용한다. 예를 들면 아래와 같다.

; 메시지 루프
@@: ; start of loop
   invoke GetMessage, addr msg, NULL, 0, 0
   or eax, eax
   je @f
   invoke TranslateMessage, addr msg
   invoke DispatchMessage, addr msg
   jmp @b
@@: ; end of loop

필자가 사용하는 어셈블리 템플리트 코드의 일부분으로서, 우리가 API로 윈도프로그램을 작성할때 대부분
공통적으로 적용되는 메시지루프의 로직이다. 대충 훑어보면 C로 작성했을때와 비슷한점을 많이 볼수 있
다. 이것은 어셈블러가 고급언어의 특성을 지원하기 위한 의사명령을 제공하기 때문이다. 예를 들면 invoke
라는 단어를 보자. 이것은 실제로 기계어로 1:1 대응되는 명령어가 아니다. 확인하고 싶다면 C로 동일한 기
능의 프로그램을 작성한다음, 디버거의 디스어셈블리창을 통해서 실제로 기계어(어셈블리)로 변환된 코드
를 확인해보자. 분명 invoke라는 명령어는 찾을 수 없을 것이다. 이러한 것들을 통틀어서 의사명령이라고
한다. 실제로 기계어로 변환되지 않지만 어셈블러가 어셈블을 하기위한 환경이나 조건을 제시해주는 것이
다.(바로 고급언어의 특성이다.) 그뿐 아니라 @@, @f, @b 등의 라벨들 또한 실제 기계어로 어셈블될때에는
실제 주소로 치환될것이다. 이렇듯 실제로 어셈블리 프로그래밍을 배운다는 것은 단순히 어셈블리(기계어
로 치환되는)를 익히는것보다는 어셈블러가 제공하는 기능이나 문법을 배운다는 의미가 크다. 그러나 우리
는 어셈블리로 프로그램을 작성하려는 목적이 아니므로 실제로 어셈블리가 어떻게 쓰여지는가를 주로 다루
어보도록 한다.

2. CPU를 알면 프로그램이 보인다 !!

또 다시 CPU에 관한 얘기를 해야할때가 왔다. 결국 프로그램을 수행하는 주체는 CPU이므로 CPU를 모르고서
는 프로그래밍을 말할 수 없다. 어셈블리나 시스템 프로그래밍을 다루는 많은 책에서 CPU 아키텍쳐에 대한
설명이 많이 있지만, 그 모든것을 여기서 다룰수는 없으므로 자세한것들은 참고서적을 이용하도록 하고, 필
자의 생각으로 꼭 짚고 넘어가야할 몇가지만 언급하겠다. CPU를 알기위해서 CPU가 다루는 데이터와 명령을
알아야 할것이다. CPU가 다룰수 있는 데이터와 명령의 종류는 CPU마다 차이가 있고 명령의 종류 또한 한두
개가 아니지만, 우리는 Intel x86 계열의 CPU 위주로 설명해나가자. CPU가 처리하는 명령의 종류는 CPU가
업그레이드될때마다 추가되기도 확장되기도 하는데(좋은예로 MMX 코드를 들수있다. MMX 코드는 CPU가 멀티
미디어 데이터처리를 빠르게 하기위해서 지원되는 명령어세트이다.) 필요한 명령어들은 조금 있다가 알아보
도록 하고, 일단 CPU가 다루는 데이터에 대해 얘기해보자. 지난 강좌에서 CPU는 메모리만을 다룬다고 했었
는데, 사실은 메모리와 레지스터라는 기억장소를 데이터로써 다룬다. 레지스터는 CPU 내부의 임시기억장소
라고 보면 될것이다. 따라서 CPU가 다루는 가장 빠른 매체라고 보면될것이다. x86 레지스터에는 8개의 범용
레지스터와 6개의 세그먼트레지스터, 인스트럭션 포인터 레지스터와 플래그레지스터, 디버그 레지스터등,
여러가지 종류가 있는데 보통 사용자가 다룰수 있는 레지스터의 종류와 그 용도는 다음과 같다.

<레지스터의 종류와 용도>

eax    : 정수 함수의 반환값들을 담는용도로 사용된다.
ebx : 뭐 특별한건 없는것 같다. 그냥 범용으로 사용된다.
ecx : 반복문에서 카운터로 사용된다.
edx : 64비트(large integer) 값들의 상위 32비트를 담는 용도로 사용된다.

esi : 보통 메모리 이동이나 비교시에 원본주소를 담는 용도로 사용된다.
edi : 보통 메모리 이동이나 비교시에 대상(타겟)주소를 담는 용도로 사용된다.

esp : 스택포인터, 스택의 꼭대기(x86은 스택이 아래로 자라므로 실제로는 바닥이 되겠다.)를 가리킨다. 스
택에 데이터가 푸쉬하거나 팝될때마다 증감된다.
ebp : 스택프레임, 프로시저(함수)에 대한 스택프레임을 담는 용도로 사용된다.

cs : 코드세그먼트, 코드영역의 세그먼트를 지닌다. 32비트 어드레싱에서는 별의미가 없다.
ds : 데이터세그먼트, 데이터영역의 세그먼트를 지닌다. 역시 32비트 어드레싱에서는 별의미가 없다.

eip : 인스트럭션 포인터, 실행중인(정확히는 실행되어질) 코드의 주소를 가지고 있다.

이밖에도 몇가지 레지스터가 더 존재하지만 CPU가 내부적으로 사용하거나, 사용자가 건드릴수 없는것들이므
로 일단 위에 리스트된 레지스터만이라도 숙지하고 넘어가자. 어셈블리가 처음인 사람에게는 저게 무슨얘긴
가 하겠지만, 일단 외워두던가 아님 한쪽에 잘 프린트해서 붙여놓자. 나중에 실제 코드를 보게되면 이해가
될것이므로... 참고로 접두어 e가 붙은 레지스터이름은(eax, esi, eip 등...) extended의 약자로서 32비트
확장레지스터라는 의미이며, 4바이트(32비트)크기를 가지며, 기존의 16비트로 엑세스하려면 e를 뺀 ax 등
의 형태로 쓰여지며, 명시적으로 상위/하위 영역을 지정하려면 ah, al의 형태로 사용된다.

3. 구조화의 시작, 서브루틴(함수) !!

서브루틴은 보통 함수(반환값이 있는)와 프로시저(반환값이 없는)로 구분하는데 아시다시피 C언어는 프로시
저와 함수의 구별이 없이 무조건 다 함수로 취급한다. 그러므로 C언어에서는 프로시저와 함수는 동일한 의
미로 사용한다. 실제로 프로그램의 실행이라는 것이 main함수가 시작됨으로써 시작되며, main함수가 종료됨
으로써 프로그램도 종료된다고 알고있다. 이렇듯 프로그램 자체도 하나의 함수로 취급되는것이 일반적이므
로 함수가 어떻게 구현되는지에 안다는 것은 프로그램의 흐름을 이해하는데 매우 중요한 의미를 가진다.

함수(또는 프로시저)는 특정한 목적을 위해 구현된 코드덩어리이다. 함수호출은 실제로 프로그램의 지시포
인터(인스트럭션 포인터, ip)를  함수가 구현된 코드로 옮겨서 실행한뒤 원래의 호출한쪽의 코드로 되돌아
오는 일련의 작업이다. 백문이 불여일견!! 실제로 함수호출이 어떻게 구현되는지 VC++ 디스어셈블러를 통해
서 확인해보도록 하자.

간단히 아래와 같은 코드를 작성한뒤 디버거에서 디스어셈블리코드를 확인해보자.

int TestFunc(int a, char b)
{
   return a+1;
}

int main()
{
   int ret;
   ret = TestFunc(3, "a");

   return 0;
}

일단 main()함수내에서 TestFunc()을 호출하는 부분을 살펴보자.

19:       ret = TestFunc(3, "a");
00401098   push        61h
0040109A   push        3
0040109C   call        @ILT+5(TestFunc) (0040100a)
004010A1   add         esp,8
004010A4   mov         dword ptr [ebp-4],eax

어셈블리는 [명령어] [매개변수], ... 의 형식을 갖는데 일단 위에서 나오는 명령어를 살펴보자.
push 명령으로 정수 3과 문자 "a"를 스택에 집어넣는다. 이 작업으로 스택포인터는 8바이트 감소하는데, 스
택은 4바이트로 정렬되기때문에 1바이트인 문자를 넣더라도 4바이트가 감소한다. 스택포인터가 감소하는 이
유는 스택이 아래로 자라기때문이다.
call 명령으로 TestFunc 함수로 실행주소를 옮긴다(점프한다). call 명령은 jmp 명령과 더불어 실행흐름
(eip)을 변경할수 있는 명령이다.  jmp는 단순히 실행위치를 옮기는데 그치지만, call은 돌아올 주소를 스
택에 백업한뒤 실행위치를 옮긴다. 따라서 TestFunc이 호출되는 순간에는 호출되기 전보다 스택포인터는 12
만큼 감소되어 있을것이다.(직접 디버거로 확인해보라.)
add 명령으로 esp(스택포인터)를 8바이트만큼 증가시킨다. 스택을 함수를 부르기전으로 맞춰놓는작업을 해
주는 것이다. 어라? 그런데 이상하다. 아까 매개변수 8바이트 + 리턴주소 4바이트, 분명 12바이트가 감소되
었다고 했었는데? 그 이유는 다음에 나올 함수코드를 보면 이해할수 있다. 함수의 마지막에 리턴주소로 돌
아가는 ret 명령을 만나는데 이작업으로 스택에 저장된 리턴주소를 pop해주게 된다. 결국 스택은 정확하게
복원된다.(많은 책에서는 내부적으로 call 명령과 ret 명령이 리턴주소를 저장하기위해 스택을 사용한다고
설명하는데, 실제로 디버거로 따라가보면 이를 명확하게 확인할 수 있다.)

다음코드는 TestFunc 함수의 내부코드이다. 눈으로 쭉 따라가보자.

9:    int TestFunc(int a, char b)
10:   {
00401020   push         ebp            ; (1) 함수 시작코드 !!
00401021   mov         ebp,esp

00401023   sub         esp,40h        ; (2) 지역변수를 위한 공간확보 !!

00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-40h]
0040102C   mov         ecx,10h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]

11:       return a+1;
00401038   mov         eax,dword ptr [ebp+8]    ; (3) 반환값 설정 !!
0040103B   add         eax,1        

12:   }
0040103E   pop         edi
0040103F   pop         esi
00401040   pop         ebx

00401041   mov         esp,ebp        ; (4) 함수 종료코드 !!
00401043   pop         ebp
00401044   ret

일단 현재 TestFunc이 호출된 시점에서의 스택의 상태는 매개변수와 되돌아갈 주소가 담겨져있다. (아까 위
에서 매개변수 61h, 3과 call 명령이 리턴주소를 푸쉬했다.) 이것도 한번 확인해보자. 함수의 시작에 중단
점을 걸고(함수의 시작에 중단점을 설정하려면 중괄호 열기("{")에다가 중단점을 지정하면 된다.), 레지스
터윈도우에서 esp의 값을 복사한뒤 메모리윈도우에서 붙여넣어보자. 그냥 보면 틀림없이 바이트 형태로 보
여질테니까 메모리윈도에서 오른쪽 버튼을 눌러서 long hex format(4바이트 형식)을 선택해서 보자. (메모
리 형식을 변경하면 갑자기 엉뚱한 곳으로 메모리가 튀는경우가 종종있는데, 이것은 VC++ 디버거의 버그인
것 같다. 그럴땐 다시 주소를 지정해주면 된다.)

0012FF24  004010A1  00000003  00000061  
0012FF30  00000000  00000000  7FFDF000
...

필자의 시스템에서는 esp의 값이 0x0012FF24이다. 스택은 아래로 자란다고 했으니 실제 메모리상에는 리턴
주소, 3, "a" 순서로 들어있을 것이다. 보아하니 리턴주소는 0x004010a1이고, 정수 3과 "a"(아스키코드
0x61)이 올바르게 들어있는것을 볼수 있을것이다.

자, 이제 매개변수와 리턴주소가 어떤 형식으로 스택에 보관되어 있는지를 알았으니 함수를 시작해보
자. "(1)함수 시작코드"를 보자.

00401020   push         ebp            ; (1) 함수 시작코드 !!
00401021   mov         ebp,esp

일단, ebp의 값을 스택에 백업한다. 그런다음 mov 명령으로 esp의 값을 ebp로 복사(이동)한다. mov 명령은
오른쪽에 있는 값을 왼쪽으로 이동하는 명령이다. 현재 esp는 리턴주소를 가리키고 있으므로 ebp도 리턴주
소를 가리키고 있을것이다. 이것이 일반적으로 C언어에서 함수를 기술할때 사용되는 형태의 함수 시작코드
이다. 일단 이상태에서 매개변수를 접근하려면 어떻게 해야할까? esp+알파, 또는 ebp+알파의 형태로 매개변
수를 접근할수 있을것이다(위에서 확인했던것처럼). 그러나 esp(스택포인터) 함수내부에서 또 다른 함수를
호출한다든가 하는 경우에 얼마든지 변할수 있으므로 함수내부에서는 ebp를 기준으로 매개변수를 접근한
다. 자, 잘 이해가 안된다면 일단은 외워서라도 알고 있자. "esp+알파의 형식으로 함수의 매개변수(파라미
터)를 접근한다."

중간은 다음에 보도록 하고 "(4)함수의 종료코드"를 먼저 보도록 하자.

00401041   mov         esp,ebp        ; (4) 함수 종료코드 !!
00401043   pop         ebp
00401044   ret

함수의 종료코드는 함수의 시작코드와 반대의 작업을 해준다. esp의 값을 ebp로 복원한뒤, 스택에 백업된
ebp를 복원한다. pop명령은 push와 반대로 스택에 있는 데이터를 꺼내오는 일은 한다. (혹시 헷갈릴까봐 노
파심에서 얘기하면 pop ebp는 ebp를 꺼내온다는 의미가 아니고 스택에서 꺼내온 데이터를 ebp에 집어넣는다
는 의미이다. 필자는 처음에 이것을 헷갈렸던것 같은데... 다른사람들은 안그렇나?) 마지막으로 ret 명령으
로 함수를 호출한 쪽으로 되돌아간다. (이것도 정확히 말하면 함수를 호출한 바로 다음주소로 돌아간다.)
아까 call명령이 리턴주소를 스택에 푸쉬한다음 점프한다고 했는데, ret은 반대로 스택에서 리턴주소를 팝
하고 그 주소로 점프한다.

자, 그러면 나머지를 보도록하자. 먼저 "(2)지역변수를 위한 공간확보"를 살펴보자.

00401023   sub         esp,40h        ; (2) 지역변수를 위한 공간확보 !!

sub 명령은 add 명령과 반대의 기능을 수행하는 명령으로 스택포인터(esp)를 40h 만큼 감소시킨다. 이것은
스택에 지역변수를 위한 공간을 할당하는 것으로 위 명령의 실행되는 시점에서의 esp와 esp-40h의 40h만큼
의 공간을 지역변수를 위해 사용할것이라는 의미이다. 지난강좌에서 LoadLibrary를 호출할때 사용할 매개변
수인 문자열을 위해서 지역변수 공간을 사용했었다. (잘 생각이 안난다면 지난강좌에서
FAKE_LOADLIBRARY_CODE 구조체를 설정하는 InjectSpyDll() 함수를 다시 보도록 하자.) 어쨋든 여기서 또하
나 중요한 사실은 지역변수는 ebp-알파의 형태로 접근된다는 사실이다.(역시 잘 이해가 안된다면 외워서라
고 알고있자.)

여기서 재미있는 사실하나를 발견하게 된다. 사실 위의 코드는 디버그 버전으로 빌드된 바이너리의 실행코
드이다. 실제로 TestFunc()은 지역변수를 사용하지 않는데도 불구하고 지역변수를 위한 공간을 확보하고
그 공간을 온통 0xcc의 값으로 채우는것을 볼수있다. 사실 릴리즈 모드로 빌드하게 되면 TestFunc() 아래처
럼 간단한 어셈블리코드로 변환된다.

00401000   mov         eax,dword ptr [esp+4]
00401004   inc         eax
00401005   ret

어쨋든 굳이 지역변수 공간을 잡고 그 안을 0xcc로 채우 는이유를 생각해보면 먼저 0xcc의 값에 대해 알아
볼 필요가 있다. 0xcc는 어셈블리 코드로 int 3 이 다. (이전 강좌에서 본적이 있다. 바로 중단점이다 !!)
초기화되지 않은 지역변수의 값이 0xcccccccc인 것 또한 우연이 아니다. 컴파일러가 디버그 모드일때에 수
행할 에러처리를 위해 위와 같은 검사루틴을 삽인한것으로 유추할 수 있다. (일단 저런 메모리 공간으로 뛰
어들게 되면 중단점으로 인해서 디버거가 활성화될것이며 이벤트처리기는 적절한 처리를 해줄수 있을것이
다.)

마지막으로 "(3)반환값 설정"을 보자.

00401038   mov         eax,dword ptr [ebp+8]    ; (3) 반환값 설정 !!
0040103B   add         eax,1        

앞에서 ebp+알파의 형식으로 함수의 매개변수를 접근한다고 했다. 대괄호("[]") 표시는 C언어의 *(간접지정
연산자)와 비슷한 용도로 사용된다. 대괄호안의 값을 포인터로 인식하고 포인터가 가리키는 값을 가져온
다. 어쨌든 첫번째 매개변수 정수 a를 eax 레지스터에 싣고, add 명령으로 1 증가시킨다. eax 포인터는 일
반적으로 리턴값을 담는용도로 사용된다. 따라서 a+1의 값이 eax에 담기면서 리턴값으로 처리된다.

4. 끝으로

오늘은 어셈블리에 대한 기본적인 설명과 더불어 C언어에서 함수호출이 어떻게 구현되는지를 어셈블리를 통
해 상세히 알아보았다. 오늘 강좌에서 가장 중요한 것은 뭐니뭐니 해도 C언어의 함수호출 메카니즘일 것이
다. 매개변수와 지역변수가 어떻게 쓰여지며, 함수의 반환값은 또 어떻게 구현되는지. 이것들만큼은 반드
시 이해하고 넘어가길 바란다. 필자의 경험으로는 함수호출 메카니즘을 이해하는 가장 좋은 방법은 VC++ 디
버거를 이용하는것이다. 디버거의 레지스터윈도, 메모리윈도, 디스어셈블리윈도를 띄어놓고서 한줄한줄
(Step-by-Step) 진행하면서 레지스터와 메모리의 값들을 비교하면서 따라가다보면 이해하기가 한결 수월할
것이다. 물론 옆에 연습장 가져다 놓구 그림도 그려가면서 말이다.

5. 다음강좌에서는

다음강좌는 Win32 어셈블리 두번째 시간이 될것이다. 다음시간에는 오늘 배운 함수호출 메카니즘을 토대로
각각의 호출규약에 따른 함수들의 형태를 다루며, 조건문과 반복문이 어떻게 어셈블리로 쓰여지는지와 시간
이 된다면 Win32 구조화 예외처리(SEH)가 실제로 어떻게 구현되는지를 다뤄보도록 하자. 느끼고 있는지는
모르지만 다음 강좌까지만 마스터한다면 "시스템프로그래밍의 사생아"라 불리는 바이러스 제작에 대해서도
심도있게 다루어볼수 있을것이다. (물론 강좌를 진행할 생각은 아직 없지만 말이다.) 사실 바이러스와 전
역 API 후킹은 여러모로 비슷한 점이 많이 있다. 크게 다른것은 사용자의 인증을 받은것인지 그렇지 않은지
의 차이가 있을뿐이다. 이쯤되면 귀가 번쩍 트이는 독자분도 여럿될텐데 추천할만한 서적을 하나 소개하고
오늘 강좌를 마치고자 한다.

시스템프로그래밍에 대한 국내서적은 거의 전무하다시피한데 재미있는 책한권을 소개한다. 노파심에서 말씀
드리면 이책의 저자나 출판사와 필자는 아무런 상관관계가 없음을 밝힌다. (주)정보게이트 라는 출판사에
서 나온 "파괴의 광학"이라는 책이다. 이책의 저자인 김성우님은 월간 마이크로소프트웨어에서 윈도 시스
템 해킹을 주제로 강좌를 진행한적이 있는데, 그 강좌의 내용을 모아 한권의 책으로 출간하게 되었다. 주제
가 시스템 해킹이고 심지어는 바이러스제작까지 다루고 있지만, 기존의 해킹관련 서적들이 단순한 프로그
램 사용법을 소개하는데 그치는 수준인데 비해 이 책의 내용은 프로그래머를 위한 내용을 상당히 알차게 구
성하고 있다. 난이도는 상당히 높은 편이지만 본강좌의 내용을 이해할 정도라면 어렵지않게 따라갈수 있을
것이다.

<잡설 하나.>
윈도 XP의 출시가 코앞으로 다가왔더군요. 저희 회사에서도 이미 예약구매를 마친 상태이고, 몇몇 동료들
은 개인적으로도 예약을 해놓은 상태라더군요. XP가 몰고올 파장에 대해서 염려하는 분위기는 여러분들도
이미 잘 아실테지만, MS가 새 운영체제를 내놓을때마다 경쟁력있는 소프트웨어 회사가 하나씩 문을 닫거나
문을 닫을 위기에 처하는걸 보면서 이번에는 또 누가? 라는 조심스런 추측을 하곤 합니다. 인터넷 메신저,
인터넷 전화, CD Writer, DVD 플레이어, 개인방화벽 등등 이 포함된다고 하던데... 운영체제가 많은 지원을
한다는 것은 사용자입장에서는 편리한 것임에는 틀림없지만... 무언가 찜찜한것은 저만의 생각일까요?
Posted by skensita
Programming/Win32 API2008. 12. 1. 15:36
Win32 Global API Hook - 제2강 다른 프로세스의 주소공간으로 !! (2)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 다들 추석연휴는 알차게 보내셨는지요... 바쁜일이 있어서 생각보다 늦게 강좌를 올리게되었습니다. 너그
럽게 이해해주시길... (아무도 기다리지 않으셨다면 뭐... 하하...-.-;;) 자, 늦게 온만큼 알차게 시작해야
겠죠? 오늘은 지난시간에 이어서 다른 프로세스의 주소공간을 넘나들수 있는 방법에 대해서 알아보도록 하
죠. 지난시간에 말씀드렸듯이 오늘 내용은 NUMEGA SOFTWARE의 시스템 엔지니어인 Matt Pietrek의 아이디어
에서 빌어온것이지만 Advanced Windows의 저자 Jeffrey Ritcher나 Debugging Applications의 저자 John
Robbinson 등이 인용했었고, 국내의 모 프로그래밍 잡지의 시스템 해킹칼럼에서도 다루어 진적이 있었던 내
용입니다. API 후킹의 실질적인 기초를 다루는 내용이기도 하지요. 그럼 강좌나갑니다.
 
(강좌의 진행상 존칭을 생략합니다.)

 자 그럼 우리는 무얼하려고 했는지 생각해보자. 이번 강좌의 내용이 다른 프로세스의 주소공간을 우리가
사용해보자는 것이었다. 어떻게 하면 다른 프로세스의 주소공간으로 들어갈 수 있을까? 아니 그것보다 다
른 프로세스의 주소공간을 넘나드는 것이 어떤게 있을지 생각해보자. 쉽게 떠오르고 가장 대표적인것이 바
로 디버거일것이다. 여러분이 Visual C++ 등의 개발툴로 디버깅을 하는것을 한번 생각해보자. 디버거와 내
가 만든 프로그램은 엄연히 다른 프로세스이다. 그치만 디버거는 여러분이 만든 프로그램(디버기, 디버깅
을 당하는 프로세스를 말한다.)의 변수의 값을 추적하거나 변경할 수 있다. 디버거는 컴파일러와 더불어 시
스템프로그래밍을 익히는 가장 좋은예제가 될것이지만, 다들 아시다시피 제대로된 디버거를 제작하는것은
컴파일러만큼이나 복잡하고 어려운 작업이다. 음... 필자는 국내에서 제작된 상용 컴파일러와 디버거가 하
나도 없다는것이 좀 아쉬운데(예전에 씨앗이라는 C언어 비슷한 한글언어가 있었긴 하지만, DOS용이었던것으
로 기억된다.) 물론 우리나라의 개발자들의 능력이 못미쳐서라기보다는 일단 개발하는데 필요한 시간과 노
력에 비해 그 사업성에서 결코 낙관적이지 않을것이라는 이유일것이다. 음... 어쨋든 얘기가 잠시 삼천포
로 빠졌는데, 다시 디버거 얘기를 해보자. 어쨋든 디버거는 확실히 다른 프로세스의 주소공간을 넘나들며
실행된다는것은 의심의 여지가 없는것 같다.
 
 그렇다면?, 우리도 그와 비슷한 작업을 할 수 있지 않을까? Win32 API는 다른 프로세스의 주소공간을 엑세
스할수 있는 API세트를 제공한다. 이것이 바로 Debugging API 이다. 아마 처음 들어보는 사람도 많을 것이
다. 백문이 불여일견, 지금 당장 MSDN을 열고 "WriteProcessMemory"라고 타이핑해보자. 그러면
WriteProcessMemory함수에 대한 내용이 표시될것이다. (WriteProcessMemory는 대표적인 디버깅 API로 이름
그대로 프로세스 메모리를 쓰는(WRITE)하는 함수이다.) Win32에서는 프로세스간의 주소공간이 철저히 보호
된다더니만 그렇지도 않네? 이거 너무 싱겁잖아? 뭐 이렇게 생각하는 분들도 있을지 모르겠다. 그렇지만 운
영체제가 어디 그리 엉성하게 제작되었겠는가? Debugging API는 말그대로 Debugging을 위해서, 또는 디버거
를 제작하기 위한 목적으로 생겨난것이므로 우리같이 엉뚱한 목적을 위해 사용하려는데에는 많은 제한을 두
고 있다. 쉽게 말하면 저러한 종류의 API를 사용할수 있는 경우는 매우 제한적이라는 것이다. 실례로
WriteProcessMemory등으로 다른 프로세스의 주소공간을 접근하려면 프로세스를 디버깅모드로 실행시키지 않
는한 매우 까다로운 절차를 거쳐야 하며, 그나마 운영체제에 따른 지원여부또한 불투명하다. (운영체제, 정
확히 말하면 상당수의 시스템 API가 Win9x에서 지원되지 않거나, 제한적으로 적용된다는 말이다.)
 
 참고로 언급하면 지금까지의 예제들은 모두 Win9x에서 동작한다. WinNT/2000에서는 정상적인 동작을 보장
할 수 없다. 실제로 지난번 예제또한 WinNT/2000에서는 필자가 말한대로 동작하지 않았을것이다. NT계열
(2000, XP까지)의 운영체제와 9x계열의 운영체제는 보기에는 비슷해보이고 대부분의 어플리케이션이 호환되
는듯 동작하지만, 내부적인 많은 차이점을 지니고 있다. 결론적으로 말하면 API 후킹에 관한 아이디어는 9x
나 NT계열이나 별반 다를게 없지만 적용하는 방법에 있어서는 차이를 가진다. 잘라말하면 9x가 NT계열보다
쉽다. 이유는 9x는 호환성을 위해 DOS와 Windows 3.x의 내부구조를 상당부분 포함하고 있기때문이다. 결국
호환성때문에 운영체제의 안정성과 합리적인 구조를 포기할수밖에 없었던 것인데, 9x가 NT계열보다 불안정
한 이유도 여기에 있다. 일단 구체적인 두 운영체제의 차이점은 나중에 디바이스드라이버 강좌에서 자세히
알아보기로하고 우린 9x에서 작업한다고 가정하고 강좌를 진행하도록 하자.
 
 우리가 다른 프로세스의 주소공간에서 작업할 수 있는 가장 효과적인 방법이 무엇이 있을까? 당연히 DLL
을 이용하는 것이다. 다들 아시다시피 DLL은 DLL을 로드한 프로세스의 주소공간에 매핑되며 얼마든지 프로
세스의 자원을 사용할 수도 있다. 그럼 우리는 원하는 작업을 수행하는 DLL을 제작한다음, 그녀석을 원하
는 프로세스에 주입시키면 될것이다.
 
 그런데, 내가 제작한 프로그램이 아닌녀석에게 어떻게 내가 원하는 DLL을 주입할수 있을까? 지난번 강좌에
서 구체적으로 프로그램을 어떻게 실행시키는지를 잠깐 언급한적이 있다. 결국 컴파일러는 바이너리파일을
만들어내며, CPU는 그 파일에서 지정된 코드를 찾아 순차적으로 실행해나간다고 했다. 그렇다면 우리가 그 
실행코드를 원하는 코드로 덮어써버린다면? 당연히 내가 덮어쓴 코드가 실행될 것이다. 실행파일이 실행되
는것은 실제로 주기억장치(오랜만에 들어본다. ^^ 메모리를 말하는것이다.)에 로드된후에 실행된다고 했
다. 그렇다면 실행파일이 로드된 지점을 찾아 위에서 말한 Debugging API로 원하는 코드영역을 내가 원하
는 코드로 바꿔치기한후, 실행시키면 될것이다. 자, 아이디어는 매우 간단하다. 그럼 실제로 다음과 같은
과정을 거쳐 구현에 들어가보자.
 
1. 프로세스의 실행코드를 알아낸다.
 : Debugging API를 사용해서 특정프로세스의 실행코드위치를 알아낼 수 있다. 먼저 언급한대로 우리는 9x
에서 우리가 프로세스를 출발시키는 경우에만 적용하도록하자.
 
2. 원하는 실행코드를 제작한다.
 : 아마 상당수의 어플리케이션 프로그래머(대부분 VC++, VB, Delphi 등으로 작업할 것이다.)가 이해하기
힘들어하는 부분일것이다. 왜냐면 어셈블리, 정확히 말하면 기계어코드를 작성해야 하기때문이다. 지금은
어셈블리로 프로그래밍하는 사람이 거의 없겠지만 어셈블리를 아는 프로그래머와 그렇지 않은 프로그래머
는 분명한 차이가 있다. 반드시 어셈블리를 이용해서 프로그래밍하지 않더라도 어셈블리를 알면 디버깅과
시스템에 대한 이해가 분명해질것이다. 이번 강좌에서는 매우 간단한 어셈블리 코드만을 사용하지만, 자신
이 프로그래밍을 천직으로 알고 있다면(이 강좌를 보는 대부분의 사람들이 그럴것이라고 필자는 믿고 싶
다.) 반드시 어셈블리를 공부하길 바란다. (어셈블리에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍"
다시 다루도록 하자.)
 
3. 실행코드를 덮어쓴다.
 : 원하는 기능을 수행하는 코드를 원래의 코드에 덮어쓴다.
 
4. 필요한 기능이 실행되었다면 코드를 복원한다.
 : 3에서 덮어써진 코드를 복원/실행 한다.
 
전체적인 작업의 흐름은 이렇다. 자, 그럼 잠시 머리속을 정리한후, 실제 코드를 구현해보도록 하자.

그럼 먼저 간단한 마루타가 될 Win32 응용프로그램을 제작한다. 일단은 그냥 윈도에서 제공되는 노트패드
를 그냥 가져다 써도 무방할 것이지만 나중에 API 후킹을 테스트하려면 하나 만들어두는것도 좋을것이다.
간단하게 VC++에서 Win32 Application - Hello World 프로그램을 선택해서 AppWizard를 통해 만들어도 상관
없다. 실행해보면 그냥 달랑 메인윈도가 뜨고, 클라이언트 영역에 "Hello, World"라고 출력될것이다.

그 다음은 프로세스 몰래 주입할 DLL을 하나 제작한다. 일단 다른 프로세스에 제대로 로드되었는가를 확인
하기 위해, 아래와 같이 프로세스에 붙을때와 떨어질때 메시지 박스를 츨력해주는 간단한 DLL을 만들어보
자.

BOOL APIENTRY DllMain(HANDLE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved)
{
    switch(ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        MessageBox(NULL, "DLL_PROCESS_ATTACH", "TestDll", MB_OK);
        break;
    case DLL_PROCESS_DETACH:
        MessageBox(NULL, "DLL_PROCESS_DETACH", "TestDll", MB_OK);
        break;
    }

    return TRUE;
}

일단 여기까지 별 무리없을 것이다. 그러면 프로세스에 DLL을 주입하는 모듈을 제작해보자.
앞서서 프로세스를 디버깅모드로 실행시켜야 한다고 말했다. 아래와 같이 프로세스를 디버깅 모드로 실행한
다.

CreateProcess(NULL,
    (LPSTR)szCmdLine,
    0,
    0,
    FALSE,
    DEBUG_ONLY_THIS_PROCESS,
    0,
    0,
    &StartupInfo,
    &ProcessInfo);

단지 CreateProcess의 6번째 인자로 DEBUG_ONLY_THIS_PROCESS를 준것외에 특별한 것은 없다. 그러면
szCmdLine으로 실행된 프로세스는 내 프로세스의 디버기(전에 언급했다. 디버깅을 당하는 프로세스라는 의
미이다.)가 된다. 그러면 디버기는 디버거(바로 내 프로세스가 된다.)에게 디버깅 이벤트를 발생시킨다. 우
리는 적절한 이벤트를 핸들링함으로써 원하는 작업을 수행할 수 있을것이다. 아래의 코드를 보자.

DEBUG_EVENT event;
DWORD dwContinueStatus;

while(1)
{
    // 디버그 이벤트가 발생할때까지 대기
    WaitForDebugEvent(&event, INFINITE);

    dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;

    if(CREATE_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 프로세스 생성 이벤트
        TRACE("CREATE_PROCESS_DEBUG_EVENT fired !!\n");

        // 디버그 프로세스 정보
        m_ProcessDebugInfo = event.u.CreateProcessInfo;
    }
    else if(EXCEPTION_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 예외 이벤트
        TRACE("EXCEPTION_DEBUG_EVENT fired !!\n");
        HandleException(&event, &dwContinueStatus);
    }
    else if(EXIT_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 프로세스 종료 이벤트
        TRACE("EXIT_PROCESS_DEBUG_EVENT fired !!\n");
        return;
    }

    // 디버그 프로세스로 제어를 넘김.
    ContinueDebugEvent(event.dwProcessId, event.dwThreadId, dwContinueStatus);
}

 EXIT_PROCESS_DEBUG_EVENT 이벤트가 들어올때까지 무한루프를 수행하는 것을 알수 있다.
EXIT_PROCESS_DEBUG_EVENT 이벤트는 디버그 프로세스(== 디버기)가 종료될때 발생한다. 우리가 처리해야 하
는 이벤트는 이것외에 CREATE_PROCESS_DEBUG_EVENT 와 EXCEPTION_DEBUG_EVENT 이벤트인데 이름 그대로 디버
기가 생성될때와 디버기에서 예외가 발생할때 디버거에서 발생한다. 우리는 여기서 프로세스 정보를 백업하
고, 코드를 덮어쓰고 복원한다. 실제로 우리가 관심을 가져야 할 곳은 바로 EXCEPTION_DEBUG_EVENT 이벤트
인데, 이녀석은 아까 말한대로 디버기가 예외를 일으킬때 디버거로 발생되는 이벤트인데, 예외의 종류는 다
들 잘 알다시피 각종 오버플로우, 0으로 나눔, 접근금지 등등이 있는데, 여러분이 디버깅을 할때 사용하는
중단점(브레이크포인트) 또한 예외의 한 종류이다. 중단점은 디버기가 실행되는 순간
(CREATE_PROCESS_DEBUG_EVENT 다음으로)에 실행파일 로더에 의해서 한번 발생하며, 당연히 중단점이 설정
될 경우에도 발생한다. 그럼 HandleException() 함수의 내용을 보자.

// 브레이크 포인트인가?
if(EXCEPTION_BREAKPOINT
    == pEvent->u.Exception.ExceptionRecord.ExceptionCode)
{
    TRACE("EXCEPTION_BREAKPOINT fired !!\n");

    if(0 == m_uBreakCount)
    {
        // 첫번째 브레이크 포인트
        TRACE("First EXCEPTION_DEBUG_EVENT fired !! - InjectSpyDll() Call !!\n");
        if(!InjectSpyDll())
            TRACE("ERROR : InjectSpyDll() Fail !!\n");
    }
    else if(1 == m_uBreakCount)
    {
        // 두번째 브레이크 포인트
        TRACE("Second EXCEPTION_DEBUG_EVENT fired !! - ReplaceOriginalPagesAndContext()
Call !!\n");
        if(!ReplaceOriginalPagesAndContext())
            TRACE("ERROR : ReplaceOriginalPagesAndContext() Fail !!\n");
    }

    m_uBreakCount++;
    *pContinueStatus = DBG_CONTINUE;
}
else
    *pContinueStatus = DBG_EXCEPTION_NOT_HANDLED;

 첫번째 중단점은 디버기가 실행된 직후에 한번 발생한다고 했다. 따라서 우리가 코드를 덮어쓰는 시점이
바로 이부분이 되어야 할것이다. 아직 디버기는 실행되기 전이며, 우리는 디버기의 첫 실행코드에서 우리
의 코드를 덮어쓰는 셈이다. 두번째 중단점은 원본코드를 복원하기 위해서, 덮어쓰는 코드에서 지정해주는
데, 디버거로 제어를 넘기기 위해 사용된다. 이부분에서 원본코드를 복원해주어야 할 것이다.

 그럼 실제로 실행코드를 덮어쓰는 부분(InjectSpyDll())을 살펴보자.
 
// LoadLibraryA()의 주소
FARPROC pfnLoadLibrary = GetProcAddress(
    GetModuleHandle("KERNEL32.DLL"), "LoadLibraryA");

// 실행 프로세스의 첫번째 페이지 얻어옴
m_pFirstCodePage = FindFirstCodePage(m_ProcessDebugInfo.hProcess,
    m_ProcessDebugInfo.lpBaseOfImage);

// 실행 스레드 컨텍스트 백업
m_OrgContext.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext);

BOOL    bRetCode;
DWORD    cBytesMoved;

// 실행 프로세스의 첫번째 페이지 백업
bRetCode = ReadProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)
    return FALSE;

// 스파이 DLL 을 로드할 루틴을 담은 구조체 제작
PFAKE_LOADLIBRARY_CODE pNewCode = (PFAKE_LOADLIBRARY_CODE)m_pFakeCodePage;

// sub esp, 1000h
pNewCode->instr_SUB = 0xEC81;
pNewCode->operand_SUB_value = PAGE_SIZE; // 페이지크기(4096);

// push <매개변수>
pNewCode->instr_PUSH = 0x68;
pNewCode->operand_PUSH_value = (DWORD)m_pFirstCodePage
    + offsetof(FAKE_LOADLIBRARY_CODE, data_DllName);

// call <함수주소> ; LoadLibraryA() 호출
pNewCode->instr_CALL = 0xE8;
pNewCode->operand_CALL_offset = (DWORD)pfnLoadLibrary
    - (DWORD)m_pFirstCodePage - offsetof(FAKE_LOADLIBRARY_CODE, instr_CALL) - 5;

// 마지막에 브레이크 포인트 삽입
pNewCode->instr_INT_3 = 0xCC;

// 매개변수 (로드될 스파이 DLL)
char pszDll[MAX_PATH];
if(!GetSpyDllName(pszDll, sizeof(pszDll)))
    return FALSE;
strcpy(pNewCode->data_DllName, pszDll);

// 우리의 루틴을 실행프로세스에 Write !!
bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    &m_pFakeCodePage, sizeof(m_pFakeCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pFakeCodePage) != cBytesMoved)
    return FALSE;

// 실행 포인트(EIP)를 첫번째 페이지로 설정
m_FakeContext = m_OrgContext;
m_FakeContext.Eip = (DWORD)m_pFirstCodePage;

// 실행 스레드 컨텍스트 설정
if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_FakeContext))
    return FALSE;

return TRUE;

 코드가 좀 긴데, 흐름은 이렇다. 먼저 우리가 수행하기를 원하는 코드는 이렇다. 바로 우리가 앞서 만든
testdll.dll을 디버기로 하여금 로드하게 하는것이다. C코드로 하면 디버기에 아래와 같은 코드를 삽입하
는 것이다.

LoadLibrary("testdll.dll");

그럼 이런 작업을 수행할 코드를 제작해보자. 결론부터 말하면 어셈블리로 아래와 같은 코드가 될것이다.

push <"test.dll"의 주소>
call <LoadLibrary의 함수주소>
int 3

 C언어가 파라미터를 패스하는 방법은 스택을 이용하는 것이다. 함수 파라미터 패싱은 레지스터를 이용하
는 방법과 스택을 이용하는 방법이 있는데, 일반적인 경우 C언어에서 파라미터는 스택을 통해서 넘겨진다.
(함수호출규약에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍" 자세히 다루도록 하자.) 어쨋든 위와 같
은 코드를 통해서 우리가 원하는 DLL이 로드될것이며, 작업이 끝나면 원본코드의 복원을 위해 디버거로 제
어를 넘겨야 하는데, 이를 위해 중단점을 지정한다. 중단점은 어셈블리 코드로 인터럽트 3번 즉, int 3 이
다.

 그럼 위의 어셈블리코드를 기계어로 변환시켜보자. 그런데 문제가 있다. test.dll이나 LoadLibrary의 함수
주소를 어떻게 처리해야 하는것일까? 먼저 LoadLibrary의 함수주소를 찾아보자. GetModuleHandle(),
GetProcAddress()로 함수의 주소를 알아내는 것은 간단할 것이다. (적어도 9x에서는) 그런데 이 함수주소
를 실제로 CPU가 해석할때는 어떤방식으로 접근하는지를 알아야할것이다. call 명령이나 jmp 명령 등의 실
행제어를 변경하는 명령어들은 32비트 환경에서 보통 5바이트의 크기를 갖는데, 이는 명령어코드 1바이트
와 이동할 주소 4바이트(32비트 어드레싱이므로)로 이루어진다. 그런데 명령어의 파라미터가 되는 주소는
절대주소가 아니고 상대주소이다. 그러니까 현재 call 명령을 수행하고 돌아올 리턴주소를 실제 이동할 주
소에서 뺀 값으로 기계어코드를 생성한다. 예를 들어 call 100 이라는 어셈블리 명령이 위치한 주소가 50이
라면 이 명령은 기계어로 변환되면 0xe8(call 명령어)과 100(이동할주소) - 50(현재 명령의 주소) - 5(현
재 명령의 크기)로 전개된다는 것이다. 결과적으로 0xe845000000의 코드값으로 변환될 것이다.(물론 일반적
으로 위와 같은 주소값은 Win32에서 유효할수 없다.) 그렇다면 실제로 LoadLibararyA의 함수주소가
0xbff77750
이고, 현재 실행중인 코드의 주소가 0x00401bc2 라고 한다면 LoadLibraryA를 호출하는 기계어 코드는 다음
과 같을 것이다. 0xe8895bb7bf(call bff77750h) (Intel 계열의 CPU에서는 역워드지정방식을 사용한다는것
을 기억하자. 0x12345678은 실제 메모리상에는 0x78, 0x56, 0x34, 0x12로 적재된다.)

 자, 함수호출이 어떻게 이루어지는지 알았으니 이제는 파라미터를 어떻게 전달하는가를 알아보자. 위부분
에서 설명했듯이 C언어는 일반적으로 스택을 통해서 파라미터를 전달한다고 했다. 스택에 파라미터
("testdll.dll")을 푸쉬하고 좀전에 본대로 함수를 호출해주면 되겠는데, 우리가 사용할 파라미터는 정수
나 문자같은 단순데이터형이 아니고 포인터형이다. 그렇다면 실제 문자열을 담고 있는 데이터를 우리가 마
련해주어야 하며 그것을 어떻게 찾아서 프로그램이 사용하는지 또한 우리가 지정해주어야 할것이다. 컴파일
러는 지역변수와 전역변수를 스택과 힙에 각각 공간을 할당해주며, 코드내에서 이들을 찾아서 연결될수 있
도록 배려한다. 그러므로 프로그래머는 데이터를 어떻게 접근해야 하는지를 신경쓸 필요가 없게되고, 단순
히 데이터의 이름으로만 참조하면 된다. 우리가 만드는 코드 또한 작은 실행파일과 유사한 실행가능한 코드
조각이지만 이런 작업들을 해줄 컴파일러따위는 없다. 그러므로 데이터를 접근하는 방법또한 우리가 직접
지정해주어야 할것이다. 일단 우리는 독립적으로 실행될수 있어야 하므로 전역변수가 사용하는 힙을 사용하
기에는 무리가 있다. 결국 스택을 이용해야 하는데 컴파일러는 지역변수를 스택에서 관리한다. (다음강좌에
서 다시 다루겠지만 일단 스택포인터를 감소시켜서 지역변수를 위한 공간을 할당한다고만 알아두자. 이부분
은 어셈블리를 얘기할때 아주 중요한 사항이므로 꼭 알아두도록 하자.) 스택포인터를 감소시키고 그 공간
에 문자열을 담은 데이터를 써넣고 그 문자열의 주소를 다시 스택에 푸쉬한다음 LoadLibraryA를 호출한다
면 원하는 DLL을 로드할 수 있을것이다.

 전체적인 쉘코드(쉘코드란 말은 원래 유닉스 계열의 운영체제에서 루트의 권한에서 실행되는 프로그램의
코드를 변조해서 루트의 권한으로 쉘을 획득할수 있게 하는 해킹 코드덩어리를 말한다. 최근까지 유행했던
스택오버플로우 해킹기법으로 세상에 알려졌으며, 보통 리턴주소를 덮어쓰는 방법으로 코드를 실행한다.)
가 구상되었다면 이제 실제로 이러한 기능을 담은 쉘코드를 위한 자료구조를 아래와 같이 준비한다.
 
// 구조체를 1바이트씩 packing한다.
#pragma pack(1)

// 실행프로세스에 주사될 실행루틴(LoadLibraryA())
typedef struct _FAKE_LOADLIBRARY_CODE{
    WORD    instr_SUB;
    DWORD    operand_SUB_value;
    BYTE    instr_PUSH;
    DWORD    operand_PUSH_value;
    BYTE    instr_CALL;
    DWORD    operand_CALL_offset;
    BYTE    instr_INT_3;
    char    data_DllName[1];
}FAKE_LOADLIBRARY_CODE, *PFAKE_LOADLIBRARY_CODE;

 일단 1바이트로 구조체를 정렬하도록 지정한후, 구조체의 각 필드를 채워넣는 로직은 위에 리스트된 코드
를 참고하길 바라며, 결과적으로 우리가 삽입할 전체적인 어셈블리 구문은 아래처럼 될것이다.

sub esp, 1000h
push <"test.dll"의 주소>
call <LoadLibrary의 함수주소>
int 3

 처음 스택포인터를 0x1000(4096)만큼 감소시킨것은 아까 말했듯이 마치 컴파일러가 지역변수를 위한 공간
을 확보하듯이 우리의 데이터를 위한 스택공간을 확보한것이다.
 
 자!! 어쨋든 쉘코드가 완성되었다면 이녀석을 프로세스의 코드영역에 덮어쓰고 실행시키기만 하면 우리가
원하는 작업(LoadLibraryA("testdll.dll"))을 수행해줄것이다. 그렇다면 어느곳에 덮어쓸것인가? 우리는 프
로그램의 제일 처음코드에 우리의 코드를 덮어씀으로써 먼저 우리가 원하는 작업을 수행한후, 원본코드를
복원해서 마치 프로세스는 아무일없었다는듯 실행되게 할 예정이다. 그렇다면 먼저 프로세스의 실행코드의
첫부분을 찾아내야 하는데, 이를 위해서 FindFirstCodePage() 함수를 보도록 하자.
 
BOOL    bRetCode;
DWORD    cBytesMoved;
DWORD    peHdrOffset, baseOfCode;

// 실행 프로세스의 첫번째 페이지 얻음.
bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + offsetof(IMAGE_DOS_HEADER, e_lfanew),
    &peHdrOffset, sizeof(peHdrOffset), &cBytesMoved);
if(!bRetCode || sizeof(peHdrOffset) != cBytesMoved)
    return FALSE;

bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + peHdrOffset
    + 4 + IMAGE_SIZEOF_FILE_HEADER
    + offsetof(IMAGE_OPTIONAL_HEADER, BaseOfCode),
    &baseOfCode, sizeof(baseOfCode), &cBytesMoved);
if(!bRetCode || sizeof(baseOfCode) != cBytesMoved)
    return FALSE;

return (LPVOID)((DWORD)pProcessBase + baseOfCode);

 그다지 까다로운것은 없는것 같다. 1강에서 언급했던 PE구조를 이해했다면 별어려움 없이 이해할 수 있을
것이다. 프로세스의 베이스어드레스에서 코드의 베이스 어드레스만큼의 오프셋을 더한 값을 반환해준다. 이
미 눈치챘겠지만 코드의 베이스 어드레스 또한 상대주소라는 것을 알 수 있을것이다.
 
 어쨋든 디버기의 현재 스레드의 컨텍스트와 첫번째 코드페이지를 백업한후, 아까 만들어놓은 쉘코드를 디
버기의 첫번째 코드페이지에 덮어쓴다. 그다음 현재 스레드의 컨텍스트의 실행포인터를 덮어쓴 첫번째 코드
페이지로 복원한다. 스레드 컨텍스트에 대해 생소한 사람이 있을것 같아서 짚고 넘어가면 스레드 컨텍스트
는 문맥그대로 현재 스레드의 실행상태를 보관하고 있다. 대부분 CPU 레지스터에 관한 정보인데 그중 우리
가 꼭 알고 넘어갈것이 eip(확장 인스트럭션 포인터)이다. 이 레지스터가 가지고 있는 데이터는 다음에 실
행할 코드주소를 가지고 있다. 따라서 이 녀석을 수정하게 되면 프로그램의 흐름을 원하는 방향으로 제어할
수 있다. VC++ 디버거에서 제공하는 Set Next Statment 명령이 바로 이 eip를 수정하므로써 실행흐름을 원
하는 곳으로 점프 또는 리턴시킨다. 못믿겠다면 VC++ 디버거에서 레지스터 윈도우를 오픈한뒤 eip의 값을
수정해보라. 프로그램이 여러분이 eip로 지정한 주소로 점프하는 것을 볼수 있을것이다.(실행흐름을 조작하
는것은 시스템프로그래밍 디버깅에서는 아주 흔한경우이지만 어플리케이션 레벨의 디버깅에서는 많이 쓰이
지 않는듯 하다. Set Next Statment 는 VC++ 디버거에서도 매우 유용한 기능이므로 꼭 기억해두도록 하자.
그러나 남발하게 되면 스택이 망가지거나 돌이킬수 없는 상황을 초래하기도 하므로 잘 알고 사용해야 할것
이다.)

 마지막으로 ReplaceOriginalPagesAndContext() 함수는 덮어썼던 코드페이지와 컨텍스트를 원본대로 복원하
는 함수이다.
 
BOOL    bRetCode;
DWORD    cBytesMoved;

// 첫번째 페이지와 스레드 컨텍스트를 원래대로 되돌림.
bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)
    return FALSE;

if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext))
    return FALSE;

return TRUE;

 이전에 InjectSpyDll()에서 백업해 두었던 페이지와 컨텍스트를 단순히 되돌림으로써 프로세스(디버기)는
자신이 무슨짓을 했는지도 모른채 정상적으로 실행된다.
 
<끝으로 !!>

 오늘강좌는 약간 긴 분량의 강좌였던것 같다. 우리는 실행중인 다른 프로세스에 우리의 모듈을 몰래 주입
하는 방법을 배웠다. 어셈블리가 생소한분에게는 좀 버거웠던 강좌였을것이다. 이번 강좌는 예외적으로 예
제프로그램의 소스코드를 같이 올리도록 하겠다. 같이 올린 소스코드를 분석해보면 알겠지만 몇가지 잡다
한 부분은 오늘 설명에서 제외되었지만 그리 어렵지 않거나 별로 중요하지 않은 부분이니 충분히 혼자 이해
할수 있을거라고 믿는다.
 
 원래는 1강에서 강의한 모듈로서 DLL을 제작한 후, 그것을 다른 프로세스에 주입해서 실제로 다른 프로세
스의 API 후킹을 보여주려 했지만, 역시나 욕심이 지나쳐 설명이 많아지는 바람에 다루지 못했다. 이부분
은 여러분들이 직접 스스로 해보길바란다. 1강에서 다룬 내용은 동일한 프로세스의 주소공간에서는 유효한
API 후킹이었으므로 이것을 DLL로 제작해 오늘 배운방법으로 프로세스에 주입시키면 그 프로세스의 API를
후킹할수 있을것이다.
 
 그렇지만 오늘 강좌도 모든 경우에 적용할 수 있는 API후킹이라고 하기에는 모자란점이 많다. 일단 임포트
테이블을 조작하는 방법에는 한계가 있다는 점을 말해주고 싶다. 또한 어떠한 경우에는 위의 방법이 전혀
통하지 않는 경우도 있다는것을 참고하기 바란다. 그밖에 멀티스레딩에 대한 문제 등, 일반화하기 위해서
는 넘어야 할 산이 많다. 그렇지만 오늘 강좌의 내용은 API 후킹의 실질적인 기초가 되었던 강좌임에는 틀
림없다. 시간관계상 설명하지 못하거나 또는 빠뜨린것이 있을수 있겠지만 적어도 오늘 내용만큼은 꼭 이해
해주길 바란다.
Posted by skensita
Programming/Win32 API2008. 12. 1. 15:35
Win32 Global API Hook - 제2강 다른 프로세스의 주소공간으로 !! (1)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 지난 강좌의 내용이 어땠는지 모르겠군요. 첨부터 반말로 써서 혹시 기분이 상하셨을까봐, 이번 강좌부터
는 처음에 간단한 인사를 드리고 시작하겠습니다. ^^ 생각보다 많은 사람들이 보신것 같은데요. 실제로 코
딩을 해가면서 디버깅까지 해보셨다면 별로 어려울게 없었을거라고 믿습니다.
 자 이제 이번 강좌부터는 약간의 레벨업이 필요할듯 한데요. 이번 강좌에서 다루는 내용은 SOFTICE,
BOUNDS CHECKER 등의 디버깅툴로 유명한 NUMEGA SOFTWARE의 시스템 엔지니어인 Matt Pietrek의 아이디어에
서 빌어온 것임을 밝히며, API HOOKING의 원리를 이해할수 있을것으로 생각됩니다. 또 기운이 빠지는 얘기
일지도 모르겠지만 이번 강좌의 내용으로도 우리가 원하는것(첫 강좌에서 밝혔죠?)을 완벽하게 이룰수는 없
다는점입니다. 그렇지만 이 내용을 모르고는 다음 강좌로 넘어갈수는 없다는 판단에 두회에 걸쳐 강좌를 진
행하도록 하겠습니다. 그럼, 담배한대 피우고 가보기로 하죠 ^^

(강좌의 진행상 존칭을 생략합니다.)

1. CPU

 시스템프로그래밍에서의 CPU의 중요성은 말할필요가 없을것이다. CPU 아키텍쳐는 무엇보다도 중요하고 기
본적인 내용이지만 이 강좌에서 그것을 다룰수는 없다. 자세한것은 각자 자료를 찾거나 책을 보면서 익히도
록하고 우리는 여기서 CPU가 프로그램을 어떻게 실행하는지에 관해서만 언급하고자 한다. 운영체제가 실행
파일을 메모리에 로드하게 되면 정해진규칙에 따라 실행시작주소(보통 엔트리포인트라고 말한다.)를 찾고
그곳으로 제어를 넘긴다. (여기서 제어를 넘긴다는 말은 IP(인스트럭션 포인터)가 지정된곳으로 세팅된다
는 말과 같다.) 그렇게 되면 CPU는 그곳에서부터 정해진 바이트씩 읽어오면서 그것을 해석하며 실행해나간
다. 이러한 작업을 두고 명령어가 패치된다고 말한다. 당연히 CPU가 해석하는 명령어는 기계어코드이며 어
셈블리로 1:1 대응시킬수 있다. 이것만 기억하고 넘어가자, CPU는 기계어로 된 명령어를 해석해서 순차적으
로 실행해 나간다. 혹시 OOP 프로그래밍이나 이벤트드리븐방식(Windows같은)의 프로그래밍에만 익숙하다
면, 프로그래밍 방법론은 변했어도 CPU가 명령어를 처리하는 그순간은 그 옛날 도스시절이나 지금이나 별
반 다를게 없다는것을 명심하기 바란다.

2. Win32의 메모리 관리

 지난강좌에서도 잠깐 언급한적이 있지만, 시스템프로그래밍에서 메모리관리는 빠질수 없는 주제이다. 대부
분의 CPU와 운영체제가 보호모드를 지원하는 최근에 와서는 더더욱 중요한 주제가 되었고 우리도 당연히 짚
고 넘어가야 하겠다. 지면과 시간의 한계로 메모리 관리의 많은 부분을 다룰수 없는것을 안타깝게 생각한
다. 역시 이부분도 우리가 다루려는 핵심만 짚고 넘어가야 할듯하다. CPU는 메모리에 있는 데이터를 다룬
다. CPU가 디스크를 엑세스한다? 말도 안되는 얘기다. CPU는 모든것이 메모리라고 생각하면서 작업한다.
(물론 이말에 대해서 반론의 여지가 있는 사람도 있겠지만, 일단은 이렇게 생각하고 강좌를 진행하는 것이
이해가 빠를것 같다.)
 
 CPU의 실행모드에는 리얼모드와 보호모드의 두가지가 있는데, 리얼모드는 본 강좌와 무관하니 언급하지 않
기로 하고, 보호모드에 대해서만 얘기해보자. 메모리관리에서 웬 보호모드냐 하겠지만, 말하고자 하는것은
보호모드의 특징중의 하나인 가상메모리 메카니즘을 말하려고 함이다. 가상메모리라고 하는것은 말그대로
진짜메모리가 아니다. 단언하건데 여러분이 디바이스드라이버나 하드웨어를 제작하는 사람이 아니라면 여러
분이 지금까지 알고 있었던, 사용해왔던 메모리는 모두 가상메모리였을것이다. 보호모드에서 가상메모리는
연속된다는 의미로 선형메모리(leaner 또는 flat memory)라고도 한다. (앞으로 선형메모리와 가상메모리는
같은 의미로 사용하겠다.) 가상메모리로 인해서 실제로 시스템에 장착된 메모리보다 큰 메모리를 우리는 사
용할수 있었던 것이다. 이런 얘기는 들었을것이다. Windows는 모두 4기가바이트의 메모리를 사용할 수 있는
데, 그중 응용프로그램이 사용하는 영역은 0-2기가바이트이며, 그 이상은 운영체제가 사용한다. 이것 역시
가상메모리이며, 윈도 운영체제는 가상메모리를 페이징메카니즘이란 방법으로 관리한다.
 
 그럼 페이징메카니즘이란 도대체 무얼 말하는것인가? 페이지는 시스템이 인식하는 가상메모리의 단위이
다. 페이지는 CPU와 운영체제에 따라 그 크기가 다양한데 보통 Intel CPU의 윈도운영체제일 경우 보통 4K
의 크기를 갖는다. 가상메모리는 페이지 단위로 스왑되거나 맵된다. 여러분이 단지 한바이트의 메모리만 할
당한다하더라도 시스템은 하나의 페이지를 준비한다. 또 하나의 중요한 사실은 하나의 페이지는 연속된다
는 것이다. 이말은 하나 이상의 페이지는 실제로는 연속되지 않을수도 있다는 말이다. 보호모드에서의 가상
메모리는 리얼모드와 달리 산술적인 연산으로 물리주소로 연결되지 않으며, 페이지디렉토리와 페이지테이블
이라는 자료구조를 통해서 실제메모리(물리메모리)로 연결된다. 실제로 가상메모리상에서는 연속되는 메모
리영역이라 하더라도 실제 물리메모리상에서는 연속되지 않을수도 있다.
 
 이러한 개념은 매우 중요한데, 예를 들면 이러하다. 내가 만약 1메가바이트의 메모리를 할당했다고 하더라
도 실제로 그 메모리는 물리주소상에서 연속된다고 보장할수는 없다. 가상메모리가 3페이지에 걸쳐서 존재
한다면 그에 따르는 물리주소도 3페이지만큼이 존재하지만 그들의 위치는 우리가 예상하는대로 배치되지 않
을수 있다는것이다.
 
3. 프로세스와 메모리

 내가 만든 A라는 프로그램이 있다고 치자. 그놈이 어떤 메모리를 할당했는데 그것의 시작주소가
0x70000000 이었다고 가정하자. 그런데 B라는 프로그램도 메모리를 할당하는데 그것의 시작주소 또한
0x70000000 이었다고 한다면, A, B 두 프로세스가 가지고 있는 이 두개의 메모리 영역은 과연 실제로는 어
디에 존재할까? 주소는 같지만 실제로 둘은 전혀다른 메모리이다. 둘다 같은 가상주소값을 가지고 있지만
둘은 엄연히 물리주소상에서는 다른 곳을 가리키고 있다. 이것이 어떻게 가능할까? 시스템은 현재프로세스
가 변할때(태스크 스위칭이 일어날때)마다 페이지디렉토리의 내용을 갱신한다. 윗부분에서 가상주소는 페이
지디렉토리와 페이지테이블를 통해 물리주소와 연결된다고 했다. 따라서 이들이 변한다는것은 실제 가상주
소가 가리키는 실제주소(물리주소)가 변한다는 말과 같다. 이러한 원리로 프로세스 A와 B는 서로의 공간을
전혀 알수가 없으며 이로 인해서 운영체제가 더욱 견고해지는 것이다.
 
 그렇다면 만약 10메가 바이트의 메모리를 사용하는 프로세스 10개가 동시에 동작한다면 100메가 바이트의
물리메모리가 필요할까? 반드시 그렇지는 않다. 왜냐면 위에서 말한 페이지 스왑이라는 기법을 운영체제가
지원하기 때문이다. 운영체제는 어떠한 페이지가 현재 필요하지 않다고 판단되면 그것을 디스크에 기록한
후, 물리메모리에서 해제한다. 그러다가 그 페이지가 다시 필요한 시점에서 디스크에 보관된 페이지를 다
시 물리메모리로 로드한다. 이러한 일련의 작업들로써 응용프로그램들은 현재 시스템에 장착된 메모리보다
더 큰 메모리를 사용할수 있는것이다. 우리가 자주보는 시스템 오류중 하나인 페이지 폴트(page fault)는
바로 이러한 페이징에 오류가 생겼을때 발생하는데 대표적인 경우는 현재 물리메모리가 할당되어지지 않은
(디스크 스왑된) 페이지를 마치 메모리에 존재하는 페이지처럼 접근하려고 할때이다. 시스템은 기본적으로
이러한 에러를 예외핸들러를 설치해서 복구하게된다.
 
그렇다면 이제 프로세스간에 주소공간을 공유할수 있는가라는 문제에 대해 생각해보자. 계속 이론만 늘어놓
았으니 이번에는 간단한 예제를 통해서 확인해보도록 하자. 아래에 리스트된 코드를 보자.

PVOID p1 = malloc(16);
if(NULL == p1)
    return -1;
memset(p1, "A", 16);
*((char*)p1 + 15) = "\0";

HANDLE hMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL,
    PAGE_READWRITE|SEC_RESERVE, 0, 16, NULL);
PVOID pMap = MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, 16);
PVOID p2 = VirtualAlloc(pMap, 16, MEM_COMMIT, PAGE_READWRITE);
if(NULL == p1)
    return -1;
memset(p2, "B", 16);
*((char*)p2 + 15) = "\0";

printf("p1:0x%08x \n", p1);
printf("dump:%s \n", p1);
printf("p2:0x%08x \n", p2);
printf("dump:%s \n", p2);

printf("press any key... \n");
getch();

if(NULL != p1)
    free(p1);
if(NULL != p2)
    VirtualFree(p2, 16, MEM_DECOMMIT);
if(NULL != pMap)
    UnmapViewOfFile(pMap);
if(NULL != hMap)
    CloseHandle(hMap);

간단히 설명하면 일단 malloc()으로 16바이트만큼 메모리를 할당한다음 문자 "A"로 채운다음 그 주소와 내
용을 화면에 출력한다. 그런다음 메모리맵파일을 생성한후 문자 "B"로 채운다음 그 주소와 내용을 화면에
출력한다. 그리고 잠시 사용자 키입력을 기다린후, 입력이 들어오면 메모리를 해제하고 프로그램을 종료한
다. 메모리맵파일에 관한 자세한 설명은 MSDN을 참고하길 바라며, 일단 컴파일한후 실행시켜보자.

p1:0x00780eb0
dump:AAAAAAAAAAAAAAA
p2:0x85536000
dump:BBBBBBBBBBBBBBB
press any key...

필자의 시스템에서는 위와같이 출력되고 사용자 입력을 기다리는 상태가 되었다. 키입력을 하게되면 메모리
를 해제하고 프로그램을 종료하게 되므로 일단 저상태로 내버려 두고 p1과 p2의 주소만 잘 적어두고 두번
째 프로그램을 작성하자.

printf("dump:%s \n", 0x00780eb0);
printf("dump:%s \n", 0x85536000);

먼저 작성한 프로그램의 p1, p2의 주소를 출력하는 코드이다. 혹시 그냥 Cop & Paste하는 사람이 있을까봐
얘기하는데, 하드코딩된 주소는 당연히 앞서 작성한 프로그램에서 출력된 주소를 적어주어야 할것이다.
자, 두번째 프로그램을 컴파일한후 실행시켜보자.

dump:emTest2.exe
dump:BBBBBBBBBBBBBBB

어떤가? 확실히 이해가 되는가? 결론부터 말하자면 malloc으로 할당한 메모리의 주소는 다른 프로세스에서
는 쓸모없는 무효한 주소가 된다. 그러나 메모리맵파일로 할당한 메모리의 주소는 다른 프로세스의 주소공
간에서도 여전히 유효한것을 볼수 있다. (혹시 운이 없는 사람은 두번째 프로그램을 실행시키다가 시스템
이 죽거나 블루스크린을 만났었을지도 모른다. 아마 대부분 그러지 않았을거라 확신하지만... ^^) 여기서
중요한것은 바로 다른 프로세스간에도 유효한 메모리와 무효한 메모리의 주소이다. 주소는 시스템마다 약간
씩 차이가 있었겠지만 분명한것은 malloc으로 할당한 메모리의 주소는 0x80000000보다 작았을것이고, 메모
리맵파일로 할당한 메모리주소는 분명히 0x80000000보다 큰 주소로 할당되었을것이다. 0x80000000은 10진수
로 2147483648, 즉 정확히 2기가바이트이다. 우리는 이제 주소만 보고도 이것이 프로세스 전용메모리인지
아니면 시스템에서 공유되는 메모리인지를 구별할 수 있을것이다.

4. 끝으로

 필자의 생각으로 이번 강좌는 여러분들에게 정말로 지루하고 재미없는 강좌였을것이다. 대부분 이론적인
내용뿐이니 말이다. 하지만 이번에 다룬내용은 모두 우리가 앞으로 해야할 작업의 기초가 되는 내용들이니
지루하더라도 꼭 이해하고 넘어가기 바란다. CPU와 메모리, 프로세스에 관한 내용은 이것말고도 굉장히 중
요한 내용들을 포함하고 있으니 다른자료나 참고서적을 통해서라도 꼭 살펴보길 바란다. 참고서적을 추천해
달라는 분이 계셨는데 지금은 시스템프로그래밍에 관한 책이 여러권 나와있지만 필자가 살펴본 바로는 1강
에서도 언급한 Jeffrey Ritcher의 Advanced Windows라는 책이 볼만할것이다. 한글번역본도 있으니 꼭 구해
다가 한번씩 읽어보기 바란다. 이책은 어플리케이션 프로그래머를 위한 시스템프로그래밍 서적이지만 워낙
유명한 책이니 책장에 꽂아놓는것만으로도 의미가 있을듯 싶다. 그밖에 역시 1강에서 언급한, NUMEGA
SOFTWARE의 시스템 엔지니어인 John Robbins의 Debugging Applications 라는 책 또한 참고할 내용이 많다.
나중에 NUMEGA SOFTWARE의 제품인 SOFTICE라는 디버거를 사용하게 될터인데(그때보면 알게되겠지만 정말 엄
청난 프로그램이라 하지않을수 없다. 필자는 SOFTICE가 없는 시스템프로그래밍 디버깅을 생각할수조차 없
을 정도이다.) 이책은 디버깅을 위한 책이라고 할수있지만 시스템에 관한 내용도 다루어지며, 아마 이책을
읽고나면 그럴듯한 디버거를 하나 만들수 있을것이다. 더 깊은 내용을 다루는 서적이나 자료를 알고싶다면
추후에 디바이스드라이버 강좌를 진행할때 소개하기로 하자.

5. 다음 강좌에서는...

 다음 강좌는 "제2강 다른 프로세스의 주소공간으로 !!" 두번째 시간이다. 원래 이번 강좌에서 다룰예정이
었던 Debugging API와 그것을 이용한 간단한 디버거를 작성해보고, 다른 프로세스 공간에 우리의 모듈을 삽
입하는 방법을 알아보자. 사실 강좌를 진행하다보니 욕심이 지나쳐 예상보다 진도가 늦어진 것같다. 애초 2
강을 두번에 걸쳐서 진행하려 했으니 다음 강좌는 좀 타이트한 진행이 될것 같다. 그렇지만 이번 강좌보다
는 덜 지루한 내용으로 채워질 예정이니 너무 걱정마시길...

(P.S) 추석연휴라서 고향에 내려왔다가 잠시 짬을 내서 올립니다. 이론위주의 강좌를 진행하다보니 끝도 없
이 진행될것 같아서 일단 필요하다고 생각되는 부분만 수박겉핡기 식으로 살펴보았습니다. 추석연휴에 고향
에 가시는 분들도 계실테고, 고향에 못가고 일하시는 분들도 계시겠지만 마음만이라도 즐거운 한가위가 되
시길 기원합니다. 
Posted by skensita
Programming/Win32 API2008. 12. 1. 15:33

다음은 데브피아의 성상훈님의 강좌 입니다.


Win32 Global API Hook - 제1강 Win32 API 후킹의 기본
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(강의의 진행상 존칭을 생략합니다.)


자신이 만약 어느정도의 레벨을 가진 윈도우즈 프로그래머라면 이런 생각을 한번쯤 해보았을 것이다.

"만약 Windows API를 후킹할 수 있다면 재미있는 것을 많이 해볼수 있을텐데..."

그리고 의욕과 시간이 있었다면 아마 도전해본 사람도 꽤 있었을것이다.

그러나 실제로 이것을 성공한 사람은 그리 많지 않았을것으로 안다. 여러분이 만약 어플리케이션 레벨에서만 프로그래밍했다면 이것 은 불가능해 보였을지도 모른다. 시스템 레벨 프로그래머라면 이것이 제법 까다롭고 다루기 힘든 주제라는것을 알았을것이다.

자 여기서 필자는 많은 사람들이 궁금해하는 이 비밀스런 작업을 하나하나 풀어가려고 한다. 그리
고 의외로 간단한 곳에 해답이 있었음을 이 강좌가 끝날때쯤 알게될 것이다.

자, 서론을 접고 본론으로 들어가자. 우리가 하려는 일은 다음과 같다.

Win32 API를 후킹해서 내가 원하는 작업을 수행하거나, 작업을 흐름을 원하는대로 제어할 수 있다.

단 지 이것이다. 무슨 설명이 더 필요한가? 실제로 우리가 하 려는 것은 이것이 전부이다. 예를 들어서 쉽게 말하자면 CreateProcess() 라는 API를 후킹하면 내가 원하지않는 프로그램의 실행을 막을수도 있고, 윈속함수인 send()나 recv()를 후킹하면 나가고 들어오는 패킷을 훔쳐보거나 조작할 수 있다. 느낌이 팍 오지 않는가? 느낌이 오지않는 사람은 아마 도 아직 이 강좌를 들을만한 수준이 아니거나 해커(순수한 의미의)의 기질이 없는 사람일 수도 있다.

일단 오늘은 첫날이니 그동안 많은 사람들이 제시했던 Windows API 후킹방법에 대해서 먼저 얘기해보자.

1. exe 파일헤더의 import descriptor table을 변경하는 방법

가 장 쉬운 방법이 되겠다. 물론 쉬운만큼 문제점이 적지 않 다. 일단 Win32 응용프로그램은 PE (PortableExecutable)이라는 형식으로 바이너리화 되어있다. 실행가능한 바이너리 파일구조에는 POSIX, COFF, PE 형식등등이 있는데, 윈도는 PE형식을 사용한다. 따라서 PE형식을 알면 윈도실행파일구조를 분석할 수 있겠다. 실제 윈도실행 파일은 크게 헤더, 리소스, 임포트테이블, 익스포트테이블, 데이터, 코드 등등의 영역으로 나누어지는데 여기서 임포트테이블이 이름 그대로 임포트된 라이브러리의 함수에 관한 정보가 담겨졌있다. 따라서 정적으로 링크된 모든 함수는 이곳에서 볼수 있게된다. 그 러면 이부분의 임포트된 함수의 주소들을 내가 만든 후킹함수주소로 바꿔치기 해주면 간단할 것이다. 아래에 리스트된 코드를 보자.

// 포인터 변환 및 연산 매크로
#ifndef MakePtr
#define MakePtr(cast, ptr, addValue) (cast)((DWORD)(ptr)+(DWORD)(addValue))
#endif // MakePtr

PIMAGE_IMPORT_DESCRIPTOR GetImportDescriptor(HMODULE hMod,        // 모듈
                                       
     LPCTSTR pszModName)// 모듈이름
{
    TRACE("[FIND IMPORT DESCRIPTOR] \n");

    // 매개변수 유효성 검사
    ASSERT(!IsBadReadPtr(hMod, sizeof(IMAGE_DOS_HEADER)));
    ASSERT(NULL != pszModName);

    // DOS 헤더
    PIMAGE_DOS_HEADER pDosHdr = (PIMAGE_DOS_HEADER)hMod;
    if(IMAGE_DOS_SIGNATURE/*0x5A4D*/ == pDosHdr->e_magic)
    {
        // NT 헤더
        PIMAGE_NT_HEADERS pNtHdr = MakePtr(PIMAGE_NT_HEADERS,
            pDosHdr, pDosHdr->e_lfanew);
        if(!IsBadReadPtr(pNtHdr, sizeof(IMAGE_NT_HEADERS))
            && IMAGE_NT_SIGNATURE/*0x00004550*/ == pNtHdr->Signature)
        {
            // image descriptor
            PIMAGE_IMPORT_DESCRIPTOR pImpDesc = 
                MakePtr(PIMAGE_IMPORT_DESCRIPTOR,
                    pDosHdr,
                    pNtHdr->OptionalHeader.
                    DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].
                    VirtualAddress);
            if(NULL != pImpDesc)
            {
                while(NULL != pImpDesc->Name)
                {
                    PSTR pszName = MakePtr(PSTR, pDosHdr, (DWORD)pImpDesc-
>Name);
                    TRACE(" %s ", pszName);

                    if(stricmp(pszName, pszModName) == 0)
                    {
                        // 찾았다 !!
                        TRACE(": Found It !! \n");

                        return pImpDesc;
                    }

                    TRACE("\n");
                    pImpDesc++;
                }
            }
        }
    }

    return NULL;
}

PROC WINAPI HookImportFunction(HMODULE hMod,// Hooking 할 모듈
                     PSTR pszModName,        // Hooking 함수가 위치한 모듈이름
                     PSTR pszFuncName,        // Hooking 할 함수
                     PROC pfnNewProc)        // Hooking 함수
{
    // 매개변수 유효성 검사
    ASSERT(!IsBadReadPtr(hMod, sizeof(IMAGE_DOS_HEADER)));
    ASSERT(NULL != pszModName);
    ASSERT(NULL != pszFuncName);
    ASSERT(NULL != pfnNewProc);
    ASSERT(!IsBadCodePtr(pfnNewProc));

    // 반환값 (원래 함수)
    PROC pfnOrgProc = NULL;

    // Win9x이고 2GB 이상의 시스템 DLL 이라면
    // 조용히 사라져야 한다.
    if(1 != __GetOsType() && 0x80000000 < (DWORD)hMod)
    {
        return NULL;
    }

    // import descriptor 를 얻는다.
    PIMAGE_IMPORT_DESCRIPTOR pImpDesc = GetImportDescriptor(hMod, pszModName);
    if(NULL == pImpDesc)
        return NULL;

    // 원본 thunk
    PIMAGE_THUNK_DATA pOrgThunk = MakePtr(PIMAGE_THUNK_DATA,
        hMod, pImpDesc->OriginalFirstThunk);

    // 실제 thunk (for Hooking)
    PIMAGE_THUNK_DATA pRealThunk = MakePtr(PIMAGE_THUNK_DATA,
        hMod, pImpDesc->FirstThunk);

    // 루프를 돌면서 Hooking 할 함수를 찾는다.
    TRACE("[FIND IMPORT FUNCTION] : %s \n", pszModName);
    while(NULL != pOrgThunk->u1.Function)
    {
        // 이름으로 import된 함수만 검색
        if(IMAGE_ORDINAL_FLAG !=
            (pOrgThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG))
        {
            // import된 함수 이름
            PIMAGE_IMPORT_BY_NAME pByName =
                MakePtr(PIMAGE_IMPORT_BY_NAME,
                hMod, pOrgThunk->u1.AddressOfData);

            // 이름이 NULL로 시작되면 넘어간다.
            if(0 == pByName->Name[0])
                continue;

            TRACE(" %s ", (PSTR)pByName->Name);

            if(pszFuncName[0] == pByName->Name[0]
                && 0 == stricmp(pszFuncName, (PSTR)pByName->Name))
            {
                // 찾았다 !!
                TRACE(": Found It !! \n");

                MEMORY_BASIC_INFORMATION mbi;
                VirtualQuery(pRealThunk, &mbi, sizeof(mbi));

                // 가상 메모리의 보호속성 변경
                if(!VirtualProtect(mbi.BaseAddress, mbi.RegionSize,
                    PAGE_READWRITE, &mbi.Protect))
                {
                    // VirtualProtect() fail !!
                    ASSERT(0);
                    return NULL;
                }

                // 원래 함수(반환값) 저장
                pfnOrgProc = (PROC)pRealThunk->u1.Function;

                // 새로운 함수로 덮어쓴다.
                TRACE("** old function : 0x%08X \n", (DWORD)pRealThunk-
>u1.Function);
                pRealThunk->u1.Function =
                    (DWORD)pfnNewProc;
                TRACE("** new function : 0x%08X \n", (DWORD)pRealThunk-
>u1.Function);

                // 가상 메모리의 보호속성 원래대로 되돌림
                DWORD dwTmp;
                VERIFY(VirtualProtect(mbi.BaseAddress, mbi.RegionSize,
                    mbi.Protect, &dwTmp));

                // 세상을 다 가져라 !!
                TRACE("** Hook OK !! What a wonderful world !! \n");
                return pfnOrgProc;
            }

            TRACE("\n");
        }

        pOrgThunk++;
        pRealThunk++;
    }

    return NULL;
}

GetImportDescriptor()함수는 모듈의 임포트디스크립터를 얻어내는 함수이고, HookImportFunction()함수는
실제로 임포트된 함수를 후크하는 함수이다. 이 두 함수를 이용해서 MessageBox API를 후킹해보자.

// MessageBox API 원형
typedef int (WINAPI *PROC_MESSAGEBOX)(HWND, PSTR, PSTR, UINT);

PROC_MESSAGEBOX pfnOrgMessageBox = NULL;

// MessageBox를 대체할 훅함수
int WINAPI MyMessageBoxA(HWND hWnd, PSTR pszText, PSTR pszTitle, UINT uType)
{
    ASSERT(NULL != pfnOrgMessageBox);

    return pfnOrgMessageBox(NULL, "Hooked MessageBox !!", "Hooked !!", MB_ICONINFORMATION);
}

// Main 프로세스
int APIENTRY WinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPSTR     lpCmdLine,
                     int       nCmdShow)
{
     // TODO: Place code here.

    MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);

    // hook !!
    pfnOrgMessageBox = (PROC_MESSAGEBOX)HookImportFunction(
        GetModuleHandle(NULL), "user32.dll", "MessageBoxA", (PROC)MyMessageBoxA);
    if(NULL == pfnOrgMessageBox)
        return 0;

    MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);

    // unhook !!
    pfnOrgMessageBox = (PROC_MESSAGEBOX)HookImportFunction(
        GetModuleHandle(NULL), "user32.dll", "MessageBoxA", (PROC)pfnOrgMessageBox);
    if(NULL == pfnOrgMessageBox)
        return 0;
    TRACE("replace orginal function \n");

    MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);

    return 0;
}

MyMessageBoxA 라는 함수를 보면 타이틀에 Hooked !! 라는 캡션을 가진 Hooked MessageBox !! 메시지를 출력하는 메시지 박스 를 보여주는 함수이다. 후킹이 성공적으로 이루어지면 MessageBox는 무조건 위와같은 형태로 보여지게 될것이다. MessageBoxA의 A 문자는 ANSI 문자열 버전으로서 Windows 9x 계열에서 주로 사용되며 1바이트 문자를 사용한 다. 반대로 W가 붙은 API는 Wide Charactor로서 2바 이트 문자를 사용하는 유니코드 사용 API이다. 실제로 Win9x 계열에서는 생략할경우 A문자 버전의 API가 호출되도록 재지정되어 있으며, NT4/W2K 계열에서는 내부적으로는 W문 자 버전의 API가 호출된다. 문자열을 인자로 사용하는 대부분의 API가 이와같이 두가지 버전으로 나뉘어지며 만약 훅함수를 설치한 다면 정확한 버전을 사용해야 할것이다.

실행시켜보면 실제로 중간의 MessageBox(NULL, "Default MessageBox", "Windows 98",
MB_ICONINFORMATION); 함수는 우리가 지정한 후킹함수로 대체되어 Hooked ... 라는 메시지 박스가 출력될것이다.

자, 원리를 알고 실행이 제대로 되는것을 확인했다면 문제점에 대해서 알아보자.
위 의 방법으로는 현재 내 프로그램만이 후킹될수 있다. 이유인즉슨 임포트 디스크립터 테이블이라는것이 프로그램마다 가지고 있는것이기 때 문에 당연히 내 프로그램만이 적용되어진다. 그렇다면 다른 프로그램들 도 모조리 임포트 디스크립터 테이블을 변경하면 될것이 아닌가? 라고 생각하는 사람이 있을지 모르겠지만, 그건 불가능하다. 왜냐하면 Win32 응용프로그램들은 각각 독립적인 주소공간에서 실행되기 때문에 기본적으로 서로 다른 프로그램의 영역을 침범할수 없다. 그러면 어떻게 다른 응용프로그램(정확히 말하면 프로세스)의 주소공 간 을 볼수있을까? 이 문제에 관해서는 다음 강좌에서 다루어 보도록 하자.

끝으로...
오 늘은 Win32 API 후 킹에 대한 맛보기였다. 사실 오늘 제시한 방법으로는 우리가 생각하는 것들을 하기에는 턱없이 모자란다. 그렇지만 모든것은 순서가 있 듯이 가장 기본적인 방법론부터 제시해보았다. 사실 오늘 강좌에도 따라오는 부수적인 내용은 상당히 방대하다. 일단 Windows 실 행파일구조인 PE구조에 대한 이해와 Windows 프로세스간 메모리 관리 또한 매우 중요한 내용이다. 지면상(또는 시간상 ^^) 여기서 다 다룰수는 없지만 참고할만한 서적을 소개하면 PE구조에 관해서는 MSJ나 마이크로소프트웨어 잡지를 검색하면 찾을 수 있을 것이다. 실제로 PEDUMP 같은 유틸리티를 작성해본다면 더없이 좋 을것이다. Win32 메모리관리에 관해서는 Jeffry Richter의 Advanced Windows 라는 책을 추천한다. 좀 오래된 책인데 필자가 공부할때는 가장 훌륭 했 다고 여겨지는 책이다. 아마 다른 시스템 프로그래밍을 다루는 책에서도 자료를 얻을 수 있을것이다. 또한 위 소스코드의 모체가된 John Robbins의 Debugging Applications라는 책을 참고하는 것도 좋을 것이다.

노 파심에서 사족을 달자면, 혹시 C/C++와 Windows 시스템과 메카니즘에 익숙하지 않다면 과감히 강좌를 보는것을 포기하길 권유한다. 적 어도 위의 소스코드정도는 쉽게 이해할 수 있어야 할것이다. 또한 필자의 사정상 빨라야 일주일에 두번정도 강좌를 올릴 수 있을것 같 다. 그래서 전체적인 커리를 다음과 같이 제시하니 필요한 부분은 미리미리 공부한다면 빨리 따라올 수 있을것이다.

1. Win32 API 후킹의 기본
2. 다른 프로세스의 주소공간으로 들어가자 !!
3. Win32 어셈블리 프로그래밍
4. Win9x 디바이스 드라이버(VxD) 모델
5. 기계어 프로그래밍 - Shell Code 작성
6. Win9x Global API Hooking
7. WinNT/2000 디바이스 드라이버 모델
8. WinNT/2000 Global API Hooking

앞 으로 전개될 강좌의 주요 테마이다. 하나같이 만만하지 않은 주제들로 이루어져있으며 한두회의 강좌로는 턱없이 부족한 주제도 대부분일 것이다. 그러나 차근차근 따라오다보면 어느새 자신의 실력이 부쩍 향상되어 있음을 피부로 느낄것이다. 이것은 필자가 이름을 걸고 맹세할 수 있다. 실제로 디바이스드라이버 프로그래밍과 어셈블리 프로 그래밍에 대한 경험이 있거나, 얼마전까지 유행하던 해킹기법인 Stack Overflow의 익스플로잇과 쉘코드를 직접 제작할 수 있는 능력이 있다면 강좌를 따라오기가 한결 수월할 것이 다.

P.S : 본 강좌는 얼마든지 다른 사이트로 복사되어질 수 있지만, 상업적인 용도로 사용되어질 경우, 원저자의 동의를 얻어야함을 명시합니다. 복사를 하실때는 예의상 원저자의 이름정도는 밝혀주시기 바랍니다.

Posted by skensita