Programming/Win32 API2008. 12. 1. 15:36
Win32 Global API Hook - 제2강 다른 프로세스의 주소공간으로 !! (2)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 다들 추석연휴는 알차게 보내셨는지요... 바쁜일이 있어서 생각보다 늦게 강좌를 올리게되었습니다. 너그
럽게 이해해주시길... (아무도 기다리지 않으셨다면 뭐... 하하...-.-;;) 자, 늦게 온만큼 알차게 시작해야
겠죠? 오늘은 지난시간에 이어서 다른 프로세스의 주소공간을 넘나들수 있는 방법에 대해서 알아보도록 하
죠. 지난시간에 말씀드렸듯이 오늘 내용은 NUMEGA SOFTWARE의 시스템 엔지니어인 Matt Pietrek의 아이디어
에서 빌어온것이지만 Advanced Windows의 저자 Jeffrey Ritcher나 Debugging Applications의 저자 John
Robbinson 등이 인용했었고, 국내의 모 프로그래밍 잡지의 시스템 해킹칼럼에서도 다루어 진적이 있었던 내
용입니다. API 후킹의 실질적인 기초를 다루는 내용이기도 하지요. 그럼 강좌나갑니다.
 
(강좌의 진행상 존칭을 생략합니다.)

 자 그럼 우리는 무얼하려고 했는지 생각해보자. 이번 강좌의 내용이 다른 프로세스의 주소공간을 우리가
사용해보자는 것이었다. 어떻게 하면 다른 프로세스의 주소공간으로 들어갈 수 있을까? 아니 그것보다 다
른 프로세스의 주소공간을 넘나드는 것이 어떤게 있을지 생각해보자. 쉽게 떠오르고 가장 대표적인것이 바
로 디버거일것이다. 여러분이 Visual C++ 등의 개발툴로 디버깅을 하는것을 한번 생각해보자. 디버거와 내
가 만든 프로그램은 엄연히 다른 프로세스이다. 그치만 디버거는 여러분이 만든 프로그램(디버기, 디버깅
을 당하는 프로세스를 말한다.)의 변수의 값을 추적하거나 변경할 수 있다. 디버거는 컴파일러와 더불어 시
스템프로그래밍을 익히는 가장 좋은예제가 될것이지만, 다들 아시다시피 제대로된 디버거를 제작하는것은
컴파일러만큼이나 복잡하고 어려운 작업이다. 음... 필자는 국내에서 제작된 상용 컴파일러와 디버거가 하
나도 없다는것이 좀 아쉬운데(예전에 씨앗이라는 C언어 비슷한 한글언어가 있었긴 하지만, DOS용이었던것으
로 기억된다.) 물론 우리나라의 개발자들의 능력이 못미쳐서라기보다는 일단 개발하는데 필요한 시간과 노
력에 비해 그 사업성에서 결코 낙관적이지 않을것이라는 이유일것이다. 음... 어쨋든 얘기가 잠시 삼천포
로 빠졌는데, 다시 디버거 얘기를 해보자. 어쨋든 디버거는 확실히 다른 프로세스의 주소공간을 넘나들며
실행된다는것은 의심의 여지가 없는것 같다.
 
 그렇다면?, 우리도 그와 비슷한 작업을 할 수 있지 않을까? Win32 API는 다른 프로세스의 주소공간을 엑세
스할수 있는 API세트를 제공한다. 이것이 바로 Debugging API 이다. 아마 처음 들어보는 사람도 많을 것이
다. 백문이 불여일견, 지금 당장 MSDN을 열고 "WriteProcessMemory"라고 타이핑해보자. 그러면
WriteProcessMemory함수에 대한 내용이 표시될것이다. (WriteProcessMemory는 대표적인 디버깅 API로 이름
그대로 프로세스 메모리를 쓰는(WRITE)하는 함수이다.) Win32에서는 프로세스간의 주소공간이 철저히 보호
된다더니만 그렇지도 않네? 이거 너무 싱겁잖아? 뭐 이렇게 생각하는 분들도 있을지 모르겠다. 그렇지만 운
영체제가 어디 그리 엉성하게 제작되었겠는가? Debugging API는 말그대로 Debugging을 위해서, 또는 디버거
를 제작하기 위한 목적으로 생겨난것이므로 우리같이 엉뚱한 목적을 위해 사용하려는데에는 많은 제한을 두
고 있다. 쉽게 말하면 저러한 종류의 API를 사용할수 있는 경우는 매우 제한적이라는 것이다. 실례로
WriteProcessMemory등으로 다른 프로세스의 주소공간을 접근하려면 프로세스를 디버깅모드로 실행시키지 않
는한 매우 까다로운 절차를 거쳐야 하며, 그나마 운영체제에 따른 지원여부또한 불투명하다. (운영체제, 정
확히 말하면 상당수의 시스템 API가 Win9x에서 지원되지 않거나, 제한적으로 적용된다는 말이다.)
 
 참고로 언급하면 지금까지의 예제들은 모두 Win9x에서 동작한다. WinNT/2000에서는 정상적인 동작을 보장
할 수 없다. 실제로 지난번 예제또한 WinNT/2000에서는 필자가 말한대로 동작하지 않았을것이다. NT계열
(2000, XP까지)의 운영체제와 9x계열의 운영체제는 보기에는 비슷해보이고 대부분의 어플리케이션이 호환되
는듯 동작하지만, 내부적인 많은 차이점을 지니고 있다. 결론적으로 말하면 API 후킹에 관한 아이디어는 9x
나 NT계열이나 별반 다를게 없지만 적용하는 방법에 있어서는 차이를 가진다. 잘라말하면 9x가 NT계열보다
쉽다. 이유는 9x는 호환성을 위해 DOS와 Windows 3.x의 내부구조를 상당부분 포함하고 있기때문이다. 결국
호환성때문에 운영체제의 안정성과 합리적인 구조를 포기할수밖에 없었던 것인데, 9x가 NT계열보다 불안정
한 이유도 여기에 있다. 일단 구체적인 두 운영체제의 차이점은 나중에 디바이스드라이버 강좌에서 자세히
알아보기로하고 우린 9x에서 작업한다고 가정하고 강좌를 진행하도록 하자.
 
 우리가 다른 프로세스의 주소공간에서 작업할 수 있는 가장 효과적인 방법이 무엇이 있을까? 당연히 DLL
을 이용하는 것이다. 다들 아시다시피 DLL은 DLL을 로드한 프로세스의 주소공간에 매핑되며 얼마든지 프로
세스의 자원을 사용할 수도 있다. 그럼 우리는 원하는 작업을 수행하는 DLL을 제작한다음, 그녀석을 원하
는 프로세스에 주입시키면 될것이다.
 
 그런데, 내가 제작한 프로그램이 아닌녀석에게 어떻게 내가 원하는 DLL을 주입할수 있을까? 지난번 강좌에
서 구체적으로 프로그램을 어떻게 실행시키는지를 잠깐 언급한적이 있다. 결국 컴파일러는 바이너리파일을
만들어내며, CPU는 그 파일에서 지정된 코드를 찾아 순차적으로 실행해나간다고 했다. 그렇다면 우리가 그 
실행코드를 원하는 코드로 덮어써버린다면? 당연히 내가 덮어쓴 코드가 실행될 것이다. 실행파일이 실행되
는것은 실제로 주기억장치(오랜만에 들어본다. ^^ 메모리를 말하는것이다.)에 로드된후에 실행된다고 했
다. 그렇다면 실행파일이 로드된 지점을 찾아 위에서 말한 Debugging API로 원하는 코드영역을 내가 원하
는 코드로 바꿔치기한후, 실행시키면 될것이다. 자, 아이디어는 매우 간단하다. 그럼 실제로 다음과 같은
과정을 거쳐 구현에 들어가보자.
 
1. 프로세스의 실행코드를 알아낸다.
 : Debugging API를 사용해서 특정프로세스의 실행코드위치를 알아낼 수 있다. 먼저 언급한대로 우리는 9x
에서 우리가 프로세스를 출발시키는 경우에만 적용하도록하자.
 
2. 원하는 실행코드를 제작한다.
 : 아마 상당수의 어플리케이션 프로그래머(대부분 VC++, VB, Delphi 등으로 작업할 것이다.)가 이해하기
힘들어하는 부분일것이다. 왜냐면 어셈블리, 정확히 말하면 기계어코드를 작성해야 하기때문이다. 지금은
어셈블리로 프로그래밍하는 사람이 거의 없겠지만 어셈블리를 아는 프로그래머와 그렇지 않은 프로그래머
는 분명한 차이가 있다. 반드시 어셈블리를 이용해서 프로그래밍하지 않더라도 어셈블리를 알면 디버깅과
시스템에 대한 이해가 분명해질것이다. 이번 강좌에서는 매우 간단한 어셈블리 코드만을 사용하지만, 자신
이 프로그래밍을 천직으로 알고 있다면(이 강좌를 보는 대부분의 사람들이 그럴것이라고 필자는 믿고 싶
다.) 반드시 어셈블리를 공부하길 바란다. (어셈블리에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍"
다시 다루도록 하자.)
 
3. 실행코드를 덮어쓴다.
 : 원하는 기능을 수행하는 코드를 원래의 코드에 덮어쓴다.
 
4. 필요한 기능이 실행되었다면 코드를 복원한다.
 : 3에서 덮어써진 코드를 복원/실행 한다.
 
전체적인 작업의 흐름은 이렇다. 자, 그럼 잠시 머리속을 정리한후, 실제 코드를 구현해보도록 하자.

그럼 먼저 간단한 마루타가 될 Win32 응용프로그램을 제작한다. 일단은 그냥 윈도에서 제공되는 노트패드
를 그냥 가져다 써도 무방할 것이지만 나중에 API 후킹을 테스트하려면 하나 만들어두는것도 좋을것이다.
간단하게 VC++에서 Win32 Application - Hello World 프로그램을 선택해서 AppWizard를 통해 만들어도 상관
없다. 실행해보면 그냥 달랑 메인윈도가 뜨고, 클라이언트 영역에 "Hello, World"라고 출력될것이다.

그 다음은 프로세스 몰래 주입할 DLL을 하나 제작한다. 일단 다른 프로세스에 제대로 로드되었는가를 확인
하기 위해, 아래와 같이 프로세스에 붙을때와 떨어질때 메시지 박스를 츨력해주는 간단한 DLL을 만들어보
자.

BOOL APIENTRY DllMain(HANDLE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved)
{
    switch(ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        MessageBox(NULL, "DLL_PROCESS_ATTACH", "TestDll", MB_OK);
        break;
    case DLL_PROCESS_DETACH:
        MessageBox(NULL, "DLL_PROCESS_DETACH", "TestDll", MB_OK);
        break;
    }

    return TRUE;
}

일단 여기까지 별 무리없을 것이다. 그러면 프로세스에 DLL을 주입하는 모듈을 제작해보자.
앞서서 프로세스를 디버깅모드로 실행시켜야 한다고 말했다. 아래와 같이 프로세스를 디버깅 모드로 실행한
다.

CreateProcess(NULL,
    (LPSTR)szCmdLine,
    0,
    0,
    FALSE,
    DEBUG_ONLY_THIS_PROCESS,
    0,
    0,
    &StartupInfo,
    &ProcessInfo);

단지 CreateProcess의 6번째 인자로 DEBUG_ONLY_THIS_PROCESS를 준것외에 특별한 것은 없다. 그러면
szCmdLine으로 실행된 프로세스는 내 프로세스의 디버기(전에 언급했다. 디버깅을 당하는 프로세스라는 의
미이다.)가 된다. 그러면 디버기는 디버거(바로 내 프로세스가 된다.)에게 디버깅 이벤트를 발생시킨다. 우
리는 적절한 이벤트를 핸들링함으로써 원하는 작업을 수행할 수 있을것이다. 아래의 코드를 보자.

DEBUG_EVENT event;
DWORD dwContinueStatus;

while(1)
{
    // 디버그 이벤트가 발생할때까지 대기
    WaitForDebugEvent(&event, INFINITE);

    dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;

    if(CREATE_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 프로세스 생성 이벤트
        TRACE("CREATE_PROCESS_DEBUG_EVENT fired !!\n");

        // 디버그 프로세스 정보
        m_ProcessDebugInfo = event.u.CreateProcessInfo;
    }
    else if(EXCEPTION_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 예외 이벤트
        TRACE("EXCEPTION_DEBUG_EVENT fired !!\n");
        HandleException(&event, &dwContinueStatus);
    }
    else if(EXIT_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)
    {
        // 디버그 프로세스 종료 이벤트
        TRACE("EXIT_PROCESS_DEBUG_EVENT fired !!\n");
        return;
    }

    // 디버그 프로세스로 제어를 넘김.
    ContinueDebugEvent(event.dwProcessId, event.dwThreadId, dwContinueStatus);
}

 EXIT_PROCESS_DEBUG_EVENT 이벤트가 들어올때까지 무한루프를 수행하는 것을 알수 있다.
EXIT_PROCESS_DEBUG_EVENT 이벤트는 디버그 프로세스(== 디버기)가 종료될때 발생한다. 우리가 처리해야 하
는 이벤트는 이것외에 CREATE_PROCESS_DEBUG_EVENT 와 EXCEPTION_DEBUG_EVENT 이벤트인데 이름 그대로 디버
기가 생성될때와 디버기에서 예외가 발생할때 디버거에서 발생한다. 우리는 여기서 프로세스 정보를 백업하
고, 코드를 덮어쓰고 복원한다. 실제로 우리가 관심을 가져야 할 곳은 바로 EXCEPTION_DEBUG_EVENT 이벤트
인데, 이녀석은 아까 말한대로 디버기가 예외를 일으킬때 디버거로 발생되는 이벤트인데, 예외의 종류는 다
들 잘 알다시피 각종 오버플로우, 0으로 나눔, 접근금지 등등이 있는데, 여러분이 디버깅을 할때 사용하는
중단점(브레이크포인트) 또한 예외의 한 종류이다. 중단점은 디버기가 실행되는 순간
(CREATE_PROCESS_DEBUG_EVENT 다음으로)에 실행파일 로더에 의해서 한번 발생하며, 당연히 중단점이 설정
될 경우에도 발생한다. 그럼 HandleException() 함수의 내용을 보자.

// 브레이크 포인트인가?
if(EXCEPTION_BREAKPOINT
    == pEvent->u.Exception.ExceptionRecord.ExceptionCode)
{
    TRACE("EXCEPTION_BREAKPOINT fired !!\n");

    if(0 == m_uBreakCount)
    {
        // 첫번째 브레이크 포인트
        TRACE("First EXCEPTION_DEBUG_EVENT fired !! - InjectSpyDll() Call !!\n");
        if(!InjectSpyDll())
            TRACE("ERROR : InjectSpyDll() Fail !!\n");
    }
    else if(1 == m_uBreakCount)
    {
        // 두번째 브레이크 포인트
        TRACE("Second EXCEPTION_DEBUG_EVENT fired !! - ReplaceOriginalPagesAndContext()
Call !!\n");
        if(!ReplaceOriginalPagesAndContext())
            TRACE("ERROR : ReplaceOriginalPagesAndContext() Fail !!\n");
    }

    m_uBreakCount++;
    *pContinueStatus = DBG_CONTINUE;
}
else
    *pContinueStatus = DBG_EXCEPTION_NOT_HANDLED;

 첫번째 중단점은 디버기가 실행된 직후에 한번 발생한다고 했다. 따라서 우리가 코드를 덮어쓰는 시점이
바로 이부분이 되어야 할것이다. 아직 디버기는 실행되기 전이며, 우리는 디버기의 첫 실행코드에서 우리
의 코드를 덮어쓰는 셈이다. 두번째 중단점은 원본코드를 복원하기 위해서, 덮어쓰는 코드에서 지정해주는
데, 디버거로 제어를 넘기기 위해 사용된다. 이부분에서 원본코드를 복원해주어야 할 것이다.

 그럼 실제로 실행코드를 덮어쓰는 부분(InjectSpyDll())을 살펴보자.
 
// LoadLibraryA()의 주소
FARPROC pfnLoadLibrary = GetProcAddress(
    GetModuleHandle("KERNEL32.DLL"), "LoadLibraryA");

// 실행 프로세스의 첫번째 페이지 얻어옴
m_pFirstCodePage = FindFirstCodePage(m_ProcessDebugInfo.hProcess,
    m_ProcessDebugInfo.lpBaseOfImage);

// 실행 스레드 컨텍스트 백업
m_OrgContext.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext);

BOOL    bRetCode;
DWORD    cBytesMoved;

// 실행 프로세스의 첫번째 페이지 백업
bRetCode = ReadProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)
    return FALSE;

// 스파이 DLL 을 로드할 루틴을 담은 구조체 제작
PFAKE_LOADLIBRARY_CODE pNewCode = (PFAKE_LOADLIBRARY_CODE)m_pFakeCodePage;

// sub esp, 1000h
pNewCode->instr_SUB = 0xEC81;
pNewCode->operand_SUB_value = PAGE_SIZE; // 페이지크기(4096);

// push <매개변수>
pNewCode->instr_PUSH = 0x68;
pNewCode->operand_PUSH_value = (DWORD)m_pFirstCodePage
    + offsetof(FAKE_LOADLIBRARY_CODE, data_DllName);

// call <함수주소> ; LoadLibraryA() 호출
pNewCode->instr_CALL = 0xE8;
pNewCode->operand_CALL_offset = (DWORD)pfnLoadLibrary
    - (DWORD)m_pFirstCodePage - offsetof(FAKE_LOADLIBRARY_CODE, instr_CALL) - 5;

// 마지막에 브레이크 포인트 삽입
pNewCode->instr_INT_3 = 0xCC;

// 매개변수 (로드될 스파이 DLL)
char pszDll[MAX_PATH];
if(!GetSpyDllName(pszDll, sizeof(pszDll)))
    return FALSE;
strcpy(pNewCode->data_DllName, pszDll);

// 우리의 루틴을 실행프로세스에 Write !!
bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    &m_pFakeCodePage, sizeof(m_pFakeCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pFakeCodePage) != cBytesMoved)
    return FALSE;

// 실행 포인트(EIP)를 첫번째 페이지로 설정
m_FakeContext = m_OrgContext;
m_FakeContext.Eip = (DWORD)m_pFirstCodePage;

// 실행 스레드 컨텍스트 설정
if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_FakeContext))
    return FALSE;

return TRUE;

 코드가 좀 긴데, 흐름은 이렇다. 먼저 우리가 수행하기를 원하는 코드는 이렇다. 바로 우리가 앞서 만든
testdll.dll을 디버기로 하여금 로드하게 하는것이다. C코드로 하면 디버기에 아래와 같은 코드를 삽입하
는 것이다.

LoadLibrary("testdll.dll");

그럼 이런 작업을 수행할 코드를 제작해보자. 결론부터 말하면 어셈블리로 아래와 같은 코드가 될것이다.

push <"test.dll"의 주소>
call <LoadLibrary의 함수주소>
int 3

 C언어가 파라미터를 패스하는 방법은 스택을 이용하는 것이다. 함수 파라미터 패싱은 레지스터를 이용하
는 방법과 스택을 이용하는 방법이 있는데, 일반적인 경우 C언어에서 파라미터는 스택을 통해서 넘겨진다.
(함수호출규약에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍" 자세히 다루도록 하자.) 어쨋든 위와 같
은 코드를 통해서 우리가 원하는 DLL이 로드될것이며, 작업이 끝나면 원본코드의 복원을 위해 디버거로 제
어를 넘겨야 하는데, 이를 위해 중단점을 지정한다. 중단점은 어셈블리 코드로 인터럽트 3번 즉, int 3 이
다.

 그럼 위의 어셈블리코드를 기계어로 변환시켜보자. 그런데 문제가 있다. test.dll이나 LoadLibrary의 함수
주소를 어떻게 처리해야 하는것일까? 먼저 LoadLibrary의 함수주소를 찾아보자. GetModuleHandle(),
GetProcAddress()로 함수의 주소를 알아내는 것은 간단할 것이다. (적어도 9x에서는) 그런데 이 함수주소
를 실제로 CPU가 해석할때는 어떤방식으로 접근하는지를 알아야할것이다. call 명령이나 jmp 명령 등의 실
행제어를 변경하는 명령어들은 32비트 환경에서 보통 5바이트의 크기를 갖는데, 이는 명령어코드 1바이트
와 이동할 주소 4바이트(32비트 어드레싱이므로)로 이루어진다. 그런데 명령어의 파라미터가 되는 주소는
절대주소가 아니고 상대주소이다. 그러니까 현재 call 명령을 수행하고 돌아올 리턴주소를 실제 이동할 주
소에서 뺀 값으로 기계어코드를 생성한다. 예를 들어 call 100 이라는 어셈블리 명령이 위치한 주소가 50이
라면 이 명령은 기계어로 변환되면 0xe8(call 명령어)과 100(이동할주소) - 50(현재 명령의 주소) - 5(현
재 명령의 크기)로 전개된다는 것이다. 결과적으로 0xe845000000의 코드값으로 변환될 것이다.(물론 일반적
으로 위와 같은 주소값은 Win32에서 유효할수 없다.) 그렇다면 실제로 LoadLibararyA의 함수주소가
0xbff77750
이고, 현재 실행중인 코드의 주소가 0x00401bc2 라고 한다면 LoadLibraryA를 호출하는 기계어 코드는 다음
과 같을 것이다. 0xe8895bb7bf(call bff77750h) (Intel 계열의 CPU에서는 역워드지정방식을 사용한다는것
을 기억하자. 0x12345678은 실제 메모리상에는 0x78, 0x56, 0x34, 0x12로 적재된다.)

 자, 함수호출이 어떻게 이루어지는지 알았으니 이제는 파라미터를 어떻게 전달하는가를 알아보자. 위부분
에서 설명했듯이 C언어는 일반적으로 스택을 통해서 파라미터를 전달한다고 했다. 스택에 파라미터
("testdll.dll")을 푸쉬하고 좀전에 본대로 함수를 호출해주면 되겠는데, 우리가 사용할 파라미터는 정수
나 문자같은 단순데이터형이 아니고 포인터형이다. 그렇다면 실제 문자열을 담고 있는 데이터를 우리가 마
련해주어야 하며 그것을 어떻게 찾아서 프로그램이 사용하는지 또한 우리가 지정해주어야 할것이다. 컴파일
러는 지역변수와 전역변수를 스택과 힙에 각각 공간을 할당해주며, 코드내에서 이들을 찾아서 연결될수 있
도록 배려한다. 그러므로 프로그래머는 데이터를 어떻게 접근해야 하는지를 신경쓸 필요가 없게되고, 단순
히 데이터의 이름으로만 참조하면 된다. 우리가 만드는 코드 또한 작은 실행파일과 유사한 실행가능한 코드
조각이지만 이런 작업들을 해줄 컴파일러따위는 없다. 그러므로 데이터를 접근하는 방법또한 우리가 직접
지정해주어야 할것이다. 일단 우리는 독립적으로 실행될수 있어야 하므로 전역변수가 사용하는 힙을 사용하
기에는 무리가 있다. 결국 스택을 이용해야 하는데 컴파일러는 지역변수를 스택에서 관리한다. (다음강좌에
서 다시 다루겠지만 일단 스택포인터를 감소시켜서 지역변수를 위한 공간을 할당한다고만 알아두자. 이부분
은 어셈블리를 얘기할때 아주 중요한 사항이므로 꼭 알아두도록 하자.) 스택포인터를 감소시키고 그 공간
에 문자열을 담은 데이터를 써넣고 그 문자열의 주소를 다시 스택에 푸쉬한다음 LoadLibraryA를 호출한다
면 원하는 DLL을 로드할 수 있을것이다.

 전체적인 쉘코드(쉘코드란 말은 원래 유닉스 계열의 운영체제에서 루트의 권한에서 실행되는 프로그램의
코드를 변조해서 루트의 권한으로 쉘을 획득할수 있게 하는 해킹 코드덩어리를 말한다. 최근까지 유행했던
스택오버플로우 해킹기법으로 세상에 알려졌으며, 보통 리턴주소를 덮어쓰는 방법으로 코드를 실행한다.)
가 구상되었다면 이제 실제로 이러한 기능을 담은 쉘코드를 위한 자료구조를 아래와 같이 준비한다.
 
// 구조체를 1바이트씩 packing한다.
#pragma pack(1)

// 실행프로세스에 주사될 실행루틴(LoadLibraryA())
typedef struct _FAKE_LOADLIBRARY_CODE{
    WORD    instr_SUB;
    DWORD    operand_SUB_value;
    BYTE    instr_PUSH;
    DWORD    operand_PUSH_value;
    BYTE    instr_CALL;
    DWORD    operand_CALL_offset;
    BYTE    instr_INT_3;
    char    data_DllName[1];
}FAKE_LOADLIBRARY_CODE, *PFAKE_LOADLIBRARY_CODE;

 일단 1바이트로 구조체를 정렬하도록 지정한후, 구조체의 각 필드를 채워넣는 로직은 위에 리스트된 코드
를 참고하길 바라며, 결과적으로 우리가 삽입할 전체적인 어셈블리 구문은 아래처럼 될것이다.

sub esp, 1000h
push <"test.dll"의 주소>
call <LoadLibrary의 함수주소>
int 3

 처음 스택포인터를 0x1000(4096)만큼 감소시킨것은 아까 말했듯이 마치 컴파일러가 지역변수를 위한 공간
을 확보하듯이 우리의 데이터를 위한 스택공간을 확보한것이다.
 
 자!! 어쨋든 쉘코드가 완성되었다면 이녀석을 프로세스의 코드영역에 덮어쓰고 실행시키기만 하면 우리가
원하는 작업(LoadLibraryA("testdll.dll"))을 수행해줄것이다. 그렇다면 어느곳에 덮어쓸것인가? 우리는 프
로그램의 제일 처음코드에 우리의 코드를 덮어씀으로써 먼저 우리가 원하는 작업을 수행한후, 원본코드를
복원해서 마치 프로세스는 아무일없었다는듯 실행되게 할 예정이다. 그렇다면 먼저 프로세스의 실행코드의
첫부분을 찾아내야 하는데, 이를 위해서 FindFirstCodePage() 함수를 보도록 하자.
 
BOOL    bRetCode;
DWORD    cBytesMoved;
DWORD    peHdrOffset, baseOfCode;

// 실행 프로세스의 첫번째 페이지 얻음.
bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + offsetof(IMAGE_DOS_HEADER, e_lfanew),
    &peHdrOffset, sizeof(peHdrOffset), &cBytesMoved);
if(!bRetCode || sizeof(peHdrOffset) != cBytesMoved)
    return FALSE;

bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + peHdrOffset
    + 4 + IMAGE_SIZEOF_FILE_HEADER
    + offsetof(IMAGE_OPTIONAL_HEADER, BaseOfCode),
    &baseOfCode, sizeof(baseOfCode), &cBytesMoved);
if(!bRetCode || sizeof(baseOfCode) != cBytesMoved)
    return FALSE;

return (LPVOID)((DWORD)pProcessBase + baseOfCode);

 그다지 까다로운것은 없는것 같다. 1강에서 언급했던 PE구조를 이해했다면 별어려움 없이 이해할 수 있을
것이다. 프로세스의 베이스어드레스에서 코드의 베이스 어드레스만큼의 오프셋을 더한 값을 반환해준다. 이
미 눈치챘겠지만 코드의 베이스 어드레스 또한 상대주소라는 것을 알 수 있을것이다.
 
 어쨋든 디버기의 현재 스레드의 컨텍스트와 첫번째 코드페이지를 백업한후, 아까 만들어놓은 쉘코드를 디
버기의 첫번째 코드페이지에 덮어쓴다. 그다음 현재 스레드의 컨텍스트의 실행포인터를 덮어쓴 첫번째 코드
페이지로 복원한다. 스레드 컨텍스트에 대해 생소한 사람이 있을것 같아서 짚고 넘어가면 스레드 컨텍스트
는 문맥그대로 현재 스레드의 실행상태를 보관하고 있다. 대부분 CPU 레지스터에 관한 정보인데 그중 우리
가 꼭 알고 넘어갈것이 eip(확장 인스트럭션 포인터)이다. 이 레지스터가 가지고 있는 데이터는 다음에 실
행할 코드주소를 가지고 있다. 따라서 이 녀석을 수정하게 되면 프로그램의 흐름을 원하는 방향으로 제어할
수 있다. VC++ 디버거에서 제공하는 Set Next Statment 명령이 바로 이 eip를 수정하므로써 실행흐름을 원
하는 곳으로 점프 또는 리턴시킨다. 못믿겠다면 VC++ 디버거에서 레지스터 윈도우를 오픈한뒤 eip의 값을
수정해보라. 프로그램이 여러분이 eip로 지정한 주소로 점프하는 것을 볼수 있을것이다.(실행흐름을 조작하
는것은 시스템프로그래밍 디버깅에서는 아주 흔한경우이지만 어플리케이션 레벨의 디버깅에서는 많이 쓰이
지 않는듯 하다. Set Next Statment 는 VC++ 디버거에서도 매우 유용한 기능이므로 꼭 기억해두도록 하자.
그러나 남발하게 되면 스택이 망가지거나 돌이킬수 없는 상황을 초래하기도 하므로 잘 알고 사용해야 할것
이다.)

 마지막으로 ReplaceOriginalPagesAndContext() 함수는 덮어썼던 코드페이지와 컨텍스트를 원본대로 복원하
는 함수이다.
 
BOOL    bRetCode;
DWORD    cBytesMoved;

// 첫번째 페이지와 스레드 컨텍스트를 원래대로 되돌림.
bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,
    m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);
if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)
    return FALSE;

if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext))
    return FALSE;

return TRUE;

 이전에 InjectSpyDll()에서 백업해 두었던 페이지와 컨텍스트를 단순히 되돌림으로써 프로세스(디버기)는
자신이 무슨짓을 했는지도 모른채 정상적으로 실행된다.
 
<끝으로 !!>

 오늘강좌는 약간 긴 분량의 강좌였던것 같다. 우리는 실행중인 다른 프로세스에 우리의 모듈을 몰래 주입
하는 방법을 배웠다. 어셈블리가 생소한분에게는 좀 버거웠던 강좌였을것이다. 이번 강좌는 예외적으로 예
제프로그램의 소스코드를 같이 올리도록 하겠다. 같이 올린 소스코드를 분석해보면 알겠지만 몇가지 잡다
한 부분은 오늘 설명에서 제외되었지만 그리 어렵지 않거나 별로 중요하지 않은 부분이니 충분히 혼자 이해
할수 있을거라고 믿는다.
 
 원래는 1강에서 강의한 모듈로서 DLL을 제작한 후, 그것을 다른 프로세스에 주입해서 실제로 다른 프로세
스의 API 후킹을 보여주려 했지만, 역시나 욕심이 지나쳐 설명이 많아지는 바람에 다루지 못했다. 이부분
은 여러분들이 직접 스스로 해보길바란다. 1강에서 다룬 내용은 동일한 프로세스의 주소공간에서는 유효한
API 후킹이었으므로 이것을 DLL로 제작해 오늘 배운방법으로 프로세스에 주입시키면 그 프로세스의 API를
후킹할수 있을것이다.
 
 그렇지만 오늘 강좌도 모든 경우에 적용할 수 있는 API후킹이라고 하기에는 모자란점이 많다. 일단 임포트
테이블을 조작하는 방법에는 한계가 있다는 점을 말해주고 싶다. 또한 어떠한 경우에는 위의 방법이 전혀
통하지 않는 경우도 있다는것을 참고하기 바란다. 그밖에 멀티스레딩에 대한 문제 등, 일반화하기 위해서
는 넘어야 할 산이 많다. 그렇지만 오늘 강좌의 내용은 API 후킹의 실질적인 기초가 되었던 강좌임에는 틀
림없다. 시간관계상 설명하지 못하거나 또는 빠뜨린것이 있을수 있겠지만 적어도 오늘 내용만큼은 꼭 이해
해주길 바란다.
Posted by skensita