안녕.
오늘은 너희들을 위해서 API 후킹이란 기술에 대해서 좀 써볼까 해. 물론 이 글은 API 후킹이 뭐지, 점프 코드를 어떻게 만들지, 트램펄린은 뭐지, 따위를 고민하는 초보 프로그래머를 위한 글은 아니야. 이 글은 적어도 실무에서 API 후킹 코드를 한번쯤은 짜 본 프로그래머를 위한 글이야. API 후킹 코드가 뭐 대단한게 있냐고? 없지. 없는데도 잘 모르는거 같아서 내가 몇 자 적어볼까 해. 니가 뭔데 이 따위 건방진 글을 쓰냐고? 그래. 맨날 버그만 만드는 내가 이런 글을 쓸 자격이 있는지 모르겠다. 그래도 훅 코드를 작성해 본 경험도 많고, 관련 검색어에 후킹도 나오고 하는걸로 봐서 한번 정도는(!?) 주제넘게 이런 이야기를 해도 되지 않을까란 생각이 들어서 감히 몇 자 끄적여 보려고 해.
시작하기에 앞서 먼저 밝히는데 이 이야기는 절.대.로. 너희에게 뭐라고 하는 건 아니니까 오해하지 않았으면 좋겠어. 너희가 무슨 죄가 있겠니. 후킹 라이브러리 하나 안 사주는 사장님이 잘못된 거지. 또 돈 안 된다고 위험한 모듈에 학교 갓 졸업한 인턴 투입하는 사장님이 잘못된 거지. 그런 악덕 사장님 덕에 나중에도 이런 위험천만한 기능을 별 생각 없이 만들 수많은 미래 보안 프로그래머를 위해서 쓰는거니까 절대 오해하지 않았으면 좋겠다. 나도 맨날 버그만 싸질러서 지원팀한테 엄청 미움받고 있어 ㅋㅋㅋ~ 돈 벌면 진짜 QA 해줄 테스트 프로그래머 한 명 뽑아야지 ㅠㅜ~ 눈물 좀 닦고, …
이 이야기의 시작은 크래시 덤프에서 출발해. 오늘 크래시 덤프를 하나 받았어. 너희들도 보안 업체에서 일하니까 크래시 덤프가 얼마나 토나오는 말인지는 알지? 너네 문제가 아닌 크래시 덤프 파일을 얼마나 많이 접했니? 아마 수도 없이 많을거야. 매일 다른 업체 버그를 수정하는 코드를 만드느라 고생하는 것도 잘 알고 있어. 내가 만든 버그 수정하랴, 다른 사람 버그 수정하랴, 충돌 피하랴. 고생이 이만저만이 아니지 정말. 어쨌든 그 크래시 덤프는 원인이 이랬어.
0:000> u ntdll!ZwOpenProcess
ntdll!ZwOpenProcess:
7709fc00 e9bb223000 jmp 773a1ec0
7709fc05 33c9 xor ecx,ecx
7709fc07 8d542404 lea edx,[esp+4]
7709fc0b 64ff15c0000000 call dword ptr fs:[0C0h]
0:000> u 773a1ec0
773a1ec0 ?? ???
^ Memory access error in ‘u 773a1ec0′
너네도 알겠지만 부연 설명하면 점프 코드를 잘못 싸지른 게 원인인거지. 왜 아무도 안 건드리는 ZwOpenProcess가 저리도 어설프고 불완전하게 후킹됐을까 엄청 고민했어. 처음엔 악성 코드에 감염된 건 줄 알았어. 흔하게 중2병 학생이 코딩한 악성 코드에는 저런 버그는 수도 없이 많잖아. 그런데 안타깝게도 그 PC는 클린한 우리 테스트 PC 였거든. 물론 테스트 PC도 악성 코드에 감염될 수는 있어. 하지만 정말 아쉽게도 그 프로세스엔 다른 훅 코드가 몇 개 더 있었고, 그 모듈 정보를 통해서 난 저 훅 코드가 악성 코드가 아닌 너네가 만든 훅 코드라는 걸 알 수 있었어. 실제로 문제는 e9bb2233다음에 89가 기록돼야 하는데 89가 기록돼지 못한 것이 원인이었거든. 그 번지에 89가 기록됐으면 안전하게 너네 훅 코드로 점프가 이루어졌을거야.
왜 이런 상황이 벌어졌는지 보기 위해서 본의 아니게 너네 모듈을 좀 살펴봤어. 요약하면 그 모듈은 WH_CBT 훅을 설치해서 훅 루틴에서 몇 개의 API를 후킹하는 모듈이었어. 그런데 내가 보기에 너네가 사용하는 API 훅 설치 코드에 조금 문제가 있다는 생각이 들었어. 내가 왜 그렇게 생각했는지 코드를 보면서 설명하는 시간을 조금 가져보도록 할께.
STEP 1: 훅 대상 코드 추적 부분, 0xe9 루프를 추적하는 루프
너네가 작성한 훅 설치 루틴은 크게 세 부분으로 돼 있는데 여기가 그 첫번째 부분인 훅 대상 코드 추적 부분이야. 후킹하려고 하는 NtOpenProcess에 이미 다른 훅이 설치돼 있으면 해당 주소로 점프한 곳을 계산해서 다시 그곳에 점프 코드가 있는지 점검하는 코드로 구성돼 있어. 그런데 여기서 재미난 사실은 아무 생각없이 0xe9를 찾고 있다는 점이야. 무한 루프를 추적하게되면 너네도 같이 무한 루프에 빠진다는 함정이 있어. 쉽게는 “jmp $(e9 fbffffff)” 같은 코드를 들 수 있겠지. 조금 더 영리했다면 루프를 탐지할 수 있도록 이미 스캔한 주소를 목록에 보관하고 비교를 했어야 하는게 아닌가 싶어.
STEP 2: VirtualProtect를 사용해서 대상 코드의 프로텍션 속성 변경 부분
다음으로 VirtualProtect를 이용해서 점프 코드를 삽입할 위치에 프로텍션 모드를 변경하는 코드야. 여기에는 큰 문제는 없어 보이는데 뒤쪽 부분과 결부하면 한가지 아쉬운 점은 VirtualProtect로 변경한 속성을 원복하지 않고 있다는 점이야. 왜 이전 프로텍션 속성으로 다시 변경을 하지 않는지 모르겠어. 다양한 환경을 접하면 알게 되겠지만 CRT에 포함된 일부 코드 중에서도 페이지 속성을 체크하는 코드도 있어. 그런 코드는 PAGE_EXECUTE_READWRITE 따위와 같이 코드에 WRITE 속성이 걸려 있으면 체크해서 오류를 토해내거든. 그러니까 변경한 속성은 꼭 다시 원복시켜 주도록 하자.
여기가 핵심이야. 특히 연두색으로 표시한 저 부분이 코드를 덮어쓰는 부분인데 내가 크래시 덤프에서 보았던 실패 원인이 여기에 있는 것 같기도 해. mov [eax], ecx라는 부분이 완전하게 이루어지지 못한 거지. 어떤 이유에서였든 말이야.
자 이제 너네가 작성한 훅 코드를 살펴밨으니 내가 아쉬운 몇 가지 부분을 감히(?!) 언급해 보는 시간을 좀 가져볼까 해. 아마 너네가 다음번 훅 설치 루틴을 작성할 때에는 크게 도움이 될거야. 이런 복잡한 부분을 고려하기 힘들다면 그냥 상용 훅 설치 라이브러리를 사서 쓰길 바래. 그건 그래도 이런 초보적인 방식 보다는 좀 더 다양한 처리가 돼 있거든.
어쨌든 한번 시작해볼께. 첫 번째는 너네는 함수 도입부가 무조건 5바이트라는 가정을 하고 있어. 왜지? 함수 코드는 너네처럼 변경하는 놈들이 아주 많을 수 있거든. 그리고 너네만 후킹하라고 만들어 놓은 부분도 아니잖니? 그런데 왜 5바이트에 꼭 맞춰져 있을거라는 가정을 하는 거지. “mov edi, edi” 같은 코드를 넣어서 윈도우가 API 도입부를 5바이트로 맞춘 것도 그리 오래되지 않았어. 핫패칭 재미를 쏠쏠하게 보기 시작하면서 넣기 시작한 거거든. 가까운 윈도우 2000만해도 그렇지 않은 함수들이 많이 있어. 그러니까 다음부터 훅 설치 루틴을 작성할 때에는 코드 길이를 계산하는 부분을 반드시 넣어 주면 좋겠어. 그러면 가변적인 코드도 안전하게 후킹할 수 있게 될거야.
두 번째는 너네 훅 설치 코드에는 재배치 코드가 빠져있어. 그냥 단순히 원본을 카피하는데 그 원본에 상대주소 호출(e8), 쇼트 점프(eb) 같은 코드가 있으면 복사한 지점에서는 제대로 동작을 하지 않겠지. 왜냐하면 호출하는 지점 주소가 바꼈으니까 말이야. 그러니까 코드를 복사하고 나면 반드시 원본 코드에 상대 주소를 사용하는 부분이 있는지를 체크해서 주소를 새로 계산해 줘야해. eb 같은 경우에는 점프 주소가 변경되면 오프셋으로 사용할 공간이 모자랄수도 있거든. 그럴 경우에는 e9같은 걸로 확장시키는 방식을 써줘야 돼. 사실 너네처럼 재배치를 안 하는 코드 때문에 일부 생각있는 사람들은 단순 복사에도 안전한 6바이트 후킹(ff 25)을 더 선호하기도 해. 그런데 너네는 그마저도 5바이트로 가정을 해버려서 교양있는 사람들의 행동도 처참히 실패하게 만들어 버리곤 하지 ㅠㅜ~
이제부터가 제일 중요한 거야. 프로세스 안에는 있잖아. 너네 말고도 엄청 많은 스레드가 동작하고 있어. 그 스레드들도 항상 너네와 같은 일들을 하려고 할 수도 있고, 심지어는 너네가 변경하려는 곳을 참조하려고 하기도 하거든. 간단한 예, 몇 가지만 생각해 보자. 1) mov [eax], ecx를 하는 도중에 다른 스레드가 너네 코드가 쓰려는 페이지를 VirtualProtect로 쓰지 못하도록 만들어 버리는 상황, 2) mov byte ptr [esi], 0xe9를 한 순간 다른 스레드가 NtOpenProcess를 호출해서 그 코드를 실행시켜 버리는 상황, 3) 너네가 덮어 쓰려는 데 다른 프로그램도 그 주소에 값을 덮어 쓰려는 상황, 등등. 간단한 것만 생각해도 이런데 복잡하게 들어가면 더 다양한 케이스가 있겠지?
적어도 난 너네가 이런 문제에 대해서 일말의 고민이라도 했다면 InterlockedExchange 정도의 함수는 썼어야 했다고 생각해. 물론 InterlockedExchange 함수를 쓰더라도 5바이트를 덮어써야 하기 때문에 한번에 할 수는 없지. 그래도 적어도 문제가 발생할 수 있는 확률은 낮춰줄 수 있지 않겠니? 그런데 이런 고민 조차도 없다는 게 정말 안타까워. 나도 회사 다니면서 다양한 프로그래머들을 만났지만 이런 이야기들을 하면 항상 그래. 그럴 일이 없다고. 그래서 꼭 코드로 보여줘야 믿지. 아래는 내가 작성해 본 간단한 코드야. VPThread가 동작할 때, 하지 않을 때, InterlockedExchange를 썼을 때, 쓰지 않을 때, 출력되는 문장의 개수를 살펴봤으면 좋겠다.
#include "windows.h"
#include "process.h"
#include "stdio.h"
#include "tchar.h"
UINT
CALLBACK
VPThread(PVOID)
{
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
FARPROC ntqit = GetProcAddress(ntdll, "NtQueryInformationThread");
for(;;)
{
ULONG oldp;
VirtualProtect(ntqit, 4, PAGE_EXECUTE_READ, &oldp);
}
return 0;
}
UINT
CALLBACK
HookThread(PVOID)
{
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
FARPROC ntop = GetProcAddress(ntdll, "NtOpenProcess");
PUCHAR opcode = (PUCHAR) ntop;
PULONG offset = (PULONG) &opcode[1];
for(;;)
{
__try
{
ULONG oldp;
VirtualProtect(ntop, 4, PAGE_EXECUTE_READWRITE, &oldp);
offset[0] = 0x11223344;
offset[0] = 0x88776655;
// InterlockedExchange(offset, 0x11223344);
// InterlockedExchange(offset, 0x88776655);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
}
}
return 0;
}
UINT
CALLBACK
WatchThread(PVOID)
{
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
FARPROC ntop = GetProcAddress(ntdll, "NtOpenProcess");
PUCHAR opcode = (PUCHAR) ntop;
volatile PULONG offset = (volatile PULONG) &opcode[1];
for(;;)
{
__try
{
ULONG k = InterlockedCompareExchange(offset, offset[0], offset[0]);
if(k != 0x88776655 && k != 0x11223344)
printf("%p\n", k);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
}
}
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
HANDLE t1 = (HANDLE) _beginthreadex(NULL, 0, VPThread, NULL, 0, NULL);
HANDLE t2 = (HANDLE) _beginthreadex(NULL, 0, HookThread, NULL, 0, NULL);
HANDLE t3 = (HANDLE) _beginthreadex(NULL, 0, WatchThread, NULL, 0, NULL);
SetThreadPriority(t1, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadAffinityMask(t1, 1);
SetThreadPriority(t2, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadAffinityMask(t2, 2);
SetThreadPriority(t2, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadAffinityMask(t3, 4);
getchar();
return 0;
}
이쯤되면 이제 내가 이야기 하려는 말을 조금은 이해할 것 같다는 생각이 들어. 다시 한번 쉽게 설명하면 너네가 문지방을 수리하려고 하는데 그 문지방을 넘나드는 수많은 사람들이 있다는 거야. 문지방을 수리하다가 그 사람들과 부딪힐 수도 있겠지? 그럼 어떻게 해야 할까? 그렇지. 문지방을 수리하는 동안에는 사람들이 넘나들지 못하게 해야 하는거야.
어떻게 하냐면 있잖아. SuspendThread라는 게 있어. 이 놈이 문지방을 넘나드는 사람들을 제어할 수 있게 해주거든. 그러니까 너네가 문지방을 수리하기 전에는 저 함수를 사용해서 사람들의 통행을 제어하는 게 안전하다고 할 수 있어. 그렇다고 단순히 죄다 SuspendThread를 시켜버리면 또 무슨 문제가 있냐면 SuspendThread 시키는 시점에 딱 문지방에 걸려 있는 놈들이 있을 수 있거든. 그런 놈들은 다시 ResumeThread를 하고 조금 있다 안전한 곳으로 이동했을 때 다시 SuspendThread를 해줘야 해. 복잡하지? 엄청 복잡해. 그런데 여기서도 끝이 아니야. 이건 진짜 기본적인 것만 말한거고 더 복잡하고 고려해야 할 내용들도 많이 있어. 시간나면 FlushInstructionCache 같은 API도 같이 공부해 보면 도움이 될 것 같아. 코드 하나 바꾸는데 왜 이렇게 알아야 하는게 많냐고? 너네 프로세스가 아닌 다른 프로세스에 끼어들어서, 그 끼어든 것도 모자라 대상 프로세스의 운영체제 코드를 변경하려는 일을 하려면 이정도 복잡함은 감수해야 하는거야.
다양한 생각과 함께 깊이 있는 고민을 많이 하다 보면 어느날 문득 깨닫게 될꺼야. 아 인라인 후킹이라는 게 본질적으로 안전할 수가 없구나. 그냥 확률 싸움이구나, 하는 생각 말이야. 그나마 안전한 건 4바이트를 한 번에 변경하는 테이블 후킹 밖에는 없어. 인라인 후킹을 안전하게 하려면 운영체제 스케줄러를 제어하는 수 밖에는 없지. 하지만 그런 방법은 합법적으로 존재하지 않거든. 어쨌든 불완전한 방법이지만 지금은 광범위하게 사용되고 있는 기술인만큼 고민을 많이해서 최대한 안전하게 작성하는 요령을 터득하는 게 중요할 것 같다는 생각이 들어. 그리고 항상 생각해. 이게 과연 필요한가, 이게 과연 최선인가, 다른 방법은 없는가, 라는 것들을 말이야. 후킹을 하지 않는 것이 상책 중에 상책이라는 걸 꼭 명심했으면 좋겠다.
조금 어설프고 서툴러도 괜찮아. 처음이잖아. 다음 번엔 고민을 좀 더 해서 꼼꼼하게 만들었으면 좋겠어. 아니면 상용 라이브러리를 쓰던지. 아니면 사장님한테 건의해. 그 많은 프로그래머 중에 똑똑한 애 한 명 시켜서 라이브러리 하나 만들자고 말이야.
잘 지내고. 고생해!
2013.06.17