Förstå Hash-funktioner och hålla lösenord säkra

Från tid till annan blir servrar och databaser stulna eller komprometterade. Med detta i åtanke är det viktigt att se till att vissa viktiga användardata, som lösenord, inte kan återställas. Idag ska vi lära oss grunderna bakom hash och vad som krävs för att skydda lösenord i dina webbapplikationer.

Publicerad handledning

Varje par veckor besöker vi några av våra läsares favoritinlägg från hela webbplatsens historia. Denna handledning publicerades först i januari 2011.


1. Ansvarsfriskrivning

Kryptologi är ett tillräckligt komplicerat ämne, och jag är inte alls en expert. Det sker konstant forskning på detta område, i många universitet och säkerhetsbyråer.

I den här artikeln kommer jag att försöka hålla saker så enkelt som möjligt, samtidigt som du presenterar en rimligt säker metod för lagring av lösenord i en webbapplikation.


2. Vad gör "Hashing"?

Hashing omvandlar en bit data (antingen liten eller stor) till en relativt kort bit av data, såsom en sträng eller ett heltal.

Detta uppnås genom att använda en envägs hash-funktion. "One-way" betyder att det är mycket svårt (eller praktiskt taget omöjligt) att vända det.

Ett vanligt exempel på hash-funktion är md5 (), vilket är ganska populärt på många olika språk och system.

$ data = "Hello World"; $ hash = md5 ($ data); echo $ hash; // b10a8db164e0754105b7a99be72e3fe5

Med md5 (), resultatet kommer alltid att vara en 32 tecken lång sträng. Men den innehåller bara hexadecimala tecken; Tekniskt kan det också representeras som ett 128-bitars (16 byte) heltal. Du får md5 () mycket längre strängar och data, och du kommer ändå att sluta med en hash av denna längd. Detta faktum i sig kan ge dig en ledtråd till varför detta betraktas som en "enkelriktad" funktion.


3. Använda en Hash-funktion för lagring av lösenord

Den vanliga processen under en användarregistrering:

  • Användaren fyller i registreringsformuläret, inklusive lösenordsfältet.
  • Webbskriptet lagrar all information i en databas.
  • Lösenordet körs dock via en hash-funktion innan den lagras.
  • Den ursprungliga versionen av lösenordet har inte lagrats någonstans, så det kasseras tekniskt.

Och inloggningsprocessen:

  • Användaren anger användarnamn (eller e-post) och lösenord.
  • Skriptet löser lösenordet via samma hashing-funktion.
  • Skriptet hittar användarregistret från databasen och läser det lagrade hashed-lösenordet.
  • Båda dessa värden jämförs och åtkomsten beviljas om de matchar.

När vi väl bestämmer oss för en anständig metod för att lösa lösenordet, kommer vi att genomföra denna process senare i den här artikeln.

Observera att det ursprungliga lösenordet aldrig har lagrats någonstans. Om databasen stulits kan användarinloggningarna inte äventyras, eller hur? Tja, svaret är "det beror på det." Låt oss titta på några potentiella problem.


4. Problem # 1: Hash Collision

En hash-kollision uppträder när två olika dataingångar alstrar samma resulterande hash. Sannolikheten för detta händer beror på vilken funktion du använder.

Hur kan detta utnyttjas?

Som exempel har jag sett några äldre skript som använde crc32 () för hash-lösenord. Denna funktion genererar ett 32-bitars heltal som resultat. Det betyder att det bara är 2 ^ 32 (dvs 4 294 967 296) möjliga resultat.

Låt oss ha ett lösenord:

echo crc32 ('supersecretpassword'); // utdata: 323322056

Låt oss nu anta rollen som en person som har stulit en databas och har hashvärdet. Vi kanske inte kan konvertera 323322056 till "supersecretpassword", men vi kan räkna ut ett annat lösenord som konverterar till samma hash-värde med ett enkelt skript:

set_time_limit (0); $ i = 0; medan (sant) if (crc32 (base64_encode ($ i)) == 323322056) echo base64_encode ($ i); utgång;  $ i ++; 

Det kan hända ett tag, men i slutändan bör det returnera en sträng. Vi kan använda den här returnerade strängen - istället för "supersecretpassword" - och det kommer att göra det möjligt för oss att logga in på den personens konto.

Till exempel, efter att ha kört detta exakta skript för några minuter på min dator, fick jag "MTIxMjY5MTAwNg =='. Låt oss testa det:

echo crc32 ('supersecretpassword'); // utdata: 323322056 echo crc32 ('MTIxMjY5MTAwNg =='); // utdata: 323322056

Hur kan detta förhindras?

Numera kan en kraftfull hemdator användas för att driva en hashfunktion nästan en miljard gånger per sekund. Så vi behöver en hashfunktion som har a mycket stort utbud.

Till exempel, md5 () kan vara lämplig, eftersom det genererar 128-bitars hash. Detta innebär 340,282,366,920,938,463,463,374,607,431,768,211,456 möjliga resultat. Det är omöjligt att springa igenom så många iterationer för att hitta kollisioner. Men vissa människor har fortfarande hittat sätt att göra detta (se här).

SHA1

Sha1 () är ett bättre alternativ, och det genererar ett ännu längre 160-bitars hashvärde.


5. Problem # 2: Regnbordsbord

Även om vi löser kollisionsproblemet är vi fortfarande inte säkra än.

Ett regnbordsbord är byggt genom att beräkna hashvärdena för vanliga ord och kombinationer.

Dessa tabeller kan ha så många som miljoner eller till och med miljarder rader.

Du kan till exempel gå igenom en ordlista och skapa hashvärden för varje ord. Du kan också börja kombinera ord tillsammans och skapa hash för dem också. Det är inte allt; du kan till och med börja lägga till siffror före / efter / mellan ord och lagra dem i tabellen också.

Med tanke på hur billig lagring är nuförtiden kan gigantiska regnbordsbord produceras och användas.

Hur kan detta utnyttjas?

Låt oss föreställa oss att en stor databas är stulen, tillsammans med 10 miljoner lösenordshastigheter. Det är ganska lätt att söka på regnbordsbordet för var och en av dem. Inte alla kommer säkert att hittas, men ändå ... några av dem kommer!

Hur kan detta förhindras?

Vi kan försöka lägga till ett "salt". Här är ett exempel:

$ password = "easypassword"; // detta kan hittas i ett regnbordsbord // eftersom lösenordet innehåller 2 vanliga ord echo sha1 ($ password); // 6c94d3b42518febd4ad747801d50a8972022f956 // använd en massa slumpmässiga tecken, och det kan vara längre än detta $ salt = "f # @ V) Hu ^% Hgfds"; // detta kommer INTE att hittas i någon förbyggd regnbordsbord echo sha1 ($ salt. $ lösenord); // cd56a16759623378628c0d9336af69b74d9d71a5

Vad vi i grunden gör är att sammanlänka "salt" -strängen med lösenorden innan de hämmar dem. Den resulterande strängen kommer givetvis inte att förekomma på något förbyggt regnbordsbord. Men vi är fortfarande inte säkra just än!


6. Problem # 3: Rainbow tabeller (igen)

Kom ihåg att ett regnbordsbord kan skapas från början, efter att databasen har blivit stulen.

Hur kan detta utnyttjas?

Även om ett salt användes kan det ha blivit stulen tillsammans med databasen. Allt de behöver göra är att skapa ett nytt regnbordsbord från början, men denna gång förenar de saltet med varje ord som de sätter i bordet.

Till exempel, i ett generiskt regnbordsbord, "easypassword"kan existera. Men i det här nya Rainbow-bordet har de"f # @ V) Hu ^% Hgfdseasypassword"också. När de kör alla de 10 miljoner stulna saltade hasherna mot detta bord, kommer de återigen att kunna hitta några matcher.

Hur kan detta förhindras?

Vi kan istället använda ett "unikt salt", vilket ändras för varje användare.

En kandidat för denna typ av salt är användarens id-värde från databasen:

$ hash = sha1 ($ user_id. $ lösenord);

Detta förutsätter att användarens id-nummer aldrig ändras, vilket vanligtvis är fallet.

Vi kan också generera en slumpmässig sträng för varje användare och använda det som det unika saltet. Men vi skulle behöva se till att vi lagrar det i användarrekord någonstans.

// genererar en 22 tecken lång slumpmässig strängfunktion unique_salt () return substr (sha1 (mt_rand ()), 0,22);  $ unique_salt = unique_salt (); $ hash = sha1 ($ unique_salt. $ lösenord); // och spara $ unique_salt med användarrekordet // ... 

Denna metod skyddar oss mot regnbordsbord, för nu har varje enskilt lösenord saltats med ett annat värde. Attacken skulle behöva generera 10 miljoner separata regnbordsbord, vilket skulle vara helt opraktiskt.


7. Problem # 4: Hash Speed

De flesta hashfunktionerna har utformats med snabbhet i åtanke, eftersom de ofta används för att beräkna kontrollsumsvärden för stora dataset och filer, för att kontrollera dataintegriteten.

Hur kan detta utnyttjas?

Som jag nämnde tidigare kan en modern PC med kraftfulla GPU (ja, grafikkort) programmeras för att beräkna ungefär en miljard hashar per sekund. På så sätt kan de använda en brute force attack för att försöka varje enskilt lösenord.

Du kanske tror att det krävs minst 8 tecken långt lösenord kan hålla det säkert från en brute force attack, men låt oss avgöra om det verkligen är fallet:

  • Om lösenordet kan innehålla små bokstäver, stora bokstäver och nummer, är det 62 (26 + 26 + 10) möjliga tecken.
  • En 8 tecken lång sträng har 62 ^ 8 möjliga versioner. Det är lite över 218 biljoner.
  • Med en hastighet på 1 miljard hash per sekund, som kan lösas på cirka 60 timmar.

Och för 6 tecken långa lösenord, vilket också är ganska vanligt, skulle det ta under 1 minut.

Känn dig fri att kräva 9 eller 10 tecken långa lösenord, men du kan börja irritera några av dina användare.

Hur kan detta förhindras?

Använd en långsammare hashfunktion.

Tänk dig att du använder en hash-funktion som bara kan köra 1 miljoner gånger per sekund på samma hårdvara, istället för 1 miljard gånger per sekund. Det skulle då ta angriparen 1000 gånger längre för att brute force a hash. 60 timmar skulle bli nästan 7 år!

Ett sätt att göra det skulle vara att genomföra det själv:

funktion myhash ($ lösenord, $ unique_salt) $ salt = "f # @ V) Hu ^% Hgfds"; $ hash = sha1 ($ unique_salt. $ lösenord); // gör det ta 1000 gånger längre för ($ i = 0; $ i < 1000; $i++)  $hash = sha1($hash);  return $hash; 

Eller du kan använda en algoritm som stöder en "kostnadsparameter", som BLOWFISH. I PHP kan detta göras med hjälp av krypta() fungera.

funktion myhash ($ lösenord, $ unique_salt) // saltet för blowfish ska vara 22 tecken lång returkryptering ($ lösenord, $ 2a $ 10 $ '. $ unique_salt); 

Den andra parametern till krypta() funktionen innehåller några värden separerade av dollar tecknet ($).

Det första värdet är "$ 2a", vilket indikerar att vi kommer att använda BLOWFISH-algoritmen.

Det andra värdet, "$ 10" i det här fallet är "kostnadsparametern". Detta är bas-2-logaritmen av hur många iterationer den ska köra (10 => 2 ^ 10 = 1024 iterationer.) Detta nummer kan variera mellan 04 och 31.

Låt oss köra ett exempel:

funktion myhash ($ lösenord, $ unique_salt) return crypt ($ lösenord, $ 2a $ 10 $ '. $ unique_salt);  funktion unique_salt () return substr (sha1 (mt_rand ()), 0,22);  $ password = "verysecret"; echo myhash ($ lösenord, unique_salt ()); // resultat: $ 2a $ 10 $ dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC

Den resulterande hash innehåller algoritmen ($ 2a), kostnadsparametern ($ 10) och det 22 tecken-salt som användes. Resten av det är den beräknade hasen. Låt oss köra ett test:

// antar att detta drogs från databasen $ hash = '$ 2a $ 10 $ dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC'; // antar att det här är lösenordet som användaren angav för att logga in igen i $ password = "verysecret"; om (check_password ($ hash, $ lösenord)) echo "Access Granted!";  else echo "Access Denied!";  funktion check_password ($ hash, $ lösenord) // första 29 tecken innehåller algoritm, kostnad och salt // låt oss kalla det $ full_salt $ full_salt = substr ($ hash, 0, 29); // köra hashfunktionen på $ password $ new_hash = crypt ($ lösenord, $ full_salt); // returnerar sann eller falsk retur ($ hash == $ new_hash); 

När vi kör detta ser vi "Access Granted!"


8. Sätta det tillsammans

Med alla ovanstående i åtanke, låt oss skriva en verktygsklass baserad på vad vi lärt oss hittills:

klass PassHash // blowfish privat statisk $ algo = '$ 2a'; // kostnadsparameter privat statisk $ kostnad = '$ 10'; // främst för internt bruk statisk statisk funktion unique_salt () return substr (sha1 (mt_rand ()), 0,22);  // Detta kommer att användas för att generera en hash-statisk statisk funktionshastighet ($ lösenord) return crypt ($ lösenord, själv :: $ algo. själv :: $ kostnad. '$'. själv :: unique_salt ());  // detta kommer att användas för att jämföra ett lösenord mot en hash-statisk statisk funktion check_password ($ hash, $ lösenord) $ full_salt = substr ($ hash, 0, 29); $ new_hash = crypt ($ lösenord, $ full_salt); returnera ($ hash == $ new_hash); 

Här är användningen under användarregistrering:

// inkludera klassens krav ("PassHash.php"); // läs all form inmatning från $ _POST // ... // gör din vanliga form validering saker // ... // hash lösenordet $ pass_hash = PassHash :: hash ($ _ POST ['lösenord']); // lagra all användarinformation i DB, med undantag för $ _POST ['lösenord'] // lagra $ pass_hash istället // ... 

Och här är användningen under en användarinloggningsprocess:

// inkludera klassens krav ("PassHash.php"); // läs all form inmatning från $ _POST // ... // hämta användarrekordet baserat på $ _POST ['användarnamn'] eller liknande // ... // kolla lösenordet som användaren försökte logga in med om (PassHash :: check_password ( $ användare ['pass_hash'], $ _POST ['lösenord']) // bevilja åtkomst // ... annat // neka åtkomst // ...

9. En kommentar om blowfish tillgänglighet

Blowfish-algoritmen kan inte implementeras i alla system, även om det är ganska populärt nu. Du kan kontrollera ditt system med den här koden:

om (CRYPT_BLOWFISH == 1) echo "Yes";  annat echo "nej"; 

Men från PHP 5.3 behöver du inte oroa dig; PHP skickas med denna implementering inbyggd.


Slutsats

Denna metod för hashing-lösenord bör vara tillräckligt solid för de flesta webbapplikationer. Som sagt, glöm inte: du kan också kräva att dina medlemmar använder starkare lösenord, genom att tillämpa minsta längder, blandade tecken, siffror och specialtecken.

En fråga till dig, läsare: hur har du lösenord? Kan du rekommendera några förbättringar under denna implementering?