SQL injection has been on the OWASP Top 10 list for well over a decade. It shows up in CVE reports constantly. Security vendors cite it in every annual threat report. And yet most explanations of it are either too abstract to be useful or too academic to be interesting.
This is a concrete look at how SQL injection actually works in a WordPress context: not a warning, not a story, but a mechanical walkthrough of the attack. Understanding it at this level changes how you think about plugin updates and database access patterns.
What SQL Injection Is, Precisely
A web application takes input from a user (a search term, a username, a product ID) and uses that input to query a database. SQL injection happens when the application doesn’t properly separate the input from the query itself, allowing the input to be interpreted as SQL code rather than data.
That’s the precise definition. The implication is significant: when input becomes code, the attacker is no longer constrained to what the application was designed to retrieve. They can query any table, bypass authentication, read credentials, or in some configurations, execute operating system commands.
A Vulnerable WordPress Query
WordPress uses the $wpdb global object for database access. Developers can use it to write custom queries. Here’s a simplified example of the kind of code that appears in vulnerable plugins:
$search = $_GET['s'];
$results = $wpdb->get_results(
"SELECT * FROM wp_posts WHERE post_title LIKE '%" . $search . "%'"
);This looks functional. It takes a search term from the URL, drops it into a query, and returns matching posts. The problem is the direct concatenation. The value of $search is taken verbatim from the HTTP request and embedded in SQL syntax without any transformation. The database will execute whatever arrives there as part of the query.
What the Attacker Actually Sends
With the vulnerable query above, consider what happens when an attacker sends this as the search term:
%' UNION SELECT user_login, user_pass, user_email, NULL, NULL FROM wp_users -- The percent sign and apostrophe close out the original LIKE clause. The UNION SELECT appends a second query that reads from wp_users – WordPress’s user table. The double dash comments out the rest of the original query so it doesn’t break the syntax. The database receives this as a single valid query and returns usernames, password hashes, and email addresses alongside whatever posts matched.
The attacker doesn’t need access to the admin panel. They don’t need to guess a password. They need a vulnerable input field and a browser. The query executes with the same database permissions as the WordPress application itself – which typically has read and write access to the entire WordPress database.
Blind Injection: When There’s No Output
Not all injection vulnerabilities return data directly to the page. Blind SQL injection is common in more defensively coded applications where error messages are suppressed and query results aren’t echoed to the browser. The attack still works – it just requires a different technique.
With boolean-based blind injection, the attacker infers database contents by asking true/false questions. A payload like ' AND 1=1 -- returns normal results; ' AND 1=2 -- returns nothing. The attacker can use this to extract data one bit at a time – asking whether the first character of the admin password hash is greater than ‘M’, then ‘P’, then ‘S’, converging on the answer through bisection.
Automated tools like SQLMap can run thousands of such requests in seconds. A vulnerability that looks unexploitable because it returns no visible output can still be fully exploited against a large database in under an hour.
Time-based blind injection is a variation that doesn’t even need response differences. It uses SQL functions like SLEEP() or BENCHMARK() to cause measurable delays when a condition is true. The attacker reads the database through timing signals in the HTTP response latency.
How $wpdb->prepare() Actually Fixes This
WordPress’s built-in defense is $wpdb->prepare(). The secure version of the earlier query looks like this:
$search = '%' . $wpdb->esc_like( $_GET['s'] ) . '%';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM wp_posts WHERE post_title LIKE %s",
$search
)
);The placeholder %s tells prepare() that this position expects a string. The function escapes the value, wrapping it in quotes, prefixing special characters with backslashes – so that whatever the user sends, the database receives it as a literal string value, not as SQL syntax. The UNION SELECT, the comment markers, the closing quotes: all of it gets escaped and treated as a search string rather than a command.
This is parameterized querying. The query structure and the data are kept separate at the protocol level. The database knows what’s a query and what’s data before it ever touches the input, which is why this defense is robust even against sophisticated bypass techniques.
Where This Fails in Plugins
The pattern breaks down in several predictable places:
Unparameterized ORDER BY clauses. prepare() can’t parameterize column names or SQL keywords, only values. A plugin that builds an ORDER BY clause dynamically from user input (ORDER BY . $_GET['sort']) is vulnerable even if the rest of the query uses prepare(). The fix is an allowlist: only accept specific, hardcoded column names.
Nested queries and string building. Developers sometimes prepare a partial query, then concatenate it with other dynamically built segments before executing. The prepared portion is safe; the concatenated portion isn’t.
Unsanitized LIKE clauses. prepare() handles SQL injection, but it doesn’t handle LIKE wildcards. An input of % or _ will match everything in a LIKE clause, which may not be a security issue but is a correctness issue. The $wpdb->esc_like() function handles this separately, which is why it appears in the secure example above.
Second-order injection. Data is stored safely in the database after being sanitized on write, then retrieved and used unsanitized in a later query. The sanitization on write creates a false sense of safety; the injection happens on read.
What’s Actually at Risk in WordPress
The WordPress database contains the wp_users table, which stores usernames and password hashes for every account including admins. A SQL injection vulnerability with SELECT permissions on that table means those hashes are readable. Modern bcrypt hashes are slow to crack, but MD5-based hashes – still used in some legacy WordPress deployments – can be cracked at billions of attempts per second on consumer hardware.
Beyond credentials: wp_options stores site configuration including API keys and plugin license keys. wp_postmeta stores custom field data, which in WooCommerce stores includes order details and billing addresses. wp_usermeta stores user metadata. A plugin with write access (INSERT, UPDATE, DELETE) on a vulnerable query can modify any of this data, not just read it.
In environments where MySQL is configured with FILE privileges and the web server has write access to the filesystem, SQL injection can escalate to remote code execution via SELECT INTO OUTFILE. This is uncommon in hardened hosting environments but not rare in shared hosting configurations where those controls aren’t applied by default.
Recognizing It In the Wild
SQL injection vulnerabilities in published plugins show up in CVE databases regularly. The NVD entry format for WordPress CVEs typically identifies the affected component, the parameter that’s injectable, and the authentication requirement – whether the attack is accessible to unauthenticated visitors or requires at least a subscriber-level account. Unauthenticated SQL injection vulnerabilities are rated higher (CVSS 9.8 in critical cases) because they require no foothold at all.
When you see a plugin update described as “fixes SQL injection vulnerability in [parameter]” – even a minor-sounding update: that is a patch for something that was fully exploitable before the fix. The window between a vulnerability disclosure and patching on live sites is where active exploitation happens. Automated scanners probe for known-vulnerable versions within hours of a public disclosure.
The Practical Implication for Site Owners
You don’t need to audit plugin source code yourself. What matters is keeping plugins updated and knowing when a security-relevant update exists. Trusti Security’s vulnerability scanner checks your installed plugins against a database of known vulnerabilities, flagging when you’re running a version with a published CVE – so you have the information before the window closes, not after.
SQL injection is a solved problem at the code level. $wpdb->prepare() exists, it works, and it’s been documented in WordPress core for fifteen years. The vulnerabilities that appear in plugins aren’t mysterious – they’re the predictable result of missing a function call, or taking a shortcut in dynamic query construction. Understanding the mechanism makes the update notifications make sense, and makes it clearer why “I’ll update it next week” is a different calculation than it feels like.