개발자를위한윈도우후킹테크닉 WH_DEBUG 훅을이용한훅탐지방법 지금까지우리는다양한종류의훅을사용해서다른프로그램으로전달되는메시지를가로채는방법을법을배웠다. 이번시간에는 WH_DEBUG 훅을사용해서훅프로시저를탐지하는방법에대해서다룬다. 목차 목차...1 필자소개...1 연재가이드...1 연재순서... 오류! 책갈피가정의되어있지않습니다. 필자메모...2 Intro...2 WH_DEBUG 훅...3 훅디텍터...4 설치한프로세스 ID 구하기...7 프로세스이름구하기... 11 프로세스에로드된모듈열거하기... 12 도전과제... 14 참고자료... 14 필자소개 신영진 pop@jiniya.net, http://www.jiniya.net " 너는네세상어디에있느냐? 너에게주어진몇몇해가지나고몇몇날이지났는데, 그래너는네세상어디쯤에와있느냐?" 2006 년도이제몇달남지않았다. 후회가되지않는한해를보내기란버그없는프로그램을만드는것만큼힘든일인것같다. 연재가이드 운영체제 : 윈도우 98/2000/XP 개발도구 : 마이크로소프트비주얼스튜디오 2003 기초지식 : C/C++, Win32 프로그래밍응용분야 : 훅탐지프로그램, 보안프로그램
연재순서 2006. 05 키보드모니터링프로그램만들기 2006. 06 마우스훅을통한화면캡쳐프로그램제작 2006. 07 메시지훅이용한 Spy++ 흉내내기 2006. 08 SendMessage 후킹하기 2006. 09 Spy++ 클론 imspy 제작하기 2006. 10 저널훅을사용한매크로제작 2006. 11 WH_SHELL 훅을사용해다른프로세스윈도우서브클래싱하기 2006. 12 WH_DEBUG 훅을이용한훅탐지방법 2007. 01 OutputDebugString 의동작원리 필자메모 언젠가뉴스그룹에누군가가프로그램을해킹하는것을막는방법이있냐고질문한적이있었다. 여러분이어떻게생각하는가? 폰노이만식아키텍처 ( 프로그램과데이터가같은메모리에저장되는방식 ) 를사용하는유사튜링머신 ( 읽기, 쓰기, 실행형태로동작 ) 형태를취하고있는현재의컴퓨터시스템에서완벽한보안이란있을수없다고필자는굳게믿고있다. 왜냐하면모든것이결국은메모리에있어야하고, CPU 에의해서해석될수있는코드집합이기때문이다. 압축을하고암호화를해도그것을실행하려면압축을풀고암호를해독해야한다. 데이터도마찬가지다. 그때그토론도그런식으로결론이났었다. 하지만완벽할수없다고해서보안에대한 노력이무의미한것은아니다. 해커도사람이기때문이다. 사람의인내심과이해력의한계는 분명히있다. 가장최선의보안은해커를가장귀찮게만드는것이다. Intro 지금까지우리는다양한훅을통해서다른프로그램을훔쳐보는방법을배웠다. 늘그렇듯이훔쳐보는것은재미난일이지만자신이공들여만든프로그램이다른프로그램에게감시당하는것은썩기분좋은일은아니다. 키로거에의해서패스워드를도난당하거나악성프로그램에의해서데이터가파손되는일들이자신의프로그램에서벌어지지않으리란법은없는것이다. 2/14 페이지
이번시간에는위와같은질문에대한해답을찾아본다. WH_DEBUG 훅을사용해서다른훅들을감시하고그것들의실행을중지시키는방법을배운다. 이과정에서훅디텍터를제작해보고 toolhelp 라이브러리를사용해서프로세스, 스레드, 모듈정보를구하는방법에대해서다룬다. WH_DEBUG 훅 WH_DEBUG 훅은시스템에설치된다른훅을디버깅하기위해서만들어진훅이다. 하지만이훅을디버깅을위해서사용하는경우는정말드물다. 대부분의프로그램은다른후킹프로그램을탐지하기위해서 WH_DEBUG 훅을사용한다. WH_DEBUG 훅을사용하면 WH_KEYBOARD_LL, WH_MOUSE_LL, WH_DEBUG 훅을제외한모든훅을검출할수있다. LRESULT CALLBACK DebugProc(int ncode, WPARAM wparam, LPARAM lparam); code [ 입력 ] HC_ACTION 인경우엔훅프로시저를수행하면되고, 0 보다작은경우에는훅 프로시저를수행하지않고 CallNextHookEx 를호출한후바로끝내야한다. wparam [ 입력 ] 탐지한훅프로시저의종류를나타낸다. WH_KEYBOARD_LL, WH_MOUSE_LL, WH_DEBUG 훅은감지되지않는다. lparam [ 입력 ] DEBUGHOOKINFO 구조체포인터를가리킨다. DEBUGHOOKINFO 구조체의 원형은다음과같다. 필드별의미는 < 표 1> 에나와있다. typedef struct DWORD idthread; DWORD idthreadinstaller; LPARAM lparam; WPARAM wparam; int code; DEBUGHOOKINFO, *PDEBUGHOOKINFO; 표 1 DEBUGHOOKINFO 구조체필드별의미 필드명 idthread idthreadinstaller lparam wparam 의미훅프로시저가수행되고있는스레드 ID 훅을설치한스레드 ID ( 문서의내용과달리 NT 계열의운영체제에서는이값이항상 0 으로넘어온다.) 탐지된훅프로시저로전달될 lparam 탐지된훅프로시저로전달될 wparam 3/14 페이지
code 탐지된훅프로시저로전달될 code 리턴값 : WH_DEBUG 훅에검출된훅함수를수행하지않으려면 0 이아닌값을리턴해야한다. 그렇지않은경우라면 CallNextHookEx 의리턴값을그대로사용하는것이좋다. 훅디텍터 이제 WH_DEBUG 훅을사용해서훅디텍터를제작해보자. 이프로그램은다른프로그램의훅코드가수행됨을사용자에게알려주는역할을한다. 프로그램의동작모습은 < 화면 1> 에나와있다. 각줄은훅종류, 훅이수행되는스레드 ID, 훅을설치한스레드 ID, 훅을설치한프로세스, 훅을설치한프로세스명으로구성된다. 화면 1 Windows XP 에서훅탐지프로그램을실행한경우 결과화면을보면알수있지만한가지이상한현상이존재한다. 다름아닌훅을설치한스레드의 ID 가모두 0 이라는점이다. NT 이상의운영체제의경우에는 WH_DEBUG 훅으로넘어오는 DEBUGHOOKINFO 구조체의 idthreadinstaller 항목이문서와달리전부 0 으로넘어왔다. 따라서훅프로시저가호출된다는건알수있어도누가설치한훅프로시저인지검출하는것은불가능하다. 반면에 9x 계열의운영체제에서는 DEBUGHOOKINFO 구조체의 4/14 페이지
모든값이정상적으로넘어오기때문에훅을설치한프로그램을추적하는것이가능하다. < 화면 2> 는 Windows 98 SE 컴퓨터에서프로그램을동작시킨모습을보여주고있다. 화면 2 Windows 98 SE 에서훅탐지프로그램을실행한경우 훅프로시저는정말간단하다. < 리스트 1> 에코드가나와있다. 훅디텍터의경우자신을후킹하는외부프로그램을검출하는것이목적이기때문에 WH_DEBUG 훅의범위를자신의스레드로제한해서설치하였다. 따라서훅프로시저가실행프로그램과동일한컨텍스트에서호출되기때문에포인터를메시지로전달해도된다. 리스트 1 WH_DEBUG 훅프로시저 LRESULT CALLBACK DebugProc(int code, WPARAM w, LPARAM l) if(code == HC_ACTION) SendMessage(AfxGetMainWnd()->GetSafeHwnd(), WM_HOOKDETECTED, w, l); return CallNextHookEx(NULL, code, w, l); 훅프로시저에서 SendMessage 로날린메시지에대한핸들러는 < 리스트 2> 에나와있다. 훅종류를조사해서정보를출력하는단순한역할을한다. 자신이설치한훅을출력하는 5/14 페이지
것을방지하기위해서실행하고있는스레드 ID 와설치한스레드 ID 가다른경우에만 출력하도록했다. 또한가지주의해야할점은 ID 를출력할때에 NT 계열의경우단순히 정수로표시하지만, 9x 계열의운영체제는 16 진수로출력해주어야한다는것이다. 리스트 2 WM_HOOKDETECTED 메시지핸들러 LRESULT ChkdetectorDlg::OnHookDetected(WPARAM w, LPARAM l) // 훅종류에대한이름 HOOKNAME hooknames[] = WH_MSGFILTER, "WH_MSGFILTER", WH_JOURNALPLAYBACK, "WH_JOURNALPLAYBACK", WH_JOURNALRECORD, "WH_JOURNALRECORD", WH_KEYBOARD, "WH_KEYBOARD", WH_MOUSE, "WH_MOUSE", WH_KEYBOARD_LL, "WH_KEYBOARD_LL", WH_MOUSE_LL, "WH_MOUSE_LL", WH_DEBUG, "WH_DEBUG", WH_SHELL, "WH_SHELL", WH_FOREGROUNDIDLE, "WH_FOREGROUNDIDLE", WH_GETMESSAGE, "WH_GETMESSAGE", WH_CALLWNDPROC, "WH_CALLWNDPROC", WH_CALLWNDPROCRET, "WH_CALLWNDPROCRET", WH_HARDWARE, "WH_HARDWARE", WH_CBT, "WH_CBT", WH_SYSMSGFILTER, "WH_SYSMSGFILTER" ; CString result; TCHAR processname[max_path] = 0,; DWORD pid = 0; // 훅종류검색 for(int i=0; i<sizeof(hooknames)/sizeof(hookname); ++i) if(w == hooknames[i].id) DEBUGHOOKINFO *info = (DEBUGHOOKINFO *) l; // 자신이설치한훅인지판별 if(info->idthreadinstaller!= info->idthread) // 훅을설치한프로세스이름구하기 pid = GetProcessIdFromThreadId(info->idThreadInstaller); GetProcessNameFromPID(processName, sizeof(processname), pid); // 운영체제가 9x 계열인경우 if(getversion() & 0x80000000) result.format( "%20s %X %X %X %s\r\n", hooknames[i].name, info->idthread, 6/14 페이지
info->idthreadinstaller, pid, processname ); else result.format( "%20s %d %d %d %s\r\n", hooknames[i].name, info->idthread, info->idthreadinstaller, pid, processname ); // 에디터의마지막에탐지된훅프로시저기록 int len = m_edtresult.getwindowtextlength(); m_edtresult.setsel(len, len); m_edtresult.replacesel(result); return 0; 설치한프로세스 ID 구하기 DEBUGHOOKINFO 구조체로부터우리가알수있는것은훅이설치한스레드와실행되는스레드 ID 가전부다. 사실이러한정보는사용자에게어떠한도움을주지도못하는정보다. 최종사용자에게는프로세스와관련된정보를제공하는것이일반적이다. 그래야사용자가해당프로세스를종료하거나파일을제거하는등의작업을할수있기때문이다. < 표 2> 에는자주사용하는프로세스와스레드관련함수들이열거되어있다. 살펴보면알수있겠지만각각의 ID 와핸들은손쉽게변환할수있지만프로세스 ID 로부터스레드 ID 를구하거나, 스레드 ID 에대한부모프로세스 ID 를구하는작업을하는것은힘들다. 후자의작업에사용할수있는 API 로 GetProcessIdOfThreadId 라는함수가있지만, 이함수의경우 Windows Server 2003 이상이되어야만사용할수있기때문에아직사용하기에는이른함수라고할수있다. 표 2 프로세스 / 스레드함수들 함수명 기능 OpenThread 스레드 ID 에대한핸들을반환한다. GetThreadId 스레드핸들에대한 ID 를반환한다. TerminateThread 스레드를강제로종료시킨다. 7/14 페이지
GetCurrentThread 현재스레드핸들을반환한다. GetCurrentThreadId 현재스레드 ID 를반환한다. OpenProcess 프로세스 ID 에대한핸들을반환한다. GetProcessId 프로세스핸들에대한 ID 를반환한다. TerminateProcess 프로세스를강제로종료시킨다. GetCurrentProcess 현재프로세스핸들을반환한다. GetCurrentProcessId 현재프로세스 ID 를반환한다. GetProcessIdOfThread 스레드핸들에대한부모프로세스 ID 를반환한다. API 가없다면직접만드는수밖에는없다. Toolhelp 라이브러리를사용하면시스템에서실행중인스레드 / 프로세스 / 모듈을손쉽게열거할수있다. Toolhelp 라이브러리에서스레드정보를알려주는구조체인 THREADENTRY32 의 th32ownerprocessid 를참고하면부모프로세스의 ID 를구할수있다. < 리스트 3> 에 toolhelp 를사용해서스레드 ID 에해당하는프로세스 ID 를구하는 Emulate_GetProcessIdOfThreadId 함수가나와있다. Windows 2003 이상의시스템에서는굳이우리가직접작성한 Emulate_GetProcessIdOfThreadId 를수행하는것보다실제 GetProcessIdOfThread 를사용하는것이좋다. 시스템 API 의경우이미검증된것이고성능이우리가작성한것보다좋을수있기때문이다. 아마도 Windows 2003 이상에서구현된 GetProcessIdOfThread 함수는단순한포인터참조로구현되었을가능성이높다. 따라서시스템에 API 가없는경우에만 Emulate_GetProcessIdOfThreadId 를사용하는것이바람직하다. < 리스트 3> 을살펴보면그런경우를처리하는일반적인방법을알수있다. GetProcessIdOfThread 는스레드핸들을인자로받는다. 스레드 ID 를바로프로세스 ID 로 변환할수없기때문에소스가다소복잡하다 (< 표 3> 참고 ). 표 3 사용된심벌이름및역할이름역할 Real_GetProcessIdOfThread 2003 이상에존재하는 GetProcessIdOfThread 함수포인터를저장할변수 GetProcessIdOfThreadId 프로그램에서사용할함수를저장할함수포인터. Emulate_GetProcessIdOfThreadId 와 Real_GetProcessIdOfThread 중하나를가짐. Emulate_GetProcessIdOfThreadId toolhelp 라이브러리를사용해서스레드 ID 를프로세스 ID 로변환하는함수 8/14 페이지
Real_GetProcessIdOfThreadId Real_GetProcessIdOfThread 함수를사용해서스레드 ID 를프로세스 ID 로변환하는함수 Probe_GetProcssIdOfThreadId Emulate_GetProcessIdOfThreadId 와 Real_GetProcessIdOfThreadId 중에어떤함수를사용할지 결정하는함수 코드의동작원리는간단하다. GetProcessIdOfThread 를실행될때찾아보고있는경우엔그함수를사용하는버전의함수를사용하고, 없는경우에는에뮬레이팅하는버전의함수를사용하는것이다. 또한 Probe_GetProcessIdOfThreadId 는로드될때한번만호출되기때문에호출빈도에따른부하도없다. 리스트 3 특정스레드 ID 의부모프로세스 ID 를구하는함수 #include <tlhelp32.h> #ifdef cplusplus extern "C" #endif #ifdef WANT_GETPROCESSIDOFTHREAD_WRAPPER // GetProcessIdOfThreadId 함수원형 typedef DWORD (WINAPI *FGetProcessIdOfThreadId)(DWORD); // GetProcessIdOfThread 함수원형 typedef DWORD (WINAPI *FGetProcessIdOfThread)(HANDLE); #ifdef COMPILE_API_STUB // 2003 이상에존재하는 GetProcessIdOfThread 함수포인터를저장할변수 FGetProcessIdOfThread Real_GetProcessIdOfThread = NULL; // toolhelp 를사용해구현한함수 // 스레드 ID 를프로세스 ID 로변환함 DWORD CALLBACK Emulate_GetProcessIdOfThreadId(DWORD tid) DWORD ret = 0; // 스레드를열거할 toolhelp 스냅샷생성 HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if(snap == INVALID_HANDLE_VALUE) return ret; THREADENTRY32 te; ZeroMemory(&te, sizeof(te)); te.dwsize = sizeof(te); 9/14 페이지
// 첫번째스레드정보구함 if(!thread32first(snap, &te)) goto $cleanup; // 스냅샷의끝까지반복하면서스레드 ID 검색 do if(te.th32threadid == tid) // 찾고자하는스레드 ID 를발견한경우 PID 저장 ret = te.th32ownerprocessid; break; while(thread32next(snap, &te)); $cleanup: // 생성한스냅샷을해제하고리턴 CloseHandle(snap); return ret; // GetProcessIdOfThread API 를사용해구현한함수 // 스레드 ID 를프로세스 ID 로변환함 DWORD CALLBACK Real_GetProcessIdOfThreadId(DWORD tid) HANDLE thread = OpenThread(THREAD_QUERY_INFORMATION, FALSE, tid); if(thread == NULL) return 0; DWORD pid = Real_GetProcessIdOfThread(thread); CloseHandle(thread); return pid; // 어떤버전의함수를사용할지결정하는함수 // 사용할수있는적절한버전의함수포인터를리턴한다. FGetProcessIdOfThreadId Probe_GetProcessIdOfThread() HINSTANCE inst; inst = GetModuleHandle(TEXT("KERNEL32")); if(!inst) return Emulate_GetProcessIdOfThreadId; // GetProcessIdOfThread 함수포인터저장 Real_GetProcessIdOfThread = (FGetProcessIdOfThread) GetProcAddress(inst, "GetProcessIdOfThread"); if(!real_getprocessidofthread) 10/14 페이지
return Emulate_GetProcessIdOfThreadId; return Real_GetProcessIdOfThreadId; // 사용할함수포인터를저장할변수 FGetProcessIdOfThreadId GetProcessIdOfThreadId = Probe_GetProcessIdOfThread(); #endif #endif #ifdef cplusplus #endif 프로세스이름구하기 프로세스 ID 를구했다고모든작업이마무리된것은아니다. 9x 계열의운영체제에서는프로세스 ID 에대한이미지이름을보여주는기본유틸리티가없기때문에사용자편의를위해서프로세스이름을보여주는것이바람직하다. toolhelp 라이브러리를사용하면프로세스 ID 에대한이름또한쉽게구할수있다. < 리스트 4> 에그코드가나와있다. GetProcessNameFromPID 함수는인자로넘어온 pid 에해당하는프로세스이름을 buf 에 저장한다. size 에는 buf 의크기를넘겨주면된다. pid 를찾은경우엔 TRUE 를찾지못한 경우엔 FALSE 를반환한다. 리스트 4 특정프로세스 ID 에대한이미지이름구하기 BOOL GetProcessNameFromPID(LPTSTR buf, UINT size, DWORD pid) DWORD ret = FALSE; // 프로세스를열거하기위한 toolhelp 스냅샷생성 HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if(snap == INVALID_HANDLE_VALUE) return ret; PROCESSENTRY32 pe; ZeroMemory(&pe, sizeof(pe)); pe.dwsize = sizeof(pe); // 첫번째프로세스정보구하기 if(!process32first(snap, &pe)) goto $cleanup; // 스냅샷의마지막까지반복 do 11/14 페이지
// 프로세스 ID 를발견한경우프로세스이름복사 if(pe.th32processid == pid) StringCbCopy(buf, size, pe.szexefile); ret = TRUE; break; while(process32next(snap, &pe)); // 스냅샷핸들해제및결과리턴 $cleanup: CloseHandle(snap); return ret; 프로세스에로드된모듈열거하기 WH_DEBUG 훅의가장큰단점은 NT 계열에서 idthreadinstaller 값이제대로구해지지않는다는점이다. 사실 NT 계열에서는무용지물이다. 그렇다면 NT 계열에서다른프로그램이설치한훅의존재유무를판단할수없을까? 물론할수있다. 다른프로세스를후킹하기위해서는 DLL 에훅프로시저를두어야했다. 이것은결국 DLL 이대상프로세스공간으로로드된다는말이다. 따라서우리는굳이훅프로시저를찾을필요없이불필요한모듈이있는지를찾으면된다. toolhelp 라이브러리의모듈열거기능을사용하면손쉽게구현할수있다. < 리스트 5> 에 toolhelp 라이브러리를사용해서자신의프로세스에로드된모듈의이름과 베이스주소를출력하는프로그램이나와있다. < 화면 3> 은이프로그램을실행시킨 화면이다. EXE 를제외하고두개의모듈이로드되었음을알수있다. 열거한모듈이름중에서자신이사용하지않은모듈이름이발견된다면후킹당하고있는것이다. 물론이방법의경우 WH_DEBUG 훅처럼정확한후킹정보를알순없다. 하지만 WH_DEBUG 훅의경우는윈도우메시지훅만탐지할수있지만, 이방법은 DLL 인젝트에기반을둔대부분의공격에방어할수있다는장점이있다. 리스트 5 자신의프로세스에로드된모듈을열거하는코드 #include <windows.h> #include <tlhelp32.h> int _tmain(int argc, _TCHAR* argv[]) 12/14 페이지
DWORD ret = FALSE; // 모듈을열거할스냅샷생성, 두번째인자로프로세스 ID 를넣어준다. HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId()); if(snap == INVALID_HANDLE_VALUE) return 0; MODULEENTRY32 me; ZeroMemory(&me, sizeof(me)); me.dwsize = sizeof(me); // 첫번째모듈정보를구함 if(!module32first(snap, &me)) goto $cleanup; // 스냅샷의끝까지반복하면서모듈이름과베이스주소를출력한다. do printf("%20s - %08X\n", me.szmodule, me.modbaseaddr); while(module32next(snap, &me)); $cleanup: CloseHandle(snap); return 0; 화면 3 로드된모듈목록을출력하는프로그램 지난시간에배웠듯이메시지훅 DLL 이침투하는시점을알수없다. 그렇다면언제이 검사를수행해야할까? 가장무식한한가지방법밖에는없다. 루프를도는것이다. 별도의 스레드로루프를돌건아니면타이머로돌건주기적으로검사해주는방법뿐이다. NT 시스템에서는유저레벨에서프로세스의생성이나모듈이로드되는시점을판단할수있는함수를제공하지않는다. 커널레벨에서는 PsSetImageLoadNotifyRoutine 이나 PsSetCreateProcessNotifyRoutine 을사용하면시스템이프로세스나모듈이로드되는시점에등록한콜백함수를호출해준다. 13/14 페이지
박스 1 인터럽트와폴링 I/O 를처리하는방식에는크게두가지가있다. 폴링과인터럽트가그것이다. 앞서예에서우리는모듈이로드되는시점을알수없기때문에주기적으로그것을검사해야한다고했다. 이것이폴링이다. 이벤트발생시점을알수없기때문에지속적으로이벤트가발생했는지를체크해야하는것이다. 반면에인터럽트는이벤트가발생하면이벤트가발생한곳에서통보를해주는형태다. 앞의예가인터럽트방식이되려면시스템에서모듈이로드되는시점을우리에게콜백함수나메시지등을통해서알려주어야한다. 커널에서제공하는함수들이인터럽트방식으로동작한다고할수있다. 도전과제 훅디텍터를좀더강화시켜보자. 단순히훅프로시저가호출된다는것을알려주는것에서좀더나아가서사용자에게경고창을띄워서훅프로시저를수행할지건너뛸지물어보도록만들어보자. 또한그것을포장해서다른사람이쉽게쓸수있도록훅탐지라이브러리로제작해보는것도재미있을것같다. 이걸로이제껏진행했던훅강좌는실질적으로끝이다. 다음시간에는우리의손과발을꽁꽁묶어두었던디버그뷰유틸리티를대체할수있는프로그램을작성하는방법을배울것이다. 참고자료 참고자료 1. Jeffrey Richter. <<Programming Applications for Microsoft Windows (4/E)>> Microsoft Press 참고자료 2. 김상형, <<Windows API 정복 >> 가남사참고자료 3. 김성우, << 해킹 / 파괴의광학 >> 와이미디어참고자료 4. 프로세스생성탐지방법 http://www.codeproject.com/threads/procmon.asp 14/14 페이지