안전하지 않은 비밀번호 해싱(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()는 저장된 매개변수가 현재보다 약할 때 알려 주므로 다음 로그인 성공 시 해시를 업그레이드할 수 있습니다.- 응답 시간과 계정 열거
- 없는 이메일에는 즉시 응답하고 실제 이메일에는 해시 검증 시간만큼 걸리면, 응답 시간으로 어떤 계정이 있는지 드러납니다. 더미 해시로 검증하면 두 경로의 시간이 같아집니다.
단계별 공격 실행 흐름
사용자 테이블이 유출됨
SQL 인젝션, 노출된 백업, 도난당한 노트북 등으로 공격자가 password_hash 열을 손에 넣습니다.
공격자가 GPU 크래커를 돌림
솔트 없는 SHA-256이라면 단어 목록을 한 번만 해시해 모든 행과 비교할 수 있습니다. 비밀번호가 Summer2024!인 테스트 계정은 흔한 패턴이 얼마나 빨리 뚫리는지 보여 줍니다.
같은 비밀번호는 함께 뚫림
같은 비밀번호를 고른 사용자는 모두 같은 다이제스트를 가지므로, 해시 하나가 뚫리면 전부 노출됩니다.
비밀번호가 다른 곳에서 재사용됨
뚫린 비밀번호는 이메일, 은행, 클라우드 계정에 대입됩니다. 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
엔지니어링 및 시스템 보안 강화 체크리스트
- 비밀번호는 Argon2id(메모리 19MiB 이상, 반복 2회, 레인 1개), scrypt, bcrypt로 해시합니다. 비밀번호 변수에 대한
hashlib호출을 표시하는 Semgrep 규칙을 추가하고 MD5와 SHA-1에는 Bandit B324를 실행합니다. - 라이브러리가 돌려주는 PHC 문자열 전체를 저장하고, 솔트를 따로 떼어 내거나 재사용하지 않습니다.
- 로그인에 성공할 때마다
check_needs_rehash()를 호출하고 약한 매개변수로 만든 해시를 교체합니다. - 계정이 없을 때도 더미 해시로 검증해 응답 시간으로 가입된 이메일이 드러나지 않게 합니다.
- 최소 12자를 요구하고 최소 64자까지의 긴 패스프레이즈를 허용하며, 유출 목록에 있는 비밀번호는 거부합니다.