sql >> Databasteknik >  >> RDS >> Database

Förhindra SQL-injektionsattacker med Python

Med några års mellanrum rankar Open Web Application Security Project (OWASP) de mest kritiska säkerhetsriskerna för webbapplikationer. Sedan den första rapporten har injektionsriskerna alltid varit på topp. Bland alla injektionstyper, SQL-injektion är en av de vanligaste attackvektorerna, och utan tvekan den farligaste. Eftersom Python är ett av de mest populära programmeringsspråken i världen är det viktigt att veta hur man skyddar sig mot Python SQL-injektion.

I den här självstudien kommer du att lära dig:

  • Vilken Python SQL-injektion är och hur man förhindrar det
  • Hur man skriver frågor med både bokstaver och identifierare som parametrar
  • Hur man kör frågor på ett säkert sätt i en databas

Denna handledning är lämplig för användare av alla databasmotorer . Exemplen här använder PostgreSQL, men resultaten kan återges i andra databashanteringssystem (som SQLite, MySQL, Microsoft SQL Server, Oracle, och så vidare).

Gratis bonus: 5 Thoughts On Python Mastery, en gratiskurs för Python-utvecklare som visar dig färdplanen och tankesättet du behöver för att ta dina Python-färdigheter till nästa nivå.


Förstå Python SQL Injection

SQL Injection-attacker är en så vanlig säkerhetsrisk att den legendariska xkcd webcomic ägnade en serie åt det:

Att generera och köra SQL-frågor är en vanlig uppgift. Företag runt om i världen gör dock ofta hemska misstag när det kommer till att komponera SQL-satser. Även om ORM-lagret vanligtvis sammanställer SQL-frågor, måste du ibland skriva dina egna.

När du använder Python för att köra dessa frågor direkt i en databas, finns det en chans att du kan göra misstag som kan äventyra ditt system. I den här handledningen lär du dig hur du framgångsrikt implementerar funktioner som skapar dynamiska SQL-frågor utan utsätter ditt system för risk för Python SQL-injektion.



Sätta upp en databas

För att komma igång kommer du att ställa in en ny PostgreSQL-databas och fylla den med data. Genom hela handledningen kommer du att använda den här databasen för att se hur Python SQL-injektion fungerar.


Skapa en databas

Öppna först ditt skal och skapa en ny PostgreSQL-databas som ägs av användaren postgres :

$ createdb -O postgres psycopgtest

Här använde du kommandoradsalternativet -O för att ställa in ägaren av databasen till användaren postgres . Du angav också namnet på databasen, som är psycopgtest .

Obs! postgres är en särskild användare , som du normalt reserverar för administrativa uppgifter, men för denna handledning går det bra att använda postgres . I ett riktigt system bör du dock skapa en separat användare för att vara ägare till databasen.

Din nya databas är redo att användas! Du kan ansluta till den med psql :

$ psql -U postgres -d psycopgtest
psql (11.2, server 10.5)
Type "help" for help.

Du är nu ansluten till databasen psycopgtest som användaren postgres . Den här användaren är också databasägaren, så du har läsbehörighet för varje tabell i databasen.



Skapa en tabell med data

Därefter måste du skapa en tabell med lite användarinformation och lägga till data till den:

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)

Tabellen har två kolumner:username och admin . admin kolumnen anger om en användare har administrativa rättigheter eller inte. Ditt mål är att rikta in dig på admin och försök missbruka det.



Konfigurera en virtuell Python-miljö

Nu när du har en databas är det dags att ställa in din Python-miljö. För steg-för-steg-instruktioner om hur du gör detta, kolla in Python Virtual Environments:A Primer.

Skapa din virtuella miljö i en ny katalog:

(~/src) $ mkdir psycopgtest
(~/src) $ cd psycopgtest
(~/src/psycopgtest) $ python3 -m venv venv

När du har kört det här kommandot kommer en ny katalog som heter venv kommer att skapas. Denna katalog kommer att lagra alla paket du installerar i den virtuella miljön.



Ansluter till databasen

För att ansluta till en databas i Python behöver du en databasadapter . De flesta databasadaptrar följer version 2.0 av Python Database API Specification PEP 249. Varje större databasmotor har en ledande adapter:

Databas Adapter
PostgreSQL Psychopg
SQLite sqlite3
Oracle cx_oracle
MySql MySQLdb

För att ansluta till en PostgreSQL-databas måste du installera Psycopg, som är den mest populära adaptern för PostgreSQL i Python. Django ORM använder det som standard, och det stöds också av SQLAlchemy.

I din terminal, aktivera den virtuella miljön och använd pip för att installera 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

Nu är du redo att skapa en anslutning till din databas. Här är början på ditt Python-skript:

import psycopg2

connection = psycopg2.connect(
    host="localhost",
    database="psycopgtest",
    user="postgres",
    password=None,
)
connection.set_session(autocommit=True)

Du använde psycopg2.connect() för att skapa kopplingen. Denna funktion accepterar följande argument:

  • host är IP-adressen eller DNS för servern där din databas finns. I det här fallet är värden din lokala dator, eller localhost .

  • database är namnet på databasen att ansluta till. Du vill ansluta till databasen du skapade tidigare, psycopgtest .

  • user är en användare med behörigheter för databasen. I det här fallet vill du ansluta till databasen som ägare, så du skickar användaren postgres .

  • password är lösenordet för den du angav i user . I de flesta utvecklingsmiljöer kan användare ansluta till den lokala databasen utan lösenord.

Efter att ha ställt in anslutningen konfigurerade du sessionen med autocommit=True . Aktiverar autocommit innebär att du inte behöver hantera transaktioner manuellt genom att utfärda en commit eller rollback . Detta är standardbeteendet i de flesta ORM:er. Du använder detta beteende även här så att du kan fokusera på att skapa SQL-frågor istället för att hantera transaktioner.

Obs! Django-användare kan hämta instansen av anslutningen som används av ORM från django.db.connection :

from django.db import connection


Köra en fråga

Nu när du har en anslutning till databasen är du redo att köra en fråga:

>>>
>>> with connection.cursor() as cursor:
...     cursor.execute('SELECT COUNT(*) FROM users')
...     result = cursor.fetchone()
... print(result)
(2,)

Du använde connection objekt för att skapa en cursor . Precis som en fil i Python, cursor implementeras som en kontexthanterare. När du skapar sammanhanget visas en cursor öppnas för att du kan använda för att skicka kommandon till databasen. När sammanhanget avslutas visas cursor stängs och du kan inte längre använda den.

Obs! För att lära dig mer om sammanhangshanterare, kolla in Python Context Managers och "med"-utlåtandet.

När du var inne i sammanhanget använde du cursor för att utföra en fråga och hämta resultaten. I det här fallet skickade du en fråga för att räkna raderna i users tabell. För att hämta resultatet från frågan körde du cursor.fetchone() och fick en tuppel. Eftersom frågan bara kan returnera ett resultat använde du fetchone() . Om frågan skulle returnera mer än ett resultat, måste du antingen iterera över cursor eller använd en av de andra fetch* metoder.




Använda frågeparametrar i SQL

I föregående avsnitt skapade du en databas, upprättade en anslutning till den och körde en fråga. Frågan du använde var statisk . Med andra ord hade den inga parametrar . Nu börjar du använda parametrar i dina frågor.

Först ska du implementera en funktion som kontrollerar om en användare är en administratör eller inte. is_admin() accepterar ett användarnamn och returnerar den användarens adminstatus:

# 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

Den här funktionen kör en fråga för att hämta värdet för admin kolumn för ett givet användarnamn. Du använde fetchone() för att returnera en tupel med ett enda resultat. Sedan packade du upp denna tuppel i variabeln admin . För att testa din funktion, kontrollera några användarnamn:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True

Än så länge är allt bra. Funktionen returnerade det förväntade resultatet för båda användarna. Men hur är det med en icke-existerande användare? Ta en titt på denna Python-spårning:

>>>
>>> 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

När användaren inte finns visas ett TypeError är upphöjd. Detta beror på att .fetchone() returnerar None när inga resultat hittas och packar upp None skapar en TypeError . Det enda stället du kan packa upp en tuppel är där du fyller i admin från result .

För att hantera icke-existerande användare, skapa ett specialfall för när result är 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

Här har du lagt till ett specialfall för hantering av None . Om username inte existerar, bör funktionen returnera False . Testa funktionen igen på vissa användare:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False

Bra! Funktionen kan nu även hantera icke-existerande användarnamn.



Utnyttja frågeparametrar med Python SQL Injection

I föregående exempel använde du stränginterpolation för att generera en fråga. Sedan körde du frågan och skickade den resulterande strängen direkt till databasen. Det finns dock något du kan ha förbisett under den här processen.

Tänk tillbaka på username argument som du skickade till is_admin() . Vad exakt representerar denna variabel? Du kan anta att username är bara en sträng som representerar en faktisk användares namn. Men som du snart ser kan en inkräktare lätt utnyttja denna typ av förbiseende och orsaka stor skada genom att utföra Python SQL-injektion.

Försök att kontrollera om följande användare är administratör eller inte:

>>>
>>> is_admin("'; select true; --")
True

Vänta... Vad hände just?

Låt oss ta en ny titt på implementeringen. Skriv ut den faktiska frågan som körs i databasen:

>>>
>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'

Den resulterande texten innehåller tre påståenden. För att förstå exakt hur Python SQL-injektion fungerar måste du inspektera varje del individuellt. Det första påståendet är som följer:

select admin from users where username = '';

Detta är din avsedda fråga. Semikolonet (; ) avslutar frågan, så resultatet av denna fråga spelar ingen roll. Därefter kommer det andra påståendet:

select true;

Detta uttalande konstruerades av inkräktaren. Den är utformad för att alltid returnera True .

Till sist ser du denna korta kodbit:

--'

Det här utdraget desarmerar allt som kommer efter det. Inkräktaren lade till kommentarsymbolen (-- ) för att förvandla allt du kan ha lagt efter den sista platshållaren till en kommentar.

När du kör funktionen med detta argument kommer den alltid att returnera True . Om du till exempel använder den här funktionen på din inloggningssida kan en inkräktare logga in med användarnamnet '; select true; -- , och de kommer att beviljas åtkomst.

Om du tycker att det här är dåligt kan det bli värre! Inkräktare med kunskap om din tabellstruktur kan använda Python SQL-injektion för att orsaka permanent skada. Till exempel kan inkräktaren injicera en uppdateringssats för att ändra informationen i databasen:

>>>
>>> is_admin('haki')
False
>>> is_admin("'; update users set admin = 'true' where username = 'haki'; select true; --")
True
>>> is_admin('haki')
True

Låt oss bryta ner det igen:

';

Det här utdraget avslutar frågan, precis som i föregående injektion. Nästa påstående är som följer:

update users set admin = 'true' where username = 'haki';

Det här avsnittet uppdaterar admin till true för användare haki .

Slutligen finns det här kodavsnittet:

select true; --

Liksom i föregående exempel returnerar denna bit true och kommenterar allt som följer.

Varför är det här värre? Tja, om inkräktaren lyckas utföra funktionen med denna ingång, då användaren haki kommer att bli admin:

psycopgtest=# select * from users;
 username | admin
----------+-------
 ran      | t
 haki     | t
(2 rows)

Inkräktaren behöver inte längre använda hacket. De kan bara logga in med användarnamnet haki . (Om inkräktaren verkligen ville orsaka skada kunde de till och med utfärda en DROP DATABASE kommando.)

Innan du glömmer, återställ haki tillbaka till sitt ursprungliga tillstånd:

psycopgtest=# update users set admin = false where username = 'haki';
UPDATE 1

Så varför händer detta? Tja, vad vet du om username argument? Du vet att det borde vara en sträng som representerar användarnamnet, men du kontrollerar eller upprätthåller faktiskt inte detta påstående. Detta kan vara farligt! Det är precis vad angripare letar efter när de försöker hacka ditt system.


Skapa säkra frågeparametrar

I föregående avsnitt såg du hur en inkräktare kan utnyttja ditt system och få administratörsbehörigheter genom att använda en noggrant utformad sträng. Problemet var att du tillät att värdet som skickades från klienten exekveras direkt till databasen, utan att utföra någon form av kontroll eller validering. SQL-injektioner är beroende av denna typ av sårbarhet.

Varje gång användarinmatning används i en databasfråga finns det en möjlig sårbarhet för SQL-injektion. Nyckeln till att förhindra Python SQL-injektion är att se till att värdet används som utvecklaren avsett. I föregående exempel tänkte du på username att användas som ett snöre. I verkligheten användes den som en rå SQL-sats.

För att se till att värden används som de är avsedda måste du escape värdet. Till exempel, för att förhindra inkräktare från att injicera rå SQL i stället för ett strängargument, kan du undvika citattecken:

>>>
>>> # BAD EXAMPLE. DON'T DO THIS!
>>> username = username.replace("'", "''")

Detta är bara ett exempel. Det finns många specialtecken och scenarier att tänka på när man försöker förhindra Python SQL-injektion. Tur för dig, moderna databasadaptrar, kommer med inbyggda verktyg för att förhindra Python SQL-injektion genom att använda frågeparametrar . Dessa används istället för vanlig stränginterpolation för att skapa en fråga med parametrar.

Obs! Olika adaptrar, databaser och programmeringsspråk refererar till frågeparametrar med olika namn. Vanliga namn inkluderar bindningsvariabler , ersättningsvariabler och ersättningsvariabler .

Nu när du har en bättre förståelse för sårbarheten är du redo att skriva om funktionen med hjälp av frågeparametrar istället för stränginterpolation:

 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

Det här är vad som är annorlunda i det här exemplet:

  • På rad 9, du använde en namngiven parameter username för att ange vart användarnamnet ska gå. Lägg märke till hur parametern username är inte längre omgiven av enkla citattecken.

  • På rad 11, du skickade värdet för username som det andra argumentet till cursor.execute() . Anslutningen kommer att använda typen och värdet för username när du kör frågan i databasen.

För att testa den här funktionen, prova några giltiga och ogiltiga värden, inklusive den farliga strängen från tidigare:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
>>> is_admin("'; select true; --")
False

Fantastisk! Funktionen returnerade det förväntade resultatet för alla värden. Dessutom fungerar den farliga strängen inte längre. För att förstå varför kan du inspektera frågan som genereras av 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; --'

Anslutningen behandlade värdet för username som en sträng och escaped alla tecken som kan avsluta strängen och introducera Python SQL-injektion.



Att godkänna Safe Query-parametrar

Databasadaptrar erbjuder vanligtvis flera sätt att skicka frågeparametrar. Namngivna platshållare är vanligtvis bäst för läsbarhet, men vissa implementeringar kan dra nytta av att använda andra alternativ.

Låt oss ta en snabb titt på några av de rätta och fel sätten att använda frågeparametrar. Följande kodblock visar vilka typer av frågor du vill undvika:

# 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}'");

Var och en av dessa påståenden skickar username från klienten direkt till databasen, utan att utföra någon form av kontroll eller validering. Den här typen av kod är mogen för att bjuda in Python SQL-injektion.

Däremot bör dessa typer av frågor vara säkra för dig att köra:

# 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});

I dessa uttalanden, username skickas som en namngiven parameter. Nu kommer databasen att använda den angivna typen och värdet för username när du kör frågan, erbjuder skydd från Python SQL-injektion.




Använda SQL-komposition

Hittills har du använt parametrar för bokstaver. Literaler är värden som siffror, strängar och datum. Men vad händer om du har ett användningsfall som kräver att du skriver en annan fråga – en där parametern är något annat, som ett tabell- eller kolumnnamn?

Inspirerad av föregående exempel, låt oss implementera en funktion som accepterar namnet på en tabell och returnerar antalet rader i den tabellen:

# 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

Försök att köra funktionen på din användartabell:

>>>
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'
                        ^

Kommandot kunde inte generera SQL. Som du redan har sett behandlar databasadaptern variabeln som en sträng eller en bokstavlig. Ett tabellnamn är dock inte en vanlig sträng. Det är här SQL-kompositionen kommer in.

Du vet redan att det inte är säkert att använda stränginterpolation för att komponera SQL. Lyckligtvis tillhandahåller Psycopg en modul som heter psycopg.sql för att hjälpa dig att skapa SQL-frågor på ett säkert sätt. Låt oss skriva om funktionen med 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

Det finns två skillnader i denna implementering. Först använde du sql.SQL() för att skapa frågan. Sedan använde du sql.Identifier() för att kommentera argumentvärdet table_name . (En identifierare är ett kolumn- eller tabellnamn.)

Obs! Användare av det populära paketet django-debug-toolbar kan få ett fel i SQL-panelen för frågor som består av psycopg.sql.SQL() . En korrigering förväntas för release i version 2.0.

Försök nu att köra funktionen på users tabell:

>>>
>>> count_rows('users')
2

Bra! Låt oss sedan se vad som händer när tabellen inte finns:

>>>
>>> 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"
                        ^

Funktionen kastar UndefinedTable undantag. I följande steg kommer du att använda detta undantag som en indikation på att din funktion är säker från en Python SQL-injektionsattack.

Obs! Undantaget UndefinedTable lades till i psycopg2 version 2.8. Om du arbetar med en tidigare version av Psycopg får du ett annat undantag.

För att sätta ihop allt, lägg till ett alternativ för att räkna rader i tabellen upp till en viss gräns. Den här funktionen kan vara användbar för mycket stora bord. För att implementera detta, lägg till en LIMIT klausul till frågan, tillsammans med frågeparametrar för gränsens värde:

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

I det här kodblocket kommenterade du limit med sql.Literal() . Som i föregående exempel, psycopg kommer att binda alla frågeparametrar som bokstavliga ord när du använder den enkla metoden. Men när du använder sql.SQL() , måste du uttryckligen annotera varje parameter med antingen sql.Identifier() eller sql.Literal() .

Obs! Tyvärr tar Python API-specifikationen inte upp bindningen av identifierare, bara bokstavliga. Psycopg är den enda populära adaptern som lagt till möjligheten att säkert komponera SQL med både bokstavliga och identifierare. Detta faktum gör det ännu viktigare att vara uppmärksam när du binder identifierare.

Kör funktionen för att se till att den fungerar:

>>>
>>> count_rows('users', 1)
1
>>> count_rows('users', 10)
2

Nu när du ser att funktionen fungerar, se till att den också är säker:

>>>
>>> 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...
                            ^

Denna spårning visar att psycopg undgick värdet och databasen behandlade det som ett tabellnamn. Eftersom en tabell med detta namn inte finns, en UndefinedTable undantag togs upp och du blev inte hackad!



Slutsats

Du har framgångsrikt implementerat en funktion som skapar dynamisk SQL utan utsätter ditt system för risk för Python SQL-injektion! Du har använt både bokstavliga och identifierare i din fråga utan att kompromissa med säkerheten.

Du har lärt dig:

  • Vilken Python SQL-injektion är och hur det kan utnyttjas
  • Hur man förhindrar Python SQL-injektion med frågeparametrar
  • Hur man komponerar SQL-satser på ett säkert sätt som använder bokstaver och identifierare som parametrar

Du kan nu skapa program som tål attacker utifrån. Gå fram och omintetgör hackarna!



  1. Hur man ändrar en kolumn från Null till Not Null i SQL Server

  2. Hur sammanfogar man strängar av ett strängfält i en PostgreSQL "gruppa efter"-fråga?

  3. Skapa beräknad kolumn med hjälp av data från en annan tabell

  4. Varför avrundar SQL Server resultaten av att dividera två heltal?