CVE-2020-28243 SaltStack Minion Local Privilege Escalation
I discovered a command injection vulnerability in SaltStack's Salt that allows privilege escalation via specially crafted process names on a minion when the master calls restartcheck.
tldr;
I discovered a command injection vulnerability in SaltStack's Salt that allows privilege escalation via specially crafted process names on a minion when the master calls restartcheck.
Affected Versions: All versions between 2016.3.0rc2 and 3002.5
CVSS Score: 7.0 High
Announcement: SaltStack
Links: Mitre, NVD
Proof of Concept: CVE-2020-28243
This is going to be a longish post (for me) so for those with an attention span as short as mine you may want to skip ahead to a specific section that interests you:
- the discovery - how did I find it?
- the vulnerability - why does it work?
- the exploit - how can I exploit it? Including screenshots and video
- the security considerations - container escapes, unauthenticated RCE
- the timeline - how long did this all take?
SaltStack Salt
SaltStack Salt is a popular tool used to automate and secure infrastructure. Its usage is split into two roles: one system is set up as the master and is responsible for controlling those systems that connect to it. One or more systems are then set up as minions that connect to the master and respond to any commands it issues.
Both master and minions are typically run as root on the systems they are installed on.
Discovery
Whilst looking at the source code for SaltStack for one of the previously disclosed vulnerabilities at work, I decided to run the source code through Bandit, a security scanner for Python applications to see how many issues it would find.
I was expecting to see quite a few results, as it has a relatively large codebase and has existed for several years. However, as can be seen in the screenshot below Bandit showed even more issues than I was expecting including 117 high severity issues.
Of course, many of these issues are false positives or of little importance to us, and it can take significant time to parse through all the data. For a quick win, I decided to focus my time researching some potential command injections due to several instances of subprocess.Popen
used in conjunction with shell=True
in the codebase.
After looking at a few of these that turned out not to be in any way controllable I found one that could be controlled via some clever trickery using process names.
Vulnerability
The minion's restartcheck
is vulnerable to command injection via a crafted process name when this process has open file descriptors associated with (deleted)
at the end of a filename (note the leading space is required). This allows for a local privilege escalation to root from any user able to create files on the minion in a directory that is not explicitly forbidden.
Vulnerable Code
The vulnerable code is at line 615 in restartcheck.py where subprocess.Popen
is called with shell=True
and a command that can be maniplulated by an attacker:
cmd = cmd_pkg_query + package
paths = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
Where package
is formed from the process name and cmd_pkg_query
is one of the following depending on the OS:
- Debian:
dpkg-query --listfiles
- RedHat:
repoquery -l
- NILinuxRT:
opkg files
If we can insert a bash control character such as ;
|
&&
etc into the process name we can trigger the injection when this code is reached.
However, to reach this code, a process first needs to have a filehandler open to a file with a filename that ends in (deleted)
, and this file needs to reside in a directory that is not explicitly forbidden.
The list of forbidden directories can be seen here. This deny list immediately rules out some of the more obvious places we might try to use such as /tmp
or /dev/shm
, however, there are a few common ones that a low privileged user may have access to such as:
/var/crash
/var/spool/samba
/var/www
Process names
During this research, it turned out that process names can be a surprisingly tricky thing to modify reliably, and a process name listed by ps
may not be the same returned by the Python psutil library.
In Linux, process names can contain any characters (apart from null). Any user can start processes on a system and the process itself can set the process name. They are a good target for command injection vulnerabilities as developers are unlikely to expect process names to contain special characters or injections.
It is possible to use exec -a
to directly set a process name; however, this doesn't work in busybox
or sh
shells and doesn't appear to show the same name when using psutil
. It's also possible to modify the process name by directly manipulating procfs however this also leads to inconsistent results.
So in the end, the simplest and most consistent way to set a process name is to rename the binary or script being run. As filenames in Linux cannot contain /
this restricts the commands we can inject, however using base64 encoding it is trivial to bypass this restriction as shown below:
# If we wanted to copy the shadow file to /tmp we could run this
cp /etc/shadow /tmp/shadow
# Convert it to a base64 string
echo cp /etc/shadow /tmp/shadow | base64 -w0
# The result of the conversion
Y3AgL2V0Yy9zaGFkb3cgL3RtcC9zaGFkb3cK
# The new command we need to run
echo Y3AgL2V0Yy9zaGFkb3cgL3RtcC9zaGFkb3cK|base64 -d|sh -i
Exploit
For this exploit to work, a writable directory that isn't explicitly forbidden by SaltStack is required. Running the proof of concept script with no arguments will search for directories that match this restriction.
Then by passing a suitable writable directory with the -w flag and a command with the -c flag, a process will be created containing the command injection in the process name and an open file handler that will cause the exploit to be triggered when the master calls restartcheck.
Let's step through the process of performing the exploit to demonstrate the vulnerability. We aim to create a simple file as the root user. First, we run the script providing the proper flags:
Now that we've confirmed the malicious process is running with a command injection in the name and a filehandler open, we can issue the restartcheck.restartcheck
command on the SaltStack master. Once this has completed, we can check for the existence of the hacked file in the root directory.
That's great but writing files is dull, lets do something a bit cooler. How about getting a shell as root? The video below shows one way of doing this by copying the find binary and making it suid:
For additional context the screenshot below shows how the exploit above appears from the master's point of view:
Additional Security Considerations
The above discusses the exploitation of this vulnerability locally to escalate privileges to the root user; however, there a couple of additional security concerns that should be considered:
Container Escape
As containerized processes are listed on the host machine, this exploit could be performed from within a container to gain command execution as root on the host machine.
Potential For Unprivileged RCE
While somewhat unlikely, there is potential for this attack to be performed by an attacker without local shell access. This is because, under certain circumstances, a remote user can influence process names.
Timeline
SaltStack were very fast to respond and the whole timeline for discovery and disclosure can be seen below:
05 Nov 2020 — Vulnerability discovered
05 Nov 2020 — SaltStack notified
05 Nov 2020 — SaltStack investigating
06 Nov 2020 — CVE ID assigned
07 Nov 2020 — SaltStack confirmed vulnerability
18 Nov 2020 — SaltStack notified intention to release fix in Jan
22 Jan 2021 — Security fix announced for 4th Feb
04 Feb 2021 — Security fix delayed to 25th Feb
25 Feb 2021 — Security fix released (+91 days)
Summary
Developing and maintaining secure code is difficult and who the hell expects process names to be malicious? Hit me up on twitter if you have any questions or feedback, this was my first CVE so be kind.