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 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
| PostgreSQL | cur.execute('... WHERE nombre = %s', (nombre,)) |
| MySQL | cur.execute('... WHERE nombre = %s', (nombre,)) |
| SQL Server | cur.execute('... WHERE nombre = ?', (nombre,)) |
| SQLite | cur.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 intentar | Por qué no basta |
|---|---|
| Prohibir las comillas en el formulario | Deja fuera apellidos de verdad, y quien ataca manda la petición sin pasar por tu formulario |
| Duplicar las comillas a mano | Funciona hasta el primer caso raro. Es reescribir mal lo que el conector ya hace bien |
| Buscar palabras como DROP o DELETE | El ataque de arriba no lleva ninguna. Y las mayúsculas, los comentarios y los espacios raros lo esquivan |
| Confiar en que la web es interna | La 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 columna | El 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
UNIONse llevó nombres de otra tabla. - 🔒 Se arregla con el hueco:
?o%ssegú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! 🌸