Post

CVE-2025-23266 – part 2

Introduction

In my previous post about CVE-2025-23266 I covered:

  • Dynamic linker behavior (to explain how LD_PRELOAD works)
  • Alternate runtimes (to explain what the NVIDIA Container Toolkit is)
  • CUDA forward compatibility (why the toolkit uses a createContainer hook)
  • OCI Runtime Specs and Hooks (what createContainer hooks are)

But one important detail was missing. From the original Wiz write-up:

While prestart hooks run in a clean, isolated context, createContainer hooks have a critical property: they inherit environment variables from the container image unless explicitly configured not to.

At the time of writing, I could not find details about it, nor could I reproduce it using the latest runc version

Investigating runc init

My first attempt was to reproduce the issue by hand, using runc with a minimal OCI spec.

1
2
3
4
5
6
7
8
9
10
11
# Create bundle directory structure
mkdir /tmp/mycontainer
cd /tmp/mycontainer
mkdir rootfs

# Download and extract Alpine Linux
wget https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
tar -C rootfs -xzf alpine-minirootfs-3.19.1-x86_64.tar.gz

# Generate OCI spec
runc spec

Then I modified config.json to add a createContainer hook:

1
2
3
4
5
6
7
8
"hooks": {
  "createContainer": [
    {
      "path": "/tmp/mycontainer/dumpenv",
      "args": ["none"]
    }
  ]
}

and set environment variables:

1
2
3
4
5
"env": [
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "TERM=xterm",
  "FOO=bar"
]

The dumpenv program simply wrote all its environment variables to a file:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[], char *envp[]) {
    FILE *fp = fopen("/tmp/envdump.txt", "w");
    if (!fp) return 1;

    for (char **env = envp; *env; env++) {
        fprintf(fp, "%s\n", *env);
    }
    fclose(fp);
    return 0;
}

Both this approach and a variant that used sleep(60) (and then inspecting /proc/<pid>/environ) showed the expected result: FOO=bar was visible in the hook’s environment.

But when I built the latest runc from GitHub, the bug was gone.

As a sanity check, I tried to reproduce it with the runc version shipped on my system, and as expected I managed to reproduce the issue.

On my system, Ubuntu shipped runc 1.2.5. The current runc is 1.3.1, where the issue no longer reproduces. After some time bisecting, I traced the fix to this commit:

06f1e0765576dcf6d8c2ef5e56d309618310992c

The populateProcessEnvironment function

That commit modified how runc sets environment variables. The function populateProcessEnvironment, calls os.Setenv for each variable in config.Env. While it didn’t set the hook’s env directly, it updated the parent process’s environment. Because Go’s exec.Cmd defaults to inheriting the parent environment, the hook ended up with container-supplied variables, including LD_PRELOAD.

To test this theory, I created a hook that just slept for 120 seconds:

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Sleeping...\n");
    sleep(120);
    return 0;
}

Then I inspected its parent process with ps axjf and /proc/<pid>/environ. Instead of FOO=BAR, I only saw _LIBCONTAINER_* variables.

Eventually, I realized I could trace all setenv calls directly:

1
sudo ltrace -e setenv -f runc create mycontainer

The -f flag is important, since it tells setenv follows child processes:

1
2
[pid 254818] runc->setenv("TERM", "xterm", 1) = 0
[pid 254818] runc->setenv("FOO", "bar", 1) = 0

Detour: environment variables on Linux

On Linux, an environment variable can be set in two ways:

  • At exec time, passed in the envp array of an execve() call
  • At run time, changed by the C library function setenv()

/proc/<pid>/environ shows the current contents of the process’s environ block in memory. Programs that call setenv() (or Go’s os.Setenv) will update it. But beware: shells like Bash keep their own variable tables. When you export FOO=BAR interactively, the shell marks it for children but may not update its own environ. As a result, /proc/<shell-pid>/environ might not show your new variable, even though child processes inherit it.

THat detail was that threw me off for a while and caused te issue whre I could not see the FOO=BAR environment variable.

The runc init stage

Here’s what happens during runc create:

  • runc init starts
    • It enters namespaces (via nsenter)
    • Initializes the container (startInitialization), which:
      • Runs the createContainer hook
      • Calls pivot_root
      • Finally execve()s the container process

From inside the PID namespace, runc init is PID 1, and all processes (including hooks) are its children.

Before commit 06f1e07, the sequence was:

  • runc init starts
  • containerInit calls populateProcessEnvironment, which does setenv for each entry in config.Env
  • Hooks started with exec.Cmd

Go’s exec.Cmd works like this:

1
2
// Env specifies the environment of the process.
// If Env is nil, the new process uses the current process's environment.

This effectively makes the run init process and all of its children have the same environment variable, unless explicitely set otherwise.

This does not affect runc init itself, since LD_PRELOAD only runs the library being set at exec time, not for an already-running process. Remember that runc init first starts, then setenv is called afetrwards. however, it does affect the hook subprocesses, since they inherith the environment variable at exec time.

Putting it all together

  • An attacker builds an image with LD_PRELOAD=./poc.so.

    1
    2
    3
    
    FROM busybox
    ENV LD_PRELOAD=./poc.so
    ADD poc.so /
    
  • On runc ≤1.2.6:

    • runc init calls populateProcessEnvironment, which sets LD_PRELOAD in its own environment.
    • Hooks started with no explicit Env inherit that.
    • The malicious poc.so is loaded before the hook’s own code runs.

Conclusion

Older runc versions (≤1.2.6) unintentionally allowed container-supplied environment variables to leak into host-side hooks, creating a neat LD_PRELOAD injection path. Commit 06f1e07 fixed this by sanitizing the environment before running hooks.

If you rely on createContainer hooks, upgrade your runc. Otherwise, container images could trick your hook into loading arbitrary libraries.

This post is licensed under CC BY 4.0 by the author.