리버스 엔지니어링 바이블
1. API 후킹 기본
단순히 API의 엔트리 포인트를 jmp 문으로 바꾸면 후킹 탐지에 쉽게 무력화 가능

ws2_32.dll의 send 함수
해당 함수는 위와 같이 mov edi, edi로 시작하며 일반적으로 API 후킹을 하게 되면 아래와 같이 해당 엔트리 포인트를 jmp 문으로 바꾸게됨

API 후킹 이후엔 위와 같이 엔트리 포인트 5바이트만 수정됨
이러한 방법으로는 다른 애플리케이션과 충돌이 가능..

그래서 이렇게 코드 중간에 점프 코드를 삽입하는 방법을 사용하면 코드 후킹 탐지도 피할 수 있고 다른 애플리케이션과 충돌도 피할 수 있음
*사진은 예시를 위해 임의의 명령어와 주소를 삽입함
2. DLL 인젝션
코드 후킹을 위해선 DLL 인젝션이 선행되어야 함.
대부분 흔히 널려있는 DLL 인젝션 툴들은 백신에 악성코드로 진단되기 때문에 직접 만들어서 구현해보는 것도 좋음
2.1. CreateRemoteThread
DLL 인젝션의 핵심 원리는 CreateRemoteThread API임
해당 API는 타 프로세스의 메모리에 스레드를 생성할 수 있는 기능을 지원함
일반적으로 DLL 로딩은 다음과 같은 코드를 사용함

위 코드는 C:\Windows\system32\ws2_32.dll을 메모리에 로딩하는 코드임
LoadLibrary() 를 호출해 DLL 파일의 경로를 전달하면, 그 프로세스 메모리에 위 DLL이 로딩됨
CreateRemoteThread() 호출하고 해당 프로세스에서 LoadLibrary() 실행, 인자로 들어가는 DLL 파일의 전체 경로만 지정하면 DLL 인젝션 가능
구현은 다음과 같은 과정으로 가능함
| void InjectionDll(DWORD pid, LPCSTR dll) | cs |
위와 같이 타겟이 될 프로세스의 pid와 인젝션 할 dll을 두 번째 인자로 받는 구조로 선언
| HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); | cs |
타겟 프로세스의 핸들이 필요하므로 OpenProcess()로 pid를 지정한 후 핸들 hProcess를 얻음
| LPVOID lpAddr = VirtualAllocEx(hProcess, NULL, strlen(dll)+1, MEM_COMMIT, PAGE_READWRITE); | cs |
타겟 프로세스에 LoadLibrary()로 넘겨줄 dll의 주소를 써 넣기 위해 메모리를 할당해줌
* VirtualAlloc은 자신의 프로세스에 할당, VirtualAllocEx는 타 프로세스에 메모리 할당
크기는 dll 경로 문자열이 들어갈 공간이면 충분하므로 Null 문자를 위한 strlen(dll)+1 로 설정
위 함수의 반환 값인 lpAddr에는 DLL의 전체 경로가 담긴 메모리 번지가 되고 이를 LoadLibrary()와 CreateRemoteThread() 에 전달함
| WriteProcessMemory(hProccess, lpAddr, dll, strlen(dll)+1, NULL); | cs |
할당해준 메모리에 dll의 전체 경로를 써줌
| LPTHREAD_START_ROUTINE pfnLoadLibraryA = (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA"); | ₩cs |
CreateRemoteThread() 에 사용할 LoadLibrary()의 주소를 GetProcAddress()를 이용하여 구해옴
| HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, pfnLoadLibraryA, lpAddr, 0, NULL); | cs |
CreateRemoteThread() 를 호출하면 타겟 프로세스에서는 LoadLibrary() 가 호출되며 인젝션할 DLL를 로드한다
3. 코드 후킹
3.1 후킹 기본
실제 Lws_32.dll() 파일의 send() API는 다음과 같이 후킹할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | void InitHooking1() { typedef void (WINAPI *Tsend)(SOCKET, const char *, int, int); Tsend fnsend; TCHAR szWs2_32Dll[MAX_PATH]; GetSystemDirectory(szWs2_32Dll, MAX_PATH); _tcscat(szWs2_32Dll, _T("\\ws2_32.dll")); HMODULE hMod = LoadLibrary(szWs2_32Dll); fnsend = (Tsend)GetProcAddress(hMod, "send"); // 엔트리부터 0x16 바이트 떨어진 지점을 대상 주소로 설정 LPVOID lpTargetAdr = (LPVOID)((DWORD)fnsend + 0x16); TRACEB(_T("lpTargetAdr: %x"), lpTargetAdr); DWORD dwOldProtect = 0; VirtualProtect(lpTargetAdr, 6, PAGE_READWRITE, &dwOldProtect); // 기초 설명을 위한 하드코딩 //719E4C3D E9 BEC391C1 jmp 33301000 //719E4C42 90 nop *((LPBYTE)lpTargetAdr + 0) = 0xE9; *((LPBYTE)lpTargetAdr + 1) = 0xBE; *((LPBYTE)lpTargetAdr + 2) = 0xC3; *((LPBYTE)lpTargetAdr + 3) = 0x91; *((LPBYTE)lpTargetAdr + 4) = 0xC1; *((LPBYTE)lpTargetAdr + 5) = 0x90; } | cs |
이를 통해 send() 함수가 호출될 경우 0x33301000에 있는 코드를 실행하게 할 수 있음
0x33301000에 필요한 DLL를 로드하면 원하는 DLL를 실행시킬 수 있음
| #pragma comment(linker, "/base:0x33300000 /fixed") | cs |
위와 같은 코드를 소스코드의 위쪽에 배치하면 0x33300000번지가 ImageBase가 됨
그러나 위와 같이 주소를 하드코딩하는 방식은 DLL의 주소가 그 주소에 로딩되지 못하면 무의미함
주소가 변경됐을 때도 주소를 찾아 연결시킬 수 있는 방법이 필요함
위의 코드에서 20~26번째 라인을 다음과 같이 변경하여 동적 주소를 구할 수 있음
| *((LPBYTE)lpTargetAdr + 0) = 0xE9; DWORD dwBufferAdr = (DWORD)HookSend - (DWORD)fnsend - 0x16 - 5; *((LPDWORD)((LPBYTE)lpTargetAdr + 1)) = dwBufferAdr; | cs |
먼저 타겟 jmp 명령어를 의미하는 0xE9는 타겟 주소에 넣어줌
주소부분 계산은 삽입할 주소의 위치와 jmp 대상이 되는 주소의 차이와 5바이트의 op code를 고려하여 계산해주면 됨
719E4C3D E9 BEC391C1 jmp 33301000
앞서 이 명령어의 operand도 이렇게 계산됨
0x33301000 - 0x719E4C3D - 5 = 0xFFFFFFFFC191C3BE
이를 4바이트 주소인 DWORD로 타입 캐스팅 하면 앞의 0xFFFFFFFF는 제거되고 0xC191C3BE만 남고
이를 리틀엔디안 방식으로 하면 명령어의 operand 부분인 BEC391C1이 됨
마지막에는 ((LPBYTE)(lpTargetAdr + 1)) 의 위치를 LPDWORD 타입으로 4바이트 묶어서 계산된 주소를 넣어줌
3.2 Send 후킹
send 함수의 프로토 타입을 참고하여 버퍼를 출력하는 코드를 작성할 수 있음
| int send( __in SOCKET s, __in const char *buf, __in int len, __in int flags ); | cs
|
2번째 인자로 전송할 버퍼를 전달받고, 버퍼의 길이를 3번째 인자로 전달받음
이를 참고하여 버퍼를 OutputDebugString을 통해 출력하는 코드를 다음과 같이 어셈블리 코드로 작성
| __declspec(naked) HookSend() { __asm { mov eax, [ebp+0xC] push eax call ds:OutputDebugString mov eax, 0x719E4C43 jmp eax } } | cs |
[ebp+0xC]에는 함수로 전달되는 두 번째 인자가 존재하니까, 그걸 eax에 넣어주고 OutputDebugString 함수의 인자로 전달하기 위해 스택에 넣음
그리고 원래 코드로 돌아오기 위한 점프문 배치하고 이 함수를 0x33301000 번지에 올라가게 하면 됨
그러나 단순히 이렇게 해버리면, send 함수를 통해 나가는 모든 패킷을 모니터링하게됨...
send 함수를 호출한 부모 함수를 식별해서 어느 함수에서 호출한 send인지 출력 가능
또한 모니터링 하고자 하는 패킷만 모니터링 할 수 있음
부모 함수를 식별하기 위한 방법은 send 함수의 return 주소인 [ebp + 0x4]를 참조하면 식별이 가능함
먼저, 어셈블리 코드에서 사용할 ebx, eax, edx 레지스터가 망가지지 않게 스택에 넣어두고, 각각 레지스터에 메시지의 길이, 메시지 내용, return 주소를 넣음
| push ebx push eax push edx mov ebx, [ebp+0x10] // 패킷 길이 mov ebx, [ebp+0xC] // 패킷 버퍼 mov ebx, [ebp+0x4] // return 주소 | cs |
이렇게 원래의 레지스터에 있는 값을 저장하고 레지스터를 마음대로 쓴 이후 다시 pop을 통해 복원해줌
다음과 같은 코드로 send 함수를 호출한 부모 함수의 주소와, 버퍼를 문자열과 덤프 형식으로 출력이 가능함
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | void PseudoFunc(int retaddr, char *buf, int len) { if (len != 1 && len > 0) { //TRACEB("[%x] send : [%d]", retaddr, len); int len_temp = 0; int len_temp2 = 0; char szMsg1[MAX_PATH] = {0,}; char szMsg2[MAX_PATH] = {0,}; for (int i=0; i<len; ++i) { if (MAX_PATH < i) break;
// null 바이트는 왜 "_"로 바꾸지... if (buf[len_temp] == '\0') len_temp += sprintf(szMsg1 + len_temp, "_"); else len_temp += sprintf(szMsg1 + len_temp, "%c", buf[i]); // 200 바이트 까지 버퍼를 Hex dump if (len_temp2 < 200) len_temp2 += sprintf(szMsg2 + len_temp2, "%02X ", *(buf + i)); } TRACEB("[%x] send : [%d] %s", retaddr, len, szMsg1); TRACEB(" dump : %s", szMsg2); } } | cs |
이처럼 작성한 함수를 호출하는 어셈블리 코드를 작성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | __declspec(naked) HookSend() { __asm { push ebx push eax push edx mov ebx, [ebp+0x10] mov eax, [ebp+0xC] mov edx, [ebp+0x4] push ebx push eax push edx call lpPseudoFunc add esp, 0xc pop edx pop eax pop ebx mov eax, 0x719E4C43 jmp eax } } | cs |
15번째 줄의 add esp, 0xc는 함수 호출 과정에서 늘어난 스택 크기를 조정해주기 위함