Capítulo 13 de 18 9 secciones 11 min

Cuando el dato lo escribe un desconocido

La consulta que devuelve la tabla entera porque alguien escribió una comilla, y las dos líneas que lo arreglan.

Este es el error de SQL que sale en las noticias, y en realidad es uno solo: pegar texto de fuera dentro de una consulta. Lo he visto de las dos formas, y aquí lo mido sobre la base del libro, y una comilla bien puesta convierte una búsqueda de un cliente en las 120 filas de la tabla. Se arregla con una línea, que es no pegar el texto y pasarlo aparte 🔒

Hasta aquí las consultas las escribías tú. En una aplicación de verdad, parte de la consulta la escribe quien está del otro lado 🔒

Alguien teclea un nombre en un buscador, y ese nombre entra en tu WHERE. Ahí empieza el capítulo.

Una pregunta antes: ¿qué pasa si lo que teclea esa persona lleva una comilla? Piénsalo un segundo, porque la respuesta es todo lo que hay que entender de esto 💭

La búsqueda normal

SELECT id, nombre FROM clientes WHERE nombre = 'Autoservicio Norte 007';
id  nombre
--  ----------------------
7   Autoservicio Norte 007

Un cliente. Es lo que esperas, y lo que esperas es lo que va a pasar el 99% de las veces 🙂

Pero fíjate en cómo está armada esa consulta si la escribe un programa: hay una parte fija y un trozo que vino de fuera, metido entre comillas.

SELECT id, nombre FROM clientes WHERE nombre = '   AQUÍ VA LO QUE TECLEÓ   ';

Y ahora la misma consulta, con una comilla dentro

Imagina que en vez de un nombre, la persona teclea esto:

' OR '1'='1

Métrelo en el hueco de arriba y lee despacio lo que queda, que es una consulta perfectamente válida:

SELECT COUNT(*) AS filas_devueltas FROM clientes WHERE nombre = '' OR '1'='1';
filas_devueltas
---------------
120

Los 120 clientes 😳

La comilla que tecleó cerró la que tu programa había abierto, y a partir de ahí lo que escribió dejó de ser un dato y pasó a ser consulta. El OR '1'='1' es cierto siempre, así que el WHERE deja pasar todo.

Eso es la inyección SQL entera, y no hace falta saber nada más para hacerla. Por eso importa tanto 🚨

Peor: llevarse una tabla que ni tocabas

SELECT nombre FROM clientes WHERE nombre = ''
UNION SELECT nombre FROM productos LIMIT 3;
nombre
-----------
Producto 01
Producto 02
Producto 03

Esa consulta dice FROM clientes y está devolviendo productos 🤯

El UNION del capítulo 8, que ahí era una herramienta, aquí es la puerta: quien inyecta lo usa para pegar los resultados de otra tabla a los de la tuya. Con paciencia se recorre la base entera, incluida la tabla de usuarios y contraseñas si la hay.

Y lo peor de todo: borrar

Casi todos los conectores dejan mandar varias sentencias separadas por punto y coma. Vamos a montarlo sobre una tabla de mentira, para no romper la de verdad:

CREATE TABLE promos_demo (id INTEGER PRIMARY KEY, texto TEXT);
INSERT INTO promos_demo (texto) VALUES ('2x1 en abarrotes'), ('10% en bebidas'), ('envio gratis');
SELECT COUNT(*) AS promos FROM promos_demo;
promos
------
3

Ahora, quien busca teclea esto:

'; DELETE FROM promos_demo; --

Y la consulta que queda es esta. Léela entera antes de ejecutarla:

SELECT * FROM promos_demo WHERE texto = '';
DELETE FROM promos_demo;
SELECT COUNT(*) AS quedan FROM promos_demo;
quedan
------
0

Cero 🫠

La comilla cerró el dato, el punto y coma abrió una sentencia nueva, y los dos guiones del final comentan lo que sobraba para que no dé error de sintaxis. Tres caracteres y la tabla está vacía.

Todo esto pasa por una sola cosa: pegar texto de fuera dentro de una consulta.

El mismo texto tecleado por dos caminos: pegado en la consulta, la comilla cierra el dato y lo demás pasa a ser consulta, y devuelve los 120 clientes. Pasado aparte en el hueco, el motor lo trata como texto y devuelve 0, que es lo correcto.
Es la misma entrada y el mismo motor. Lo único que cambia es si el texto se pega o se pasa aparte.

El arreglo, que es una línea

La solución no es revisar lo que teclea la gente ni prohibir las comillas. Alguien se puede apellidar D'Onofrio y tiene derecho a buscarse 🙂

La solución es no pegar el texto: se manda la consulta con un hueco marcado y el dato aparte. Así la base sabe cuál es la consulta antes de ver el dato, y ese dato ya no puede convertirse en instrucciones.

import sqlite3

con = sqlite3.connect(':memory:')
con.execute('CREATE TABLE clientes (id INTEGER, nombre TEXT)')
con.executemany('INSERT INTO clientes VALUES (?, ?)',
                [(1, 'Bodega San Martin'), (2, 'Minimarket El Sol'), (3, 'Mayorista Peru')])

def pegando(nombre):
    sql = f"SELECT nombre FROM clientes WHERE nombre = '{nombre}'"
    return sql, con.execute(sql).fetchall()

def parametrizada(nombre):
    return con.execute('SELECT nombre FROM clientes WHERE nombre = ?', (nombre,)).fetchall()

ATAQUE = "' OR '1'='1"
sql, filas = pegando(ATAQUE)
print('la consulta que se arma:', sql)
print('devuelve             :', len(filas), 'de 3 clientes')
print('parametrizada devuelve:', len(parametrizada(ATAQUE)))
la consulta que se arma: SELECT nombre FROM clientes WHERE nombre = '' OR '1'='1'
devuelve             : 3 de 3 clientes
parametrizada devuelve: 0

La diferencia entre las dos funciones son las comillas 🎯

La de arriba las pone ella con una f-string. La de abajo escribe un ? y le pasa el dato en una tupla aparte, así que el motor lo trata como texto pase lo que pase. Con el mismo ataque devuelve 0, que es lo correcto: ningún cliente se llama así.

Y no, no es más lento. Al contrario: muchos motores guardan el plan de la consulta con su hueco y lo reutilizan, que es justo lo que el capítulo 14 quiere que pase.

El hueco para el dato, que se escribe distinto en cada uno

PostgreSQLcur.execute('... WHERE nombre = %s', (nombre,))
MySQLcur.execute('... WHERE nombre = %s', (nombre,))
SQL Servercur.execute('... WHERE nombre = ?', (nombre,))
SQLitecur.execute('... WHERE nombre = ?', (nombre,))

Ojo con el de PostgreSQL y MySQL: ese %s NO es el de las f-string de Python y no se rellena con % ni con format. Es el marcador del conector, y el dato va siempre en la tupla del segundo argumento. Escribirlo con % es exactamente el error del capítulo, con otra cara.

El error que sale sin que nadie ataque

Y antes de los ejercicios, la versión inocente del mismo fallo. Un cliente que se apellida así:

SELECT nombre FROM clientes WHERE nombre = 'D'Onofrio';
OperationalError: near "Onofrio": syntax error

Ni ataque ni nada: un apellido normal. La comilla del apellido cierra la del programa y lo que sigue deja de tener sentido.

Esto es lo que más veces vas a ver en la práctica, y es la misma grieta por la que entra lo demás. Si tu sistema se rompe con un apellido, el agujero ya está abierto: solo falta que alguien escriba algo peor que un apellido 🚩

Lo que NO sirve

Lo que se suele intentarPor qué no basta
Prohibir las comillas en el formularioDeja fuera apellidos de verdad, y quien ataca manda la petición sin pasar por tu formulario
Duplicar las comillas a manoFunciona hasta el primer caso raro. Es reescribir mal lo que el conector ya hace bien
Buscar palabras como DROP o DELETEEl ataque de arriba no lleva ninguna. Y las mayúsculas, los comentarios y los espacios raros lo esquivan
Confiar en que la web es internaLa mitad de los casos que he visto son de gente de dentro, y no siempre a propósito
Parametrizar el dato pero pegar el nombre de la columnaEl hueco vale para datos, no para nombres de tabla o columna. Para eso hay que validar contra una lista tuya

Lo único que sirve es el hueco. Todo lo demás son parches encima de la grieta 🩹

La trampa

Un equipo arma el informe de ventas por ciudad. La ciudad la elige quien consulta, en un desplegable, así que "no puede venir cualquier cosa". El dato lo parametrizan bien y el orden lo pegan, porque un ORDER BY no acepta hueco.

# la ciudad viene de un desplegable y va parametrizada, correcto
sql = "SELECT ciudad, SUM(monto) AS total FROM pedidos p "
sql += "JOIN clientes c ON c.id = p.id_cliente WHERE c.ciudad = ? "
sql += "GROUP BY ciudad ORDER BY " + columna + " " + sentido

cur.execute(sql, (ciudad,))
Qué está mal

El dato está bien y el ORDER BY está pegado, que es cierto que no admite hueco. El problema es de dónde salen columna y sentido: vienen de la petición, o sea de fuera, aunque en la pantalla sean dos flechitas. Ahí cabe un UNION entero, y nadie lo mira porque "el dato está parametrizado". Se arregla comprobándolos contra una lista tuya antes de pegarlos: if columna not in ('ciudad', 'total'): columna = 'ciudad', y lo mismo con ASC y DESC. La regla, y vale para todo el capítulo: el hueco es para datos, y todo lo que no sea un dato se valida contra una lista cerrada. Un nombre de tabla, un nombre de columna y un sentido de orden no son datos.

Ejercicios

Siete sobre tienda.db. El 4 es el que más enseña 💛

1. Cuántas filas debería devolver

Antes de correr nada: si el buscador recibe Mayorista Peru 019, ¿cuántas filas salen? ¿Y con ' OR 1=1 --?

SELECT COUNT(*) AS normal FROM clientes WHERE nombre = 'Mayorista Peru 019';
SELECT COUNT(*) AS inyectada FROM clientes WHERE nombre = '' OR 1=1 --';

Fíjate en que esta variante ni siquiera usa '1'='1': con 1=1 basta, y los dos guiones se comen la comilla que sobraba. Hay docenas de formas de escribir lo mismo, y por eso buscar patrones no funciona.

2. Cuenta las tablas de la base ajena

Con un UNION, averigua cuántas tablas tiene la base desde una consulta que solo miraba clientes.

SELECT nombre FROM clientes WHERE nombre = ''
UNION SELECT name FROM sqlite_master WHERE type = 'table';

Ahí está el primer paso de cualquier ataque de verdad: enterarse de qué hay. Los cuatro motores tienen su catálogo, con otro nombre. Es lo mismo que hicimos en el capítulo 2 para explorar, usado al revés.

3. El apellido con comilla, arreglado

Haz que la búsqueda de D'Onofrio funcione, sin prohibir nada.

import sqlite3
con = sqlite3.connect(':memory:')
con.execute('CREATE TABLE clientes (nombre TEXT)')
con.execute("INSERT INTO clientes VALUES (\"D'Onofrio\")")
print(con.execute('SELECT nombre FROM clientes WHERE nombre = ?', ("D'Onofrio",)).fetchall())

Funciona sin tocar el apellido. Esa es la prueba de que parametrizar no es una restricción: es lo que te deja aceptar datos de verdad.

4. Escribe tú el ataque

Sin código nuevo. Tienes esta consulta y controlas {correo}. Escribe en un papel qué tecleas para entrar sin saber la contraseña.

SELECT * FROM usuarios WHERE correo = '{correo}' AND clave = '{clave}'

La respuesta cabe en pocos caracteres y la vas a encontrar sola si releíste lo del punto y coma y los guiones. Esto es lo que enseña de verdad: mientras no lo hayas escrito tú, el peligro suena teórico.

Y quede claro para qué es: para reconocerlo en tu propio código. Probar esto contra un sistema que no es tuyo no es un ejercicio, es un delito.

5. Revisa código de verdad

Sin dataset. Busca en el código de tu trabajo, o en cualquier proyecto tuyo, las tres señales de esto.

Son estas: una f-string o un + pegando algo dentro de una cadena que empieza por SELECT, un % aplicado a una consulta, y un ORDER BY con una variable al lado. Con buscar SELECT en tu editor y mirar cada uno tienes el rato hecho 🔍

6. El que borra, con red de seguridad

Repite el DELETE encadenado, pero envuélvelo en una transacción y deshazlo.

CREATE TABLE promos_demo2 (texto TEXT);
INSERT INTO promos_demo2 VALUES ('una'), ('dos');
BEGIN;
DELETE FROM promos_demo2;
ROLLBACK;
SELECT COUNT(*) AS quedan FROM promos_demo2;

Vuelven las dos. Es el capítulo 12 usado como red, y es la costumbre que te salva el día que ejecutes algo sin querer.

7. Los permisos, que son la otra mitad

Sin código. Piensa qué permisos necesita de verdad el usuario con el que tu web se conecta a la base.

Casi siempre: leer, y escribir en dos o tres tablas. Casi nunca: DROP, ALTER ni tocar las tablas de otro sistema. Si el usuario de la web no puede borrar tablas, una inyección hace muchísimo menos daño.

Esto no reemplaza parametrizar, se suma. Y es de las cosas que se piden una vez al de sistemas y protegen para siempre 🛡️

Comprueba que lo tienes

Estás revisando código y encuentras esta línea. ¿Qué haces?
cur.execute("SELECT * FROM pedidos WHERE id = %s" % id_pedido)

  • La cambio a execute con el dato en el segundo argumento
  • Está bien, ya usa %s
  • Compruebo que id_pedido sea un número y lo dejo
  • Le pongo comillas alrededor del %s

Lo que te llevas

  • 🔓 Toda inyección sale de lo mismo: pegar texto de fuera dentro de la consulta.
  • 💥 Una comilla convirtió una búsqueda de un cliente en las 120 filas, y un UNION se llevó nombres de otra tabla.
  • 🔒 Se arregla con el hueco: ? o %s según el motor, y el dato en el segundo argumento.
  • 📋 El hueco vale para datos. Nombres de columna y sentidos de orden se validan contra una lista tuya.
  • 🩹 Prohibir comillas, buscar palabras raras y confiar en que la red es interna no sirven.
  • 🛡️ Y si además el usuario de la aplicación no puede borrar tablas, el daño es mucho menor.

Si esto lo vas a escribir desde Python, el conector y las tuplas están en el libro de Python desde cero 🐍

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?