Scanner for CVE-2023-22515 - Broken Access Control Vulnerability in Atlassian Confluence
#!/usr/bin/env python3
import argparse
import json
import os
import random
import re
import requests

# disable the warnings about the insecure requests
requests.packages.urllib3.disable_warnings()

def get_args():
  parser = argparse.ArgumentParser(description="Scan Atlassian Confluence web instances for CVE-2023-22515")
  parser.add_argument("-f", help="File containing a list of URLs to scan", dest="file", required=False)
  parser.add_argument("-t", help="Comma-separated list of URLs to scan", dest="targets", required=False)
  parser.add_argument("-o", help="Output directory", dest="output_dir", default="./") 
  args = parser.parse_args()
  return args

# add status printing with different Print
class Print:
  RED = "\033[01;31m"
  GREEN = "\033[01;32m"
  YELLOW = "\033[01;33m"
  BLUE = "\033[01;34m"
  MAGENTA = "\033[01;35m"
  RESET = "\033[00m"

  def template(status_color, status_symbol, text):
    print(f"[ {status_color}{status_symbol}{Print.RESET} ] {text}")

  def status(text):
    Print.template(Print.BLUE, '*', text)
  
  def good(text):
    Print.template(Print.GREEN, '+', text)
  
  def error(text):
    Print.template(Print.RED, '-', text)
  
  def warning(text):
    Print.template(Print.YELLOW, '!', text)
  
  def unknown(text):
    Print.template(Print.MAGENTA, '?', text)
  
  def code_red(text):
    Print.template(Print.RED, '!!!', text)
  
  def banner():
    print(Print.BLUE + '#' * 80)
    print(f"""\t\t     {Print.RED}CVE-2023-22515 {Print.GREEN}Atlassian Confluence {Print.RED}Scanner\n\t\t\t\t{Print.BLUE}by {Print.RED}Erik Wynter {Print.BLUE}""")
    print('#' * 80)
    print(Print.RESET)

# the main class with the core logic
class Confluence:
  # user agents to make the requests seem a little more legit
  # one of these will be selected at random whenever the script runs
  USER_AGENTS = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36', # chrome
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0', # firefox
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15', # safari
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61' # edge
  ]

  # vulnerable endpoint. the patch removes this altogether
  SERVER_INFO_URI = '/server-info.action'
  
  # Exhaustive list of vulnerable versions taken from the official advisory
  VULNERABLE_VERSIONS = ['8.0.0', '8.0.1', '8.0.2', '8.0.3', '8.0.4', '8.1.0', '8.1.1', '8.1.3', '8.1.4', '8.2.0', '8.2.1', '8.2.2', '8.2.3', '8.3.0', '8.3.1', '8.3.2', '8.4.0', '8.4.1', '8.4.2', '8.5.0', '8.5.1']

  def __init__(self):
    self.results = []
    # set a random user agent
    self.user_agent = self.USER_AGENTS[random.randint(0, len(self.USER_AGENTS) - 1)]

  # sanity check for the provided options
  def validate_options(self):
    options = get_args()
    self.targets = []

    if not options.file and not options.targets:
      Print.error("Specify either a targets file (-f) and/or a comma-separated list of targets (-t).")
      return False

    if options.file:
      if not os.path.isfile(options.file):
        Print.error(f"The provided targets file '{options.file}' does not exist.")
        return False
      
      if os.path.getsize(options.file) == 0:
        Print.error(f"The provided targets file '{options.file}' is empty.")
        return False

      # load the targets from the file
      with open (options.file, 'r') as f:
        self.targets += f.read().splitlines()
       
    if options.targets:
      self.targets += options.targets.split(",")

    self.targets = list(set(self.targets))

    out_dir = os.path.abspath(options.output_dir)
    if not os.path.exists(out_dir):
      Print.warning(f"The provided output directory '{out_dir}' does not exist, so this script will create it.")
      try:
        os.makedirs(out_dir)
      except Exception as err:
        Print.error(f"Failed to create the output directory '{out_dir}'.")
        Print.error(f"Error: {err}")
        return False

    self.output_dir = out_dir
    
    return True

  # central method for iterating of the targets and performing all necessary actions
  def run(self):
    Print.status(f"Proceeding to scan {len(self.targets)} target(s)...")
    for target in self.targets:
      if target.endswith('/'):
        target = target[:-1]

      # let's get the target info
      target_results = self.identify_target(target)

      if not target_results: continue
      
      self.results.append(target_results)

  # method for trying to identify the product
  def identify_target(self, target) -> dict | None:
    target_info = {
      'target_url': target,
      'product': 'unknown',
      'version': 'unknown',
      'vulnerability_status': 'unknown',
    }
    # check if the target is up
    Print.status(f"{target} - Scanning target...")
    headers = { 'User-Agent': self.user_agent }
    try:
      res = requests.get(target, headers=headers, verify=False, timeout=5)
    except Exception as err:
      Print.error(f"Error connecting to {target}: {err}")
      return None
	  
    if not res.ok:
      Print.error(f"{target} - Received status code {res.status_code}. Skipping this target.")
      return None

    if not res.text:
      Print.error(f"{target} - Received empty HTTP response body.")
      return None
    
    if not 'Atlassian Confluence' in res.text:
      Print.error(f"{target} - Target does not appear to be Atlassian Confluence. Skipping this target.")
      return None
    
    version_match = re.search(r'Atlassian Confluence ([\d\.]+)<', res.text)
    if not version_match:
      version_match = re.search(r'"ajs-version-number" content="([\d\.]+)"', res.text)
    
    if not version_match:
      Print.warning(f"{target} - Could not obtain the version. The script will continue, but the target may not be Atlassian Confluence.")
      target_info['product'] = 'Atlassian Confluence ?'
    else:
      version = version_match.group(1)
      target_info['product'] = 'Atlassian Confluence'
      target_info['version'] = version

    initial_vuln_status = 'not vulnerable'
    if target_info['version'] != 'unknown':
      if version in self.VULNERABLE_VERSIONS:
        Print.warning(f"{target} - The target is Atlassian Confluence {version}, which is a vulnerable version.")
        initial_vuln_status = "potentially vulnerable"
      else:
        Print.status(f"{target} - The target is Atlassian Confluence {version}, which is not a vulnerable version.")
        target_info['vulnerability_status'] = initial_vuln_status
        return target_info
    else:
      Print.status(f"{target} - The target may be Atlassian Confluence, but the version could not be identified.")

    # Check if the vulnerable endpoint is available
    Print.status(f"{target} - Checking for the vulnerable endpoint {self.SERVER_INFO_URI}")
    try:
      res = requests.get(f"{target}{self.SERVER_INFO_URI}", headers=headers, verify=False, allow_redirects=False, timeout=5)
    except Exception as err:
      Print.error(f"{target} - Error connecting to {target}{self.SERVER_INFO_URI}: {err}")
      return target_info

    if res.status_code != 200:
      Print.good(f"{target} - The vulnerable endpoint {self.SERVER_INFO_URI} is not accessible. Received status code {res.status_code}. The target is likely patched.")
      if initial_vuln_status == 'potentially vulnerable':
        target_info['vulnerability_status'] = 'likely not exploitable'
      else:
        target_info['vulnerability_status'] = 'not vulnerable'
      return target_info

    Print.code_red(f"{target} - The vulnerable endpoint {self.SERVER_INFO_URI} is accessible.")
    target_info['vulnerability_status'] = 'likely vulnerable'  
    return target_info

  # save the results and print a nice report
  def save_and_report(self):
    if not self.results:
      Print.error("No systems were identified as Atlassian Confluence devices. Exiting.")
      return

    # save the results to a file
    json_file = f"{self.output_dir}/cve_2023_22515_scan.json"
    with open(json_file, 'w') as f:
      json.dump(self.results, f, indent=2)
    Print.status(f"The full scan results for identified systems were saved to {json_file}\n")

    # generate a human-readable report
    Print.status("Results:")
    print('-' * 80)
    human_readable_report = f"{self.output_dir}/cve_2023_22515_scan.txt"
    vuln_status_ct_hash = {
      'likely vulnerable': 0,
      'likely not exploitable': 0,
      'unknown': 0,
      'not vulnerable': 0
    }

    with open(human_readable_report, 'w') as f:
      finding_sort_order = { 'likely vulnerable':0, 'likely_not_exploitable': 1, 'not vulnerable': 2, 'unknown': 3}
      sorted_results = sorted(self.results, key=lambda word: finding_sort_order[word['vulnerability_status']])
      for result in sorted_results:
        info_line = f"{result['target_url']} - Product: {result['product']} Version: {result['version']}"
        vuln_status = result['vulnerability_status']
        info_line += f" - vulnerability status: {vuln_status}"
        f.write(f"{info_line}\n")

        vuln_status_ct_hash[vuln_status] += 1
        if vuln_status == 'not vulnerable': Print.good(info_line)      
        elif vuln_status == 'likely vulnerable': Print.code_red(info_line)
        elif vuln_status == 'likely not exploitable': Print.warning(info_line)
        elif vuln_status == 'unknown': Print.unknown(info_line)
    print('-' * 80)
    Print.status(f"The human-readable report was saved to {human_readable_report}\n")

    # print the summary
    Print.status(f"Summary:")
    print('-' * 80)
    for vuln_status, vuln_ct in vuln_status_ct_hash.items():
      if vuln_ct > 0:
        if vuln_status == 'not vulnerable': Print.good(f"Not vulnerable targets: {vuln_ct}")
        elif vuln_status == 'likely vulnerable': Print.code_red(f"Likely vulnerable targets: {vuln_ct}")
        elif vuln_status == 'likely not exploitable': Print.warning(f"Likely not exploitable targets: {vuln_ct}")
        elif vuln_status == 'unknown': Print.unknown(f"Targets with unknown vulnerability status: {vuln_ct}")     
    print('-' * 80)

def main():
  confluence = Confluence()
  options_check = confluence.validate_options()
  if not options_check: exit(1)
  
  Print.banner()
  confluence.run()
  if not confluence.results:
    Print.status("No relevant results were obtained. Exiting.")
    exit(0)
  
  confluence.save_and_report()
  print()
  Print.status("Done! Taking a nap.")

if __name__ == "__main__":
  main()

cve_2023_22515_scan.py
ErikWynter/CVE-2023-22515-Scan