- 위험도
- 높음(High)
- CWE
- CWE-916 · 계산 비용이 부족한 해시
- OWASP
- A02:2021
- 룰
- vg-weak-password-hash
이게 무슨 말이냐 (쉽게)
해시는 "되돌릴 수 없게 섞는 것"이 아니라 "되돌리는 데 시간이 아주 오래 걸리게 만드는 것"입니다. md5·sha1은 파일이 바뀌었는지 확인하려고 만든 알고리즘이라 빠른 게 장점이에요. 비밀번호에는 그 장점이 그대로 약점이 됩니다.
bcrypt·argon2는 반대로 일부러 느리게 설계됐습니다. 로그인 한 번에 0.2초는 사람에게 티도 안 나지만, 수억 번 대입하려는 쪽에는 벽이 됩니다.
"소금(salt)을 쳤으니 괜찮다"도 아닙니다. salt는 같은 비밀번호가 같은 값이 되는 걸 막아줄 뿐, 속도를 늦추지는 못해요.
실제로 어떻게 터지나 (시나리오)
회원 5,000명짜리 서비스에서 백업 파일 노출이나 SQL 인젝션으로 users 테이블이 한 번 새어 나갑니다. 비밀번호 칸에는 5f4dcc3b5aa765d61d8327deb882cf99 같은 md5 값이 들어 있어요.
공격자는 이걸 해독하지 않습니다. 흔한 비밀번호 수억 개의 md5 값은 이미 표로 만들어져 인터넷에 돌아다니거든요. 대조는 몇 분이면 끝나고, 보통 절반 이상이 맞습니다. 위 값은 password고요.
그다음이 진짜 피해입니다. 사람들은 비밀번호를 돌려 쓰기 때문에, 공격자는 그 이메일·비밀번호 쌍을 다른 서비스에 그대로 넣어봅니다(크리덴셜 스터핑). 당신 서비스에서 샌 비밀번호로 회원의 메일함이 털려요.
최악의 경우
- 회원 비밀번호 전량 복원 → 타 서비스 연쇄 침해. 피해가 당신 서비스 밖으로 번집니다.
- PIPA는 비밀번호를 일방향(복원 불가) 암호화해 저장하도록 요구합니다. 유출되기 전이라도 저장 방식 자체가 이미 위반입니다.
- 사고 통지·과징금과 별개로, 비밀번호는 "전부 바꾸세요"라고 안내하는 것 말고 되돌릴 방법이 없습니다.
나한테 해당되나? (체크)
- 로그인·회원가입을 직접 구현했다(Supabase Auth·Clerk·NextAuth 같은 걸 쓰지 않는다).
- 코드에 createHash("md5")·createHash("sha1")·md5(...)가 있다.
- 그 근처에 password·pwd 같은 이름이 있다.
- "sha256이면 괜찮겠지"라고 생각했다. (sha256도 빠릅니다 — 비밀번호용이 아니에요. 룰 vg-weak-hash-general)
- 비밀번호를 아예 해시 없이 저장한다. (그건 더 급합니다 — 룰 vg-password-plaintext-store)
→ 2·3번이 같이 체크되면 해당됩니다. 이 룰은 휴리스틱이라 리포트에는 신뢰도 점검 필요로 떠요 — md5를 비밀번호가 아니라 캐시 키에 쓰고 있다면 오탐이에요.
어떻게 고치나 (복붙)
① 가장 게으르고 가장 안전한 답 — 직접 만들지 않기. Supabase Auth·Clerk·NextAuth 같은 인증 서비스를 쓰면 비밀번호가 당신 DB에 저장되지 않습니다. 새 프로젝트라면 이게 정답입니다.
② 직접 해야 한다면 bcrypt(또는 argon2)
import bcrypt from "bcrypt";
// 가입 — 저장할 때
const hash = await bcrypt.hash(password, 12); // cost 12 이상
await db.users.insert({ email, password_hash: hash });
// 로그인 — 확인할 때
const ok = await bcrypt.compare(password, user.password_hash); // 문자열 직접 비교 금지③ 기존 회원은 "로그인할 때 자동 교체". 이미 저장된 md5 값은 되돌릴 수 없으니, 다음 로그인 때 갈아끼웁니다. 회원에게 아무것도 요구하지 않고 넘어가는 방법이에요.
if (user.password_hash.length === 32) { // 옛 md5 값
if (md5(password) !== user.password_hash) return fail();
await db.users.update(user.id, {
password_hash: await bcrypt.hash(password, 12), // 통과한 그 자리에서 교체
});
} else if (!(await bcrypt.compare(password, user.password_hash))) {
return fail();
}교체가 끝나면(또는 정해둔 기한이 지나면) 옛 경로를 삭제하세요. 남겨두면 그게 곧 뒷문입니다.
④ 이미 유출됐다면 — 전원 비밀번호 재설정과 유출 통지가 먼저입니다. 해시만 바꾸는 걸로 끝나지 않습니다.
고친 뒤 확인
- 01
값 모양 확인
bcrypt는 $2b$12$...로 시작하는 60자입니다. 32자 16진수면 아직 md5예요.
select count(*) from users where password_hash not like '$2%'; -- 0이어야 정상 - 02
코드 훑기
git grep -nE "createHash\(('|\")(md5|sha1)" → 비밀번호 근처에 남아 있지 않아야 합니다.
- 03
응답 시간 보기
로그인 응답이 너무 빠르지 않은지 — bcrypt는 원래 0.1~0.3초 걸립니다. 즉시 응답이면 옛 경로를 타고 있을 수 있어요.
- 04
옛 경로 제거 후 재로그인
예전 비밀번호로 로그인해 보기 → ③이 제대로 돌았다면 정상 로그인돼야 합니다.
규제 연결
PIPA 제29조 안전조치의무·시행령 제30조는 비밀번호의 일방향 암호화 저장을 명시합니다. ISMS-P 2.7.1(암호화 적용 — 비밀번호 일방향) 항목에 직접 대응하고요. 우리 리포트는 이 발견에 해당 조항을 붙여 "무엇을 위반했고 무엇을 바꿔야 하는지"까지 한국어로 정리합니다.
출처/더 읽기: OWASP A02:2021(Cryptographic Failures), OWASP Password Storage Cheat Sheet, CWE-916, 개인정보 보호법 제29조·시행령 제30조, 개인정보의 안전성 확보조치 기준. 이 카드는 우리가 직접 작성했습니다.
← 문서 목록으로