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:
| |
Inside this container, attempts to write files to the filesystem will fail:
| |
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:
Open
/proc/$pid/memwith write permissions — Shell interpreters can create file descriptors pointing to thememfile, and child processes inherit those descriptors.Read
/proc/$pid/mapsto defeat ASLR — The maps file reveals exactly where memory regions are mapped, bypassing Address Space Layout Randomization.Write shellcode into executable memory — Using the inherited file descriptor, the tool overwrites portions of the running process’s memory with a stager.
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:
| |
The binary never touches the filesystem. It lives entirely in the process’s memory space.
--read-only containers. The /proc filesystem is always mounted, and writing to /proc/self/mem is a legitimate kernel feature, not a bug.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:
| |
Attackers can:
- Write shellcode or scripts to
/dev/shm - Use DDexec’s companion tool
DDsc.shto execute shellcode from/dev/shmwithout 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:
- Write a compiled binary to a writable location (e.g.,
/dev/termination-log) - Invoke the dynamic linker directly, bypassing the execute permission check:
| |
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.
/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:
- Create the anonymous file:
fd = memfd_create("", MFD_CLOEXEC); - Write the ELF into it: stream the payload straight into
fd(from a socket, a pipe, or a decrypted blob in memory). - Execute it: call
fexecve(fd, argv, envp)— or equivalentlyexecveat(fd, "", argv, envp, AT_EMPTY_PATH)— or simplyexecve("/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:
| |
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:
| |
memfd_create beats noexec too. Mounting /dev/shm, /tmp, and the root filesystem noexec is good hygiene, but an anonymous memfd is not part of any mount — so the noexec flag never applies to it. Read-only and noexec containers are still fully exploitable this way.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) viactypes/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:
| Mechanism | What It Provides | Blocked by --read-only? | Blocked by noexec? |
|---|---|---|---|
/proc/self/mem | Direct process memory write access | No | No |
/proc/self/maps | ASLR bypass via memory layout exposure | No | No |
/dev/shm | In-memory writable tmpfs | No | Partially |
| Dynamic linker | ELF loading without execute bit | No | No |
memfd_create + execveat | Anonymous, path-less in-RAM execution | No | No |
process_vm_writev | Cross-process memory write without ptrace | No | No |
| Inherited file descriptors | Privilege-preserving memory access | No | No |
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:
| |
| |
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:
| |
memfd_create is exactly why. Path-based MAC mediates access by filename. An anonymous memfd has no name, so a path rule has nothing to match against, and the execution slips past. AppArmor will happily block the /proc/*/mem write that DDexec needs, but it cannot, on its own, stop a memfd-based loader. This gap is the whole reason low-level, behavior-based detection is mandatory rather than optional.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:
| |
Detecting /dev/shm Abuse
| |
Detecting Dynamic Linker Abuse
| |
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:
| |
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:
| |
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:
- 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. - 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/*/memwrite is unmistakable, no matter how the payload arrived. - 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
- Use minimal base images — Prefer distroless or
scratch. Nobash,wget,curl,dd,python, orperlmeans no execution engine to live off of. - Drop capabilities — Run with
--cap-drop=ALLand only add back what’s strictly needed - Tighten seccomp — Start from Docker’s default, then deny the syscalls your app never uses (
memfd_create,process_vm_writev,ptrace) after profiling - Apply a custom AppArmor/SELinux profile — Deny
/proc/*/memwrites,ptrace, andmount; SELinux in enforcing mode can additionally restrictexecmem - Use
no-new-privileges— Prevents privilege escalation via setuid binaries - Mount writable tmpfs with
noexecand a size cap — Raises the bar even thoughmemfdsidesteps it:1 2 3 4 5 6 7docker 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 - Keep
--read-only— It still blocks unsophisticated attacks and disk-persistence
Detect and respond
- Deploy Falco (or Tetragon/Sysdig) — Syscall-level runtime monitoring is non-negotiable for production containers
- Alert on the fileless fingerprints —
/proc/*/memwrites,memfd-backed exec, direct dynamic-linker invocation, anomalous egress - 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:
- DDexec — GitHub
memfd_create(2)— Linux man pages- Executing Arbitrary Code & Executables in Read-Only Filesystems — WithSecure Labs
- Falco — Cloud Native Runtime Security
- Tetragon — eBPF-based Security Observability & Runtime Enforcement
- Docker seccomp security profiles
- Docker AppArmor security profiles
- MITRE ATT&CK — T1620: Reflective Code Loading
