● CWE-916 · OWASP A02:2021

안전하지 않은 비밀번호 해싱(CWE-916): SHA-256으로 부족한 이유와 비밀번호 저장 방법

비밀번호를 솔트 없는 SHA-256, SHA-1, MD5 다이제스트로 저장하면 유출된 테이블을 GPU에서 초당 수십억 번 대입해 볼 수 있고, 같은 비밀번호를 쓰는 사용자는 모두 같은 해시를 갖게 됩니다. 솔트, Argon2id 같은 메모리 집약 함수, 로그인 시 재해싱, 더미 검증이 도난당한 데이터베이스를 어떻게 지키는지 Python argon2-cffi 예제로 설명합니다.

알기 쉬운 설명 (ELI5)

물품 보관소는 어떤 코트가 누구 것인지 확인하려고 번호표마다 사본을 보관합니다. 사본이 그냥 복사본이라면 서류철을 훔친 도둑은 모든 코트를 들고 나갈 수 있습니다. 빠른 해시는 살짝 번진 복사본과 같아서, 빠른 스캐너를 가진 도둑은 초당 수백만 장을 되살립니다. 느리고 메모리를 많이 쓰는 해시는 여는 데 꼬박 1초와 큰 작업대가 필요한 작은 금고에 사본을 넣어 두는 것과 같습니다. 번호표마다 금고가 하나씩 있고 자물쇠도 모두 다릅니다. 주인은 입구에서 금고 하나를 열 뿐이라 지연을 느끼지 못하지만, 도둑은 수백만 개를 열어야 합니다.

핵심 개념 및 용어

빠른 해시
SHA-256, SHA-1, MD5는 빠르게 계산되도록 설계되었습니다. 최신 GPU 한 장이 초당 수십억 번의 SHA-256을 계산하므로, 짧거나 흔한 비밀번호는 무차별 대입과 사전 공격에 몇 시간 안에 뚫립니다.
솔트
해시마다 함께 저장하는 무작위 값으로, 같은 비밀번호라도 다른 해시가 나오게 합니다. 솔트가 없으면 미리 계산한 표 하나로 모든 계정이 한꺼번에 뚫리고, 같은 해시로 같은 비밀번호를 쓰는 사용자가 드러납니다.
메모리 집약 함수(Argon2id)
Argon2id는 추측 한 번마다 시간과 메모리를 모두 쓰게 해 GPU의 이점을 대부분 없앱니다. OWASP 최소 기준은 메모리 19MiB, 반복 2회, 레인 1개(memory_cost=19456, time_cost=2, parallelism=1)이며, scrypt와 bcrypt도 허용되는 대안입니다.
PHC 문자열과 재해싱
PasswordHasher.hash()는 $argon2id$v=19$m=19456,t=2,p=1$... 같은 문자열을 돌려주며, 여기에 알고리즘, 매개변수, 솔트, 해시가 모두 담깁니다. check_needs_rehash()는 저장된 매개변수가 현재보다 약할 때 알려 주므로 다음 로그인 성공 시 해시를 업그레이드할 수 있습니다.
응답 시간과 계정 열거
없는 이메일에는 즉시 응답하고 실제 이메일에는 해시 검증 시간만큼 걸리면, 응답 시간으로 어떤 계정이 있는지 드러납니다. 더미 해시로 검증하면 두 경로의 시간이 같아집니다.

단계별 공격 실행 흐름

단계 1

사용자 테이블이 유출됨

SQL 인젝션, 노출된 백업, 도난당한 노트북 등으로 공격자가 password_hash 열을 손에 넣습니다.

단계 2

공격자가 GPU 크래커를 돌림

솔트 없는 SHA-256이라면 단어 목록을 한 번만 해시해 모든 행과 비교할 수 있습니다. 비밀번호가 Summer2024!인 테스트 계정은 흔한 패턴이 얼마나 빨리 뚫리는지 보여 줍니다.

단계 3

같은 비밀번호는 함께 뚫림

같은 비밀번호를 고른 사용자는 모두 같은 다이제스트를 가지므로, 해시 하나가 뚫리면 전부 노출됩니다.

단계 4

비밀번호가 다른 곳에서 재사용됨

뚫린 비밀번호는 이메일, 은행, 클라우드 계정에 대입됩니다. 2012년 LinkedIn에서는 솔트 없는 SHA-1 비밀번호 해시 약 650만 개가 유출되었고, 2016년에는 같은 사고의 자격 증명 약 1억 1,700만 건이 판매되었습니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

취약한 구현
# users.py: 비밀번호마다 빠르고 솔트 없는 다이제스트 하나
import hashlib

from .db import db


# 같은 비밀번호는 같은 해시, GPU로 초당 수십억 번 대입 가능
def create_user(email: str, password: str) -> None:
    digest = hashlib.sha256(password.encode()).hexdigest()
    db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))


# 없는 이메일은 즉시 응답하므로 시간으로 계정 존재 여부가 드러난다
def check_login(email: str, password: str) -> bool:
    row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
    return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
보안 강화 패치
# users.py: 비밀번호별 솔트를 쓰는 Argon2id, 로그인 시 업그레이드
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError

from .db import db

# Argon2id의 OWASP 최소 기준: 19MiB, 반복 2회, 레인 1개
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")


# 저장 문자열에 알고리즘, 매개변수, 솔트, 해시가 모두 담긴다
def create_user(email: str, password: str) -> None:
    if not 12 <= len(password) <= 128:
        raise ValueError("password must be 12 to 128 characters")
    db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))


# 없는 사용자도 검증 한 번의 시간을 들인다; 약한 해시는 업그레이드된다
def check_login(email: str, password: str) -> bool:
    row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
    stored = row[1] if row else DUMMY_HASH
    try:
        hasher.verify(stored, password)
    except (VerificationError, InvalidHashError):
        return False
    if row is None:
        return False
    if hasher.check_needs_rehash(stored):
        db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
    return True

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← 전체 보안 디렉터리 보기 모든 취약점 가이드 →