Safeguard
Vulnerability Analysis•

containerd CVE-2026-95837: Checkpoint Images Restore as Root, Whatever the Pod Spec Says

containerd 2.1 to 2.3 restored checkpoint images with saved privileges, ignoring the pod security context. Versions, the fix and a checker script.

Safeguard Security Team
Security Research
12 min read

On containerd 2.1 to 2.3, a pod whose image is a container checkpoint doesn't start the way its spec says. CRIU restores the process exactly as it was saved, including its user, its Linux capabilities, no_new_privs and its seccomp state, and containerd never applies the pod's security context on top. A checkpoint saved from a root process with all capabilities and no seccomp filter comes back that way inside a pod that asked for runAsNonRoot, dropped capabilities and RuntimeDefault seccomp. Worse, CRI status reports the settings that were requested, not the ones the process actually got. This is CVE-2026-95837 (GHSA-p7v4-vr35-mj6f, CVSS 3.1 score 7.8). containerd 2.2.7 and 2.3.4, released on 12 August, turn the feature off by default, and 2.4 removes it.

This post is for platform and Kubernetes teams running containerd nodes. It covers how restore through CreateContainer works, why the fix is to switch it off rather than repair it, which versions are exposed, and a script that checks node versions, containerd configuration and images.

The short answer

Upgrade containerd to 2.2.7, 2.3.4, 2.4.0 or later, and make sure enable_experimental_restore_via_create is not set to true in config.toml. containerd 2.1 is end of life and has no fix, so nodes on 2.1 need to move to 2.3 or 2.4. containerd 2.0 and 1.7 never had restore through CreateContainer and are not affected. If a container was restored from a checkpoint you don't trust, stop it, delete it and recreate it. On unpatched nodes there's no setting to turn the feature off, so limit who can create pods and which registries they can pull from.

How a pod becomes a restore

Kubernetes can checkpoint a running container through the kubelet's checkpoint API, which writes an archive for forensic analysis. Turning that archive back into a running container has never had its own Kubernetes API. containerd 2.1 added it through the side door: when the CRI CreateContainer call names an image, containerd checks whether that image is a checkpoint.

The check is short. In the 2.3.3 source, container_create.go first asks whether the image string is a path to a file on the node, and if so treats it as a checkpoint archive. Otherwise checkIfCheckpointOCIImage reads the image's index and looks for one annotation, org.criu.checkpoint.container.name. If it's there, the create request is handed to CRImportCheckpoint, and the container is rebuilt from the checkpoint data instead of started from the image.

So anyone who can create a pod and point it at an image they control can trigger a restore. The image only needs that annotation and the CRIU data inside it. Nothing in the pod spec says "restore", and an admission controller that reads the spec sees an ordinary image reference.

What CRIU restores, and what containerd skips

CRIU, Checkpoint/Restore In Userspace, saves a running process tree to disk and later recreates it. A restore puts back what was saved: memory, open files, credentials, capability sets, the no_new_privs bit and seccomp filters. That's the point of the tool. A restored process should be the same process.

For a container runtime, it's a problem. The orchestrator asked for a container with a particular security context, and the runtime is meant to enforce it. The advisory says containerd restored credentials, capabilities, no_new_privs and seccomp state "directly from checkpoint data rather than enforcing the destination CRI ContainerConfig". CRIU's own image security page is clear about where the trust sits: it rebuilds the full security context from the metadata in the image files, and it assumes those files are authentic and haven't been tampered with.

The reporting gap makes it harder to catch. containerd's CRI status shows the configuration the orchestrator requested. A pod restored this way looks compliant from the API while its process runs as root with full capabilities and no seccomp filter.

There is one practical limit. runc hands restores to the criu binary, so a node without CRIU installed can't complete one. That's not a safe assumption to rely on, since the earlier checkpoint bugs below didn't need CRIU at all, and 2.3.4 added a separate change to switch the restore code paths off when CRIU is missing.

The fourth checkpoint bug this year

CVE-2026-95837 isn't the first problem with this code path. In June, containerd 2.1.9, 2.2.5 and 2.3.2 fixed three others, all triggered by a crafted checkpoint image:

CVEWhat a checkpoint image could do
CVE-2026-53489Restore container.log as a symlink and read any host file through kubectl logs
CVE-2026-50195Pull an image and give it any local tag, poisoning the node's cache for other pods
CVE-2026-53492Carry CDI annotations that inject device nodes and host mounts

Each was patched on its own. The fix for CVE-2026-95837 is different, and the pull request explains why. PR #13913 says that even with those fixes, it's "still problematic" that CreateContainer mixes two very different control flows, a normal create and a restore, which an administrator may want separate policy for. The release notes mark it as a breaking change and deprecate the feature in the same release. containerd's RELEASES.md lists restore during CreateContainer as removed in 2.4, with a dedicated RestorePod API to follow under Kubernetes KEP-5823.

Other runtimes have gone the same way. CRI-O fixed CVE-2024-8676, a crafted checkpoint that could gain node access, in 2024. Podman removed checkpoint-image detection from podman run in September after CVE-2026-94603. Treating a checkpoint as an ordinary image keeps causing trouble, because a checkpoint is a saved process, with all of that process's privileges, not a filesystem.

Affected versions and the fix

LineStatusAffectedFixed
1.7, 2.01.7 end of life 30 Sep 2026; 2.0 extended LTSNot affectedNot needed
2.1End of life 3 Jul 2026All releasesNone
2.2Active until 6 Nov 20262.2.0 to 2.2.62.2.7
2.3LTS until 30 Apr 20282.3.0 to 2.3.32.3.4
2.4ActiveNot affectedFeature removed

The fix commit landed on 7 August and the patch releases on 12 August. containerd published the advisory on 1 September, the CVE record went out on 12 October, and GitHub's reviewed advisory followed on 15 October. That's two months in which a patched release existed and most scanners couldn't name the reason to install it.

The fix adds a configuration option, enable_experimental_restore_via_create, under [plugins.'io.containerd.cri.v1.runtime'], and CRImportCheckpoint now refuses to run unless it's true. The advisory is plain about what turning it back on means: the vulnerability is present again, because containerd "cannot enforce destination security policy during CRIU process restoration".

containerd credits @l2yyd5 and @berkpolatCE for reporting the issue.

Check your nodes, config and images

This script has three modes. nodes reads kubectl get nodes -o json and classifies each containerd version. config reads a containerd config.toml and flags the option that brings the bug back. image fetches a manifest or index from a registry, or reads a local OCI layout, and flags the checkpoint annotation containerd looks for. It uses only the Python standard library, and Python 3.11 or later for tomllib.

#!/usr/bin/env python3
"""Check exposure to containerd CVE-2026-95837 (checkpoint restore ignores the
container's security context).

  python3 containerd_95837_check.py nodes nodes.json        # kubectl get nodes -o json
  python3 containerd_95837_check.py config config.toml      # /etc/containerd/config.toml
  python3 containerd_95837_check.py image REF [REF ...]     # registry ref or oci:/path/to/layout

nodes:  classifies each node's containerd version.
config: flags enable_experimental_restore_via_create = true, which brings the bug back.
image:  flags images whose manifest or index carries the org.criu.checkpoint.container.name
        annotation, the one containerd 2.1 to 2.3 uses to decide "this is a checkpoint".
Exits 1 if anything is flagged. Standard library only (Python 3.11+ for tomllib).
"""
import json, os, re, sys, tomllib, urllib.error, urllib.request

CKPT = "org.criu.checkpoint.container.name"
FIXED = {(2, 2): 7, (2, 3): 4}   # first patch release with restore-via-create off by default
ACCEPT = ",".join([
    "application/vnd.oci.image.index.v1+json",
    "application/vnd.docker.distribution.manifest.list.v2+json",
    "application/vnd.oci.image.manifest.v1+json",
    "application/vnd.docker.distribution.manifest.v2+json",
])


def classify(version):
    m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", version)
    if not m:
        return "CHECK", f"can't parse {version!r}"
    major, minor, patch = map(int, m.groups())
    if (major, minor) < (2, 1):
        return "ok", "no checkpoint restore in CreateContainer before 2.1"
    if (major, minor) == (2, 1):
        return "VULN", "2.1 is end of life (3 Jul 2026) and has no fix; move to 2.3 or 2.4"
    if (major, minor) in FIXED:
        if patch >= FIXED[(major, minor)]:
            return "ok", "restore via CreateContainer off by default; check the config"
        return "VULN", f"upgrade to {major}.{minor}.{FIXED[(major, minor)]} or later"
    return "ok", "restore via CreateContainer removed in 2.4"


def cmd_nodes(path):
    flagged = 0
    for node in json.load(open(path))["items"]:
        name = node["metadata"]["name"]
        rt = node["status"]["nodeInfo"]["containerRuntimeVersion"]
        if not rt.startswith("containerd://"):
            print(f"[skip] {name}: {rt}")
            continue
        status, why = classify(rt.split("://", 1)[1])
        flagged += status != "ok"
        print(f"[{status}] {name}: {rt}: {why}")
    return flagged


def find_key(tree, key, path=""):
    if isinstance(tree, dict):
        for k, v in tree.items():
            here = f"{path}.{k}" if path else k
            if k == key:
                yield here, v
            yield from find_key(v, key, here)


def cmd_config(path):
    cfg = tomllib.load(open(path, "rb"))
    flagged = 0
    hits = list(find_key(cfg, "enable_experimental_restore_via_create"))
    for where, value in hits:
        if value is True:
            flagged += 1
            print(f"[VULN] {where} = true: restore via CreateContainer is on, and the fix doesn't apply")
        else:
            print(f"[ok]   {where} = {json.dumps(value)}")
    if not hits:
        print("[ok]   enable_experimental_restore_via_create not set (default false on 2.2.7+/2.3.4+)")
    for where, value in find_key(cfg, "enable_criu"):
        print(f"[info] {where} = {json.dumps(value)}")
    return flagged


def registry_get(ref):
    m = re.match(r"(?:(?P<host>[^/]+\.[^/]+|localhost(?::\d+)?)/)?(?P<repo>[^:@]+)(?::(?P<tag>[^@]+))?(?:@(?P<dig>.+))?$", ref)
    host = m["host"] or "registry-1.docker.io"
    repo = m["repo"] if m["host"] or "/" in m["repo"] else "library/" + m["repo"]
    url = f"https://{host}/v2/{repo}/manifests/{m['dig'] or m['tag'] or 'latest'}"
    headers = {"Accept": ACCEPT, "User-Agent": "ckpt-check/1.0"}
    try:
        return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=20))
    except urllib.error.HTTPError as e:
        if e.code != 401:
            raise
        params = dict(re.findall(r'(\w+)="([^"]*)"', e.headers["WWW-Authenticate"]))
        token_url = f"{params['realm']}?service={params.get('service', '')}&scope=repository:{repo}:pull"
        token = json.load(urllib.request.urlopen(token_url, timeout=20))
        headers["Authorization"] = "Bearer " + (token.get("token") or token["access_token"])
        return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=20))


def oci_layout_get(path):
    index = json.load(open(os.path.join(path, "index.json")))
    # An OCI layout's index.json points at the image; read the blob it names.
    desc = index["manifests"][0]
    algo, digest = desc["digest"].split(":", 1)
    return json.load(open(os.path.join(path, "blobs", algo, digest)))


def cmd_image(refs):
    flagged = 0
    for ref in refs:
        try:
            doc = oci_layout_get(ref[4:]) if ref.startswith("oci:") else registry_get(ref)
        except Exception as e:
            print(f"[?]    {ref}: fetch failed ({e})")
            flagged += 1
            continue
        ann = doc.get("annotations") or {}
        criu = {k: v for k, v in ann.items() if k.startswith("org.criu.checkpoint.")}
        if CKPT in ann:
            flagged += 1
            print(f"[VULN] {ref}: checkpoint image (container {ann[CKPT]!r}); containerd 2.1 to 2.3 will restore it")
            for k, v in sorted(criu.items()):
                print(f"         {k} = {v}")
        elif criu:
            print(f"[CHECK] {ref}: has {sorted(criu)} but not {CKPT}")
        else:
            print(f"[ok]   {ref}: no checkpoint annotations ({doc.get('mediaType', 'no mediaType')})")
    return flagged


def main(argv):
    if len(argv) < 3 or argv[1] not in ("nodes", "config", "image"):
        sys.exit(__doc__)
    if argv[1] == "nodes":
        flagged = cmd_nodes(argv[2])
    elif argv[1] == "config":
        flagged = cmd_config(argv[2])
    else:
        flagged = cmd_image(argv[2:])
    print(f"\n{flagged} item(s) flagged")
    sys.exit(1 if flagged else 0)


if __name__ == "__main__":
    main(sys.argv)

We ran it on 16 October. The node list and config are fixtures. The two registry images are real public images, and the OCI layout is a checkpoint index we built with the annotations checkpointctl defines:

$ python3 containerd_95837_check.py nodes nodes.json
[VULN] pool-a-1: containerd://2.3.3: upgrade to 2.3.4 or later
[ok] pool-a-2: containerd://2.3.7: restore via CreateContainer off by default; check the config
[VULN] pool-b-1: containerd://2.1.6: 2.1 is end of life (3 Jul 2026) and has no fix; move to 2.3 or 2.4
[VULN] pool-c-1: containerd://2.2.6: upgrade to 2.2.7 or later
[ok] gke-lts-1: containerd://2.0.14: no checkpoint restore in CreateContainer before 2.1
[skip] edge-1: cri-o://1.35.0

3 item(s) flagged

$ python3 containerd_95837_check.py config config.toml
[VULN] plugins.io.containerd.cri.v1.runtime.enable_experimental_restore_via_create = true: restore via CreateContainer is on, and the fix doesn't apply
[info] plugins.io.containerd.cri.v1.runtime.enable_criu = true

1 item(s) flagged

$ python3 containerd_95837_check.py image nginx:1.29 registry.k8s.io/pause:3.10 oci:ckpt-layout
[ok]   nginx:1.29: no checkpoint annotations (application/vnd.oci.image.index.v1+json)
[ok]   registry.k8s.io/pause:3.10: no checkpoint annotations (application/vnd.docker.distribution.manifest.list.v2+json)
[VULN] oci:ckpt-layout: checkpoint image (container 'web'); containerd 2.1 to 2.3 will restore it
         org.criu.checkpoint.container.name = web
         org.criu.checkpoint.rootfsImageName = docker.io/library/nginx:1.29
         org.criu.checkpoint.runtime.name = runc

1 item(s) flagged

The node on 2.3.7 passes the version check, but the config check still matters there: one line in config.toml puts it back in the same position as 2.3.3. The image check reads the top-level index or manifest, which is where containerd 2.1 to 2.3 looks. Run it on images from registries you don't control before you allow them.

What to do

  1. Upgrade. 2.3.4 or later on the 2.3 LTS line, or 2.4. 2.2.7 fixes it, but 2.2 support ends on 6 November. Managed Kubernetes users should check the containerd version on their node images rather than assume.
  2. Move off 2.1. It has no fix and no support.
  3. Check the config. Search node configuration and image-building pipelines for enable_experimental_restore_via_create. Unless you have a tested restore workflow and trust every image, it should be absent or false.
  4. Close the gap on unpatched nodes. Restrict pod creation to trusted users, allow images only from registries you control, and reject images carrying org.criu.checkpoint.* annotations. Our post on Kubernetes admission controller policy patterns covers registry allowlisting.
  5. Recreate restored containers. Look for containers with the restored annotation containerd adds on restore. If the checkpoint didn't come from you, the advisory says to stop, delete and recreate them.

The fair view

The bar for exploitation is real. The attacker needs to create pods, the node needs CRIU installed, and the image has to be a working checkpoint. Many clusters don't ship CRIU on their nodes, and many only allow images from internal registries. Container checkpointing is also a useful feature for forensics and migration, and the maintainers are moving it to a dedicated API rather than dropping it.

But the same input, a checkpoint image, produced four containerd CVEs in 2026, and similar bugs in CRI-O and Podman. When a runtime keeps patching one input, turning the feature off by default is the honest fix. For anyone running multi-tenant clusters, the lesson is that "can create a pod with this image" has carried more privilege than the pod spec showed. Our containerd security configuration guide covers the rest of the node configuration worth checking.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.