Notepad WriteFile() Hooking

컴컴한해커·2025년 1월 15일

리버스 엔지니어링

목록 보기
15/18

📌 Debug Event

디버그 이벤트의 종류

- **EXCEPTION_DEBUG_EVENT**
- CREATE_THREAD_DEBUG_EVENT
- CREATE_PROCESS_DEBUG_EVENT
- EXIT_THREAD_DEBUG_EVENT
- EXIT_PROCESS_DEBUG_EVENT
- LOAD_DLL_DEBUG_EVENT
- UNLOAD_DLL_DEBUG_EVENT
- OUTPUT_DEBUT_STRING_EVENT
- RIP_EVENT

이 중 디버깅에 관련된 이벤트는 EXCEPTION_DEBUG_EVENT이다. 이와 관련된 이벤트의 종류는 아래와 같다.

- EXCEPTION_ACCESS_VIOLATION
- EXCEPTION_ARRAY_BOUNDS_EXCEEDED
- **EXCEPTION_BREAKPOINT**
- EXCEPTION_DATATYPE_MISALIGNMENT
- EXCEPTION_FLT_DENORMAL_OPERAND
- EXCEPTION_FLT_DIVIDE_BY_ZERO
- EXCEPTION_FLT_INEXACT_RESULT
- EXCEPTION_FLT_INVALID_OPERATION
- EXCEPTION_FLT_OVERFLOW
- EXCEPTION_FLT_STACK_CHECK
- EXCEPTION_FLT_UNDERFLOW
- EXCEPTION_FLT_ILLEGAL_INSTRUCTIOIN
- EXCEPTION_IN_PAGE_ERROR
- EXCEPTION_INT_DIVIDE_BY_ZERO
- EXCEPTION_INT_OVERFLOW
- EXCEPTION_INVALID_DISPOSITION
- EXCEPTION_NONCONTINUABLE_EXCEPTION
- EXCEPTION_PRIV_INSTRUCTION
- EXCEPTION_SINGLE_STEP
- EXCEPTION_STACK_OVERFLOW

각종 예외에서 디버거가 반드시 처리해야 하는 예외는 EXCEPTION_BREAKPOINT이다. BP를 구현하는 방법은 설치하는 코드의 메모리 시작 주소의 1바이트를 CC로 변경하는 것이다.


📌 작업 순서

디버거-디버기 관계를 가진 상태에서 디버기의 API 시작 부분을 0xCC로 바꿔 제어를 디버거로 가져온 후 원하는 작업을 수행한 후 디버기를 다시 실행 상태로 바꾸는 것이다.

  1. 대상 프로세스에 'attach'하여 debuggee로 만들기
  2. API 시작 주소의 첫 바이트를 0xCC로 변경
  3. 해당 API가 호출 시 제어가 debugger에게 넘어옴
  4. 원하는 작업 수행
  5. Unhook (0xCC를 원래대로 복원)
  6. API 실행
  7. Hook (지속적 후킹을 위해 다시 0xCC로 바꿈)
  8. debugee에게 제어를 돌려줌

📌 API Hooking 실습

notepad에 글을 쓰기 전에, hookdbg.exe를 notepad의 PID를 이용하여 hooking한다.

그리고 글을 test.txt로 저장한다.

그러면 cmd에서 해당 글이 출력됨과 동시에 test.txt는 모두 대문자로 변한다.


📌 코드 분석

DebugLoop()

void DebugLoop()
{
    DEBUG_EVENT de;
    DWORD dwContinueStatus;

    // Debuggee 로부터 event 가 발생할 때까지 기다림
    while( WaitForDebugEvent(&de, INFINITE) )
    {
        dwContinueStatus = DBG_CONTINUE;

        // Debuggee 프로세스 생성 혹은 attach 이벤트
        if( CREATE_PROCESS_DEBUG_EVENT == de.dwDebugEventCode )
        {
            OnCreateProcessDebugEvent(&de);
        }
        // 예외 이벤트
        else if( EXCEPTION_DEBUG_EVENT == de.dwDebugEventCode )
        {
            if( OnExceptionDebugEvent(&de) )
                continue;
        }
        // Debuggee 프로세스 종료 이벤트
        else if( EXIT_PROCESS_DEBUG_EVENT == de.dwDebugEventCode )
        {
            // debuggee 종료 -> debugger 종료
            break;
        }

        // Debuggee 의 실행을 재개시킴
        ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus);
    }
}

디버기로부터 발생하는 이벤트를 받아서 처리한 후 디버기의 실행을 재개하는 역할이다.
아래는 코드 중 디버기로부터 디버그 이벤트가 발생할 때까지 기다리는 함수이다.

BOOL WINAPI WaitForDebugEvent{
	LPDEBUG_EVENT lpDebugEvent,
    DWORD dwMilliseconds
};

코드에서 WaitForDebufEvent(&de, INFINITE) 라고 되어있는데, de 변수에 해당 이벤트에 대한 정보를 설정한 후 즉시 리턴하는 함수이다. de의 구조체인 DEBUG_EVENT 구조체 정의는 아래와 같다.

typedef struct _DEBUG_EVENT {
  DWORD dwDebugEventCode;
  DWORD dwProcessId;
  DWORD dwThreadId;
  union {
    EXCEPTION_DEBUG_INFO      Exception;
    CREATE_THREAD_DEBUG_INFO  CreateThread;
    CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
    EXIT_THREAD_DEBUG_INFO    ExitThread;
    EXIT_PROCESS_DEBUG_INFO   ExitProcess;
    LOAD_DLL_DEBUG_INFO       LoadDll;
    UNLOAD_DLL_DEBUG_INFO     UnloadDll;
    OUTPUT_DEBUG_STRING_INFO  DebugString;
    RIP_INFO                  RipInfo;
  } u;
} DEBUG_EVENT, *LPDEBUG_EVENT;

디버그 이벤트 9가지 종류 중 하나가 dwDebugEventCode에 세팅되고, 해당 이벤트의 종류에 따라 u(유니온) 멤버가 세팅된다.
아래는 디버기의 실행을 재개하는 함수이다.

BOOL WINAPI ContinueDebugEvent(
	DWORD dwProcessId,
    DWORD dwThreadId,
    DWORD dwContinueStatus
};

ContinueDebugEvent() API는 debuggee의 실행을 재개하는 함수다. 해당 API의 마지막 파라미터인 dwContinueStatus는 DBG_CONTINUE(정상적으로 처리) 또는 DBG_EXCEPTION_NOT_HANDLED(처리하지 못함) 중에서 하나의 값을 갖는다.

이렇게 DebugLoop 함수는 이벤트를 기다렸다가 받아서 처리하고 재개하는 함수이다. 처리하는 이벤트는 코드에서 보면 3가지이다.

  • EXIT_PROCESS_DEBUG_EVENT
    • 프로세스가 종료될 때 발생하는 이벤트이다.
  • CREATE_PROCESS_DEBUG_EVENT
BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde) {
	// WriteFile() API 주소구하기 --> 디버기 프로세스의 메모리 주소가 아니라 디버거 프로세스의 메모리 주소를 얻어서 사용
	g_pfWriteFile = GetProcAddress(GetModuleHandle(TEXT("kernel32.dll")), "WriteFile");
	
	//API Hook-WriteFile()
	//첫 번째 byte를 0xCC로 변경
	memcpy(&g_cpdi, &pde->u.CreateProcessInfo, sizeof(CREATE_PROCESS_DEBUG_INFO));
    
	ReadProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chOrgByte, sizeof(BYTE), NULL);
    
	WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, &g_chINT3, sizeof(BYTE), NULL);

	return TRUE;
}
  • 우선 WriteFile API의 주소를 GetProcAddress API를 통해 구한 후, g_cpdi 변수에 memcpy를 통해 debuggee process 정보를 복사한다. g_cpdi 변수는 CREATE_PROCESS_DEBUG_INFO 구조체 변수이다.
typedef struct _CREATE_PROCESS_DEBUG_INFO {
  HANDLE                 hFile;
  HANDLE                 hProcess;
  HANDLE                 hThread;
  LPVOID                 lpBaseOfImage;
  DWORD                  dwDebugInfoFileOffset;
  DWORD                  nDebugInfoSize;
  LPVOID                 lpThreadLocalBase;
  LPTHREAD_START_ROUTINE lpStartAddress;
  LPVOID                 lpImageName;
  WORD                   fUnicode;
} CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;

hProcess 멤버를 이용하여 WriteFile() API를 후킹 할 수 있다. 이 때 ReadProcessMemory를 이용해 WriteFile() API의 첫 바이트를 읽어서 g_ch0rgByte에 저장하는데 이는 후킹을 해제할 때 BP를 없애는 데 필요하기 때문이다.

  • EXCEPTION_DEBUG_EVENT
BOOL OnExceptionDebugEvent(LPDEBUG_EVENT pde)
{
    CONTEXT ctx;
    PBYTE lpBuffer = NULL;
    DWORD dwNumOfBytesToWrite, dwAddrOfBuffer, i;
    PEXCEPTION_RECORD per = &pde->u.Exception.ExceptionRecord;

    // BreakPoint exception (INT 3) 인 경우
    if( EXCEPTION_BREAKPOINT == per->ExceptionCode )
    {
        // BP 주소가 WriteFile() 인 경우
        if( g_pfWriteFile == per->ExceptionAddress )
        {
            // #1. Unhook
            //   0xCC 로 덮어쓴 부분을 original byte 로 되돌림
            WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, 
                               &g_chOrgByte, sizeof(BYTE), NULL);

            // #2. Thread Context 구하기
            ctx.ContextFlags = CONTEXT_CONTROL;
            GetThreadContext(g_cpdi.hThread, &ctx);

            // #3. WriteFile() 의 param 2, 3 값 구하기
            //   함수의 파라미터는 해당 프로세스의 스택에 존재함
            //   param 2 : ESP + 0x8
            //   param 3 : ESP + 0xC
            ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8), 
                              &dwAddrOfBuffer, sizeof(DWORD), NULL);
            ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC), 
                              &dwNumOfBytesToWrite, sizeof(DWORD), NULL);

            // #4. 임시 버퍼 할당
            lpBuffer = (PBYTE)malloc(dwNumOfBytesToWrite+1);
            memset(lpBuffer, 0, dwNumOfBytesToWrite+1);

            // #5. WriteFile() 의 버퍼를 임시 버퍼에 복사
            ReadProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, 
                              lpBuffer, dwNumOfBytesToWrite, NULL);
            printf("\n### original string ###\n%s\n", lpBuffer);

            // #6. 소문자 -> 대문자 변환
            for( i = 0; i < dwNumOfBytesToWrite; i++ )
            {
                if( 0x61 <= lpBuffer[i] && lpBuffer[i] <= 0x7A )
                    lpBuffer[i] -= 0x20;
            }

            printf("\n### converted string ###\n%s\n", lpBuffer);

            // #7. 변환된 버퍼를 WriteFile() 버퍼로 복사
            WriteProcessMemory(g_cpdi.hProcess, (LPVOID)dwAddrOfBuffer, 
                               lpBuffer, dwNumOfBytesToWrite, NULL);
            
            // #8. 임시 버퍼 해제
            free(lpBuffer);

            // #9. Thread Context 의 EIP 를 WriteFile() 시작으로 변경
            //   (현재는 WriteFile() + 1 만큼 지나왔음)
            ctx.Eip = (DWORD)g_pfWriteFile;
            SetThreadContext(g_cpdi.hThread, &ctx);

            // #10. Debuggee 프로세스를 진행시킴
            ContinueDebugEvent(pde->dwProcessId, pde->dwThreadId, DBG_CONTINUE);
            Sleep(0);

            // #11. API Hook
            WriteProcessMemory(g_cpdi.hProcess, g_pfWriteFile, 
                               &g_chINT3, sizeof(BYTE), NULL);

            return TRUE;
        }
    }

    return FALSE;
}

코드 안의 # 주석을 따라가면서 보겠다.

  1. UnHook (API 훅 제거)
    BP를 해제하는 과정

  2. Thread Context 구하기
    Thread Context = 프로세스의 실제 명령어 코드는 스레드 단위로 실행되는데, 스레드의 CPU 레지스터 정보를 저장하는 구조체가 Context 구조체이다.
    GetThreadContext() API를 호출하면 ctx의 구조체 변수에 해당 스레드의 Context를 저장한다.

  3. WriteFile()의 param 2,3 값 구하기
    함수의 파라미터는 스택에 저장되므로 Context.esp 멤버를 이용해서 각각의 값을 구한다.

  4. 4부터 8까지 대문자 덮어쓰기

  5. ~

  6. ~

  7. ~

  8. ~

  9. Thread Context의 EIP를 WriteFile() 시작으로 변경
    EIP를 INT3에 의해 1byte 밀렸기 때문에 다시 WriteFile의 시작 주소가 저장된 g_pfWriteFile을 대입한다.

  10. 디버거 프로세스를 진행한다.
    ContinueDebugEvent를 통해 디버기 프로세스 실행을 재개한다.

  11. API 훅 설치
    다음 번 후킹을 위해 파일을 저장할 때 후킹이 되도록 설정한다.

0개의 댓글