널자의 Windows API Hook
사전지식
-
32bit 시스템에서 함수 포인터를 사용할 땐 호출하려고 하는 함수의 호출규약까지 적어야 한다. 적지 않으면 c/c++의 호출규약으로 생각해서 런타임 중 함수 호출때 문제가 발생한다.
환경 32bit 64bit C/C++ cdecl 운영 체제 표준 방식 사용 Windows stdcall Microsoft x64 Linux cdecl System V AMD64 ABI -
switch-case를 사용해서 함수를 분기시키는 경우 성능상 좋지 않아서 시그니쳐가 같다면 함수 포인터 배열로 룩업 테이블 형식으로 구현하는게 성능상 좋다.
int(__cdecl *lookupTable[3])(int, int) = { func1, func2, func3 } do { scanf_s("%d%*c", &input); if (0 < input && input < 4) lookupTable[input - 1](1, 2); } 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! 가 두번 찍히는데, 이런것처럼 컴파일러가 이해하지 못하는 옵션은 최적화될 때 원치 않는 코드로 변할 수 있다.const char* hello = "Hello World!"; std::cout << hello; char* newHello = const_chast<char*>(hello); DWORD dwOldProtect = 0; VirtualProtect(newHello, 8, PAGE_READWRITE, &dwOldProtect); newHello[4] = '\0'; std::cout << hello; -
CPU와 물리 메모리와 연결된 주소버스는 최적화를 위해 하위 3bit가 없기 때문에 워드단위로 fetch해올 수 밖에 없고, 프로그램에서 메모리 정렬을 해서 fetch에 들어가는 사이클을 줄이는 방식을 사용하고 있다.
- 만약 0x10006 에 8byte짜리 데이터가 있다면, CPU는 0x10000과 0x10008 이렇게 두번의 fetch로 데이터를 가져올 것이다.
- 그래서 비정렬을 선택하면 모든 데이터의 주소가 망가지기 때문에 주소가 걸쳐있을 가능성이 높아서 걸쳐있는 모든 데이터들은 fetch에 2사이클씩 걸린다.
-
pragma pack 키워드로 얼라인 단위를 마음대로 지정할 수 있다.
#pragma pack(push, 1) typedef struct JUMP_CODE { BYTE opCode; LPVOID targetAddr; } JUMP_CODE; #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
이 함수는 다른 프로세스 안에서 스레드를 생성하는 기능이기 때문에 실행하려면 권한이 있어야한다.
- (target) 타겟 API를 사용하는 라이브러리를 이미 올려놨다.
- (Injector) EnumProcesses() 또는 CreateToolhelp32Snapshot() 로 타겟 PID 획득
- (Injector) GetModuleHandle(), GetProcAddress() 로 LoadLibrary 코드주소 획득. Injector의 주소에서 얻었지만 후킹할 Kernel32.dll은 공유 라이브러리라 시스템에서 모든 프로세스가 주소가 같다.
- (Injector) OpenProcess() 로 타겟 프로세스 핸들을 얻어서 VirtualAllocEx()로 메모리 공간 할당
- (Injector) 후킹 라이브러리를 로드하게 하려면 어떤 경로에 있는지를 알아야 하기 때문에 WriteProcessMemory()로 “hook.dll” 경로 문자열을 타겟의 가상메모리에 작성해둔다.
- (Injector) CreateRemoteThread() 로 LoadLibrary 를 호출해서 hook.dll을 로드한다.
- (hook.dll) 이제 로드가 됐으니 init_array나 DLL_ONLOAD 이벤트가 실행되면서 후킹 코드가 실행된다.
Ejection 할때는 그냥 FreeLibrary() 를 호출하면서 hook.dll에서 UnHook 코드를 실행시키면 된다.
가짜방화벽
그냥 전체 프로세스 enumeration 해서 dll 을 인젝션 하고 dll 에서는 로드되면 검사하는 코드를 작성하면 된다.
이 방법으로 하면 권한이 부족할 수 있는 경우가 있는데 MS에서 방화벽을 만들려면 권장하는 방법들이 있다 SPI, 커널모드 NIC 드라이버 쯤에서
Comments