A cada poucos anos, o Open Web Application Security Project (OWASP) classifica os riscos de segurança de aplicativos da Web mais críticos. Desde o primeiro relatório, os riscos de injeção sempre estiveram no topo. Entre todos os tipos de injeção, a injeção de SQL é um dos vetores de ataque mais comuns e sem dúvida o mais perigoso. Como o Python é uma das linguagens de programação mais populares do mundo, saber como se proteger contra a injeção de SQL do Python é fundamental.
Neste tutorial, você aprenderá:
- O que injeção de SQL do Python é e como evitá-lo
- Como compor consultas com literais e identificadores como parâmetros
- Como executar consultas com segurança em um banco de dados
Este tutorial é adequado para usuários de todos os mecanismos de banco de dados . Os exemplos aqui usam PostgreSQL, mas os resultados podem ser reproduzidos em outros sistemas de gerenciamento de banco de dados (como SQLite, MySQL, Microsoft SQL Server, Oracle e assim por diante).
Bônus grátis: 5 Pensamentos sobre o domínio do Python, um curso gratuito para desenvolvedores de Python que mostra o roteiro e a mentalidade de que você precisa para levar suas habilidades de Python para o próximo nível.
Compreendendo o Python SQL Injection
Os ataques de injeção de SQL são uma vulnerabilidade de segurança tão comum que o lendário xkcd webcomic dedicou um quadrinho a ele:
Gerar e executar consultas SQL é uma tarefa comum. No entanto, empresas em todo o mundo muitas vezes cometem erros horríveis quando se trata de compor instruções SQL. Embora a camada ORM geralmente componha consultas SQL, às vezes você precisa escrever suas próprias.
Quando você usa o Python para executar essas consultas diretamente em um banco de dados, há uma chance de cometer erros que podem comprometer seu sistema. Neste tutorial, você aprenderá como implementar com sucesso funções que compõem consultas SQL dinâmicas sem colocando seu sistema em risco para injeção de SQL do Python.
Configurando um banco de dados
Para começar, você vai configurar um novo banco de dados PostgreSQL e preenchê-lo com dados. Ao longo do tutorial, você usará esse banco de dados para testemunhar em primeira mão como funciona a injeção de SQL do Python.
Criando um banco de dados
Primeiro, abra seu shell e crie um novo banco de dados PostgreSQL de propriedade do usuário
postgres
:$ createdb -O postgres psycopgtest
Aqui você usou a opção de linha de comando
-O
para definir o proprietário do banco de dados para o usuário postgres
. Você também especificou o nome do banco de dados, que é psycopgtest
. Observação:
postgres
é um usuário especial , que você normalmente reservaria para tarefas administrativas, mas para este tutorial, não há problema em usar postgres
. Em um sistema real, no entanto, você deve criar um usuário separado para ser o proprietário do banco de dados. Seu novo banco de dados está pronto para ser usado! Você pode se conectar a ele usando
psql
:$ psql -U postgres -d psycopgtest
psql (11.2, server 10.5)
Type "help" for help.
Agora você está conectado ao banco de dados
psycopgtest
como o usuário postgres
. Esse usuário também é o proprietário do banco de dados, portanto, você terá permissões de leitura em todas as tabelas do banco de dados. Criando uma tabela com dados
Em seguida, você precisa criar uma tabela com algumas informações do usuário e adicionar dados a ela:
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)
A tabela tem duas colunas:
username
e admin
. O admin
coluna indica se um usuário tem ou não privilégios administrativos. Seu objetivo é segmentar o admin
campo e tentar abusar dele. Configurando um ambiente virtual Python
Agora que você tem um banco de dados, é hora de configurar seu ambiente Python. Para obter instruções passo a passo sobre como fazer isso, confira Python Virtual Environments:A Primer.
Crie seu ambiente virtual em um novo diretório:
(~/src) $ mkdir psycopgtest
(~/src) $ cd psycopgtest
(~/src/psycopgtest) $ python3 -m venv venv
Depois de executar este comando, um novo diretório chamado
venv
Será criado. Este diretório armazenará todos os pacotes que você instalar dentro do ambiente virtual. Conectando ao banco de dados
Para se conectar a um banco de dados em Python, você precisa de um adaptador de banco de dados . A maioria dos adaptadores de banco de dados segue a versão 2.0 da especificação Python Database API PEP 249. Todo mecanismo de banco de dados principal tem um adaptador líder:
Banco de dados | Adaptador |
---|---|
PostgreSQL | Psicótico |
SQLite | sqlite3 |
Oráculo | cx_oracle |
MySql | MySQLdb |
Para se conectar a um banco de dados PostgreSQL, você precisará instalar o Psycopg, que é o adaptador mais popular para PostgreSQL em Python. O Django ORM o usa por padrão e também é suportado pelo SQLAlchemy.
Em seu terminal, ative o ambiente virtual e use
pip
para instalar o 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
Agora você está pronto para criar uma conexão com seu banco de dados. Aqui está o início do seu script Python:
import psycopg2
connection = psycopg2.connect(
host="localhost",
database="psycopgtest",
user="postgres",
password=None,
)
connection.set_session(autocommit=True)
Você usou
psycopg2.connect()
para criar a conexão. Esta função aceita os seguintes argumentos:-
host
é o endereço IP ou o DNS do servidor onde seu banco de dados está localizado. Nesse caso, o host é sua máquina local oulocalhost
.
-
database
é o nome do banco de dados ao qual se conectar. Você deseja se conectar ao banco de dados criado anteriormente,psycopgtest
.
-
user
é um usuário com permissões para o banco de dados. Neste caso, você deseja se conectar ao banco de dados como proprietário, então você passa o usuáriopostgres
.
-
password
é a senha para quem você especificou emuser
. Na maioria dos ambientes de desenvolvimento, os usuários podem se conectar ao banco de dados local sem uma senha.
Após configurar a conexão, você configurou a sessão com
autocommit=True
. Ativando autocommit
significa que você não terá que gerenciar manualmente as transações emitindo um commit
ou rollback
. Este é o comportamento padrão na maioria dos ORMs. Você também usa esse comportamento aqui para poder se concentrar em compor consultas SQL em vez de gerenciar transações. Observação: Os usuários do Django podem obter a instância da conexão usada pelo ORM em
django.db.connection
:from django.db import connection
Executando uma consulta
Agora que você tem uma conexão com o banco de dados, está pronto para executar uma consulta:
>>>
>>> with connection.cursor() as cursor:
... cursor.execute('SELECT COUNT(*) FROM users')
... result = cursor.fetchone()
... print(result)
(2,)
Você usou a
connection
objeto para criar um cursor
. Assim como um arquivo em Python, cursor
é implementado como um gerenciador de contexto. Quando você cria o contexto, um cursor
é aberto para você usar para enviar comandos ao banco de dados. Quando o contexto sai, o cursor
fecha e você não pode mais usá-lo. Observação: Para saber mais sobre gerenciadores de contexto, confira Python Context Managers e a declaração “with”.
Enquanto dentro do contexto, você usou
cursor
para executar uma consulta e buscar os resultados. Nesse caso, você emitiu uma consulta para contar as linhas no arquivo users
tabela. Para buscar o resultado da consulta, você executou cursor.fetchone()
e recebeu uma tupla. Como a consulta pode retornar apenas um resultado, você usou fetchone()
. Se a consulta retornar mais de um resultado, você precisará iterar sobre cursor
ou use um dos outros fetch*
métodos. Usando parâmetros de consulta em SQL
Na seção anterior, você criou um banco de dados, estabeleceu uma conexão com ele e executou uma consulta. A consulta que você usou foi estática . Em outras palavras, não tinha nenhum parâmetro . Agora você começará a usar parâmetros em suas consultas.
Primeiro, você implementará uma função que verifica se um usuário é ou não um administrador.
is_admin()
aceita um nome de usuário e retorna o status de administrador desse usuário:# 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 função executa uma consulta para buscar o valor do
admin
coluna para um determinado nome de usuário. Você usou fetchone()
para retornar uma tupla com um único resultado. Então, você descompactou esta tupla na variável admin
. Para testar sua função, verifique alguns nomes de usuário:>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
Até agora tudo bem. A função retornou o resultado esperado para ambos os usuários. Mas e o usuário inexistente? Dê uma olhada neste traceback do 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
Quando o usuário não existe, um
TypeError
é levantada. Isso ocorre porque .fetchone()
retorna None
quando nenhum resultado for encontrado e descompactar None
gera um TypeError
. O único lugar onde você pode descompactar uma tupla é onde você preenche admin
de result
. Para lidar com usuários inexistentes, crie um caso especial para quando
result
é 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
Aqui, você adicionou um caso especial para lidar com
None
. Se username
não existe, então a função deve retornar False
. Mais uma vez, teste a função em alguns usuários:>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
Excelente! A função agora também pode lidar com nomes de usuário inexistentes.
Explorando parâmetros de consulta com Python SQL Injection
No exemplo anterior, você usou a interpolação de string para gerar uma consulta. Em seguida, você executou a consulta e enviou a string resultante diretamente para o banco de dados. No entanto, há algo que você pode ter esquecido durante esse processo.
Pense no
username
argumento que você passou para is_admin()
. O que exatamente essa variável representa? Você pode supor que username
é apenas uma string que representa o nome de um usuário real. Como você está prestes a ver, porém, um intruso pode facilmente explorar esse tipo de supervisão e causar grandes danos ao executar a injeção de SQL do Python. Tente verificar se o seguinte usuário é um administrador ou não:
>>>
>>> is_admin("'; select true; --")
True
Espere... O que aconteceu?
Vamos dar uma outra olhada na implementação. Imprima a consulta real que está sendo executada no banco de dados:
>>>
>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'
O texto resultante contém três declarações. Para entender exatamente como funciona a injeção de SQL do Python, você precisa inspecionar cada parte individualmente. A primeira afirmação é a seguinte:
select admin from users where username = '';
Esta é a sua consulta pretendida. O ponto e vírgula (
;
) encerra a consulta, portanto, o resultado dessa consulta não importa. A seguir vem a segunda afirmação:select true;
Esta declaração foi construída pelo intruso. Ele foi projetado para sempre retornar
True
. Por fim, você vê este pequeno trecho de código:
--'
Este snippet desativa qualquer coisa que venha depois dele. O intruso adicionou o símbolo de comentário (
--
) para transformar tudo o que você pode ter colocado após o último espaço reservado em um comentário. Quando você executa a função com este argumento, ela sempre retornará
True
. Se, por exemplo, você usar esta função em sua página de login, um intruso poderá fazer login com o nome de usuário '; select true; --
, e eles terão acesso. Se você acha que isso é ruim, pode piorar! Intrusos com conhecimento da estrutura da sua tabela podem usar injeção de SQL do Python para causar danos permanentes. Por exemplo, o intruso pode injetar uma instrução de atualização para alterar as informações no banco de dados:
>>>
>>> is_admin('haki')
False
>>> is_admin("'; update users set admin = 'true' where username = 'haki'; select true; --")
True
>>> is_admin('haki')
True
Vamos dividir novamente:
';
Este snippet encerra a consulta, assim como na injeção anterior. A próxima afirmação é a seguinte:
update users set admin = 'true' where username = 'haki';
Esta seção atualiza
admin
para true
para o usuário haki
. Por fim, há este trecho de código:
select true; --
Como no exemplo anterior, esta parte retorna
true
e comenta tudo o que se segue. Por que isso é pior? Bem, se o intruso conseguir executar a função com esta entrada, então o usuário
haki
se tornará um administrador:psycopgtest=# select * from users;
username | admin
----------+-------
ran | t
haki | t
(2 rows)
O intruso não precisa mais usar o hack. Eles podem fazer login com o nome de usuário
haki
. (Se o intruso realmente quisessem causar danos, então eles poderiam até emitir um DROP DATABASE
comando.) Antes que você esqueça, restaure
haki
de volta ao seu estado original:psycopgtest=# update users set admin = false where username = 'haki';
UPDATE 1
Então, por que isso está acontecendo? Bem, o que você sabe sobre o
username
argumento? Você sabe que deve ser uma string representando o nome de usuário, mas na verdade você não verifica ou impõe essa afirmação. Isso pode ser perigoso! É exatamente o que os invasores procuram quando tentam invadir seu sistema. Criação de parâmetros de consulta seguros
Na seção anterior, você viu como um intruso pode explorar seu sistema e obter permissões de administrador usando uma string cuidadosamente elaborada. O problema era que você permitia que o valor passado do cliente fosse executado diretamente no banco de dados, sem realizar nenhum tipo de verificação ou validação. As injeções de SQL dependem desse tipo de vulnerabilidade.
Sempre que a entrada do usuário é usada em uma consulta de banco de dados, há uma possível vulnerabilidade para injeção de SQL. A chave para evitar a injeção de SQL do Python é certificar-se de que o valor está sendo usado como o desenvolvedor pretendia. No exemplo anterior, você pretendia
username
para ser usado como uma string. Na realidade, foi usado como uma instrução SQL bruta. Para garantir que os valores sejam usados conforme pretendido, você precisa escape O valor que. Por exemplo, para evitar que invasores injetem SQL bruto no lugar de um argumento de string, você pode escapar das aspas:
>>>
>>> # BAD EXAMPLE. DON'T DO THIS!
>>> username = username.replace("'", "''")
isso é apenas um exemplo. Há muitos caracteres especiais e cenários a serem considerados ao tentar impedir a injeção de SQL do Python. Para sua sorte, os adaptadores de banco de dados modernos vêm com ferramentas integradas para impedir a injeção de SQL do Python usando parâmetros de consulta . Eles são usados em vez de interpolação de string simples para compor uma consulta com parâmetros.
Observação: Diferentes adaptadores, bancos de dados e linguagens de programação referem-se a parâmetros de consulta por nomes diferentes. Os nomes comuns incluem variáveis de vinculação , variáveis de substituição e variáveis de substituição .
Agora que você compreendeu melhor a vulnerabilidade, está pronto para reescrever a função usando parâmetros de consulta em vez de interpolação de string:
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
Aqui está o que é diferente neste exemplo:
-
Na linha 9, você usou um parâmetro nomeadousername
para indicar onde o nome de usuário deve ir. Observe como o parâmetrousername
não está mais entre aspas simples.
-
Na linha 11, você passou o valor deusername
como o segundo argumento paracursor.execute()
. A conexão usará o tipo e o valor deusername
ao executar a consulta no banco de dados.
Para testar esta função, tente alguns valores válidos e inválidos, incluindo a string perigosa de antes:
>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
>>> is_admin("'; select true; --")
False
Incrível! A função retornou o resultado esperado para todos os valores. Além disso, a corda perigosa não funciona mais. Para entender o porquê, você pode inspecionar a consulta gerada 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; --'
A conexão tratou o valor de
username
como uma string e escapou quaisquer caracteres que possam encerrar a string e introduzir a injeção de SQL do Python. Passando parâmetros de consulta segura
Os adaptadores de banco de dados geralmente oferecem várias maneiras de passar parâmetros de consulta. Espaços reservados nomeados geralmente são os melhores para legibilidade, mas algumas implementações podem se beneficiar do uso de outras opções.
Vamos dar uma olhada rápida em algumas das maneiras certas e erradas de usar parâmetros de consulta. O bloco de código a seguir mostra os tipos de consultas que você deseja 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 uma dessas instruções passa
username
do cliente diretamente para o banco de dados, sem realizar nenhum tipo de verificação ou validação. Esse tipo de código está pronto para convidar a injeção de SQL do Python. Por outro lado, esses tipos de consultas devem ser seguros para você executar:
# 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});
Nessas declarações,
username
é passado como um parâmetro nomeado. Agora, o banco de dados usará o tipo e o valor especificados de username
ao executar a consulta, oferecendo proteção contra injeção de SQL do Python. Usando a composição SQL
Até agora você usou parâmetros para literais. Literais são valores como números, strings e datas. Mas e se você tiver um caso de uso que exija a composição de uma consulta diferente — uma em que o parâmetro seja outra coisa, como um nome de tabela ou coluna?
Inspirado no exemplo anterior, vamos implementar uma função que aceita o nome de uma tabela e retorna o número de linhas dessa tabela:
# 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
Tente executar a função na sua tabela de usuários:
>>>
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'
^
O comando falhou ao gerar o SQL. Como você já viu, o adaptador de banco de dados trata a variável como uma string ou literal. Um nome de tabela, no entanto, não é uma string simples. É aqui que entra a composição SQL.
Você já sabe que não é seguro usar interpolação de strings para compor SQL. Felizmente, o Psycopg fornece um módulo chamado
psycopg.sql
para ajudá-lo a compor consultas SQL com segurança. Vamos reescrever a função 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
Existem duas diferenças nesta implementação. Primeiro, você usou
sql.SQL()
para compor a consulta. Então, você usou sql.Identifier()
para anotar o valor do argumento table_name
. (Um identificador é um nome de coluna ou tabela.) Observação: Usuários do popular pacote
django-debug-toolbar
pode receber um erro no painel SQL para consultas compostas com psycopg.sql.SQL()
. Uma correção é esperada para lançamento na versão 2.0. Agora, tente executar a função em
users
tabela:>>>
>>> count_rows('users')
2
Excelente! A seguir, vamos ver o que acontece quando a tabela não 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"
^
A função lança a
UndefinedTable
exceção. Nas etapas a seguir, você usará essa exceção como uma indicação de que sua função está protegida contra um ataque de injeção de SQL do Python. Observação: A exceção
UndefinedTable
foi adicionado no psycopg2 versão 2.8. Se você estiver trabalhando com uma versão anterior do Psycopg, receberá uma exceção diferente. Para juntar tudo, adicione uma opção para contar linhas na tabela até um determinado limite. Esse recurso pode ser útil para tabelas muito grandes. Para implementar isso, adicione um
LIMIT
cláusula para a consulta, juntamente com os parâmetros de consulta para o valor do limite: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
Neste bloco de código, você anotou
limit
usando sql.Literal()
. Como no exemplo anterior, psycopg
vinculará todos os parâmetros de consulta como literais ao usar a abordagem simples. No entanto, ao usar sql.SQL()
, você precisa anotar explicitamente cada parâmetro usando sql.Identifier()
ou sql.Literal()
. Observação: Infelizmente, a especificação da API Python não aborda a vinculação de identificadores, apenas literais. Psycopg é o único adaptador popular que adicionou a capacidade de compor SQL com segurança com literais e identificadores. Esse fato torna ainda mais importante prestar muita atenção ao vincular identificadores.
Execute a função para certificar-se de que ela funciona:
>>>
>>> count_rows('users', 1)
1
>>> count_rows('users', 10)
2
Agora que você vê que a função está funcionando, verifique se ela também é 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 traceback mostra que
psycopg
escapou o valor e o banco de dados o tratou como um nome de tabela. Como não existe uma tabela com este nome, um UndefinedTable
exceção foi levantada e você não foi hackeado! Conclusão
Você implementou com sucesso uma função que compõe SQL dinâmico sem colocando seu sistema em risco para injeção de SQL do Python! Você usou literais e identificadores em sua consulta sem comprometer a segurança.
Você aprendeu:
- O que injeção de SQL do Python é e como pode ser explorado
- Como evitar a injeção de SQL do Python usando parâmetros de consulta
- Como compor instruções SQL com segurança que usam literais e identificadores como parâmetros
Agora você pode criar programas que podem resistir a ataques externos. Vá em frente e frustre os hackers!