Security - Real World/Reversing

Windows Kernel Hooking - I

PS리버싱마크해커박종휘TV 2024. 1. 4. 04:00

오랜만에 글 올린다. 디미고에 들어간답시고 블로그를 2년동안 방치해서 지금 휴면 계정 상태에 상주한 계정을 끌고 나왔다.

 

디미고에서의 생활은 처음엔 힘들었다.. 기숙사도 적응 안되고, 새로운 친구들과 고등학교라는 새로이 등장한 관문이 나를 시련으로 몰고 갔다. 하지만 꿋꿋이 1년을 버텨내고 결국엔 2학년이 되었다.

 

만약에 디미고에 합격해서 디미고 태그 신나게 쳐서 들어온 친구들이면 정말 미안하지만 당신은 99.81% 확률로 디미고에 들어간 것을 후회하게 될 것이다... 특히 이 글의 제목을 보고 흥미를 느껴서 들어온 친구들이면 (100 - 0.1^100)% 확률로 후회하게 될 것이다.

 

본론으로 들어가보자. 디미고 2학년이 되었는데 진짜 아무것도 한 게 없는 것 같아서 방학에 무언가를 하고 있다. 이 모든 과정은 단 하나의 목표, BattlEye를 뚫기 위해 배우는 것이다.

 

우선 한 번도 커널 모드 후킹을 해본 적이 없으니, 조사에 들어간다. 우리는 유저 모드 함수 OpenProcess의 커널 함수를 후킹하여 반환되는 핸들을 모두 INVALID_HANDLE_VALUE로 바꿀 것이다.

 

조사에 착수한다. 샘플 프로그램을 제작한 뒤, OpenProcess의 Trace를 조사한다.

 

#include <Windows.h>
#include <cstdio>
#include <Psapi.h>
#include <Tlhelp32.h>

int main() {
    if (OpenProcess(PROCESS_ALL_ACCESS, FALSE, 6974) != INVALID_HANDLE_VALUE) {
        printf("Success!\n");
    }
    else {
        printf("You failed!\n");
    }
    return 0;
}

 

Trace를 조사하기 위해 다양한 리버싱 도구들을 사용할 수 있겠지만, Windows 프로그램에 가장 최적화된 디버거인 WinDbg를 사용하여 조사할 것이다.

 

Figure 1. Research

 

kernel32.dll의 OpenProcess 함수는 ntdll의 NtOpenProcess를 호출한다. 여기서 NtOpenProcess는 일종의 syscall을 하는데, 이것이 유저 모드 프로그램이 커널에게 처리 요청을 쿼리하는 것이라 할 수 있겠다.

 

ntdll!NtOpenProcess는 커널 모드의 중추 ntoskrnl.exe의 nt!NtOpenProcess를 호출한다. ZwOpenProcess와 도중에 헷갈렸는데, 커널 모드 모듈에서 호출하는 OpenProcess는 대개 ZwOpenProcess고, 유저 모드 프로세스에서 호출하는 OpenProcess는 대개 NtOpenProcess이다.

 

따라서 우리는 nt!NtOpenProcess를 후킹하는 것이 목표이다. 우선 나의 본 컴퓨터에서 실험하는 것은 매우 위험하므로 VM 환경에서 실행할 것이다.

 

커널 모드로 진입한 뒤 명령어를 실행하기 위해 커널 드라이버를 개발하는 방향으로 진행한다. 다음 단계를 수행해 Visual Studio에서 커널 드라이버를 개발할 수 있는 환경을 마련한다. 마치게 되면 ntddk.h 및 여러 헤더 파일에 마이크로소프트가 배포한 커널 함수가 정의되어 있는데, 이게 일부분이라서 몇 가지 함수를 손수로 정의해야 한다.

 

#pragma once
#include <ntdef.h>
#include <ntifs.h>
#include <ntddk.h>
#include <windef.h>
#include <ntstrsafe.h>
#include <wdm.h>


typedef enum _SYSTEM_INFORMATION_CLASS
{
	SystemBasicInformation,
	SystemProcessorInformation,
	SystemPerformanceInformation,
	SystemTimeOfDayInformation,
	SystemPathInformation,
	SystemProcessInformation,
	SystemCallCountInformation,
	SystemDeviceInformation,
	SystemProcessorPerformanceInformation,
	SystemFlagsInformation,
	SystemCallTimeInformation,
	SystemModuleInformation = 0x0B
} SYSTEM_INFORMATION_CLASS, * PSYSTEM_INFORMATION_CLASS;

typedef struct _RTL_PROCESS_MODULE_INFORMATION
{
	HANDLE Section;
	PVOID MappedBase;
	PVOID ImageBase;
	ULONG ImageSize;
	ULONG Flags;
	USHORT LoadOrderIndex;
	USHORT InitOrderIndex;
	USHORT LoadCount;
	USHORT OffsetToFileName;
	UCHAR FullPathName[256];
} RTL_PROCESS_MODULE_INFORMATION, * PRTL_PROCESS_MODULE_INFORMATION;

typedef struct _RTL_PROCESS_MODULES
{
	ULONG NumberOfModules;
	RTL_PROCESS_MODULE_INFORMATION Modules[1];
} RTL_PROCESS_MODULES, * PRTL_PROCESS_MODULES;

extern "C" __declspec(dllimport)
NTSTATUS NTAPI ZwProtectVirtualMemory(
	HANDLE ProcessHandle,
	PVOID * BaseAddress,
	PULONG ProtectSize,
	ULONG NewProtect,
	PULONG OldProtect
);

extern "C" NTKERNELAPI
PVOID NTAPI RtlFindExportedRoutineByName(
	_In_ PVOID ImageBase,
	_In_ PCCH RoutineName
);

extern "C"
NTSTATUS ZwQuerySystemInformation(
	ULONG InfoClass,
	PVOID Buffer,
	ULONG Length,
	PULONG ReturnLength
);

extern "C" NTKERNELAPI
PPEB PsGetProcessPeb(
	IN PEPROCESS Process
);

extern "C"
NTSTATUS NTAPI MmCopyVirtualMemory(
	PEPROCESS SourceProcess,
	PVOID SourceAddress,
	PEPROCESS TargetProcess,
	PVOID TargetAddress,
	SIZE_T BufferSize,
	KPROCESSOR_MODE PreviousMode,
	PSIZE_T ReturnSize
);

 

이와 같이 선언만 해도 되는 이유는 WDK에서 자동으로 NT 커널(ntoskrnl.exe)과 링킹 시켜주어서 그 안의 함수를 모두 사용할 수 있게 하기 때문이다. 따라서 우리는 그저 선언만 하면 된다.

 

이제 훅을 걸기 위해 메모리 작업을 할 헤더도 추가하자. 커널 내 함수는 대부분 ReadOnly 이므로 영역 보호를 해제하고 패치하는 함수를 따로 만들어야 한다.

 

#pragma once
#include "def.h"

PVOID KeGetSystemModuleBase(const char* mName) {
	ULONG bytes = 0;
	NTSTATUS status = ZwQuerySystemInformation(SystemModuleInformation, NULL, bytes, &bytes);
	DbgPrintEx(0, 0, "%x, %d", status, bytes);

	if (!bytes) return NULL;

	PRTL_PROCESS_MODULES modules = (PRTL_PROCESS_MODULES)ExAllocatePoolWithTag(NonPagedPool, bytes, 0xdeadbeef);
	PRTL_PROCESS_MODULE_INFORMATION module_arr = modules->Modules;

	status = ZwQuerySystemInformation(SystemModuleInformation, modules, bytes, &bytes);
	DbgPrintEx(0, 0, "%x", status);

	if (!NT_SUCCESS(status)) return NULL;

	DbgPrintEx(0, 0, "%x", modules->NumberOfModules);

	PVOID module_base = 0;

	for (ULONG i = 0; i < modules->NumberOfModules; i++) {
		DbgPrintEx(0, 0, "%s", module_arr[i].FullPathName);
		if (!strcmp((char*)module_arr[i].FullPathName, mName)) {
			module_base = module_arr[i].ImageBase;
		}
	}

	if (modules) ExFreePoolWithTag(modules, 0xdeadbeef);
	if (module_base <= 0) return NULL;

	return module_base;
}

PVOID KeGetSystemModuleExport(const char* mName, LPCSTR rName) {
	PVOID lp_module = KeGetSystemModuleBase(mName);
	if (!lp_module) return NULL;
	DbgPrintEx(0, 0, "Success!1");
	return RtlFindExportedRoutineByName(lp_module, rName);
}

bool WriteMemory(void* addr, void* buf, size_t sz) {
	return RtlCopyMemory(addr, buf, sz);
}

bool WriteMemoryRO(void* addr, void* buf, size_t sz) {
	PMDL mdl = IoAllocateMdl(addr, sz, FALSE, FALSE, NULL);

	if (!mdl) return false;
	
	MmProbeAndLockPages(mdl, KernelMode, IoReadAccess);
	PVOID mapping = MmMapLockedPagesSpecifyCache(mdl, KernelMode, MmNonCached, NULL, FALSE, NormalPagePriority);
	MmProtectMdlSystemAddress(mdl, PAGE_READWRITE);

	WriteMemory(mapping, buf, sz);

	MmUnmapLockedPages(mapping, mdl);
	MmUnlockPages(mdl);
	IoFreeMdl(mdl);

	return true;
}

 

중간중간 DbgPrintEx 함수가 보이는데, 이는 내가 버그를 잡으려고 삽질한 흔적이라고 볼 수 있다. DebugView를 통해 볼 수 있다.

 

마지막으로 후킹을 관리하는 함수와 후킹할 함수를 정의하는 헤더를 생성한다.

 

#pragma once
#include "mem.h"

bool CallKernelFunction(void* addr) {
	if (!addr) return false;
	PVOID* function = reinterpret_cast<PVOID*>(KeGetSystemModuleExport(
		"\\SystemRoot\\system32\\ntoskrnl.exe",
		"NtOpenProcess"));
	DbgPrintEx(0, 0, "%p", function);

	if (!function) return false;

	BYTE orig[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
	BYTE sc_s[] = { 0x48, 0xB8 };
	BYTE sc_e[] = { 0xFF, 0xE0 };

	RtlSecureZeroMemory(&orig, sizeof(orig));
	memcpy((PVOID)((ULONG_PTR)orig), &sc_s, sizeof(sc_s));
	uintptr_t hook_addr = reinterpret_cast<uintptr_t>(addr);
	memcpy((PVOID)((ULONG_PTR)orig + sizeof(sc_s)), &hook_addr, sizeof(void*));
	memcpy((PVOID)((ULONG_PTR)orig + sizeof(sc_s) + sizeof(void*)), &sc_e, sizeof(sc_e));
	WriteMemoryRO(function, &orig, sizeof(orig));

	return true;
}

NTSTATUS HookFunction(
	OUT PHANDLE ProcessHandle,
	IN ACCESS_MASK DesiredAccess,
	IN POBJECT_ATTRIBUTES ObjectAttributes,
	IN PCLIENT_ID ClientId OPTIONAL) {

	DbgPrintEx(0, 0, "NtOpenProcess Called!");
	*ProcessHandle = (HANDLE)-1;
	return STATUS_SUCCESS;
}

 

후킹 방식은 다음과 같다.

 

mov rax, HookFunction
jmp rax

 

이제 드라이버 메인을 작성해 테스트를 하면 된다.

 

#include "hook.h"

extern "C" NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT driver_object, PUNICODE_STRING reg_path) {
	UNREFERENCED_PARAMETER(driver_object);
	UNREFERENCED_PARAMETER(reg_path);
	
	DbgPrintEx(0, 0, "Can you hear me?");
	if (CallKernelFunction(HookFunction)) {
		DbgPrintEx(0, 0, "Success!!!!!!WOW!!!!!!");
	}
	else {
		DbgPrintEx(0, 0, "L");
	}
	return STATUS_SUCCESS;
}

 

실제로 빌드하여 kdmapper를 이용해 드라이버를 로딩할 경우 성공한다. 하지만 예상대로 OpenProcess가 먹통이 되는 바람에 운영체제의 핵심 유틸리티들이 동작하지 못하게 되어 정확한 결과는 얻을 수 없었다. 다음과 같이 테스트할 프로그램도 못하는 것을 알 수 있다. 심지어 전원을 끄는 것도 제대로 못했다..

 

Figure 2. Wtf

 

하지만 이런 결과를 보니 후킹은 성공적이라고 볼 수 있다.

 

다음 글에서는 이런 후킹 오류를 방지하고, 프로세스의 PID를 검사해 검열하는 '정상적인' 후킹을 만들고, 이를 우회해 보겠다. 아직 해보진 않았다. 하는 방법을 알아내야 한다...

'Security - Real World > Reversing' 카테고리의 다른 글

Windows Kernel Hooking - Part 3  (2) 2024.12.06
Windows Kernel Hooking - II-1  (1) 2024.01.10
Windows Kernel Hooking - II  (1) 2024.01.08
Windows Kernel Hooking - I-1  (1) 2024.01.06