안전한 토큰 생성: random 과 secrets 의 차이를 실측으로
Python random 을 출력 624개로 약 1ms 만에 복원해 다음 SMS 인증번호를 맞히는 실험과 secrets·random_bytes·UUIDv4·시간 시드의 위험, 토큰 길이별 추측 시간 표를 실측으로 정리한다.
시리즈: 보안 기초 강좌 3/6 (중급). 비밀번호 해시(2강)는 제대로 하고 있고, 이제 세션 ID, 비밀번호 재설정 링크, API 키, SMS 인증번호를 발급해야 하는 개발자를 위한 글입니다. 다 읽으면 Python 과 PHP 에서 어떤 난수 함수를 불러야 하는지, 토큰이 몇 비트여야 하는지, "랜덤해 보인다"가 왜 아무것도 증명하지 못하는지 설명할 수 있습니다.
회원가입이나 비밀번호 찾기에서 보내는 6자리 인증번호는, 유효한 3분 동안은 사실상 비밀번호입니다. 개인정보보호법은 비밀번호 같은 인증정보를 안전하게 다루도록 요구하고(비밀번호는 일방향 암호화가 원칙이라는 건 2강에서 다뤘습니다), 정보보안기사 공부를 해 봤다면 "예측 가능한 난수"가 단골 취약점이라는 것도 알 겁니다(법 해석은 일반론이며 구체적 판단은 전문가에게). 그런데 실무 코드에서 원인은 거의 늘 하나입니다. 암호용 난수가 필요한 곳에 범용 난수를 쓴 것. 이 글에서는 일부러 그렇게 만든 장난감 앱을 내 컴퓨터에서 직접 깨 보고, 대안을 모두 측정한 뒤 규칙으로 정리합니다. 실행 환경은 Python 3.9.6, PHP 8.5.6, OpenSSL 4.1.0, Apple Silicon Mac(M1 Pro)이고, 시드가 중요한 곳은 고정했습니다. 모든 공격은 이 컴퓨터에서 직접 만든 코드와 데이터만 대상으로 했습니다.
해시와 MAC 의 차이가 아직 헷갈리면 1강을 먼저 보세요. 이 시리즈는 프로그래밍 학습 로드맵의 일부입니다.
random 과 secrets 는 다른 도구
Python 에는 생김새가 비슷한 난수 API 가 두 개 있고, 용도가 전혀 다릅니다.
| 모듈 | 알고리즘 | 시드 | 용도 |
|---|---|---|---|
random |
메르센 트위스터(MT19937), 내부 상태 32비트 × 624워드 | 인수 없는 random.seed() 면 os.urandom, 아니면 넘긴 값 |
시뮬레이션, 게임, 테스트 데이터 섞기, 재현 가능한 실행 |
secrets(3.6+) |
자체 알고리즘 없음. OS 의 CSPRNG 를 읽음 | 커널 | 토큰, 키, 솔트, 인증번호 등 추측당하면 곤란한 모든 것 |
CSPRNG(암호학적으로 안전한 의사 난수 생성기)에는 약속이 하나 더 있습니다. "출력을 아무리 많이 봐도 다음 출력을 예측할 수 없다." MT19937 에는 그런 약속이 없습니다. 통계적으로는 아주 훌륭한 난수지만 예측 불가능하지는 않습니다.
import random, secrets
print(secrets.token_hex(16)) # 16진수 32자 = 128비트
print(secrets.token_urlsafe(16)) # URL 에 쓸 수 있는 약 22자 = 128비트
print(secrets.DEFAULT_ENTROPY) # 길이를 생략했을 때 쓰는 바이트 수
try:
random.SystemRandom().getstate()
except Exception as e:
print(type(e).__name__, e)
e6a825116541f1d1a20030e60398aa48
CBomwXz1dT6i6kcAXQqszA
32
NotImplementedError System entropy source does not have state.
마지막 줄이 CSPRNG 의 핵심입니다. 프로세스 안에 누가 복사해 갈 상태 자체가 없습니다.
흔한 실수: random 으로 인증번호 → 복제본이 맞힌다 → 해결
가장 흔한 형태입니다. 장난감 앱이 random.Random 하나를 두 용도로 씁니다. API 응답에 나가는 공개 요청 ID(16진수 8자, 그 자체로는 무해)와 SMS 인증번호입니다. 인증번호는 randrange 로 만들었으니 "32비트 원본이 안 보이니까 괜찮다"고 생각하기 쉽습니다. 요청 ID 를 연속으로 624개 모은 관찰자는 생성기를 다시 만들 수 있습니다.
원리는 이렇습니다. MT19937 의 내부 상태는 624워드입니다. 32비트 출력 하나는 상태 워드 하나를 "템퍼링"이라는 4단계 시프트와 XOR 에 통과시킨 것뿐입니다. 각 단계는 거꾸로 계산할 수 있으므로 출력 하나에서 상태 워드 하나가 그대로 나옵니다. 624개를 모으면 상태 전체가 손에 들어오고, Python 자신의 setstate() 로 복제본에 넣을 수 있습니다.
# mt_otp_ko.py - 내 컴퓨터 안에서만 하는 데모: random 의 상태를 출력 624개로 복원
import random
def unshift_right(y, shift):
result = y
for _ in range(32 // shift + 1):
result = y ^ (result >> shift)
return result & 0xFFFFFFFF
def unshift_left(y, shift, mask):
result = y
for _ in range(32 // shift + 1):
result = y ^ ((result << shift) & mask)
return result & 0xFFFFFFFF
def untemper(y): # 템퍼링 4단계를 역순으로 되돌림
y = unshift_right(y, 18)
y = unshift_left(y, 15, 0xEFC60000)
y = unshift_left(y, 7, 0x9D2C5680)
y = unshift_right(y, 11)
return y
def issue(r): # SMS 인증번호 6자리 발급
return f"인증번호 {r.randrange(10**6):06d}"
victim = random.Random(2026) # 데모용 고정 시드
observed = [victim.getrandbits(32) for _ in range(624)] # 공개 요청 ID
real = [issue(victim) for _ in range(3)] # 다른 사람에게 간 인증번호
clone = random.Random()
clone.setstate((3, tuple(untemper(v) for v in observed) + (624,), None))
guess = [issue(clone) for _ in range(3)]
for a, b in zip(real, guess):
print(a, "|", b, "MATCH" if a == b else "differ")
인증번호 267359 | 인증번호 267359 MATCH
인증번호 824815 | 인증번호 824815 MATCH
인증번호 154857 | 인증번호 154857 MATCH
상태 복원에 걸린 시간은 중앙값 1.08ms(100회 × 5세트)였습니다. (3, state, None) 은 CPython 3.9 의 getstate() 가 돌려주는 형식(버전 3, 624워드 + 위치, 가우시안 캐시 없음)입니다. 길이를 틀리면 ValueError: state vector is the wrong size 가 납니다.
자주 듣는 세 가지 변명과 답:
- "getrandbits 가 아니라 randrange 만 썼는데요." 위 결과가 답입니다. 상태를 얻은 복제본은 그 뒤의 호출을
randrange,choice,shuffle까지 전부 재현합니다. 모두 같은 32비트 흐름에서 꺼내 쓰기 때문입니다. 공개되는 출력 자체가 잘려 있으면 상태 수집이 어려워지지만, 그 방법도 연구로 공개되어 있습니다. "공격자에게 보이는 비트가 적다"를 안전의 근거로 삼지 마세요. - "시드는 os.urandom 에서 가져왔어요." 상관없습니다. 시드는 처음 상태만 정할 뿐이고, 출력에서 상태는 계속 샙니다.
- "624개나 모을 사람은 없어요." 트래픽이 많은 API 라면 몇 초면 나옵니다.
해결: 비밀이 되는 값은 전부 secrets, random 은 시뮬레이션 전용.
import secrets
session_id = secrets.token_urlsafe(32) # 256비트, 쿠키에 넣을 수 있음
reset_token = secrets.token_urlsafe(32) # URL 에 넣고, DB 에는 해시만 저장
api_key = "sk_" + secrets.token_hex(32)
otp = f"{secrets.randbelow(10**6):06d}" # 인증번호: 시도 횟수 제한과 함께
규칙: 맞히면 공격자에게 이득인 값은 OS 의 CSPRNG 에서 꺼낸다. 직접 시드를 넣은 생성기는 쓰지 않는다.
PHP 와 다른 언어
PHP 도 똑같이 둘로 나뉩니다. mt_rand() 와 rand()(PHP 7.1부터 mt_rand 의 별칭)는 메르센 트위스터이고, random_bytes() 와 random_int()(PHP 7.0+)는 OS 의 CSPRNG 를 읽습니다. PHP 8.2 에서 Random\Randomizer 클래스가 들어왔고 기본 엔진은 Random\Engine\Secure 입니다.
<?php
echo bin2hex(random_bytes(16)), "\n"; // 128비트 16진수 토큰
echo rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='), "\n"; // URL 안전
echo str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT), "\n"; // 인증번호
mt_srand(1114); $a = mt_rand(); mt_srand(1114); $b = mt_rand();
echo "mt_rand same seed: $a $b\n";
$s = new Random\Randomizer();
echo get_class($s->engine), "\n";
echo uniqid(), "\n", uniqid(), "\n";
cd11d73e54d4712cfa62290971d93ec7
a6OmaIUvgPVddjIEO3rPkA
473973
mt_rand same seed: 324694876 324694876
Random\Engine\Secure
6af7e6f032055
6af7e6f032076
두 uniqid() 값은 끝 세 자리만 다릅니다. uniqid() 는 현재 시각(마이크로초)을 16진수로 쓴 것이기 때문입니다. 겹치기 어려울 뿐 비밀은 아닙니다. PHP 8.5 는 옛 습관에 경고도 냅니다. MT_RAND_PHP 는 Deprecated: Constant MT_RAND_PHP is deprecated since 8.3, as it uses a biased non-standard variant of Mt19937, lcg_value() 는 Deprecated: Function lcg_value() is deprecated since 8.4, use \Random\Randomizer::getFloat() instead 를 출력했습니다.
| 언어 | 안전한 난수원(따로 적지 않으면 실행함) | 비밀에 쓰지 말 것 |
|---|---|---|
| Python | secrets.token_bytes/hex/urlsafe, os.urandom |
random.* |
| PHP | random_bytes, random_int, Random\Randomizer() |
mt_rand, rand, uniqid, lcg_value |
| Java 26 | java.security.SecureRandom(알고리즘 출력 NativePRNG) |
java.util.Random, Math.random |
| Dart | Random.secure() |
Random() |
| CLI | openssl rand -hex 32 |
셸의 $RANDOM |
| 브라우저 JS | crypto.getRandomValues, crypto.randomUUID(실행 안 함: 여기 jsc 에는 crypto 가 없고 typeof crypto 는 undefined) |
Math.random |
토큰 길이와 추측 시간
충분한 길이는 "비트 수 ÷ 시도 속도"로 정해집니다. 특정 토큰 하나를 맞히려면 평균적으로 전체의 절반을 시도해야 합니다. 속도는 측정값이 아니라 가정입니다. 속도 제한이 없는 온라인 창구라면 초당 1,000회, 오프라인으로 대조할 수 있다면(예: 토큰을 솔트 없는 빠른 해시로 저장했다가 유출) 초당 10⁹회. 마지막 열은 유효한 토큰이 동시에 100만 개 있어서 아무거나 하나만 맞혀도 되는 경우입니다.
| 토큰 | 비트 | 10³회/초 온라인 | 10⁹회/초 오프라인 | 유효 10⁶개, 10⁹회/초 |
|---|---|---|---|---|
| 6자리 숫자 | 19.9 | 8.3분 | 0.0005초 | 즉시 |
| 16진수 8자 | 32 | 24.9일 | 2.15초 | 즉시 |
| 16진수 16자 | 64 | 약 10⁸년 | 292년 | 2.56시간 |
| UUIDv4 | 122 | 약 10²⁶년 | 약 10²⁰년 | 약 10¹⁴년 |
token_hex(16) |
128 | 약 10²⁸년 | 약 10²²년 | 약 10¹⁶년 |
token_urlsafe(32) |
256 | 약 10⁶⁶년 | 약 10⁶⁰년 | 약 10⁵⁴년 |
교훈은 두 가지입니다. 6자리 인증번호가 안전한 이유는 번호 자체가 아니라 짧은 유효 시간과 시도 횟수 제한(예: 5회 틀리면 폐기) 덕분입니다. 정보보안기사 실기에서 "인증번호 무차별 대입" 대책으로 시도 제한이 나오는 이유가 바로 이 표의 첫 줄입니다. 그리고 64비트는 커 보여도 동시에 유효한 토큰이 많으면 확 줄어듭니다. 최소 128비트, 기본 256비트. 비용은 거의 0입니다.
UUIDv4 의 엔트로피: 버전 4 UUID 는 128비트 중 4비트가 버전, 2비트가 변형(variant) 표시라서 랜덤한 것은 122비트입니다. CPython 의 uuid.uuid4() 는 내용이 UUID(bytes=os.urandom(16), version=4) 그대로라서(inspect.getsource 로 확인) Python 에서는 CSPRNG 기반입니다. 다른 라이브러리나 다른 버전은 다릅니다. v1 은 시각과 노드 ID 를 담고, v7 은 밀리초 시각으로 시작합니다. UUID 는 식별자 형식입니다. "비밀"이 필요하면 secrets 를 쓰세요.
시간으로 시드하는 위험을 측정하기
또 하나의 단골 실수는 요청마다 현재 시각으로 다시 시드하는 것입니다. 옛 C 코드의 srand(time(NULL)) 를 따라 하다 자주 생깁니다.
# timeseed.py - 시간 시드가 위험한 이유 (내 컴퓨터 안에서만 하는 데모)
import random, time
def reset_token(now): # 나쁜 예: 요청마다 시각으로 다시 시드
r = random.Random(int(now))
return f"{r.getrandbits(128):032x}"
T = 1794614400 # 고정한 "서버 시각" (2026-11-14 00:00 UTC)
a = reset_token(T + 0.12) # 사용자 A 가 재설정 요청
b = reset_token(T + 0.87) # 사용자 B, 같은 1초 안
print("A:", a); print("B:", b); print("same token:", a == b)
victim = reset_token(T + 37.4) # 관찰자는 시각을 ±1분 범위로 안다
cands = [reset_token(s) for s in range(T - 60, T + 61)]
print("candidates:", len(cands), "victim in list:", victim in cands)
A: 7a7c99a76d9b36196739a5195bf659ee
B: 7a7c99a76d9b36196739a5195bf659ee
same token: True
candidates: 121 victim in list: True
같은 1초에 요청한 두 사람이 같은 재설정 토큰을 받았습니다. 게다가 요청 시각을 대략 아는 사람(직접 요청을 일으킬 수도 있습니다)이 시도할 후보는 121개뿐입니다. 겉보기엔 128비트지만 실제 불확실성은 7비트 정도입니다.
후보는 얼마나 빨리 만들 수 있을까요? 하루 86,400초 각각을 시드로 토큰을 만드는 데 1.15초(5회 중앙값, 1.146~1.158초), 코어 하나로 초당 약 75,000개였습니다. 이 측정 속도로 계산하면 1년치 초 단위 시드는 약 7분, 1시간치 밀리초 단위 시드(360만 개)는 약 48초입니다. 시계를 잘게 쪼개도 구원받지 못합니다.
규칙: 보안 목적으로 생성기에 직접 시드를 넣지 않는다. CSPRNG 는 우리가 시드를 줄 필요가 없다.
속도: 안전한 난수는 느린가
흔한 핑계가 속도입니다. 128비트 토큰 하나를 만드는 호출을 20만 회 × 5세트 측정해 중앙값을 냈습니다.
secrets.token_hex(16) 은 2,109ns, random.getrandbits(128) 은 254ns. 약 8배 느리지만 그래도 2마이크로초입니다. 100만 개를 만들어도 약 2초. PHP 8.5 에서는 bin2hex(random_bytes(16)) 이 310ns, mt_rand() 가 22ns(20만 회 × 5세트 중앙값)였습니다. 둘 다 토큰을 저장하는 DB 쓰기 비용과는 비교도 안 됩니다.
솔직한 한계와 버전 메모
- Python 3.9.6 에서만 실행했습니다.
secrets는 Python 3.6(PEP 506),random.randbytes는 3.9 에서 추가됐습니다. 더 오래된 버전은 시험하지 않았습니다. - 상태 형식
(3, 원소 625개 튜플, None)은 CPython 구현 세부 사항입니다. 3.9.6 에서는 동작했습니다. - PHP
random_bytes/random_int는 7.0부터,Random\Randomizer는 8.2부터. 위의 사용 중단 메시지는 8.5.6 의 것입니다. - 추측 시간 표는 가정한 속도(초당 10³회, 10⁹회)로 계산한 것이며 측정값이 아닙니다. 실제 온라인 공격 속도는 여러분의 속도 제한이 정합니다.
- 브라우저
crypto.getRandomValues, Node 의crypto.randomBytes는 실행하지 않았습니다. - 이 데모에서 관찰자는 32비트 원본 출력(요청 ID)을 봤습니다. 인증번호처럼 잘린 출력만 보고 상태를 복원하는 방법은 연구가 있지만 여기서는 시연하지 않았습니다.
연습 문제
secrets.choice로[a-z0-9]에서 10글자를 균등하게 고른 토큰의 엔트로피는 몇 비트인가? 세션 ID 로 충분한가?- 앱이 비밀번호 재설정 토큰을 DB 에 평문으로 저장하고 있다. 테이블이 유출됐을 때의 위험을 시리즈의 어떤 생각으로 막을 수 있나? 대신 무엇을 저장하나?
정답
- log2(36¹⁰) = 10 × log2(36) ≈ 51.7비트. 세션 ID 로는 짧습니다. 초당 10⁹회 오프라인 시도라면 토큰 하나에 평균 약 21일, 유효한 토큰이 많으면 훨씬 짧아집니다.
secrets.token_urlsafe(32)를 쓰세요. - 토큰의 해시를 저장합니다. 이 토큰은 그 자체로 256비트 랜덤 값이라서, 사람이 만든 비밀번호(2강)와 달리 SHA-256 으로 충분합니다. 링크가 쓰이면 받은 토큰을 해시해서 조회하고, 한 번 쓰거나 만료되면 지웁니다.
다음
- 이전: 보안 기초 강좌 2/6: bcrypt 와 Argon2 로 비밀번호 해시
- 다음: 보안 기초 강좌 4/6: JWT HS256 서명의 함정 (약한 비밀 키가 100단어 목록에 뚫립니다)
- 토큰이 오가는 길: HTTP 쿠키와 세션, PHP XSS·CSRF·세션 보안
- 토큰을 안전하게 비교하는 법은 6강: 타이밍 공격과 상수 시간 비교
자주 묻는 질문
Python random 으로 세션 ID 나 인증번호를 만들어도 되나요?
안 됩니다. random 은 메르센 트위스터(MT19937)라서, 실험에서는 연속된 32비트 출력 624개로 내부 상태를 약 1ms 만에 복원했고 randrange 로 만든 다음 6자리 인증번호 3개를 모두 맞혔습니다. secrets.randbelow, secrets.token_urlsafe 를 쓰세요.
토큰은 몇 비트면 충분한가요?
CSPRNG 에서 최소 128비트, 기본은 256비트(secrets.token_urlsafe(32))를 권합니다. 표에서 32비트 토큰은 초당 10억 회 오프라인 시도로 약 2초, 128비트는 같은 속도로도 약 10^22년이 걸립니다.
UUIDv4 를 비밀 토큰으로 써도 되나요?
Python 의 uuid.uuid4() 는 os.urandom(16) 을 읽으므로 랜덤 부분 122비트는 추측되지 않습니다. 하지만 v1, v7 은 시간을 포함하고, 같은 길이라면 secrets 가 엔트로피가 더 많으므로 비밀 값에는 secrets 를 쓰는 습관이 안전합니다.