Zabezpečení databáze a dat

Z PostgreSQL
Skočit na navigaci Skočit na vyhledávání

Základní doporučení z hlediska bezpečnosti přístupu

  • POZOR NA SQL INJECTION!!! Primárně se jedná o aplikační problém, který nelze vyřešit na db úrovni. Na úrovni databáze lze maximálně minimalizovat dopad průniku.
  • Používejte více databázových (případně systémových) účtů.
  • Každá role musí mít přístup pouze k těm datům, které potřebuje pro svou činnost.
  • Aplikační databázový uživatel nikdy nesmí být vlastníkem databázových objektů (vlastníkem tabulek, funkcí, databází).
  • Je praktické používat pouze jeden vyhrazený účet, který slouží jako vlastník všech db objektů - tento účet (pokud možno) nikdy nepoužíváme z veřejně dostupné aplikace! Např. omezíme možnost přihlášení pouze z lokální sítě. Důležité: heslo k tomuto účtu není nikde uloženo (maximálně někde v zapečetěné obálce v trezoru).
  • Přístup nastavujte explicitně - příkazem GRANT, nikoliv implicitně (skrz vlastníka) - donutí to přemýšlet o zabezpečení.
  • Pokud používáte SECURITY DEFINER funkce, tak explicitně nastavujte SEARCH_PATH jako atribut funkce.

Kontrola vynucení zadání hesla (pg_hba.conf)

PostgreSQL umožňuje nezabezpečený přístup k databázi (který je ve výchozí konfiguraci) vždy neaktivní. Při kontrole zabezpečení je vhodné si zkontrolovat obsah konfiguračního souboru 'pg_hba.conf'. Tento soubor by nesmí obsahovat nezakomentované řádky, kde je metoda ověřování "trust".

SQL injection

SQL injection je nejběžnější útok na aplikace používající SQL databáze. Podobně jako při jiných typech útoků, kdy aplikace vykoná podstrčený (infikovaný) kód, zde aplikace vykoná podstrčený SQL příkaz (nebo jeho fragment, tj dojde k modifikaci zabudovaného SQL příkazu). Pokud vývojář správně neošetří vstup a použije dnes již nedoporučované techniky parametrizace SQL příkazů, jako je sčítání neošetřených vstupů, pak útočník může změnit charakter SQL příkazu. Zabezpečení proti SQL injection je jednoduché a spolehlivé - stačí nepoužívat nebezpečné techniky. Je důležité si uvědomit, že se jedná o aplikační problém, a že se nemusí jednat pouze o www aplikaci (většina aplikací, které jsme psali před rokem 2000 ve Visual Basicu byla děravá jako řešeto).

# ověření hesla
# CHYBA NEPOUZIVAT UKAZKA SQL INJECTION !!!
$heslo = ... # hodnota ze vstupu -- utocnik pouzije heslo " ' or 1=1 -- "
$uzivatel = 'xxx';

# sestaveni dotazu -- POZOR SQL INJECTION !!!
$sql = "SELECT * FROM users WHERE user='$uzivatel' AND password='$heslo';

# vysledkem je dotaz SELECT * FROM users WHERE user='xxx' AND password=' ' or 1=1 -- '
# tento prikaz je syntakticky spravny a vzdy vraci jeden nebo vice radku, bez ohledu
# na zadane vstupy

Pozor, je nutné zabezpečit všechny SQL operace (NEJEN LOGIN). Tady jako běžný uživatel zadávám nový tiket a při následném výpisu tiketů uvidím administrátorské heslo:

postgres=# create table users(name text, password text);
CREATE TABLE
postgres=# create table tickets(inserted timestamp default current_timestamp, other text);
CREATE TABLE
postgres=# insert into users values('admin','mysecretpass');
INSERT 0 1
-- útok prostřednictvím obyčejného uživatele
-- Jako obsah pole "other" bych zvolil řetězec: "' || (select password from users where name = 'admin') || '"
postgres=# insert into tickets(other) values((select password from users where name = 'admin'));
INSERT 0 1
postgres=# select * from tickets;
          inserted          |    other     
----------------------------+--------------
 2015-02-14 11:18:41.434376 | mysecretpass
(1 row)

Ochrana

Každé vývojové prostředí nabízí bezpečné API pro imunizaci vstupů (escaping) nebo pro předávání parametrů dotazu odděleně - a je třeba o nich vědět a používat je. Může to být použití prepared statements (předpřipravených dotazů), může to být použití implicitního escapeování, případně explicitního. Vždy je však nutné v hostitelském prostředí (PHP, Perl, ...) nějakým jasně definovaným způsobem oddělit SQL příkaz a jeho parametry.

Nikdy nepoužívat skládání řetězců!!!

Pozor! Je chybou se soustředit pouze na zabezpečení LOGINu. U většiny systémů si útočníci mohou vytvořit účet legálně - a pak tento účet použít pro útok.

Logujte chyby - pokus o průnik v chybně zabezpečené aplikaci se většinou projeví množstvím syntaktických chyb. Pozor! V ten okamžik víte, že máte děravou aplikaci. Ale v tu chvíli útočník může stahovat citlivá data.

Ukázka bezpečného kódu s využitím rozhraní PDO v PHP:

<?php
    $dbh = new PDO("pgsql:dbname=postgres;host=localhost", "pavel", "" );

    $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    $usename = 'pavel';

    /*** prepare the SQL statement ***/
    $stmt = $dbh->prepare("SELECT * FROM pg_user WHERE usename = :usename");

    /*** bind the paramaters ***/
    $stmt->bindParam(':usename', $usename, PDO::PARAM_STR, 64);

    /*** execute the prepared statement ***/
    $stmt->execute();

    /*** fetch the results ***/
    $result = $stmt->fetchAll();

    /*** loop of the results ***/
    foreach($result as $row)
        {
        echo $row['usename'].'<br />'."\n";
        echo $row['usesysid'].'<br />'."\n";
        }

    /*** close the database connection ***/
    $dbh = null;
?>

Kontrola

Pro databázový účet, který používá aplikace zapneme logování všech dotazů:

postgres=# ALTER ROLE pavel SET log_min_duration_statement = 0;
ALTER ROLE

V logu Postgresu pak bychom měli vidět záznam o použití prepared statement:

LOG:  duration: 0.681 ms  parse pdo_stmt_00000001: SELECT * FROM pg_user WHERE usename = $1
LOG:  duration: 0.757 ms  bind pdo_stmt_00000001: SELECT * FROM pg_user WHERE usename = $1
DETAIL:  parameters: $1 = 'pavel'
LOG:  duration: 0.103 ms  execute pdo_stmt_00000001: SELECT * FROM pg_user WHERE usename = $1
DETAIL:  parameters: $1 = 'pavel'
LOG:  duration: 0.114 ms  statement: DEALLOCATE pdo_stmt_00000001

Poznámka - Starší verze PostgreSQL mohly mít určité problémy s efektivitou optimalizace prepared statements z důvodu optimalizace naslepo. Tento problém by měl být vyřešen ve verzi 9.2 a novějších.

Verifikace hesla bez možnosti čtení tabulky s uloženými hesly

Aplikační databázový uživatel musí mít přístup k tabulce obsahující seznam uživatelů a hashi hesel, aby mohl ověřit účet a heslo. To ale znamená významné bezpečnostní riziko, pokud by došlo ke kompromitování aplikačního databázového účtu. Pak je možné si stáhnout seznam uživatelů. Možným řešením je použití SECURITY DEFINER funkce - to je funkce, jejíž tělo je výkonáváno s právy vlastníka funkce (nikoliv s právy volajícího). Tato funkce umožní vykonání jasně definovaného bezpečného kódu s dočasně změněnou identitou. V případě kompromitace účtu může účastník volat tyto funkce, ale nemá přístup k tabulce uživatelů/hesel.

postgres=# CREATE ROLE eshop_owner NOLOGIN;
CREATE ROLE
postgres=# CREATE ROLE eshop_app LOGIN;
CREATE ROLE
postgres=# ALTER ROLE eshop_app PASSWORD 'bezpecnejsiHeslo';
ALTER ROLE

SET ROLE eshop_owner;
CREATE TABLE eshop_users(account text CHECK (account <> '') PRIMARY KEY, password text CHECK (password <> ''));

CREATE OR REPLACE FUNCTION public.new_account(account text, password text)
 RETURNS void
 LANGUAGE plpgsql
 SECURITY DEFINER
 SET search_path TO pg_catalog
AS $function$
BEGIN
  IF coalesce(trim(new_account.account), '') = '' THEN
    RAISE EXCEPTION 'cannot create account'
       USING DETAIL='Name is empty string.';
  ELSEIF coalesce(trim(new_account.password), '') = '' THEN
    RAISE EXCEPTION 'cannot create account "%"', new_account.account
       USING DETAIL='Password is empty string'; 
  END IF;

  INSERT INTO public.eshop_users(account,password)
    VALUES(trim(new_account.account), 
           md5(new_account.password || trim(new_account.account) || 'somerandomstr')); 

EXCEPTION 
  WHEN unique_violation THEN
    PERFORM pg_sleep(random());
    RAISE EXCEPTION 'cannot create account "%"', new_account.account
       USING DETAIL='User already exists';
  WHEN others THEN
    PERFORM pg_sleep(random());
    -- reraise exception
    RAISE;
END;
$function$;

CREATE OR REPLACE FUNCTION public.verify_password(account text, password text)
 RETURNS boolean
 LANGUAGE plpgsql
 SECURITY DEFINER
 SET search_path TO pg_catalog
AS $function$
BEGIN
  IF EXISTS(SELECT *
               FROM public.eshop_users e
              WHERE e.account = trim(verify_password.account)
                AND e.password=md5(verify_password.password || trim(verify_password.account) || 'somerandomstr'))
  THEN
    RETURN true;
  ELSE
    PERFORM pg_sleep(random());
    RETURN false;
  END IF;
END;
$function$;

postgres=> SET ROLE eshop_app;
SET
postgres=> SELECT * FROM eshop_users;
ERROR:  permission denied for relation eshop_users
postgres=> SELECT new_account('pavel','mojeheslo');
 new_account 
-------------
 
(1 row)

postgres=> SELECT new_account('pavel','mojeheslo');
ERROR:  cannot create account "pavel"
DETAIL:  User already exists

postgres=> SELECT verify_password('pavel','mojeheslo');
 verify_password 
-----------------
 t
(1 row)

postgres=> SELECT verify_password('pavel','mojehesloa');
 verify_password 
-----------------
 f
(1 row)

Výše uvedený kód ochrání přístup k citlivým údajům v případě že dojde ke kompromitaci aplikačního uživatele (což je nejčastější varianta - např. použitím SQL injection).

V případě kompromitace účtu s právy administrátora může dojít k několika možným rizikům:

  • přístup k logům Postgresu, kde mohou být zalogované SQL příkazy a jejich parametry (tudíž i hesla). Toto riziko lze odstranit vytvořením hashe na už na straně klienta - nicméně získání hashe znamená také výrazné bezpečnostní riziko.
  • plný přístup do databáze - administrátor se může přihlásit jako uživatel s právy superusera.
  • možnost odposlechu síťového provozu - toto riziko hrozí v případě, že databáze je na jiném serveru než je aplikační server. Tomuto riziku lze zabránit pouze využitím šifrované komunikace - např. ssl, ssh tunel, ...

Proto, vždy se přihlašujte k silnějším účtům pomocí bezpečného (šifrovaného) kanálu z bezpečné sítě! Svůj operační systém udržujte aktuální - tak aby v případě kompromitování aplikačního účtu bylo maximálně sníženo riziko získání neoprávněného získání práv administrátora.

Na co si dát pozor

Komunikace po síti

Ve výchozím nastavení (některé distribuce jsou vyjímkou - např. Debian) PostgreSQL nešifruje komunikaci mezi klientem a serverem. Data jsou posílaná v textové čitelné podobě a hrozí riziko zachycení a monitorování komunikace. Jedinou vyjímkou je posílání hesel, kdy se posílá pouze osolený hash - tj. přihlášení k databázi je bezpečné - zbytek už ale ne. Jednoduše lze zapnout podporu ssl, které zajistí šifrování komunikace a odolnost vůči odposlechu. Pozor - ssl protokol může výrazně zpomalit přihlašování do databáze - pokud máte aplikaci, která často navazuje spojení, tak je pak vhodné uvažovat o poolování spojení.

Nastavení SSL

Jednoduchý postup pro nastavení šifrované komunikace bez ověřování certifikátu serveru:

  • Generování certifikátu
openssl req -new -text -out cert.req
openssl rsa -in privkey.pem -out cert.pe 
openssl req -x509 -in cert.req -text -key cert.pem -out cert.cert 
cp cert.pem $PGDATA/server.key
cp cert.cert $PGDATA/server.crt
chmod 600 $PGDATA/server.key
chmod 600 $PGDATA/server.crt
  • V konfiguračním souboru postgresu (postgresql.conf) zapnout podporu ssl
ssl=on
  • V souboru pg_hba.conf si vynutit použití ssl, nezapomenout zkontrolovat (a nastavit pořadí) ostatních host* konfigurací
hostssl all             all             0.0.0.0/0            md5
  • Restart serveru

Kontrola přihlášení:

[pavel@localhost ~]$ /usr/local/pgsql/bin/psql postgres -h 127.0.0.1
Password: 
psql (9.4devel)
SSL connection (cipher: DHE-RSA-AES256-SHA, bits: 256)
Type "help" for help.

postgres=# 

Bezpečné techniky zabezpečení přístupu k datům pomocí pohledů

Jedním z možných využití pohledů (Views) je omezení přístupu k datům. Prostřednictvím pohledů přistupujeme k tabulkám s právy vlastníka pohledu a stejně tak, jako tabulkám, nastavujeme přístupová práva k pohledu.

postgres=# SET ROLE eshop_owner;
SET
postgres=> CREATE TABLE zamestnanci(jmeno text, mzda integer);
CREATE TABLE
postgres=> DROP TABLE zamestnanci ;
DROP TABLE
postgres=> CREATE TABLE zamestnanci(jmeno text NOT NULL, mzda integer NOT NULL);
CREATE TABLE
postgres=> INSERT INTO zamestnanci VALUES('Tomas', 10000);
INSERT 0 1
postgres=> CREATE VIEW zamestnanci_public AS SELECT jmeno FROM zamestnanci;
CREATE VIEW
postgres=> GRANT SELECT ON zamestnanci_public TO public;
GRANT
postgres=> SET ROLE eshop_app;
SET
postgres=> SELECT * FROM zamestnanci;
ERROR:  permission denied for relation zamestnanci
postgres=> SELECT * FROM zamestnanci_public;
 jmeno 
-------
 Tomas
(1 row)

Podobně můžeme pohledy použít pro omezení přístupu k některým řádkům:

postgres=> SET ROLE eshop_owner;
SET
postgres=> CREATE TABLE zamestnanci(jmeno text NOT NULL, mzda integer NOT NULL, kategorie int);
CREATE TABLE
postgres=> INSERT INTO zamestnanci VALUES('Tomas', 10000, 1);
INSERT 0 1
postgres=> INSERT INTO zamestnanci VALUES('Jirka', 20000, 2);
INSERT 0 1
postgres=> CREATE VIEW zamestnanci2_public AS SELECT jmeno, mzda, kategorie FROM zamestnanci WHERE kategorie = 1;
CREATE VIEW
postgres=> GRANT SELECT ON zamestnanci2_public TO public;
GRANT
postgres=> SET ROLE eshop_app;
SET
postgres=> SELECT * FROM zamestnanci;
ERROR:  permission denied for relation zamestnanci
postgres=> SELECT * FROM zamestnanci2_public ;
 jmeno | mzda  | kategorie 
-------+-------+-----------
 Tomas | 10000 |         1
(1 row)

postgres=> SELECT * FROM zamestnanci2_public WHERE kategorie = 2;
 jmeno | mzda | kategorie 
-------+------+-----------
(0 rows)

Na první pohled vypadá, že je vše ok. Ale to bohužel není pravda. Pokud některý uživatel má možnost si vytvářet funkce v Postgresu a používat ladící výstup, tak si může napsat funkci, kterou optimalizátor předsune před filtr kategorie (z důvodu nižší ceny).

postgres=> CREATE OR REPLACE FUNCTION show(text, int, int)
RETURNS boolean AS $$
BEGIN
  RAISE NOTICE '% % %', $1, $2, $3;
  RETURN true;
END;
$$ LANGUAGE plpgsql COST 0.000000001;
CREATE FUNCTION
postgres=> EXPLAIN VERBOSE SELECT * FROM zamestnanci2_public WHERE show(jmeno, mzda, kategorie);
                                                  QUERY PLAN                                                  
--------------------------------------------------------------------------------------------------------------
 Seq Scan on public.zamestnanci  (cost=0.00..24.50 rows=2 width=40)
   Output: zamestnanci.jmeno, zamestnanci.mzda, zamestnanci.kategorie
   Filter: (show(zamestnanci.jmeno, zamestnanci.mzda, zamestnanci.kategorie) AND (zamestnanci.kategorie = 1))
(3 rows)

postgres=> SELECT * FROM zamestnanci2_public WHERE show(jmeno, mzda, kategorie);
NOTICE:  Tomas 10000 1
NOTICE:  Jirka 20000 2
 jmeno | mzda  | kategorie 
-------+-------+-----------
 Tomas | 10000 |         1
(1 row)

Pohledy se většinou nepoužívají pro omezení přístupu - proto není snaha omezit optimalizátor v PostgreSQL, aby respektoval pořadí zadání predikátů. Naopak, většinou chceme, aby PostgreSQL zvolil zvolil plán, jehož cena je nejnižší a je pravděpodobné, že jeho spočítání bude nejrychlejší. Nicméně problém zůstává - a řeší se pomocí atributu "security_barrier". Pokud je pohled označkován tímto atributem, tak pak optimalizátor ví, že je pohled použitý jako zabezpečení přístupu k datům, a optimalizátor vždy provede nejdříve predikáty uvedené v definici pohledu:

postgres=> SET ROLE eshop_owner;
SET
postgres=> CREATE OR REPLACE VIEW zamestnanci2_public WITH (security_barrier) AS SELECT jmeno, mzda, kategorie FROM zamestnanci WHERE kategorie = 1;
CREATE VIEW
postgres=> SET ROLE eshop_app;
SET
postgres=> EXPLAIN SELECT * FROM zamestnanci2_public WHERE show(jmeno, mzda, kategorie);
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 Subquery Scan on zamestnanci2_public  (cost=0.00..24.56 rows=2 width=40)
   Filter: show(zamestnanci2_public.jmeno, zamestnanci2_public.mzda, zamestnanci2_public.kategorie)
   ->  Seq Scan on zamestnanci  (cost=0.00..24.50 rows=6 width=40)
         Filter: (kategorie = 1)
(4 rows)

postgres=> SELECT * FROM zamestnanci2_public WHERE show(jmeno, mzda, kategorie);
NOTICE:  Tomas 10000 1
 jmeno | mzda  | kategorie 
-------+-------+-----------
 Tomas | 10000 |         1
(1 row)