title: Защита от SQL-инъекций и безопасность низкоуровневого $wpdb description: >- Глубокий инженерный разбор безопасности базы данных в WordPress 7.0+ (PHP 8.4+): внутреннее устройство $wpdb->prepare(), плейсхолдер идентификаторов %i (WP 6.2+), безопасный поиск LIKE через esc_like, транзакции InnoDB и почему esc_sql() без кавычек является фатальной уязвимостью. module: security order: 2 difficulty: advanced estimatedTime: 42 мин topics:
SQL-инъекция (SQLi, CWE-89) остаётся одной из самых разрушительных уязвимостей в веб-разработке. Успешная эксплуатация позволяет атакующему в обход бизнес-логики выгрузить конфиденциальные данные пользователей, подменить хеши паролей администраторов в wp_users, внедрить бэкдор или полностью уничтожить базу данных.
В ядре WordPress работа с базой данных инкапсулирована в глобальном классе-синглтоне $wpdb (экземпляр wpdb). При работе с $wpdb категорически запрещена прямая конкатенация пользовательских переменных в SQL-запрос. Единственным стандартом безопасного выполнения запросов является Параметризация через метод $wpdb->prepare().
Однако многие разработчики допускают критические архитектурные ошибки:
esc_sql(): функция esc_sql() экранирует только спецсимволы в строках, но не добавляет кавычки. Если переменная вставляется в числовое поле (WHERE id = $id), злоумышленник передаёт строку 1 OR 1=1 и инъекция успешно срабатывает, так как экранировать там нечего.%s автоматически оборачивает значение в одинарные кавычки (SELECT * FROM 'wp_posts'), что вызывает синтаксическую ошибку MySQL. В современном WordPress для безопасной подстановки динамических имён таблиц и колонок внедрён специализированный плейсхолдер %i (Identifier Placeholder).LIKE: поисковые запросы вида LIKE '%term%' без предварительного вызова $wpdb->esc_like() позволяют злоумышленнику передать символы % и _, превращая точечный поиск в тяжелейший Full Table Scan, способный положить сервер базы данных.Представьте выполнение SQL-запроса через $wpdb->prepare() как заполнение типового юридического договора у нотариуса:
%d, %f, %s, %i) — это официальный типографский бланк договора с пустыми полями строгой формы (в поле «Сумма» физически влезают только цифры %d, в поле «ФИО» строгие буквы %s, а в поле «Подразделение» — сертифицированный идентификатор отдела %i).$wpdb->prepare() — это нотариус, который проверяет документы и лично вписывает значения в поля бланка, автоматически заключая текстовые строки в защитные кавычки и приводя числа к их математическому представлению."WHERE id = " . $_GET['id']) — это подписание чистого листа бумаги, на котором клиент может написать любой собственный пункт договора мелким шрифтом («...и переписать всё имущество компании на меня: OR 1=1; DROP TABLE wp_users;»).ВХОДНЫЕ ДАННЫЕ: $_POST['status'], $_POST['limit'], $_POST['table']
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ ШАБЛОН: "SELECT * FROM %i WHERE status = %s LIMIT %d" │
└──────────────────────────────┬─────────────────────────────────────────┘
│ Метод $wpdb->prepare(..., $table, $status, $limit)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ КОНВЕЙЕР ОБРАБОТКИ АРГУМЕНТОВ: │
│ • %i (Identifier) ──► Обрамляет в обратные кавычки: `wp_custom_table` │
│ • %s (String) ──► mysqli_real_escape_string + одинарные кавычки │
│ • %d (Integer) ──► Принудительное приведение к intval() │
└──────────────────────────────┬─────────────────────────────────────────┘
│ Безопасный результирующий SQL
▼
┌────────────────────────────────────────────────────────────────────────┐
│ ИСПОЛНЕНИЕ В MYSQL: $wpdb->get_results() │
│ SELECT * FROM `wp_custom_table` WHERE status = 'active' LIMIT 20; │
└────────────────────────────────────────────────────────────────────────┘Запрос проектируется с плейсхолдерами: %d (целые), %f (дробные), %s (строки) и %i (идентификаторы таблиц/колонок).
Метод prepare() через внутренний vsprintf() строго приводит числовые типы (intval/floatval) и валидирует соответствие количества аргументов.
Для %s вызывается экранирование спецсимволов ( , , , , ', ", ) и значение автоматически обрамляется в одинарные кавычки.
Для %i значение очищается от обратных кавычек и оборачивается в безопасные тики `table_name` по стандартам MySQL/MariaDB.
Запрос отправляется в базу данных с поддержкой транзакций (START TRANSACTION, COMMIT, ROLLBACK) и телеметрией ошибок.
Определите точные типы данных: %d для ID и счетчиков, %f для цен и задержек, %s для текста, %i для таблиц и полей.
Если имя таблицы или колонки динамическое, убедитесь, что оно присутствует в строго ограниченном константном массиве.
Для условий LIKE предварительно пропустите поисковую строку через $wpdb->esc_like() перед добавлением процентов.
Оберните серию мутаций в START TRANSACTION / COMMIT, проверяйте $wpdb->last_error и делайте ROLLBACK при исключениях.
Разработчик выполняет запрос к динамической таблице: $wpdb->prepare('SELECT * FROM %s WHERE id = %d', $tableName, $userId). Почему этот код завершится фатальной синтаксической ошибкой базы данных?
В инженерном стеке Fullstack Agentic Lab стандарты безопасности $wpdb применяются для:
WHERE, ORDER BY и GROUP BY.$wpdb->esc_like() перед подстановкой в плейсхолдер %s.Сравним уязвимые шаблоны работы с $wpdb и строгий стандарт безопасности:
Ниже представлен эталонный производственный артефакт для WordPress 7.0+ и PHP 8.4+. Он реализует высоконадёжный Enterprise Audit Telemetry Repository:
%d, %s, %f).%i для динамических таблиц с дополнительной валидацией по белому списку.LIKE через $wpdb->esc_like().<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise Database Repository
* Description: Защищённая работа с $wpdb: плейсхолдер %i, транзакции InnoDB и защита от SQLi.
* Version: 3.0.0
* Requires at least: 6.8
* Requires PHP: 8.4
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Security\Database;
use RuntimeException;
use wpdb;
defined('ABSPATH') || exit;
final readonly class TelemetryLogRepository
{
/**
* Белый список разрешённых таблиц для исключения подмены идентификатора.
*/
private const array ALLOWED_TABLES = [
'agentic_telemetry_logs',
'agentic_audit_events',
'agentic_performance_benchmarks',
];
public function __construct(
private wpdb $db
) {}
/**
* Создает запись аудита внутри защищённой транзакции InnoDB.
*
* @param array{userId: int, eventName: string, latencyMs: float, payload: array<string, mixed>} $eventData
*/
public function recordEvent(string $tableName, array $eventData): int
{
$sanitizedTable = $this->assertValidTable($tableName);
// Начинаем транзакцию для гарантии атомарности
$this->db->query('START TRANSACTION');
try {
$fullTableName = $this->db->prefix . $sanitizedTable;
// КРИТИЧНО: Используем плейсхолдер %i (WP 6.2+) для имени таблицы!
// Для скаляров используем %d (целые), %s (строки), %f (дробные)
$query = $this->db->prepare(
'INSERT INTO %i (user_id, event_name, latency_ms, payload_json, created_at_gmt)
VALUES (%d, %s, %f, %s, %s)',
$fullTableName,
$eventData['userId'],
$eventData['eventName'],
$eventData['latencyMs'],
wp_json_encode($eventData['payload'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
gmdate('Y-m-d H:i:s')
);
$result = $this->db->query($query);
if ($result === false) {
throw new RuntimeException("Ошибка выполнения SQL: {$this->db->last_error}");
}
$insertedId = (int) $this->db->insert_id;
// Фиксируем транзакцию
$this->db->query('COMMIT');
return $insertedId;
} catch (Throwable $e) {
// При любом сбое мгновенно откатываем изменения
$this->db->query('ROLLBACK');
throw new RuntimeException("Транзакция отменена: {$e->getMessage()}", 0, $e);
}
}
/**
* Поиск записей по подстроке с безопасным экранированием LIKE и Keyset-пагинацией.
*
* @return array<int, array<string, mixed>>
*/
public function searchEvents(
string $tableName,
string $searchTerm,
int $minUserId,
int $lastSeenId = 0,
int $limit = 50
): array {
$sanitizedTable = $this->assertValidTable($tableName);
$fullTableName = $this->db->prefix . $sanitizedTable;
// 1. КРИТИЧНО: Экранируем мета-символы оператора LIKE (% и _) перед интерполяцией!
$escapedSearch = $this->db->esc_like($searchTerm);
$likeArgument = '%' . $escapedSearch . '%';
// 2. Keyset-пагинация вместо медленного LIMIT offset
$query = $this->db->prepare(
'SELECT id, user_id, event_name, latency_ms, created_at_gmt
FROM %i
WHERE event_name LIKE %s
AND user_id >= %d
AND id > %d
ORDER BY id ASC
LIMIT %d',
$fullTableName,
$likeArgument,
$minUserId,
$lastSeenId,
min($limit, 100)
);
/** @var array<int, array<string, mixed>>|null $results */
$results = $this->db->get_results($query, ARRAY_A);
return $results ?: [];
}
/**
* Строгая валидация динамического имени таблицы по белому списку.
*/
private function assertValidTable(string $table): string
{
$clean = sanitize_key($table);
if (!in_array($clean, self::ALLOWED_TABLES, true)) {
throw new RuntimeException("Попытка доступа к неавторизованной таблице: {$table}");
}
return $clean;
}
}
Команда выполнила аудит кода и обнаружила строку: $wpdb->query('DELETE FROM wp_logs WHERE id = ' . esc_sql($_GET['id'])). Переменная $_GET['id'] не обернута в кавычки. Каким образом злоумышленник может эксплуатировать эту конструкцию?