비밀번호 해시 입문: bcrypt 와 Argon2id 를 실측으로 고르기

보안 기초 강좌 2. 솔트 없는 SHA-256 을 직접 만든 20단어 목록으로 깨 보고, bcrypt·Argon2id·scrypt·PBKDF2 의 1회 비용을 실측합니다. bcrypt 72바이트 제한도 PHP 8.5 에서 재현.

시리즈: 보안 기초 강좌 2/6 (초급). 처음으로 회원가입 폼을 만드는 사람, 또는 이미 있는 사용자 테이블을 넘겨받았는데 비밀번호가 안전하게 저장돼 있는지 확신이 없는 사람을 위한 글입니다. 다 읽고 나면 PHP 와 Python 으로 비밀번호를 올바르게 저장·대조할 수 있고, '충분히 느리다'가 몇 밀리초인지 말할 수 있으며, 유출 사고 보고서에 단골로 나오는 실수를 피할 수 있습니다.

강좌 1의 마지막 숫자를 떠올려 보세요. 노트북 코어 하나가 SHA-256 을 초당 약 789만 번 계산합니다. 이번 강좌는 그 숫자가 'DB 가 유출된 날' 무엇을 뜻하는지 봅니다. 한국에서는 개인정보보호법과 관련 기준에 따라 비밀번호를 일방향 암호화해서 저장하라는 요구를 일찍부터 듣게 되는데, 이 '일방향 암호화'가 바로 이 글에서 다루는 비밀번호 해시입니다. 문제는 "SHA-256 도 일방향이니 됐다"에서 멈추는 경우가 많다는 점입니다. 정보보안기사 시험에서도 솔트, 키 스트레칭, 레인보 테이블은 단골 주제인데, 이 글은 그 개념을 실제 숫자로 확인합니다. (법령의 구체적인 요건은 전문가에게 확인하세요. 여기서는 일반론만 다룹니다.)

실행 환경은 모두 Apple Silicon Mac(M1 Pro), PHP 8.5.6(password_hash 의 bcrypt 와 Argon2id, libsodium 1.0.22), Python 3.9.6, OpenSSL 4.1.0 입니다. 여기 나오는 '공격'은 전부 내 컴퓨터 안에서, 직접 만든 사용자와 직접 만든 20단어 목록을 대상으로 했습니다.

비밀번호 저장 5단계: 최악부터 정답까지

저장하는 값 예 DB 를 훔친 사람이 얻는 것
평문 saranghae1 모든 비밀번호를 즉시. 재사용했다면 다른 서비스 것까지
양방향 암호화 비밀번호의 AES 키까지 새는 순간 전원 것(키는 대개 같은 서버에 있다)
빠른 해시, 솔트 없음 sha256("saranghae1") 약한 비밀번호는 마이크로초 만에. 같은 비밀번호는 같은 해시로 드러남
빠른 해시 + 솔트 sha256(salt + pw) 약한 비밀번호는 여전히 마이크로초 만에. 한 명씩일 뿐
느린 비밀번호 해시 bcrypt, Argon2id, scrypt, PBKDF2 추측 1회에 수십~수백 밀리초. 강한 비밀번호는 살아남음

용어 두 개를 먼저 정리합니다. 솔트는 사용자마다 다른 무작위 값으로, 비밀번호와 함께 해시하고 해시 옆에 저장합니다. 비밀이 아닙니다. 역할은 같은 비밀번호를 쓰는 두 사람이 서로 다른 해시를 갖게 해서, 공격자가 한 명씩 따로 공격하게 만드는 것입니다. 느린 비밀번호 해시(키 유도 함수, KDF 라고도 합니다)는 일부러 작업을 많이 반복하거나 메모리를 많이 쓰는 함수이고, 하드웨어가 빨라지면 올릴 수 있는 비용(cost) 설정이 있습니다. 반복으로 느리게 만드는 것을 정보보안기사 교재에서는 '키 스트레칭'이라고 부릅니다.

5분 만에 첫 password_hash

PHP 에는 5.5 부터 올바른 도구가 들어 있습니다. password_hash() 가 솔트를 만들고 bcrypt(또는 Argon2id)를 실행해서, 열 하나에 저장할 문자열 하나를 돌려줍니다.

<?php
$pw = 'tiger2026';
$h1 = password_hash($pw, PASSWORD_DEFAULT);
$h2 = password_hash($pw, PASSWORD_DEFAULT);
echo $h1, "\n", $h2, "\n";
var_dump($h1 === $h2);                                  // 같은 비밀번호, 다른 솔트
var_dump(password_hash($pw, PASSWORD_DEFAULT) === $h1); // 초보자가 자주 하는 비교
var_dump(password_verify($pw, $h1), password_verify('tiger2025', $h1));
print_r(password_get_info($h1));
$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq
$2y$12$29U5HBehXh1Llpb/miK5W.itq90n0dJTPPg7FSeA77my08szzXrES
bool(false)
bool(false)
bool(true)
bool(false)
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )

)

문자열 읽는 법: $2y$ 는 bcrypt, 12 는 비용(2¹² 라운드), 이어지는 22글자가 솔트, 나머지가 해시입니다. 대조에 필요한 정보가 전부 안에 있습니다. 그래서 세 번째 줄이 중요합니다. 로그인 입력을 해시해서 문자열을 비교하면 안 됩니다. 저장값에서 솔트와 비용을 읽어 주는 password_verify() 를 호출합니다.

흐름도. 가입: 비밀번호를 password_hash 에 넣으면 무작위 솔트와 비용이 붙고, $2y$12$ 뒤에 솔트와 해시가 이어지는 문자열이 열 하나에 저장된다. 로그인: 입력한 비밀번호와 저장 문자열을 password_verify 에 넣는다. false 면 로그인 실패. true 면 password_needs_rehash 로 알고리즘이나 비용이 낡았는지 보고, 낡았으면 현재 설정으로 다시 해시해 행을 갱신한다. 어느 쪽이든 로그인 성공.
password_hash, password_verify, password_needs_rehash 를 쓰는 가입과 로그인. 솔트와 비용이 저장 문자열 안에 들어 있어서 DB 열은 하나면 된다.

로컬 해독 데모: 솔트 없는 SHA-256 대 20단어 목록

먼저 옛날의 나쁜 방식을 실제 공격자와 같은 순서로 공격합니다. 사용자도 목록도 직접 만든 것이고, 컴퓨터 밖으로는 아무것도 나가지 않습니다.

# 로컬 전용 데모: 직접 만든 사용자와 20단어 목록
import hashlib, time
users = {'kim': 'saranghae1', 'lee': 'qwer1234', 'park': 'saranghae1', 'choi': 'V7#qLm2!pZ9x'}
words = ("password 123456 qwerty qwer1234 asdf1234 saranghae saranghae1 1q2w3e4r hangul daebak "
         "fighting seoul2026 iloveyou admin welcome letmein hello summer dragon monkey").split()
leak = {u: hashlib.sha256(p.encode()).hexdigest() for u, p in users.items()}  # bad old storage
for u, h in leak.items():
    print(f"{u:7s} {h[:20]}...")
t = time.perf_counter_ns()
table = {hashlib.sha256(w.encode()).hexdigest(): w for w in words}  # 20 hashes, once
for u, h in leak.items():
    print(f"{u:7s} -> {table.get(h, '(not in list)')}")
print(f"{len(words)} words, {len(leak)} users, {(time.perf_counter_ns() - t)/1000:.0f} µs")
kim     169a90acc84601e38752...
lee     4d4f26369171994f3a46...
park    169a90acc84601e38752...
choi    3655812bce4e006ad2a4...
kim     -> saranghae1
lee     -> qwer1234
park    -> saranghae1
choi    -> (not in list)
20 words, 4 users, 15 µs

눈여겨볼 점이 두 가지입니다. kim 과 park 의 해시가 같아서, 공격자는 아무것도 풀기 전에 두 사람이 같은 비밀번호라는 것을 압니다. 그리고 목록은 한 번만 해시해서 모든 사용자에게 재사용했습니다. 이것을 거대한 규모로 미리 해 두는 것이 레인보 테이블 같은 사전 계산 표입니다.

사용자마다 16바이트 무작위 솔트를 붙이자 중복은 사라졌습니다(네 해시가 모두 달라짐). 하지만 공격자는 목록을 사용자마다 한 번씩 돌리면 그만입니다. SHA-256 호출 38번, 24마이크로초에 같은 세 사람 것이 나왔습니다. 솔트는 필요하지만 그것만으로는 부족합니다.

이번에는 같은 사용자를 password_hash()(bcrypt, 비용 12)로 저장하고 단어마다 password_verify() 로 공격합니다.

kim     $2y$12$G4W6qt2OPOwRgqR7YCZEfecp1NAQI7clqqbc7IRt7hca7ESoJiQdK
lee     $2y$12$S81HNJGLyB85e.bs/qBtnOvKUhhNtBQNlyR.umWVtNtR91r/LISCq
park    $2y$12$toYTcp6BkcyPzc/KDO1LueprtpJFOEgHehSEeBZw7kWIaOVgBCwrC
choi    $2y$12$GpvUIRe.78dHzkVA/rkaFeBoztla.a9IgpHI203eSxm8U9N6Igwda
kim     -> saranghae1
lee     -> qwer1234
park    -> saranghae1
choi    -> (not in list)
38 password_verify calls, 8.9 s

솔직하게 말하면 bcrypt 는 saranghae1 을 구하지 못했습니다. 어떤 목록이든 상위 20개 안에 드는 비밀번호는 해시가 무엇이든 깨집니다. bcrypt 가 바꾼 것은 가격입니다. 24마이크로초가 8.9초, 약 37만 배가 됐습니다. 수백만 단어짜리 실제 목록이라면 이 배율이 '몇 분'과 '몇 년'의 차이가 됩니다. choi 의 무작위 12자리 비밀번호는 두 공격 모두에서 찾지 못했습니다.

실측: 추측 1회의 비용

각 알고리즘을 코어 하나로 쟀습니다. PHP 행은 워밍업 1회 뒤 11회 실행한 중앙값(SHA-256 은 101회)입니다. scrypt 행과 PBKDF2 한 행은 openssl kdf 결과에서 프로세스 시작 시간 약 6 ms 를 뺐습니다.

해시 1회당 밀리초를 보여 주는 막대그래프. SHA-256 0.0004, Argon2id(19 MiB, t=2) 20.2, bcrypt 비용 10 59.2, PBKDF2-SHA256 60만 회(openssl) 66, Argon2id(64 MiB, t=4) 151.8, bcrypt 비용 12 236.7, scrypt N=2^17(openssl) 301, PBKDF2-SHA256 60만 회(PHP) 793.6, bcrypt 비용 14 942.5.
Apple Silicon Mac(M1 Pro) 코어 하나로 비밀번호를 한 번 해시하는 시간. PHP 8.5.6 에서 11회(SHA-256 은 101회) 실행한 중앙값. openssl 이라고 적은 행은 OpenSSL 4.1 'openssl kdf' 결과에서 프로세스 시작 약 6 ms 를 뺀 값. 여기의 1밀리초는 같은 하드웨어를 쓰는 공격자에게도 추측 한 번당 1밀리초다.

같은 PBKDF2(HMAC-SHA256 60만 회)라도 PHP hash_pbkdf2() 는 794 ms, Python hashlib.pbkdf2_hmac() 은 178 ms, OpenSSL 은 66 ms 였습니다. 로그인은 느린 구현의 값을 치르고, 공격자는 빠른 구현을 씁니다. 반복 횟수는 내 서버의 체감이 아니라 가장 빠른 숫자를 기준으로 정하세요.

같은 데이터를 '코어 하나로 N번 추측하는 데 걸리는 시간'으로 바꾸면 이렇습니다.

저장 방식 (코어 1개, M1 Pro) 추측/초 20단어 목록 100만 단어 목록 영문 소문자 8자리 전부(26⁸)
SHA-256 (openssl) 7,890,000 2.5 µs 0.13초 7.4시간
PBKDF2-SHA256, 60만 회 (openssl) 15.2 1.3초 18.3시간 437년
Argon2id, 64 MiB, t=4 6.6 3.0초 1.8일 1,005년
bcrypt, 비용 12 4.2 4.7초 2.7일 1,567년
scrypt, N=2¹⁷, r=8 3.3 6.0초 3.5일 1,993년

실제 공격자는 GPU 와 여러 대의 컴퓨터를 쓰므로 모든 칸을 큰 수로 나눠서 읽어야 합니다(여기서는 재지 않았습니다). 중요한 것은 행 사이의 비율입니다. Argon2id 와 scrypt 에는 표에 안 보이는 방어가 하나 더 있습니다. 추측 한 번마다 64 MiB, 128 MiB 의 메모리가 따로 필요해서 GPU 의 병렬성이 잘 먹히지 않습니다.

로그인할 때 비용 올리기: password_needs_rehash

PHP 8.4 에서 bcrypt 기본 비용이 10 에서 12 로 올랐고, 몇 년 뒤에는 직접 또 올리고 싶어질 겁니다. 저장된 해시를 오프라인에서 다시 만들 수는 없습니다(평문이 없으니까요). 하지만 로그인에 성공하는 순간에는 평문을 잠깐 갖고 있습니다.

<?php
$users = ['alice' => password_hash('sunshine-river-42', PASSWORD_BCRYPT, ['cost' => 10])];
const DUMMY = '$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq';

function login(array &$users, string $name, string $pw): bool {
    $hash = $users[$name] ?? null;
    if ($hash === null) {            // 없는 사용자도 같은 시간을 쓰게 한다
        password_verify($pw, DUMMY);
        return false;
    }
    if (!password_verify($pw, $hash)) return false;
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        $users[$name] = password_hash($pw, PASSWORD_DEFAULT);
        echo "rehashed: ", substr($hash, 0, 7), " -> ", substr($users[$name], 0, 7), "\n";
    }
    return true;
}
foreach ([['alice', 'wrong'], ['alice', 'sunshine-river-42'], ['alice', 'sunshine-river-42'],
          ['mallory', 'x']] as [$u, $p]) {
    $t = hrtime(true);
    $ok = login($users, $u, $p);
    printf("%-8s %-18s %-5s %6.1f ms\n", $u, $p, $ok ? 'OK' : 'FAIL', (hrtime(true) - $t) / 1e6);
}
alice    wrong              FAIL    59.6 ms
rehashed: $2y$10$ -> $2y$12$
alice    sunshine-river-42  OK     299.0 ms
alice    sunshine-river-42  OK     240.6 ms
mallory  x                  FAIL   242.2 ms

시간 열에서 미묘한 함정이 보입니다. 없는 사용자용 더미 해시는 비용 12 인데 alice 의 옛 해시는 비용 10 이었습니다. 그래서 해시가 갱신되기 전까지는 '비밀번호 틀림'(59.6 ms)과 '사용자 없음'(242.2 ms)을 구별할 수 있었습니다. 더미는 실제 해시와 같은 비용으로 맞추세요. 시간 차이로 새는 정보는 강좌 6에서 자세히 다룹니다.

bcrypt 72바이트 제한 재현

bcrypt 는 비밀번호의 앞 72바이트만 읽습니다. PHP 8.5.6 도 아무 경고 없이 잘라 냅니다. 한글 패스프레이즈로 시험했습니다(유명한 문장은 목록에 들어 있으니 실제로는 쓰지 마세요).

<?php
$a = '동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세';
printf("%d chars, %d bytes\n", mb_strlen($a), strlen($a));
$h = password_hash($a . ' A', PASSWORD_BCRYPT);
var_dump(password_verify($a . ' B', $h));          // bcrypt: 앞 72바이트만 본다
var_dump(password_verify(substr($a, 0, 72), $h));
$h = password_hash($a . ' A', PASSWORD_ARGON2ID);
var_dump(password_verify($a . ' B', $h));          // Argon2id: 입력 전체를 본다
35 chars, 91 bytes
bool(true)
bool(true)
bool(false)

끝이 달라도 통과했고, 앞 72바이트(= "동해물과 백두산이 마르고 닳도록 하느님이 보우하사 "까지)만으로도 통과했습니다. 영어라면 72글자지만 UTF-8 한글은 한 글자 3바이트라서 공백 없이 쓰면 24글자가 한계입니다. 긴 한글 패스프레이즈를 권하는 서비스일수록 이 제한에 걸립니다.

관련된 함정이 하나 더 있습니다. PHP 8.5.6 의 bcrypt 는 NUL 바이트를 거부합니다. 흔히 말하는 "날것의 SHA-256 으로 전처리" 방법은 약 12% 확률로 실패합니다. 무작위 32바이트에 \0 이 들어 있을 확률이 1 − (255/256)³² ≈ 11.8% 이기 때문입니다.

first candidate with NUL in raw sha256: user-pass-2 (byte 12)
ValueError: Bcrypt password must not contain null character

페퍼: DB 밖에 두는 비밀

페퍼(pepper)는 모든 비밀번호에 해시 전에 섞는 서버 쪽 비밀입니다. 솔트와 달리 DB 에 저장하지 않고 환경 변수, 비밀 관리 서비스, DB 서버가 읽을 수 없는 파일에 둡니다. DB 만 유출됐다면 공격자는 추측을 한 번도 시험할 수 없습니다. HMAC 과 Base64 를 함께 쓰면 위의 bcrypt 함정 두 개도 해결됩니다. 결과가 44글자 ASCII 라서 NUL 이 없고 72바이트보다 짧기 때문입니다.

<?php
$pepper = getenv('PW_PEPPER') ?: 'demo-only-pepper-change-me-32byt';
function prep(string $pw, string $pepper): string {
    return base64_encode(hash_hmac('sha256', $pw, $pepper, true)); // 44 ASCII chars, no NUL
}
$a = '동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세';
$h = password_hash(prep($a . ' A', $pepper), PASSWORD_DEFAULT);
var_dump(password_verify(prep($a . ' A', $pepper), $h));
var_dump(password_verify(prep($a . ' B', $pepper), $h));   // 72바이트 뒤의 차이도 거부
var_dump(password_verify(prep($a . ' A', 'stolen-db-no-pepper'), $h));
bool(true)
bool(false)
bool(false)

페퍼의 대가는 운영입니다. 잃어버리면 아무도 로그인하지 못하고, 바꾸려면 모든 사용자가 다시 로그인하기를 기다려야 합니다. 페퍼 없이 72바이트 제한이 없는 Argon2id 를 고르는 팀도 많고, 둘 다 합리적입니다. 합리적이지 않은 것은 페퍼를 저장소에 커밋하는 것입니다.

Python: 표준 라이브러리에 bcrypt 는 없다

Python 3.9 표준 라이브러리에는 bcrypt 도 Argon2 도 없습니다. 이 Mac 에서는 hashlib 이 LibreSSL 2.8.3 으로 빌드돼 있어 scrypt 도 없었습니다.

AttributeError: module 'hashlib' has no attribute 'scrypt'
ModuleNotFoundError: No module named 'bcrypt'

hashlib.pbkdf2_hmac 은 항상 있고, 60만 회 반복은 현재 OWASP 의 PBKDF2-HMAC-SHA256 권장치와 같습니다. PHP 처럼 알고리즘, 반복 횟수, 솔트를 해시와 함께 저장합니다.

import base64, hashlib, hmac, secrets
ITER = 600_000

def hash_password(pw: str) -> str:
    salt = secrets.token_bytes(16)
    dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, ITER)
    b = lambda x: base64.b64encode(x).decode()
    return f"pbkdf2_sha256${ITER}${b(salt)}${b(dk)}"

def verify_password(pw: str, stored: str) -> bool:
    algo, it, salt, dk = stored.split("$")
    test = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), base64.b64decode(salt), int(it))
    return hmac.compare_digest(test, base64.b64decode(dk))

s = hash_password("가을하늘-2026")
print(s)
print(verify_password("가을하늘-2026", s), verify_password("가을하늘-2025", s))
pbkdf2_sha256$600000$hmFV9gzFWWLMXfqM2OIGKA==$nyV8N58YABdw9lixHEk0a3SGeVEB4+SH3XlzFNRVC0Y=
True False

실제 프로젝트에서는 관리가 잘 되는 argon2 나 bcrypt 패키지를 추가하는 것이 보통입니다(여기서는 설치하지 않았습니다). Django 같은 프레임워크도 이 알고리즘$반복횟수$솔트$해시 모양으로 저장합니다.

흔한 실수: 무슨 일이 생기고 어떻게 고치나

1. 로그인 입력을 해시해서 문자열로 비교하기. 생기는 일: 호출할 때마다 새 솔트를 쓰므로 password_hash($pw) === $stored 는 항상 false 입니다(위에서 확인). 그러면 누군가 솔트를 빼서 '고쳐' 버립니다. 고치는 법: password_verify().

2. PHP 에서 해시를 == 로 비교하기.

string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true)     // md5('240610708') == md5('QNKCDZO')
bool(false)    // ===
bool(false)    // hash_equals()

둘 다 '0 × 10 의 몇 제곱'이라는 숫자로 보이기 때문에 PHP 8.5 에서도 == 는 숫자로 비교합니다. 고치는 법: 비밀번호는 password_verify(), 그 밖의 비밀 비교는 hash_equals().

3. DB 열이 너무 짧다. 생기는 일: bcrypt 는 60글자, Argon2id 는 이번 실행에서 97글자였고, PASSWORD_DEFAULT 는 앞으로 바뀔 수 있습니다. CHAR(60) 이면 미래의 해시가 조용히 잘립니다. 고치는 법: PHP 매뉴얼 권장대로 VARCHAR(255).

4. 비밀번호를 trim·소문자화하거나 짧게 제한하기. 생기는 일: 해시 전에 trim() 이나 strtolower() 를 하면 비밀번호가 조용히 약해지고, 16자 상한은 패스프레이즈를 막습니다. 고치는 법: 입력된 바이트를 그대로 쓰고, 최소 64자까지 허용하고(NIST SP 800-63B 지침), Unicode 정규화를 한다면 NFC 로 일관되게 합니다. Mac 에서 붙여 넣은 한글이 NFD 로 들어오는 경우도 있으니 가입과 로그인에서 같은 규칙을 쓰세요.

5. '없는 사용자'와 '비밀번호 틀림'의 메시지를 다르게 하기. 생기는 일: 어떤 이메일이 가입돼 있는지 공격자가 알게 됩니다. 고치는 법: 메시지는 하나로, 위 로그인 코드처럼 같은 비용으로 더미 대조를 합니다.

6. 로그인 시도 횟수를 제한하지 않기. 생기는 일: 느린 해시가 지키는 것은 유출된 DB 이지 운영 중인 로그인 폼이 아닙니다. 고치는 법: 계정별·IP 별 시도 제한과, 가입 시 유출된 비밀번호 확인. 느린 해시는 서버 CPU 도 먹는다는 점에도 주의하세요. 1회 237 ms 라면 코어 하나로 처리하는 로그인은 초당 약 4번입니다.

알고리즘과 비용 고르기

상황 선택 시작 파라미터 (최신 OWASP 지침 확인)
새 PHP 프로젝트 PASSWORD_ARGON2ID 또는 PASSWORD_DEFAULT(bcrypt) Argon2id m=19 MiB, t=2, p=1 이상. bcrypt 비용 10 이상, PHP 기본값 12
긴 패스프레이즈, 한글 입력 Argon2id, 또는 HMAC + Base64 전처리 bcrypt 위와 같음
Python 표준 라이브러리만 hashlib.pbkdf2_hmac SHA-256, 60만 회, 16바이트 솔트
FIPS 등 준수 요건 PBKDF2-HMAC-SHA256 60만 회 이상
기존 MD5/SHA-1 테이블 지금 password_hash(옛 md5) 로 감싸고, 다음 로그인에서 교체 어느 형식의 행인지 표시해 둔다

마지막으로 운영 환경과 같은 하드웨어에서 비용을 조정합니다. 피크 시간에 1회 100~500 ms 정도가 기준입니다.

정직한 한계와 버전 메모

  • PHP 8.5.6, Python 3.9.6(LibreSSL 2.8.3), OpenSSL 4.1.0 으로 실행했습니다. password_hash() 는 PHP 5.5, Argon2i 는 7.2, Argon2id 는 7.3 에서 추가됐습니다. bcrypt 기본 비용은 PHP 8.4 에서 10 에서 12 로 바뀌었고, 8.5.6 에서 12 인 것을 확인했습니다.
  • NUL 바이트의 ValueError 와 72바이트에서의 조용한 잘림은 PHP 8.5.6 에서만 확인했습니다. 옛 PHP 의 NUL 바이트 처리는 다릅니다(여기서는 실행하지 않았습니다).
  • 속도는 모두 M1 Pro 코어 하나 기준입니다. GPU 를 쓰는 공격자의 숫자는 빠른 해시에서는 훨씬 크고, 메모리를 많이 쓰는 해시에서는 그만큼 극적이지 않습니다. GPU 는 돌리지 않았습니다.
  • OWASP 의 파라미터 권장값은 바뀝니다. 표의 숫자는 작성 시점 OWASP Password Storage Cheat Sheet 값입니다. 고르기 전에 다시 확인하세요.
  • 공격은 모두 로컬 컴퓨터에서 직접 만든 사용자와 20단어 목록으로 했습니다. 실제 목록은 수백만 단어입니다.

연습 문제

1. 사용자 테이블에 솔트 없는 sha256(password) 로 저장한 1만 행이 있습니다. 모두에게 비밀번호 재설정을 요청하지 않고 오늘부터 지키려면 어떻게 할까요?

2. bcrypt 비용 12 실측값 236.7 ms 를 쓰면 코어 하나로 초당 몇 번의 로그인을 처리할 수 있나요? 시도 제한이 없는 로그인 API 에 대해 무엇을 말해 주나요?

3. 한 사용자가 가나다라마바사아자차카타파하 를 두 번 이어 붙인 패스프레이즈(28글자)를 정했습니다. bcrypt 는 어디부터 무시하나요?

정답

1. 옛 해시를 감쌉니다. password_hash(옛_sha256_16진수, PASSWORD_DEFAULT) 를 저장하고 그 행에 '감쌈' 표시를 합니다. 로그인할 때는 password_verify(hash('sha256', $pw), $stored) 로 대조하고, 성공하면 새 password_hash($pw) 를 저장하고 표시를 지웁니다. 모든 행이 즉시 보호되고, 사용자가 로그인할 때마다 정상 형식으로 옮겨 갑니다.

2. 1000 ÷ 236.7 ≈ 초당 4.2번입니다. 공격자는 그보다 훨씬 많은 요청을 보내 CPU 를 다 써 버릴 수 있으므로, 해시가 느려도 로그인 API 에는 시도 제한이 필요합니다.

3. 한글 음절은 UTF-8 에서 3바이트라 72바이트는 24글자입니다. 마지막 4글자 '카타파하'가 무시됩니다. PHP 8.5.6 에서 앞 24글자만으로 password_verify 가 통과하는 것도 확인했습니다. Argon2id 를 쓰거나 HMAC + Base64 로 전처리하세요.

다음 글

자주 묻는 질문

SHA-256 에 솔트를 붙이면 비밀번호 저장에 충분한가요?

충분하지 않습니다. 솔트는 미리 계산한 표를 무력화하고 같은 비밀번호를 다른 해시로 보이게 하지만, SHA-256 자체는 여전히 빠릅니다. PHP 는 M1 Pro 코어 하나로 솔트 붙은 SHA-256 을 초당 약 218만 번 계산했고, 20단어 목록으로도 약한 비밀번호를 24마이크로초 만에 모두 찾았습니다. bcrypt, Argon2id, scrypt, 반복 횟수가 충분한 PBKDF2 처럼 일부러 느린 해시가 필요합니다.

bcrypt 는 정말 72바이트 뒤를 무시하나요?

네. PHP 8.5.6 에서 91바이트짜리 한글 문장 끝에 ' A' 를 붙여 bcrypt 로 해시했더니, ' B' 로 바꾼 문장도, 앞 72바이트만 있는 문자열도 password_verify 를 통과했습니다. 한글은 UTF-8 에서 한 글자 3바이트라 24글자가 한계입니다. Argon2id 에는 이 제한이 없고, bcrypt 를 쓴다면 HMAC 과 Base64 로 전처리합니다.

기존 사용자의 해시를 더 높은 비용이나 Argon2id 로 옮기려면?

로그인할 때 password_verify() 가 성공한 직후, 현재 알고리즘과 설정으로 password_needs_rehash() 를 호출합니다. true 면 방금 검증한 평문 비밀번호를 다시 해시해 행을 갱신합니다. 실행 예에서는 비용 10 해시가 첫 로그인에서 비용 12 가 됐습니다. 로그인하지 않는 사용자는 옛 해시 그대로 남습니다.

  • #보안
  • #초급
  • #비밀번호
  • #bcrypt
  • #Argon2id
  • #PHP
  • #PBKDF2

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑