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.

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 el | timingSafeEqual |
|---|---|---|---|
| 64 B | 4,8 ns | 6,4 ns | 44 ns |
| 256 B | 5,8 ns | 18,9 ns | ~170 ns |
| 1 KiB | 5,3 ns | 47 ns | ~650 ns |
| 4 KiB | 8 ns | 275 ns | ~2,6 µs |
| 64 KiB | 12,8 ns | 4,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
- V8:
src/objects/string.cc–String::SlowEquals - V8:
src/builtins/builtins-string-gen.cc–StringEqual_Core,StringEqual_FastLoop - Node.js dokumentáció:
crypto.timingSafeEqual(a, b) - GitHub Docs: Validating webhook deliveries
- Crosby, Wallach, Riedi: Opportunities and Limits of Remote Timing Attacks (ACM TISSEC, 2009)
- Nate Lawson: Timing attack in Google Keyczar library (2009)