Database and code execution
Prepared Statements and Their Limits in wpdb
What $wpdb->prepare() actually does to a query, why placeholders bind values only, and how %i stops the ORDER BY injection that %s does not.
You add $wpdb->prepare() to a query, the plugin passes review, and
six months later a report comes in that a customer can read other customers' orders.
The prepared call is still there. It was doing exactly what it was asked to do, and it
never covered the part of the query the attacker used.
The name is the source of most of the confusion. A prepared statement in PDO or
MySQLi sends the query and the data as two separate things, so the server parses the
statement knowing that a marker is a value and can never be syntax. WordPress does not
do that. prepare() is a formatting function that runs in PHP, substitutes
your arguments into the query string, and hands the finished string to
mysqli_query(). There are no bound parameters anywhere in the path.
That distinction matters because a formatter can only help where a value is allowed
to appear. Table names, column names and a sort direction are structure. A placeholder
for them would be meaningless to a function that escapes values, which is why
%s cannot be used for a column and why WordPress needed a separate
placeholder for identifiers.
Every placeholder is substituted before the SQL is sent
Start with the part that is easy to get wrong. On a lab table holding five orders, this query is safe and the substitution is visible in the finished string:
global $wpdb;
$reference = 'ORD-1001';
$sql = $wpdb->prepare(
"SELECT id, reference, customer FROM {$wpdb->prefix}m4_orders WHERE reference = %s",
$reference
);
SELECT id, reference, customer FROM wp_m4_orders WHERE reference = 'ORD-1001'
The quotes around ORD-1001 were not in the query you wrote. They were
added by prepare(), because a single %s with no format
specifier is treated as a string value and quoted. The quote characters the reader
supplies are escaped first: pass x' OR '1'='1 and the result is
'x\' OR \'1\'=\'1', which MySQL reads as one string literal containing an
apostrophe. That is the behaviour most people mean when they say WordPress escapes the
value for you.
The other three placeholders exist so that you do not have to guess what MySQL will
do with a string. %d casts to an integer. Pass '12abc' to
%d and the finished string contains 12 with no quotes.
%f casts to a float and formats it with six decimal places, so
'1e3' becomes 1000.000000, again unquoted.
One exception is worth memorising because it is documented and easy to trip over.
A numbered placeholder is not quoted for you. The manual page for
wpdb::prepare() says that numbered or formatted placeholders such as
%1$s and %5s are left as they are for backward
compatibility. So this produces invalid SQL:
$sql = $wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}m4_orders WHERE reference = %1\$s",
'ORD-1002'
);
SELECT id FROM wp_m4_orders WHERE reference = ORD-1002
If you need a numbered placeholder, you supply the quotes yourself. The plain
%s form needs nothing.
A placeholder binds a value, and only a value
SQL has two kinds of slot. A value slot is where the grammar expects an expression:
somewhere a string or a number could legally stand. A structure slot is where the
grammar expects a name or a keyword: after FROM, after
ORDER BY, and the ASC or DESC that follows a
sort column.
Escaping answers the question "how do I make this text into a value". It has no answer for "how do I make this text into a column name", because the correct output for a value is a quoted literal and the correct output for a column name is a backtick identifier, and the two are not interchangeable. Hand an escaped value to a structure slot and MySQL does not complain. It reads the quoted literal in that position as a constant expression, which is legal almost everywhere, and carries on.
That is the failure mode to internalise. There is no error, no log line and no exception. The query returns the wrong rows, or the same rows in an arbitrary order, and everything downstream looks like it works.
Why prepare() cannot make a column name safe
You can see the substitution logic in wp-includes/class-wpdb.php. A
%s argument is passed through mysqli_real_escape_string()
and then vsprintf() writes the result into the query. A
%i argument goes through a different branch that doubles any backtick
and wraps the result in backticks. The two branches produce strings that are
respectively correct inside quotes and correct as a name, and neither one can be
asked for the other's job.
An ORDER BY built from %s is still an injection you can reach
Take a sort control that passes its request value into a prepared statement. Both
queries below are the real output of prepare() against the lab table:
$orderby = $_GET['orderby'] ?? 'reference';
$sql = $wpdb->prepare(
"SELECT id, reference FROM {$wpdb->prefix}m4_orders ORDER BY %s",
$orderby
);
SELECT id, reference FROM wp_m4_orders ORDER BY 'reference'
MySQL accepts that statement. It sorts by the constant string
'reference', which is the same value for every row, so the ordering is
whatever the storage engine returns. The page looks fine. Now pass the sort direction
from the same request, and let the reader supply the string that used to be appended to
the query:
// The query as a maintainer would write it after a review that said
// "use prepare() for request values".
$orderby = $_GET['orderby'];
$sql = "SELECT id, reference FROM {$wpdb->prefix}m4_orders ORDER BY " . esc_sql( $orderby );
With orderby set to
id, (CASE WHEN (SELECT COUNT(*) FROM wp_users) > 0 THEN id ELSE reference END) DESC,
the finished query is a valid statement and MySQL executes the subquery:
SELECT id, reference FROM wp_m4_orders ORDER BY id, (CASE WHEN (SELECT COUNT(*) FROM wp_users) > 0 THEN id ELSE reference END) DESC
rows: 5 error: (none)
1 ORD-1001
2 ORD-1002
3 ORD-1003
4 ORD-1004
5 ORD-1005
Five rows came back and no error was logged. The attacker learned that
wp_users has at least one row, and by changing the condition can walk the
rest of the schema and the data one bit at a time. Nothing here is exotic. The
subquery is legal because ORDER BY accepts expressions, and
esc_sql() is irrelevant because the payload contains no quote characters
to escape.
%i escapes as an identifier, which is a different kind of safety
WordPress 6.2 added %i for identifiers. It quotes the argument with
backticks and doubles any backtick inside it, so the value in that position can only
ever be read as one name. The plugin-facing check for it is
$wpdb->has_cap( 'identifier_placeholders' ), which returns true here.
The same payload through %i:
$sql = $wpdb->prepare(
"SELECT id, reference FROM {$wpdb->prefix}m4_orders ORDER BY %i",
$orderby
);
SELECT id, reference FROM wp_m4_orders ORDER BY `id, (CASE WHEN (SELECT COUNT(*) FROM wp_users) > 0 THEN id ELSE reference END) DESC`
WordPress database error Unknown column 'id, (CASE WHEN (SELECT COUNT(*) FROM wp_users) > 0 THEN id ELSE reference END) DESC' in 'order clause' for query ...
MySQL looked for a column whose name contains the whole payload, did not find one, and refused the query. The value the reader controls is now a name, and a name is a thing the schema either has or does not have.
Two limits come with %i. It escapes, it does not validate: the payload
above is still visible in the error message, so a site that prints database errors to
the browser now leaks the attacker's own input back to them, which is harmless, and
would leak real column names in other cases. And a dotted name is quoted as one
identifier, so %i with wp_m4_orders.total asks for a column
called wp_m4_orders.total and fails. To reach a qualified column you write
%i.%i and pass the parts separately. Neither limit matters if the argument
came from an allowlist in the first place, which is where the next lesson goes.
prepare() reports misuse and then does something anyway
Three of the mistakes above produce a notice rather than an exception, and it is worth knowing which, because in production the notice is frequently the only signal you get.
| Input | What prepare() does | Return value |
|---|---|---|
Query contains no % at all |
Fires a _doing_it_wrong() notice: the query argument must have a placeholder |
The original query, unchanged |
| Fewer arguments than placeholders | Fires a notice naming the two counts | An empty string, which will fail as a query |
| An argument is an array or an object | Fires an unsupported value type notice for each | The value is replaced with an empty string |
One argument used as both %i and %s |
Fires a notice listing the conflicting placeholders | null, so the query is never run |
The first row is the one that catches people who wrap an already-safe query in
prepare() to be tidy. It returns the query you passed and adds a debug
notice, so the call has no effect except noise. That is the intended behaviour: a
prepared query with no placeholders means either the query is fully static, in which
case the call is unnecessary, or something was supposed to be interpolated and was not.
Both are worth knowing about at development time and neither should be discovered in
production for the first time.
The rows about counts and value types are the reason to run with
WP_DEBUG enabled on a staging copy. prepare() returning an
empty string does not throw, and a caller that ignores the return value will run an
empty query against the database and treat the empty result as "no records
found".
One last property to keep in mind: the escape that prepare() performs
is the same mysqli_real_escape_string() that esc_sql() uses.
Its correctness depends on the session's SQL mode, because under
NO_BACKSLASH_ESCAPES the backslash before a quote stops being an escape
character. In practice prepare() survives that mode and a hand-quoted string does
not, but the reason is not that the escaping is better. It is that
prepare() places the quote characters itself, so there is no mismatch
between what the developer quoted and what the session thinks a quote means. The next
lesson shows the two queries side by side.
On a WordPress 7.0.x install you control, register a shortcode that lists
orders from a table of your own. Give it one attribute, sort, and
accept any string the reader sends.
add_shortcode( 'm4_orders', function ( $atts ) {
global $wpdb;
$atts = shortcode_atts( array( 'sort' => 'reference' ), $atts );
$sql = $wpdb->prepare(
"SELECT id, reference FROM {$wpdb->prefix}m4_orders ORDER BY %s DESC",
$atts['sort']
);
// ... render the rows
} );
Then do three things. First, prove that the shortcode returns the rows in
insertion order rather than in the order you asked for, and explain why without
looking at the database error log. Second, print the string
prepare() produced and identify every character that was not in your
query literal. Third, change the attribute so that it accepts
sort="reference" and sort="total" and refuses anything
else, and say what the shortcode renders for
sort="reference; DROP TABLE wp_m4_orders".
Show the solution and the reasoning
The shortcode returns rows in insertion order because ORDER BY %s
becomes ORDER BY 'reference' DESC. The %s branch of
prepare() treats the argument as a value, so it quotes it, and MySQL
reads a quoted string in an ORDER BY as a constant expression. Every row
has the same sort key, so the sort is a no-op and the rows arrive in whatever order
the table scan produced. Nothing is logged, because the statement is valid.
Printing the prepared string shows two characters that were not in the literal:
the pair of single quotes around the value. That is the whole of the difference
between this and a plain concatenation of esc_sql( $atts['sort'] ):
prepare() also decided where the quotes go.
The fix is to stop treating the sort key as a value and to bound it with an allowlist:
add_shortcode( 'm4_orders', function ( $atts ) {
global $wpdb;
$atts = shortcode_atts( array( 'sort' => 'reference' ), $atts );
$allowed = array( 'reference', 'total', 'customer', 'status' );
$column = in_array( $atts['sort'], $allowed, true ) ? $atts['sort'] : 'reference';
$sql = $wpdb->prepare(
"SELECT id, reference FROM {$wpdb->prefix}m4_orders ORDER BY %i DESC",
$column
);
// ... render the rows
} );
There are two independent reasons this is safe, and it is worth being able to
name both. in_array() with strict comparison means the value is one of
four strings the developer wrote, so the attacker's input never reaches the query at
all. %i means that even if a future edit removes the allowlist, the
value can still only be read as a name. Writing both is not redundant. The allowlist
is the fix; %i is the property that keeps a later mistake from being
exploitable.
For sort="reference; DROP TABLE wp_m4_orders", the attribute fails
the allowlist and the shortcode sorts by reference. The order rows
render normally and the table is untouched. Without the allowlist, the value would
reach %i and produce
ORDER BY `reference; DROP TABLE wp_m4_orders` DESC, which MySQL rejects
as an unknown column. The table would survive that too, but on the strength of
%i alone, and a rejected query is a worse outcome than a silently
correct one.
A reasonable alternative to the allowlist is a switch that maps request values to columns, which has the same effect and reads better when the public name differs from the column name. What does not work is a blocklist of dangerous characters, because the payload above contains no quotes, no semicolons that matter after escaping, and nothing else a filter would think to remove.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.