Přeskočit na obsah

Bcrypt hash se rozbije při SSH transportu (double shell expansion)

import { Aside } from ‘@astrojs/starlight/components’;

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.

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:

  1. Lokální shell — pokud je hash v "$HASH" (uvozovky), tady to ještě přežije, expandovaná je jen samotná proměnná $HASH.
  2. 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... zbyde Vx1q7A0R... minus všechno za dalším $.
Terminál
# Š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 rozbije
ssh user@host "psql <<EOF
UPDATE ... '$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.

Posílat hash přes stdin pipe — stdin nejde shell argument parsingem:

Terminál
# 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_hash nebo ssh.*"\$ — 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" i docker exec -e HASH=$HASH container psql ... pokud HASH obsahuje $.
  • Lepší přístup:
    1. Stdin pipe pro jednorázové secrets.
    2. 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.
    3. Souborový transportscp hash do temp file s chmod 600, vzdálený script ho přečte, smaže.
    4. Vždy verifikovat — admin scripty pro reset hesla musí mít round-trip check (bcrypt.compare po updatu). Tichý UPDATE 1 ne­garantuje 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.zzz je ok, ale xxx$yyy ne), 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(), Python passlib, Ruby bcrypt — všechny generují stejný $alg$cost$salt$hash formát, takže stejná past pro všechny stacky.
Přidal aiarchitekt.cz · 31. 5. 2026 2:00
Provozuje aiarchitekt.cz