본문 바로가기

카테고리 없음

[Windows] 어플리케이션 보안에 대해 알아보자. : 나는 내 프로그램이 크랙되는 것을 원치 않는다.

 [Windows] 어플리케이션 보안에 대해 알아보자. : 나는 내 프로그램이 크랙되는 것을 원치 않는다.

 안녕하세요. 이번 시간에는 어플리케이션 보안에 대해 알아봅시다. :)

 어플리케이션의 보안은 대부분의 프로그래머의 근심거리입니다. (특히 상용 소프트웨어를 개발하는 회사 입장에서는 두말할 것 없이 중요한 작업이죠.)

 이 글은 어플리케이션 해킹의 원리와 그것에 대한 보안 대응책을 한번 정리해보았습니다.

 이 글을 읽고 한 명의 프로그래머라도 항상 보안에 대해 경각심을 가지고 프로그래밍을 해주었으면 하는 바람입니다.


 지피지기면 백전백승! 프로그램은 어떻게 해킹하는 것인가?

 그렇다면, 프로그램은 어떻게 해킹되는 것일까요? :) 대부분의 독자들은 불법 프로그램을 인터넷에서 한번 이상은 다운받아보신 경험이 있으실 것입니다.

 보 통 불법적인 루트로 프로그램을 다운받아서 쓰는 경우에는, 흔히 키젠(Key Generator; KeyGen)이나 크랙(Crack)을 많이 다운로드 받아서 프로그램에 적용시키는데요. :p 이러한 키젠과 크랙은 해커가 해당 소프트웨어의 분석을 한 후, 취약점이 발견되면 그 부분을 이용해서 소프트웨어의 해킹을 한다고 생각하시면 되겠습니다.

 보통 어플리케이션을 해킹한다고 하면 대부분 아래 네 가지중 하나일겁니다. :)

 1. 어플리케이션을 디어셈블(Disassemble)하거나, 디버깅(Debugging) 과정을 통해 실행 코드를 분석/조작하여 크랙 등을 만드는 작업
 2. 어플리케이션의 취약점(Vulnerability)을 이용, 어플리케이션을 이용하는 컴퓨터에 local 혹은 remote에서 원하는 code를 실행하게 하는 작업
 3. 어플리케이션 프로세스의 메모리를 조작하여 원하는 결과를 얻는 작업
 4. 실행 환경을 조작함으로써 어플리케이션에 간접적으로 영향을 미치는 작업

1~3번은 보통 디버거(Debugger)를 이용해서 많이 해킹을 합니다. 디버거 중에서도 프리웨어이면서 강력하고, 해커들이 자주 사용하는 프로그램이 OllyDbg(Olly Debugger)인데요. 그 프로그램의 스크린샷은 아래와 같고, 다운로드는 http://ollydbg.de/ 에서 하실 수 있습니다.

사용자 삽입 이미지

[그림 설명] Olly Debugger 실행 화면


심플한 UI(User Interface)와 강력한 기능 덕에 많은 해커가 이용하고 있고, 본 강좌에서도 디버깅을 통해 보여줘야 하는 부분은 이 디버거(Debugger)를 사용해서 보여드리겠습니다. :-)

참고적으로 3번의 경우, 디버거의 힘을 빌리지 않고, 단순히 메모리를 조작하기 위한 프로그램인 TSearch, Cheat Engine, ArtMoney, GameHack 등을 이용하여 메모리를 조작할 수도 있습니다.

이들 프로그램은 보통 온라인 게임을 해킹할 때 많이 사용됩니다. 아래는 메모리 에디터(Memory Editor) 중 대표적인 프로그램인 Cheat Engine의 스크린샷입니다. (다운로드는 http://www.cheatengine.org/ 에서 하실 수 있습니다. 현 시점에서 최신 버전은 1.4 입니다.)

사용자 삽입 이미지

[그림 설명] Cheat Engine 5.4 실행 화면


자, 그렇다면 4번의 예는 무엇이 있을까요? 대표적인 예로는 SpeedHack이나 Macro Program등이 있습니다. 이들 프로그램은 실행 환경을 조작하여 게임에서 좀 더 유리한 조건을 갖기 위해 쓰이는 경우가 대부분입니다. (Macro Program의 경우 본래 용도는 계속 반복되는 작업등을 한번에 편리하게 하기 위한 프로그램이었습니다.)

대표적인 스피드핵(SpeedHack)으로는 SpeederXP(http://www.speederxp.com/)가 있습니다. 이 프로그램의 스크린샷은 다음과 같습니다.


매크로 프로그램은 보통 게임에 사용할 때는 Hacker(해커)가 직접 프로그래밍 언어등으로 구현하는 경우가 많으나, 간단한 매크로 프로그램의 예로는 GhostMouse등이 있습니다. (스크린샷은 생략하겠습니다.)

이런 프로그램들은 어떤 방식으로 작성되어졌을까요? 우선 해킹 프로그램들의 분류를 3가지로 나누겠습니다.

1. Memory Editor (메모리 에디터)
2. Speed Cheater (스피드 핵)
3. Debugger (디버거)

첫번째로, 메모리 에디터(Memory Editor) 입니다. 이 프로그램은 보통 아래 API를 사용하여 구현됩니다.

BOOL WINAPI ReadProcessMemory(
  HANDLE hProcess,
  LPCVOID lpBaseAddress,
  LPVOID lpBuffer,
  SIZE_T nSize,
  SIZE_T* lpNumberOfBytesRead
);
BOOL WINAPI WriteProcessMemory(
  HANDLE hProcess,
  LPVOID lpBaseAddress,
  LPCVOID lpBuffer,
  SIZE_T nSize,
  SIZE_T* lpNumberOfBytesWritten
);

첫번째 인자는 메모리 I/O를 수행할 프로세스의 핸들(Handle)이며, 프로세스의 핸들은 OpenProcess() API로 얻을 수 있고, 그 API의 프로토타입은 아래와 같습니다.

HANDLE WINAPI OpenProcess(
  DWORD dwDesiredAccess,
  BOOL bInheritHandle,
  DWORD dwProcessId
);

첫번째 인자에는 핸들의 권한을 말하며, 보통 PROCESS_ALL_ACCESS를 대입한다. 두번째 인자로는 대부분 FALSE를 넣으며, 세번째 인자로는 핸들을 얻을 프로세스의 아이디를 지정하면 된다. 이 프로세스의 아이디는 CreateToolhelp32Snapshot() API와 Process32First/Process32Next API로 프로세스 목록을 순회하여 구하거나, FindWindow() API와 GetWIndowThreadProcessId() API를 사용하여 대상 프로그램의 창(Window)으로부터 프로세스 아이디를 획득할 수 있다.

핸들(Handle)이란?

왜 윈도우(WIndows)는 프로세스나 스레드 등의 개체를 접근할 때 직접 포인터로써 접근하게 하지 않고 핸들이라는 식별자를 별도로 구현해서 쓰는것일까? 이는 보안(Security)접근 제어(Access Control) 두가지의 의미가 있다.

우선 포인터(Pointer)로써 개체를 표현하게 되면, 어느 프로세스나 다 개체에 접근할 수 있음을 의미한다. 이런 경우 심각한 Security Hole이 발생하게 된다. 또 어떤 의미에서 핸들은 접근 제어의 역할을 한다. 지정된 권한 이외의 접근은 거부하고, 핸들을 상속한 프로세스가 아니면 접근하지 못하게 한다. 또한, 핸들은 개체에 직접 접근하지 않고, 핸들(Handle)이라는 식별자를 두어 접근하므로, 개체를 보다 안전하게 보호하고, 커널을 안정적으로 동작하게 하는 접근 제어의 기능을 수행한다.

두 번째 인자는 접근할 메모리 주소(포인터)를 지정하면 됩니다. (LPCVOID)0x401000 같이 접근할 주소를 지정하면 됩니다. 세번째 인자는 버퍼를 지정하고, 네번째 인자는 사이즈를, 다섯번째 인자는 I/O가 완료된 바이트의 수를 반환받기 위한 DWORD(unsigned long) 변수의 포인터를 넣으면 되나, NULL을 지정할 수도 있습니다.

윈도우의 가상 메모리(Virtual Memory) 체계에는 접근 제어를 위한 보호 기능이 존재합니다. 이러한 보호 기능에 대한 정보를 얻거나, 무력화 시키기 위해 아래 API들을 사용하기도 합니다.:

SIZE_T WINAPI VirtualQueryEx(
  HANDLE hProcess,
  LPCVOID lpAddress,
  PMEMORY_BASIC_INFORMATION lpBuffer,
  SIZE_T dwLength
);
BOOL WINAPI VirtualProtectEx(
  HANDLE hProcess,
  LPVOID lpAddress,
  SIZE_T dwSize,
  DWORD flNewProtect,
  PDWORD lpflOldProtect
);

위 API들에 대한 정보는 구글링(googling) 혹은 MSDN 검색을 해보시기 바랍니다.

두번째로, 스피드 핵(Speed Cheater)입니다. 이런 프로그램은 보통 후킹(Hooking)이라는 기법을 통해서 해킹이 이루어집니다. (후킹의 방법 외에도 하드웨어적으로 PIT(Programmable Interrupt Timer)의 값을 조작하는 방법도 있습니다.)

후킹(Hooking)이란, 어플리케이션 해킹 분야에서 많이 쓰이는 기법으로, 특정 함수나 API가 호출될 때, 호출의 제어권을 일시적으로 빼앗고, 원하는 작업을 하기 위해 사용되는 방법입니다. 일반적인 게임이나 프로그램의 경우, 시간 지연(프레임 제한)을 구현할 때 보통 아래와 같이 구현합니다.

DWORD dwOld = GetTickCount();
while(GetTickCount() <= dwOld + 1000); // 1초 대기한다.

스피드핵들은 이러한 코드에 착안, 해당 API들을 후킹하여 조작된 시간을 반환합니다.

보통 후킹되는 API는 아래와 같습니다.

GetTickCount();
GetSystemTime();
GetLocalTime();
QueryPerformanceCounter();


아래는 스피드핵의 동작 원리를 간단하게 그림으로 표현한 것 입니다.:

사용자 삽입 이미지
제가 직접 구현한 스피드핵(공부용)도 있습니다. 아래 페이지에서 다운로드 받으실 수 있습니다.
http://vbdream.tistory.com/entry/오래-전-심심해서-만들었던-스피드핵

제가 개발했던 스피드핵은 아래 API들을 후킹합니다. 조금 더 치밀하게 하기 위해서, 그리고 전체적인 퍼포먼스 때문이죠.

KERNEL32.DLL::GetTickCount
WINMM.DLL::timeGetTime
KERNEL32.DLL::GetSystemTime
KERNEL32.DLL::GetLocalTime
KERNEL32.DLL::SetSystemTIme
KERNEL32.DLL::SetLocalTime
KERNEL32.DLL::QueryPerformanceCounter    // 고수준 API
NTDLL.DLL::ZwQueryPerformanceCounter     // 저수준 API (저수준 API를 후킹함으로써 저수준 API를 직접 호출하는 것도 조작한다.)

SetSystemTime()/SetLocalTime() 도 후킹하여 시스템 날짜를 바꾸는 행위도 차단하는 것을 보실 수 있습니다.

그럼 마지막으로, 디버거입니다. 디버거(Debugger)는 어떻게 구현되었을까요?

아래 글을 참고하세요:
http://vbdream.tistory.com/entry/연구실-Debugger는-어떤-방식으로-구현되어있을까

디버거는 보통 아래의 API들을 사용해서 구현됩니다.

BOOL WINAPI DebugActiveProcess(DWORD dwProcessId);
void WINAPI DebugBreak(void);
BOOL WINAPI DebugBreakProcess(
  HANDLE Process
);
BOOL WINAPI WaitForDebugEvent(
  LPDEBUG_EVENT lpDebugEvent,
  DWORD dwMilliseconds
);
BOOL WINAPI ContinueDebugEvent(
  DWORD dwProcessId,
  DWORD dwThreadId,
  DWORD dwContinueStatus
);

그리고, 디버거의 골격은 아래와 같습니다. 자세한건 위 글을 참고하세요:

 DEBUG_EVENT dbgEvent;
 // TODO: Attach Process and Initializes variable
 while(WaitForDebugEvent(&dbgEvent, INFINITE))
 {
     // TODO: Process the exceptions
     /*
     switch(dbgEvent.dwDebugEventCode) {
           case EXCEPTION_DEBUG_EVENT:
               // ...
               break;
           // ...
     }
     */

     ContinueDebugEvent(dbgEvent.dwProcessId, dbgEvent.dwThreadId, DBG_CONTINUE);
 }

아, 그리고 설명하지 않은 부분이 있는데, 매크로(Macro Program)의 경우, 아래 API들을 사용해서 구현됩니다.
사용 방법은 생각보다 간단하니 MSDN을 참고하세요.

VOID WINAPI keybd_event(BYTE bVk,
    BYTE bScan,
    DWORD dwFlags,
    PTR dwExtraInfo
); // 키보드 입력 시뮬레이션
VOID WINAPI mouse_event(DWORD dwFlags,
    DWORD dx,
    DWORD dy,
    DWORD dwData,
    ULONG_PTR dwExtraInfo
); // 마우스 입력 시뮬레이션
UINT WINAPI SendInput(UINT nInputs,
    LPINPUT pInputs,
    int cbSize
);
// SendInput()은 keybd_event()와 mouse_event()가 최종적으로 호출하는 API이다.
HHOOK WINAPI SetWindowsHookEx(int idHook,
    HOOKPROC lpfn,
    HINSTANCE hMod,
    DWORD dwThreadId
);
// WH_JOURNALPLAYBACK 훅을 통해 입력을 시뮬레이트할 수도 있다.
LRESULT WINAPI SendMessage(HWND hWnd,
    UINT Msg,
    WPARAM wParam,
    LPARAM lParam
);
// 직접 마우스/키보드 메시지를 창에 보내 시뮬레이트할 수도 있다.
COLORREF WINAPI GetPixel(
  HDC hdc,    // handle to DC
  int nXPos,  // x-coordinate of pixel
  int nYPos   // y-coordinate of pixel
);
// 매크로와 직접적인 관련은 없지만, 요즘의 매크로는 화면 상의 색상을 인식하여
// 그에 맞는 동작을 취하는 형태로 개발되고 있다.


그렇다면, 어플리케이션 해킹을 막을 수 있는 방법은?

아래 섹션들로 나눠서 그에 걸맞는 방어 기법을 소개하겠습니다.

1. Memory Editor
2. Disassembler
3. Debugger
4. Macro Program
5. Speed Hack
6. Packet Sniffer


1. Memory Editor

먼 저, Memory Editor를 막는 방법입니다. 사실 프로세스의 메모리 I/O를 관장하고 있는 부분은 운영체제 커널(Kernel)로, 어플리케이션단에서 직접적으로 막는 방법은 없습니다. 하지만, 특정 프로그램을 감지하고 중간에 프로그램을 바로 마칠 수는 있습니다.

특정 핵 프로그램을 감지하는 방법은, 생각해보면 여러가지 방법이 나올 수 있습니다.

대표적으로 아래 두가지 방법들이 있는데요. 첫번째 방법은 백신의 실시간 감시에서나 쓰일 정도로 상당히 비효율적인 방법입니다. (오래걸리기도 하고, 게임을 개발하는 입장에서는, 게임에 과부하가 걸리면 안되죠. 속도가 생명이니까요.)

1. 프로세스를 모두 순회한 후, 프로그램 경로를 따내어 경로의 시그너처 분석 및 체크섬 비교
2. 프로세스가 사용하고 있는 이벤트, 파이프, 뮤텍스, 섹션 등 이름 있는 커널 개체들에 대한 조사

두번째 방법을 대체로 많이 사용합니다. 이 두번째 방법을 하기 앞서 대상 핵 프로그램에 대한 조사를 해야 하는데요. Sysinternals社(현재는 MS에 인수되었으며, www.sysinternals.com가 홈페이지이다.)에서 만든 Process Explorer(procexp) 프로그램(프리웨어이므로 홈페이지에서 다운받으세요.)을 사용하면 분석할 수 있습니다. 아래 메뉴를 클릭하면 특정 프로세스가 사용하고 있는 커널 개체의 핸들들에 대해서 볼 수 있습니다.

사용자 삽입 이미지

아래는 vmnat.exe 프로세스에서 사용중인 핸들을 조사한 모습:
사용자 삽입 이미지

Process Explorer에서는 어떻게 다른 프로세스의 핸들을 조사할 수 있을까?

내부적으로 핸들은 프로세스 개체의 HandleTable포인터에 의해 지시되어지는 핸들 테이블(HANDLE_TABLE)에 저장되어 있고, 커널 장치 드라이버에서 이를 조사하면 사용중인 핸들을 조사해볼 수는 있습니다.

lkd> dt !_EPROCESS // 프로세스 개체
nt!_EPROCESS
   +0x000 Pcb              : _KPROCESS
   +0x06c ProcessLock      : _EX_PUSH_LOCK
   +0x070 CreateTime       : _LARGE_INTEGER
   +0x078 ExitTime         : _LARGE_INTEGER
   +0x080 RundownProtect   : _EX_RUNDOWN_REF
   +0x084 UniqueProcessId  : Ptr32 Void
   +0x088 ActiveProcessLinks : _LIST_ENTRY
   +0x090 QuotaUsage       : [3] Uint4B
   +0x09c QuotaPeak        : [3] Uint4B
   +0x0a8 CommitCharge     : Uint4B
   +0x0ac PeakVirtualSize  : Uint4B
   +0x0b0 VirtualSize      : Uint4B
   +0x0b4 SessionProcessLinks : _LIST_ENTRY
   +0x0bc DebugPort        : Ptr32 Void
   +0x0c0 ExceptionPort    : Ptr32 Void
   +0x0c4 ObjectTable      : Ptr32 _HANDLE_TABLE (핸들이 저장되어있는 핸들 테이블이다.)
   +0x0c8 Token            : _EX_FAST_REF
   +0x0cc WorkingSetLock   : _FAST_MUTEX
   +0x0ec WorkingSetPage   : Uint4B
   +0x0f0 AddressCreationLock : _FAST_MUTEX
   +0x110 HyperSpaceLock   : Uint4B
   +0x114 ForkInProgress   : Ptr32 _ETHREAD
   (... 생략)

lkd> dt !_HANDLE_TABLE
nt!_HANDLE_TABLE
   +0x000 TableCode        : Uint4B // 핸들이 저장된 공간을 가리키는 부분
   +0x004 QuotaProcess     : Ptr32 _EPROCESS
   +0x008 UniqueProcessId  : Ptr32 Void
   +0x00c HandleTableLock  : [4] _EX_PUSH_LOCK
   +0x01c HandleTableList  : _LIST_ENTRY
   +0x024 HandleContentionEvent : _EX_PUSH_LOCK
   +0x028 DebugInfo        : Ptr32 _HANDLE_TRACE_DEBUG_INFO
   +0x02c ExtraInfoPages   : Int4B
   +0x030 FirstFree        : Uint4B
   +0x034 LastFree         : Uint4B
   +0x038 NextHandleNeedingPool : Uint4B
   +0x03c HandleCount      : Int4B
   +0x040 Flags            : Uint4B
   +0x040 StrictFIFO       : Pos 0, 1 Bit

그 런데, 핸들은 꼭 커널에서만 조회할 수 있을까? Windows NT 버전부터는 Native API(Ntdll.dll에 익스포트(export) 되어있는 함수로, 보통 Zw*, Nt* 로 시작하는 함수군을 말한다)를 사용해서 핸들을 조회할 수 있습니다. 해당 API는 ZwQuerySystemInformation로, 시스템 정보를 조회하는 함수입니다. 이 함수는 아래와 같은 프로토타입을 가지고 있습니다.

NTSTATUS
NTAPI

ZwQuerySystemInformation(
  IN SYSTEM_INFORMATION_CLASS SystemInformationClass,
  OUT PVOID               SystemInformation,
  IN ULONG                SystemInformationLength,
  OUT PULONG              ReturnLength OPTIONAL );

SystemInformationClass 인자에 SystemHandleInformation(16) 을 집어넣으면 모든 프로세스에 대한 핸들 정보를 얻을 수 있습니다. ( http://www.ntcore.com/Files/wfp.htm 에서 WFP(Windows File Protection)을 해제하기 위해 SystemHandleInformation를 사용한 예를 찾아볼 수 있습니다. 관심있으신 분은 읽어보세요. :) )


Process Explorer로 조사해보니 치트 엔진은 아래와 같은 개체를 open하고 있었습니다.

사용자 삽입 이미지

이에 착안하여 치트 엔진을 감지하는 코드는 아래와 같이 코딩할 수 있을것 입니다.

/* 치트 엔진 5.x를 감지하는 코드 */
BOOL __fastcall DetectCheatEngine()
{
 HANDLE hMapping = NULL;
 hMapping = OpenFileMapping(FILE_MAP_READ, FALSE, "CEHYPERSCANSETTINGS");
 if(hMapping)
 {
  CloseHandle(hMapping);
  return TRUE;
 } else {
  return FALSE;
 }
}

적절한 부분에

if(DetectCheatEngine())
{
MessageBox(NULL, "게임 핵 프로그램이 실행중인 것을 발견하였습니다.", "오류", MB_OK | MB_ICONERROR);
ExitProcess(1);
}

위와 같은 코드를 삽입시켜 준다면 치트 엔진은 더 이상 사용할 수 없습니다.

마찬가지의 방법으로, 티서치, 아트머니, 게임핵을 감지할 수 있습니다.

/* 치트 엔진 5.x를 감지하는 코드 */
BOOL __fastcall DetectCheatEngine()
{
 HANDLE hMapping = NULL;
 hMapping = OpenFileMapping(FILE_MAP_READ, FALSE, "CEHYPERSCANSETTINGS");
 if(hMapping)
 {
  CloseHandle(hMapping);
  return TRUE;
 } else {
  return FALSE;
 }
}
/* 치트 엔진 1.x를 감지하는 코드 */
BOOL __fastcall DetectTSearch()
{
 HANDLE hEvent = NULL;
 hEvent = OpenEvent(EVENT_MODIFY_STATE, FALSE, "User stopped search");
 if(hEvent)
 {
  CloseHandle(hEvent);
  return TRUE;
 } else {
  return FALSE;
 }
}
/* 아트머니 7.x를 감지하는 코드 */
BOOL __fastcall DetectArtMoney()
{
 HANDLE hMapping = NULL;
 hMapping = OpenFileMapping(FILE_MAP_READ, FALSE, "ArtMoneyHookFileMap");
 if(hMapping)
 {
  CloseHandle(hMapping);
  return TRUE;
 } else {
  return FALSE;
 }
}
/* 게임핵 2.0를 감지하는 코드 */
BOOL __fastcall DetectGameHack()
{
 HWND hWnd = NULL;
 hWnd = FindWindow(NULL, "GameHack 2.0");
 return (hWnd) ? TRUE : FALSE;
}

하지만, 이러한 방식으로는 분명 한계가 있습니다. 언제 어디서 메모리 에디터가 또 나올지 모르며, 버전 업이 되었을 수도 있고, 또한 몰래 만들어서 혼자만 쓰고 있을수도 있을 수 있기 때문입니다. 따라서 근본적으로 차단하려면, 장치 드라이버(Device Driver)를 만들어서, 커널 모드에서 메모리 I/O를 담당하는 함수를 후킹(Hooking)하여 프로세스를 보호할 수 있을 것 입니다. 아래는 커널 드라이버에서 NtReadVirtualMemory API를 후킹하고 후킹된 함수를 redirect한 함수의 C 코드입니다. 프로세스 식별자를 비교하여 보호된 프로세스인지 체크하여 적절한 처리를 수행합니다.

NTSTATUS NTAPI MyNtReadVirtualMemory(
    IN HANDLE ProcessHandle,
    IN PVOID BaseAddress,
    OUT PVOID Buffer,
    IN ULONG NumberOfBytesToRead,
    OUT PULONG NumberOfBytesReaded OPTIONAL
)
{
    PEPROCESS Process;
    NTSTATUS Status;

    Status = ObReferenceObjectByHandle(ProcessHandle, PROCESS_ALL_ACCESS, NULL, KernelMode, (PVOID *)&Process, NULL);
    if(NT_SUCCESS(Status))
    {
        ObDereferenceObject(ProcessHandle);
        if(*(HANDLE *)((LONG)Process + UniqueProcessIdOffset) == GameProcessId) // 보호되고 있는 프로세스인가?
        {
            // 접근 거부 오류를 리턴한다.
            return STATUS_ACCESS_DENIED;
        }
        else
        {
            return OldNtReadVirtualMemory(ProcessHandle, BaseAddress, Buffer, NumberOfBytesToRead, NumberOfBytesReaded);
        }
    } else return Status;
}

일반적으로, 게임 제작 업체의 경우 이러한 문제를 해결하기 위해 GameGuardAhnLab HackShield등의 게임보안솔루션을 도입하기도 합니다. 또는 이러한 문제를 해결하기 위해 루트킷(Rootkit)을 도입해볼 필요도 있습니다. (http://www.rootkit.com 참고)

커널 드라이버를 직접 코딩할 수 있다면, 커널 드라이버와 프로세스를 연계하여 보호할 수 있습니다. 확실한 보호를 위해 다음과 같은 커널 함수를 후킹해야할 필요가 있습니다.

NtReadVirtualMemory();       // 메모리 읽기 방지
NtWriteVirtualMemory();       // 메모리 쓰기 방지
NtProtectVirtualMemory();    // 메모리 권한 제어 방지
NtOpenProcess();              // 핸들 얻는 것을 방지
NtOpenThread();                // 스레드 핸들 얻는 것을 방지
NtSetContextThread();        // 스레드 레지스터 조작 방지
NtGetContextThread();        // 스레드 레지스터 조작 방지
NtCreateThread();              // 원격 스레드 생성(CreateRemoteThread 등) 방지
NtDebugActiveProcess();    // 원격 디버그 방지
NtSystemDebugControl();    // Kernel Mode로의 진입 차단
NtOpenSection();               // 물리 메모리 read/write 차단
KeAttachProcess();            // 가상 메모리 접근 차단 - 프로세스 메모리 보호
KeStackAttachProcess();    // 가상 메모리 접근 차단 - 프로세스 메모리 보호
NtTerminateProcess();        // 프로세스 종료 차단
NtTerminateThread();          // 스레드 종료 차단
NtSuspendThread();           // 스레드 중지 차단
NtSuspendProcess();         // 프로세스 중지 차단
PspTerminateThreadByPointer(); // 프로세스 종료 차단

** PspTerminateThreadByPointer() 함수의 경우 unexported 된 함수이므로, NtTerminateThread()를 트레버싱해서 알아낼 수 있습니다.


후킹 메커니즘의 이해

대체 후킹(Hooking)이라 불리는 기술은 어떻게 하는건지 궁금하지 않으신가요?

대개 윈도우에서의 후킹은 세 가지 유형으로 분류됩니다.

1. 코드를 조작하는 후킹
2. 테이블 내용을 건드리는 후킹
3. SetWindowsHookEx() 훅을 이용한 후킹

1 번 메커니즘의 원리는 단순하면서도 강력합니다. 코드를 조작한다는 의미는 메모리를 쓴다는 의미이고, 사실상 메모리에 있는 '모든 함수'를 후킹할 수 있습니다. 대표적으로 Detour 후킹이 있으며, 이 후킹 방식의 개요는 다음과 같습니다.

후킹 전의 함수:
CodeA
CodeB
CodeC
CodeD
...

후킹 후의 함수:
JumpTo NewFunction // NewFunction() 함수 주소로 실행 흐름 변경 (어셈블리의 jmp에 해당)
CodeB
CodeC
CodeD
...
...
...

--------------------------------------------------------
NewFunction
--------------------------------------------------------

/* 적절한 처리를 한 후... */

CallTo Trampoline // 트렘펄린 함수를 호출한다.
Return // 후킹된 함수를 호출한 코드로 return한다.

--------------------------------------------------------
Trampoline
--------------------------------------------------------
CodeA // 코드 수정에 의해 덮어씌워진 코드를 재실행한 후
JumpTo 'Addressof CodeB' // CodeB의 위치로 실행 흐름을 바꾼다.

실행 흐름이

'대상 함수 -> 리턴'

에서

'대상 함수 -> 후킹 함수 -> 트렘펄린 -> 대상 함수 -> 리턴 -> '후킹 함수'에서 리턴

으로 바뀌었음을 볼 수 있습니다.


2. Disassembler

프 로그램 분석의 첫 단계가 디스어셈블러를 이용해서 코드를 분석하는 것일껍니다. 디스어셈블러란 컴파일된 기계어 코드를 어셈블리 언어로 바꾸어서 표기해주는 프로그램으로, 기계어와 어셈블리 언어는 1:1로 그 명령어가 매치되므로 가능한 일입니다.

대 부분의 디버거는 디스어셈블리 기능을 제공하므로, 디스어셈블러 대신 디버거를 쓰는 경우가 많습니다. Disassembler를 막는 방법은, 대표적으로 Code Obfuscation(코드를 디어셈블러가 알아들을 수 없도록 복잡하게 꼬아놓는 것)이 있습니다.

다음은 대표적인 Code Obfuscation입니다.

__asm
{
    jmp jmpTo
    __emit 0xE9 // JMP LONG에 해당하는 opcode. Code obfuscation
jmpTo:
    mov eax, 1
    xor ebx, ebx
    not ebx
    test eax, ebx
    je jmpTo2
    __emit 0xE9 // JMP LONG에 해당하는 opcode. Code obfuscation
jmpTo2:
}

디어셈블러의 입장에서 보면 대략 아래와 같은 코드로 보일것입니다.

JMP 00401024
JMP ????????
???? ( 쓰레기 명령어 )
???? ( 쓰레기 명령어 )
UNKNOWN ( 알수 없는 명령어 )
... ( 생략 )
...

디버거/디어셈블러에 따라선 이런 트릭을 사용하면 해당 디버거/디어셈블러가 오류와 함께 팅길 수 있습니다. 물론, 정상적인 프로그램 실행에는 아무런 영향을 미치지 않습니다.

3. Debugger

프로그램 개발 중 제일 성가신 놈이 디버거(Debugger) 입니다. 이 Debugger를 감지할 수 있는 방법은 없을까요?

1. IsDebuggerPresent()을 이용한 감지 방법

윈도우에서는 기본적으로 디버거를 감지하기 위한 API를 제공합니다.

그 API는 IsDebuggerPresent()라는 이름의 API로, 아래와 같은 프로토타입을 갖습니다.

BOOL WINAPI IsDebuggerPresent(void);

반환값은 디버거의 존재 여부입니다.

아래와 같이 코딩하면 디버거 발견시 프로그램이 꺼지게 할 수 있을것 입니다.
Sysinternals에서 개발한 대부분의 프로그램에서는 아래와 같은 코드가 들어있었습니다.:

if(IsDebuggerPresent())
{
ExitProcess(1);
}

이 방법의 장점은 OS에서 제공하는 메커니즘이므로 안전하다는 장점이 있지만, 단점으로는 너무 유명해서 대부분의 디버거의 플러그인에 의해 막힌다는 단점이 있습니다.

2. CheckRemoteDebuggerPresent()를 이용한 감지 방법

CheckRemoteDebuggerPresent() 는 아래와 같은 API로,

BOOL WINAPI CheckRemoteDebuggerPresent(
  HANDLE hProcess,
  PBOOL pbDebuggerPresent
);

첫번째 인자에 넘겨진 프로세스 핸들의 프로세스에 디버거 유무를 조사해 pbDebuggerPresent로 그 유무를 반환합니다.

이 API는 다음과 같이 사용하면 됩니다.

BOOL DebuggerPresent = FALSE;
CheckRemoteDebuggerPresent(GetCurrentProcess(), &DebuggerPresent);
// NT 이상에서는 현재 프로세스에 대한 핸들이 NtCurrentProcess(0xFFFFFFFF)로 준비되어있으므로, 이를 사용해도 된다.
if(DebuggerPresent)
{
/* 적절한 처리 */
}

그러나 이 API는 XP 이상에서만 사용 가능하다는 단점이 있습니다.
최소한 NT 이상으로 호환성을 높이기 위해서는 이 API가 내부적으로 호출하는 Native API인 ZwQueryInformationProcess()를 직접 호출해주는 방법이 있습니다.

3. ZwQueryInformationProcess()를 이용한 감지 방법

ZwQueryInformationProcess()는 아래와 같은 API입니다.

NTSTATUS NTAPI ZwQueryInformationProcess(
  HANDLE ProcessHandle,
  PROCESSINFOCLASS ProcessInformationClass,
  PVOID ProcessInformation,
  ULONG ProcessInformationLength,
  PULONG ReturnLength
);


Ntdll.dll에 export되어 있으며, ProcessInformationClass에 ProcessDebugPort(7)을 넣으면 디버그 포트 값을 얻을 수 있는데, 기본적으로 디버거가 Attach 되어있으면 NULL이 아닌 값이 전달됩니다.

이 API는 아래와 같은 방식으로 활용 가능합니다.

typedef LONG (WINAPI *ZWQUERYINFORMATIONPROCESS)(HANDLE,DWORD,PVOID,ULONG,PULONG);

/* ... */

 HMODULE hNtDLL = GetModuleHandle("ntdll.dll");
 if(hNtDLL)
 {
  ZWQUERYINFORMATIONPROCESS QueryProc =
   (ZWQUERYINFORMATIONPROCESS) GetProcAddress(hNtDLL, "ZwQueryInformationProcess");
  if(QueryProc)
  {
   HANDLE DebugPort = NULL;
   ULONG ReturnLength = 0L;
   if(QueryProc((HANDLE)0xFFFFFFFFL, 7, &DebugPort, 4, &ReturnLength) >= 0) // NTSTATUS는 0 이상이면 성공의 의미
   {
    if(DebugPort)
    {
     // 디버거 발견!
     ExitProcess(1);
    }
   }
  }
 }

4. 의도적 예외와 SEH을 이용한 감지 방법

Debugger는 중단점(Breakpoint)이 핵심입니다. 중단점 중 대표적인 것이 INT 3 명령어로, binary code로는 0xCC입니다. INT 3 명령어를 이용해서 디버거를 감지합니다.

#include <excpt.h>

int main(void)
{
 __try
 {
  __asm INT 3;
  /*
   디버거 발견 시 처리
  */

  return 1;
 }
 __except(EXCEPTION_EXECUTE_HANDLER)
 {}

 /* 계속 처리 */

 return 0;
}

가장 기초적인 형태의 감지 기법입니다. 좀 낡은 수법이지만, 아직까지도 많이 쓰이고 있습니다.

※ 디버깅 시 뜻하지 않은 예외를 만나면 Shift+F9 (OllyDbg 기준)을 누름으로써 넘어가면 어느정도 이런 트릭을 회피할 수 있습니다.

5. INT 2Dh(DebugService) trick

NT 계열 OS에서 INT 2Dh를 Debugger가 연결된 상태에서 호출하면 특이한 일이 일어나는 데, 이 점을 이용한 트릭입니다.

__declspec(naked) int DebuggerEnabled(void)
{  
    __asm
 {
  push offset notDebugger
  push DWORD ptr FS:[0]  
  mov DWORD ptr FS:[0], esp   // Install SEH!
  INT 2Dh  
  NOP      // Breakpoint it here!
              // SoftIce Driver must crash in here!
 
//IsDebugger:
  pop DWORD ptr FS:[0]
  add esp, 4
  mov eax, 1
  retn
 
notDebugger:
  push edi
  mov edi, [esp+10h] // pContext
  mov dword ptr [edi+0B8h], offset Cleanup // eip
  pop edi
  xor eax, eax
  retn
Cleanup:
  pop DWORD ptr FS:[0]   // Cleanup SEH!
  add esp, 4
  xor eax, eax
  retn
 }
}

이 기법은 http://www.rootkit.com/newsread.php?newsid=669 에 처음 소개된 방법입니다.

위 함수를 선언한 후 아래와 같이 사용하세요.

if(DebuggerEnabled())
{
/* 디버거 발견시 적절한 처리 */
}

6. ZwSetInformationThread() trick

이 트릭을 이용하면 Debugger로부터 Thread를 감출 수 있게 됩니다.

NTSTATUS
NTAPI

ZwSetInformationThread(
  IN HANDLE               ThreadHandle,
  IN THREAD_INFORMATION_CLASS ThreadInformationClass,
  IN PVOID                ThreadInformation,
  IN ULONG                ThreadInformationLength );

ThreadInformationClass에 HideFromDebugger (0x11)를 넣고, ThreadInformation, ThreadInformationLength에 각각 NULL, 0L 을 집어넣으면 숨길 수 있습니다. 디버거의 스레드 리스트에 나타나지 않게 됩니다.

실제 Themida 프로텍터에 의해 보호된 프로세스는 자동적으로 이 API를 호출하였습니다.

사용 예제 코드:

typedef LONG (WINAPI *ZWSETINFORMATIONTHREAD)(HANDLE,DWORD,PVOID,ULONG);
/* ... */
 HMODULE hNtDLL = GetModuleHandle("ntdll.dll");
 if(hNtDLL)
 {
  ZWSETINFORMATIONTHREAD SetThread =
   (ZWSETINFORMATIONTHREAD) GetProcAddress(hNtDLL, "ZwSetInformationThread");
  if(SetThread)
  {
   SetThread((HANDLE)-2L, (DWORD)0x11, NULL, 0);
  }
 }

그 밖에 많은 방법들이 아래 링크에 있습니다. 참고하시기 바랍니다:
http://www.openrce.org/reference_library/anti_reversing
http://www.securityfocus.com/infocus/1893

4. Macro Program

매크로 프로그램을 막는 방법은, DLL Injection을 이용해서도 막을 수 있습니다.
User Mode에서 keybd_event() / mouse_event() / SendInput() / GetPixel() 등 매크로에 사용되는
몇가지 API를 Detour Hook하면 막을 수 있습니다.

DLL Injection이란?

Windows DLL을 다른 프로세스에 침투시키는 방법입니다. DLL은 보통 아래와 같이 프로그래밍됩니다.

BOOL CALLBACK DllMain(HINSTANCE hInstExe, DWORD dwReason, LPVOID lpReserved)
{
    switch(dwReason)
    {
        case DLL_PROCESS_ATTACH:
        /* DLL 로드시 수행할 코드 */
        break;
        case DLL_PROCESS_DETACH:
        /* DLL 언로드시 수행할 코드 */
        break;

        case DLL_THREAD_ATTACH:
        case DLL_THREAD_DETACH:
        break;
    }
    return TRUE;
}

이 DLL을 다른 프로세스에 침투한다면 Dll은 해당 프로세스의 문맥에서 실행될 것 입니다.
이 때 DLL을 침투하는 대표적 방법으로는 두가지가 있습니다.

1. CreateRemoteThread()

외부에서 LoadLibrary()를 Remote Thread 방법으로 실행시킬 수 있습니다. LoadLibrary() 인자 구조와
Thread Procedure의 인자 구조가 같다는 데서 착안한 아이디어라고 할 수 있겠습니다.

아래는 CreateRemoteThread()를 이용한 Dll Injection Helper 함수입니다.
VOID InjectDLL(HANDLE hProcess, LPSTR DllPath)
{
 LPVOID lpAddress = VirtualAllocEx(hProcess, NULL, strlen(DllPath)+1, MEM_COMMIT, PAGE_READWRITE);
 if(!lpAddress) return;
 DWORD dwDummy = 0L;
 if(!WriteProcessMemory(hProcess, lpAddress, DllPath, strlen(DllPath)+1, &dwDummy)){
  VirtualFreeEx(hProcess, lpAddress, strlen(DllPath)+1, MEM_RELEASE);
  return;
 }
 LPVOID lpLoadLibrary = (LPVOID)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "LoadLibrary"));
 if(!lpLoadLibrary){
  VirtualFreeEx(hProcess, lpAddress, strlen(DllPath)+1, MEM_RELEASE);
  return;
 }
 HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE) lpLoadLibrary, (LPVOID) lpAddress, 0, &dwDummy);
 if(!hThread)
 {
  VirtualFreeEx(hProcess, lpAddress, strlen(DllPath)+1, MEM_RELEASE);
  return;
 }
 if(WaitForSingleObject(hThread, 10000) != WAIT_OBJECT_0)
 {
  TerminateThread(hThread, 0xFFFFFFFF);
 }
 CloseHandle(hThread);
 VirtualFreeEx(hProcess, lpAddress, strlen(DllPath)+1, MEM_RELEASE);
 return;
}

2. SetWindowsHookEx() Hook Procedure 이용

SetWindowsHookEx() 훅에서 hModule 인자에는 DLL의 로드 베이스 값을 넣어줄 수 있습니다.
이 때, 글로벌 훅의 경우엔 해당 프로세스에 dll이 인젝션 되게 됩니다. 이를 이용할 수 있습니다.

5. Speed Hack

이 것은 확실하게 막으려면 서버 단에서 패킷의 시간을 체크하는 방법밖엔 없습니다.
클라이언트에서 감지하는 방법은, 인터넷 시간과 동기화시킨 후, 인터넷 시간과 체크하는 방법 등이 있습니다.
그 밖에도 파일을 생성한 후, 파일 생성 시간을 저장해뒀다가 GetTickCount() 함수로 3초 기다린 후, 다시 파일을 생성한 후 생성 시간이 3초 차이 나는지 체크하는 방법 등이 있습니다.

6. Packet Sniffer

이번 섹션에는 패킷 스니퍼를 감지하는 방법에 대해 알아보겠습니다.

패킷 스니퍼 중 대표적인 WPE PRO 를 감지해보겠습니다.

첫번째로, WpeSpy.DLL을 감지하여 감지하는 방법이 있습니다.

if(GetModuleHandle("WpeSpy.DLL"))
{
/* 발견시 적절한 처리 */
}

두번째로, 후킹을 검사하는 방법이 있습니다.

HMODULE hSocketDll = GetModuleHandle("ws2_32.dll");
if(!hSocketDll)
{
    hSocketDll = LoadLibrary("ws2_32.dll");
    if(!hSocketDll) return;
}
LPVOID recvApi = (LPVOID)GetProcAddress(hSocketDll, "recv");
if(recvApi)
{
    BYTE FirstByte = *(BYTE *)recvApi;
    if(FirstByte == (BYTE)0xCC || FirstByte == (BYTE)0xE9 || FirstByte == (BYTE)0xE8 || FirstByte == (BYTE)0xEB || FirstByte == (BYTE)0xCD)
    {
        /* 훅 발견시 적절한 처리
        closesocket(...);
        MessageBox(NULL, "패킷 스니퍼 발견!", "오류", MB_OK | MB_ICONERROR);
        ExitProcess(1);*/
    }
}
LPVOID sendApi = (LPVOID)GetProcAddress(hSocketDll, "send");
if(sendApi)
{
    BYTE FirstByte = *(BYTE *)sendApi;
    if(FirstByte == (BYTE)0xCC || FirstByte == (BYTE)0xE9 || FirstByte == (BYTE)0xE8 || FirstByte == (BYTE)0xEB || FirstByte == (BYTE)0xCD)
    {
        /* 훅 발견시 적절한 처리
        closesocket(...);
        MessageBox(NULL, "패킷 스니퍼 발견!", "오류", MB_OK | MB_ICONERROR);
        ExitProcess(1);*/
    }
}

7. Misc

1) Debug Register Hook을 감지하는 방법

GetThreadContext() API를 통해서 감지할 수 있습니다.

 CONTEXT cx;
 RtlZeroMemory(&cx, sizeof(cx)); // zero clear
 cx.ContextFlags = CONTEXT_FULL;
 GetThreadContext((HANDLE)-2, &cx);
 if(cx.Dr0 || cx.Dr1 || cx.Dr2 || cx.Dr3 || cx.Dr6 || cx.Dr7)
 {
  /* 적절한 처리 */
 }

2) Debug Register를 은밀하게 clear하는 방법

 __asm
 {
  push offset seh_handler
  push DWORD ptr FS:[0]
  mov DWORD ptr FS:[0], esp
  xor ebx, ebx // 일부러 예외를 발생시키기 위해 EBX=0
  mov ebx, dword ptr [ebx] // 예외 발생!
  pop dword ptr FS:[0]
  add esp, 4
  jmp Continue

seh_handler:
  push esi
  mov esi, dword ptr [esp + 10h] // pContext
  mov dword ptr [esi + 0A4h], offset seh_handler
  mov dword ptr [esi + 04h], 0 // Dr0
  mov dword ptr [esi + 08h], 0 // Dr1
  mov dword ptr [esi + 0Ch], 0 // Dr2
  mov dword ptr [esi + 10h], 0 // Dr3
  mov dword ptr [esi + 14h], 0 // Dr6
  mov dword ptr [esi + 18h], 0 // Dr7
  pop esi
  xor eax, eax
  retn

Continue:
 }

간단하게 어플리케이션 보안을 하는 방법에 대해 알아보았습니다.

위에서 알아본것 외에도 패커/프로텍터를 사용하여 보호해볼 수 있습니다.

요즘 유명한 프로텍터로는 Themida가 있고, 이 Themida를 이용해서 실행화일을 디버거로부터 강력하게 보호할 수 있습니다.

읽어주셔서 감사합니다.


[출처] 수학쟁이님의 블로그~
(http://vbdream.tistory.com)