Segurium Research Ultimate Addons for Elementor

Ultimate Addons for Elementor 2.9.4 closes an unpublished content leak

Segurium Research 11 min read

Plugin
Ultimate Addons for Elementor header-footer-elementor
Affected versions
2.9.3 and earlier
Fixed in
2.9.4
Class
Insecure direct object reference
Severity
medium Segurium Research assessment. No CVSS score published yet.
CVE
None assigned at the time of writing
Installs
2,000,000 active
Patch released
Sources
Advisory record for Ultimate Addons for Elementor 2.9.4 The same finding as a structured entry in the advisory directory.

Brainstorm Force shipped Ultimate Addons for Elementor 2.9.4 on 2026-09-02. The plugin runs on ~2,000,000 sites. The release carries three notes, and the vendor marked two of them Security.

The first note says the plugin “Hardened the [hfe_template] shortcode so that unpublished, scheduled, trashed and non-public content can no longer be rendered by users who are not allowed to view it.” The second says the vendor “Removed the plugin’s redundant SVG upload handler; SVG uploads are now managed by Elementor’s own upload control and sanitisation, closing a stored XSS vector in the legacy sanitiser.”

The diff between 2.9.3 and 2.9.4 touches 3 files, +14 and -368 lines. Almost every removed line belongs to one home-grown SVG sanitiser. The access control fix is 8 lines. Both notes match the code, and the code says more than the notes do.

What the release fixes

The shortcode that renders a saved template checked the target post against a list of bad statuses. WordPress core registers more statuses than that list held, and other plugins register their own. Anything outside the list passed, so a scheduled post, a trashed post or a post of a private post type rendered its content to a visitor with no right to read it. Release 2.9.4 inverts the rule: the post must be published and its post type must be viewable.

  • Affected versions: 2.9.3 and earlier
  • Fixed in: 2.9.4
  • Class: IDOR (unauthorized content disclosure)
  • Installs: ~2,000,000 sites, per the wordpress.org plugin directory

How the bug works

The status denylist in the shortcode

The guard sits in inc/class-header-footer-elementor.php, around line 827. Here is the change, from the diff:

-		// Check if the current user has permission to edit posts.
+		// Never expose revisions or autosaves: their 'inherit' status can carry a parent's unpublished content.
+		if ( wp_is_post_revision( $id ) || wp_is_post_autosave( $id ) ) {
+			return '';
+		}
+
 		if ( ! current_user_can( 'edit_post', $id ) ) {
-			$post_status = get_post_status( $id );
-			// Prevent access to drafts, private, pending, and password-protected posts for unauthorized users.
-			if ( in_array( $post_status, [ 'draft', 'private', 'pending' ], true ) || post_password_required( $id ) ) {
+			if ( 'publish' !== get_post_status( $id )
+				|| ! is_post_type_viewable( get_post_type( $id ) )
+				|| post_password_required( $id ) ) {
 				return ''; // Prevent access to restricted posts.
 			}
 		}

Read the old branch as the code ran it. current_user_can( 'edit_post', $id ) is the escape hatch. A user who can edit that exact post skips every further check, which is correct: an editor may preview their own unpublished work. Everyone else, including a logged out visitor, reached the in_array() call.

That call is a denylist. It named draft, private and pending. WordPress core registers eight statuses: publish, future, draft, pending, private, trash, auto-draft and inherit. The list covered three of them. Five core statuses passed the check untouched:

  • future is a scheduled post. The content is written and dated for later. The shortcode rendered it before its publish date.
  • trash is a deleted post. WordPress keeps the row and the content until the trash is emptied. The shortcode rendered it after the author deleted it.
  • auto-draft is a new post WordPress created but nobody saved yet.
  • inherit belongs to revisions, autosaves and attachments. A revision row holds a full copy of the parent post’s content at that moment, including content the author later cut.
  • Any status a second plugin registers. An editorial workflow plugin that adds its own status for unfinished work gets no protection here, because that status was never in the list.

The in_array() call uses strict comparison, so no type juggling helps an attacker. The bug is simpler than a bypass. The list was short and the world was longer.

The second half of the old guard is what is missing rather than what is wrong. The old code never looked at the post type. A post of a custom post type registered with public => false still has a status, and that status is often publish. A published post of a private post type passed the denylist and rendered. Plugins use non-public post types for data users are never meant to read on the front end. is_post_type_viewable() is the function the new code calls to close this, and it returns true only when the post type is public or publicly queryable.

The new revision guard runs before the capability check, so it applies to everybody. wp_is_post_revision() and wp_is_post_autosave() return the parent id for a revision row and false otherwise. The vendor’s comment names the reason directly: an inherit status carries the parent’s unpublished text.

What an attacker gets. The rendered content of a post they may not read. Scheduled announcements before the date. Text an author deleted into the trash. The body of a revision. Records held in a private post type. No write, no code execution, no session. This is a read across an authorization boundary, and the read returns the post as the front end would print it.

Where the diff stops. The hunk begins mid function, after an earlier return '';. The lines that read $id from the shortcode attributes are not in the diff, and neither is the render call below the guard. The shortcode tag [hfe_template] comes from the vendor’s release note, not from the changed lines. So the diff proves the check was too narrow. It does not show whether $id can reach the callback from a URL parameter, and no unauthenticated request route is established here.

The SVG upload handler

The other file, inc/class-hfe-settings-page.php, loses a filter registration from its constructor:

if ( version_compare( get_bloginfo( 'version' ), '5.1.0', '>=' ) ) {
	add_filter( 'wp_check_filetype_and_ext', [ $this, 'real_mime_types_5_1_0' ], 10, 5 );
} else {
	add_filter( 'wp_check_filetype_and_ext', [ $this, 'real_mime_types' ], 10, 4 );
}

wp_check_filetype_and_ext is the WordPress filter that decides what a file really is. WordPress calls it inside _wp_handle_upload for every upload on the site. The callback receives the detected MIME type as its fifth argument:

public function real_mime_types_5_1_0( $defaults, $file, $filename, $mimes, $real_mime ) {
	return $this->real_mimes( $defaults, $filename, $file );
}

$real_mime holds what the server decided the bytes actually are. The wrapper drops that argument on the floor and never passes it on. real_mimes() then decides by filename alone:

public function real_mimes( $defaults, $filename, $file ) {

	if ( 'svg' === pathinfo( $filename, PATHINFO_EXTENSION ) ) {
		$svg_content           = file_get_contents( $file );
		$sanitized_svg_content = $this->sanitize_svg( $svg_content );
		file_put_contents( $file, $sanitized_svg_content );

		$defaults['type'] = 'image/svg+xml';
		$defaults['ext']  = 'svg';
	}

	return $defaults;
}

Follow the return value. _wp_handle_upload reads $wp_filetype['ext'] and $wp_filetype['type'] from this array. When either is empty, WordPress rejects the upload with “Sorry, you are not allowed to upload this file type.” SVG is not in get_allowed_mime_types() on a default install, so a plain WordPress site returns empty values and refuses the file. This filter fills both fields in, so the upload passes.

That is the important part. Installing the plugin turned SVG uploads on for the whole site. The site owner never chose it, no setting controlled it, and the filter ran on every upload including uploads made by other plugins. Once the file passed, the site’s safety rested on one function: sanitize_svg().

Inside the sanitiser. The function is a hand-written allowlist of 31 tags and an allowlist of attributes, plus a blocklist of attribute values. Parts of it are sound. script and foreignObject are absent from $allowed_tags, so the two obvious routes to script execution get removed with the element. Event handler names such as onload are absent from $allowed_attributes, and they do not start with aria- or data-, so the attribute loop strips them.

The gaps show up where the function trusts a single pass or a single spelling.

The PHP tag strip re-checks its own work:

$content = preg_replace( '/<\?(=|php)(.+?)\?>/i', '', $original_content );
$content = preg_replace( '/<\?(.*)\?>/Us', '', $content );
$content = preg_replace( '/<\%(.*)\%>/Us', '', $content );

if ( ( false !== strpos( $content, '<?' ) ) || ( false !== strpos( $content, '<%' ) ) ) {
	return '';
}

The author knew that preg_replace() runs once, and that removing a match can join the characters on either side into a fresh match. So the code looks again and bails out when the marker survived. The CSS strip has no such second look:

$css = $style_element->textContent;
$css = preg_replace( '/@import\s+[^;]+;?/i', '', $css );
$css = preg_replace( '/url\s*\([^)]+\)/i', '', $css );
$style_element->textContent = $css;

Same one-pass removal, no follow-up strpos() check. Put one copy of the token inside another and preg_replace() removes the inner copy. The outer characters then sit next to each other and spell the token again. The result goes straight into textContent and no code reads it a second time. That is the same class of trick as a blocklist key that hides an extension inside itself, and the file already contains the correct defence twenty lines higher up.

The attribute value blocklist is a blocklist:

if ( ! in_array( $attr_name_lowercase, $allowed_attributes ) &&
	! preg_match( '/^aria-/', $attr_name_lowercase ) &&
	! preg_match( '/^data-/', $attr_name_lowercase ) ) {
	$current_element->removeAttribute( $attr_name );
	continue;
}

$attr_value = $current_element->attributes->item( $i )->value;
if ( ! empty( $attr_value ) &&
	( preg_match( '/^((https?|ftp|file):)?\/\//i', $attr_value ) ||
	preg_match( '/base64|data|(?:java)?script|alert\(|window\.|document/i', $attr_value ) ) ) {
	$current_element->removeAttribute( $attr_name );
	continue;
}

The first regex is anchored with ^, so it only rejects a value that begins with a scheme or with //. A value with a URL in the middle passes it. The second regex matches literal words. Both are pattern lists that have to name every bad thing in advance, and style is an allowed attribute name while url is not in the word list. So url() inside a style attribute survives, even though the same construct inside a <style> element gets stripped. Two paths through the same document, two different rules.

The external reference rule looks at one spelling of one attribute:

$xlink_href = $current_element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
if ( $xlink_href && strpos( $xlink_href, '#' ) !== 0 ) {
	$current_element->removeAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
}

getAttributeNS() reads the namespaced xlink:href and nothing else. Plain href is in $allowed_attributes, and SVG 2 honours plain href on <a> and on <use>. The rule that says a reference must start with # therefore applies to the old spelling and skips the current one.

What the diff supports, and what it does not. The vendor states that the legacy sanitiser carried a stored XSS vector. The removed code shows a hand-rolled filter with a one-pass CSS strip, a word blocklist and a namespace check that misses the modern attribute name. It does not contain a complete script execution path that our analysis can trace end to end, so this post does not claim one. What the code does prove on its own is the reachability: any file named .svg was accepted site-wide because the plugin overwrote the MIME result and threw away the real detected type.

Who is exposed

Sites running 2.9.3 or earlier.

For the shortcode leak, a page or post must contain the [hfe_template] shortcode pointing at the hidden content. Somebody who can edit posts places it. On a site with contributors, authors or guest writers, that is a low privilege user. The person who then reads the content needs no account at all, because the guard applied to anonymous visitors the same way it applied to a subscriber.

What narrows it: draft, pending and private posts were already blocked in 2.9.3, and so were password-protected posts. The old post_password_required() call survives into 2.9.4 unchanged. Users who can edit the target post could always preview it in any status, before and after the patch, and that is intended behaviour.

What widens it: the missing post type check meant a published post of a non-public post type rendered even though nothing else on the site would show it. Any second plugin that registers its own post status also fell outside the old list.

The diff does not show how $id reaches the callback. Treat this as content disclosure that needs an editable post carrying the shortcode, not as a bug an anonymous visitor triggers from a URL on their own.

For the SVG handler, exposure is wider and shallower. The filter registered in the settings page constructor, so it changed uploads on every site with the plugin active, whatever the site’s own SVG policy was. Actually uploading a file needs the upload_files capability, which authors and above hold by default. Contributors do not. Sites that already allow SVG through another plugin were in the same position with or without this handler, except that this handler rewrote the file in place before anything else saw it.

What to do

Update to 2.9.4. The plugin is on the wordpress.org directory, so the normal update screen carries it.

Search your posts and pages for the text hfe_template. For every match, check the id it points at and open that post in the admin. A shortcode pointing at a scheduled, trashed or private post type entry is the exact case the old guard let through. Look at who wrote the post carrying the shortcode, and when.

Check your media library for .svg files. Note the upload date and the uploader for each one. An SVG uploaded by a non-administrator on a site whose owner never enabled SVG uploads is worth opening in a text editor. Look for a <style> element with CSS that pulls an external reference, and for href on <a> or <use> pointing off site.

Check your access logs for direct requests to .svg files under wp-content/uploads, especially a request that follows soon after an upload by a low privilege account. A stored SVG only runs anything when a browser opens the file itself, so a direct hit is the signal.

Expect a behaviour change after updating. SVG uploads now depend on Elementor’s own upload control and its own sanitisation. A site that relied on this plugin to accept SVG files will start rejecting them until that control is switched on.

The patch itself is small and it does two clean things. The shortcode now requires a published post of a viewable type for anyone without edit_post on that post, and it refuses revisions and autosaves for everybody. The settings page no longer filters wp_check_filetype_and_ext, so WordPress and Elementor decide what an upload is, and 368 lines of hand-written SVG parsing stop running on your site.

More research

Segurium is a WordPress malware scanner and cleanup tool on the official directory at wordpress.org/plugins/segurium/.