dev.to

Django 6.1's PBKDF2 default change rewrites existing password hashes on next login

Django 6.1, 기본 PBKDF2 반복 횟수 변경…다음 로그인 때 기존 해시를 다시 씁니다

Django 6.1은 PBKDF2 기본 반복 횟수를 120만 회에서 150만 회로 올립니다. 기존 해시는 다음 로그인 때 자동 갱신되지만, 기본값보다 강하게 설정한 해시도 새 기본값으로 낮아질 수 있어 직접 반복 횟수를 설정한 서비스는 업그레이드 전에 확인해야 합니다.

AI 요약

Django 6.1은 PBKDF2 비밀번호 해싱의 기본 반복 횟수를 120만 회에서 150만 회로 높였습니다. 작성자는 Django 6.0.9와 6.1.2에서 해싱 시간과 동시 처리량을 측정하고, 로그인 때 저장된 해시가 어떻게 바뀌는지도 확인했습니다. 테스트 환경은 4코어 컨테이너였으며, 각 측정은 세 차례 반복했습니다.

로그인당 CPU 비용은 약 25% 늘어납니다

같은 비밀번호를 각 반복 횟수로 20회 해싱한 결과, 해시 생성 평균 시간은 6.0의 270~285ms에서 6.1의 327~377ms로 늘었습니다. 로그인 시 실행하는 검증 평균 시간도 268~290ms에서 366~378ms로 증가했습니다. 가장 빠른 실행 시간을 비교해도 반복 횟수 증가율인 25%와 비슷하게 시간이 늘었습니다. 작성자는 로그인 한 번에 추가되는 CPU 시간이 머신 상황에 따라 약 50~90ms라고 설명합니다.

다만 이 작업은 단일 스레드에 묶이지 않습니다. Django가 호출하는 CPython의 hashlib.pbkdf2_hmac은 OpenSSL에서 실제 해싱을 수행하는 동안 GIL을 놓습니다. 1, 2, 4, 8개 작업자에서 처리량을 재 보니 각각 초당 2.62~3.03회, 5.16~5.59회, 10.52~10.70회, 10.69~11.03회였습니다. 4개 작업자까지 처리량이 거의 선형으로 증가하고 8개에서는 정체했습니다. 따라서 부하가 높은 서비스에서는 요청 하나의 지연 시간과 별개로 CPU 여유를 고려해야 합니다.

로그인하면 기존 해시를 자동으로 갱신합니다

작성자는 120만 회로 만든 해시를 저장한 사용자로 authenticate()를 한 번 호출했습니다. 로그인은 성공했고, 별도의 마이그레이션 명령이나 애플리케이션 코드의 set_password() 호출 없이 저장된 해시가 150만 회로 바뀌었습니다. 데이터베이스에는 사용자 조회 뒤 password 필드를 갱신하는 UPDATE가 실행됐습니다. 이후 다시 로그인하면 해시가 이미 현재 설정과 일치하므로 UPDATE는 발생하지 않았습니다.

이 동작은 기존 해시를 현재 알고리즘과 매개변수에 맞춰 갱신하는 Django의 일반적인 기능에 따른 것입니다. 따라서 6.0에서 6.1로 올리면 사용자가 로그인할 때마다 해당 계정의 해시가 새 기본값으로 바뀝니다. 일괄 갱신 작업은 필요하지 않습니다.

기본값보다 강한 해시는 낮아질 수 있습니다

문제는 갱신 여부를 판단할 때 저장된 반복 횟수가 현재 값보다 낮은지 확인하지 않고, 현재 값과 다른지만 확인한다는 점입니다. 작성자가 300만 회로 직접 강화한 해시도 로그인 뒤 150만 회로 바뀌었습니다. 알림이나 경고는 없었습니다. 따라서 사용자별 해시를 로그인 시 갱신하는 기능이 기본값보다 약한 해시만 강화한다고 가정하면 안 됩니다.

반복 횟수를 기본값보다 높여 둔 서비스는 6.1로 업그레이드하기 전에 설정을 확인해야 합니다. 댓글에서 작성자는 PASSWORD_HASHERS에 반복 횟수를 직접 적는 방식은 지원하지 않는다고 덧붙였습니다. PBKDF2PasswordHasher를 상속한 클래스를 만들고 iterations 값을 지정한 뒤, 해당 클래스 경로를 PASSWORD_HASHERS 목록 앞에 두면 사용자 해시가 그 설정에 맞춰 유지됩니다.

반복 횟수 설정에는 최소 보장이 없습니다

작성자는 PBKDF2PasswordHasher의 iterations를 1로 설정해도 해시가 생성되는 것을 확인했습니다. 실행 시간은 0.24ms였고, manage.py check --deploy에도 PASSWORD_HASHERS 반복 횟수를 검사하는 항목은 없었습니다. 0보다 작은 값을 넘기면 ValueError가 발생하지만, 1은 허용됩니다. 한편 encode()는 내부에서 iterations = iterations or self.iterations 방식으로 값을 정합니다. 따라서 반복 횟수 0을 명시하면 거부하지 않고 인자를 무시한 뒤 현재 기본값인 150만 회를 사용합니다.

해시 갱신을 구분해 알려주는 로그나 Django 신호도 없습니다. 작성자가 확인한 방법은 password 컬럼의 해시 문자열을 조회해 반복 횟수별 계정 수를 세는 것이었습니다. 예를 들어 pbkdf2_sha256$1500000$로 시작하는 값과 pbkdf2_sha256$1200000$로 시작하는 값을 각각 집계할 수 있습니다.

Django 6.1은 Python 3.12 이상이 필요합니다. 작성자는 처음 Python 3.11 환경에서 설치를 시도했다가 실패했고, python3.12로 가상 환경을 만든 뒤 해결했습니다. 6.0에서 업그레이드할 때는 팀에서 기본값보다 높은 반복 횟수를 설정했는지 확인하고, 로그인 때 그 설정이 낮아져도 괜찮은지 판단하라고 권합니다.

dev.to 반응

  • @analista_83 — GIL을 놓고 해싱한다는 점이 부하 상황을 생각하는 방식을 바꿉니다. 처리량은 코어 수에 따라 늘지만 로그인 한 건의 지연 시간은 줄어들지 않습니다. p95 로그인 SLO에 약 90ms의 여유가 없다면 설정에서 PASSWORD_HASHERS를 명시하세요. APM에서 뒤늦게 발견하는 것보다 코드 리뷰에서 선택을 드러내는 편이 낫습니다.
  • @mp6nfjxhrlxc — 로그인 때 해시를 갱신하는 동작은 의도된 것입니다. 사용자가 인증할 때마다 Django가 해시를 현재 알고리즘과 매개변수로 다시 써서 오래된 해시를 자연스럽게 옮깁니다. 실제 문제는 저장된 해시의 반복 횟수가 새 기본값보다 높아도 무조건 덮어쓴다는 점입니다. 관리자가 명시적으로 선택한 보안 설정을 조용히 낮춥니다. 실용적인 방법은 기본값보다 높게 설정한 반복 횟수를 PASSWORD_HASHERS에 고정하는 것입니다.
    • @alexgeorgiev17 — 네, 저도 그 부분이 문제라고 봤습니다. 로그인 때 갱신하는 동작 자체가 아니라, 현재 값과 다른지만 비교하고 더 작은지 확인하지 않는 점이 문제입니다. 설정에 대한 작은 정정이 있습니다. PASSWORD_HASHERS에는 클래스 경로만 넣을 수 있으므로 반복 횟수를 직접 지정할 수 없습니다. PBKDF2PasswordHasher를 상속해 iterations = 3_000_000처럼 설정한 뒤 그 클래스를 목록 앞에 넣어야 합니다. 그러면 비교 기준이 그 값이 되어 반복 횟수가 낮아지지 않습니다. 두 번째 방법은 잘렸던 것 같은데, 무엇이었나요?

원문: dev.to / 번역·요약: Trawling