Argon2는 왜 반복 횟수가 아니라 메모리로 GPU 크래킹을 막는가

seonwoo_jung·4일 전

1. 도입 — "반복을 더 돌려서 안전한 것"이 아니었다

비밀번호 저장에 bcrypt 대신 Argon2를 쓰라는 권고는 흔히 듣는다. 그런데 "왜 더 안전한가"를 물으면 나는 오랫동안 "반복 횟수를 더 많이 돌리니까"라고 답해 왔다. 이건 틀린 요약이다. Argon2의 방어력 핵심은 시간(iteration)이 아니라 메모리이고, 그 메모리 강제가 하드웨어 수준에서 GPU·ASIC 크래킹의 경제학을 왜 무너뜨리는지 이 글에서 내부 흐름(블록 행렬 → 압축함수 → 참조 인덱싱)을 따라가며 정리한다.

2. 핵심 개념 — memory-hard 함수란

Argon2는 해시 한 번을 계산하려면 수십~수백 MiB의 메모리를 실제로 채우고 무작위로 되읽게 강제해, 연산 코어는 많아도 빠른 메모리는 적은 GPU·ASIC의 병렬 크래킹 이점을 구조적으로 제거하는 memory-hard 함수다.

Argon2는 2015년 PHC(Password Hashing Competition) 우승작이고 RFC 9106으로 표준화됐다. 입력 파라미터의 핵심은 셋의 역할 분담이다: m(KiB)은 채워야 하는 메모리 양, t(passes)는 그 메모리를 몇 번 재사용하며 갈아엎는지, p(lanes)는 몇 개 lane으로 병렬 계산하는지다. 방어의 무게중심은 m에 있고 t는 시간비용을 선형으로 올리는 보조 손잡이일 뿐이다. 변종 y는 d=0, i=1, id=2로 구분된다.

3. 내부 동작 — 블록 행렬을 채우는 과정

메모리는 1024바이트 블록 단위로 잡힌다. 총 블록 수 m'4p의 배수로 내림 정렬되고, 이를 p개 lane(행) × q = m'/p개 column(열)의 행렬 B[i][j]로 본다. 각 lane은 다시 4개 slice로 세로 분할되며, 이 slice 경계가 lane 간 동기화 배리어가 된다.

        col 0 ...... slice0 | slice1 | slice2 | slice3 ...... col q-1
lane 0  [B00][B01] ..........................................[B0,q-1]
lane 1  [B10][B11] ..........................................[B1,q-1]
 ...              (lane끼리는 이미 끝난 slice의 블록만 서로 참조 가능)
lane p-1[..]                                                 [Bp-1,q-1]
                                                                 |
   최종 태그 = H'( B[0][q-1] XOR B[1][q-1] XOR ... XOR B[p-1][q-1] )

각 lane의 앞 두 블록만 초기 해시 H0 = BLAKE2b(모든 파라미터 ‖ P ‖ S ‖ ...)에서 직접 나오고, 나머지는 모두 두 블록을 섞어 만든다.

B[i][j] = G( B[i][j-1],  B[i'][j'] )   // t>1 pass에선 기존 값과 XOR해 덮어씀
  • B[i][j-1]은 같은 lane의 직전 블록.
  • B[i'][j']참조 블록 — 인덱싱 함수가 정한, 지금까지 계산된 임의 위치의 블록. 이 "무작위 되읽기"가 memory-hardness의 심장이다.

압축함수 G(X,Y)는 먼저 R = X XOR Y(1024B를 8×8 워드 행렬로 봄)를 만든 뒤, BLAKE2b 라운드 함수를 행 방향 8번 → 열 방향 8번 적용해 확산시킨 Z를 얻고, 최종 출력은 Z XOR R이다. 결국 1024B 전체가 서로에게 영향을 주도록 뒤섞인다.

헷갈리는 지점 ① — 변종은 "참조 인덱스를 어디서 뽑나"로 갈린다

참조 위치 (i', j')는 두 값 J1, J2에서 나온다. 이 값을 어디서 얻느냐가 d/i/id를 가른다.

변종J1,J2 출처성질
Argon2d직전 블록의 첫 64비트 (데이터 의존)접근 패턴이 비밀에 의존 → 캐시 타이밍 사이드채널 취약, 대신 TMTO 저항 최대
Argon2iG를 카운터 모드로 돌려 생성 (데이터 독립)접근 패턴이 입력과 무관 → 사이드채널 안전, 대신 트레이드오프 공격 여지
Argon2id첫 pass 전반은 i처럼, 이후는 d처럼둘의 절충. RFC 9106 기본 권장

J2는 참조할 lane을, J1은 그 lane 안 참조 area 내 상대 위치를 정한다(최근 블록에 더 몰리는 비균등 매핑). 다른 lane은 직전 slice까지만 참조할 수 있어, 이 제약 덕에 lane들을 slice 단위로 병렬 계산할 수 있다.

헷갈리는 지점 ② — 왜 이게 GPU/ASIC을 막나

공격자가 초당 수십억 해시를 뽑으려면 후보 비밀번호마다 독립적으로 m KiB 메모리를 잡고 무작위 접근해야 한다. GPU는 산술 코어가 수천 개지만 코어당 붙는 빠른 메모리(레지스터·공유메모리)는 작다. m을 64MiB로 잡으면 동시 실행 가능한 해시 수가 메모리 대역폭·용량에 묶여 급감한다. bcrypt(~4KiB 고정)나 PBKDF2(메모리 거의 0)가 GPU에 잘 뚫리는 이유가 바로 이 메모리 비용의 부재다.

"그럼 메모리를 안 쓰고 그때그때 다시 계산하면 되지 않나"(time-memory tradeoff, TMTO)가 자연스러운 반격이다. 하지만 참조 블록이 임의 위치를 가리키고 각 블록이 압축함수로 전부 뒤섞여 있어, 한 블록을 버렸다 다시 만들려면 그것이 참조한 블록, 또 그게 참조한 블록…으로 재계산이 연쇄 폭발한다. RFC 9106은 메모리를 절반으로 줄이면 계산량이 큰 상수배로 뛰도록 설계됐다고 기술한다.

4. 예시 — Spring Security Argon2PasswordEncoder

// Spring Security 6 — Argon2id, m=16MiB, t=3, p=1
Argon2PasswordEncoder enc =
    new Argon2PasswordEncoder(16, 32, 1, 1 << 14, 3); // saltLen, hashLen, p, m(KiB), t
String h = enc.encode("s3cr3t");
// -> $argon2id$v=19$m=16384,t=3,p=1$<salt>$<hash>
//    파라미터가 문자열에 박혀 있어 matches()가 같은 m/t/p로 재계산해 비교
enc.matches("s3cr3t", h); // true

m=16384(KiB)는 해시 1회에 16MiB를 실제로 채운다는 뜻이다. 즉 검증 서버도 로그인마다 그만큼 메모리를 쓴다 — 로그인 폭주 시 DoS를 고려해야 하는 지점이다. 파라미터가 인코딩 문자열에 함께 박히므로, 나중에 m·t·p를 올려도 기존 해시는 자기 파라미터로 검증되고 신규 해시만 강해진다.

주의할 오해 하나: salt는 속도와 무관하다. salt는 레인보우 테이블 무력화와 "같은 비번 → 같은 해시" 방지용이고, 느리게 만드는 비용은 오직 m·t·p에서 나온다. 둘을 뭉뚱그리면 salt를 늘려 보안을 높이려는 헛수고를 하게 된다.

5. 정리

Argon2의 방어력은 "반복을 더 돌려서"가 아니라 "큰 m으로 메모리를 강제로 채우고 무작위로 되읽게 만들어" 나온다. t는 보조, m이 핵심, 실무 기본 변종은 Argon2id다.

다음에 더 파고들 만한 것:

  • bcrypt의 EksBlowfish 키 스케줄과 cost factor가 왜 메모리는 안 늘리고 시간만 늦추는가 — Argon2와의 정량 비교.
  • scrypt의 ROMix/BlockMix와 Argon2의 TMTO 저항 정의가 어떻게 다른가.

참고 자료

  • RFC 9106 — Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work (§3 알고리즘, §4 파라미터 권장, Appendix 테스트 벡터)
  • PHC(Password Hashing Competition) 2015 선정 요약
  • Spring Security Argon2PasswordEncoder 문서

0개의 댓글