title: Defensa contra inyecciones SQL y seguridad de bajo nivel con $wpdb description: >- Análisis exhaustivo de seguridad de base de datos en WordPress 7.0+ (PHP 8.4+): funcionamiento interno de $wpdb->prepare(), el marcador de identificadores %i (WP 6.2+), búsquedas LIKE seguras mediante esc_like, transacciones InnoDB y por qué esc_sql() sin comillas constituye una vulnerabilidad crítica. module: security order: 2 difficulty: advanced estimatedTime: 42 min topics:
La inyección SQL (SQLi, CWE-89) continúa siendo una de las categorías de vulnerabilidad más devastadoras en la ingeniería de software web. Su explotación exitosa permite al atacante eludir las reglas de negocio, exfiltrar registros confidenciales de usuarios, sobrescribir hashes de contraseñas de administradores en wp_users, implantar puertas traseras persistentes o destruir por completo los clústeres de bases de datos.
En el núcleo de WordPress, la comunicación con el motor de base de datos está encapsulada en la clase singleton global $wpdb (una instancia de wpdb). La concatenación directa de variables procedentes de usuarios en sentencias SQL sin procesar está estrictamente prohibida. El estándar indiscutible de ejecución segura es la Parametrización mediante el método $wpdb->prepare().
No obstante, muchos desarrolladores introducen fallas arquitectónicas severas debido a conceptos erróneos habituales:
esc_sql(): La función esc_sql() únicamente neutraliza caracteres especiales en literales de texto, pero no envuelve los valores entre comillas. Si una variable se concatena en una cláusula numérica (WHERE id = $id), el atacante puede inyectar 1 OR 1=1 y la inyección se ejecutará con éxito, puesto que no contiene caracteres especiales que escapar.%s rodea automáticamente el valor con comillas simples (SELECT * FROM 'wp_posts'), generando un error de sintaxis en MySQL. En WordPress moderno, la interpolación segura de identificadores dinámicos se realiza mediante el marcador especializado %i (Identifier Placeholder).LIKE: Construcciones de búsqueda como LIKE '%term%' sin invocar previamente $wpdb->esc_like() permiten a un atacante enviar comodines % y _, degradando la consulta a un costoso Full Table Scan que satura la CPU del servidor.Imagine la ejecución de consultas SQL mediante $wpdb->prepare() como la firma de un contrato formal ante un notario público:
%d, %f, %s, %i) es el formulario preimpreso oficial con campos en blanco fuertemente tipados (en el campo de "Importe" %d físicamente solo caben enteros numéricos, en el campo de "Nombre" %s se escribe texto entrecomillado, y en el de "Unidad Organizativa" %i solo se acepta un identificador certificado).$wpdb->prepare() es el notario que revisa la documentación e inscribe personalmente los valores, asegurando que las cadenas queden blindadas entre comillas y que los números se reduzcan a sus primitivas matemáticas."WHERE id = " . $_GET['id']) equivale a firmar una hoja en blanco, permitiendo a la contraparte redactar cláusulas arbitrarias en letra pequeña («...y traspasar todas las propiedades de la empresa a mi favor: OR 1=1; DROP TABLE wp_users;»).DATOS ENTRANTES: $_POST['status'], $_POST['limit'], $_POST['table']
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ PLANTILLA: "SELECT * FROM %i WHERE status = %s LIMIT %d" │
└──────────────────────────────┬─────────────────────────────────────────┘
│ Invocación de $wpdb->prepare(..., $table, $status, $limit)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ CANALIZACIÓN DE PROCESAMIENTO: │
│ • %i (Identificador) ──► Envoltorio en tildes invertidas: `wp_table` │
│ • %s (Cadena) ──► mysqli_real_escape_string + comillas simples │
│ • %d (Entero) ──► Coerción forzada con intval() │
└──────────────────────────────┬─────────────────────────────────────────┘
│ SQL resultante seguro
▼
┌────────────────────────────────────────────────────────────────────────┐
│ EJECUCIÓN EN MYSQL: $wpdb->get_results() │
│ SELECT * FROM `wp_custom_table` WHERE status = 'active' LIMIT 20; │
└────────────────────────────────────────────────────────────────────────┘La consulta se formula con marcadores explícitos: %d (enteros), %f (flotantes), %s (cadenas) y %i (identificadores de tablas/columnas).
El método prepare() emplea rutinas vsprintf() para imponer coerción numérica estricta (intval/floatval) y valida el recuento de argumentos.
Para marcadores %s, los caracteres especiales ( , , , , ', ", ) se escapan y el valor se envuelve entre comillas simples.
Para marcadores %i, los valores se depuran de tildes invertidas y se encierran en apóstrofos de identificador (`table_name`).
La consulta se envía al motor con soporte de transacciones ACID (START TRANSACTION, COMMIT, ROLLBACK) y monitorización de errores.
En la arquitectura de Fullstack Agentic Lab, los estándares de seguridad de $wpdb se implementan para:
WHERE, ORDER BY y GROUP BY.$wpdb->esc_like() antes de la asignación de parámetros con %s.Contraste entre patrones vulnerables y el estándar endurecido de $wpdb:
A continuación se presenta un artefacto de producción desarrollado para WordPress 7.0+ y PHP 8.4+. Implementa un Enterprise Audit Telemetry Repository con:
%d, %s, %f).%i y validación secundaria contra una lista blanca inmutable.$wpdb->esc_like().<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise Database Repository
* Description: Arquitectura segura con $wpdb: marcador %i, transacciones InnoDB y prevención de 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
{
/**
* Lista blanca de tablas autorizadas para evitar sustituciones maliciosas de esquemas.
*/
private const array ALLOWED_TABLES = [
'agentic_telemetry_logs',
'agentic_audit_events',
'agentic_performance_benchmarks',
];
public function __construct(
private wpdb $db
) {}
/**
* Registra un evento de auditoría dentro de una transacción InnoDB segura.
*
* @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);
// Iniciar transacción para garantizar atomicidad ACID
$this->db->query('START TRANSACTION');
try {
$fullTableName = $this->db->prefix . $sanitizedTable;
// CRÍTICO: ¡Usar %i (WP 6.2+) para nombres de tablas!
// Parámetros escalares: %d (enteros), %s (cadenas), %f (flotantes)
$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("Error en la ejecución de SQL: {$this->db->last_error}");
}
$insertedId = (int) $this->db->insert_id;
// Confirmar transacción
$this->db->query('COMMIT');
return $insertedId;
} catch (\Throwable $e) {
// Revertir inmediatamente ante cualquier error
$this->db->query('ROLLBACK');
throw new RuntimeException("Transacción revertida: {$e->getMessage()}", 0, $e);
}
}
/**
* Búsqueda por coincidencia de subcadena con escape seguro de LIKE y paginación 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. CRÍTICO: ¡Escapar comodines de LIKE (% y _) antes de la interpolación!
$escapedSearch = $this->db->esc_like($searchTerm);
$likeArgument = '%' . $escapedSearch . '%';
// 2. Paginación Keyset basada en cursor en lugar de 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 ?: [];
}
/**
* Valida nombres dinámicos de tablas contra una lista blanca inmutable.
*/
private function assertValidTable(string $table): string
{
$clean = sanitize_key($table);
if (!in_array($clean, self::ALLOWED_TABLES, true)) {
throw new RuntimeException("Intento de acceso a tabla no autorizada: {$table}");
}
return $clean;
}
}
Mapee cada variable a su formato exacto: %d para IDs y contadores, %f para importes, %s para cadenas y %i para identificadores.
Si las tablas o columnas son dinámicas, verifique que pertenezcan a una matriz inmutable definida en constantes.
En cláusulas LIKE, procese el texto con $wpdb->esc_like() antes de incorporar los porcentajes de búsqueda.
Agrupe mutaciones en bloques START TRANSACTION / COMMIT, inspeccione $wpdb->last_error y aplique ROLLBACK ante fallos.