Operating System/이해하면 인생이 바뀌는 Windows API hook

사전지식 - 두 번째

Tony Lim 2024. 11. 27. 16:44

가상 메모리 접근 모드

  • 메모리에 대한 접근 권한은 보통 3 가지로 분류
  • read ,write ,execute
  • 타 프로세스 메모리에 대해서도 권한이 잇다면 접근가능
    • debugger가 그러하다

VirtualProtect 라는 함수가 가상메모리 접근모드를 조절하는 함수이다.

int main()
{
    const char* pszHello = "Hello World!\n";
    std::cout << pszHello;

    char* pszNewHello = const_cast<char*>(pszHello);
    //pszNewHello[4] = '\0';

    DWORD dwOldProtect = 0;
    ::VirtualProtect(
        (LPVOID)pszHello, 8, PAGE_READWRITE, &dwOldProtect);
    pszNewHello[4] = '\0';

    std::cout << pszHello;
    return 0;
}

const_cast 는 const variable 을 const를 제거해서 참조할 수 있게 해준다.

VirutalProtect 함수 호출 없이 pszNewHello에 쓰려고하면 접근권한 오류가 뜬다.

최적화 빌드에 따라 컴파일 타임에 pszNewHello[4] = '\0' 를 인지를 못하고 적용하지 않는다.

The compiler, especially under optimization, assumes string literals are immutable and may optimize the code in a way that ignores any modifications to them. Therefore, despite changing the memory protection and attempting to alter the string, the compiler's optimizations prevent the change from affecting the output.


힙 메모리에 저장된 코드 실행하기

  • 동적 할당한 메모리 공간은 데이터 영역 메모리로 기본속성 상 실행 대상이 아님(해킹 방지 목적)
  • 의도적으로 실행 속성을 부여 할 경우 문제 없이 실행 가능

DEP = Data Execution Prevention

heap ,stack에서는 RW 만있지 execution 권한은 존재하지 않는다.

int main();

__declspec(naked) void testFunc(void)
{
    puts("testFunc() - Begin");
    puts("testFunc() - End");
}

int main()
{
    puts("main() - Begin");
    BYTE* pBuffer = new BYTE[128];
    memset(pBuffer, 0, sizeof(BYTE[128]));

    DWORD dwOldProtect = 0;
    BOOL bResult = ::VirtualProtect(
                        (LPVOID)pBuffer, sizeof(BYTE[128]),
                        PAGE_EXECUTE_READWRITE, &dwOldProtect);

    memcpy(pBuffer, testFunc, sizeof(BYTE[128]));

    __asm {
        jmp pBuffer
    }

    delete[] pBuffer;
    puts("main() - End");
    return 0;
}

pBuffer 는 heap 영역에 선언이 된 상태인데 VirtualProtect를 통해서 RW말고 X까지 권한을 부여하였다.

memcpy 를 통해 naked function의 기계어가 해당 메모리로 copy되게 된다. 

inline assembly jmp 를 통해서 해당 메모리주소로 가니까 puts 2개가 실행이 될 것이다.

하지만 naked function이기 때문에 function epilogue 가 없어서 pBuffer의 코드를 실행하고 다시 caller로 되돌아 오지못해서 정상적으로 동작하지 못하게 된다.

In the case of the JVM, bytecode is loaded into the method area (not the heap) for interpretation or JIT compilation. If JIT-compilation occurs, the resulting native code is stored in an executable memory area separate from the heap. Execution permissions are only relevant for the memory storing native code generated by the JIT compiler.


실행코드 (기계어) 수정하기 

//컴파일러 최적화 안 함

#pragma pack(push, 1)
typedef struct JUMP_CODE {
    BYTE opCode;
    LPVOID targetAddr;
} JUMP_CODE;
#pragma pack(pop)

int main();

__declspec(naked) void testFunc(void)
{
    puts("__declspec(naked) testFunc() - Begin");
    puts("__declspec(naked) testFunc() - End");
    __asm {
        jmp main + 72h
    }
}

void targetFunc(void)
{
    puts("targetFunc()");
}

int main()
{
    puts("main() - Begin");

    //Before hook
    targetFunc();

    DWORD dwOldProtect = 0;
    BOOL bResult = ::VirtualProtect(
        (LPVOID)targetFunc, 5,
        PAGE_EXECUTE_READWRITE, &dwOldProtect);


    //목적지 - 현재주소 - 5
    //5는 JMP 명령어 크기
    JUMP_CODE jmpCode = { 0 };
    jmpCode.opCode = 0xE9;  //jmp
    jmpCode.targetAddr =
        (void*)((DWORD)testFunc - (DWORD)targetFunc - 5);

    memcpy(targetFunc, &jmpCode, 5);
    //After hook!!
    targetFunc();

    puts("main() - End");
    return 0;
}

#pragma pack(1)  ~  #pragma pack(pop) 을 통해서 안의 코드가 기존에 8byte를 차지하던것을 1byte alignment를 해달라고 요청한 것이다. padding 없이 딱 붙여서 1 + 4 = 5Byte

targetFunc()가 수정 대상이다.

VirutalProtect를 통해서 targetFunc의 5 byte 앞의 메모리 주소의 권한에 execution 을 추가한 RWX 를 부여한다. 하필 5bytes인 이유는 뒤에 나올 jmpCode의 size 가 5bytes이기 떄문이다.

function prologue 내용을 memcpy를 통해서 jmp 로 바뀌게 된다. 

jmp 는 상대주소를 게산하게 되는데 

Destination Address=Current Address of ‘jmp‘+Instruction Size+Relative Offset

  1. (DWORD)testFunc - (DWORD)targetFunc: This computes the absolute distance (offset) between the memory address of testFunc and the memory address of targetFunc.
  2. -5: This subtracts the size of the jmp instruction (5 bytes) because the offset for a relative jmp is calculated relative to the address of the next instruction after the jmp.

즉 targetFunc를 다시실행시키면 functino prologue 자리에 jmpCode가 있게되고 여기는 execution권한이 있으므로 jmp가 실행되고 testFunc가 수행된이후 

72 hexadecimal (Bytes) 이기 떄문에 위와 같은 메모리 주소로 다시 돌아가게된다.

강사분도 위와 같은 방법으로 72 를 계산한것이다.


캐시 플러싱

위에서 런타임에 코드를 수정했지만 미리 cpu register 에 캐싱을 해놓은 경우가 있을 수 있다.

즉 변경전의 targetFunction을 미리 캐싱해두어서 수정한것이 반영이 안될 수 도 있다는 의미이다.

멀티 스레드환경이면 cpu1에서 실행된 스레드는 캐싱된것을 실행하고 cpu2에서 실행되는 스레드는 변경된 함수를 메모리에서 로드해서 실행해서 서로 결과가 다를 수 도 있다.

#pragma pack(push, 1)
typedef struct JUMP_CODE {
    BYTE opCode;
    LPVOID targetAddr;
} JUMP_CODE;
#pragma pack(pop)

int main();
//컴파일러 최적화 안 함
__declspec(naked) void testFunc(void)
{
    puts("__declspec(naked) testFunc() - Begin");
    puts("__declspec(naked) testFunc() - End");
    __asm {
        jmp main + 86h
    }
}

void targetFunc(void)
{
    puts("targetFunc()");
}

int main()
{
    puts("main() - Begin");

    //Before hook
    targetFunc();

    DWORD dwOldProtect = 0;
    BOOL bResult = ::VirtualProtect(
        (LPVOID)targetFunc, 5,
        PAGE_EXECUTE_READWRITE, &dwOldProtect);

    //목적지주소 - 현재명령어주소 - 5
    //5(Byte)는 JMP, CALL 명령어의 크기 만큼 빼주는 것.
    JUMP_CODE jmpCode = { 0 };
    jmpCode.opCode = 0xE9;  //jmp
    jmpCode.targetAddr =
        (void*)((DWORD)testFunc - (DWORD)targetFunc - 5);

    memcpy(targetFunc, &jmpCode, 5);
    ::FlushInstructionCache(
        ::GetCurrentProcess(),
        targetFunc, 5);

    //After hook!!
    targetFunc();

    puts("main() - End");
    return 0;
}

FlushInsturctionCache 를 통해서 core에 cach들을 싹다 날려서 변경된 함수를 메모리에서 불러오게 할 수 있다.

 

 

 

 

 

 

'Operating System > 이해하면 인생이 바뀌는 Windows API hook' 카테고리의 다른 글

DLL injection  (0) 2024.12.13
Inline hook  (0) 2024.12.02
IAT hook  (0) 2024.12.02
사전 지식 - 첫 번째  (0) 2024.11.22