널자의 Windows API Hook

널자의 Windows API Hook

2024년 11월 14일

사전지식 #

  • 32bit 시스템에서 함수 포인터를 사용할 땐 호출하려고 하는 함수의 호출규약까지 적어야 한다. 적지 않으면 c/c++의 호출규약으로 생각해서 런타임 중 함수 호출때 문제가 발생한다.

    환경 32bit 64bit
    C/C++ cdecl 운영 체제 표준 방식 사용
    Windows stdcall Microsoft x64
    Linux cdecl System V AMD64 ABI
  • switch-case를 사용해서 함수를 분기시키는 경우 성능상 좋지 않아서 시그니쳐가 같다면 함수 포인터 배열로 룩업 테이블 형식으로 구현하는게 성능상 좋다.

    1int(__cdecl *lookupTable[3])(int, int) = {
    2    func1, func2, func3
    3}
    4do {
    5    scanf_s("%d%*c", &input);
    6    if (0 < input && input < 4) 
    7        lookupTable[input - 1](1, 2);
    8} while (input != 0)
    
  • 스레드당 스택의 크기는 컴파일러에 의존적인데 기본값은 windows 1MB, linux 8MB, max 512KB, JVM 1~2MB

  • Naked 함수: VC++에서만 사용할 수 있는 문법으로 32bit, arm에서만 사용가능한 프롤로그, 에필로그 없이 기능에 대해서만 컴파일된 코드이다. 필요하다면 어셈으로 작성해줘야 하고, 리턴도 직접 해줘야 한다.
    함수 앞에 __declspec(naked) 를 붙여주면 된다.

  • 인라인 어셈블리는 c 코드 사이에 어셈블리를 넣을 수 있는 문법이다. 컴파일러에 따라 asm, __asm__, __asm 등 사용 방법이 다르다.

  • 운영체제는 프로세스를 실행할때 CR3 레지스터에 물리메모리 매핑을 위한 페이지테이블 주소를 저장한다. 이 페이지 테이블은 프로세스마다 갖게 된다.

    • 프로세스가 실행되면서 메모리에 접근하려는 어셈 코드를 CPU가 실행하려 할 때 CPU는 MMU에게 가상주소에 맞는 물리주소를 가져오라고 지시한다.
    • MMU는 CR3을 참조해서 램의 커널메모리 공간에 있는 페이지테이블을 전적으로 신뢰하여 현재 요청 주소와 접근 권한을 확인해 실제 할당되어있는 페이지인지, 이 명령어를 실행시켜도 되는 권한이 있는지(사실 MMU입장에서는 회로상 어떤 권한을 요청했는지만 들어올것이다) 확인하고 페이지 폴트를 발생시킨다.
      • 페이지테이블은 가상 → 물리 매핑정보, 페이지 위치(Present bit. 0인경우 디스크, 1인경우 로드된상태), 접근권한, 추가 메타데이터가 저장된다.
      • 페이지 폴트는 MMU가 발생시키는 인터럽트가 아니라 Present bit 가 0인 경우 MMU가 CPU에게 페이지폴트가 발생했다고 플래그로 알려주고 CPU가 스스로 예외를 트리거해서 운영체제의 핸들러를 호출하는 구조이다.
    • 페이지 폴트가 발생되면 CPU가 운영체제의 페이지폴트 핸들러를 실행시킨다.
      • 만약 유효한 접근이라면 디스크의 이미지에서 페이지를 로드해서 물리메모리에 매핑하고 페이지테이블을 업데이트한다.
      • 만약 메모리가 찼다면 페이지 교체 알고리즘에 의해 결정된 기존 페이지를 물리메모리에서 제거한다.
      • 유효하지 않은 접근이라면(권한문제나 할당되지 않은 메모리접근) 운영체제 핸들러에서 세그멘테이션 폴트 시그널(SIGSEGV)을 프로세스에게 보내게 되어 기본적으로 종료되거나 자체 핸들링을 하게된다.
    • 페이지폴트가 처리가 완료되면 다시 프로세스가 계속해서 실행되게 된다. 이때 프로세스 입장에서는 커널에서 무슨일이 일어났는지 알지 못한다.
  • 컴파일러 최적화 옵션이 켜져있을때 컴파일러가 인지하지 못하는 코드가 있을 수 있다.
    아래 코드는 hello 와 같은 주소를 가리키는 newHello 위치에서 PAGE_READWRITE 권한을 주고 널문자를 중간에 삽입해서 두번째 hello 출력에서는 hell만 나오는게 맞다.
    최적화 옵션을 주고 실행하면 Hello World! 가 두번 찍히는데, 이런것처럼 컴파일러가 이해하지 못하는 옵션은 최적화될 때 원치 않는 코드로 변할 수 있다.

    1const char* hello = "Hello World!";
    2std::cout << hello;
    3
    4char* newHello = const_chast<char*>(hello);
    5DWORD dwOldProtect = 0;
    6VirtualProtect(newHello, 8, PAGE_READWRITE, &dwOldProtect);
    7newHello[4] = '\0';
    8
    9std::cout << hello;
    
  • CPU와 물리 메모리와 연결된 주소버스는 최적화를 위해 하위 3bit가 없기 때문에 워드단위로 fetch해올 수 밖에 없고, 프로그램에서 메모리 정렬을 해서 fetch에 들어가는 사이클을 줄이는 방식을 사용하고 있다.

    • 만약 0x10006 에 8byte짜리 데이터가 있다면, CPU는 0x10000과 0x10008 이렇게 두번의 fetch로 데이터를 가져올 것이다.
    • 그래서 비정렬을 선택하면 모든 데이터의 주소가 망가지기 때문에 주소가 걸쳐있을 가능성이 높아서 걸쳐있는 모든 데이터들은 fetch에 2사이클씩 걸린다.
  • pragma pack 키워드로 얼라인 단위를 마음대로 지정할 수 있다.

    1#pragma pack(push, 1)
    2typedef struct JUMP_CODE {
    3    BYTE opCode; 
    4    LPVOID targetAddr;
    5} JUMP_CODE;
    6#pragma pack(pop)
    
  • 원본 코드가 명령어캐시에 미리 캐싱되어 있을 수 있다. 윈도우의 경우 FlushInstructionCache API를 통해 초기화할 수 있다. 후킹한 이후에 한번 초기화 해주는게 좋다.

  • 연구 소스 : 런타임에 후킹코드를 생성하는 방식으로 점프코드를 작성해서 호출하면 디버거에서 레퍼런스를 확인할 수 있을까? -> 런타임 디버거는 어떰? 사실 함수 후킹하고 콜스택 확인하면 보일지도? 점프로도 보이나?


Hook #

IAT 훅 #

IAT? #

DLL 에서는 함수를 EAT에 노출시켜놓고, 사용하는 프로그램에서는 IAT에 가져다 쓴 함수에 대해서만 기록한다. 하지만 dlsym 처럼 동적으로 명시적 함수 호출하는 경우에는 IAT에 기록되지 않는다.

정적링킹 방식으로 DLL의 헤더를 가져와서 함수를 호출하는 코드를 작성하고 컴파일을 하면 컴파일러는 이 dll 함수가 있을 것이라는 생각으로 IAT 테이블을 이용할 생각을 하고있다.

plt got를 사용하는 ELF 파일에서는 지연링크 방식을 사용해서 동적링커의 리졸버를 통해 첫 호출때 채워주고, IAT 방식에서는 동적링커가 프로그램 처음 로드시에 채워넣는다.

방법 #

PE구조를 잘 알고있다면 전체 임포트 함수중에서 이름을 다찾아보고 주소를 알아낸다음 주소를 변경해버리면 된다.

한계 #

  • 인라인에 비해 안정적인 기법이지만, 정적 로딩 방식을 사용해야만 IAT에 기록되기 때문에 이 경우에만 후킹할 수 있음
  • IAT를 백업해서 오버라이트 됐는지 감지가 쉽다.

inline 훅 #

함수 내부에 점프 코드를 삽입시켜 원하는 코드를 실행시키도록 변경한다. 그만큼 코드가 손상되기 때문에 기존 명령을 실행하도록 다시 돌려줘야한다.

트램폴린을 삽입하는 방법 #

점프해서 바로 원하는 함수 루틴으로 이동시키면 스택프레임을 제대로 사용하기 어려워진다. 트램폴린용 네이키드 함수에서 call하는 방식으로 구현하면 스택도 자동으로 정리되고 원본코드를 실행하고 돌아가는것만 신경쓰면 된다.

API 시그니쳐를 맞춰서 후킹하는 방법 #

API 시그니쳐만 동일하게 맞춰주면 사용하는 스택은 동일하기 때문에 함수 초기에서 점프한다고 하더라도 리턴할때 같은 방법으로 스택을 정리하게된다.

64bit #

64bit VC++에서 어셈을 쓰려면 설정을 따로 해야하기 때문에 강의 확인

어셈 파일을 직접 만들고 어셈과 c++ 가 서로 함수를 콜을 할 수 있어야해서 c++에서 자동으로 만들어지는 함수의 네임 맹글링을 의도적으로 막아줘야 편하다.
함수앞에 extern "C" 키워드를 넣어줘야 한다.


DLL Injection #

다른 프로그램을 조사하기 위해 그 프로세스의 메모리에 강제로 주입하고 가상메모리 공간을 조작할 수 있도록 한다.

  • CreateRemoteThread() API 사용하는 방법
  • SetWindowsHookEx() API 사용하는 방법
  • 레지스트리 AppInit_DLLs 값을 변경해서 기본 라이브러리로 설정하는 방법

CreateRemoteThread() Injection #

이 함수는 다른 프로세스 안에서 스레드를 생성하는 기능이기 때문에 실행하려면 권한이 있어야한다.

  1. (target) 타겟 API를 사용하는 라이브러리를 이미 올려놨다.
  2. (Injector) EnumProcesses() 또는 CreateToolhelp32Snapshot() 로 타겟 PID 획득
  3. (Injector) GetModuleHandle(), GetProcAddress() 로 LoadLibrary 코드주소 획득. Injector의 주소에서 얻었지만 후킹할 Kernel32.dll은 공유 라이브러리라 시스템에서 모든 프로세스가 주소가 같다.
  4. (Injector) OpenProcess() 로 타겟 프로세스 핸들을 얻어서 VirtualAllocEx()로 메모리 공간 할당
  5. (Injector) 후킹 라이브러리를 로드하게 하려면 어떤 경로에 있는지를 알아야 하기 때문에 WriteProcessMemory()로 “hook.dll” 경로 문자열을 타겟의 가상메모리에 작성해둔다.
  6. (Injector) CreateRemoteThread() 로 LoadLibrary 를 호출해서 hook.dll을 로드한다.
  7. (hook.dll) 이제 로드가 됐으니 init_array나 DLL_ONLOAD 이벤트가 실행되면서 후킹 코드가 실행된다.

Ejection 할때는 그냥 FreeLibrary() 를 호출하면서 hook.dll에서 UnHook 코드를 실행시키면 된다.

가짜방화벽 #

그냥 전체 프로세스 enumeration 해서 dll 을 인젝션 하고 dll 에서는 로드되면 검사하는 코드를 작성하면 된다.
이 방법으로 하면 권한이 부족할 수 있는 경우가 있는데 MS에서 방화벽을 만들려면 권장하는 방법들이 있다 SPI, 커널모드 NIC 드라이버 쯤에서

comments powered by Disqus