Cada pocos años, el Proyecto de seguridad de aplicaciones web abiertas (OWASP) clasifica los riesgos de seguridad de aplicaciones web más críticos. Desde el primer informe, los riesgos de inyección siempre han estado en la cima. Entre todos los tipos de inyección, inyección SQL es uno de los vectores de ataque más comunes y posiblemente el más peligroso. Como Python es uno de los lenguajes de programación más populares del mundo, es fundamental saber cómo protegerse contra la inyección SQL de Python.
En este tutorial, aprenderá:
- Qué inyección Python SQL es y cómo prevenirlo
- Cómo redactar consultas con literales e identificadores como parámetros
- Cómo ejecutar consultas de forma segura en una base de datos
Este tutorial es adecuado para usuarios de todos los motores de bases de datos . Los ejemplos aquí usan PostgreSQL, pero los resultados se pueden reproducir en otros sistemas de administración de bases de datos (como SQLite, MySQL, Microsoft SQL Server, Oracle, etc.).
Bonificación gratuita: 5 pensamientos sobre el dominio de Python, un curso gratuito para desarrolladores de Python que le muestra la hoja de ruta y la mentalidad que necesitará para llevar sus habilidades de Python al siguiente nivel.
Comprender la inyección SQL de Python
Los ataques de inyección SQL son una vulnerabilidad de seguridad tan común que el legendario xkcd webcomic le dedicó un cómic:
La generación y ejecución de consultas SQL es una tarea común. Sin embargo, las empresas de todo el mundo suelen cometer errores terribles cuando se trata de redactar sentencias SQL. Mientras que la capa ORM normalmente compone consultas SQL, a veces tienes que escribir las tuyas propias.
Cuando usa Python para ejecutar estas consultas directamente en una base de datos, existe la posibilidad de que pueda cometer errores que puedan comprometer su sistema. En este tutorial, aprenderá cómo implementar con éxito funciones que componen consultas SQL dinámicas sin poniendo su sistema en riesgo de inyección Python SQL.
Configuración de una base de datos
Para comenzar, configurará una base de datos PostgreSQL nueva y la llenará con datos. A lo largo del tutorial, usará esta base de datos para presenciar de primera mano cómo funciona la inyección SQL de Python.
Crear una base de datos
Primero, abra su shell y cree una nueva base de datos PostgreSQL propiedad del usuario postgres
:
$ createdb -O postgres psycopgtest
Aquí usó la opción de línea de comando -O
para establecer el propietario de la base de datos para el usuario postgres
. También especificó el nombre de la base de datos, que es psycopgtest
.
postgres
es un usuario especial , que normalmente reservarías para tareas administrativas, pero para este tutorial, está bien usar postgres
. Sin embargo, en un sistema real, debe crear un usuario independiente para que sea el propietario de la base de datos.
¡Tu nueva base de datos está lista para funcionar! Puedes conectarte usando psql
:
$ psql -U postgres -d psycopgtest
psql (11.2, server 10.5)
Type "help" for help.
Ahora estás conectado a la base de datos psycopgtest
como usuario postgres
. Este usuario también es el propietario de la base de datos, por lo que tendrá permisos de lectura en todas las tablas de la base de datos.
Crear una tabla con datos
A continuación, debe crear una tabla con información del usuario y agregarle datos:
psycopgtest=# CREATE TABLE users (
username varchar(30),
admin boolean
);
CREATE TABLE
psycopgtest=# INSERT INTO users
(username, admin)
VALUES
('ran', true),
('haki', false);
INSERT 0 2
psycopgtest=# SELECT * FROM users;
username | admin
----------+-------
ran | t
haki | f
(2 rows)
La tabla tiene dos columnas:username
y admin
. El admin
La columna indica si un usuario tiene o no privilegios administrativos. Tu objetivo es dirigirte al admin
e intentar abusar de él.
Configuración de un entorno virtual de Python
Ahora que tiene una base de datos, es hora de configurar su entorno de Python. Para obtener instrucciones paso a paso sobre cómo hacer esto, consulte Entornos virtuales de Python:un manual básico.
Cree su entorno virtual en un nuevo directorio:
(~/src) $ mkdir psycopgtest
(~/src) $ cd psycopgtest
(~/src/psycopgtest) $ python3 -m venv venv
Después de ejecutar este comando, un nuevo directorio llamado venv
se creará. Este directorio almacenará todos los paquetes que instale dentro del entorno virtual.
Conexión a la base de datos
Para conectarse a una base de datos en Python, necesita un adaptador de base de datos . La mayoría de los adaptadores de bases de datos siguen la versión 2.0 de la especificación PEP 249 de la API de base de datos de Python. Cada motor de base de datos principal tiene un adaptador líder:
Base de datos | Adaptador |
---|---|
PostgreSQL | Psicopg |
SQLite | sqlite3 |
Oráculo | cx_oracle |
MySql | MySQLdb |
Para conectarse a una base de datos PostgreSQL, deberá instalar Psycopg, que es el adaptador más popular para PostgreSQL en Python. Django ORM lo usa de forma predeterminada y también es compatible con SQLAlchemy.
En tu terminal, activa el entorno virtual y usa pip
para instalar psycopg
:
(~/src/psycopgtest) $ source venv/bin/activate
(~/src/psycopgtest) $ python -m pip install psycopg2>=2.8.0
Collecting psycopg2
Using cached https://....
psycopg2-2.8.2.tar.gz
Installing collected packages: psycopg2
Running setup.py install for psycopg2 ... done
Successfully installed psycopg2-2.8.2
Ahora está listo para crear una conexión a su base de datos. Aquí está el comienzo de su secuencia de comandos de Python:
import psycopg2
connection = psycopg2.connect(
host="localhost",
database="psycopgtest",
user="postgres",
password=None,
)
connection.set_session(autocommit=True)
Usaste psycopg2.connect()
para crear la conexión. Esta función acepta los siguientes argumentos:
-
host
es la dirección IP o el DNS del servidor donde se encuentra su base de datos. En este caso, el host es su máquina local olocalhost
. -
database
es el nombre de la base de datos a la que conectarse. Desea conectarse a la base de datos que creó anteriormente,psycopgtest
. -
user
es un usuario con permisos para la base de datos. En este caso, desea conectarse a la base de datos como propietario, por lo que le pasa al usuariopostgres
. -
password
es la contraseña de quien haya especificado enuser
. En la mayoría de los entornos de desarrollo, los usuarios pueden conectarse a la base de datos local sin contraseña.
Después de configurar la conexión, configuró la sesión con autocommit=True
. Activando autocommit
significa que no tendrá que administrar manualmente las transacciones emitiendo un commit
o rollback
. Este es el comportamiento predeterminado en la mayoría de los ORM. También utiliza este comportamiento aquí para que pueda concentrarse en redactar consultas SQL en lugar de administrar transacciones.
django.db.connection
:
from django.db import connection
Ejecutar una consulta
Ahora que tiene una conexión a la base de datos, está listo para ejecutar una consulta:
>>>>>> with connection.cursor() as cursor:
... cursor.execute('SELECT COUNT(*) FROM users')
... result = cursor.fetchone()
... print(result)
(2,)
Usaste la connection
objeto para crear un cursor
. Al igual que un archivo en Python, cursor
se implementa como un administrador de contexto. Cuando creas el contexto, un cursor
se abre para que lo use para enviar comandos a la base de datos. Cuando el contexto sale, el cursor
se cierra y ya no puedes usarlo.
Mientras estaba dentro del contexto, usó cursor
para ejecutar una consulta y obtener los resultados. En este caso, emitió una consulta para contar las filas en los users
mesa. Para obtener el resultado de la consulta, ejecutó cursor.fetchone()
y recibió una tupla. Dado que la consulta solo puede devolver un resultado, utilizó fetchone()
. Si la consulta arrojara más de un resultado, entonces tendría que iterar sobre cursor
o usa uno de los otros fetch*
métodos.
Uso de parámetros de consulta en SQL
En la sección anterior, creó una base de datos, estableció una conexión con ella y ejecutó una consulta. La consulta que utilizó fue estática . En otras palabras, no tenía parámetros. . Ahora comenzará a usar parámetros en sus consultas.
Primero, implementará una función que verifique si un usuario es administrador o no. is_admin()
acepta un nombre de usuario y devuelve el estado de administrador de ese usuario:
# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
admin
FROM
users
WHERE
username = '%s'
""" % username)
result = cursor.fetchone()
admin, = result
return admin
Esta función ejecuta una consulta para obtener el valor de admin
columna para un nombre de usuario dado. Usaste fetchone()
para devolver una tupla con un único resultado. Luego, desempaquetaste esta tupla en la variable admin
. Para probar su función, verifique algunos nombres de usuario:
>>> is_admin('haki')
False
>>> is_admin('ran')
True
Hasta aquí todo bien. La función devolvió el resultado esperado para ambos usuarios. Pero, ¿qué pasa con el usuario inexistente? Echa un vistazo a este rastreo de Python:
>>>>>> is_admin('foo')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 12, in is_admin
TypeError: cannot unpack non-iterable NoneType object
Cuando el usuario no existe, un TypeError
es elevado. Esto se debe a que .fetchone()
devuelve None
cuando no se encuentran resultados y desempaquetar None
genera un TypeError
. El único lugar donde puede desempaquetar una tupla es donde rellena admin
de result
.
Para manejar usuarios no existentes, cree un caso especial para cuando result
es None
:
# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
admin
FROM
users
WHERE
username = '%s'
""" % username)
result = cursor.fetchone()
if result is None:
# User does not exist
return False
admin, = result
return admin
Aquí, ha agregado un caso especial para manejar None
. Si username
no existe, entonces la función debería devolver False
. Una vez más, pruebe la función en algunos usuarios:
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
¡Estupendo! La función ahora también puede manejar nombres de usuario que no existen.
Explotación de parámetros de consulta con inyección SQL de Python
En el ejemplo anterior, utilizó la interpolación de cadenas para generar una consulta. Luego, ejecutó la consulta y envió la cadena resultante directamente a la base de datos. Sin embargo, hay algo que puede haber pasado por alto durante este proceso.
Piense en el username
argumento que pasaste a is_admin()
. ¿Qué representa exactamente esta variable? Puede suponer que username
es solo una cadena que representa el nombre de un usuario real. Sin embargo, como está a punto de ver, un intruso puede explotar fácilmente este tipo de descuido y causar un gran daño al realizar una inyección Python SQL.
Intente comprobar si el siguiente usuario es administrador o no:
>>>>>> is_admin("'; select true; --")
True
Espera... ¿Qué acaba de pasar?
Echemos otro vistazo a la implementación. Imprima la consulta real que se está ejecutando en la base de datos:
>>>>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'
El texto resultante contiene tres declaraciones. Para comprender exactamente cómo funciona la inyección SQL de Python, debe inspeccionar cada parte individualmente. La primera declaración es la siguiente:
select admin from users where username = '';
Esta es su consulta prevista. El punto y coma (;
) finaliza la consulta, por lo que el resultado de esta consulta no importa. Lo siguiente es la segunda declaración:
select true;
Esta declaración fue construida por el intruso. Está diseñado para devolver siempre True
.
Por último, verá este breve fragmento de código:
--'
Este fragmento desactiva todo lo que viene después. El intruso agregó el símbolo de comentario (--
) para convertir todo lo que hayas puesto después del último marcador de posición en un comentario.
Cuando ejecuta la función con este argumento, siempre devolverá True
. Si, por ejemplo, utiliza esta función en su página de inicio de sesión, un intruso podría iniciar sesión con el nombre de usuario '; select true; --
y se les otorgará acceso.
Si crees que esto es malo, ¡podría empeorar! Los intrusos con conocimiento de la estructura de su tabla pueden usar la inyección Python SQL para causar daños permanentes. Por ejemplo, el intruso puede inyectar una declaración de actualización para alterar la información en la base de datos:
>>>>>> is_admin('haki')
False
>>> is_admin("'; update users set admin = 'true' where username = 'haki'; select true; --")
True
>>> is_admin('haki')
True
Vamos a desglosarlo de nuevo:
';
Este fragmento finaliza la consulta, al igual que en la inyección anterior. La siguiente declaración es la siguiente:
update users set admin = 'true' where username = 'haki';
Esta sección actualiza admin
a true
para el usuario haki
.
Finalmente, está este fragmento de código:
select true; --
Como en el ejemplo anterior, esta pieza devuelve true
y comenta todo lo que sigue.
¿Por qué es esto peor? Bueno, si el intruso logra ejecutar la función con esta entrada, entonces el usuario haki
se convertirá en administrador:
psycopgtest=# select * from users;
username | admin
----------+-------
ran | t
haki | t
(2 rows)
El intruso ya no tiene que usar el truco. Simplemente pueden iniciar sesión con el nombre de usuario haki
. (Si el intruso realmente querían causar daño, incluso podrían emitir un DROP DATABASE
comando.)
Antes de que te olvides, restaura haki
volver a su estado original:
psycopgtest=# update users set admin = false where username = 'haki';
UPDATE 1
Entonces, ¿por qué sucede esto? Bueno, ¿qué sabes sobre el username
¿argumento? Sabe que debe ser una cadena que represente el nombre de usuario, pero en realidad no verifica ni hace cumplir esta afirmación. ¡Esto puede ser peligroso! Es exactamente lo que buscan los atacantes cuando intentan piratear su sistema.
Elaboración de parámetros de consulta seguros
En la sección anterior, vio cómo un intruso puede explotar su sistema y obtener permisos de administrador mediante el uso de una cadena cuidadosamente diseñada. El problema era que permitía que el valor pasado del cliente se ejecutara directamente en la base de datos, sin realizar ningún tipo de verificación o validación. Las inyecciones SQL se basan en este tipo de vulnerabilidad.
Cada vez que se utiliza la entrada del usuario en una consulta de base de datos, existe una posible vulnerabilidad para la inyección de SQL. La clave para evitar la inyección SQL de Python es asegurarse de que el valor se utilice como pretendía el desarrollador. En el ejemplo anterior, tenía la intención de username
para ser utilizado como una cadena. En realidad, se usó como una instrucción SQL sin formato.
Para asegurarse de que los valores se utilicen según lo previsto, debe escape el valor. Por ejemplo, para evitar que los intrusos inyecten SQL sin procesar en lugar de un argumento de cadena, puede escapar las comillas:
>>>>>> # BAD EXAMPLE. DON'T DO THIS!
>>> username = username.replace("'", "''")
Esto es sólo un ejemplo. Hay muchos caracteres especiales y escenarios en los que pensar cuando se intenta evitar la inyección SQL de Python. Por suerte para usted, los adaptadores de bases de datos modernos vienen con herramientas integradas para evitar la inyección SQL de Python mediante el uso de parámetros de consulta. . Estos se utilizan en lugar de la interpolación de cadenas simples para componer una consulta con parámetros.
Ahora que comprende mejor la vulnerabilidad, está listo para reescribir la función usando parámetros de consulta en lugar de interpolación de cadenas:
1def is_admin(username: str) -> bool:
2 with connection.cursor() as cursor:
3 cursor.execute("""
4 SELECT
5 admin
6 FROM
7 users
8 WHERE
9 username = %(username)s
10 """, {
11 'username': username
12 })
13 result = cursor.fetchone()
14
15 if result is None:
16 # User does not exist
17 return False
18
19 admin, = result
20 return admin
Esto es lo que es diferente en este ejemplo:
-
En la línea 9, usaste un parámetro con nombre
username
para indicar dónde debe ir el nombre de usuario. Observe cómo el parámetrousername
ya no está entre comillas simples. -
En la línea 11, pasaste el valor de
username
como segundo argumento decursor.execute()
. La conexión utilizará el tipo y el valor deusername
al ejecutar la consulta en la base de datos.
Para probar esta función, pruebe algunos valores válidos e inválidos, incluida la cadena peligrosa de antes:
>>>>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
>>> is_admin("'; select true; --")
False
¡Increíble! La función devolvió el resultado esperado para todos los valores. Además, la cuerda peligrosa ya no funciona. Para comprender por qué, puede inspeccionar la consulta generada por execute()
:
>>> with connection.cursor() as cursor:
... cursor.execute("""
... SELECT
... admin
... FROM
... users
... WHERE
... username = %(username)s
... """, {
... 'username': "'; select true; --"
... })
... print(cursor.query.decode('utf-8'))
SELECT
admin
FROM
users
WHERE
username = '''; select true; --'
La conexión trató el valor de username
como una cadena y escapó cualquier carácter que pudiera terminar la cadena e introducir la inyección Python SQL.
Pasar parámetros de consulta seguros
Los adaptadores de bases de datos suelen ofrecer varias formas de pasar parámetros de consulta. Marcadores de posición con nombre suelen ser los mejores para la legibilidad, pero algunas implementaciones pueden beneficiarse del uso de otras opciones.
Echemos un vistazo rápido a algunas de las formas correctas e incorrectas de usar los parámetros de consulta. El siguiente bloque de código muestra los tipos de consultas que querrá evitar:
# BAD EXAMPLES. DON'T DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = '" + username + '");
cursor.execute("SELECT admin FROM users WHERE username = '%s' % username);
cursor.execute("SELECT admin FROM users WHERE username = '{}'".format(username));
cursor.execute(f"SELECT admin FROM users WHERE username = '{username}'");
Cada una de estas declaraciones pasa username
del cliente directamente a la base de datos, sin realizar ningún tipo de comprobación o validación. Este tipo de código está listo para invitar a la inyección SQL de Python.
Por el contrario, estos tipos de consultas deberían ser seguras para ejecutar:
# SAFE EXAMPLES. DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = %s'", (username, ));
cursor.execute("SELECT admin FROM users WHERE username = %(username)s", {'username': username});
En estas declaraciones, username
se pasa como un parámetro con nombre. Ahora, la base de datos utilizará el tipo y el valor especificados de username
al ejecutar la consulta, ofreciendo protección contra la inyección SQL de Python.
Uso de la composición SQL
Hasta ahora has usado parámetros para literales. Literales son valores como números, cadenas y fechas. Pero, ¿qué sucede si tiene un caso de uso que requiere redactar una consulta diferente, una en la que el parámetro es otra cosa, como un nombre de tabla o columna?
Inspirándonos en el ejemplo anterior, implementemos una función que acepte el nombre de una tabla y devuelva el número de filas en esa tabla:
# BAD EXAMPLE. DON'T DO THIS!
def count_rows(table_name: str) -> int:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
count(*)
FROM
%(table_name)s
""", {
'table_name': table_name,
})
result = cursor.fetchone()
rowcount, = result
return rowcount
Intenta ejecutar la función en tu tabla de usuarios:
>>>Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 9, in count_rows
psycopg2.errors.SyntaxError: syntax error at or near "'users'"
LINE 5: 'users'
^
El comando no pudo generar el SQL. Como ya ha visto, el adaptador de la base de datos trata la variable como una cadena o un literal. Sin embargo, un nombre de tabla no es una cadena simple. Aquí es donde entra en juego la composición de SQL.
Ya sabe que no es seguro usar la interpolación de cadenas para componer SQL. Afortunadamente, Psycopg proporciona un módulo llamado psycopg.sql
para ayudarlo a redactar consultas SQL de manera segura. Reescribamos la función usando psycopg.sql.SQL()
:
from psycopg2 import sql
def count_rows(table_name: str) -> int:
with connection.cursor() as cursor:
stmt = sql.SQL("""
SELECT
count(*)
FROM
{table_name}
""").format(
table_name = sql.Identifier(table_name),
)
cursor.execute(stmt)
result = cursor.fetchone()
rowcount, = result
return rowcount
Hay dos diferencias en esta implementación. Primero, usó sql.SQL()
para redactar la consulta. Luego, usó sql.Identifier()
para anotar el valor del argumento table_name
. (Un identificador es un nombre de columna o tabla.)
django-debug-toolbar
podría obtener un error en el panel SQL para consultas compuestas con psycopg.sql.SQL()
. Se espera una corrección para el lanzamiento en la versión 2.0.
Ahora, intente ejecutar la función en los users
tabla:
>>> count_rows('users')
2
¡Estupendo! A continuación, veamos qué sucede cuando la tabla no existe:
>>>>>> count_rows('foo')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 11, in count_rows
psycopg2.errors.UndefinedTable: relation "foo" does not exist
LINE 5: "foo"
^
La función lanza la UndefinedTable
excepción. En los siguientes pasos, usará esta excepción como una indicación de que su función está a salvo de un ataque de inyección SQL de Python.
UndefinedTable
se agregó en psycopg2 versión 2.8. Si está trabajando con una versión anterior de Psycopg, obtendrá una excepción diferente.
Para ponerlo todo junto, agregue una opción para contar filas en la tabla hasta cierto límite. Esta característica puede ser útil para tablas muy grandes. Para implementar esto, agregue un LIMIT
cláusula a la consulta, junto con los parámetros de consulta para el valor del límite:
from psycopg2 import sql
def count_rows(table_name: str, limit: int) -> int:
with connection.cursor() as cursor:
stmt = sql.SQL("""
SELECT
COUNT(*)
FROM (
SELECT
1
FROM
{table_name}
LIMIT
{limit}
) AS limit_query
""").format(
table_name = sql.Identifier(table_name),
limit = sql.Literal(limit),
)
cursor.execute(stmt)
result = cursor.fetchone()
rowcount, = result
return rowcount
En este bloque de código, anotó limit
usando sql.Literal()
. Como en el ejemplo anterior, psycopg
vinculará todos los parámetros de consulta como literales cuando se utilice el enfoque simple. Sin embargo, al usar sql.SQL()
, debe anotar explícitamente cada parámetro usando sql.Identifier()
o sql.Literal()
.
Ejecute la función para asegurarse de que funciona:
>>>>>> count_rows('users', 1)
1
>>> count_rows('users', 10)
2
Ahora que ve que la función está funcionando, asegúrese de que también sea segura:
>>>>>> count_rows("(select 1) as foo; update users set admin = true where name = 'haki'; --", 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 18, in count_rows
psycopg2.errors.UndefinedTable: relation "(select 1) as foo; update users set admin = true where name = '" does not exist
LINE 8: "(select 1) as foo; update users set adm...
^
Este rastreo muestra que psycopg
escapó del valor y la base de datos lo trató como un nombre de tabla. Dado que no existe una tabla con este nombre, una UndefinedTable
¡Se generó una excepción y no fuiste pirateado!
Conclusión
Ha implementado con éxito una función que compone SQL dinámico sin ¡poniendo su sistema en riesgo de inyección Python SQL! Ha utilizado literales e identificadores en su consulta sin comprometer la seguridad.
Has aprendido:
- Qué inyección Python SQL es y cómo se puede explotar
- Cómo prevenir la inyección SQL de Python usando parámetros de consulta
- Cómo componer sentencias SQL de forma segura que usan literales e identificadores como parámetros
Ahora puede crear programas que pueden resistir ataques desde el exterior. ¡Avanza y frustra a los piratas informáticos!