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, ellerlocalhost
. -
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ändarenpostgres
. -
password
är lösenordet för den du angav iuser
. 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 parameternusername
är inte längre omgiven av enkla citattecken. -
På rad 11, du skickade värdet för
username
som det andra argumentet tillcursor.execute()
. Anslutningen kommer att använda typen och värdet förusername
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!