Bcrypt hash se rozbije při SSH transportu (double shell expansion)
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Admin resetne uživateli heslo přímo v DB (např. po žádosti o reset přes support). Script reportne UPDATE 1, hash je zapsaný. Uživatel ale stále nemůže login s novým heslem — bcrypt.compare(newPassword, dbHash) vrací false. Admin si myslí že je něco s frontendem nebo session cache, restartuje appku, čistí cookies — nic nepomáhá.
Při ručním prozkoumání DB se ukáže, že password_hash v DB nezačíná $2b$12$ jak by měl, ale je to nějaký kratší zkomolený string typu b2.Z2X6uheQPSGSzYtsSZYgRb2DlmKD2vne2gm0ce.
Root cause
Sekce “Root cause”Bcrypt hash obsahuje znaky $2b, $12, $<další> — to jsou pro shell proměnné ($VAR). Když ho admin pošle přes SSH command-line argument, hodnota se expanduje DVAKRÁT:
- Lokální shell — pokud je hash v
"$HASH"(uvozovky), tady to ještě přežije, expandovaná je jen samotná proměnná$HASH. - Vzdálený shell — SSH předá celý příkaz vzdálenému shellu jako string, ten ho parsuje znovu. Tady
$2b,$12, atd. neexistují jako proměnné → expandují se na prázdno → z$2b$12$Vx1q7A0R...zbydeVx1q7A0R...minus všechno za dalším$.
# ŠPATNĚ — hash se rozbije:HASH='$2b$12$Vx1q7A0RD9OomehVDUM1iuinYAy2ewejNrYzb6VCdBR8elkBdqfym'ssh user@host "psql -c \"UPDATE users SET password_hash='$HASH' WHERE email='x'\""# V DB skončí: b2.Z2X6uheQPSGSzYtsSZYgRb2DlmKD2vne2gm0ce# ^ nezačíná $2b$12$ → bcrypt.compare vždy false
# Stejně špatně — i s psql -v variable, i s escapováním:ssh user@host "psql -v hash=\"'$HASH'\" -c \"...\"" # taky rozbijessh user@host "psql <<EOFUPDATE ... '$HASH' ...EOF" # taky rozbije (heredoc se parsuje na vzdálené straně)Argument SSH je vždy string, který vzdálený shell pre-parsuje. Žádný počet zpětných lomítek nebo uvozovek to nezachrání, dokud je hash součástí argumentu.
Fix
Sekce “Fix”Posílat hash přes stdin pipe — stdin nejde shell argument parsingem:
# SPRÁVNĚ:HASH=$(node -e "console.log(require('bcryptjs').hashSync('newPassword', 12))")printf "UPDATE users SET password_hash='%s' WHERE email='x';\n" "$HASH" \ | ssh user@host "psql -U user -d db -h localhost"Nebo z Node.js přes child_process.spawnSync s input: parametrem:
import { spawnSync } from 'node:child_process'import bcrypt from 'bcryptjs'
const hash = bcrypt.hashSync(password, 12)const sql = `UPDATE users SET password_hash='${hash}' WHERE email='${email}';\n`
spawnSync('ssh', ['user@host', 'psql -U user -d db -h localhost'], { input: sql, // ← stdin, bez shell parsingu encoding: 'utf8',})Klíčové: vždy po resetu verifikovat bcrypt.compare, jinak nezjistíš, že hash je rozbitý, dokud se uživatel neozve. Stačí druhý dotaz po updatu:
const dbHash = readBackHashFromDB(email)if (!bcrypt.compareSync(password, dbHash)) throw new Error('Hash transport corrupted')Jak se tomu vyvarovat v jiných systémech
Sekce “Jak se tomu vyvarovat v jiných systémech”- Detection: grep své reset-password scripty na vzor
ssh.*password_hashnebossh.*"\$— pokud posíláš hash v argumentu, máš problém. V DB ad-hoc kontrola:SELECT email FROM users WHERE password_hash NOT LIKE '$2_$%$%'(správný bcrypt prefix; výsledky = rozbité hashe). - Anti-pattern: jakýkoli admin/devops nástroj, který bere heslo nebo secret a předává ho přes SSH/docker exec/kubectl exec command-line argumentem. Stejný problém má
kubectl exec -- bash -c "echo $HASH"idocker exec -e HASH=$HASH container psql ...pokud HASH obsahuje$. - Lepší přístup:
- Stdin pipe pro jednorázové secrets.
- Environment variable přes
ssh -o SendEnv=…(vyžaduje config na obou stranách) nebo-o "SetEnv=HASH=…"— pak na vzdálené straně$HASHčte env proměnnou. - Souborový transport —
scphash do temp file schmod 600, vzdálený script ho přečte, smaže. - Vždy verifikovat — admin scripty pro reset hesla musí mít round-trip check (
bcrypt.comparepo updatu). TichýUPDATE 1negarantuje nic.
Sister bugs / související
Sekce “Sister bugs / související”- Stejný princip láme i jiné secrets s
$: některé API tokeny, JWT (xxx.yyy.zzzje ok, alexxx$yyyne), Stripe webhook signing keys, base64 stringy obsahující náhodou$. - Argon2 hashe (
$argon2id$v=19$...) mají identický problém — víc$segmentů, vznikne ještě hůř zkomolený hash. - Související: PHP
crypt(), Pythonpasslib, Rubybcrypt— všechny generují stejný$alg$cost$salt$hashformát, takže stejná past pro všechny stacky.