본문 바로가기

패스워드 크래킹과 해시 분석

앞 회차에서 우리는 평문 HTTP 트래픽 안에 비밀번호가 그대로 흘러가는 모습을 직접 캡처해 봤습니다. 이번 회차의 질문은 한 단계 다릅니다. 서버는 그 비밀번호를 어떤 모양으로 저장해야 하는가, 그리고 저장된 모양이 유출됐을 때 공격자는 무엇을 할 수 있는가입니다.

이 회차의 모든 실습은 본인이 직접 만든 해시, 본인이 직접 만든 비밀번호 후보에 대해서만 수행합니다. 남의 서비스에서 흘러나온 해시 덤프를 학습 자료라며 다운로드하는 행위는, 그 자체로 개인정보보호법·정보통신망법 위반입니다.

1. 해시 함수가 하는 일

해시 함수는 임의 길이의 입력을 고정 길이의 짧은 출력으로 바꾸는 함수입니다. 그림으로 보면 다음과 같습니다.

순방향(입력 → 해시)은 누구나 빠르게 계산할 수 있습니다. 반대로 해시 값만 보고 원본 입력을 되돌리는 일은 사실상 불가능합니다. 이 비대칭성이 일방향성이고, 비밀번호를 평문이 아니라 해시로 저장할 수 있는 근거가 됩니다.

보안에 쓰이는 해시 함수는 그 위에 세 가지 성질을 추가로 요구합니다.

성질의미깨졌을 때의 위험
일방향성 (preimage resistance)해시 값에서 원본을 복원할 수 없다비밀번호 복원
2차 프리이미지 저항성같은 해시를 만드는 또 다른 입력을 찾기 어렵다위조
충돌 저항성 (collision)해시가 같은 두 입력을 찾기 어렵다인증서 위조, 디지털 서명 우회

2차 프리이미지와 충돌 저항성은 자주 헷갈리는 자리입니다. 차이는 공격자가 입력 X를 자유롭게 고를 수 있느냐입니다. 2차 프리이미지는 "이미 정해진 X가 있고, 그 X와 같은 해시를 내는 또 다른 Y를 찾아라"이고(예: 누군가 서명한 문서의 해시는 고정 → 같은 해시를 내는 위조 문서를 만들어야 함), 충돌 저항성은 "X든 Y든 좋으니 해시가 같은 어떤 두 입력 쌍을 찾아라"입니다(예: 두 가지 버전의 문서를 직접 만들어 한쪽에만 서명을 받고 다른 쪽으로 바꿔치기). 충돌 저항성이 더 약한 가정이라(공격자에게 자유도가 더 많아서) 더 빨리 깨집니다. SHA-1에서 충돌은 시연됐지만(2017 SHAttered) 2차 프리이미지는 아직 시연된 적이 없는 까닭이기도 합니다.

자주 듣는 이름들의 현재 위치는 다음과 같습니다.

알고리즘출력 길이현재 권장 여부메모
MD5128 bit사용 금지충돌이 1초 안에 발생. 무결성 체크용으로조차 권장 안 함
SHA-1160 bit사용 금지Google이 2017년 SHAttered로 충돌 시연
SHA-256 / SHA-512256 / 512 bit무결성에는 OK단, 비밀번호 해시로는 부적합 (이유는 7절)
SHA-3가변OK내부 구조가 SHA-2와 완전히 다름
BLAKE2 / BLAKE3가변OK빠르고 강함. 무결성·키 유도용

마지막 행은 이번 회차의 가장 중요한 메시지를 미리 담고 있습니다. "안전한 해시"와 "비밀번호를 저장하기에 안전한 해시"는 다른 개념입니다. 7절에서 다시 짚습니다.

2. 파이썬 hashlib 한 페이지

표준 라이브러리 hashlib만 있으면 위 알고리즘을 거의 모두 다룰 수 있습니다.

import hashlib

password = "hello123"

print(hashlib.md5(password.encode()).hexdigest())
# 5d41402abc4b2a76b9719d911017c592 와 같은 패턴은 위험합니다 (실제 해시 X)

print(hashlib.sha256(password.encode()).hexdigest())
print(hashlib.sha3_256(password.encode()).hexdigest())
print(hashlib.blake2b(password.encode(), digest_size=32).hexdigest())

update()로 조각조각 먹여 큰 파일도 처리할 수 있습니다.

# hash_file.py: 큰 파일도 메모리 부족(OOM, Out Of Memory) 없이 해시
import hashlib

h = hashlib.sha256()                                # 빈 해시 객체 생성
with open("big.bin", "rb") as f:                    # 바이너리 모드로 파일 열기
    # 파일을 64KB(65536바이트) 조각으로 나눠 읽음
    # 10GB 파일이라도 메모리는 항상 64KB만 사용
    for chunk in iter(lambda: f.read(65536), b""):  # 빈 바이트(b"")가 나올 때까지 반복
        h.update(chunk)                             # 조각을 해시에 누적
print(h.hexdigest())                                # 최종 해시 값 출력

f.read()로 파일 전체를 한 번에 읽으면 10GB 파일은 10GB 메모리를 먹습니다. 위 코드처럼 64KB씩 잘라 update()로 누적하면 파일 크기와 무관하게 메모리는 일정합니다. 해시 함수가 입력을 통째로 받을 필요가 없도록 설계된 덕분입니다.

이 패턴은 무결성 체크(파일이 같은지 비교)나 포렌식에서 자주 씁니다. 단, 비밀번호에는 절대로 이 방식을 그대로 쓰지 마세요. 그 이유가 다음 세 절의 주제입니다.

3. 사전 공격 (Dictionary Attack)

사용자가 만드는 비밀번호의 분포는 끔찍하리만치 한쪽으로 쏠려 있습니다. 매년 유출되는 데이터 분석을 보면 상위 100개 비밀번호가 전체 사용자의 5–10%를 점유합니다. 이걸 노리는 가장 단순한 공격이 사전 공격입니다.

# dict_attack.py: 가장 짧은 사전 공격
import hashlib

target = hashlib.sha256(b"summer2024").hexdigest()  # 미리 만들어둔 표적

with open("wordlist.txt", encoding="utf-8") as f:
    for line in f:
        candidate = line.strip()
        if hashlib.sha256(candidate.encode()).hexdigest() == target:
            print(f"[+] cracked: {candidate}")
            break
    else:
        print("[-] not in wordlist")

wordlist.txt는 본인이 만든 작은 텍스트 파일(예: 30~50줄짜리)을 사용합니다. rockyou.txt처럼 유출 사고에서 비롯된 거대 파일은 학습 목적이라도 다운받지 않는 편이 안전합니다. 출처가 명확한 작은 어휘 표본만으로 원리는 충분히 체감할 수 있습니다.

실습용으로 아래 40줄짜리 사전을 그대로 복사해 wordlist.txt로 저장하세요. 위 예제의 표적인 summer2024가 포함되어 있어서 크랙이 성공하는 모습을 바로 확인할 수 있습니다.

password
123456
qwerty
admin
letmein
welcome
monkey
dragon
master
hello
hello123
iloveyou
abc123
password1
admin123
root
toor
test
test123
guest
user
login
pass
secret
changeme
qwerty123
1q2w3e4r
asdf1234
korea
seoul
samsung
spring2024
summer2024
autumn2024
winter2024
january
december
birthday
sunshine
football

표적을 summer2024 대신 dragon, iloveyou로 바꿔보거나, 일부러 사전에 없는 단어(MyDog#7)로 바꿔 "사전 공격 실패"도 함께 관찰해 보세요. 사전에 없으면 못 깬다는 한계가 4절(브루트포스)의 동기로 자연스럽게 이어집니다.

3.1 속도가 얼마나 빠른가

같은 코드를 1만 단어짜리 사전에 대해 돌려보면, 보통의 노트북에서 1초 안에 끝납니다. CPU 한 코어가 SHA-256을 초당 수백만 번 계산할 수 있기 때문입니다.

# dict_attack_timing.py
import hashlib, time

target = hashlib.sha256(b"summer2024").hexdigest()
start = time.perf_counter()
tried = 0

with open("wordlist.txt", encoding="utf-8") as f:
    for line in f:
        tried += 1
        if hashlib.sha256(line.strip().encode()).hexdigest() == target:
            elapsed = time.perf_counter() - start
            print(f"[+] {line.strip()}  ({tried} tries, {elapsed*1000:.1f} ms)")
            print(f"    rate ≈ {tried/elapsed:,.0f} hashes/sec")
            break

이 출력에서 보이는 수백만 hashes/sec이 바로, 7절에서 "비밀번호 전용 해시는 일부러 느려야 한다"고 말하는 이유가 됩니다.

3.2 변형 규칙 (mangling rules)

실제 사용자는 단어를 그대로 쓰기보다 숫자·기호를 끼우는 변형을 자주 합니다(password → Password!, password123, p@ssw0rd). hashcat 같은 도구는 이 변형을 규칙 파일로 받습니다. 우리는 짧은 파이썬 함수로 같은 일을 흉내 낼 수 있습니다.

# mangle.py: 한 단어를 흔한 변형 형태로 부풀리기
import itertools

def mangle(word: str):
    yield word
    yield word.capitalize()
    for suffix in ["!", "?", "1", "12", "123", "2024", "2025"]:
        yield word + suffix
        yield word.capitalize() + suffix
    table = str.maketrans({"a": "@", "o": "0", "i": "1", "s": "$"})
    yield word.translate(table)

for c in mangle("summer"):
    print(c)

이 24~30개의 변형을 사전의 모든 단어에 곱하면, 1만 단어 사전이 30만 후보로 부풀어 오릅니다. 그래도 SHA-256이라면 1초대 안에 끝납니다.

4. 브루트포스의 한계

사전에 없는 임의 문자열은 모든 가능한 조합을 다 시도해야 합니다. itertools.product로 한 줄에 만들 수 있습니다.

# brute_force_demo.py: 4자리 숫자 비밀번호 깨기
import hashlib, itertools, time

target = hashlib.sha256(b"4729").hexdigest()
start = time.perf_counter()

for combo in itertools.product("0123456789", repeat=4):
    candidate = "".join(combo)
    if hashlib.sha256(candidate.encode()).hexdigest() == target:
        print(f"[+] {candidate}  ({time.perf_counter()-start:.3f}s)")
        break

4자리 숫자(10⁴ = 10,000개)는 노트북에서 1초도 걸리지 않습니다. 하지만 자릿수가 늘어나면 시간은 지수적으로 폭발합니다.

비밀번호후보 개수초당 1억 해시 기준 예상 시간
4자리 숫자10⁴ ≈ 1만즉시
8자리 숫자10⁸ ≈ 1억1초
8자리 영소문자26⁸ ≈ 2,000억2,000초 (33분)
8자리 영대소+숫자+기호 (95개)95⁸ ≈ 6,600조약 2년
12자리 영대소+숫자+기호95¹² ≈ 5×10²³1.6억 년

이 표의 가장 큰 함정은 "초당 1억 해시"라는 가정입니다. SHA-256으로 8자리 영소문자를 깨는 데 33분이라는 숫자는 GPU 하나의 실측치입니다. RTX 4090 한 장이면 SHA-256을 초당 220억 번 가까이 처리합니다. 같은 표를 그 속도로 다시 그리면 8자리 영소문자가 10초에 끝납니다.

왜 자꾸 SHA-256을 무너뜨리는 이야기를 하는가: 이 표는 SHA-256이 약하다는 뜻이 아닙니다. SHA-256은 무결성 체크용으로는 여전히 훌륭합니다. 다만 "한 번의 해시 계산이 너무 빠르다"는 점이 비밀번호 저장 용도에는 치명적이라는 말입니다. 이 한 문장이 5절·7절 내내 반복됩니다.

5. 솔트 (Salt)

아무 처리 없이 SHA-256만 돌려 비밀번호를 저장하면 두 가지 사고가 즉시 일어납니다.

  1. 같은 비밀번호를 쓰는 사용자의 해시가 똑같다: 한 명을 깨면 같은 비밀번호를 쓰는 모두를 깬다.
  2. 사전 한 번 만들어두면 영구적으로 쓸 수 있다: sha256("password")은 늘 그 값이다.

해결책은 사용자마다 다른 무작위 값을 비밀번호 앞에 붙이고 해싱하는 것입니다. 이 무작위 값을 솔트라 부릅니다. 그림으로 보면 같은 비밀번호 hello123이 사용자별로 어떻게 갈라지는지가 한눈에 들어옵니다.

# salting.py: 사용자별 무작위 솔트
import hashlib, secrets

def hash_password(password: str) -> tuple[str, str]:
    salt = secrets.token_hex(16)  # 32글자(16바이트) 무작위
    digest = hashlib.sha256((salt + password).encode()).hexdigest()
    return salt, digest

def verify(password: str, salt: str, digest: str) -> bool:
    return hashlib.sha256((salt + password).encode()).hexdigest() == digest

salt, digest = hash_password("hello123")
print(salt, digest)
print(verify("hello123", salt, digest))     # True
print(verify("wrong", salt, digest))        # False

secrets.token_hex(16)은 암호학적으로 안전한 난수원에서 16바이트를 뽑아 줍니다. 같은 비밀번호 hello123도 사용자마다 다른 솔트를 만나 다른 해시로 저장됩니다.

솔트는 비밀이 아니다: 솔트는 데이터베이스에 평문으로 같이 저장합니다. 솔트의 목적은 숨기는 것이 아니라 사용자별로 다르게 만드는 것입니다. 솔트를 알고 있어도 공격자는 사용자 한 명마다 사전을 새로 돌려야 하므로, "한 번 깨면 모두 뚫리는" 사태가 사라집니다.

6. 레인보우 테이블

레인보우 테이블은 "미리 모든 해시를 계산해 표로 만들어두고, 표에서 찾는다"는 발상의 발전형입니다. 단순 룩업 테이블이 "비밀번호 후보 N개의 해시를 모두 저장 → 디스크가 N배 필요"라면, 레인보우 테이블은 체인을 만들어 저장 공간을 압축합니다.

체인의 아이디어는 이렇습니다. 비밀번호 P0에서 시작해 H0 = hash(P0) 를 계산하고, 이 해시에 어떤 함수 R(reduction function)을 적용해 다시 비밀번호처럼 생긴 후보 P1 = R(H0) 을 만듭니다. 이걸 수천 번 반복(P0 → H0 → P1 → H1 → ... → P_n)한 뒤 체인의 시작점과 끝점만 디스크에 저장합니다. 검색은 거꾸로 갑니다. 깨고 싶은 해시 H에서 시작해 R 함수로 같은 체인을 따라가다 어딘가 끝점과 일치하면, 그 체인의 시작점부터 다시 계산해 원본을 찾아내는 식이죠. 저장은 줄이고 계산은 늘리는 시간-공간 트레이드오프입니다.

체인이 1만 단계라면 디스크에는 P0, Pn 두 칸만 들어가지만, 검증할 때는 그 1만 단계를 다시 계산해야 한다는 뜻입니다.

"레인보우"라는 이름은 단계마다 서로 다른 reduction 함수를 써서 한 표 안에 여러 색깔의 체인을 섞어두는 기법에서 왔습니다. 한 가지 R만 쓰면 체인이 충돌해 같은 자리만 반복하는데, 단계별로 다른 R을 쓰면 충돌이 분산돼 표 효율이 올라갑니다.

레인보우 테이블의 본질은 한 줄로 요약됩니다. "솔트가 없는 해시를 무력화한다". sha256("password")은 누가 계산해도 같은 값이라, 한 번 만들어 둔 체인 표를 영원히 재활용할 수 있습니다.

그래서 솔트는 레인보우 테이블에 대한 결정적 방어이기도 합니다. 사용자마다 솔트가 다르면 공격자가 미리 만들어둔 표를 사용자 수만큼 다시 만들어야 합니다. 1만 명짜리 서비스라면 1만 개의 표를 만들어야 하니, 사실상 미리 계산하는 의미가 없어집니다.

7. 현대적 패스워드 해시: bcrypt, scrypt, Argon2

지금까지 본 모든 공격이 빠른 해시를 전제로 돌아갑니다. SHA-256이 GPU에서 초당 220억 번 돌아가지 않는다면 위 표의 시간 계산은 모두 무너집니다. 그래서 비밀번호 전용 해시 함수는 "일부러 느리게" 설계됩니다.

알고리즘핵심 비용출시메모
bcrypt (1999)CPU 시간오랜 검증"work factor" 파라미터로 속도 조절. 입력 72바이트 제한
scrypt (2009)CPU + 메모리메모리 하드GPU 가속에 강함
Argon2 (2015)CPU + 메모리 + 병렬도현 시점 권장2015 PHC(Password Hashing Competition) 우승

scrypt와 Argon2가 메모리를 핵심 비용으로 끌어들인 이유는 GPU의 약점을 정조준하기 위함입니다. GPU는 코어 수는 수천 개지만 코어당 가용 메모리는 작습니다. SHA-256처럼 메모리는 거의 안 쓰고 산술만 도는 알고리즘은 GPU가 압도적으로 빠르지만, "이 비밀번호를 검증하려면 64MB의 메모리를 한 번 채웠다 비워야 한다" 같은 알고리즘은 GPU 코어 수만큼 64MB가 필요해져 가속이 무너집니다. 공격자의 하드웨어를 일부러 평범한 CPU 수준으로 끌어내리는 설계인 셈입니다.

세 함수의 공통 사상은 같습니다. 계산 한 번에 0.1~0.5초가 걸리도록 일부러 만든다. 사용자가 로그인할 때 사람이 못 느끼는 시간이지만, 공격자가 100억 후보를 시도하려면 수십 년이 걸리는 시간이기도 합니다.

# bcrypt_demo.py
# pip install bcrypt
import bcrypt

password = b"hello123"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print(hashed)                        # b'$2b$12$...'
print(bcrypt.checkpw(password, hashed))   # True

gensalt(rounds=12)의 12가 work factor입니다. bcrypt는 내부적으로 2^rounds 라운드를 돌리기 때문에 한 단계 올릴 때마다 시간이 정확히 두 배가 됩니다. 12에서 14로 올리면 2^14 / 2^12 = 4배 느려지는 식이죠. 하드웨어가 빨라지면 work factor도 같이 올린다는 운영 원칙이 함께 갑니다.

# argon2_demo.py
# pip install argon2-cffi
from argon2 import PasswordHasher

ph = PasswordHasher(time_cost=2, memory_cost=64*1024, parallelism=2)
hashed = ph.hash("hello123")
print(hashed)
print(ph.verify(hashed, "hello123"))  # True

OWASP의 현 시점 권고를 한 줄로 요약하면 다음과 같습니다.

"새로 만드는 시스템은 Argon2id, 기존 시스템은 bcrypt를 유지·업그레이드. SHA 계열을 비밀번호 저장에 직접 쓰지 말 것."

8. 직접 만들어보는 비교 도구

서로 다른 해시 함수가 얼마나 다른 시간을 쓰는지 한 번에 측정하는 작은 벤치 도구를 만들어 둡니다. 이번 회차의 모든 메시지를 숫자로 한 번 더 박아주는 도구입니다.

아래 코드를 실행해보기 위해서는 2개 설치가 필요합니다.

pip install bcrypt argon2-cffi

아래 코드를 실행시켜보세요.

# hash_bench.py: 알고리즘별 초당 해시 횟수 측정
import hashlib, time, bcrypt
from argon2 import PasswordHasher

DURATION = 1.0  # 알고리즘마다 1초씩 돌림
password = b"hello123"

def bench_hashlib(name):
    end = time.perf_counter() + DURATION
    n = 0
    while time.perf_counter() < end:
        hashlib.new(name, password).hexdigest()
        n += 1
    return n

def bench_bcrypt():
    salt = bcrypt.gensalt(rounds=12)
    end = time.perf_counter() + DURATION
    n = 0
    while time.perf_counter() < end:
        bcrypt.hashpw(password, salt)
        n += 1
    return n

def bench_argon2():
    ph = PasswordHasher(time_cost=2, memory_cost=64*1024, parallelism=2)
    end = time.perf_counter() + DURATION
    n = 0
    while time.perf_counter() < end:
        ph.hash("hello123")
        n += 1
    return n

for name in ["md5", "sha1", "sha256", "sha3_256"]:
    print(f"{name:12} {bench_hashlib(name):>10,} ops/sec")

print(f"{'bcrypt':12} {bench_bcrypt():>10,} ops/sec")
print(f"{'argon2id':12} {bench_argon2():>10,} ops/sec")

저자의 PC에서는 아래와 같은 출력이 나옵니다. 아래 숫자의 의미는 1초에 몇 번의 해시 계산이 가능한가입니다. SHA-256이 초당 160만 번 이상 계산되는 반면, bcrypt는 초당 7회, Argon2id는 초당 32회에 불과합니다.

md5           1,604,757 ops/sec
sha1          1,653,960 ops/sec
sha256        1,664,789 ops/sec
sha3_256      1,228,620 ops/sec
bcrypt                7 ops/sec
argon2id             32 ops/sec

SHA-256은 bcrypt보다 50만 배 빠릅니다. 같은 GPU를 쓴 공격자에게 그 차이는 "오늘 저녁에 다 깨진다"와 "몇 만 년 걸린다"의 차이가 됩니다.