MD5와 SHA-1이 깨진 이유 — 그리고 "깨졌다"의 진짜 의미
MD5는 1991년 Ron Rivest가 설계했습니다. SHA-1은 1995년 NSA가 설계했습니다. 둘 다 범용 암호 해시 함수이고, 고정 크기 다이제스트를 만들고, 오랫동안 "깨졌다"고 여겨져 왔습니다. 다만 "깨졌다"는 두 종류의 공격을 모두 가리키므로 정의가 필요합니다.
Collision attack은 공격자가 같은 해시를 만드는 서로 다른 입력 A, B를 찾을 수 있다는 뜻. Second-preimage attack은 고정 입력 A에 대해 같은 해시를 만드는 다른 B를 찾을 수 있다는 뜻으로, 훨씬 더 어렵습니다. MD5는 2004년 collision-broken되었고 오늘날 노트북에서 몇 분이면 collision이 만들어집니다. SHA-1의 첫 실용적 collision은 2017년 2월 구글의 "SHAttered" 공격 — 같은 SHA-1 해시를 가지는 서로 다른 두 PDF를 만들었고, 비용은 AWS GPU 약 11만 달러(2020 Shambles 논문에서 약 4.5만 달러로 감소). MD5도 SHA-1도 실용적 second-preimage 공격은 아직 없지만 격차는 매년 좁혀지며, "공격자에게 고정 타겟을 줄 수 없다"는 것은 의존하기 약한 속성입니다.
실무 영향 — 어떤 보안 목적에도 MD5 / SHA-1 쓰지 마세요. 실제 사건이 있습니다. Flame 멀웨어(2012, 국가 행위자 추정)는 Microsoft Terminal Services 인증서 서명 체인의 MD5 collision을 악용해 valid Windows 코드 서명 인증서를 위조했습니다. Git은 2.13 전환 계획 전까지 콘텐츠 주소화에 SHA-1을 썼고, SHAttered 데모는 정확히 서로 다른 두 커밋이 같은 해시를 공유할 수 있음을 보였습니다 — Git은 2017년 collision detection을 도입하고 SHA-256으로 이전 중. CA/Browser Forum은 2017년 TLS 인증서에서 SHA-1을 강제로 퇴출. PGP, S/MIME, 대부분의 코드 서명도 같은 길.
MD5 / SHA-1은 어디까지 OK한가? 비적대적 무결성 체크. 무작위 손상을 감지(CDN 파일 vs 원본, 다운로드 산출물 vs 공개 체크섬)하는 용도이고 공격자가 collision을 만들 인센티브가 없으면 여전히 작동합니다. collision resistance가 보안 모델이 아닌 데이터 구조 내부의 fingerprint로도 적합합니다 — Git의 SHA-1이 2026년에도 살아있는 이유는 Git의 위협 모델이 producer를 신뢰하고 content addressing을 편의 기능으로 보기 때문입니다.
현대 권장 — 신규 시스템엔 SHA-256 또는 SHA-3-256, throughput이 중요하면 BLAKE3. SHA-256은 2001년 이후 의미 있는 약화 없이 버텨오고 있고, 암호 커뮤니티는 향후 20년 이상 신뢰를 더 쌓을 준비가 되어 있습니다. SHA-3(Keccak, 2015 표준화)는 SHA-2와 구조가 달라 SHA-2에 결함이 발견돼도 같은 결함을 공유할 가능성이 낮습니다. BLAKE3는 소프트웨어에서 SHA-256보다 약 5배 빠르고 보안 수준은 동일 — 큰 파일에 적합.
// MD5 collision in seconds (e.g., HashClash, fastcoll)
fastcoll input.bin a.bin b.bin
md5sum a.bin b.bin
# 008ee33a9d58b51cfeb425b0959121c9 a.bin
# 008ee33a9d58b51cfeb425b0959121c9 b.bin <-- different files, same MD5
// Modern hashing in Node 20+
import { createHash } from 'node:crypto';
createHash('sha256').update(buf).digest('hex'); // recommended
createHash('sha3-256').update(buf).digest('hex'); // SHA-3 (Keccak)
createHash('blake2b512').update(buf).digest('hex'); // BLAKE2
// BLAKE3 — install @noble/hashes for portability
import { blake3 } from '@noble/hashes/blake3';
blake3(buf, { dkLen: 32 }); // 32-byte digest
// Decision:
// - Adversary involved (signatures, content auth)? → SHA-256 / SHA-3 / BLAKE3
// - Just detecting random corruption (CRC-style)? → MD5/SHA-1 still OK
// - Speed matters on huge files (TB-scale)? → BLAKE3Argon2id vs scrypt vs bcrypt — 현대 패스워드 해싱
패스워드에는 평범한 SHA-256은 잘못된 답입니다. SHA-256은 빠르도록 설계됩니다 — GPU에서 초당 수십억 해시. 패스워드 해싱의 핵심은 충분히 느려서 탈취된 DB로 brute-force가 무의미해지는 것입니다. 2000년대 이후 3개 패밀리가 지배했고, OWASP 2026 password storage cheat sheet의 권장 순서는 —
Argon2id (2015년 Password Hashing Competition 우승, RFC 9106 / 2021 표준화). 노브 3개 — memory cost(m, KiB), time cost(t, 반복), parallelism(p, 스레드). "id" 변종은 data-dependent / data-independent 경로를 섞어 side-channel과 time-memory tradeoff 양쪽에 저항합니다. OWASP 2026 baseline — m=19456(19 MiB), t=2, p=1, 또는 m=12288, t=3, p=1, 또는 m=7168, t=5, p=1 중 피크 로그인 부하에서 서버가 견디는 것 선택. 신규 시스템엔 명백히 Argon2id 권장. 주요 라이브러리 — argon2(Node), argon2-cffi(Python), org.bouncycastle.crypto.generators.Argon2BytesGenerator(Java).
scrypt (2009, Colin Percival). 노브 2개 — N(CPU/메모리 비용, 2의 제곱), r(block size), p(병렬화). 권장(2026) — N = 2^17(약 128 MiB), r=8, p=1. memory-hard이지만 Argon2의 저항 특성은 없습니다. 호환성으로 묶여 있다면 그대로 OK.
bcrypt (1999, Niels Provos). 노브 1개 — cost factor(work factor, +1마다 비용 2배). 권장(2026) — 하드웨어 예산에 따라 12 또는 13. 2026년에 10 미만은 너무 빠릅니다. 결함 — 입력 길이 72바이트 cap(긴 패스워드 조용히 잘림), 메모리 사용량 미미, 전용 하드웨어에 저항 없음. 레거시엔 OK지만 오늘 새로 고를 답은 아님. PBKDF2는 더 오래되고 더 약함 — FIPS 준수가 강제될 때만.
세 가지 공통 핵심 구현 노트 — bcrypt 비교를 직접 작성하지 마세요. verify 함수는 timing 공격을 막기 위해 constant-time 비교를 씁니다. hex 다이제스트에 == 쓰는 순진한 비교는 몇 글자가 일치하는지를 누설합니다. argon2.verify(hash, password) 또는 bcrypt.compare(password, hash)를 쓰고 해시 자체에 동등 연산자 절대 금지.
마이그레이션 전략 — 더 강한 알고리즘으로 옮길 때 모든 사용자를 한꺼번에 강제 rehash하지 마세요. 다음 로그인에서 옛 알고리즘으로 검증한 뒤 새 알고리즘으로 rehash해 저장. "lazy migration" 패턴이며, Stack Exchange가 MD5 → bcrypt → Argon2를 10년에 걸쳐 옮긴 방식 그대로입니다.
// Argon2id with OWASP 2026 baseline
import argon2 from 'argon2';
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
// Stored format embeds params:
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
const ok = await argon2.verify(hash, password); // constant-time
// Lazy migration on login
async function login(email, password) {
const user = await db.users.findOne({ email });
if (!user) return null;
if (user.hash.startsWith('$argon2')) {
if (!await argon2.verify(user.hash, password)) return null;
} else if (user.hash.startsWith('$2')) { // bcrypt
if (!await bcrypt.compare(password, user.hash)) return null;
// upgrade!
const newHash = await argon2.hash(password, ARGON2_OPTS);
await db.users.update({ id: user.id }, { hash: newHash });
}
return user;
}암호 해시 vs 비암호 해시 — 적합한 부류 선택
해시 함수는 설계 목표가 매우 다른 두 패밀리로 나뉘고, 한쪽 자리에 다른 쪽을 쓰면 흔한 버그가 됩니다.
암호 해시(SHA-256, SHA-3, BLAKE3, BLAKE2)는 적대적 입력에 대한 collision / preimage / second-preimage 저항을 우선합니다. 의도적으로 가장 빠르지는 않습니다. 입력이 악의적 당사자의 통제하에 있을 가능성이 있는 모든 곳 — 콘텐츠 주소화, 서명, 보안 컨텍스트의 식별자 해시, 패스워드 해싱(위의 특수 함수), HMAC, 키 유도. 하드웨어 SHA 확장이 있는 modern x86에서 SHA-256은 1.5~2 GB/s, BLAKE3은 5~10 GB/s, SHA-3은 SHA-2보다 약간 느림.
비암호 해시(xxHash, MurmurHash, CityHash, FNV, FarmHash)는 순수한 속도와 좋은 통계적 분포를 우선합니다. 적대적 입력에 어떤 보장도 하지 않습니다 — xxHash 설계상 공격자는 같은 버킷에 모두 충돌하는 입력을 만들 수 있습니다. modern CPU에서 xxHash는 약 30+ GB/s(SHA-256의 10배), 그게 존재 이유입니다. 용도 — 해시 테이블, Bloom filter, 비적대적 데이터의 dedup key, 무작위 손상 감지(네트워크 패킷, 메모리 비트 플립), 클라이언트별 로그 버킷팅.
버그 패턴 — 적대적 컨텍스트에 비암호 해시 사용. 고전 2003 "Crosby & Wallach" 논문은 초창기 Perl, Java, PHP 해시테이블이 dictionary key에 그런 해시를 썼다는 점을 보였습니다 — form 파라미터를 보낼 수 있는 원격 공격자가 모두 충돌하는 입력을 만들면 O(1) lookup이 O(n)이 되어 서버 DoS. 언어들이 채택한 해법(프로세스별 랜덤 키를 가진 SipHash 알고리즘) 자체가 "keyed cryptographic hash" — 비암호 해시처럼 빠르지만 키를 모르는 공격자에 대해 안전.
반대 버그 — 해시 테이블에 SHA-256 사용 — 은 단지 느립니다. 정확히 작동하지만 애플리케이션이 필요 없는 암호 보장에 CPU를 낭비합니다. 100배 속도 차이는 inner loop과 언어 런타임에서 큽니다.
빠른 결정 트리 — 신뢰할 수 없는 입력 AND collision이 정확성·보안에 중요 → SHA-256 / SHA-3 / BLAKE3. 신뢰되거나 저위험 무작위 데이터 AND 속도 중요 → xxHash / MurmurHash. 임의 사용자 입력의 해시 테이블 key → SipHash (Python 3.4+, Ruby, Rust 등이 보통 자동 선택). 패스워드 → Argon2id.
// CRYPTOGRAPHIC — adversary-resistant
import { createHash } from 'node:crypto';
createHash('sha256').update(buf).digest('hex');
// ~1.5 GB/s on modern x86
// NON-CRYPTOGRAPHIC — fast, NOT for security
import xxhash from 'xxhash-wasm';
const { h64 } = await xxhash();
h64(buf); // ~30+ GB/s, 64-bit digest
// Use for: hashtables, dedup, sharding non-adversarial data
// HASH TABLE BUG — never use plain non-crypto hash on attacker input
class BadCache {
constructor() { this.buckets = new Array(1024); }
set(k, v) {
const i = murmur(k) % 1024; // attacker can force all to same bucket
(this.buckets[i] ||= []).push([k, v]);
}
}
// CORRECT — Map / Object use the language's keyed/hardened hasher
const cache = new Map();
cache.set(userInput, value); // V8 uses random-keyed hashing
// Non-adversarial integrity (e.g., copy across rack)
const a = h64(file);
const b = h64(receivedFile);
if (a !== b) console.log('corrupted in transit');