Skip to content

Why so many WordPress plugins are vulnerable?


Look at any week of WordPress plugin disclosures on WPScan and the same handful of bug classes keep coming back: cross site scripting, SQL injection, missing authorization, cross site request forgery, arbitrary file access. Different plugins, different authors, different years, same short list.

That repetition is the interesting part. These are not exotic bugs. WordPress ships a function for every one of them, the functions have been there for a very long time, and they are documented in the security section of the developer handbook. Almost every vulnerability we read about is disuse or misuse of a core API that was already sitting there.

Here is what that looks like in code, class by class.

Escaping is a property of the context, not of the variable

Cross site scripting stays at the top of the list because escaping gets treated as a one time cleanup instead of a per output decision. A value gets sanitized once on the way in, and from then on it is considered clean, so it is printed into an attribute, a URL, and a script block with no further thought. Those are three different grammars, and each one needs its own escape.

Sanitizing on input and escaping on output are separate jobs. Sanitize when you store, escape at the moment you print, every single time, based on where the value lands:

Context Code sample Escape with
HTML body <div>DATA</div> esc_html()
HTML attribute <input value=”DATA“> esc_attr()
Textarea contents <textarea>DATA</textarea> esc_textarea()
href or src attribute <a href=”DATA“> esc_url()
Inline JavaScript string <script>foo(‘DATA‘);</script> esc_js(), or better, wp_localize_script()
Markup you intend to allow <div>POST BODY</div> wp_kses_post()
CSS value <div style=”width:DATA“> No core escaper. Match against known values yourself.

So this, which is the shape most reported XSS takes:

<?php
// Wrong. $_GET is attacker controlled, and neither line escapes.
echo '<h2>Results for ' . $_GET['q'] . '</h2>';
echo '<input type="text" name="q" value="' . $_GET['q'] . '">';

becomes this:

<?php
$q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';

echo '<h2>' . esc_html( sprintf( __( 'Results for %s', 'my-plugin' ), $q ) ) . '</h2>';
echo '<input type="text" name="q" value="' . esc_attr( $q ) . '">';

Two details that catch people out. First, data read back from the database is untrusted too: it was user input at some earlier point, possibly before a validation rule existed. Second, the translation functions do not escape. __() and _e() print whatever the translation file contains, so reach for esc_html__() and esc_attr_e() instead. The handbook covers the full set under escaping data, and OWASP explains the underlying grammar problem in the XSS prevention cheat sheet.

SQL injection: prepare, always

$wpdb has had a parameterized query method for as long as most plugins have existed, and injection still shows up in disclosure feeds. The pattern is nearly always string concatenation into a query:

<?php
// Wrong. Quotes around the placeholder do not save you either.
$id  = $_GET['user_id'];
$row = $wpdb->get_row( "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = $id" );
<?php
// Right. Placeholders are typed, and prepare() handles the quoting.
$row = $wpdb->get_row( $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = %d AND status = %s",
    $_GET['user_id'],
    'active'
) );

Note what does not go through prepare(): the table name. Concatenating $wpdb->prefix is fine because it comes from your own configuration, but if a table or column name is ever derived from a request, use the %i identifier placeholder that prepare() gained in WordPress 6.2, or validate the name against a fixed list before it reaches the query. See the wpdb::prepare() reference and the SQL injection prevention cheat sheet.

There is a second, sneakier version of this class: a query that is written correctly but sits behind a handler anybody can reach. The classic misuse is is_admin(), which does not mean what its name suggests. It returns true when the request is for an admin screen, whether or not the visitor is logged in as anything at all. It is a context check, not an identity check, and it has protected more than one export or import routine that turned out to protect nothing.

A nonce is not authorization

This is the single most common misunderstanding we see. A nonce answers “did this request come from a form I generated, recently, for this user?” It says nothing about whether that user is allowed to do the thing. Those are two questions and they need two checks:

<?php
// Wrong. Any logged in subscriber can load the form, so any logged in
// subscriber gets a valid nonce for it.
function my_plugin_save() {
    check_admin_referer( 'my-plugin-save' );
    update_option( 'my_plugin_settings', $_POST['settings'] );
}
<?php
// Right. Capability first, then origin, then sanitize.
function my_plugin_save() {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( esc_html__( 'You are not allowed to do this.', 'my-plugin' ) );
    }

    check_admin_referer( 'my-plugin-save' );

    update_option( 'my_plugin_settings', my_plugin_sanitize( wp_unslash( $_POST['settings'] ) ) );
}

Reusing one nonce action across screens with different privilege levels makes it worse: a low privileged user gets handed a token that a high privileged handler will happily accept. Give each action its own nonce action string, and check the capability that action actually requires rather than defaulting to manage_options everywhere. Both current_user_can() and nonces are documented in the handbook.

CSRF: the check that is simply absent

Cross site request forgery is the flip side of the same coin, and its cause is usually not a subtle mistake but an omission. A settings form has no wp_nonce_field() in it, or the handler never validates the one that is there. An administrator with a live session clicks a link somewhere else, their browser sends the cookie, and the change goes through.

Checking authentication at save time does not help here, because the request genuinely is authenticated. The browser is doing exactly what it was told. Only proof of origin closes it, on every state changing request, including AJAX handlers (check_ajax_referer()) and anything registered on admin_post_. If a request writes something, it needs a nonce.

File inclusion and arbitrary file download

The last class comes from passing request data to a filesystem or include function without constraining it:

<?php
// Wrong. ../../../wp-config.php is a valid value for $_GET['tpl'].
include plugin_dir_path( __FILE__ ) . 'templates/' . $_GET['tpl'] . '.php';

Filtering for ../ is not the fix, because there are always more encodings than you thought of. Validate against a list of values you control instead:

<?php
$allowed = array( 'list', 'single', 'archive' );
$tpl     = isset( $_GET['tpl'] ) ? sanitize_key( wp_unslash( $_GET['tpl'] ) ) : 'list';

if ( in_array( $tpl, $allowed, true ) ) {
    include plugin_dir_path( __FILE__ ) . 'templates/' . $tpl . '.php';
}

Related, and still common: PHP files inside a plugin directory that are meant to be called directly over HTTP. A file reached that way bootstraps nothing, so current_user_can() and the nonce functions are not even loaded, and every protection you wrote elsewhere is bypassed. Route the request through WordPress instead (an AJAX action, a REST route, or an admin_post_ handler) and put defined( 'ABSPATH' ) || exit; at the top of every PHP file you ship.

Why it keeps happening

None of this is difficult, which makes the recurrence worth explaining. Plugins grow past the point where one person holds the whole codebase in their head. Admin screens feel private, so checks get skipped there first. Tutorials from a decade ago are still the top search result for plenty of these tasks. And WordPress will never stop you: unescaped output renders, an unprepared query runs, a handler without a capability check works perfectly for the developer testing it as an administrator.

So the assumptions worth writing on the wall, for anyone shipping plugin code:

  • All input is contaminated, including input read back out of the database.
  • Every output context needs its own escape, at the moment of output.
  • Authentication, origin, and authorization are three different questions.
  • Requests will arrive by routes you did not design.
  • If a core function exists for the job, use it. It has been reviewed by more people than your code has.

If you run sites rather than write plugins

You cannot audit every plugin you install, and you should not have to. Three things do most of the work:

Update promptly. Nearly all of these get fixed before they get exploited at scale, so the window that matters is between the patch and your install of it. Automatic updates for plugins you trust, and a habit of checking the rest weekly, closes most of it.

Reduce the attack surface. A deactivated plugin is still files on disk that a direct request can sometimes reach. Delete what you do not use, including old themes, and keep the number of administrator accounts as small as the site can function with.

Stop serving traffic you were never going to serve. Most of the automated probing against a WordPress site comes from places where the site has no audience at all. If your customers are in two states, requests to your login page from the other end of the world are not going to become anything good. Restricting who can reach the sensitive endpoints does not fix a vulnerable plugin, but it takes your site out of the path of the broad, indiscriminate scanning that finds most of them, and it buys you time on the days when an update is not out yet. That is what our plugin does, down to state and region level if a country is too coarse for your audience.