Rootkit Development

[0x00] Concept

대부분의 보안 엔지니어들은 본인이 사용할 도구들을 직접 개발하여 사용하는 경우가 많습니다.

이번엔 나름대로 큰 프로젝트로 PC HunterWKE 와 같은 루트킷과 같은 전용 분석 도구를 개발하기로 하였습니다.

개인적인 개발 프로젝트이며 공개 시 악용될 여지가 있으므로 해당 포스트의 소스코드 및 프로그램은 제공되지 않습니다.

[0x01] Features

  1. Process View

    • System Module List(PsLoadedModuleList 이용)
    • Process List(핸들 테이블 이용)
    • Process Module List(LdrDataTable 이용)
    • Process, Module, Kernel Driver Dump(Raw 데이터를 이용한 파일 덤프)

    • Process & Module View
    • Dump View
  2. Monitor View

    • 특정 프로세스의 IO 모니터링
      • 실행 될 프로세스에 대한 전체적인 모니터링을 위한 기능
    • 실행 중인 특정 프로세스의 IO 모니터링
    • 모든 프로세스의 IO 모니터링
    • Operation 별 필터링 기능
    • Monitor View

[0x02] Details

동작 방식의 경우 시스템에 영향이 있는 기능들의 경우, 커널에서 데이터만 가져와 유저 애플리케이션에서 처리하도록 설계되어있습니다.

[-] User Application

GUI 프로그래밍을 위해 QT를 사용하였습니다. VS 내 확장 기능으로 제공되기 때문에 개발하기 편리하기 때문입니다.

유저 애플리케이션의 경우 커널 드라이버와 통신을 통해 데이터를 받고 이를 처리하게 되어 있습니다.

특이사항으로는 미니필터 기능이 존재하기 때문에 레지스트리 값을 직접 작성하는 코드가 삽입되어 있습니다.

bool Service::SetFltRegistry()
{
	// HKLM\\SYSTEM\\CurrentControlSet\\Services\\<ServiceName>\\Instances\\ShHelper Instance
	HKEY RegKey = { 0, };
	LSTATUS Status = RegCreateKeyEx(HKEY_LOCAL_MACHINE, SUB_ALL_KEY, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_CREATE_SUB_KEY, nullptr, &RegKey, nullptr);
	if (Status == ERROR_SUCCESS)
	{
		if (
			SetRegistryValue(SUB_KEY_HEAD, "DefaultInstance", REG_SZ, FLT_INSTANCE, 0) &&
			SetRegistryValue(SUB_ALL_KEY, "Altitude", REG_SZ, "398000", 0) &&
			SetRegistryValue(SUB_ALL_KEY, "Flags", REG_DWORD, nullptr, 0)
			)
		{
			RegCloseKey(RegKey);
			return true;
		}

	}
	RegCloseKey(RegKey);
	return false;
}

대부분의 기능은 커널 드라이버에서 제공되므로 짧게 마칩니다.

[-] Kernel Driver

먼저 기능 중 프로세스 및 커널에 대한 정보를 획득하기 위해 DeviceIoControl 을 이용하였습니다. 이에 대한 MajroFunction 구현은 아래와 같습니다.

NTSTATUS ShDrvIo::DeviceIoControlEx(IN OUT PIRP Irp)
{
	NTSTATUS Status = STATUS_SUCCESS;
	PIO_STACK_LOCATION IoStackLocation = nullptr;
	IoStackLocation = IoGetCurrentIrpStackLocation(Irp);

	ULONG CtrlCode = IoStackLocation->Parameters.DeviceIoControl.IoControlCode;
	ULONG MethodType = CtrlCode & 0xFF;

	if (MethodType != METHOD_BUFFERED)
	{
		ErrLog("Not allowed method\\n");
		Status = STATUS_NOT_IMPLEMENTED;
		IoCompleteRoutine(Irp, Status, 0);
		return Status;
	}

	ULONG ControlType = CtrlCode << 0x10 >> 0x10 >> 2;

	switch (ControlType)
	{

	case REQ_PROCESS_LIST:
	{
		IoGetProcessList(Irp);
		break;
	}

	case REQ_MODULE_LIST:
	{
		IoGetModuleList(Irp);
		break;
	}

	case REQ_DUMP:
	{
		IoGetDump(Irp);
		break;
	}

	default:
	{
		break;
	}

	}
	return Status;
}

IoGetProcessList

해당 함수는 프로세스 정보를 획득하여 유저 애플리케이션으로 전달하는 역할을 합니다. 크게 3개의 동작으로 이루어져 있습니다.

  1. 프로세스 기본 정보
  2. 프로세스의 파일 링크명
  3. 프로세스의 PEB 내 프로세스 명
VOID ShIoProcess::GetProcessList(SH_IO_PROCESS* ProcessInformation)
{
	GetProcessInformationFromHandle(ProcessInformation);
	if (ProcessInformation[0].Count == 0)
	{
		ErrLog("Process count zero\\n");
		return;
	}
	GetProcessNameFromPeb(ProcessInformation);
	return;
}

첫 번째로 프로세스의 대부분의 정보는 시스템 내 핸들을 모두 확인하여 획득합니다.(GetProcessInformationFromHandle)

Handle Table&Object 내용을 참조할 수 있습니다.

NtQuerySystemInformationSYSTEM_INFORMATION_CLASSSystemExtendedHandleInformation 으로 쿼리하면 현재 시스템에 존재하는 모든 핸들에 대한 정보를 획득할 수 있습니다.

이러한 경우 EPROCESSActiveProcessLinks 를 조작하는 DKOM 기법을 이용한 은닉 프로세스까지 탐지할 수 있습니다.

// VOID ShIoProcess::GetProcessInformationFromHandle(SH_IO_PROCESS* ProcessInformation)
...
	PSYSTEM_HANDLE_INFORMATION_EX HandleInformation;
	HandleInformation = (PSYSTEM_HANDLE_INFORMATION_EX)Buff;
	int pid = 0, prepid = 0, count = 0;

	for (int i = 0; i < (DWORD64)HandleInformation->NumberOfHandles; i++)
	{
		PEPROCESS Process = nullptr;
		pid = (DWORD64)HandleInformation->Handles[i].UniqueProcessId;
		if (pid == prepid) { continue; }
		else
		{
			prepid = pid;
			if (NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)pid, &Process)))
			{
				if (IsExitProcess(Process) == false)
				{
					ProcessInformation[count].Object = Process;
					ProcessInformation[count].ProcessId = pid;
					ProcessInformation[count].b32bit = Is32bitProcess(Process);
					ProcessInformation[count].Peb = g_ShObj.PsGetProcessPeb(Process);
					ProcessInformation[count].ImageBase = g_ShObj.PsGetProcessSectionBaseAddress(Process);
					ProcessInformation[count].bAbnormal = IsAbnormalProcess(Process);
					if (HandleInformation->Handles[i].UniqueProcessId == (HANDLE)4)
					{
						ProcessInformation[count].bSystem = true;
						ProcessInformation[count].ImageBase = g_ShObj.SystemBaseAddress;
						strcpy(ProcessInformation[count].FullPath, "C:\\\\Windows\\\\System32\\\\ntoskrnl.exe");
						strcpy(ProcessInformation[count].LinkName, "ntoskrnl.exe");
					}
					else
					{
						char LinkBuff[MAX_PATH] = { 0, };
						GetProcessLinkName(Process,LinkBuff);
						strcpy(ProcessInformation[count].LinkName, LinkBuff);
					}
					count++;
				}
			}
...

위에서 사용된 GetProcessLinkName 의 경우 프로세스의 파일 오브젝트를 이용하여 하드 링크 이름을 가져옵니다.

해당 내용은 Find Hidden Process 에서 확인 가능합니다.

IoGetModuleList

해당 함수는 특정 프로세스의 모듈 정보를 획득하고 이를 유저 애플리케이션에 전달하는 역할입니다. 크게 2개의 동작으로 이루어져 있습니다.

  1. 시스템 모듈 정보 획득
  2. 프로세스 내 모듈 정보 획득
VOID ShIoProcess::GetModuleList(SH_IO_MODULE* ModuleInformation, HANDLE Pid)
{
	if (ShDrvUtil::IsValid(ModuleInformation) == false)
	{
		ErrLog("Invalid buffer\\n");
		return;
	}
	if (Pid == (HANDLE)4) { GetSystemModuleInformation(ModuleInformation); return; }
	PEPROCESS Process = nullptr;
	if (!NT_SUCCESS(PsLookupProcessByProcessId(Pid, &Process))) { ErrLog("GetModuleList Failed\\n"); return; }
	if (Is32bitProcess(Process))
	{
		PPEB32 Peb32 = nullptr;
		PEWOW64PROCESS* Wow64Process = (PEWOW64PROCESS*)ShDrvUtil::CalcOffset(Process, g_ShObj.EPROCESS_Wow64Process, false);
		Peb32 = (*Wow64Process)->Peb;
		GetModuleInformationFromPeb32(ModuleInformation, Process, Peb32);
	}
	PPEB Peb = g_ShObj.PsGetProcessPeb(Process);
	GetModuleInformationFromPeb(ModuleInformation, Process, Peb);

	return;
}

먼저 프로세스 내 모듈을 확인하는 것은 PEB 정보를 이용하여 LdrTable 을 순회합니다.

VOID ShIoProcess::GetModuleInformationFromPeb(SH_IO_MODULE* ModuleInformation, PEPROCESS Process, PEB* Peb)
{
	if (ShDrvUtil::IsValid(ModuleInformation) == false)
	{
		ErrLog("Invalid buffer\\n");
		return;
	}
	KAPC_STATE ApcState = { 0, };
	ULONG count = ModuleInformation[0].Count;
	KeStackAttachProcess(Process, &ApcState);
	__try {
		ProbeForRead(Peb, sizeof(PEB), 1);
		PLIST_ENTRY ModuleListHead = &Peb->Ldr->InLoadOrderModuleList;
		PLIST_ENTRY Temp = ModuleListHead->Flink;
		while (Temp != ModuleListHead)
		{
			LDR_DATA_TABLE_ENTRY* LdrEntry = (LDR_DATA_TABLE_ENTRY*)Temp;
			wcstombs(ModuleInformation[count].ModuleName, LdrEntry->BaseDllName.Buffer, LdrEntry->BaseDllName.Length);
			wcstombs(ModuleInformation[count].FullPath, LdrEntry->FullDllName.Buffer, LdrEntry->FullDllName.Length);
			ModuleInformation[count].Address = LdrEntry->DllBase;
			ModuleInformation[count].Size = LdrEntry->SizeOfImage;
			ModuleInformation[count].b32bit = false;
			count++;
			Temp = Temp->Flink;
		}
	}
	__except (EXCEPTION_EXECUTE_HANDLER) {
		ErrLog("Can't read peb memory\\n");
	}
	KeUnstackDetachProcess(&ApcState);
	ModuleInformation[0].Count += count;
	return;
}

Windows 10 부터는 PsLoadedModuleListEXPORT 되기 때문에 시스템 모듈 정보를 획득하는데 용이하였습니다.

// VOID ShIoProcess:GetSystemModuleInformation(SH_IO_MODULE* ModuleInformation)
...
  if (ShDrvUtil::IsValid(g_ShObj.PsLoadedModuleList) == FALSE) { ErrLog("Invalid Loaded Module List\\n"); return; }
	PLIST_ENTRY LoadedList = g_ShObj.PsLoadedModuleList;
	PLIST_ENTRY Head = LoadedList;
	PLIST_ENTRY Temp = LoadedList->Flink;
	int index = 0;
	while (Temp != Head)
	{
		PLDR_DATA_TABLE_ENTRY DataEntry = (PLDR_DATA_TABLE_ENTRY)Temp;
		wcstombs(ModuleInformation[index].ModuleName, DataEntry->BaseDllName.Buffer, DataEntry->BaseDllName.Length);
		wcstombs(ModuleInformation[index].FullPath, DataEntry->FullDllName.Buffer, DataEntry->FullDllName.Length);
		ModuleInformation[index].Address = DataEntry->DllBase;
		ModuleInformation[index].Size = DataEntry->SizeOfImage;
		index++;
		Temp = Temp->Flink;
	}
...

최대한 문서화 되지 않은 구조는 사용하지 않으려고 하였습니다. 현재 사용된 문서화 되지 않은 구조들의 경우 Windows 7 부터 현재까지 변한 적 없는 구조에 대해서만 사용했습니다.

IoGetDump

가상 메모리에 매핑된 프로세스나 모듈의 메모리를 유저 애플리케이션으로 전달하는 역할을 합니다. 2개의 동작으로 이루어져 있습니다.

  1. 프로세스 or 모듈 덤프 사이즈 획득
  2. 프로세스 or 모듈 덤프

필요한 덤프 사이즈의 경우 간단한 로직으로 이루어져 있습니다.

// DWORD64 ShDumper::GetDumpSizeFromHeader(PIMAGE_NT_HEADERS NtHeader)
...
    ULONG NumberOfSection = NtHeader->FileHeader.NumberOfSections;
		ULONG SizeOfOptHeader = NtHeader->FileHeader.SizeOfOptionalHeader;
		PIMAGE_SECTION_HEADER SectionHeader = (PIMAGE_SECTION_HEADER)ShDrvUtil::CalcOffset(&NtHeader->OptionalHeader, SizeOfOptHeader, false);
		ReturnSize = SectionHeader[NumberOfSection - 1].VirtualAddress + SectionHeader[NumberOfSection - 1].Misc.VirtualSize;
...
ReturnSize = SetAlignment(ReturnSize, PAGE_SIZE);
...

마지막 섹션의 VirtualAddress 와 해당 섹션의 VirtualSize 를 더하면 사용하는 가상 주소의 최대 크기를 구할 수 있습니다. 명확하게 하기 위해 Alignment 를 맞추었습니다.

해당 크기를 구했으면 프로세스나 모듈의 Base Address 에서부터 복사하면 끝입니다.

다만 여기에서 주의할 점은 주소 공간별 보호 수준이 다르단 것 입니다. 때문에 저는 여기서 Page Size 만큼 복사를 시도했습니다. 위의 Alignment 를 맞춘 이유가 바로 이것 입니다.

// VOID ShDumper::GetDump(SH_IO_DUMP* DumpData)
...
for (int i = 0; i < DumpSize; i += PAGE_SIZE)
	{
		PVOID TargetAddress = (PVOID)ShDrvUtil::CalcOffset(BaseAddress, i, false);
		PVOID UserMemory = (PVOID)ShDrvUtil::CalcOffset(DumpData, i, false);
		NTSTATUS Status = g_ShObj.MmCopyVirtualMemory(
			Process, 
			TargetAddress, 
			PsGetCurrentProcess(), 
			UserMemory, 
			PAGE_SIZE, KernelMode, &ret);

		if (Status == STATUS_PARTIAL_COPY)
		{
			Status = GetPageGuardMemory(TargetAddress, UserMemory, Process, Pid);
			if (!NT_SUCCESS(Status)) {
				ShDrvUtil::NtErrorHandler(":GetPageGuardMemory:", Status);
			}
		}
	}
...

PAGE_GUARD는 말 그대로 커밋된 메모리 페이지를 보호하기 위한 보호 수준입니다. 이는 보통 소프트웨어를 보호하기 위해 사용되는 보호 수준 중 하나입니다.

커널은 우리가 책임질 수 있는 한 모든 행위가 가능한 영역입니다. NtProtectVirtualMemory 를 이용하여 페이지 가드를 임시로 해제하고 메모리를 복사 후 보호 수준을 복구하였습니다.

이러한 과정을 거치면 아직 미완성 된 메모리 덤프를 획득할 수 있습니다.

오픈소스로 훌륭한 덤프에 대한 가이드가 존재하며 본인은 ScyllaHide 를 참조하였습니다.

아래의 그림에서 확인할 수 있듯이 파일 덤프 형태로 존재하며, 아이콘과 같은 리소스까지 정리된 모습을 볼 수 있습니다.(단순 메모리 덤프에서는 이와 같은 정보가 처리되지 않습니다. PE Fix 참조)

Process Monitor

실제 SysinternalsProcess Monitor와 같이 미니필터 기능이 존재합니다.

유저 애플리케이션과 통신을 위해 사용된 API들은 아래와 같습니다.

// User-Application
FilterConnectCommunicationPort
CreateIoCompletionPort
FilterSendMessage
FilterReplyMessage
FilterGetMessage
GetQueuedCompletionStatus

// Kernel-Driver
FltRegisterFilter
FltUnregisterFilter
FltBuildDefaultSecurityDescriptor
FltCreateCommunicationPort
FltCloseCommunicationPort
FltFreeSecurityDescriptor
FltStartFiltering
FltGetDiskDeviceObject
IoVolumeDeviceToDosName // PASSIVE_LEVEL
FltSendMessage

IoVolumeDeviceToDosName 의 경우 볼륨의 레이블을 구하기 위해 사용되었습니다.(ex. C:\, D:\ …)

이를 위해 필터 오브젝트 내 볼륨(FLT_VOLUME)을 이용하여 해당 볼륨의 디바이스 오브젝트를 구하고, 이를 이용해 볼륨의 레이블을 구할 수 있습니다.

// BOOLEAN ShMon::SendPreFilterMessage(IN PFLT_CALLBACK_DATA Data, IN PCFLT_RELATED_OBJECTS FltObjects, IN OperationFlag Flags)
...
					Status = FltGetDiskDeviceObject(FltObjects->Volume, &VolumeDeviceObj);
					if (NT_SUCCESS(Status))
					{
						Status = IoVolumeDeviceToDosName(VolumeDeviceObj, &VolumeLetter);
					}
...

여기서 주의할 점은 IoVolumeDeviceToDosName 의 경우 PASSIVE_LEVEL 에서만 동작하기 때문에 반드시 IRQL 을 확인하여 처리해야 합니다.

[0x03] Conclusion

코드를 공개하기에 아직 많은 고민을 하고 있습니다. 해당 루트킷의 기능 업데이트는 지속적으로 공유할 예정입니다.

아래와 같은 기능들을 순서대로 목표로 진행하고 있습니다.

  1. 프로세스 모니터링 중 특정 파일에 대한 행위가 발생했을 때 해당 파일의 처리 및 덤프
  2. 오브젝트 콜백에 대한 제어
  3. Code Integrity 모듈 관련 제어
  4. 커널 메모리 에디터 및 디스어셈블리 엔진 적용
  5. KPP 비활성화

[0x04] Reference

  1. ScyllaHide
  2. Windows Driver Sample::avscan
  3. Windows Driver Sample::minispy