Atención: La respuesta aceptada es sin duda buena, pero es insuficiente. Es cierto que el uso de consultas preparadas nos evitan casi los ataques conocidos como inyección SQL de primer grado. Pero si PDO está mal configurado las consultas preparadas podrían ser vulnerables, como se muestra con ejemplos en la respuesta. Al final he puesto los enlaces que han servido de base a la elaboración de esta respuesta, sobre todo Preventing SQL Injection in PHP Applications - the Easy and Definitive Guide y An SQL injection against which prepared statements won't help.
Mi único mérito ha sido la traducción, quizá mejorable en algunos puntos. Nada es 100% seguro, lo sabemos, pero el simple uso de PDO o MySQLi han de ser combinados con otras estrategias, explicadas en el punto 5, para asegurar algo de tanto valor como nuestros datos.
1. Nota introductoria
Inyección de SQL es una técnica para tomar el control de una consulta de base de datos que a menudo resulta en un compromiso de confidencialidad. En algunos casos (por ejemplo, si SELECT 'código diabólico aquí' INTO OUTFILE '/var/www/reverse_shell.php'
tiene éxito) esto puede resultar en una toma de posesión completa del servidor.
Dado que la inyección de código (que abarca las técnicas de SQL, LDAP, Command OS y XPath Injection) ha permanecido constantemente en la parte superior de las vulnerabilidades de Top 10 de OWASP, es un tema popular para los bloggers que intentan mojarse en el campo de seguridad de aplicaciones.
Desafortunadamente muchos de los consejos que circulan en Internet (especialmente en los blogs antiguos que ocupan un lugar destacado en los motores de búsqueda) están desactualizados, son involuntariamente engañosos y a menudo peligrosos.
2. Un error muy común: PDO mal configurado
Si usted es un desarrollador PHP que busca obtener el máximo provecho de PDO, le recomendamos que cambie dos de los valores predeterminados:
- Desactivar preparaciones emuladas. Esto asegura que obtenga declaraciones preparadas.
- Establecer el modo de error para lanzar excepciones. Esto evita que tenga que estar observando los resultados de
PDOStatement::execute()
y hace que su código sea menos redundante.
Para hacer ambas cosas:
$pdo = new PDO(/* Fill in the blank */);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
Debido a que PDO::ATTR_EMULATE_PREPARES
está seteado a false , estamos obteniendo instrucciones reales preparadas, y porque hemos seteado PDO::ATTR_ERRMORE
a PDO::ERRMODE_EXCEPTION
, en lugar de esto ...
$stmt = $pdo->prepare("SELECT * FROM foo WHERE first_name = ? AND last_name = ?");
if ($stmt->execute([$_GET['first_name'], $_GET['last_name'])) {
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
} else {
// Handle error here.
}
$args = [
json_encode($_GET)
(new DateTime())->format('Y-m-d H:i:s')
];
$insert = $pdo->prepare("INSERT INTO foo_log (params, time) VALUES (?, ?);");
if (!$insert->execute($args)) {
// Handle error here.
}
... usted puede simplemente escribir su código así:
try {
$stmt = $pdo->prepare("SELECT * FROM foo WHERE first_name = ? AND last_name = ?");
$stmt->execute([$_GET['first_name'], $_GET['last_name']);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$args = [
json_encode($_GET),
(new DateTime())->format('Y-m-d H:i:s')
];
$pdo->prepare("INSERT INTO foo_log (params, time) VALUES (?, ?);")
->execute($args);
} catch (PDOException $ex) {
// Handle error here.
}
Mayor seguridad, brevedad y mejor legibilidad. ¿Qué más se puede pedir?
3. Otro error: no entender del todo para qué sirve PDO
Un error muy frecuente es no entender del todo para qué existe PDO y terminar usándolo como un simple ayudante de actualización.
Mientras juegan con PDO, casi todos los usuarios de PHP terminan considerándolo como una especie de mejora para la consulta INSERT
/ UPDATE
, que acepta una matriz asociativa y crea una consulta con marcadores dinámicos, que básicamente se parece a esto (un código real tomado de una pregunta sobre Stack Overflow ):
$params = [];
$setStr = "" ;
foreach ( $data as $key => $value )
{
if ( $key != "id" )
{
$setStr .= $key . " = :" . $key . "," ;
}
$params [ ':' . $key ] = $value ;
}
$setStr = rtrim ( $setStr , "," );
$pdo -> prepare ( "UPDATE users SET $setStr WHERE id = :id" )-> execute ( $params );
Se ve bastante bien, ya que puede producir rápidamente una consulta dentro de una matriz o arreglo en la que las claves representan nombres de columna y los valores representan los valores que se utilizarán en la consulta. Dado que los nombres de campos en formato HTML son los mismos que los nombres de columna de tabla, puede automatizar en gran medida el procesamiento de formularios, permitiéndole utilizar el mismo código para editar información en cualquier tabla. Muy conveniente. Pero catastróficamente vulnerable.
"¿Cómo?" - usted diría probablemente - "los marcadores de posición se utilizan y los datos se enlazan con seguridad, por lo tanto nuestra consulta es segura". Sí, los datos son seguros. Pero, de hecho, lo que hace este código es tomar la entrada del usuario y agregarlo directamente a la consulta. Sí, es una variable $key
. Que va a la derecha en su consulta sin tratamiento.
Usar PDO de forma ingenua, y mal configurado, es una puerta abierta a la inyección SQL.
4. Ejemplos de inyección posibles con PDO mal configurado y desmontando algunos mitos sobre protección de la base de datos
El punto 4 sólo aplica si tenemos un PDO mal configurado. Aunque es un poco extenso lo he querido traducir para que podamos apreciar el alcance de un error tan fácil de cometer.
También desmonta algunos mitos como el saneamiento de nuestras cadenas, erróneamente recomendado en muchos sitios de internet.
Aquí hay una pequeña prueba de código de concepto, que, siempre y cuando tenga una tabla "usuarios" y una fila con id = 1, cambiará el nombre de usuario en la tabla a un resultado de la consulta SELECT
. Y esta consulta podría ser cualquier cosa:
<form method = POST>
<input type = hidden name = "name=(SELECT'hacked!')WHERE`id`=1#" value = "">
<input type = hidden name = "name" value = "Joe">
<input type = hidden name = "id" value = "1">
<input type = submit>
</form>
<? php
if ( $_POST ) {
$pdo = new PDO ( 'mysql:dbname=test;host=localhost' , 'root' , '' );
$params = [];
$setStr = "" ;
foreach ( $_POST as $key => $value )
{
if ( $key != "id" )
{
$setStr .= $key . " = :" . $key . "," ;
}
$params [ $key ] = $value ;
}
$setStr = rtrim ( $setStr , "," );
$pdo -> prepare ( "UPDATE users SET $setStr WHERE id = :id" )-> execute ( $params );
}
Este código producirá una consulta como esta
UPDATE users
SET name =
( SELECT 'hacked!' )
WHERE ` id `= 1 # = :name=(SELECT'1')
WHERE`id`=1#,name = :name WHERE id = :id
Donde todo lo pasado después de # será tratado como un comentario.
Puedes probarlo en casa.
¿Qué sucede en este código? Estamos forjando un formulario, añadiendo otro campo a él, y escribiendo un código SQL en el atributo name
. La función auxiliar mencionada tomará este SQL forjado y lo insertará en la consulta construida. Como resultado, en lugar de "Joe" el nombre se establecerá en "hackeado!". ¿No asusta demasiado, eh? Pero usted tiene que entender que lo que es peligroso aquí es el hecho de la inyección. Mientras que sea posible, la cantidad de crear consultas mal intencionadas es infinita. Y no todas son tan inofensivas. A continuación veremos una vulnerabilidad más peligrosa.
PDO :: quote () - el movimiento equivocado
Ok, necesitamos proteger nuestro código útil (así como cualquier otro código que no pueda ser protegido con declaraciones preparadas).
Lo primero que un usuario promedio de PHP probablemente pensaría es una función incorporada de PDO::quote()
, que aparentemente hace lo que necesitamos - protege los datos de la inyección de SQL -. Pero pronto estará claro que una función destinada al formato de cadena
es inaplicable para los identificadores. Ésta agregará comillas simples alrededor de valor devuelto y hará que nuestro código produzca una secuencia como esta:
'name' = 'joe'
Lo que resultará en un error de sintaxis, ya que las comillas simples son ilegales como delimitadores de nombre de campo en MySQL.
No pienses nunca en recortar comillas simples de la cadena resultante, ya que fue sugerido hace algún tiempo en un comentario fuertemente promocionado (ahora borrado) bajo la entrada de PDO::quote()
en el manual de PHP: el propósito de esta función es hacer un formateo completo, haciendo escape de ambos caracteres y agregando comillas. Saca una de estas dos acciones y tendrás una inyección.
En otras palabras, si tiramos las comillas simples circundantes del resultado de PDO::quote()
, será como si solo aplicáramos la picadura regular escapando. Y veamos por qué está mal:
Cadena de escape (mysql_real_escape_string) - un desastre
Otra cosa que un usuario de PHP podría pensar es una función de escape familiar, que "hace que sus datos sean seguros" como falsamente se declaró durante mucho tiempo en el manual de PHP. Desafortunadamente, solo ayudará si el código de inyección contiene comillas simples u otros caracteres con esta función. La mala noticia es que todos esos carácteres son innecesarios para la inyección, demostrando esta función inútil para el caso (y refutando las habilidades ficticias de protección de esta función):
<form method = POST>
<input type = hidden name = "name=(SELECT password from admins)WHERE`id`=1#" value = "">
<input type = hidden name = "name" value = "Joe">
<input type = hidden name = "id" value = "1">
<input type = submit>
</form>
<? php
if ( $_POST ) {
$pdo = new PDO ( 'mysql:dbname=test;host=localhost' , 'root' , '' );
$params = [];
$setStr = "" ;
foreach ( $_POST as $key => $value )
{
if ( $key != "id" )
{
$setStr .= addslashes ( $key ). " = :" . $key . "," ;
}
$params [ $key ] = $value ;
}
$setStr = rtrim ( $setStr , "," );
$pdo -> prepare ( "UPDATE users SET $setStr WHERE id = :id" )-> execute ( $params );
var_dump ( "UPDATE users SET $setStr WHERE id = :id" , $_POST );
}
?>
Como se puede ver, no hay una sola cita en la consulta maliciosa y por lo tanto ni addslashes()
ni varios *_escape_string()
puede hacer nada aquí.
Esta inyección es mucho más peligrosa ya que revelará la contraseña del administrador a cualquier persona.
Adición de Operadores de ejecución o comillas invertidas `` (aún vulnerable)
Ok, como ya hemos aprendido, las comillas simples no ayudarán. ¿Quizá los operadores de ejecución o comillas invertidas (``) ayudarían? Veamos si ayudan:
<form method = POST>
<input type = hidden name = "name`=(SELECT'hacked!')WHERE`id`=1#" value = "">
<input type = hidden name = "name" value = "Joe">
<input type = hidden name = "id" value = "1">
<input type = submit>
</form>
<? php
if ( $_POST ) {
$pdo = new PDO ( 'mysql:dbname=test;host=localhost' , 'root' , '' );
$params = [];
$setStr = "" ;
foreach ( $_POST as $key => $value )
{
if ( $key != "id" )
{
$setStr .= "` $key ` = : $key ," ;
}
$params [ $key ] = $value ;
}
$setStr = rtrim ( $setStr , "," );
$pdo -> prepare ( "UPDATE users SET $setStr WHERE id = :id" )-> execute ( $params );
}
?>
Como se puede ver, añadir comillas invertidas no nos ayudó en absoluto: un atacante sólo añade otra comilla para cerrar prematuramente el identificador y luego proceder con la consulta malintencionada. Sucede porque agregar cualquier delimitador alrededor de algún literal es inútil si no escapamos de estos delimitadores dentro - es la lección que aprendimos duramente de las inyecciones convencionales basadas en cadenas. ¡Así que escaparemos nuestras comillas invertidas!
Escapando las comillas invertidas
Entonces, ¿qué se puede hacer para conservar una función tan útil, pero sin una violación terrible?
Como se dijo antes, escapar delimitadores ayudaría. Por lo tanto, su primer nivel de defensa debe ser escapar delimitadores (comillas invertidas) duplicándolos.
Construyendo nuestra consulta de esta manera:
$setStr .= "`" . str_replace ( "`" , "``" , $key ). "` = :" . $key . "," ;
Usted conseguirá eliminar la inyección.
Si tratamos de inyectar, utilizando el método anterior, resultará en un error de ejecución de la consulta, que, aunque es seguramente mejor que una inyección exitosa, todavía no es un comportamiento bastante deseable.
Esta es la razón por la que citar / escapar nombres de campo aún no es suficiente. Y esta es la razón por la que usted debe utilizar otro nivel de defensa:
Solución sólida en todo
Hay una cosa más a tener en cuenta: aunque citar / escapar nombres de campo eliminará la inyección de SQL clásica, hay otro vector de ataque posible. Como no controlamos qué nombres de campo se utilizan para actualizar, es posible que el usuario modifique el valor al que no debería tener acceso. Por ejemplo, imagine que hay un campo en una tabla de usuarios como admin
que se establece en 1 para admins y 0 para usuarios regulares. La función en cuestión permitirá a cualquier manipulador de código aumentar el nivel de privilegio de su cuenta.
Suponiendo todo lo anterior, una función tan útil debe estar siempre aceptando un parámetro adicional con una lista de nombres de campos permitidos:
<form method = POST>
<input type = hidden name = "name`=(SELECT'hacked!')WHERE`id`=1#" value = "">
<input type = hidden name = "name" value = "Joe">
<input type = hidden name = "id" value = "1">
<input type = submit>
</form>
<? php
if ( $_POST ) {
$pdo = new PDO ( 'mysql:dbname=test;host=localhost' , 'root' , '' );
$allowed = [ "name" , "surname" , "email" ];
$params = [];
$setStr = "" ;
foreach ( $allowed as $key )
{
if (isset( $_POST [ $key ]) && $key != "id" )
{
$setStr .= "`" . str_replace ( "`" , "``" , $key ). "` = :" . $key . "," ;
$params [ $key ] = $_POST [ $key ];
}
}
$setStr = rtrim ( $setStr , "," );
$params [ 'id' ] = $_POST [ 'id' ];
var_dump ( "UPDATE users SET $setStr WHERE id = :id" , $params , $_POST );
$pdo -> prepare ( "UPDATE users SET $setStr WHERE id = :id" )-> execute ( $params );
}
?>
¡Con tal mejora nuestro código se convertirá en sólido en todo!
Por supuesto, los ejemplos anteriores (punto 4) sólo funcionarán si el modo de emulación está activado. Pero usted tiene que entender que si la inyección del SQL puede ser explotada con éxito o no es una pregunta diferente y completamente irrelevante. Usted no debe dejar una inyección en el primer lugar. O la manera de explotarlo se encontrará, un día u otro.
5. Entonces, las consultas preparadas sirven o no?
Sí, pero a condición de que nuestro PDO esté configurado como se indica en el apartado 2
5.1 Cómo prevenir inyección de SQL (casi) siempre* garantizado
Utilice las instrucciones preparadas , también conocidas como consultas parametrizadas . Por ejemplo:
/**
* Note: This code is provided for demonstration purposes.
* In general, you want to add some application logic to validate
* the incoming parameters. You do not need to escape anything.
*/
$stmt = $pdo->prepare('SELECT * FROM blog_posts WHERE YEAR(created) = ? AND MONTH(created) = ?');
if ($stmt->execute([$_GET['year'], $_GET['month']])) {
$posts = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
Las declaraciones preparadas eliminan cualquier posibilidad de inyección de SQL en su aplicación web. No importa lo que se pasa a las variables $_GET aquí, la estructura de la consulta SQL no puede ser cambiada por un atacante (a menos que, por supuesto, tenga PDO::ATTR_EMULATE_PREPARES
activado, lo que significaría que no está usando auténticas declaraciones).
Nota: Si usted intenta desactivar PDO::ATTR_EMULATE_PREPARES
, es posible que algunas versiones de manejadores de bases de datos ignoren este intento. Para tomar precauciones extras, establezca explícitamente el conjunto de caracteres en el DSN a uno que su aplicación y base de datos utilizan (por ejemplo UTF-8 , el cual, si está usando MySQL, se llama confusamente
utf8mb4` ).
Los estados preparados solucionan un problema fundamental de la seguridad de la aplicación: Separan los datos que se van a procesar de las instrucciones que operan sobre dichos datos enviándolos en paquetes completamente separados. Este es el mismo problema fundamental que hace provoca los desbordamientos de pila.
Siempre y cuando nunca concatenes las variables proporcionadas por el usuario o el entorno con la sentencia SQL (y asegúrandote de no usar preparaciones emuladas) puedes, para todos los propósitos, descartar la inyección de SQL de tu lista de preocupaciones para siempre.
***Advertencia Importante y Clarificación ****
Las sentencias preparadas protegen las interacciones entre su aplicación web y su servidor de base de datos (si están en máquinas separadas, también deben comunicarse a través de TLS). Todavía es posible que un atacante pueda almacenar una carga útil en un campo que podría ser peligroso, por ejemplo, en un procedimiento almacenado. Llamamos a esto una inyección SQL de orden superior (la respuesta de Stack Overflow vinculada se refiere a ellos como de "segundo orden", pero cualquier cosa que se ejecute después la consulta inicial debería ser objeto de análisis).
En esta situación, nuestro consejo sería no escribir procedimientos almacenados de tal manera que creen puntos de inyección SQL de orden superior.
5.2 ¿Qué pasa con el saneamiento de las entradas (Sanitizing Input)?
Si bien es posible prevenir ataques mediante la reescritura del flujo de datos entrantes antes de enviarlo al controlador de la base de datos, esta práctica está llena de matices peligrosos y oscuros... (Ambos enlaces en la oración anterior son muy recomendables.)
A menos que desee tomar el tiempo para investigar y lograr un dominio completo sobre todos los formatos Unicode que su aplicación use o acepte, es mejor que no intente desinfectar sus entradas. Las sentencias preparadas son más eficaces en la prevención de la inyección de SQL que las cadenas de escape.
Además, alterar el flujo de datos entrantes puede causar daños en los datos, especialmente si se trata de bloques binarios crudos (por ejemplo, imágenes o mensajes cifrados).
Las sentencias preparadas son más fáciles y pueden garantizar la prevención de la inyección de SQL.
Si la entrada del usuario nunca tiene la oportunidad de alterar la cadena de consulta, nunca puede conducir a la ejecución de código. Las sentencias preparadas separan completamente el código de los datos.
5.3 La entrada debe ser validada
La validación no es lo mismo que el saneamiento. Las sentencias preparadas pueden impedir la inyección de SQL, pero no pueden guardarlo de datos incorrectos. Para la mayoría de los casos, filter_var()
es útil aquí.
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (empty($email)) {
throw new \InvalidArgumentException('Invalid email address');
}
Nota: filter_var()
valida que la cadena de correo electrónico dada se ajusta a la especificación RFC. No garantiza que haya una bandeja de entrada abierta en esa dirección ni compruebe que el nombre de dominio esté registrado. Una dirección de correo electrónico válida todavía no es segura para usar en consultas sin procesar, ni para mostrar en una página web sin filtrar para evitar ataques XSS .
5.4 ¿Qué pasa con los identificadores de columnas y tablas?
Dado que los identificadores de columna y tabla forman parte de la estructura de consulta, no es posible parametrizarlos. Por lo tanto, si la aplicación que está desarrollando requiere una estructura de consulta dinámica donde las tablas o columnas son seleccionadas por el usuario, debe optar por una lista blanca.
Una lista blanca es una estrategia de lógica de aplicación que explícitamente sólo permite unos cuantos valores aceptados y rechaza el resto o utiliza un predeterminado sano. Contrasta con una lista negra, que sólo prohíbe las entradas mal conocidas. En la mayoría de los casos, las listas blancas son mejores para la seguridad que las listas negras.
$qs = 'SELECT * FROM photos WHERE album = ?';
// Use switch-case for an explicit whitelist
switch ($_POST['orderby']) {
case 'name':
case 'exifdate':
case 'uploaded':
// These strings are trusted and expected
$qs .= ' ORDER BY ' . $_POST['orderby'];
if (!empty($_POST['asc'])) {
$qs .= ' ASC';
} else {
$qs .= ' DESC';
}
break;
default:
// Some other value was passed. Let's just order by photo ID in descending order.
$qs .= ' ORDER BY photoid DESC';
}
$stmt = $db->prepare($qs);
if ($stmt->execute([$_POST['album_id']])) {
$photos = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
Si permite que el usuario final proporcione los nombres de la tabla y / o de las columnas, ya que los identificadores no pueden parametrizarse, debe recurrir a escapar. En estas situaciones, recomendamos lo siguiente:
No : Simplemente escriba los meta caracteres SQL (por ejemplo ' )
Sí : Filtre todos los caracteres que no están permitidos.
El siguiente fragmento de código sólo permitirá nombres de tablas que comiencen con una letra en mayúscula o minúscula, seguida de cualquier número de caracteres alfanuméricos y subrayados.
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $table)) {
throw new AppSpecificSecurityException("Possible SQL injection attempt.");
}
// And now you can safely use it in a query:
$stmt = $pdo->prepare("SELECT * FROM {$table}");
if ($stmt->execute()) {
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
5.6 ¿Qué pasa si el uso de declaraciones preparadas parece demasiado engorroso?
La primera vez que un desarrollador encuentra sentencias preparadas, puede sentirse frustrado por la perspectiva de verse forzado a escribir mucho código redundante (preparar, ejecutar, buscar, preparar, ejecutar, buscar, ad nauseam ).
Existe una biblioteca de PHP llamada EasyDB, que puede servir como alternativa al uso de PDO.
6. Enlaces
- PDO y MySQLi nos protegen de la inyección SQL primaria solamente
- Inyección SQL de segundo nivel
- Muy buen artículo con algunas recomendaciones para el uso de PDO