The technique
MAP_SHARED file mappings don't go to disk immediately. Writes go to the page cache. The file gets updated later, when the kernel feels like writing back dirty pages.
If you write shellcode to a MAP_SHARED mapping and then another process mmaps the same file MAP_SHARED at the same offset, the kernel gives it the dirty page cache page, not the clean file from disk. If the second mapping is PROT_EXEC, the shellcode executes.
The file on disk is never touched by the injection. /proc/pid/maps shows a normal file-backed mapping.
The PoC
static const unsigned char shellcode[] = {
0x6a, 0x2a, /* push 42 */
0x5f, /* pop rdi */
0x48, 0x31, 0xc0, /* xor rax, rax */
0xb0, 0x3c, /* mov al, 60 */
0x0f, 0x05 /* syscall */
};
int fd = open(path, O_CREAT|O_RDWR|O_TRUNC, 0644);
write(fd, benign, 4096);
fsync(fd);
/* seeder: mmap + write shellcode to page cache */
void *map = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(map, shellcode, sizeof(shellcode));
/* executor: fork + mmap + execute */
pid_t pid = fork();
if (pid == 0) {
void *emap = mmap(NULL, 4096, PROT_READ|PROT_EXEC, MAP_SHARED, fd, 0);
/* jump to the shellcode in the page cache */
goto *emap; // or asm volatile("call *%0" : : "r"(emap))
}
waitpid(pid, &status, 0);
// status should be 42
Output:
=== cacheghost ===
[1] seeder mmap MAP_SHARED + memcpy(shellcode)
[2] executor pid=102367 /proc/self/maps:
7ff325ade000-7ff325adf000 r-xs ... /home/user/.cacheghost
7ff325adf000-7ff325ae0000 rw-s ... /home/user/.cacheghost
[3] call shellcode...
[4] exit code: 42
Exit code 42 confirms the child executed code from the page cache. The mapping is r-xs (read-execute-shared, file-backed). The other rw-s mapping is inherited from the parent.
What's Actually Happening
On the seeder's memcpy, the store hits the page cache page. The kernel marks it dirty and will eventually write it back, but not yet. The file on disk still has benign data.
On the executor's mmap, the kernel calls filemap_fault(), which looks up the page in the file's address_space xarray. The page is already there, dirty, with the shellcode. find_get_page() returns it. The fault handler installs a PTE pointing to that physical page. No disk read. The child calls into it and the shellcode runs.
The file on disk isn't involved at any point.
What This Is
This is a demonstration that MAP_SHARED writeback semantics can produce an executable mapping whose content doesn't match the backing file. It's a curiosity about how the page cache works.
It's not a new execution primitive. The seeder already has arbitrary code execution (it just wrote to memory). The technique doesn't give you execution in a process you don't control.
It's not a kernel vulnerability. MAP_SHARED is supposed to work this way. Dirty pages are supposed to propagate to new mappers. That's the entire point of shared mappings.
What it is: a narrow observation about forensic visibility. Instead of executing shellcode from an anonymous rwxp mapping (which every EDR looks for), you can execute it from a file-backed r-xs mapping. The mapping looks normal. Whether that matters depends on what your specific EDR checks.
The Detection Angle
This is the part I'm least sure about, so I'll lay out what I know and what I don't.
The technique produces a file-backed r-xs mapping where the inode is valid and the path is real. An EDR that only scans for rwxp anonymous mappings won't flag it. An EDR that checks whether the content of a mapped page matches the file's disk content would need to bypass the page cache to do so, most don't.
But:
- An EDR that hashed the file at install time and hashes again on demand would see a mismatch after writeback commits the shellcode to disk. The file changed. That's detectable.
- An EDR that monitors mmap syscalls for
PROT_EXECon non-standard files could flag this immediately. - Volatility plugins like
linux_pagecachecan enumerate page cache pages. Comparing them against disk is possible, just uncommon. - The
r-xsmapping on a.cacheghost_demofile is itself suspicious. Rename it tolibsomething.soand it blends in better.
I'm not claiming this bypasses all detection. I'm claiming it bypasses a specific class of detection, the kind that only looks for rwxp anonymous memory and assumes file-backed mappings are safe. That covers a lot of Linux EDR agents, but far from all.
The Writeback Problem
The dirty page cache page gets written to disk eventually. The default dirty_expire_centisecs is 3000 (30 seconds). After writeback:
- The file on disk now has the shellcode
- The sha256 doesn't match the original
- If an EDR checks the file hash, it sees a change
So the stealth window is at most 30 seconds without writeback prevention. If you write to the page periodically (a byte every second), the page stays young and writeback doesn't claim it. On tmpfs, there's no writeback at all.
But this is a limitation, not a feature. The technique is for short-lived operations where the shellcode runs once and exits. It's not for persistent stealth, maybe could be used for stealers? idk.
Comparison with Other Approaches
| technique | file modified | exec memory | maps entry |
|---|---|---|---|
| memfd_create + execve | no | no | /proc/self/fd/N |
| ptrace injection | no | yes | rwxp |
| process_vm_writev | no | yes | rwxp |
| LD_PRELOAD | yes | no | .so mapping |
| memfd + fexecve | no | no | memfd (tmpfs) |
| Dirty Pipe | conditional | depends | depends |
| cacheghost | no | no | r-xs file-backed |
The closest comparison is memfd_create. Both leave the disk clean. The difference: memfd mappings are anonymous (tmpfs, no persistent path). cacheghost mappings point to a real file with a real inode. Depending on your threat model, that's either better (blends with other file mappings) or worse (the file path is visible).
Assessment
The technique is 80 lines of C that demonstrates a known kernel behavior being used in an unusual way. The interesting part is the observation that this behavior creates a blind spot in some detection tooling. The execution primitive itself isn't new.
I think that's fine.
The PoC is at https://github.com/klydz/cacheghost.