Modelový prípad 04Bezpečný vývoj softvéru

Heslá, relácie a obnova hesla v klientskom portáli

Portál ukladal heslá ako SHA-256 bez soli a prihlásenie sa nedalo odvolať. Prechod na Argon2id bez hromadného resetu hesiel a relácie, ktoré sa dajú ukončiť.

Firma
Účtovná kancelária, 11 zamestnancov, zhruba 150 klientov
Aplikácia
Portál, kde klienti nahrávajú doklady a sťahujú výkazy
Tím
Portál postavil externista, ktorý už nie je k dispozícii; nový vývojár na čiastočný úväzok
Technológie
Node.js, PostgreSQL, React, jeden VPS
Obmedzenia
Hromadný reset hesiel by zahltil kanceláriu telefonátmi

01Východiskový stav

Heslá boli v tabuľke users uložené ako hexadecimálny SHA-256 bez soli. Po prihlásení server vydal JWT s platnosťou 30 dní, frontend si ho uložil do localStorage a posielal v hlavičke Authorization. Odhlásenie token iba zmazalo z prehliadača.

Odkaz na obnovu hesla obsahoval náhodný token uložený v databáze v čitateľnej podobe, bez obmedzenej platnosti.

  1. Prehliadač (React, JWT v localStorage)
  2. Nginx
  3. Node.js API
  4. PostgreSQL (používatelia, doklady)

02Problém

Nový vývojár mal do portálu pridať elektronické podpisovanie. Pri čítaní kódu prihlasovania našiel tri súvisiace slabiny a navrhol ich opraviť skôr, než pribudne ďalšia citlivá funkcia.

Heslá: SHA-256 je rýchla hašovacia funkcia navrhnutá na kontrolu integrity, nie na heslá. Kto by získal kópiu tabuľky, zo zálohy alebo cez inú chybu, vie na bežnej grafickej karte skúšať miliardy kandidátov za sekundu. Bez soli majú rovnaké heslá rovnaký hash a dajú sa hľadať v predpočítaných tabuľkách.

Relácie: token sa nedal odvolať. Odhlásenie, zmena hesla ani zablokovanie klienta ho neukončili, platil celých 30 dní. V localStorage je dostupný každému JavaScriptu na stránke, takže chyba typu XSS by ho umožnila ukradnúť a použiť z iného počítača.

Obnova hesla: kto by videl databázu alebo starý e-mail s odkazom, mohol by nastaviť heslo cudzieho účtu aj po mesiacoch.

O zneužití firma nevie. Ide o slabiny, ktoré zväčšujú dopad inej chyby: úniku databázy, XSS alebo ukradnutého e-mailu.

03Technická príčina

Pôvodný autor zamenil hašovanie na kontrolu integrity s ukladaním hesiel. Na heslá sú určené zámerne pomalé funkcie s vlastnou soľou pre každé heslo a nastaviteľnou náročnosťou: Argon2id, scrypt alebo bcrypt. Hašovanie pritom nie je šifrovanie: hash sa nedá rozšifrovať, dá sa iba hádať, a rýchla funkcia robí hádanie lacným.

JWT je podpísaný, takže ho klient nevie zmeniť, ale server si oň po vydaní nevedie záznam. Bez zoznamu odvolaných tokenov ho nemá ako zneplatniť pred uplynutím platnosti. Pre prihlásenie do jednej webovej aplikácie je to zložitosť bez prínosu.

Token na obnovu hesla sa bral ako obyčajný údaj, hoci je to dočasné heslo.

04Vyšetrenie

Prehľad v databáze: koľko účtov zdieľa rovnaký hash. Niekoľko skupín s rovnakým hashom znamenalo rovnaké, pravdepodobne slabé heslo.

SQL
SELECT password_hash, count(*) AS accounts
  FROM users
 GROUP BY password_hash
HAVING count(*) > 1
 ORDER BY accounts DESC;

Súpis kópií tabuľky users: nočné zálohy na externom disku v kancelárii a kópia databázy na notebooku externistu z čias vývoja. Obe sú rovnako citlivé ako produkcia.

Prehľad frontendu na miesta, kde sa vkladá HTML bez escapovania (dangerouslySetInnerHTML). Jedno také bolo pri poznámkach k dokladom.

05Náprava

Heslá bez hromadného resetu: pri nasadení sa každý existujúci SHA-256 hash zabalil do Argon2id, v databáze je teda argon2id(sha256(heslo)). Stará podoba zmizla hneď pre všetkých, bez toho, aby niekto zadával heslo. Pri najbližšom prihlásení sa heslo prehashuje priamo ako argon2id(heslo).

passwords.js
import argon2 from "argon2";
import { createHash } from "node:crypto";
import { db } from "./db.js";

// OWASP Password Storage Cheat Sheet: m = 19 MiB, t = 2, p = 1
const OPTS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");

// one-off, at deploy: wrap every legacy SHA-256 hash, nobody resets anything
export async function wrapLegacyHashes() {
  const { rows } = await db.query(
    "SELECT id, password_hash FROM users WHERE scheme = 'sha256'",
  );
  for (const u of rows) {
    await db.query(
      "UPDATE users SET password_hash = $1, scheme = 'argon2id(sha256)' WHERE id = $2",
      [await argon2.hash(u.password_hash, OPTS), u.id],
    );
  }
}

// at login: verify, and replace a wrapped hash with a direct one
export async function verifyAndUpgrade(user, password) {
  const wrapped = user.scheme === "argon2id(sha256)";
  const ok = await argon2.verify(
    user.password_hash,
    wrapped ? sha256hex(password) : password,
  );
  if (ok && wrapped) {
    await db.query(
      "UPDATE users SET password_hash = $1, scheme = 'argon2id' WHERE id = $2",
      [await argon2.hash(password, OPTS), user.id],
    );
  }
  return ok;
}

Parametre podľa odporúčania OWASP: 19 MiB pamäte, 2 iterácie, 1 vlákno. Staré kópie so SHA-256 hashmi, teda zálohy a notebook externistu, sa zmazali alebo nahradili. Kým existujú, zabalenie tie účty nechráni, lebo útočník s nimi Argon2id preskočí.

Relácie na serveri namiesto JWT: po prihlásení vznikne v databáze záznam relácie s náhodným 256-bitovým identifikátorom a prehliadač dostane iba cookie s ním.

HTTP
Set-Cookie: __Host-sid=<random 256-bit id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800

Pri každom prihlásení vzniká nová relácia, čo bráni fixácii relácie. Odhlásenie záznam zmaže, zmena aj obnova hesla zmažú všetky relácie používateľa, relácia platí najviac 8 hodín. Požiadavky, ktoré menia dáta, navyše overujú hlavičku Origin, lebo SameSite nechráni pred požiadavkami z iných subdomén tej istej domény.

Obnova hesla: token má 32 náhodných bajtov, v databáze je iba jeho SHA-256, platí 30 minút a po použití sa zmaže. Rýchly SHA-256 tu stačí, lebo 256 bitov náhody sa hádaním prelomiť nedá, na rozdiel od hesla, ktoré si vymyslel človek.

Prihlasovanie: neúspešné pokusy sa obmedzujú na účet aj IP adresu narastajúcim oneskorením, nie trvalým zablokovaním, ktoré by útočník mohol zneužiť na zablokovanie cudzích účtov. Poznámky k dokladom sa vykresľujú ako text, nie ako HTML.

06Prečo oprava funguje

Argon2id je zámerne pomalý a náročný na pamäť. Každý pokus o uhádnutie stojí útočníka zhruba toľko ako jedno prihlásenie a pamäťová náročnosť obmedzuje, koľko pokusov zvládne grafická karta naraz. Soľ, ktorú Argon2 generuje pre každé heslo, zaručí, že rovnaké heslá majú rôzny hash a predpočítané tabuľky nefungujú.

Zabalený hash je rovnako drahý na hádanie: útočník s novou tabuľkou musí pri každom pokuse prejsť celým Argon2id, SHA-256 vo vnútri mu nepomôže, pokiaľ nemá aj starú kópiu.

Reláciu na serveri stačí zmazať a prístup okamžite končí. HttpOnly cookie JavaScript neprečíta, takže XSS ju nevie odniesť na iný počítač. __Host- prehliadač prijme iba so Secure, Path=/ a bez Domain, takže ju nemôže podstrčiť iná subdoména.

V databáze je iba hash tokenu na obnovu. Kto ju vidí, token z neho nezíska.

Čo to nerieši: HttpOnly nezabráni XSS konať v mene používateľa priamo v jeho otvorenom prehliadači. Pomalý hash slabé heslo neochráni úplne, iba spomalí jeho uhádnutie. Nič z toho nechráni pred phishingom.

07Overenie

  • Po migrácii v databáze nezostal žiadny riadok so schémou sha256 a dopyt na rovnaké hashe nevracia nič.
  • Automatické testy: prihlásenie pred aj po prehashovaní, zmena hesla ukončí reláciu v druhom prehliadači, odkaz na obnovu po 30 minútach aj po použití vráti chybu.
  • V nástrojoch prehliadača má cookie všetky atribúty a v localStorage nie je nič, čo súvisí s prihlásením.
  • Počet účtov so zabalenou schémou sa dá kedykoľvek zistiť jedným dopytom a s každým prihlásením klesá.

08Výsledky

Opravené

  • V databáze nie sú hashe SHA-256 bez soli.
  • Odhlásenie a zmena hesla naozaj ukončia prístup.
  • Odkaz na obnovu hesla je jednorazový a platí 30 minút.

Znížené riziko

  • Únik databázy alebo zálohy by útočníkovi dal výrazne pomalšiu cestu k heslám.
  • Chyba typu XSS už nevie ukradnúť prihlásenie na iný počítač.

Ostáva otvorené

  • Účty, ktoré sa odvtedy neprihlásili, majú heslo v zabalenej podobe.
  • Klienti môžu mať stále slabé heslá.

09Obmedzenia

  • Slabé heslo padne aj pri Argon2id, iba pomalšie. Pomôže kontrola nového hesla proti zoznamu uniknutých hesiel.
  • Kým nie je zapnuté dvojfaktorové overenie, prihlásenie chráni iba heslo a phishing funguje.
  • Relácie v databáze znamenajú jeden dopyt navyše pri každej požiadavke. Pri tejto veľkosti portálu je to zanedbateľné.

10Poučenia

  • Na heslá patrí Argon2id, scrypt alebo bcrypt, nie SHA-256 ani MD5.
  • Starú schému sa dá opustiť bez resetu hesiel: zabaliť hneď, prehashovať pri prihlásení.
  • Prihlásenie, ktoré sa nedá odvolať, zlyhá práve vtedy, keď ho treba ukončiť.
  • Token na obnovu hesla je dočasné heslo: ukladať iba hash, krátka platnosť, jedno použitie.
  • Staré kópie databázy sú rovnako citlivé ako produkcia.

11Ďalšie kroky pre malú firmu

  1. 1.Dvojfaktorové overenie (TOTP) povinne pre zamestnancov kancelárie, dobrovoľne pre klientov.
  2. 2.Kontrola nových hesiel proti zoznamu uniknutých hesiel, napríklad cez rozhranie Pwned Passwords, ktoré vďaka k-anonymite nedostane celé heslo ani jeho celý hash.
  3. 3.Hlavička Content-Security-Policy, ktorá obmedzí dopad budúcej XSS.
  4. 4.Po roku požiadať o nové heslo účty, ktoré majú stále zabalenú schému.
Ozvite sa

Spoznávate v tom svoju firmu?

Napíšte nám, čoho sa to týka. Ozveme sa do jedného pracovného dňa a povieme, či je to práca pre nás, aj keď odpoveď bude nie.