esc_sql() has a reassuring name. It is also one of the easiest WordPress functions to use in the wrong place.
It escapes characters inside a SQL string. It does not turn arbitrary input into an integer, add quotes around a value or build a prepared statement. Put its output bare inside an IN (...) clause and an attacker may not need a quote in the first place.
That is CVE-2026-39531. WP Directory Kit accepted a POST parameter named filter_ids, ran it through sanitize_text_field() and esc_sql(), then concatenated it into a numeric IN list. The same sink appeared three times across two methods in the frontend AJAX controller. Both methods were reachable through the plugin's public dispatcher. A remote visitor could alter the query and extract data from the WordPress database.
This was found during the same review that produced CVE-2026-39534. That one was missing authorisation in a dynamic model loader. This one was lower in the same controller and had nothing to do with the model name. Even when the caller chose a legitimate model, filter_ids still became SQL syntax.
Here is the vulnerable expression from version 1.5.0:
if (!empty($parameters['filter_ids'])) {
$this->db->where(array(
esc_sql($this->$table->_table_name.'.'.$this->$table->_primary_key)
.' IN ('.esc_sql($parameters['filter_ids']).')' => NULL
));
}The intended input was a comma-separated list such as:
12,18,31That produces a condition like:
wp_wdk_locations.idlocation IN (12,18,31)There are no quotes around the values. Escaping quotes therefore does not enforce the grammar the developer expected. Parentheses, commas, operators, comments and function calls are still SQL. An input shaped like 1) OR ... can close the list and append another expression without using a single quote.
The parameter had already passed through this loop:
foreach ($_POST as $key => $value) {
$parameters[$key] = sanitize_text_field($value);
}sanitize_text_field() is for normalising text for WordPress, not for making SQL safe. It strips tags, line breaks and some invalid encodings. SQL operators survive because they are perfectly valid text. The function does not know whether the value will later be used as a string, an integer, a list or a column name.
Then esc_sql() is asked to protect a value that is not being used as string content. It can escape a quote, but the input sits in a part of the query where parentheses and operators are enough. The defence and the attack are operating in different contexts.
The public path matters too. WP Directory Kit registers wp_ajax_nopriv_wdk_public_action, then its ajax_public handler reads the controller and method names from POST and dispatches to them. The vulnerable methods were treefieldid and treefieldid_checkboxes.
Both methods called check_ajax_referer() before building the query. That sounds like authentication until you trace where the nonce comes from. The plugin printed the corresponding nonce for its frontend controls on public pages. An anonymous visitor could load the page, collect the token and submit the AJAX request. The nonce proved that the request knew a short-lived value issued by the site. It did not prove that the caller was logged in.
This is normal WordPress behaviour. Nonces are anti-CSRF tokens, not access-control decisions. They are useful when tied to an authenticated session, and they can slow blind requests, but a nonce delivered to anonymous users cannot turn a public action into a private one.
The three vulnerable copies were:
- Two branches inside
treefieldid, depending on whether the request enableduser_check. - One branch inside
treefieldid_checkboxes.
That detail is easy to get wrong when reading a patch. Three changed lines do not necessarily mean three vulnerable methods. There were three sinks in two methods, and every copy needed the same fix.
I confirmed the injection with a harmless timing condition on a local installation. That was enough to show that SQL syntax after the closing parenthesis changed database execution. There was no need to retrieve a real password hash or dump a table. For a report, a stable delay compared with a control request proves the primitive while keeping the test data inside the lab.
The impact is still full database read in the worst case. The query runs as the WordPress database user, which normally has access to every table in that installation. A blind extractor can recover users, password hashes, session tokens, API credentials in wp_options and data belonging to other plugins. It is slower than a reflected UNION result, but it crosses the same database boundary.
The fix in 1.5.1 changed all three copies to this shape:
if (!empty($parameters['filter_ids'])) {
$this->db->where(array(
esc_sql($this->$table->_table_name.'.'.$this->$table->_primary_key)
.' IN ('.esc_sql(
preg_replace('/[^0-9,]/', '', $parameters['filter_ids'])
).')' => NULL
));
}The relevant addition is preg_replace('/[^0-9,]/', '', ...). It reduces the value to digits and commas before interpolation. Parentheses, spaces, comments and operators disappear, so the caller can no longer break out of the numeric list.
For this exact input format, that closes the injection. I would still write the code differently. Parse the value into an array, validate each item and use placeholders:
$ids = array_values(array_filter(
array_map('absint', explode(',', $parameters['filter_ids']))
));
if ($ids) {
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$sql = $wpdb->prepare(
"{$table}.{$primary_key} IN ($placeholders)",
$ids
);
}Now the data structure is explicit. The application wants a list of positive integers, so it creates a list of positive integers. The placeholders describe the type at the database boundary. No generic escaping function has to guess what the developer meant.
There is a second reason to prefer parsing. The regex fix can turn malformed input into a different valid value. 1foo2 becomes 12; 1,,2 keeps an empty element; an input containing no digits can leave an empty IN (). Those are not injection paths, but strict parsing makes behaviour easier to reason about and errors easier to handle.
This class of SQL injection is worth hunting because it sits between two bits of code that look defensive. You see sanitize_text_field() at input and esc_sql() at the sink, and the first instinct is to move on. The right question is not "was it sanitised?". It is "does the validation match the SQL position where this value lands?"
These are the searches I use first:
# Every use of esc_sql needs its final SQL context
grep -rn "esc_sql" wp-content/plugins/target/
# Hand-built IN and NOT IN clauses
grep -rnE "(IN|NOT IN) \\(.*\\$" wp-content/plugins/target/
# Request values described as IDs but kept as strings
grep -rnE "filter_ids|include_ids|exclude_ids|selected_ids" \
wp-content/plugins/target/
# Raw where fragments passed into query builders
grep -rnE "->where\\(|where_raw|sql_where" wp-content/plugins/target/When a hit appears, write the final query down with a normal value. Mark whether the attacker-controlled part is inside quotes. If it is quoted, check whether the escaping survives until execution. If it is not quoted, try to describe the allowed grammar. For an ID list the only valid characters are digits and separators, and even then each element should be validated separately.
Do not stop at the sink. Walk backwards until you know who can reach it. In WordPress that means checking the AJAX hook, REST permission callback, shortcode or template action. Then look for nonces and trace where they are issued. A nonce fetched from a public page changes the request format, not the privilege level.
Also walk sideways. Copy-pasted query fragments often survive in sibling methods. Search for the parameter name and the exact IN ( construction across the whole plugin. This report would have been incomplete if it fixed only the first occurrence in treefieldid and left the second branch or checkbox method behind.
Patchstack received the report on 17 February 2026. The vendor shipped 1.5.1 in March, and the CVE was published on 13 April. The same release fixed the missing-authorisation issue from CVE-2026-39534, which is why both CVE pages point at the same plugin version and source revision.
The short version is simple: escaping is contextual. esc_sql() can help with SQL string content. filter_ids was not string content. It was a small piece of SQL grammar, inserted without quotes, and the attacker could write that grammar too.
