본문 바로가기
Software/C++&MFC

리버싱 핵심원리, 메모장 WriteFile() 후킹 - 64비트에서 따라하기

by lovey25 2021. 9. 27.
반응형

리버싱이라는 흥미로운 분야에 발을 담근지는 벌써 몇 년이나 되었지만 꾸준하게 공부한 적이 없어서 그냥 재미로 예제나 따라 해 보는 수준입니다. 그래도 그나마 공부다운 공부를 하게 만들어준 자료가 바로 나뭇잎 책이라고 알려진 '리버싱 핵심원리'인데요. 저 같은 초보도 이 책 하나만 보면 리버싱이란 게 뭐구나 감 정도는 잡을 수 있게 해 주고 그리고 주변에 후기를 찾아보면 이거 하나로도 경지에 오르신 분들도 계실 정도로 아주 유익한 교재라고 생각이 됩니다.

오늘은 리버싱 핵심원리의 책 3부 30장에 있는 윈도 메모장 WriteFile() 함수 후킹 예제를 64비트 윈도에서 여러 가지 에러들을 해결하면서 실습한 후기를 정리해 보려 합니다. (개인적으로 재미도 있었고 가장 많이 공부가 된 부분이라서 언제 한번 정리해야지 하고 생각했었지만 벌써 2년도 넘어버렸네요.)

API 후킹

이 예제에서는 다양한 리버싱 기법 중 윈도의 API를 후킹 해서 원래 설계된 동작을 임의로 변경할 수 있는 방법을 다루고 있습니다. 이 책에서는 리버싱의 꽃이라는 수식어를 사용해서 리버스 엔지니어링의 핵심기술이라고 설명을 하고 있는데요. 그래서 저도 다른 건 모르더라도 이거 하나만이라도 흉내 낼 수 있다면 책 구입한 본전을 찾을 수 있지 않을까 생각을 했습니다.

윈도는 여러 가지 이유로 시스템 자원을 사용자가 직접 접근할 수 없도록 제한을 하고 있는데요. 사용자 프로그램이 시스템 자원을 사용하기 위해서는 시스템 커널에 요청을 해야 하는데 이때 MS에서 정한 Win32 API를 이용해야 한다고 합니다.

프로그램이 실행될 때 많은 DLL이 로딩되는데 기본적으로 로딩되는 DLL이 kernel32.dll과 ntdll.dll라고 해요. 사용자 프로그램에서 컴퓨터 파일 시스템에 접근해서 파일을 읽어 들이는 동작을 하게 될 때 이 DLL에 정의된 코드를 호출해서 사용하는데 API 후킹이라는 건 이 호출 순간에 제어권을 가로채서 어떤 임무를 수행하게 되는 방식이라고 합니다.

디테일한 기술정보는 책에 잘 나와 있으니 이제 본격적으로 제 실습기로 넘어가도록 하겠습니다.

메모장 WriteFile() 후킹

이 예제는 윈도의 기본 프로그램인 메모장(notepad.exe)을 대상으로 하고 있습니다. 메모장에 문자를 입력하고 파일에 저장을 하면 입력된 문자를 모두 대문자로 바꿔서 저장하게 하는 예제입니다. 간단한 예제이지만 활용도는 무궁무진한 강력한 예제라고 생각이 됩니다.

API 후킹이라고 하면 기업에서 사용하는 보안 설루션 중 DRM이 생각이 나는데요. 그중에서도 악명(?)이 자자한 Fasoo DRM이 바로 이러한 API 후킹 기법을 사용하는 설루션으로 파일을 읽고 저장하는 순간에 이 DRM에 개입하여 파일을 암호화하고 복호화하면서 외부로 파일이 유출되었을 때 내용을 볼 수 없게 만드는 그런 설루션이라고 이해하고 있습니다.

여기서는 단순히 소문자를 대문자로 바꾸지만 내가 정한 임의의 규칙으로 문자를 변형시킨다면 Fasoo DRM과 같은 보안 설루션처럼 역할할 수도 있는 거죠.

64비트 윈도에서는 실행 불가

아래는 책에서 제공하는 소스코드입니다.

#include "windows.h"
#include "stdio.h"

LPVOID g_pfWriteFile = NULL;
CREATE_PROCESS_DEBUG_INFO g_cpdi;
BYTE g_chINT3 = 0xCC, g_chOrgByte = 0;

BOOL OnCreateProcessDebugEvent(LPDEBUG_EVENT pde)
{
    // WriteFile() API 주소 구하기
    g_pfWriteFile = GetProcAddress(GetModuleHandleA("kernel32.dll"), "WriteFile");

    // API Hook - WriteFile()
    //   첫 번째 byte 를 0xCC (INT 3) 으로 변경 
    //   (orginal byte 는 백업)
    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;
}

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;
}

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);
    }
}

int main(int argc, char* argv[])
{
    DWORD dwPID;

    if (argc != 2)
    {
        printf("\nUSAGE : hookdbg.exe <pid>\n");
        return 1;
    }

    // Attach Process
    dwPID = atoi(argv[1]);
    if (!DebugActiveProcess(dwPID))
    {
        printf("DebugActiveProcess(%d) failed!!!\n"
            "Error Code = %d\n", dwPID, GetLastError());
        return 1;
    }

    // 디버거 루프
    DebugLoop();

    return 0;
}

소스코드를 컴파일해서 실행파일을 만들고 프로그램의 사용방법대로 메모장을 실행시키고 메모장의 PID를 파라미터로 하여 프로그램을 실행하면...

이런 에러가 발생합니다. 이 예제는 책에서도 언급된 것처럼 32비트 윈도에서 동작하도록 만들어졌습니다. 예전에 제가 32비트 윈도에서 테스트했을 때 물론 다른 문제가 있었지만 이렇게 초반부터 에러를 만나지는 않았는데 아무튼 메모장의 제어권을 가져오는 것부터 진행되지 않았습니다.

저도 자세한 내용은 잘 모르지만 지금 테스트하는 윈도도 64비트고 메모장도 64비트 프로그램이라서 이런 문제가 생긴 게 아닌가 싶습니다. 예제 코드를 64비트 프로그램으로 변환하는 작업이 필요할 것 같습니다.

hookdbg.exe 64비트로 변환

일단 빌드 플랫폼을 "x64"로 바꿔주고 용감하게 빌드 도전했습니다.

당연히 다양한 에러와 경고가 발생합니다.

그래도 예상보다는 에러가 많지는 않네요. ^^; 첫 번째 에러부터 하나씩 살펴보겠습니다.

51행 - error C2039: 'Esp': '_CONTEXT'의 멤버가 아닙니다.

DLL의 WriteFile() 코드가 호출되어 있을 때 함수의 파라미터로 사용될 레지스터의 "ESP + 0x8"에 저장된 값을 가져오는 부분입니다. 'Esp'라는 표현이 문제가 되고 있는데 Esp는 32비트에서 Stack Point를 지칭하는 이름입니다. 64비트에서 Esp는 'Rsp'라는 이름으로 변경되었습니다. 정말 해당 위치에서 파라미터 값을 찾을 수 있는지 확인해 보겠습니다.

먼저 메모장을 열어서 'everyx'라는 문자를 입력하였습니다.

그리고 디버깅 툴을 사용해서 메모장을 디버기로 만듭니다. 여기서 제가 사용한 프로그램은 "x64dbg"라는 64비트 디버깅 프로그램을 사용했습니다.(https://x64dbg.com/#start)

후킹을 하려고 하는 WriteFile()는 "kernel32.dll"에 있는 함수로 디버거에서 심벌 검색으로 해당 함수를 찾아서 BP(Break Point)를 설정했습니다. 이제 WriteFile() 함수가 호출되도록 메모장 메뉴에서 저장을 실행시키고 설정한 BP에 도달할 때까지 따라갔습니다.

드디어 BP에 도착했습니다. 오른쪽 레지스터를 보니 메모장에 입력한 "everyx"가 벌써 보이네요. 그리고 여기서 알아두어야 할 중요한 내용이 함수 호출 규약인데요. 64비트에서는 'x64 fastcall'이라는 형식을 사용해서 함수의 파라미터로 Rcx, Rdx, R8, R9를 사용한다고 합니다. x64dbg 기본 화면에서는 아래와 같이 호출 파라미터를 한눈에 확인할 수 있네요.

WriteFile() 함수의 정의는 아래와 같습니다.

BOOL WriteFile(
  HANDLE       hFile,
  LPCVOID      lpBuffer,
  DWORD        nNumberOfBytesToWrite,
  LPDWORD      lpNumberOfBytesWritten,
  LPOVERLAPPED lpOverlapped
);

여기서 관심 있는 파라미터는 'lpBuffer'와 'nNumberOfBytesToWrite'입니다. 앞에서부터 차례대로 파일에 쓰고자 하는 내용이 있는 기준 주소와 파일에 써야 하는 내용의 크기가 각각 해당합니다. 여기서는 "everyx"라는 문자가 시작되는 위치 주소인 'Rdx'값이 'lpBuffer'에 들어가고, 문자 개수가 6개이기 때문에 'R8'에 저장된 '6'이 'nNumberOfBytesToWrite'에 들어가게 되겠네요. 

그러면 코드를 조금 거슬러 올라가서 47행의 스택 정보를 읽어오는 부분을 고쳐줍니다.

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

83행 - error C2039: 'Eip': '_CONTEXT'의 멤버가 아닙니다.

앞에서 본 에러와 마찬가지로 'Eip'는 32비트에서 사용되던 이름이니가 64비트 이름인 Rip로 고쳐줍니다.

            // #9. Thread Context 의 RIP 를 WriteFile() 시작으로 변경
            //   (현재는 WriteFile() + 1 만큼 지나왔음)
            ctx.Rip = (DWORD)g_pfWriteFile;

이제 에러는 모두 해결이 되었고, 경고만 4개 남았습니다.

모두 캐스팅 과정에서 발생하는 것으로 기존 32비트 코드에서 DWORD를 사용해서 발생하는 문제이니 모두 DWORD64로 변경하겠습니다.

드디어 깨끗하게 빌드가 되었습니다.

이제 제대로 동작을 하는지 확인을 하고 문제가 있으면 디버깅을 해야 하겠죠. 먼길 떠나기 전에 먼저 한 가지 조치를 취할 부분이 있습니다. 매번 실행할 때마다 메모장의 PID를 명령 인수에 넣어줘야 하는데 이게 매번 바뀌니 너무 귀찮습니다. 디버깅을 하려면 앞으로 수십 번을 더 노가다를 해야 할지 모르니 메모장의 PID를 찾아서 디버기로 만들어주는 코드를 추가하고 넘어가죠.

먼저 메인 함수는 아래와 같이 수정했습니다. 실행할 때 PID가 인수로 입력이 되면 해당 PID를 이용하여 메모장 프로세스를 디버기로 만들고 만약 그렇지 않다면 프로세스에서 "Notepad.exe"이름을 찾아서 PID를 구한 후 그 값을 가지고 디버기를 만드는 로직으로 변경했습니다.

int main(int argc, char* argv[])
{
    DWORD dwPID;

    // Attach Process
    if (argc != 2)
    {
        WCHAR pname[] =_T( "Notepad.exe");
        dwPID = GetProcessByFileName(pname);
    }
    else
    {
        dwPID = atoi(argv[1]);
    }
    
    if (!DebugActiveProcess(dwPID))
    {
        printf("DebugActiveProcess(%d) failed!!!\n"
            "Error Code = %d\n", dwPID, GetLastError());
        return 1;
    }

    // 디버거 루프
    DebugLoop();

    return 0;
}

그리고 프로세스 이름으로 PID를 구하는 함수는 GetProcessByFileName()으로 정의했는데 이 함수에 대한 상세는 별도 포스팅으로 대신합니다.

 

프로세스 이름으로 PID 번호 구하기

이름으로 해당 프로세스의 PID 번호를 구하는 방법입니다. 위 작업 관리자 캡처 화면의 경우 Notepad.exe의 PID는 5528입니다. 그런데 이 번호는 메모장이 실행될 때마다 변경되기 때문에 PID가 필요한

kwonkyo.tistory.com

디버깅

이제 번거롭게 PID 번호를 입력할 필요가 없어졌으니 무자비하게 디버깅해보겠습니다.

빌드가 되었다고 프로그램이 잘 굴러갈 리 없겠죠. 역시나 실행해보면 에러가 발생합니다. 

문제는 스택 값을 읽어오는 부분에서 발생하고 있었습니다. 에러가 발생하는 부분의 코드는 아랫부분입니다. 그런데 코드를 가만히 보면 좀 이상합니다. 스택에서 파일로 저장할 데이터가 저장된 메모리의 주소 값을 'dwAddrOfBuffer'변수에 넣어야 할 것 같은데 변수의 참조에 주소 값을 복사를 하는데 이렇게 되면 메모장에 써야 할 데이터가 그대로 복사가 될 거 같습니다.

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

실제로 실행 중에 'dwAddrOfBuffer'가 가리키는 주소로 가보면 메모장에 쓴 "everyx"가 복사되어 있는 걸 볼 수 있습니다.

이렇게 되면 소문자를 대문자로 변경하고 난 다음 변경된 대문자가 저장된 주소 값을 'WriteFile()'함수에 넣어줘야 하는데 이상한 값이 들어갈 거 같네요.

그리고 'dwNumOfBytesToWrite'변수에도 데이터의 크기 값이 들어가야 하고 'ctx.R8'에는 어떤 주소 값이 아니라 데이터의 크기 그 자체가 저장되어 있어서 여기서 에러가 발생하는 것 같습니다.

그래서 제가 이해한 게 정확한지 모르겠지만 아래와 같이 수정하고서 원하는 결과를 얻을 수 있었습니다.

// #3. WriteFile() 의 param 2, 3 값 구하기
//   함수의 파라미터는 해당 프로세스의 스택에 존재함
//   param 2 : Rdx
//   param 3 : R8
dwAddrOfBuffer = ctx.Rdx;
dwNumOfBytesToWrite = ctx.R8;

결과

수정한 코드를 컴파일하여 API 후킹 프로그램을 만들었습니다.

hookdbg.exe
0.01MB

위에서 테스트했던 "everyx"가 써진 텍스트 파일을 메모장으로 열고 컴파일한 후킹 프로그램을 실행합니다. 그리고 메모장에서 저장을 실행합니다.

예상했던 것과는 달리 "WriteFile()"의 BP가 계속 여러 차례 잡히기는 하지만 후킹의 원래 의도인 소문자로 대문자로 바꾸는 후킹은 잘 동작한 것 같습니다. 메모장을 닫고 저장했던 파일을 다시 열어보면 이렇게 내용 중에 소문자는 모두 대문자로 변경되어 저장된 것을 확인할 수 있습니다.

책에 있는 예제를 64비트에서 실습해 봤는데, 사실 이렇게 하는 게 맞는 건지 저는 감이 1도 없습니다. 그냥 이렇게 해보니 되더라 정도로 봐주시면 좋겠습니다. 

그리고 추가로 해결해야 할 문제가 남아 있는데요. 위에서 실습한 대로 텍스트 파일을 불러온 다음 메모장을 디버기로 만들어서 후킹을 하는 건 문제가 없었는데 아무것도 없이 메모장만 연 상태에서 후킹을 한 다음 내용을 적고 저장을 했을 때는 디버거가 무한루프에 빠지는 현상이 나타났습니다. 아무래도 메모장의 파일 탐색기를 불러오는 과정에서 발생한 이벤트 중 처리하지 못하는 예외가 있어서 그런 거 같은데 그 부분은 여전히 숙제로 남아 있습니다.

 

끝!

반응형

댓글