upper right bubble
ephort logo
lower left bubble

Timing attacks i web applikationer

Serverens svartid fortæller ubudne gæster, hvad der sker bag facaden. Det hedder timing attacks og er nemt at overse i udviklingsprocessen. Læs her, hvordan du sikrer dig mod dem.

Af Jens Just Iversen

Web applikationer kan have mange sårbarheder, og næsten uanset hvilket system du drifter, så skal sikkerhed tænkes ind i alle dele af udviklingsfasen. Det gælder for alt fra webshops, nyhedsmedier, ERP systemer og så videre.

Både dine data og bruger-godkendelse er vigtige - du skal altså kunne stole på, at en brugerhandling er udført af den påståede person, og ingen må få adgang til data, der ikke vedkommer dem.

Nogle sikkerhedshensyn er nemme at tage og er efterhånden så fortærskede, at de ligger på rygraden for de fleste. Det drejer sig om sikring mod SQL injections, kryptering af passwords, krypterede forbindelser med SSL og så videre.

Andre sikkerhedshensyn er vanskeligere at få øje på, fordi de ikke direkte er et brist i den skrevne kode, men derimod et afledt informations-læk.

Timing attacks er et sådant sikkerhedshul, fordi hackeren udnytter informationen om, hvor lang tid en given operation til en server tager for derved at få be- eller afkræftet hypoteser.

Det svarer lidt til, hvis du bliver spurgt, om du har taget det sidste stykke kage, og du tøver inden du svarer “nej”. Det er meget muligt du svarede nej, men tiden du tog om at svare, afslørede dig.

Timing attacks tegning

Eksempel på timing attack

Mange web applikationer har et brugersystem, så lad os tage dette som et eksempel på en stump kode, der kan være sårbar over for timing attacks:

public function login()
{
    if (! User::usernameExists($_POST['username'])) {
        die('Brugernavn eller password forkert!');
    }

    $user = User::fetchUserdata($_POST['username']);

    if (! $user->checkPassword($_POST['password'])) {
        die('Brugernavn eller password forkert!');
    }

    echo 'Tillykke, du er logged ind!';
}

Ovenstående kode tjekker først om brugeren overhovedet eksisterer, inden brugeren hentes ud og adgangskoden tjekkes.

Dette kan nemt være en implementation i den virkelige verden, hvis User::fetchUserdata() er en tung funktion, for derved at hastighedsoptimere hele login-funktionen. For så behøver serveren ikke lave en masse unødige operationer, hvis en bruger slet ikke eksisterer.

Selvom udvikleren har forsøgt at sløre, hvad der egentlig foregår bag facaden ved at sende den samme fejlbesked til klienten uanset, om det er brugernavnet eller passwordet, der ikke stemmer, så afgiver login-funktionen alligevel denne vigtige information, nemlig ved det simple faktum, at et brugernavn der findes, vil tage længere tid om at give fejlbeskeden retur end et brugernavn, der ikke findes.

På denne måde kan angriberen hurtigt finde ud af, om en række emails allerede er oprettet i offerets system, og kun denne mængde emails, der faktisk er oprettet, kan arbejdes videre med.

Det betyder, at angriberen kan gå meget mere præcist til værks og for eksempel prøve passwords fra tidligere major leaks eller mest populære passwords blot for disse udvalgte email-adresser. Havde man ikke kunne indskrænke aktive email adresser på denne måde, havde mængden af mulige email- og adgangskode-kombinationer været så enorm, at det ikke havde været praktisk muligt at gennemføre.

Dette skete i den virkelige verden, da 'login' programmet på tidligere versioner af Unix først tjekkede om brugerne fandtes inden det tjekkede adgangskoden ved hjælp af en tung krypteringsfunktion.

Generelt er login formularer, nulstil adgangskode formularer og oprettelsesformularer i risikozone for denne type angreb.

Timing på mange niveauer

Ovenstående eksempel er et remote timing attack over HTTP. Men timing attacks kan ske på mange niveauer.

I nogle tilfælde vil forskellen i den observerede tid være så lille og svinge fra gang til gang, at angriberen bliver nødt til at indsamle et stort datasæt med mange observationer. På baggrund af den store datamængde vil statistiske analyser kunne afdække det ønskede informations-læk.

Sårbarheder ses også på hardware-niveau, hvor Meltdown og Spectre er navnene på nogle af de mest omfattende sårbarheder, som samtidig er timing attacks. Forsimplet set bunder sårbarhederne i, at CPU'er forsøger at hastighedsoptimere adgang til hukommelsen. Ved at observere hvor lang tid det tog for at få adgang til hukommelsen kunne et program tilgå hukommelse fra et andet program. Lidt som et cache hit/miss.

Problemet fås altså i alle former og størrelser. Lad os se på et nyt eksempel for en web applikation.

Comparison i PHP

Når man sammenligner to strenge i et programmeringssprog ved hjælp af syntaxen ===, sker det i næsten alle programmeringssprog på samme måde. Vi arbejder mest med PHP, så lad os kigge på kildekoden for selve PHP.

static zend_always_inline bool zend_string_equal_content(zend_string *s1, zend_string *s2)
{
    return ZSTR_LEN(s1) == ZSTR_LEN(s2) && zend_string_equal_val(s1, s2);
}

Først tjekkes, om de 2 strenge er lige lange. Er de ikke det, så returneres straks, at de to strenge ikke er ens.

Hvis de er lige lange, så kaldes zend_string_equal_val-funktionen, og den sammenligner blot de to strenge ved hjælp af memcmp-funktionen. Kildekoden for memcmp ser sådan ud:

int
memcmp (const PTR str1, const PTR str2, size_t count)
{
  register const unsigned char *s1 = (const unsigned char*)str1;
  register const unsigned char *s2 = (const unsigned char*)str2;
  while (count-- > 0)
    {
      if (*s1++ != *s2++)
          return s1[-1] < s2[-1] ? -1 : 1;
    }
  return 0;
}

Den løber altså hvert bogstave igennem én for én.

Er bogstavet ikke det samme, som bogstavet på den tilsvarende position i den anden streng, så returneres -1.

Det betyder altså, at der skal køres færre CPU-cykler direkte afhængigt af, hvor mange bogstaver, der var ens i starten af de to strenge.

Og færre CPU-cykler betyder kortere eksekveringstid.

Hvert bogstave vil dog normalvis tager under 100 nanosekunder at sammenligne, og det kan derfor være meget svært at måle denne forskel over internettet.

Selvom timing attacks ofte bliver lavet ved at indsamle meget store datasæt, så kan så små tidsforskelle være umulige at kortlægge på grund af uregelmæssigheder i netværket og andre foranstaltninger som DDoS-beskyttelse og throttling.

Timeless timing attacks

I sommeren 2020 udgav Tom Goethem og 3 andre forfattere en publikation, som de kalder timeless timing attacks.

I modsætning til normale timing attacks, hvor man sammenligner, hvor lang tid de enkelte kald tager, så udnytter timeless timing attacks muligheden for at sende flere HTTP kald i samme netværkspakke, som er tilfældet ved blandt andet Multiplexing med HTTP/2 protokollen. Det betyder, at de ankommer til serveren på præcis samme tid, og serveren sender dem retur i den rækkefølge, den blev færdig med dem. På den måde kan hackeren nøjes med at se på rækkefølgen de kom tilbage i stedet for hvor lang tid de tog - deraf ordet “timeless”.

Derfor kan antallet af stikprøver være betydeligt færre end normale timing attacks, og det er samtidig også ligemeget, hvor i verden serveren står.

Det er ret revolutionerende.

Lad os tage et eksempel. Dette simple PHP script ligger på en server hos Amazon i Stockholm:

<?php

if ($_GET['pw'] === 'thepassword') {
    echo "Password ok!";

    http_response_code(200);
    exit;
}

echo "Password not okay";

http_response_code(401);
exit;

Der bliver altså lavet en string comparison for at tjekke, om query parametret “pw” er det samme som strengen “thepassword”.

Ved at bruge Tom Goethem og co's script1, der kan sende 2 HTTP kald i én TCP pakke og registrere, hvilken en der blev returneret først, kan vi lave følgende Python script:

from h2time import H2Time, H2Request
import asyncio

async def attack():
    r1 = H2Request('GET', 'https://timingattack.dk/?pw=thepassxxxx')
    r2 = H2Request('GET', 'https://timingattack.dk/?pw=txxxxxxxxxx')
    num_request_pairs = 400
    async with H2Time(r1, r2, num_request_pairs=num_request_pairs, num_padding_params=40, sequential=True, inter_request_time_ms=10) as h2t:
        results = await h2t.run_attack()
        output = '\n'.join(map(lambda x: ','.join(map(str, x)), results))
        num = output.count('-')
        # print(output)
    if (num > (num_request_pairs/2)):
        print("Request 1 is likely winner (response received last from server in %s of the request pairs)" % (num))
    elif (num < (num_request_pairs/2)):
        print("Request 2 is likely winner (response received last from server in %s of the request pairs)" % (num_request_pairs-num))
    else:
        print("Could not determine winner. Even distributed with %s responses that came in with response 1 last" % (num))

loop = asyncio.get_event_loop()
loop.run_until_complete(attack())
loop.close()

Den kalder altså henholdsvis https://timingattack.dk/?pw=thepassxxxx og https://timingattack.dk/?pw=txxxxxxxxxx

Da det første kald indeholder flest rigtige bogstaver fra venstre til højre, så tager denne længst tid. Det viser sig også i resultatet fra scriptet, der returnerer:

“Request 1 is likely winner (response received last from server in 219 of the request pairs)”

Det vil altså sige, at thepassxxxx er den streng, der har flest korrekte bogstaver.

Timeless timing attacks kan afsløre tidsforskelle på ned til 100 nanosekunder. Da hvert enkelt bogstave tager meget kort tid at sammenligne, tester vi her med lidt flere bogstaver af gangen.

For at udnytte tidsforskelle på under 100 nanosekunder kræver det i øjeblikket meget store stikprøvestørrelsen.

En tidsforskel på 20 mikrosekunder kræver derimod blot en stikprøvestørrelse på 6. Dermed er det meste throttling, DDoS beskyttelse og så videre sat ud af spil.

Hvordan påvirker det jeres kode?

Da timeless timing attacks er forholdsvist nye, så er der ikke så mange, der beskytter sig mod dem. Mange har været af den overbevisning, at timing attacks ikke praktisk kunne blive udnyttet over HTTP på grund af netværksstøj, men timeless timing attacks ændrer denne antagelse.

Servere, netværksudstyr og internetforbindelser bliver hele tiden hurtigere, og i takt med det bliver software mere blottet for timing attacks.

Det betyder, at vi skal til at tænke mere over, hvordan vi strukturerer vores kode.

  • Et performance-tweak kan meget vel betyde et sikkerhedshul.
  • En short-circuit af en if-sætning (første del var false) kan meget vel betyde et sikkerhedshul.
  • Normal string comparison til at validere passwords/tokens kan meget vel betyde et sikkerhedshul.

Der er heldigvis en række ting vi kan gøre, for at forhindre det.

Brug hash_equals til string comparison

I PHP kan funktionen hash_equals bruges til at sammenligne to strenge i stedet for den normale comparison operator (===). Den svarer på samme tid uanset om de var ens eller ej. Lignende funktioner findes i andre programmeringssprog.

Eksekver asynkront

I nogle tilfælde behøver brugeren ikke vente på, at forretningslogikken håndteres. I disse tilfælde kan man gemme forretningslogikken væk i et asynkront job.

Det vil for eksempel være en god ide ved Glemt password-funktion. Hvis brugeren findes, så får vedkommende en email. Men alt dette logik sker i jobbet, som hackeren ikke kan se eksekveringstiden af.

Undgå short-circuit af if-sætninger

PHP laver som standard short-circuit evaluering af if-sætninger.

Det betyder, at hvis den består af 2 dele adskildt af et “AND” og første del ikke var opfyldt, så dropper den helt at udføre anden halvdel af if-sætningen.

if (username_exists($username) && verify_password($password)) {

I ovenstående eksempel køres verify_password() slet ikke af PHP, såfremt username_exists() returnerede false. Det er ikke så godt, for det betyder samtidig, at hele scriptet tog kortere tid om at eksekvere, og så deles denne information med brugerne, hvilket gør scriptet sårbart overfor timing attacks.

Det er ikke muligt at slå short-circuit fra i PHP såvel som en række andre programmeringssprog, og man bliver i stedet nødt til at strukturere sin kode anderledes.

For eksempel:

$username_exists = username_exists($username);
$password_ok = verify_password($password);

if ($username_exists && $password_ok) {

Der sker stadig en short-circuit, men den tunge eksekvering er flyttet op i variabler, der begge køres uanset, om username_exists() returnerede false eller true.

Det er ikke så kønt, men det kan jo eventuelt flyttes til en hjælpe-funktion.

Svar på samme tid ved hvert kald

Er der et specifikt kald, der eksempelvis kan svinge mellem 100-300 millisekunder, så kan man fiksere det til altid at blive returneret på 300 millisekunder.

Det er et slag i ansigtet på performance, men øger sikkerheden.

Randomisering af eksekveringstiden er ikke en ligeså god ide, da det over tid vil danne et gennemsnit.

Afslutning

Generelt vil det være godt, hvis udviklere altid har i baghovedet, at når man sender et response til klienten, så sender man både noget ønsket information (body, status kode og så videre) og noget uønsket information - i dette tilfælde eksekveringstid.

Så vil det software, der skrives være sikre mod timing attacks både nu og i fremtiden.

Tak til Anders Hoffmann for revidering af artiklen.

1 https://github.com/DistriNet/timeless-timing-attacks

03. MAR 2022