All posts
Finding CVEs in WordPress: CVE-2026-39534, missing authorisation in WP Directory Kit

Finding CVEs in WordPress: CVE-2026-39534, missing authorisation in WP Directory Kit

This post documentsCVE-2026-39534Read the full CVE entry →

Some WordPress plugins ship their own MVC. A controller class with methods, a dispatcher that reads which method to call from the request, models you can load by name. The dispatcher becomes a single perimeter for every method in every controller the plugin ships. If that perimeter does not check authentication, every method behind it is an unauthenticated endpoint, no matter what the method actually does.

That's the shape of CVE-2026-39534. WP Directory Kit is a WordPress plugin for building searchable directories of listings, locations and categories, with about 3,000 active installs. Its frontend AJAX controller exposes a select_2_ajax method that loads a model by name from the request body and dumps the model's rows. No authentication check. No nonce. No allowlist on the model name. Send table=user_m and the response is every directory-agent user the site has, straight out of the WordPress user table. Patchstack triaged the report. The vendor patched in 1.5.1.

Here is how the request reaches the vulnerable method. The plugin registers two AJAX hooks in includes/class-wpdirectorykit.php:

$this->loader->add_action('wp_ajax_wdk_public_action',        $plugin_public, 'ajax_public');
$this->loader->add_action('wp_ajax_nopriv_wdk_public_action', $plugin_public, 'ajax_public');

The _nopriv_ variant means anonymous visitors can call the action. The handler is ajax_public, in public/class-wpdirectorykit-public.php line 289:

public function ajax_public()
{
    $page = '';
    $function = '';

    if(isset($_POST['page']))     $page     = sanitize_text_field($_POST['page']);
    if(isset($_POST['function'])) $function = sanitize_text_field($_POST['function']);

    /* protect access only to ajax controller */
    if($page != 'wdk_frontendajax' && $page != 'wdk_backendajax') {
        exit(esc_html__('Access denied','wdk-bookings'));
    }

    $WMVC = &wdk_get_instance();
    $WMVC->load_controller($page, $function, array());
}

The check is real but narrow. It rejects any request that names the wrong controller, which is what the comment promises. What the comment does not say, and what nothing else in the dispatcher does, is verify the caller. The nopriv hook accepts anonymous traffic. The handler accepts anonymous traffic. Past this gate, every public method on the controller named in $page is reachable by sending function=<method> in the same POST body.

The controller it dispatches to is Wdk_frontendajax.php. One of its public methods is select_2_ajax, starting at line 554 of the 1.5.0 source. The relevant excerpt:

public function select_2_ajax($output="", $atts=array(), $instance=NULL)
{
    $this->load->load_helper('listing');
    $this->load->model('listing_m');
    $this->load->model('listingfield_m');

    $data = array();
    $parameters = array();

    foreach ($_POST as $key => $value) {
        $parameters[$key] = sanitize_text_field($value);
    }

    if(empty($parameters['table'])) return false;

    $model_name = $parameters['table'];

    // ... build search/key/print columns ...

    $this->load->model($model_name);

    // ... search filter logic ...

    $db_results = $this->$model_name->get_pagination($limit, $offset, $where);

    foreach($db_results as $row) {
        $results[] = [
            'id'   => wmvc_show_data($key_column, $row),
            'text' => $level_gen.esc_html__(trim(wmvc_show_data($print_column, $row)), 'wpdirectorykit'),
        ];
    }

    $data['results'] = $results;
    $this->output($data);
}

$parameters['table'] is the attacker's input. The method calls $this->load->model($model_name) to load it, then $this->$model_name->get_pagination(...) to dump rows. The model class has to exist for the load to succeed, which is the only soft gate on the input. Every model the plugin ships is reachable.

The plugin ships a user_m model that wraps the WordPress user table for the plugin's own dashboard. Its get_pagination joins wp_users with wp_usermeta and applies a default meta_value LIKE '%wdk_agent%' filter against wp_capabilities, so the rows that come back are the plugin's directory-agent users, not the full WordPress user table. The SELECT list is hardcoded in the model: ID, user_login, user_nicename, user_email, display_name, user_status, user_url plus a listings counter. Those are the columns the dispatcher can then read per row.

The key_column and print_column parameters are attacker-controlled, but only as labels. The dispatcher reads each one off the row object that the model returned. If the column isn't in the model's hardcoded SELECT, the property doesn't exist and the value comes back empty. user_pass is not selected, so this method does not expose password hashes. The leak is the columns the model already fetched: ID, login, email, display name and the rest. One unauthenticated POST to the admin-ajax endpoint with the right parameter set returns the email and login of every directory-agent user on the site, paginated. On a site running a paid-listings directory, the agent users are the customer base.

Patchstack scored this CVSS 7.5: network attack vector, no authentication, no user interaction, high confidentiality impact, on the basis that uncontrolled disclosure of the directory membership is the worst-case outcome on a typical WPDK deployment. The plugin's controller is a thin proxy to read every row of any model it ships, scoped by whatever filter that model bakes into its own queries. I'm holding the exact request body back here, the unauthenticated path is in the Patchstack and NVD records linked from the CVE page if you need it for testing your own install.

The same flaw is reachable through any model the plugin loads. listing_m, category_m, location_m, every listing-field model. Each one is a separate query against a separate table, no authentication on any of them. On a site that uses the plugin for paid listings, that's the customer database. On a site that uses it for member-only directories, that's every member.

The fix in 1.5.1 adds two things to the start of select_2_ajax:

public function select_2_ajax($output="", $atts=array(), $instance=NULL)
{
    check_ajax_referer('wdk_secure_ajax', 'wdk_secure');

    $this->load->load_helper('listing');
    $this->load->model('listing_m');
    $this->load->model('listingfield_m');

    // ... parameter collection unchanged ...

    $model_name = $parameters['table'];

    // allow only 'listing_m', 'category_m', or 'location_m' for security
    $allowed_models = array('listing_m', 'category_m', 'location_m');
    if (!in_array($model_name, $allowed_models)) {
        return false;
    }

    // ... rest unchanged ...
}

Two locks, but they do not pull equal weight:

  • Nonce check. check_ajax_referer('wdk_secure_ajax', 'wdk_secure') requires the request to carry a wdk_secure parameter with a valid nonce for the wdk_secure_ajax action. The plugin's frontend JS exposes that nonce via wp_localize_script on every public page where the script loads, so a determined attacker can still grab it from the rendered HTML and reuse it. The check raises the cost of automation and stops CSRF. It does not by itself keep an unauthenticated attacker out.
  • Model allowlist. Hardcodes the three models the method is supposed to support. This is the load-bearing fix. Even with a valid nonce in hand, an attacker cannot pivot to user_m or any other model the plugin happens to load. The user-table dump is gone the moment the allowlist is in place.

The nonce raises the cost. The allowlist contains the blast radius. The CVE closes because of the second one.

The same audit that surfaced this BAC also found a SQL injection in three other methods of the same controller. The 1.5.1 changeset ships both fixes, which is why the same patch fixes a separate CVE.

The shape of CVE-2026-39534 is a pattern I look for in any plugin that builds its own MVC. The dispatcher is a single point that decides what gets called. The dispatcher's job is to enforce three things before invoking the method: who is calling (authentication), did they go through the legitimate UI (nonce) and is the method they named one we want exposed (allowlist). Plugins that wrap the dispatcher around tens of methods often check zero of the three. The method authors assume the dispatcher checked. The dispatcher author assumes the method checks. Nobody checks.

The broader pattern, beyond WordPress plugins, is anywhere a framework promotes "convention over configuration" for routing. Method names from URLs in Rails, action names from request bodies in CodeIgniter clones, RPC dispatchers that take the operation name from JSON. Every framework that does this requires the developer to think about authorisation at the dispatcher, not at the method. Whenever someone reaches for the convenience of dynamic dispatch, the perimeter has to come with it.

Now the part that matters if you're starting out. How do you find these yourself?

Custom-MVC plugins are a narrow slice of the WordPress ecosystem but a fertile one. Plugins that built their own framework early (LatePoint, WP Directory Kit, several membership and booking systems) all use this shape. The bugs are systematic when the perimeter checks are missing.

Three greps to surface the pattern.

# 1. Unauthenticated AJAX entry hooks
grep -rnE "add_action\(\s*['\"]wp_ajax_nopriv_" wp-content/plugins/target/

# 2. Dynamic method invocation in PHP (e.g. $this->$variable(...))
grep -rnE '\$this->\$[a-z_]+\(' wp-content/plugins/target/
grep -rnE 'call_user_func.*\$_(POST|GET|REQUEST)' wp-content/plugins/target/

# 3. Dynamic model or class loading from request input
grep -rnE '->load->model\(\s*\$' wp-content/plugins/target/
grep -rnE '->load_controller\(' wp-content/plugins/target/
grep -rnE 'new \$[a-z_]+\(' wp-content/plugins/target/

Each grep is a different layer of the same shape:

Grep 1 finds the entry hook. If you have a _nopriv_ hook, you have an unauthenticated route into the plugin. That is normal for read-only public endpoints like search and pagination. It becomes a problem when the route delegates dispatching to the request body.

Grep 2 finds variable-method dispatchers. $this->$function(...) is the classic PHP idiom for "call the method named in $function". When $function traces back to $_POST, every public method on the class is reachable from the request.

Grep 3 finds dynamic loading where the loaded thing's name came from the request. ->load->model($model_name) is CodeIgniter-style. new $class() is more general. Either way, the attacker chose what gets instantiated, and the audit question shifts to "what models or classes does the plugin ship that the method downstream will operate on".

When you find a hit, the audit walk is:

  1. Walk back from the dispatcher to the AJAX hook. If the hook is nopriv and the dispatcher does not call check_ajax_referer, current_user_can or is_user_logged_in, every method the dispatcher reaches is unauth. Stop and confirm.
  2. Walk forward to one method the dispatcher reaches. Check whether the method does any authorisation of its own. Most do not, because the method author trusted the dispatcher.
  3. List the methods the dispatcher reaches. For a CodeIgniter-style dispatcher, that's every public method on every controller the dispatcher can load. For a $this->$function dispatcher, that's every public method on the current class.
  4. List the models or downstream classes those methods load by name. That's the read surface.

The exploit path is usually the same across the methods the dispatcher reaches. Pick one. Build the request. The proof of concept is one curl. The reporter's job is to show the authentication bypass and one example of data exposure. The fix usually adds a nonce check at the dispatcher and tightens each method's input allowlist. For this CVE specifically, the impact ceiling is set by what the loaded model's get_pagination actually selects: a sibling method select_2_ajax_user is reachable through the same dispatcher and uses the same user_m model with a slightly broader wdk_* role filter, but the same hardcoded SELECT list, so the leak is the same shape either way. Patchstack and NVD carry the unauthenticated request format for anyone testing their own install.

For reporting, this one went through Patchstack. WP Directory Kit is in scope for both Patchstack and Wordfence at the time of writing. The audit produced two CVEs against the same controller five days apart and Patchstack handled both. Read each program's current rules before you submit and pick on response time, payout and the program's prior relationship with the vendor.

Timeline for CVE-2026-39534. 18 February 2026, vulnerability discovered, reported to Patchstack the same day. 8 April 2026, CVE published and the patch shipped in 1.5.1. Seven weeks end to end. The same audit produced CVE-2026-39531, a SQL injection in three adjacent methods of the same controller, published five days later and patched in the same 1.5.1 release.

The pattern this rounds out across the writeups so far: missing ownership checks (CVE-2025-3769), missing output escaping (CVE-2025-4392), missing input parameterisation (CVE-2026-39511) and now missing authorisation at the dispatcher (CVE-2026-39534). Four bug classes, one root shape each, all of them shipping in plugins right now. The dispatcher pattern is the one that scales worst, because each method behind a broken dispatcher is its own bug. CVE-2026-39534 is one method. The same controller has twelve other public methods behind the same dispatcher, and three of them were the subject of CVE-2026-39531, a SQL injection published five days later in the same 1.5.1 release. The fix had to land at the perimeter.

Want me to find this in your app, or learn to find it yourself?

Request a pentestBook mentoring
← PreviousFinding CVEs in WordPress: CVE-2026-39511, SQL injection in WP Photo Album PlusNext →CrowdSec AppSec WAF bypass via chunked transfer encoding (CVE-2026-44982)