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 anexecve()
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
- Runs the
- It enters namespaces (via
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
startscontainerInit
callspopulateProcessEnvironment
, which doessetenv
for each entry inconfig.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
callspopulateProcessEnvironment
, which setsLD_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.