Unsafe SQL Calls, Vulnerable Examples, and an Automatic SQLi Attack Notifier

1 — Why SQL Injection matters for WordPress

SQL injection occurs when an attacker manipulates input that is later concatenated into SQL statements on the server, allowing arbitrary SQL commands to run against your database. WordPress core provides DB helpers ($wpdb) and internal APIs designed to prevent SQLi, but third-party plugins, themes, or custom code that build queries by concatenating raw input are where attackers find entry points. Vulnerabilities have been discovered repeatedly in plugins and occasionally in core-related code (e.g., CVE-2022-21661), demonstrating that plugin/theme hygiene and patching are essential.

Impact: Data theft, creation of admin backdoors, site defacement, ransomware, and complete database compromise. Patching and input-hardening reduce risk substantially.

 

2 — Common sources of SQLi in WordPress

  • Direct string concatenation of $_GET, $_POST, $_COOKIE values in SQL.
  • Improperly handled AJAX endpoints (admin-ajax.php, custom REST endpoints).
  • Dynamic SQL identifiers (e.g., ORDER BY, LIMIT, column names) passed directly from user input.
  • Plugins that expose admin or public parameters and don’t use prepared statements.
  • Unpatched known vulnerabilities in plugins/themes (research & scanners like WPScan, Wordfence, Patchstack track these).

 

3 — Vulnerable code examples and safe alternatives

Below are realistic vulnerable examples you’ll encounter and safe refactors using WordPress best practices.

Note: Always test changes on staging before deploying to production.

Example 1 — Basic SELECT with raw $_GET (VULNERABLE)

   // vulnerable.php (DO NOT USE)
global $wpdb;
$post_id = $_GET['post_id']; // raw user input
$sql = "SELECT * FROM wp_posts WHERE ID = $post_id";
$rows = $wpdb->get_results( $sql );

Problem: Raw interpolation allows payloads like 1 OR 1=1 or 1; DROP TABLE ….

Safe alternative:

   // safe-select.php
global $wpdb;
$post_id = isset( $_GET['post_id'] ) ? intval( $_GET['post_id'] ) : 0;
$sql = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d", $post_id );
$rows = $wpdb->get_results( $sql );

%d enforces integer binding and prepare() separates SQL from data.

Example 2 — LIKE search without escaping (VULNERABLE)

   // vulnerable-search.php
$term = $_GET['q'];
$sql = "SELECT ID FROM wp_posts WHERE post_title LIKE '%$term%'";
$results = $wpdb->get_results( $sql );

Safe alternative (esc_like + prepare):

   // safe-search.php
$term = isset( $_GET['q'] ) ? wp_unslash( $_GET['q'] ) : '';
$term = sanitize_text_field( $term );
$like = '%' . $wpdb->esc_like( $term ) . '%';
$sql = $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s", $like );
$results = $wpdb->get_results( $sql );

esc_like() prevents LIKE-wildcard abuses; prepare() protects the value.

Example 3 — ORDER BY column injection (VULNERABLE)

   // vulnerable-orderby.php
$sort = $_GET['sort']; // attacker supplies malicious payload
$sql = "SELECT ID, post_title FROM wp_posts ORDER BY $sort";
$rows = $wpdb->get_results( $sql );

Safe approach — whitelist identifiers:

   // safe-orderby.php
$allowed = [
  'title' => 'post_title',
  'date'  => 'post_date',
  'id'    => 'ID',
];
$sort_key = isset($_GET['sort']) ? sanitize_key($_GET['sort']) : 'date';
$sort_column = $allowed[$sort_key] ?? $allowed['date'];
$order = ( isset($_GET['order']) && strtoupper($_GET['order']) === 'ASC' ) ? 'ASC' : 'DESC';
$sql = $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} ORDER BY $sort_column $order" );
$rows = $wpdb->get_results( $sql );

Identifiers cannot be parameterized with prepare() — only interpolate values you control and whitelist.

Example 4 — IN() clause from comma list (VULNERABLE)

   // vulnerable-in.php
$ids = $_GET['ids']; // "1,2,3"
$sql = "SELECT * FROM wp_posts WHERE ID IN ($ids)";
$rows = $wpdb->get_results( $sql );

Safe alternative — cast and use placeholders:

   // safe-in.php
$raw = isset($_GET['ids']) ? wp_unslash($_GET['ids']) : '';
$ids = array_filter(array_map('intval', explode(',', $raw)));
if (empty($ids)) { $rows = []; } else {
  $placeholders = implode(',', array_fill(0, count($ids), '%d'));
  $sql = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID IN ($placeholders)", ...$ids );
  $rows = $wpdb->get_results( $sql );
}

Each value is cast to int and bound via %d placeholders.

Example 5 — INSERT with concatenation (VULNERABLE)

   // vulnerable-insert.php
$username = $_POST['username'];
$email = $_POST['email'];
$sql = "INSERT INTO wp_custom_table (username,email) VALUES ('{$username}','{$email}')";
$wpdb->query($sql);

Safe alternative — use $wpdb->insert():

   // safe-insert.php
$username = isset($_POST['username']) ? sanitize_text_field(wp_unslash($_POST['username'])) : '';
$email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
$wpdb->insert(
  $wpdb->prefix . 'custom_table',
  [ 'username' => $username, 'email' => $email, 'created_at' => current_time('mysql') ],
  [ '%s', '%s', '%s' ]
);

$wpdb->insert() handles escaping and types.

Example 6 — AJAX handler: unsafe vs safe

Vulnerable

   add_action('wp_ajax_nopriv_search', 'bad_search');
function bad_search() {
  $term = $_POST['q'];
  $sql = "SELECT ID FROM wp_posts WHERE post_title LIKE '%$term%'";
  $results = $GLOBALS['wpdb']->get_results($sql);
  wp_send_json($results);
}

Safe

   add_action('wp_ajax_nopriv_search', 'good_search');
function good_search() {
  $term = isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : '';
  $like = '%' . $GLOBALS['wpdb']->esc_like($term) . '%';
  $sql = $GLOBALS['wpdb']->prepare("SELECT ID FROM {$GLOBALS['wpdb']->posts} WHERE post_title LIKE %s", $like);
  $results = $GLOBALS['wpdb']->get_results($sql);
  wp_send_json($results);
}

 

4 — Practical checklist for developers & site owners

  • Use $wpdb->prepare() for all custom SQL.
  • Prefer $wpdb->insert(), $wpdb->update(), $wpdb->delete() instead of raw SQL.
  • Use esc_like() for LIKE patterns, sanitize_*() functions for input, esc_*() for output.
  • Whitelist identifiers (columns, sort keys), never allow raw identifiers from users.
  • Validate & cast numeric values (intval, floatval) before binding.
  • Use WP_Query or other WP APIs wherever possible.
  • Keep WordPress core, themes, and plugins up-to-date; monitor CVEs and security advisories
  • Run periodic scans with WPScan, Wordfence, Patchstack and review reports.
  • Apply least-privilege to DB user (avoid DROP/ALTER if not needed).
  • Maintain regular off-site backups.

 

5 — SQL Injection Attack Notifier (ready-to-deploy MU-plugin)

Below is a production-grade lightweight SQLi Attack Notifier. Save it in wp-content/mu-plugins/sqli-attack-notifier.php (MU plugins run automatically) or wp-content/plugins/ and activate.

It inspects $_GET, $_POST, $_COOKIE, and REQUEST_URI for common SQLi patterns, logs the events into a DB table ({prefix}sqli_events), writes to error_log, emails the admin, and optionally sends alerts to a Slack webhook. Tune patterns to reduce false positives.

   <?php
/**
 * Plugin Name: SQLi Attack Notifier (Lightweight)
 * Description: Detects suspicious SQL injection payload patterns, logs the event, sends email to admin, and posts to Slack (optional).
 * Version: 1.1
 * Author: Your Name
 * NOTE: Place in wp-content/mu-plugins/ for always-on protection.
 */

if ( ! defined( 'ABSPATH' ) ) exit;

class WP_SQLi_Attack_Notifier {
    private $admin_email;
    private $slack_webhook = ''; // set to your Slack incoming webhook URL to enable Slack notifications
    private $notify_threshold = 1; // number of matched suspicious inputs before action triggers

    // Basic heuristic patterns (tune them)
    private $patterns = [
        "/\bUNION\b/i",
        "/\bSELECT\b.*\bFROM\b/i",
        "/\bDROP\b\s+\bTABLE\b/i",
        "/\bINSERT\b\s+\bINTO\b/i",
        "/\bUPDATE\b\s+\bSET\b/i",
        "/\bDELETE\b\s+\bFROM\b/i",
        "/\bOR\b\s+['\"]?1['\"]?\s*=\s*['\"]?1['\"]?/i",
        "/--\s*$/", "/#\s*$/",
        "/;--|;|\bCHAR\(|\bCAST\(|\bCONCAT\(/i",
        "/sleep\(\s*\d+\s*\)/i",
        "/benchmark\(/i",
        "/\bEXEC\b/i",
        "/\bINFORMATION_SCHEMA\b/i",
        "/0x[0-9a-f]{2,}/i",
    ];

    public function __construct() {
        $this->admin_email = get_option( 'admin_email' );
        add_action( 'init', [ $this, 'inspect_request' ], 0 );
        register_activation_hook( __FILE__, [ $this, 'maybe_create_table' ] );
    }

    public function inspect_request() {
        // Optionally skip trusted contexts to reduce false positives
        if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
            return;
        }

        $sources = [
            '_GET'    => isset($_GET) ? wp_unslash($_GET) : [],
            '_POST'   => isset($_POST) ? wp_unslash($_POST) : [],
            '_COOKIE' => isset($_COOKIE) ? wp_unslash($_COOKIE) : [],
            'URI'     => isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '',
        ];

        $matches = [];
        $count = 0;

        foreach ( $sources as $key => $payload ) {
            if ( is_array( $payload ) ) {
                foreach ( $payload as $name => $value ) {
                    $value = (string) $value;
                    if ( $value === '' ) continue;
                    $hit = $this->match_patterns( $value );
                    if ( $hit ) {
                        $count++;
                        $matches[] = [ 'source' => $key, 'field' => $name, 'value' => $this->shorten($value), 'pattern' => $hit ];
                    }
                }
            } else {
                $value = (string) $payload;
                if ( $value !== '' ) {
                    $hit = $this->match_patterns( $value );
                    if ( $hit ) {
                        $count++;
                        $matches[] = [ 'source' => $key, 'field' => '', 'value' => $this->shorten($value), 'pattern' => $hit ];
                    }
                }
            }
        }

        if ( $count >= $this->notify_threshold ) {
            $event = $this->build_event( $matches );
            $this->maybe_create_table();
            $this->log_event_to_db( $event );
            error_log( '[SQLi-Notifier] ' . wp_json_encode( $event ) );
            $this->send_email( $event );
            if ( ! empty( $this->slack_webhook ) ) $this->send_slack( $event );
        }
    }

    private function match_patterns( $value ) {
        foreach ( $this->patterns as $pattern ) {
            if ( preg_match( $pattern, $value ) ) return $pattern;
        }
        return false;
    }

    private function build_event( $matches ) {
        $ip = $this->get_ip();
        $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
        $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
        return [
            'time' => current_time( 'mysql' ),
            'ip'   => $ip,
            'ua'   => $ua,
            'uri'  => $uri,
            'matches' => $matches,
        ];
    }

    private function log_event_to_db( $event ) {
        global $wpdb;
        $table = $wpdb->prefix . 'sqli_events';
        $this->maybe_create_table();
        $wpdb->insert(
            $table,
            [
                'event_time' => $event['time'],
                'ip'         => $event['ip'],
                'user_agent' => mb_substr($event['ua'], 0, 255),
                'request_uri'=> mb_substr($event['uri'], 0, 255),
                'payload'    => wp_json_encode($event['matches']),
            ],
            [ '%s','%s','%s','%s','%s' ]
        );
    }

    private function send_email( $event ) {
        $subject = '[ALERT] Possible SQLi attempt on ' . get_bloginfo( 'name' );
        $body = "Time: {$event['time']}\nIP: {$event['ip']}\nURI: {$event['uri']}\nUA: {$event['ua']}\n\nMatches:\n";
        foreach ( $event['matches'] as $m ) {
            $body .= "- {$m['source']} [{$m['field']}] => {$m['value']} (pattern: {$m['pattern']})\n";
        }
        $body .= "\nInvestigate or block the IP if needed.";
        wp_mail( $this->admin_email, $subject, $body );
    }

    private function send_slack( $event ) {
        if ( empty($this->slack_webhook) ) return;
        $text = "*SQLi Alert* on *" . get_bloginfo('name') . "*\nTime: {$event['time']}\nIP: {$event['ip']}\nURI: {$event['uri']}\nMatches: " . count($event['matches']);
        $payload = [ 'text' => $text ];
        wp_remote_post( $this->slack_webhook, [
            'headers' => [ 'Content-Type' => 'application/json' ],
            'body'    => wp_json_encode( $payload ),
            'timeout' => 3,
        ] );
    }

    public function maybe_create_table() {
        global $wpdb;
        $table_name = $wpdb->prefix . 'sqli_events';
        $charset_collate = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE IF NOT EXISTS $table_name (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            event_time datetime NOT NULL,
            ip varchar(45) NOT NULL,
            user_agent varchar(255) NOT NULL,
            request_uri varchar(255) NOT NULL,
            payload longtext NOT NULL,
            PRIMARY KEY (id),
            KEY ip (ip),
            KEY event_time (event_time)
        ) $charset_collate;";
        require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
        dbDelta( $sql );
    }

    private function get_ip() {
        if ( ! empty($_SERVER['HTTP_X_FORWARDED_FOR']) ) {
            $ips = explode(',', wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
            return trim($ips[0]);
        } elseif ( ! empty($_SERVER['REMOTE_ADDR']) ) {
            return sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
        }
        return '0.0.0.0';
    }

    private function shorten( $val, $len = 200 ) {
        $val = trim($val);
        return (mb_strlen($val) > $len) ? mb_substr($val,0,$len).'...' : $val;
    }
}

new WP_SQLi_Attack_Notifier();

 

How to configure & deploy

  1. Place file in wp-content/mu-plugins/ (always-on) or as a normal plugin.
  2. Edit the file to set $slack_webhook if you want Slack alerts.
  3. Monitor the DB table wp_sqli_events and error_log for events.
  4. Tune $patterns and $notify_threshold to reduce false positives.
  5. Optionally add automatic IP blocking logic (careful; can block legit clients).

Caveat: This plugin is heuristic-based — it detects common payloads but can generate false positives. Use a WAF (Cloudflare / ModSecurity / Sucuri) for stronger protection.

 

6 — How to triage and respond when your notifier fires

  1. Review event details: timestamp, IP, user agent, request URI, matched payloads.
  2. Check whether the IP is legitimate: lookups, GeoIP, or known scanner lists.
  3. Block abusive IPs temporarily via firewall or .htaccess. For high confidence, block via WAF.
  4. Scan the site with WPScan, Wordfence, and Patchstack for known vulnerable plugins/themes.
  5. Patch and update affected plugins/themes/core immediately. CVE advisories often list remediation steps (example: CVE-2022-21661 required users to upgrade WordPress core).
  6. Rotate secrets (DB password, API keys) if you suspect a breach.
  7. Restore from backups if data integrity is compromised and investigate root cause.

 

7 — Scanning & detection: tools and approaches

  • WPScan — vulnerability scanner with WordPress vulnerability DB; good for checking installed plugins/themes.
  • Wordfence — plugin that includes scanners and firewall rules for known SQLi patterns; good for runtime detection.
  • Patchstack — vulnerability research and patching notifications for plugins/themes; useful for pro-active patching.
  • Manual code review — look for $wpdb->query() + string concatenation and usages of $_REQUEST, $_GET, $_POST directly injected into SQL.

 

8 — Example remediation workflow

  1. Notifier flags suspicious request → review details.
  2. Run WPScan/Wordfence to look for known vulnerable components.
  3. If a plugin is vulnerable: update or remove it; if patch not available, replace with alternative.
  4. Search theme/plugin files for unsafe SQL patterns and refactor to $wpdb->prepare() / $wpdb->insert() / whitelist identifiers.
  5. Block attacker IPs temporarily, enable WAF rules, and monitor.
  6. If breach suspected: take site offline, restore a clean backup, change DB credentials, and perform a full security audit.

 

9 — Final recommendations (short)

  • Never concatenate raw input into SQL. Use parameterized queries.
  • Prefer WP APIs and WP_Query over raw SQL.
  • Monitor with WPScan, Wordfence, Patchstack and keep everything patched.
  • Deploy the included notifier for early detection and log collection — combine it with a WAF for better protection.
  • Educate plugin/theme authors: enforce code reviews, static analysis, and secure coding practices in your development process.

 

References

  • WPScan — Protecting your WordPress website against SQL injection attacks. WPScan
  • Wordfence — How to find SQL injection vulnerabilities in WordPress plugins and themes (Wordfence blog). Wordfence
  • Vicarius / vsociety — Understanding the WordPress SQL Injection Vulnerability CVE-2022-21661. vicarius.io
  • Patchstack — SQL Injection in WordPress — Everything You Need To Know. Patchstack

 

Previous Article

WordPress Unsafe SQL Calls: Protecting Your Website Against SQL Injection Attacks

Next Article

SQL Injection Attack Notifier (ready-to-deploy MU-plugin)

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨