Beyond $_GET[‘id’]: Where SQL Injection Actually Hides
Most WordPress SQL injection tutorials — including the shorter companion piece on this topic — cover the same five patterns: raw SELECT, unescaped LIKE, ORDER BY, IN() clauses, and INSERT. Those are real and worth knowing, but they’re also the injection points every scanner already checks for and every competent plugin author has long since fixed.
The vulnerabilities that actually get exploited in 2026 live somewhere less obvious: inside pre_get_posts filters that modify WP_Query internals directly, inside shortcode attributes that get passed to custom table lookups, inside widget settings that were “safe” when saved but unsafe when rendered, inside CSV/XLSX bulk import routines, and inside multisite network queries that forget $wpdb->base_prefix isn’t the same as $wpdb->prefix.
This piece covers those surfaces, then builds a genuinely production-grade detection system — not a single regex pass that fires an email on any string containing the word “union,” but a scored, context-aware notifier with false-positive tuning, multi-channel alerting, safe auto-blocking, and a self-test harness so you can verify it actually works before you need it.
Part 1 — Attack Surfaces Nobody Audits
Surface 1: pre_get_posts and Direct WP_Query SQL Manipulation
pre_get_posts lets plugins modify a query before it runs. Many plugins use set() safely — but some inject raw SQL fragments via posts_where, posts_join, or posts_orderby filters, and those fragments frequently come from unsanitised request data.
<?php
// ❌ VULNERABLE: A "filter by custom field range" plugin feature
add_filter('posts_where', function(string $where): string {
global $wpdb;
if (empty($_GET['min_price']) || empty($_GET['max_price'])) {
return $where;
}
// Raw values dropped straight into a WHERE fragment:
$min = $_GET['min_price'];
$max = $_GET['max_price'];
$where .= " AND (
SELECT meta_value FROM {$wpdb->postmeta}
WHERE post_id = {$wpdb->posts}.ID AND meta_key = '_price'
) BETWEEN {$min} AND {$max}";
return $where;
});
// Attack: ?min_price=0&max_price=1) OR (SELECT 1 FROM wp_users WHERE user_login='admin' AND user_pass LIKE '$P$B%')-- -
// This turns a price filter into a boolean-based blind injection oracle
// against the admin user's password hash prefix.
// ✅ FIXED: Validate as floats BEFORE touching posts_where, and use prepare()
// for the fragment via $wpdb->prepare() applied to the fragment alone:
add_filter('posts_where', function(string $where): string {
global $wpdb;
if (!isset($_GET['min_price'], $_GET['max_price'])) {
return $where;
}
// Strict numeric validation — reject anything that isn't a clean float:
if (!is_numeric($_GET['min_price']) || !is_numeric($_GET['max_price'])) {
return $where;
}
$min = (float) $_GET['min_price'];
$max = (float) $_GET['max_price'];
// Clamp to a sane range to prevent abuse even with valid floats:
$min = max(0, min($min, 1000000));
$max = max(0, min($max, 1000000));
$fragment = $wpdb->prepare(
" AND (
SELECT meta_value FROM {$wpdb->postmeta}
WHERE post_id = {$wpdb->posts}.ID AND meta_key = '_price'
) + 0 BETWEEN %f AND %f",
$min,
$max
);
return $where . $fragment;
});
Surface 2: Shortcode Attribute Injection
Shortcode attributes look like static configuration but are frequently populated from page builder data, URL parameters, or ACF fields — none of which should be trusted.
<?php
// ❌ VULNERABLE: [staff_list department="sales"] shortcode with dynamic override
add_shortcode('staff_list', function(array $atts): string {
global $wpdb;
$atts = shortcode_atts(['department' => 'all'], $atts);
// Allowing a URL parameter to override the shortcode attribute:
if (!empty($_GET['dept'])) {
$atts['department'] = $_GET['dept']; // Unsanitised override!
}
if ($atts['department'] === 'all') {
$sql = "SELECT * FROM {$wpdb->prefix}staff";
} else {
// Raw concatenation of the (possibly attacker-controlled) department:
$sql = "SELECT * FROM {$wpdb->prefix}staff
WHERE department = '{$atts['department']}'";
}
$staff = $wpdb->get_results($sql);
return self::renderStaffList($staff);
});
// Attack: /team-page/?dept=x' UNION SELECT user_login,user_pass,3,4,5 FROM wp_users-- -
// Any page containing [staff_list] becomes a data exfiltration endpoint,
// even though the shortcode itself looks completely innocuous in the page editor.
// ✅ FIXED: Never let query parameters silently override shortcode attributes
// without validation, and always prepare() the value:
add_shortcode('staff_list', function(array $atts): string {
global $wpdb;
$atts = shortcode_atts(['department' => 'all'], $atts, 'staff_list');
$allowed_departments = ['sales', 'support', 'engineering', 'marketing', 'all'];
// Validate the override against a whitelist — don't just sanitize, VALIDATE:
if (!empty($_GET['dept']) && in_array($_GET['dept'], $allowed_departments, true)) {
$atts['department'] = $_GET['dept'];
}
if ($atts['department'] === 'all') {
$staff = $wpdb->get_results(
"SELECT id, name, title, photo_url FROM {$wpdb->prefix}staff WHERE active = 1"
);
} else {
$staff = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, name, title, photo_url FROM {$wpdb->prefix}staff
WHERE department = %s AND active = 1",
$atts['department']
)
);
}
return self::renderStaffList($staff ?: []);
});
Surface 3: Widget Settings — Safe on Save, Unsafe on Render
A classic gap: widget settings are sanitised in the admin form callback, but a second, separately-coded render path reads the raw $instance array and builds SQL from it without repeating the sanitisation.
<?php
class Recent_Reviews_Widget extends WP_Widget {
// ── Admin form: correctly sanitised here ───────────────────────────────
public function update(array $new_instance, array $old_instance): array {
$instance = [];
$instance['category_id'] = absint($new_instance['category_id']);
$instance['count'] = absint($new_instance['count']);
return $instance;
// Looks fully safe — because it IS safe, at this stage.
}
// ❌ VULNERABLE: The render path re-reads $instance from get_option()
// storage where a DIRECT database edit, import, or a bug in an earlier
// plugin version could have left a non-integer value in category_id.
public function widget(array $args, array $instance): void {
global $wpdb;
// No re-validation here — trusting that "update()" was always the
// only way this data was ever written is a fragile assumption:
$category_id = $instance['category_id'];
$count = $instance['count'];
$reviews = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}reviews
WHERE category_id = {$category_id}
ORDER BY created_at DESC
LIMIT {$count}"
);
// If this widget was ever imported via a widget-import plugin,
// duplicated via WP-CLI, or edited via a customizer JS bug that
// bypassed update(), $instance can contain arbitrary strings.
echo $args['before_widget'];
foreach ($reviews as $review) {
echo '<p>' . esc_html($review->text) . '</p>';
}
echo $args['after_widget'];
}
// ✅ FIXED: Re-validate at render time too — never trust stored state
// just because a sanitising function exists elsewhere in the class.
public function widget_safe(array $args, array $instance): void {
global $wpdb;
$category_id = absint($instance['category_id'] ?? 0);
$count = min(20, max(1, absint($instance['count'] ?? 5)));
$reviews = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, text FROM {$wpdb->prefix}reviews
WHERE category_id = %d
ORDER BY created_at DESC
LIMIT %d",
$category_id,
$count
)
);
echo $args['before_widget'];
foreach ($reviews ?: [] as $review) {
echo '<p>' . esc_html($review->text) . '</p>';
}
echo $args['after_widget'];
}
}
Surface 4: CSV/XLSX Bulk Import Routines
Import tools are a favourite target because they process large batches quickly and are often written under deadline pressure with minimal review — and the “input” is a file the attacker fully controls.
<?php
// ❌ VULNERABLE: A "bulk update inventory" admin tool
function handle_inventory_csv_import(): void {
if (!current_user_can('manage_options')) wp_die('Unauthorized');
check_admin_referer('inventory_import');
$file = $_FILES['csv_file']['tmp_name'];
$handle = fopen($file, 'r');
global $wpdb;
while (($row = fgetcsv($handle)) !== false) {
[$sku, $quantity, $warehouse] = $row;
// Each CSV row is trusted completely — including the "warehouse" column,
// which is later used as a raw SQL identifier in some installs:
$wpdb->query(
"UPDATE {$wpdb->prefix}inventory
SET quantity = {$quantity}
WHERE sku = '{$sku}' AND warehouse = '{$warehouse}'"
);
}
fclose($handle);
}
// Attack: Upload a CSV with a row like:
// SKU123,0 WHERE 1=1; UPDATE wp_users SET user_pass='$P$hackedhash' WHERE ID=1; --,main
// A "quantity" field that looks numeric at a glance can carry a full stacked-query payload.
// ✅ FIXED: Validate and cast EVERY column, reject malformed rows entirely,
// and never build SQL from file content without prepare():
function handle_inventory_csv_import_safe(): void {
if (!current_user_can('manage_options')) wp_die('Unauthorized');
check_admin_referer('inventory_import');
// Validate file type by content, not extension:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['csv_file']['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, ['text/csv', 'text/plain'], true)) {
wp_die('Invalid file type. Only CSV files are accepted.');
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
global $wpdb;
$imported = 0;
$skipped = 0;
$allowed_warehouses = ['main', 'east', 'west', 'overflow'];
while (($row = fgetcsv($handle)) !== false) {
if (count($row) !== 3) { $skipped++; continue; }
[$sku, $quantity, $warehouse] = $row;
// Strict validation — reject the row rather than trying to "clean" it:
$sku = sanitize_text_field($sku);
$warehouse = sanitize_text_field($warehouse);
if (!preg_match('/^[A-Za-z0-9_-]{2,32}$/', $sku)) { $skipped++; continue; }
if (!is_numeric($quantity) || (int)$quantity < 0) { $skipped++; continue; }
if (!in_array($warehouse, $allowed_warehouses, true)) { $skipped++; continue; }
$wpdb->update(
"{$wpdb->prefix}inventory",
['quantity' => absint($quantity)],
['sku' => $sku, 'warehouse' => $warehouse],
['%d'],
['%s', '%s']
);
$imported++;
}
fclose($handle);
wp_die("Import complete. Imported: {$imported}. Skipped (invalid): {$skipped}.");
}
Surface 5: Multisite — $wpdb->prefix vs $wpdb->base_prefix Confusion
<?php
// ❌ VULNERABLE (in a subtle way): A network-wide reporting tool that lets a
// super admin choose which site's data to query, using the site ID directly
// to build the table prefix:
function get_network_site_stats(int $site_id): array {
global $wpdb;
// WordPress switches prefixes per-site as "wp_2_posts", "wp_3_posts", etc.
// Building that dynamically from an integer feels "safe" because it's cast —
// but the underlying pattern of building table names from request data
// generalises badly the moment someone copies this code for a non-integer field:
$table = "{$wpdb->base_prefix}{$site_id}_posts";
return $wpdb->get_results("SELECT ID, post_title FROM {$table} LIMIT 20");
}
// The immediate version above is *usually* safe if $site_id is always cast with
// absint() upstream — but this exact pattern gets copy-pasted into new features
// where the "ID" being interpolated is NOT always an integer (e.g., a site slug),
// and that's how table-name injection enters multisite codebases.
// ✅ FIXED: Use WordPress's own multisite-safe switching instead of manual
// prefix concatenation:
function get_network_site_stats_safe(int $site_id): array {
if (!is_multisite()) return [];
$site_id = absint($site_id);
if (!get_site($site_id)) return []; // Validate the site actually exists
switch_to_blog($site_id);
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_status = %s
LIMIT %d",
'publish',
20
)
);
restore_current_blog(); // ALWAYS restore, even on early return — use try/finally
return $results ?: [];
}
Part 2 — Why “First-Match” Detectors Produce Too Much Noise
The MU-plugin pattern shown in the companion article — loop through a fixed list of regexes, fire an alert on the first match — works, but it has two real production problems:
Problem 1: False positives drown out real signal. A blog with a comment field will trigger /\bSELECT\b.*\bFROM\b/i the first time someone writes “Please select an option from the dropdown.” A support ticket plugin will trigger /\bOR\b\s+[‘\”]?1[‘\”]?/i on a message like “or 1-800-555-0199 for support.” Within a week, admins start ignoring the alert emails entirely — which defeats the purpose.
Problem 2: Single-pattern matching can’t distinguish severity. A URL containing union in a French phrase (“la Union Européenne”) and a URL containing ‘ UNION SELECT username,password FROM wp_users– both trigger the same alert with the same urgency, even though only one is an actual attack.
The fix is a weighted scoring system combined with context awareness — the approach below.
Part 3 — The Production-Grade Notifier (Version 2)
<?php
/**
* Plugin Name: SQLi Attack Notifier Pro
* Description: Scored, context-aware SQL injection detection with
* multi-channel alerts, auto-blocking safety rails,
* false-positive tuning, and a self-test harness.
* Version: 2.0.0
*
* Install as: wp-content/mu-plugins/sqli-notifier-pro.php
*/
if (!defined('ABSPATH')) exit;
class SQLiNotifierPro {
// ── Weighted patterns: higher weight = stronger indicator of a real attack ──
private const PATTERNS = [
// High confidence (weight 40-50) — almost never appear in legitimate text:
['pattern' => '/\bUNION\b\s+(ALL\s+)?\bSELECT\b/i', 'weight' => 50, 'label' => 'UNION SELECT'],
['pattern' => '/\bSLEEP\s*\(\s*\d+\s*\)/i', 'weight' => 50, 'label' => 'Time-based blind (SLEEP)'],
['pattern' => '/\bBENCHMARK\s*\(\s*\d+/i', 'weight' => 50, 'label' => 'Time-based blind (BENCHMARK)'],
['pattern' => '/;\s*(DROP|TRUNCATE|ALTER)\s+TABLE\b/i', 'weight' => 50, 'label' => 'Stacked DDL query'],
['pattern' => '/\bINFORMATION_SCHEMA\b\.\b(TABLES|COLUMNS)\b/i','weight' => 45, 'label' => 'Schema enumeration'],
['pattern' => '/\bLOAD_FILE\s*\(|\bINTO\s+(OUT|DUMP)FILE\b/i', 'weight' => 45, 'label' => 'File read/write attempt'],
['pattern' => '/0x[0-9a-f]{8,}/i', 'weight' => 30, 'label' => 'Long hex literal'],
// Medium confidence (weight 15-25) — appear in attacks but occasionally
// in legitimate content too, so weighted lower and combined with others:
['pattern' => '/\bOR\b\s+[\'"]?\d+[\'"]?\s*=\s*[\'"]?\d+[\'"]?/i', 'weight' => 25, 'label' => 'Boolean tautology (OR 1=1)'],
['pattern' => '/--\s*$/m', 'weight' => 15, 'label' => 'SQL comment terminator'],
['pattern' => '/\bCONCAT\s*\(|\bGROUP_CONCAT\s*\(/i', 'weight' => 20, 'label' => 'String concatenation function'],
['pattern' => '/\bCAST\s*\(|\bCONVERT\s*\(/i', 'weight' => 15, 'label' => 'Type conversion function'],
// Low confidence (weight 5-10) — extremely common false-positive triggers,
// only useful when combined with other matches to raise total score:
['pattern' => '/\bSELECT\b.{1,80}\bFROM\b/i', 'weight' => 10, 'label' => 'SELECT...FROM fragment'],
['pattern' => '/\'|\"/', 'weight' => 5, 'label' => 'Quote character'],
['pattern' => '/#|\/\*/', 'weight' => 5, 'label' => 'Comment marker'],
];
// ── Score threshold to trigger an alert ────────────────────────────────────
private const ALERT_THRESHOLD = 45;
// ── Fields that legitimately contain SQL-like language (skip weight, not skip entirely) ──
private const HIGH_NOISE_FIELDS = ['comment', 'message', 'content', 'description', 'bio'];
// ── Channels (fill in what you use — leave empty to disable) ───────────────
private string $slackWebhook = '';
private string $discordWebhook = '';
private string $telegramToken = '';
private string $telegramChatId = '';
private string $genericWebhook = ''; // For SIEM / Zapier / your own endpoint
private const RATE_LIMIT_SECONDS = 300; // One alert per IP per 5 minutes
private const AUTO_BLOCK_ENABLED = false; // Keep OFF until tuned (see Part 5)
private const AUTO_BLOCK_SCORE = 90; // Only auto-block on very high confidence
private const NEVER_BLOCK_IPS = []; // Add your office/CI IPs here
public function __construct() {
add_action('init', [$this, 'inspect'], 0);
add_action('admin_menu', [$this, 'registerAdminPage']);
add_action('rest_api_init', [$this, 'registerRestEndpoint']);
}
// ── Core inspection ─────────────────────────────────────────────────────────
public function inspect(): void {
// Skip trusted, authenticated admin actions in wp-admin — reduces noise
// dramatically without disabling protection for the public attack surface:
if (is_admin() && current_user_can('manage_options') && !wp_doing_ajax()) {
return;
}
$ip = $this->clientIp();
if ($this->isRateLimited($ip)) return; // Already alerted for this IP recently
$findings = [];
$totalScore = 0;
foreach (['_GET' => $_GET, '_POST' => $_POST, '_COOKIE' => $_COOKIE] as $sourceName => $data) {
foreach ((array)$data as $field => $value) {
if (in_array($field, ['nonce', '_wpnonce', 'security'], true)) continue;
[$score, $matched] = $this->scoreValue((string)$value, $field);
if ($score > 0) {
$totalScore += $score;
$findings[] = [
'source' => $sourceName,
'field' => $field,
'value' => mb_substr((string)$value, 0, 200),
'score' => $score,
'labels' => $matched,
];
}
}
}
// Also check the raw request URI (query string manipulation without $_GET registration):
$uri = $_SERVER['REQUEST_URI'] ?? '';
[$uriScore, $uriMatched] = $this->scoreValue(urldecode($uri), 'REQUEST_URI');
if ($uriScore > 0) {
$totalScore += $uriScore;
$findings[] = ['source' => 'URI', 'field' => '', 'value' => mb_substr($uri, 0, 200),
'score' => $uriScore, 'labels' => $uriMatched];
}
if ($totalScore >= self::ALERT_THRESHOLD) {
$this->handleDetection($ip, $totalScore, $findings);
}
}
/**
* Score a single value against all weighted patterns.
* Field name context reduces weight for high-noise fields.
*/
private function scoreValue(string $value, string $field): array {
if (strlen($value) < 3) return [0, []];
$isNoisyField = false;
foreach (self::HIGH_NOISE_FIELDS as $noisy) {
if (stripos($field, $noisy) !== false) { $isNoisyField = true; break; }
}
$score = 0;
$matched = [];
$decoded = urldecode($value);
foreach (self::PATTERNS as $p) {
if (preg_match($p['pattern'], $decoded)) {
$weight = $isNoisyField ? (int)($p['weight'] * 0.5) : $p['weight'];
$score += $weight;
$matched[] = $p['label'];
}
}
return [$score, $matched];
}
private function isRateLimited(string $ip): bool {
$key = 'sqli_alert_' . md5($ip);
$recent = get_transient($key);
if ($recent) return true;
set_transient($key, 1, self::RATE_LIMIT_SECONDS);
return false;
}
private function handleDetection(string $ip, int $score, array $findings): void {
$event = [
'time' => current_time('mysql'),
'ip' => $ip,
'score' => $score,
'uri' => sanitize_text_field($_SERVER['REQUEST_URI'] ?? ''),
'ua' => sanitize_text_field($_SERVER['HTTP_USER_AGENT'] ?? ''),
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'findings' => $findings,
];
$this->ensureTable();
$this->logToDatabase($event);
// Structured single-line log for fail2ban / log aggregators:
error_log(sprintf(
'SQLI_NOTIFIER ip=%s score=%d method=%s uri=%s labels=%s',
$ip, $score, $event['method'], $event['uri'],
implode('|', array_unique(array_merge(...array_column($findings, 'labels'))))
));
$this->notifyAllChannels($event);
if (self::AUTO_BLOCK_ENABLED && $score >= self::AUTO_BLOCK_SCORE) {
$this->maybeAutoBlock($ip, $event);
}
}
// ── Multi-channel notifications ────────────────────────────────────────────
private function notifyAllChannels(array $event): void {
$this->notifyEmail($event);
if ($this->slackWebhook) $this->notifySlack($event);
if ($this->discordWebhook) $this->notifyDiscord($event);
if ($this->telegramToken) $this->notifyTelegram($event);
if ($this->genericWebhook) $this->notifyGenericWebhook($event);
}
private function notifyEmail(array $event): void {
$labels = array_unique(array_merge(...array_column($event['findings'], 'labels')));
$subject = "[SQLi Alert — score {$event['score']}] " . get_bloginfo('name');
$body = "Time: {$event['time']}\nIP: {$event['ip']}\nScore: {$event['score']}/100+\n"
. "URI: {$event['uri']}\nMethod: {$event['method']}\nUA: {$event['ua']}\n\n"
. "Matched indicators:\n- " . implode("\n- ", $labels) . "\n\n"
. "Full details: " . admin_url('tools.php?page=sqli-notifier-pro');
wp_mail(get_option('admin_email'), $subject, $body);
}
private function notifySlack(array $event): void {
wp_remote_post($this->slackWebhook, [
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 4,
'body' => wp_json_encode([
'text' => "🚨 *SQLi Alert* (score {$event['score']}) on " . get_bloginfo('name')
. "\nIP: `{$event['ip']}` · URI: `{$event['uri']}`",
]),
]);
}
private function notifyDiscord(array $event): void {
wp_remote_post($this->discordWebhook, [
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 4,
'body' => wp_json_encode([
'content' => "🚨 **SQLi Alert** (score {$event['score']})\nIP: `{$event['ip']}`\nURI: `{$event['uri']}`",
]),
]);
}
private function notifyTelegram(array $event): void {
$url = "https://api.telegram.org/bot{$this->telegramToken}/sendMessage";
wp_remote_post($url, [
'timeout' => 4,
'body' => [
'chat_id' => $this->telegramChatId,
'text' => "🚨 SQLi Alert (score {$event['score']})\nIP: {$event['ip']}\nURI: {$event['uri']}",
],
]);
}
private function notifyGenericWebhook(array $event): void {
wp_remote_post($this->genericWebhook, [
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 4,
'body' => wp_json_encode($event),
]);
}
// ── Safe auto-blocking (opt-in, capped, never touches allowlisted IPs) ──────
private function maybeAutoBlock(string $ip, array $event): void {
if (in_array($ip, self::NEVER_BLOCK_IPS, true)) return;
if (filter_var($ip, FILTER_VALIDATE_IP) === false) return;
// Track how many IPs we've auto-blocked in the last hour — hard safety cap
// prevents a false-positive storm from locking out large chunks of traffic:
$blockedRecently = (int) get_transient('sqli_autoblock_count');
if ($blockedRecently >= 20) {
error_log('SQLI_NOTIFIER auto-block safety cap reached — blocking paused for review');
return;
}
set_transient('sqli_autoblock_count', $blockedRecently + 1, HOUR_IN_SECONDS);
// Write to a flat file that Nginx/Apache config includes (see Part 4) —
// do NOT attempt to shell out to iptables from PHP in shared hosting:
$blockFile = WP_CONTENT_DIR . '/sqli-blocked-ips.conf';
$line = "deny {$ip}; # auto-blocked " . current_time('mysql') . " score={$event['score']}\n";
file_put_contents($blockFile, $line, FILE_APPEND | LOCK_EX);
}
// ── Storage ─────────────────────────────────────────────────────────────────
private function ensureTable(): void {
global $wpdb;
$table = $wpdb->prefix . 'sqli_events_pro';
if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) return;
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta("CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_time DATETIME NOT NULL,
ip VARCHAR(45) NOT NULL,
score SMALLINT UNSIGNED NOT NULL,
method VARCHAR(10) NOT NULL,
uri VARCHAR(500) NOT NULL,
user_agent VARCHAR(255) NOT NULL,
findings LONGTEXT NOT NULL,
reviewed TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_ip (ip),
KEY idx_time (event_time),
KEY idx_score (score)
) {$charset}");
}
private function logToDatabase(array $event): void {
global $wpdb;
$wpdb->insert($wpdb->prefix . 'sqli_events_pro', [
'event_time' => $event['time'],
'ip' => $event['ip'],
'score' => $event['score'],
'method' => $event['method'],
'uri' => mb_substr($event['uri'], 0, 500),
'user_agent' => mb_substr($event['ua'], 0, 255),
'findings' => wp_json_encode($event['findings']),
], ['%s', '%s', '%d', '%s', '%s', '%s', '%s']);
}
private function clientIp(): string {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
if (!empty($_SERVER[$key])) {
$ip = trim(explode(',', $_SERVER[$key])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
}
return '0.0.0.0';
}
// ── Admin dashboard ─────────────────────────────────────────────────────────
public function registerAdminPage(): void {
add_management_page('SQLi Notifier Pro', 'SQLi Notifier', 'manage_options',
'sqli-notifier-pro', [$this, 'renderAdminPage']);
}
public function renderAdminPage(): void {
if (!current_user_can('manage_options')) wp_die('Unauthorized');
global $wpdb;
$table = $wpdb->prefix . 'sqli_events_pro';
$topIps = $wpdb->get_results(
"SELECT ip, COUNT(*) as hits, MAX(score) as max_score
FROM {$table} GROUP BY ip ORDER BY hits DESC LIMIT 10"
);
$recent = $wpdb->get_results("SELECT * FROM {$table} ORDER BY event_time DESC LIMIT 50");
?>
<div class="wrap">
<h1>SQLi Notifier Pro — Attack Log</h1>
<h2>Top Source IPs</h2>
<table class="wp-list-table widefat striped">
<thead><tr><th>IP</th><th>Attempts</th><th>Max Score</th><th>Action</th></tr></thead>
<tbody>
<?php foreach ($topIps as $row): ?>
<tr>
<td><code><?php echo esc_html($row->ip); ?></code></td>
<td><?php echo (int)$row->hits; ?></td>
<td><?php echo (int)$row->max_score; ?></td>
<td><small>Add to <code>NEVER_BLOCK_IPS</code> or firewall manually</small></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h2 style="margin-top:32px;">Recent Events</h2>
<table class="wp-list-table widefat striped">
<thead><tr><th>Time</th><th>IP</th><th>Score</th><th>URI</th><th>Findings</th></tr></thead>
<tbody>
<?php foreach ($recent as $row): ?>
<tr>
<td><?php echo esc_html($row->event_time); ?></td>
<td><code><?php echo esc_html($row->ip); ?></code></td>
<td><strong><?php echo (int)$row->score; ?></strong></td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis">
<small><?php echo esc_html($row->uri); ?></small>
</td>
<td>
<details><summary>View</summary>
<pre style="font-size:11px;max-width:300px;overflow:auto"><?php
echo esc_html($row->findings);
?></pre>
</details>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
// ── REST endpoint for external SIEM / log shipping ─────────────────────────
public function registerRestEndpoint(): void {
register_rest_route('sqli-notifier/v1', '/events', [
'methods' => 'GET',
'permission_callback' => fn() => current_user_can('manage_options'),
'callback' => function(WP_REST_Request $request) {
global $wpdb;
$since = $request->get_param('since') ?: gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}sqli_events_pro
WHERE event_time >= %s ORDER BY event_time DESC LIMIT 500",
$since
)
);
return new WP_REST_Response($rows);
},
]);
}
// ── Self-test harness ───────────────────────────────────────────────────────
/**
* Run: wp eval 'SQLiNotifierPro::selfTest();'
* Verifies the scoring engine correctly flags known attack payloads
* and correctly IGNORES known benign phrases, without touching real requests.
*/
public static function selfTest(): void {
$instance = new self();
$reflection = new ReflectionClass($instance);
$method = $reflection->getMethod('scoreValue');
$method->setAccessible(true);
$attacks = [
"1' UNION SELECT user_login,user_pass FROM wp_users-- -",
"1 AND SLEEP(5)",
"1'; DROP TABLE wp_users; --",
"1' AND (SELECT 1 FROM information_schema.tables)--",
];
$benign = [
"Please select an option from the dropdown menu",
"The European Union announced new regulations",
"or call 1-800-555-0199 for support",
"I'd like to order 1 or 2 of these please",
];
echo "=== Attack payloads (should score >= " . self::ALERT_THRESHOLD . ") ===\n";
foreach ($attacks as $payload) {
[$score] = $method->invoke($instance, $payload, 'test_field');
$status = $score >= self::ALERT_THRESHOLD ? '✅ DETECTED' : '❌ MISSED';
echo "{$status} (score {$score}): " . substr($payload, 0, 60) . "\n";
}
echo "\n=== Benign phrases (should score < " . self::ALERT_THRESHOLD . ") ===\n";
foreach ($benign as $phrase) {
[$score] = $method->invoke($instance, $phrase, 'comment');
$status = $score < self::ALERT_THRESHOLD ? '✅ CORRECTLY IGNORED' : '⚠️ FALSE POSITIVE';
echo "{$status} (score {$score}): {$phrase}\n";
}
}
}
new SQLiNotifierPro();
// Register WP-CLI command for self-test:
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('sqli-test', ['SQLiNotifierPro', 'selfTest']);
}
Part 4 — Wiring Auto-Block Output Into Nginx/Apache
The plugin writes blocked IPs to a flat file rather than shelling out to iptables (which won’t work on shared hosting and is risky to call from PHP-FPM). Wire that file into your web server config:
# In your Nginx server block, include the auto-generated block list:
include /var/www/html/wp-content/sqli-blocked-ips.conf;
server {
# ... existing config ...
}
# Reload Nginx after the file updates (run via cron every 5 minutes, # or trigger via a webhook from the WordPress side if you prefer): */5 * * * * nginx -t && systemctl reload nginx
# Apache equivalent — .htaccess or vhost config:
<IfModule mod_authz_core.c>
Include /var/www/html/wp-content/sqli-blocked-ips.conf
</IfModule>
# Format inside sqli-blocked-ips.conf for Apache:
# Require not ip 203.0.113.5
Feeding fail2ban Instead
If you prefer fail2ban over the plugin’s own blocking, the structured error_log line format makes this trivial:
# /etc/fail2ban/filter.d/wp-sqli-notifier.conf
[Definition]
failregex = ^.*SQLI_NOTIFIER ip=<HOST> score=(4[5-9]|[5-9]\d|\d{3,}) .*$
ignoreregex =
# /etc/fail2ban/jail.local
[wp-sqli-notifier]
enabled = true
filter = wp-sqli-notifier
logpath = /var/log/php/php-error.log
maxretry = 1
bantime = 3600
findtime = 600
Part 5 — The False-Positive Tuning Workflow
Deploying any detector without a tuning period guarantees either alert fatigue or missed attacks. Run this two-week process:
WEEK 1 — Observation Mode (AUTO_BLOCK_ENABLED = false, always)
──────────────────────────────────────────────────────────────────────
Day 1-2: Deploy with default weights. Do NOT enable auto-blocking yet.
Day 3: Review admin dashboard. For every alert with score 45-65,
open the "findings" detail and ask: is this a real attack
or legitimate content that happened to match?
Day 4-5: For confirmed false positives, identify the specific field name
(e.g., "comment_text") and add it to HIGH_NOISE_FIELDS if it
isn't already there — this halves the weight for that field.
Day 6-7: Re-run wp eval 'SQLiNotifierPro::selfTest();' after any pattern
changes to confirm known attacks are STILL detected correctly.
WEEK 2 — Threshold Calibration
──────────────────────────────────────────────────────────────────────
Day 8-10: Review the full week's data. Calculate: what score would have
caught 100% of confirmed real attacks while generating the
fewest false positives? Adjust ALERT_THRESHOLD accordingly.
Day 11-12: If you plan to enable auto-blocking, review the top-IPs table.
Confirm none of your own team's IPs, CI/CD servers, or
legitimate scanner services (like Google, Bing) appear there.
Day 13: Add any legitimate high-volume IPs (payment webhooks, CDN
health checks) to NEVER_BLOCK_IPS.
Day 14: Enable AUTO_BLOCK_ENABLED = true only if confident, with
AUTO_BLOCK_SCORE set comfortably above your observed false-
positive ceiling (typically 85-95, not the alert threshold).
──────────────────────────────────────────────────────────────────────
Part 6 — Testing Your Defenses Safely with sqlmap
Once deployed, verify the notifier actually fires under real attack tool conditions — never against production, only staging:
# Install sqlmap:
pip install sqlmap
# ── Test a GET parameter (staging site only!) ─────────────────────────────────
sqlmap -u "https://staging.yoursite.com/?product_id=1" \
--batch \
--level=3 \
--risk=2 \
--technique=BEUST \
--threads=1
# ── Test a POST form (e.g., a search box) ─────────────────────────────────────
sqlmap -u "https://staging.yoursite.com/wp-admin/admin-ajax.php" \
--data="action=product_search&q=test" \
--batch --level=3
# After running sqlmap, immediately check:
wp db query "SELECT ip, score, uri, event_time FROM wp_sqli_events_pro
ORDER BY event_time DESC LIMIT 20;" --path=/var/www/staging
# Confirm:
# 1. Every sqlmap probe generated at least one logged event
# 2. The admin_email received an alert (check staging mail catcher, e.g. Mailhog)
# 3. No legitimate staging traffic during the test was mis-flagged at high scores
# ── CAUTION ────────────────────────────────────────────────────────────────────
# Never run sqlmap against a production site you don't own or without
# written authorization — this is unauthorized access testing and can be
# illegal even against your own hosting provider's shared infrastructure
# if it affects other tenants.
Part 7 — Incident Response Playbook (When the Notifier Fires for Real)
IMMEDIATE (first 15 minutes)
──────────────────────────────────────────────────────────────────────
1. Open the admin dashboard: Tools → SQLi Notifier
2. Note the IP, score, URI, and matched findings from the alert
3. Check if the targeted URI corresponds to a specific plugin/theme feature —
this tells you WHERE to look for the vulnerable code
4. Grep your codebase for that feature's query code:
grep -rn "the_specific_parameter_name" wp-content/plugins/ wp-content/themes/
WITHIN THE HOUR
──────────────────────────────────────────────────────────────────────
5. Run WPScan against your own site to check for known vulnerable
plugin versions matching the targeted feature:
wpscan --url https://yoursite.com --enumerate vp --api-token YOUR_TOKEN
6. If a known-vulnerable plugin is identified: update immediately, or
deactivate it if no patch exists yet
7. If the vulnerability is in custom code: patch using the safe patterns
from Part 1 of this guide and the main SQL Injection guide
8. Check whether the attack succeeded — look for:
- New unexpected admin users: wp user list --role=administrator
- Modified wp_options values: diff against a recent backup
- Unexpected files in wp-content/uploads (should contain zero .php files)
WITHIN 24 HOURS
──────────────────────────────────────────────────────────────────────
9. If compromise is confirmed: rotate ALL credentials (DB password,
WordPress secret keys, hosting panel password, SFTP)
10. Restore from a known-clean backup if data integrity is in question
11. Document root cause: which parameter, which file, which plugin version
12. Report the vulnerability responsibly if it's a third-party plugin:
https://patchstack.com/report
──────────────────────────────────────────────────────────────────────
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
- Place file in wp-content/mu-plugins/ (always-on) or as a normal plugin.
- Edit the file to set $slack_webhook if you want Slack alerts.
- Monitor the DB table wp_sqli_events and error_log for events.
- Tune $patterns and $notify_threshold to reduce false positives.
- 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
- Review event details: timestamp, IP, user agent, request URI, matched payloads.
- Check whether the IP is legitimate: lookups, GeoIP, or known scanner lists.
- Block abusive IPs temporarily via firewall or .htaccess. For high confidence, block via WAF.
- Scan the site with WPScan, Wordfence, and Patchstack for known vulnerable plugins/themes.
- Patch and update affected plugins/themes/core immediately. CVE advisories often list remediation steps (example: CVE-2022-21661 required users to upgrade WordPress core).
- Rotate secrets (DB password, API keys) if you suspect a breach.
- 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
- Notifier flags suspicious request → review details.
- Run WPScan/Wordfence to look for known vulnerable components.
- If a plugin is vulnerable: update or remove it; if patch not available, replace with alternative.
- Search theme/plugin files for unsafe SQL patterns and refactor to $wpdb->prepare() / $wpdb->insert() / whitelist identifiers.
- Block attacker IPs temporarily, enable WAF rules, and monitor.
- 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
Detection Is a System, Not a Regex List
The companion piece on this site gives you a working first-pass notifier and the five textbook injection patterns. This guide extends both halves of that: the attack surface (hooks, shortcodes, widgets, imports, multisite) that scanners and casual audits routinely miss, and the detection system (scored rather than binary, context-aware rather than one-size-fits-all, multi-channel rather than email-only, with a tuning process and a self-test harness so you know it actually works before an attacker proves it doesn’t).
The single highest-leverage change from the original notifier to this one isn’t any individual pattern — it’s the shift from “does this string match any regex” to “how much cumulative evidence of an attack exists in this request.” That shift is what turns a security tool people learn to ignore into one they actually trust.