Ne hasonlíts titkot ===-szal – csak épp nem azért, amiért eddig hitted

A tankönyv szerint a === az első eltérő bájtnál kilép, és a válaszidőből bájtonként kitalálható a titok. Megmértem a V8-on: a tartalomról 1–2 nanoszekundum szivárog, a hosszról sokkal több – a timingSafeEqual mégis kötelező.

Van az a code review komment, amit előbb-utóbb mindenki megkap: „titkot soha ne hasonlíts ===-szal, mert timing attack”. A magyarázat is jár mellé: az összehasonlítás az első eltérő bájtnál kilép, így a válaszidőből kiderül, hány karaktert találtunk el, a támadó pedig bájtonként végigsétál a teljes API-kulcson. Kerek sztori, évekig én is így adtam tovább. Aztán nemrég valaki megkérdezte, mekkora is ez az időkülönbség valójában – és rájöttem, hogy fogalmam sincs. Megmértem. A tankönyvi kép nagyjából minden részletében pontatlan. A végkövetkeztetése viszont marad, csak jobb indoklást érdemel.

Diagram két bájtsorozat összehasonlításáról: felül a korai kilépéses összehasonlítás az első eltérő, piros bájtnál megáll, a stopper alig mutat eltelt időt; alul a konstans idejű összehasonlítás minden bájtot végigvizsgál, teli stopperrel.

A tankönyvi támadás nem légből kapott

2009-ben Nate Lawson a Google Keyczar kriptókönyvtárában talált pontosan ilyen hibát: a HMAC-ellenőrzés Pythonban egy sima ==-vel, Javában Arrays.equals-szal hasonlította össze a várt és a kapott aláírást – mindkettő az első eltérő bájtnál kilép. A bejelentésben le is írja a receptet: minél tovább tart a válasz, annál több bájtot találtunk el. A javításból lett a máig használt minta: XOR-olj végig minden bájtot, és csak a legvégén dönts. Fontos apróság, amin sokan átsiklanak: Lawson a támadást localhoston demonstrálta, hálózaton át nem.

Az elmélet tehát áll: ha az összehasonlítás korán kilép, és a bájtonkénti különbség kimérhető, a titok pozíciónként kitalálható. A kérdés csak az, hogy ez a „ha” mennyire teljesül egy mai JavaScript-futtatókörnyezetben.

Mit csinál valójában a V8?

A === stringekre nem egy naiv bájtonkénti ciklus. A V8 először pointert hasonlít (ugyanaz az objektum?), aztán hosszt – vagyis a rossz hosszúságú tipp azonnal, a tartalom érintése nélkül megbukik. Ezután jön két gyorsítósáv, amiről ritkán esik szó. Ha mindkét string internalizált – vagyis bekerült a V8 belső stringtáblájába, ami például minden object-property-kulccsal megtörténik –, és nem ugyanaz az objektum, a motor bele sem néz a tartalomba, azonnal „nem egyenlő”-t mond: két azonos tartalmú internalizált string nem létezhet két példányban. A C++ runtime útvonal (String::SlowEquals) ráadásul a cache-elt hasheket is összeveti: ha mindkét stringnek ki van már számolva a hashe, és eltér, kész is, konstans időben.

És amikor tényleg tartalmat kell hasonlítani? A JIT-elt kódból hívott builtin (StringEqual_FastLoop) azonos kódolású, nem külső stringekre 8 bájtos szavanként hasonlít, nem bájtonként – a chunk mérete a tagged pointer mérete, a maradékot nullázott padding tölti ki. A C++ oldal pedig memcmp-t hív, amit a glibc SIMD-del, 16–32 bájtos falatokban darál. A „bájtonként szivárog” kép tehát már az architektúra szintjén sem áll: a korai kilépés létezik, de a felbontása legfeljebb 8 bájt, az ára meg néhány órajel.

Megmértem

Node 22-n, x86-64 Linuxon mértem, process.hrtime.bigint()-tel: egy 64 karakteres hexa „titok”, ellene 4000 friss (nem internalizált) tipp, 40–50 kör, medián. Az első blokk a klasszikus támadási forgatókönyv: a tipp az első, illetve az utolsó karakternél tér el.

Eset (64 karakteres titok)Medián / összehasonlítás
===, eltérés az 1. karakternél~5–7 ns
===, eltérés a 64. karakternél~6–7 ns
===, teljes egyezés~7 ns
===, rossz hosszúságú tipp~2 ns
crypto.timingSafeEqual, bárhol tér el~44 ns

A két „szivárgó” eset között 1–2 nanoszekundum van, és futásonként hol az egyik, hol a másik a gyorsabb – ez a mérési zaj szintje, egy gépen belül, forró ciklusban. Ami viszont stabilan látszik: a rossz hosszúságú tipp válasza háromszor gyorsabb. A === nem a titok tartalmát szivárogtatja a leghangosabban, hanem a hosszát – erről a tankönyvi sztori jellemzően egy szót sem szól.

A korai kilépés persze létezik, csak nem ott, ahol keressük. Növekvő titokméretnél így fest az első bájtnál, illetve az utolsó bájtnál eltérő tipp:

Hossz===, 1. bájt tér el===, utolsó bájt tér eltimingSafeEqual
64 B4,8 ns6,4 ns44 ns
256 B5,8 ns18,9 ns~170 ns
1 KiB5,3 ns47 ns~650 ns
4 KiB8 ns275 ns~2,6 µs
64 KiB12,8 ns4,6 µs~38 µs

Pár száz bájt fölött a jel már egy gépen belül tisztán mérhető, 64 KiB-nál pedig ordít. Egy 32–64 bájtos API-kulcsnál vagy HMAC-aláírásnál viszont – és a titkaink ekkorák – a tartalomfüggő különbség nanoszekundumos nagyságrendű.

Jó, de kimérhető ez hálózaton át?

Erre van klasszikus válasz: Crosby, Wallach és Riedi 2009-es tanulmánya szerint gondos statisztikai szűréssel interneten át 15–100 µs, helyi hálózaton legjobb esetben ~100 ns pontossággal mérhető a szerver válaszideje. A mi 1–2 nanoszekundumos jelünk ennél ötven-százszor kisebb – LAN-on is a mérhetőség alatt van, interneten át pedig esélytelen. Ezen a ponton hátra lehetne dőlni.

Ne dőlj hátra. A margó ugyanis nem a te érdemed, hanem a V8 optimalizálásainak mellékterméke – és nem garantálja senki. Egy kézzel írt, karakterenként charAt-ozó összehasonlítás sokszorosan lassabb lépésekkel dolgozik. Ha az egyik string két bájtos kódolású (mondjuk becsúszott egy nem latin-1 karakter), a V8 a lassú, karakterenkénti ágra esik vissza. Más futtatókörnyezet, más nyelv, más arányok – a Keyczar pont így bukott el. És a támadó nem mindig az interneten túlról jön: egy co-tenant VM, egy másik konténer ugyanazon a hoszton, vagy egy SSRF-en át localhostról mérő kód egészen más pontossággal dolgozik.

A timingSafeEqual – és amit köré szokás elrontani

A crypto.timingSafeEqual konstans idejű: a méréseimben 64 bájtra ~44 ns, függetlenül attól, hol tér el a tartalom. Hét darab === ára. Ennyibe kerül a biztosítás.

Van viszont egy éles sarka, amibe rendszeresen belesétálnak: eltérő hosszúságú bufferekre nem false-t ad, hanem dob (ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH). A naiv kód, ami közvetlenül a kérésből jövő fejlécet adja át neki, támadó által irányított hosszal 500-as hibákat termel – a „biztonságos” összehasonlításból így lesz crash. A GitHub webhook-dokumentációja szó szerint kimondja: „Never use a plain == operator” – de a helyes minta a hosszellenőrzést is tartalmazza:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(rawBody, signatureHeader, secret) {
  const expected =
    'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(signatureHeader ?? '', 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
}

A hosszellenőrzés itt nem szivárogtat semmi értékeset: a várt aláírás formátuma (sha256= + 64 hexa karakter) publikus, a hossza nem titok. Ha olyan értéket hasonlítasz, aminek a hossza is védendő, a bevett trükk, hogy előbb mindkét oldalt ugyanazzal a kulccsal HMAC-eled, és a fix hosszú digesteket hasonlítod. A Node dokumentációja egyébként odaírja a lábjegyzetet, amit kevesen olvasnak el: a timingSafeEqual attól még nem teszi konstans idejűvé a körülötte lévő kódot.

Akkor most használhatok ===-t titokra?

Nem. De nem azért, mert valaki az interneten túlról bájtonként kiolvassa az API-kulcsod – a számok alapján ez a V8-on, tipikus titokméretnél, mesébe illő forgatókönyv. Hanem mert a === hangosan szivárogtatja a hosszt; mert a nanoszekundumos margó a motor pillanatnyi optimalizálásain múlik, nem rajtad; mert a következő refaktor után lehet, hogy már nem === lesz ott, hanem valami sokkal beszédesebb; és mert 44 nanoszekundumos áron kapsz egy olyan tulajdonságot, amit nem kell újra végiggondolni minden release-nél. Production-ben minden titok-összehasonlítás timingSafeEqual – webhook-aláírás, API-kulcs, session token –, az API-kulcsot pedig amúgy is hashelve tárold, és a digesteket hasonlítsd.

A tankönyvi sztori tehát részleteiben hibás, következtetésében igaz. A === nem bájtonként árulja el a titkod: nyolcasával teszi, mérhetetlenül halkan – miközben a hosszát kikiabálja az utcára. A timingSafeEqual meg ott van mellette, hét összehasonlításnyi áron. Ennél olcsóbb biztosítást nem fogsz kötni idén.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük