요즘은 깊은 좌절감에 "비밀번호 복구" 버튼을 클릭하지 않은 사람은 거의 없습니다. 비밀번호가 의심할 여지없이 정확해 보이더라도 비밀번호 복구의 다음 단계는 이메일의 링크를 방문하여 새 비밀번호를 입력하는 것으로 대부분 순조롭게 진행됩니다. (아무도 속이지 마세요. 방금 입력한 비밀번호이기 때문에 새로운 비밀번호는 아닙니다.) 불쾌한 버튼을 누르기 전에 1단계에서 이미 세 번).
그러나 이메일 링크 이면의 논리는 세대를 불안전하게 두면 사용자 계정에 대한 무단 액세스와 관련된 수많은 취약점이 발생하므로 면밀히 조사해야 합니다. 불행하게도 다음은 보안 지침을 따르지 않는 UUID 기반 복구 URL 구조의 한 예입니다.
https://.../recover/d17ff6da-f5bf-11ee-9ce2-35a784c01695
이러한 링크를 사용하면 일반적으로 누구나 귀하의 비밀번호를 알 수 있으며 그만큼 간단합니다. 이 문서의 목표는 UUID 생성 방법을 자세히 살펴보고 해당 애플리케이션에 대한 안전하지 않은 접근 방식을 선택하는 것입니다.
UUID는 두 가지 중요한 속성을 가진 의사 난수 식별자를 생성하는 데 일반적으로 사용되는 128비트 레이블입니다. 충분히 복잡하고 고유합니다. 대부분 이는 ID가 백엔드를 떠나 프런트엔드에서 명시적으로 사용자에게 표시되거나 일반적으로 관찰할 수 있는 API를 통해 전송되는 ID에 대한 주요 요구 사항입니다. 이는 id = 123(복잡성)과 비교하여 추측이나 무차별 대입을 어렵게 만들고 생성된 ID가 이전에 사용된 ID(예: 0에서 1000(고유성)까지의 난수)와 중복될 때 충돌을 방지합니다.
"충분한" 부분은 실제로 일부 버전의 Universally Unique IDentifier에서 나오므로 약간의 중복 가능성이 열려 있습니다. 그러나 이는 추가 비교 논리로 쉽게 완화되고 제어하기 어려운 조건으로 인해 위협을 가하지 않습니다. 그 발생. 둘째, 다양한 UUID 버전의 복잡성에 대한 설명이 기사에 설명되어 있으며 일반적으로 추가 특수 사례를 제외하고는 상당히 양호한 것으로 가정됩니다.
데이터베이스 테이블의 기본 키는 UUID와 마찬가지로 복잡하고 고유하다는 동일한 원칙에 의존하는 것으로 보입니다. 많은 프로그래밍 언어 및 데이터베이스 관리 시스템에서 생성을 위한 내장 방법이 널리 채택됨에 따라 UUID는 저장된 데이터 항목을 식별하는 첫 번째 선택으로 사용되는 경우가 많으며 일반적으로 테이블과 정규화로 분할된 하위 테이블을 조인하는 필드로 사용됩니다. 특정 작업에 대한 응답으로 API를 통해 데이터베이스에서 가져온 사용자 ID를 보내는 것은 추가 임시 ID 생성 없이 데이터 흐름을 더 간단하게 통합하고 이를 프로덕션 데이터 저장소의 ID에 연결하는 일반적인 방법입니다.
비밀번호 재설정 예의 관점에서 보면 아키텍처에는 사용자가 버튼을 클릭할 때마다 생성된 UUID가 포함된 데이터 행을 삽입하는 작업을 담당하는 테이블이 포함될 가능성이 높습니다. user_id로 사용자와 연결된 주소로 이메일을 보내고 재설정 링크가 열린 후 가지고 있는 식별자를 기반으로 비밀번호를 재설정할 사용자를 확인하여 복구 프로세스를 시작합니다. 그러나 사용자에게 표시되는 식별자에 대한 보안 지침이 있으며 UUID의 특정 구현은 다양한 수준의 성공으로 이를 충족합니다.
UUID 생성 버전 1은 128비트를 장치 생성 식별자의 48비트 MAC 주소, 60비트 타임스탬프, 증분 값용으로 저장된 14비트, 버전 관리용 6비트로 분할합니다. 따라서 고유성 보장은 코드 논리의 규칙에서 생산 중인 모든 새 기계에 대한 값을 올바르게 할당해야 하는 하드웨어 제조업체로 이전됩니다. 유용한 변경 가능한 페이로드를 나타내기 위해 60+14비트만 남겨두면 특히 그 뒤에 있는 투명한 논리로 인해 식별자의 무결성이 저하됩니다. 결과적으로 생성된 UUID v1 번호의 시퀀스를 살펴보겠습니다.
from uuid import uuid1 for _ in range(8): print(uuid1())
d17ff6da-f5bf-11ee-9ce2-35a784c01695 d17ff6db-f5bf-11ee-9ce2-35a784c01695 d17ff6dc-f5bf-11ee-9ce2-35a784c01695 d17ff6dd-f5bf-11ee-9ce2-35a784c01695 d17ff6de-f5bf-11ee-9ce2-35a784c01695 d17ff6df-f5bf-11ee-9ce2-35a784c01695 d17ff6e0-f5bf-11ee-9ce2-35a784c01695 d17ff6e1-f5bf-11ee-9ce2-35a784c01695
보시다시피 "-f5bf-11ee-9ce2-35a784c01695" 부분은 항상 동일하게 유지됩니다. 변경 가능한 부분은 단순히 시퀀스 3514824410 - 3514824417의 16비트 16진수 표현입니다. 이는 일반적으로 생산 값이 더 큰 시간 간격을 두고 생성되므로 타임스탬프 관련 부분도 변경되므로 피상적인 예입니다. 60비트 타임스탬프 부분은 또한 더 큰 ID 샘플에서 식별자의 더 중요한 부분이 시각적으로 변경된다는 것을 의미합니다. 핵심 요점은 동일하게 유지됩니다. UUIDv1은 쉽게 추측할 수 있지만 처음에는 무작위로 보입니다.
주어진 8개 ID 목록에서 첫 번째 값과 마지막 값만 가져옵니다. 식별자는 엄격하게 생성되므로 결과적으로 주어진 두 개 사이에 생성된 ID는 6개(16진수 변경 가능한 부분을 빼서)임이 분명하며 해당 값도 확실하게 찾을 수 있습니다. 이러한 논리의 추정은 UUID가 이 두 경계 값을 알지 못하도록 무차별 공격을 가하는 것을 목표로 하는 소위 샌드위치 공격의 기본 부분입니다. 공격 흐름은 간단합니다. 사용자는 대상 UUID 생성이 발생하기 전에 UUID A를 생성하고 직후에 UUID B를 생성합니다. 정적 48비트 MAC 부분이 있는 동일한 장치가 세 세대 모두를 담당한다고 가정하면 대상 UUID가 있는 A와 B 사이의 잠재적 ID 시퀀스로 사용자를 설정합니다. 생성된 ID와 대상 간의 시간 근접성에 따라 범위는 무차별 접근 방식으로 액세스할 수 있는 볼륨일 수 있습니다. 가능한 모든 UUID를 확인하여 비어 있는 것 중에서 기존 UUID를 찾으세요.
앞서 설명한 비밀번호 복구 엔드포인트를 사용한 API 요청에서는 기존 URL을 명시하는 응답이 발견될 때까지 결과적인 UUID와 함께 수백 또는 수천 개의 요청을 보내는 것으로 변환됩니다. 비밀번호 재설정을 사용하면 사용자가 액세스할 수 없지만 이메일/로그인만 알고 있는 대상 계정의 복구 버튼을 누르기 위해 가능한 한 밀접하게 제어하는 두 계정에 복구 링크를 생성할 수 있는 설정으로 이어집니다. 그러면 복구 UUID A와 B가 있는 제어 계정에 대한 편지가 알려지고, 실제 재설정 이메일에 액세스하지 않고도 대상 계정의 비밀번호를 복구하기 위한 대상 링크가 무차별 공격을 받을 수 있습니다.
취약점은 사용자 인증을 위해 UUIDv1에만 의존한다는 개념에서 비롯됩니다. 비밀번호 재설정에 대한 액세스 권한을 부여하는 복구 링크를 전송함으로써 링크를 따라가는 사용자가 링크를 수신해야 하는 사람으로 인증되는 것으로 가정됩니다. 이것은 마치 누군가의 문이 두 이웃 문의 열쇠가 어떻게 생겼는지 알면 열 수 있는 것과 같은 방식으로 UUIDv1이 직접적인 무차별 대입에 노출되어 인증 규칙이 실패하는 부분입니다.
UUID의 첫 번째 버전은 생성 로직이 식별자 크기의 작은 부분만 무작위 값으로 사용하기 때문에 부분적으로 레거시로 간주됩니다. v4와 같은 다른 버전에서는 버전 관리를 위한 공간을 최대한 적게 유지하고 최대 122비트를 무작위 페이로드로 남겨 두어 이 문제를 해결하려고 합니다. 일반적으로 이는 2^122
에 가능한 전체 변형을 가져오며, 현재로서는 식별자 고유성 요구 사항과 관련된 "충분한" 부분을 충족하여 보안 표준을 충족하는 것으로 간주됩니다. 생성 구현이 무작위 부분에 남겨진 비트를 크게 줄이는 경우 무차별 대입 취약점이 나타날 수 있습니다. 하지만 제작 도구나 라이브러리가 없는데도 그럴까요?
암호화에 조금 빠져들어 UUID 생성에 대한 JavaScript의 일반적인 구현을 자세히 살펴보겠습니다. 다음은 의사 난수 생성을 위해 math.random
모듈을 사용하는 randomUUID()
함수입니다:
Math.floor(Math.random()*0x10);
그리고 무작위 함수 자체는 간단히 말해서 이 기사의 주제에 대한 관심의 일부일 뿐입니다.
hi = 36969 * (hi & 0xFFFF) + (hi >> 16); lo = 18273 * (lo & 0xFFFF) + (lo >> 16); return ((hi << 16) + (lo & 0xFFFF)) / Math.pow(2, 32);
의사 난수 생성에는 충분한 난수 시퀀스를 생성하기 위해 수학적 연산을 수행하기 위한 기반으로 시드 값이 필요합니다. 이러한 함수는 전적으로 이를 기반으로 합니다. 즉, 이전과 동일한 시드로 다시 초기화되면 출력 시퀀스가 일치하게 됩니다. 문제의 JavaScript 함수의 시드 값은 변수 hi 및 lo로 구성되며, 각각은 32비트 부호 없는 정수(10진수 0~4294967295)입니다. 암호화 목적을 위해서는 두 가지의 조합이 필요하며, 이는 큰 숫자를 사용한 정수 분해의 복잡성에 의존하기 때문에 배수를 알고 두 초기 값을 완전히 반전시키는 것이 거의 불가능합니다.
두 개의 32비트 정수는 UUID를 생성하는 초기화된 함수 뒤에 있는 hi 및 lo 변수를 추측하기 위한 2^64
가능한 경우를 제공합니다. hi 및 lo 값이 어떻게든 알려지면 생성 함수를 복제하고 해당 함수가 생성하고 시드 값 노출로 인해 미래에 생성할 모든 값을 아는 데 노력이 필요하지 않습니다. 그러나 보안 표준의 64비트는 측정 가능한 기간 동안 무차별 공격을 허용하지 않는 것으로 간주될 수 있습니다. 항상 그렇듯이 문제는 특정 구현에서 발생합니다. Math.random()
hi 및 lo 각각에서 다양한 16비트를 가져와 32비트 결과로 만듭니다. 그러나 그 위에 있는 randomUUID()
.floor()
작업으로 인해 값을 다시 한 번 이동하고 갑자기 의미 있는 유일한 부분은 이제 hi에서만 독점적으로 나옵니다. 어떤 식으로든 생성에 영향을 주지는 않지만 전체 생성 기능 시드에 대해 2^32
가능한 조합만 남기므로 암호화 접근 방식이 무너집니다(lo를 임의의 값으로 설정할 수 있으므로 hi와 lo 모두 무차별 대입할 필요가 없습니다). 값에 영향을 미치지 않으며 출력에 영향을 미치지 않습니다).
무차별 대입 흐름은 단일 ID를 획득하고 이를 생성할 수 있는 가능한 높은 값을 테스트하는 것으로 구성됩니다. 일부 최적화 및 평균 노트북 하드웨어를 사용하면 몇 분 밖에 걸리지 않으며 샌드위치 공격처럼 서버에 많은 요청을 보낼 필요가 없고 모든 작업을 오프라인으로 수행합니다. 이러한 접근 방식의 결과로 백엔드에서 사용되는 생성 기능 상태의 복제가 발생하여 비밀번호 복구 예제에서 생성된 모든 링크와 향후 재설정 링크를 얻을 수 있습니다. 취약점이 나타나는 것을 방지하는 단계는 간단하며 crypto.randomUUID()
와 같은 암호화 보안 기능을 사용하는 것이 좋습니다.
UUID는 훌륭한 개념이며 많은 응용 분야에서 데이터 엔지니어의 삶을 훨씬 쉽게 만듭니다. 그러나 이 문서에서 생성 기술의 특정 경우에 결함이 드러나는 것처럼 인증과 관련하여 사용해서는 안 됩니다. 분명히 모든 UUID가 안전하지 않다는 생각으로 해석되지는 않습니다. 그러나 기본적인 접근 방식은 보안을 위해 이를 전혀 사용하지 않도록 사람들을 설득하는 것입니다. 이는 문서에서 사용할 문서에 복잡한 제한을 설정하거나 그러한 목적으로 문서를 생성하지 않는 방법을 설정하는 것보다 더 효율적이고 안전합니다.