Database
 sql >> Base de Dados >  >> RDS >> Database

Prevenindo ataques de injeção de SQL com Python


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 ou localhost .

  • 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ário postgres .

  • password é a senha para quem você especificou em user . 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 nomeado username para indicar onde o nome de usuário deve ir. Observe como o parâmetro username não está mais entre aspas simples.

  • Na linha 11, você passou o valor de username como o segundo argumento para cursor.execute() . A conexão usará o tipo e o valor de username 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!