Ochrana proti csrf

CSRF (Cross-site Request Forgery) je typ útoku, při kterém je útočníkem podstrčen požadavek na server, který mu přinese nějaký užitek, ať už to znamená cokoli. Typickým příkladem může být vytvoření nějakého administrátorského účtu, snížení ceny v eshopu, nebo i smazání jiného účtu. Útok může, ale také nemusí vyžadovat další bezpečnostní slabinu systému, na který je útočeno.

Teorie

V podstatě jde o to, že některé akce jsou chráněny přístupovými právy, nemůže je dělat kdokoli. Na takové systémy se často útočí tak, že útočník ukradne session cookie oběti a za něj pak provede co potřebuje. Dostat se ale mezi oběť a server nemusí být jednoduché, a když už se tam dostane, měla by být komunikace šifrovaná a to pořádně, takže mu to nepomůže. Oběť nemusí být daleko od serveru, v řeči IT to znamená, že může komunikovat se serverem přímo (přes VPN). Nebo není administrátorská část webu z internetu vůbec přístupná. I takový systém může být zranitelný přesto, že je sebelépe schován.

Celá podstata CSRF útoku je v tom, že útočník donutí oběť ten požadavek na server udělat za něj. To znamená pohodlně skrz jakoukoliv armádu zabezpečení příslušné administrátorské sekce. Proto je v první řadě nutné, házet útočníkovi pod nohy všechny klacky, které má programátor k dispozici.

V první řadě je to zabezpečení systému před XSS (Cross-site scripting) útokem. Resp. nikdo mima administrátora samotného by neměl mít možnost přidat aktivní prvek do stránky, žádný! Javascriptový kód jsou zadní vrata. Ale požadavky, je možné tvořit i pomocí obrázku nebo stylu. Nebezpečné může být i automatické formátování odkazu. Pokud by v něm byla chyba, může útočník svůj odkaz zamaskovat.

V druhém případě, i když z hlediska CSRF spíš prvním, je třeba zajistit, aby kritické požadavky na server (ty měnící) mohli pocházet jen z určitých stránek. To lze zařídit pomocí kontroly http refereru a Origin hlavičky. Dále může pomoci i REST API, které říká, že požadavky, jenž mění dotaz by neměli být typu GET. Což zase ztíží cestu útočníkovi.

V poslední řadě je to ochrana kritický požadavků pomoci tzv. tokenů. Jde o náhodně vygenerovaný kód, který je zasazen do stránky, ze které mohou kritické požadavky přicházet a který je následně kontrolován před vykonáním onoho kritického požadavku, například ono vytvoření administrátorského účtu.

Implementace

Při implementaci těchto tokenů jsem si prošel různými extrémy. Jeden z extrémů fungoval tak, že jsem do cache na straně serveru ukládal pokaždé nový platný token. Díky tomu se ale aplikace stala jednostavovou. Otevřít odkaz v nové záložce znamenalo fakticky smrt aplikace, resp. odkazy fungovaly jen v té poslední záložce.

V případě Python aplikací nepoužívám žádnou cache a session cookie jsou tzv. samonosné. To znamená že jsou šifrované ale obsahují všechna důležitá data o přihlášeném uživateli. Tedy jeho login_id a kontrolní hash heshe hesla a dalších parametrů, které mohou ovlivnit jeho přihlášený stav. Do této cookie by se pak dal uložit token, je to ale přeci jen o něco nebezpečnější i když je cookie šifrovaná.

Ochranný token by měl být náhodně generovaný, ale chceme držet jeho platnost a nechceme aby byl na všech výchozích stránkách stejný. Chceme ale, aby po nějakou dobu byl na jedné stránce vždy ten samý.

Poruším tedy jedno z doporučení a token budu generovat předem stanoveným postupem. Náhodná složka tokenu bude uložena v šifrované cookie. I tak ale použijeme pro generování tokenu sha2 a dostatečně dlouhý na serveru skrytý binární text ze kterého onen token budeme skládat. Reálné nebezpečí, tedy takové, kdy by útočník stihl vygenerovat ať už hrubou silou nebo kolizí stejný token, a ještě se trefil do časového okna aktuálně přihlášené oběti ideálně ještě časově omezeného tokenu se limitně blíží nule.

Aplikace

Uživateli po každém přihlášení vygenerujeme náhodný string, jako část budoucích tokenů. String může obsahovat libovolný znak z ASCII tabulky, včetně těch řídících. Pokud bychom jej použili jako obyčejné heslo, je opravdu velmi kvalitní. Počet kombinací pro náš účel by mohl být 24 na 256.

Cookie je sice šifrovaná, ale co kdyby se jí někomu podařilo rozlousknout. Pro všechny případy tedy vygenerujeme string i pro serverovou část. K tomu máme celý arzenál možností. Například můžeme při každém startu generovat náhodný string, pak je ale možné že přihlášeným uživatelům nemusí fungovat některé odkazy, dokud si neobnoví stránku. Můžeme ale náhodně generovat víc verzí a tu si ukládat do cookie uživatele. Nebo pro každou skupinu uživatelů můžeme generovat jiný náhodný string. Nebo můžeme každému uživateli vygenerovat tento náhodný string. Cílem je aby jsme se k těmto stringům snadno dostali a nemuseli je příliš často měnit. Pak už je totiž vhodnější použít nějakou cache.

Pro naše testovací účely se kdesi v aplikaci vytvoří jednorázový token, který bude platný do dalšího restartu služby (wsgi).

from os import urandom
secret = urandom(32)

Pokud se uživatel přihlásí, vygeneruji mu jeho část a uložím do samonosné šifrované cookie. K tomu použiji vlastní implementaci PoorSession jenž je součástí PoorWSGI, možností je samozřejmě více. Rozhodně by ale nebylo moc dobré tento string jen tak vložit do cookie v jeho původní podobě.

from poorwsgi.session import PoorSession
from os import urandom

cookie = PoorSession(req)
cookie.data['hash'] = urandom(24)
cookie.header(req, req.headers_out)

Pro vygenerování tokenu použiji následující kód. Ten počítá s tím, že by mohlo jít jak o dočasný token tak o tzv. trvalý. Dočasný token je generován tak, že se vytvoří timestamp zaokrouhlený na timeout (v minutách). K němu je pak připočten dvojnásobek timeoutu. To z toho důvodu že můžeme chtít vygenerovat token v 6:39 s platností 10 minut. Aby se dal token snadno ověřovat, musíme zaokrouhlovat vždy jedním směrem, a to dolů. Ale pokud přidáme k času 6:30 jen těch 10 minut, moc času uživatel nemá. Dáme mu tedy dalších 10 minut. Výsledný timeout je tedy závislý na tom, kdy byl generován. A může dosahovat od své hodnoty k téměř dvojnásobku.

Vedle funkce sha256 tu máme i sha384 a sha512. Ty jsou lepší, na 32bitových systémech se generují sice pomaleji, ale jejich hexdigest je delší. Ten lze vyexportovat i přes base64 funkce, pak bude kratší. V principu zde může nastoupit libovolně složitý algoritmus. Hodnota expired je používaná pro ověření. Posledním povinným parametrem funkce je string references. V našem případě to je url stránky, ze které je akce povolená. To znamená že při ověřování použijeme referer.

def get_token(secret, user_hash, references, timeout=None, expired=0):
    if timeout is None:
        text = "%s%s%s" % (secret, user_hash, references)
    else:
        shift = 60 * timeout
        if expired == 0:
            now = time()
            now = int(now / shift) * shift     # shift to timeout
            expired = now + 2 * shift
        expired = sha256(str(expired)).hexdigest()
        text = "%s%s%s%s" % (secret, user_hash, references, expired)
    return sha256(text).hexdigest()

Nyní stačí do stránky k příslušným akcím přidat takto vygenerovaný token. U obyčejných GET požadavků jej přidáme do argumetnů url, u POST jej přidáme do nějakého hidden inputu a při AJAXových požadavcích můžeme použít hlavičky.

V našem příkladu pracuji jen s GET požadavky. A výsledná vygenerovaná stránka obsahuje dva tokeny, jeden trvalý, a jeden platný jednu minutu, resp. minutu až dvě. Funkce create_referer vytváří referer v takové podobě, v jaké ji posílá browser, tedy kompletní url. Vygenerovanou stránku (html) nechám na laskavém čtenáři.

def create_referer(req, referer):
    return "%s://%s%s" % (req.scheme, req.hostname, referer)

@app.route('/')
def root_uri(req):
    cookie = PoorSession(req)
    if 'hash' in cookie.data:
        referer = create_referer(req, '/')
        token_tmp = get_token(secret, cookie.data['hash'], referer)
        token_ttl = get_token(secret, cookie.data['hash'], referer, 1)
    else:
        token_tmp = token_ttl = ''

    html = """…""".format(token_tmp=token_tmp, token_ttl=token_ttl)
    return html

Nyní už stačí na stránce, která je takto chráněná otestovat validitu tokenu a provést příslušnou akci.

@app.route('/protected')
def protected(req):
    cookie = PoorSession(req)
    cookie_hash = cookie.data.get('hash')
    referer = req.referer.split('?')[0]
    if 'token_tmp' in req.args:
        token = req.args.get('token_tmp')
        if not check_token(token, secret, cookie_hash, referer):
            raise SERVER_RETURN(state.HTTP_FORBIDDEN)
        return do_it_protected_action(req)
    else:
        token = req.args.get('token_ttl')
        if not check_token(token, secret, cookie_hash, referer, 1):
            raise SERVER_RETURN(state.HTTP_FORBIDDEN)
        return do_it_protected_action(req)

Funguje !

Na závěr zrekapituluji, že implementace porušuje některá doporučení. Ideální řešení je použít cache na straně serveru. Pak jen stačí pro každého uživatele a každou url generovat náhodný token s příslušnou dobou platnosti. Taková implementace je čistá a především snadná. Doba platnosti je zkutečná a každý uživatel může mít pro každou url svůj vlastní náhodný token, jenž nebude náročný na vytvoření.

Kompletní „knihovnu“ i ukázkový kód najdete na GitHubu.

A malý tip na závěr. Stránky, které obsahují časově omezený token, by bylo vhodné před vypršením tokenu automaticky obnovit, aby měl uživatel nový token a vše mu fungovalo.

Autor:

Diskuze

Váš komentář:

© 2023 Ondřej Tůma McBig. Ondřej Tůma | Based on: Morias | Twitter: mcbig_cz | RSS: články, twitter