단 하나의 XSS 취약점만으로도 패스키는 피싱에 강한 로그인 수단에서 지속적인 계정 탈취 백도어로 바뀔 수 있다. 이 글은 attestation none의 위험, 공격이 가능한 방식, 그리고 효과적인 방어책을 설명한다.
단 하나의 XSS 취약점만으로도 패스키는 피싱에 강한 로그인 메커니즘에서 지속적인 계정 탈취 백도어로 바뀔 수 있습니다. 악성 JavaScript가 페이지에서 실행될 수 있다면, 공격자가 제어하는 패스키를 피해자의 계정에 등록할 수 있을지도 모릅니다. 사용자는 아무것도 보지 못하고, 웹사이트는 등록이 성공했다고 기록하며, 공격자는 유효한 인증 백도어를 손에 넣고 떠나게 됩니다.
조직의 관점에서 이것은 단순히 “누군가가 XSS를 발견했다”는 의미를 넘습니다. 이는 신원 침해, 지속성 확보, 감사 추적의 모호성, 규제 노출, 그리고 겉보기에는 제대로 작동한 듯 보이지만 실제로는 조용히 공격자를 돕는 보안 통제를 뜻합니다.
불편한 진실은, 패스키가 놀라운 이점을 가져다주는 것은 맞고 저 역시 모두가 사용해야 한다고 생각하지만, 제가 이야기하는 거의 모든 사람이 간과하고 있는 위협 모델의 위험한 공백이 존재한다는 점입니다. 이 글에서는 그 위험을 설명하고, 이것이 어떻게 가능한지 보여주며, 효과적인 방어가 어떤 모습인지 다룹니다.
시작하기 전에, 패스키가 어떻게 동작하는지에 대한 간단한 개요를 원한다면 제 Passkeys 101 블로그 글로 가보셔도 좋습니다. 그 글에서는 기본 개념을 설명합니다. 이 글에서는 여러분이 패스키의 개념을 이미 이해하고 있다고 가정하고, 좀 더 자세히 작동 방식을 살펴보겠습니다.
또한 이 글의 나머지 내용을 더 쉽게 이해할 수 있도록 몇 가지 용어를 정리할 필요가 있습니다.
Relying Party: 사용자의 패스키 자격 증명을 저장하고 인증 시 검증하는 웹사이트 또는 애플리케이션입니다.
Authenticator: 개인 키를 생성, 저장, 사용하여 사용자의 신원을 Relying Party에 증명하는 사용자 기기 또는 비밀번호 관리자입니다.
Attestation: 등록 과정에서 어떤 종류의 하드웨어가 자격 증명을 생성했는지 Authenticator가 증명할 수 있게 해주는 메커니즘입니다.
Report URI 같은 RP에 패스키를 등록할 때, JavaScript는 필요한 데이터를 가져오기 위해 다음과 같이 호출합니다.
const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' });
POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Cookie: session=...
Content-Length: 0
RP는 다음과 같은 응답을 반환하며, 여기에는 publicKey 객체가 포함됩니다.
HTTP/1.1 200 OK
Content-Type: application/json
{
"publicKey": {
"rp": {
"name": "Report URI",
"id": "report-uri.com"
},
"user": {
"id": "Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=",
"name": "jane@example.com",
"displayName": "jane@example.com"
},
"challenge": "kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J",
"pubKeyCredParams": [
{ "type": "public-key", "alg": -8 },
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"timeout": 60000,
"authenticatorSelection": {
"requireResidentKey": true,
"residentKey": "required",
"userVerification": "required"
},
"attestation": "none",
"excludeCredentials": [
{
"type": "public-key",
"id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...",
"transports": ["usb", "nfc", "ble", "hybrid", "internal"]
}
]
}
이제 기기가 필요한 정보를 갖추었으므로 새 패스키를 생성하고 저장할 수 있으며, 아마도 PIN, FaceID, TouchID 등이 필요한 어떤 종류의 확인 절차가 표시될 것입니다. 이것은 다음 JavaScript API 호출로 이루어지며, Authenticator와의 상호작용을 트리거합니다.
const cred = await navigator.credentials.create({ publicKey });
과정을 완료하면 Authenticator는 새 패스키를 저장합니다. 그러면 JavaScript는 모든 절차가 완료되었음을 RP에 확인하고 사용자의 계정에 새 패스키를 저장하기 위해 전송할 응답을 구성합니다.
const payload = {
name: nameInput?.value?.trim() || '',
password: passwordInput.value,
id: cred.id,
rawId: cred.rawId,
type: cred.type,
clientDataJSON: cred.response.clientDataJSON,
attestationObject: cred.response.attestationObject,
};
const finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
attestationObject에는 중요한 정보가 들어 있고, 그 외의 것들은 대부분 메타데이터입니다. 공개 키가 핵심인 attestationObject의 내용은 다음과 같습니다.
attestationObject (CBOR)
├─ fmt ← attestation format, e.g. "none" / "apple"
├─ authData ← authenticator data
│ ├─ rpIdHash ← SHA-256 hash of the RP ID
│ ├─ flags ← UP/UV/AT/ED flags, etc.
│ ├─ signCount ← signature counter
│ └─ attestedCredentialData
│ ├─ aaguid ← type/model id, not useful for synced passkeys
│ ├─ credentialIdLength
│ ├─ credentialId ← credential is, also surfaced as id/rawId
│ └─ credentialPublicKey ← COSE-encoded public key
└─ attStmt ← attestation statement; empty for fmt "none"
이제 RP는 공개 키를 사용자 계정에 저장할 수 있고, 이것이 앞으로 인증에 사용할 수 있는 패스키라는 점을 알게 됩니다. 저장된 레코드는 대략 다음과 같은 형태일 수 있습니다.
{
"id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
"name": "Jane's MacBook",
"pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n",
"counter": 0,
"created": "2026-05-16T14:22:07+00:00"
}
로그인 과정도 마찬가지로 매우 단순하며, 패스키로 인증을 성공적으로 완료하는 데 몇 단계만 필요합니다. 먼저 JavaScript는 RP에서 인증에 필요한 정보를 가져와야 합니다.
const optRes = await fetch('/passkeys/login_get_options/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin' });
POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Cookie: session=...
Content-Length: 0
RP는 필요한 정보가 담긴 publicKey 객체로 응답합니다.
HTTP/1.1 200 OK
Content-Type: application/json
{
"publicKey": {
"challenge": "Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J",
"timeout": 20000,
"rpId": "report-uri.com",
"userVerification": "required",
"allowCredentials": [
{
"id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
"type": "public-key",
"transports": ["usb", "nfc", "ble", "hybrid", "internal"]
}
]
}
}
RP에 어떤 사용자/계정이 로그인을 시도하고 있는지 알려줄 방법이 반드시 있어야 합니다. Report URI는 첫 단계에서 사용자가 이미 이메일 주소와 비밀번호 입력을 마쳤다는 점에 의존하지만, 어떤 웹사이트는 이메일 주소만 물어보기도 합니다. RP에서 돌아온 응답은 이 경우 jane@example.com 사용자의 계정을 조회한 뒤여야 하며, 이제 이전에 등록된 패스키의 id 값들인 allowCredentials 목록을 제공합니다. 앞선 등록 단계에서 id 값이 AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc인 패스키를 등록한 것을 볼 수 있었고, 이제 로그인 중 허용된 자격 증명으로 이것이 다시 반환되었습니다. 이제 우리는 다음 JavaScript API 호출을 사용해 이 값을 Authenticator에 전달할 수 있습니다.
const assertion = await navigator.credentials.get({ publicKey });
이 시점에서 Authenticator는 PIN, FaceID, TouchID 또는 유사한 것을 요구할 수 있으며, 그런 다음 앞서 등록 시 저장해 두었던 연관된 개인 키로 챌린지에 서명합니다. 어떤 개인 키를 사용할지는 제공된 id를 기준으로 식별됩니다. 이렇게 서명된 챌린지는 개인 키 보유를 증명하기 위해 RP로 반환될 수 있습니다.
const payload = {
id: assertion.id,
rawId: assertion.rawId,
type: assertion.type,
clientDataJSON: assertion.response.clientDataJSON,
authenticatorData: assertion.response.authenticatorData,
signature: assertion.response.signature,
userHandle: assertion.response.userHandle || '',
};
const finRes = await fetch('/passkeys/login_finish/' + getCsrfToken(), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1
Host: report-uri.com
Content-Type: application/json
Cookie: session=...
{
"id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc",
"rawId": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==",
"type": "public-key",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==",
"signature": "MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m",
"userHandle": "T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w="
}
그 다음 RP가 등록 과정에서 저장해 둔 공개 키를 사용해 이 페이로드의 서명을 성공적으로 검증할 수 있다면, 로그인하려는 사용자는 저장된 공개 키에 대응하는 개인 키를 보유하고 있음을 증명한 것입니다. 이는 곧 패스키로 인증을 완료했다는 뜻이며, 이제 계정 접근 권한을 부여할 수 있습니다.
Attestation은 꽤 중요한 개념이지만, 클라이언트가 /passkeys/register_get_options를 호출했던 등록 과정을 다시 보면 RP가 보낸 응답에 다음이 포함되어 있다는 점을 알 수 있습니다.
{
...
"attestation": "none",
...
}
Attestation은 여러분의 애플리케이션, 즉 RP가 ‘내가 지금 어떤 종류의 authenticator와 상호작용하고 있는가?’라는 질문에 답할 수 있게 해줍니다. 그리고 이 답은 하드웨어 수준의 답이며, 검증 가능한 형태로 제공됩니다. 훌륭하게 들리는데, 그렇다면 왜 Report URI는 이를 요구하지 않을까요?
Attestation이 동작하려면, 먼저 패스키를 생성할 수 있는 등록된 모든 authenticator의 인증서를 확보해야 합니다. 이 정보는 FIDO Alliance의 Metadata Service(MDS3)에서 얻을 수 있으며, 파일을 다운로드하고 서명을 검증한 뒤 모든 인증서를 파싱하면 됩니다. 최신 상태를 유지하려면 대략 한 달에 한 번 정도 이 작업을 해야 하며, 그 다음 애플리케이션에 패스키를 등록하는 authenticator에 대해 attestation을 요청할 수 있습니다.
그 다음 Attestation은 authenticator가 특정 제조사의 정품 authenticator임을 증명하는 서명입니다. 예를 들어 YubiKey라고 해보겠습니다. 우리 애플리케이션은 앞서 받아둔 인증서를 사용해 그 서명을 검증할 수 있고, 그러면 실제 정품 YubiKey와 상호작용하고 있다는 확신을 가질 수 있습니다. 등록 흐름에서 authenticator는 다음과 같은 attStmt를 포함한 attestationObject를 제공합니다.
"attStmt": {
"alg": -7, // COSE alg of the signature (e.g. -7 = ES256)
"sig": h'3045022100…', // sig over (authData ‖ SHA-256(clientDataJSON))
"x5c": [ // attestation certificate chain, leaf first
h'308202bd30820…', // leaf: the authenticator's attestation cert
h'30820336308…' // (optional) intermediate CA cert(s)
]
}
그렇다면 도대체 왜 우리 애플리케이션에 패스키를 등록할 때 attestation을 요구하지 않는 것일까요?
아무도 언급하지 않는 트레이드오프입니다! authenticator가 자신이 어떤 하드웨어 기기인지 암호학적으로 증명할 수 있는 능력은 보안 측면에서 엄청난 이점이라고밖에 설명할 수 없습니다. 하지만 그 이점에는 비용이 따릅니다.
우리는 현재 MDS3 목록을 가져와 그 안에 무엇이 있는지 살펴봤고, Yubico, Feitian, Thales, Ledger, 플랫폼 TPM/Hello authenticator 등 여러 항목을 확인했습니다. 문제는 우리가 보지 못한 것들이었습니다. 1Password, LastPass, Bitwarden, Dashlane, iCloud Keychain, Google Password Manager, Chrome 내장 저장소 등입니다. 이것은 이 회사들의 실수가 아니라 설계 선택입니다.
원래 패스키의 개념은 개인 키가 Secure Enclave, TPM 또는 유사한 보안 저장소 안에서 단일 기기에 잠겨 있도록 하는 것이었습니다. 저는 제 온라인 계정에 패스키를 등록하고 그것을 “Scott's Laptop”으로 저장하며, 그 패스키는 영원히 제 노트북에, Windows를 사용하는 제 경우 TPM 안에 안전하게 남아 있게 됩니다. 이것은 엄청난 보안적 초능력이지만 대가가 있습니다. 만약 노트북을 잃어버리거나, 커피를 쏟거나, 심각하게 고장 나서 magic smoke가 빠져나가 버리면 큰일입니다. 그러면 다른 어딘가의 기기에 이미 제 계정용 패스키가 등록되어 있어서 그 기기로 로그인할 수 있어야 하며, 그렇지 않다면 정말 곤란해집니다. 온라인 계정에 접근하기 위해 각 기기마다 개별 패스키를 등록하고 관리해야 한다는 이 개념이 우리를 다른 방향으로 이끌었습니다.
동기화된 패스키는 증명 가능한 하드웨어 자격 증명과 구조적으로 정반대에 있습니다. Secure Enclave나 TPM 같은 보안 저장 매체에 패스키를 저장하는 대신, 저는 개인 키를 제 1Password 보관함에 저장하는 1Password를 사용합니다. 그리고 이 보관함은 Windows 데스크톱, iPhone, MacBook Pro, iPad 등 제 모든 기기 사이에서 동기화됩니다. 이것은 엄청난 편의성을 제공합니다. RP에 패스키를 한 번만 등록하면 모든 기기에서 그 패스키로 로그인할 수 있기 때문이며, 각 기기마다 일일이 패스키를 등록할 필요가 없습니다. 하지만 바로 그것이 문제입니다. ‘이건 어떤 종류의 기기인가?’라는 질문에 답하기 위해 이 과정에서 의미 있는 하드웨어 attestation을 제공할 수는 없습니다. 왜냐하면 그 질문에 대한 답은 언제나 ‘상황에 따라 다르다’가 되기 때문입니다. 1Password 같은 소프트웨어 authenticator들이 일반적으로 유용한 방식으로 attestation을 수행할 방법은 없고, 이것이 바로 우리가 Report URI에서 attestation을 요구하지 않는 이유입니다. 만약 그렇게 했다면, 우리 사용자 대부분은 자신이 선호하는 방식으로 패스키를 등록하고 인증하는 일을 할 수 없게 되기 때문입니다.
이 긴장 관계, 즉 기기 attestation 대 동기화된 패스키가 사실상 이 글 전체의 핵심입니다.
이제 퍼즐의 모든 조각이 준비되었으니, 이것을 조합해서 어디서 문제가 생기는지 보겠습니다. 대부분의 온라인 서비스는 Attestation을 요구하지 않을 것입니다. 그렇게 하면 너무 많은 사용자가 자신이 선호하는 방식으로 패스키를 사용할 수 없게 되기 때문입니다. 하지만 Attestation은 RP가 실제 하드웨어에 기반한 bona fide Authenticator와 대화하고 있음을 알 수 있게 해줍니다. Attestation이 없으면 우리는 단지 소프트웨어와 대화하는 것이고, 코드와 대화하는 것입니다. 그런데 알고 보니 웹페이지는 코드를 실행합니다...
앞서 살펴본 패스키 등록과 인증 흐름 전체는 JavaScript에 의해 구동되었습니다. 새 패스키를 등록하려면 페이지가 navigator.credentials.create()를 호출하고 Authenticator와 상호작용하면서 데이터를 주고받습니다. 패스키로 인증하려면 페이지가 navigator.credentials.get()을 호출하고 Authenticator와 상호작용하면서 데이터를 주고받습니다. Attestation을 그림에서 제거하면, Authenticator를 전혀 개입시키지 않고도 JavaScript만으로 이 전체 흐름을 완료할 수 있습니다. 하나씩 살펴보겠습니다.
JavaScript는 평소처럼 등록 흐름을 시작하기 위해 /passkeys/register_get_options/를 호출합니다.
일반적으로 여기서 JavaScript는 Authenticator 안에 새로운 공개 키/개인 키 쌍을 생성하기 위해 navigator.credentials.create()를 호출하겠지만, 대신 우리는 JavaScript 안에서 새 키 쌍을 생성할 것입니다.
const kp = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']
);
이제 /passkeys/register_finish/로 보낼 페이로드를 구성해야 하는데, 여기에는 방금 생성한 공개 키가 필요하며 attestation 데이터는 전혀 필요하지 않습니다. RP는 나중에 이 제출된 공개 키에 대응하는 개인 키가 로그인에 사용되었는지만 검증할 수 있을 뿐이고, 그 키가 ‘진짜’ authenticator 내부에서 생성되었는지는 검증하지 못합니다.
사용자의 계정에 새로운 패스키가 사용자 상호작용 없이도 성공적으로 등록되었습니다.
이 말은, 단지 악성 JavaScript가 실행되는 페이지를 방문하는 것만으로도 아무 상호작용 없이 계정에 패스키가 등록될 수 있다는 뜻이니 미친 이야기처럼 들릴 수 있지만, 추가 단계가 요구되지 않는다면 실제로 정확히 그렇게 동작합니다.
이를 증명하기 위해 저는 Report URI Demo Site에 몇 개의 데모 페이지를 만들었고, 여기서는 특히 Passkeys Demo 1을 보셔야 합니다. 그 페이지가 브라우저에 로드되는 순간, JavaScript 페이로드가 여러분 계정에 패스키를 등록할 것입니다. 여러분은 평소처럼 자신의 패스키를 등록하고 심지어 그 패스키로 로그인도 해볼 수 있습니다. 직접 시도해 보세요. 하지만 악성 JavaScript가 등록한 두 번째 패스키가 항상 존재하며, 그것은 공격자가 소유합니다.
공격자가 여러분 계정에 자신의 패스키를 등록하는 것은 특히 지저분한 형태의 계정 탈취입니다. 이는 지속적이고, 합법적인 계정 보안 변경처럼 보이며, 패스키만으로 로그인할 수 있는 환경이라면 이제 공격자는 계정으로 다시 들어갈 수 있는 깔끔한 인증 경로를 갖게 됩니다. 이제 여러분은 완전히 당한 것이고, 더 나쁜 점도 있습니다.
패스키 등록 과정은 JavaScript에 의해 조율되므로, 페이지에서 악성 JavaScript를 실행하고 있다면 브라우저와 Authenticator 사이의 WebAuthn API 호출을 프록시할 수 있습니다. 궁극의 인페이지 MiTM 공격인 셈입니다!
navigator.credentials.create() API를 후킹하고 변조함으로써, 브라우저와 Authenticator 사이에서 전달되는 값을 바꿔치기할 수 있습니다. 이것은 사용자가 평소와 같은 등록 절차를 진행하고, Authenticator로부터 새로운 패스키를 생성하고 저장하라는 프롬프트를 받지만, 그 다음 Authenticator가 잘못된 패스키를 저장하게 된다는 뜻입니다. Authenticator는 자신이 생성한 패스키를 저장하지만, 그것은 RP로 전송된 패스키가 아닙니다. RP로 전송된 것은 공격자의 패스키로 바꿔치기된 값입니다. 이제 겉보기에는 사용자가 웹사이트에 패스키를 등록한 것처럼 보이고, 비밀번호 관리자에도 패스키가 보이며, 웹사이트도 계정에 패스키가 등록되었다고 표시합니다. 하지만 피해자가 가진 패스키는 절대 동작하지 않습니다. 실제로 동작하는 것은 공격자가 가진 패스키뿐이며, 이것이 왜 그렇게 잘 먹히는지는 다이어그램을 업데이트하면 가장 잘 드러납니다.
이 과정을 시연하기 위해 우리는 Passkeys Demo 2를 만들었습니다. 여기서 여러분은 자신의 계정에 패스키를 등록할 수 있지만, 기기에 저장되는 패스키는 올바른 패스키가 아닙니다. 그 다음 패스키로 로그인해 보며 예상대로 동작하지 않는다는 점을 확인할 수 있고, 반면 공격자는 자신의 패스키로 로그인할 수 있습니다.
Attestation이 “건너뛰어지는” 것은 게으름이나 지식 부족 때문이 아닙니다. 사용자들이 모든 기기와 모든 비밀번호 관리자에 퍼져 있는 서비스의 경우, 강한 형태의 attestation은 기기 출처에 대한 보장을 얻는 대신 실제 접근성을 크게 잃게 된다는 현실을 인정하는 것입니다. 우리에게 중요한 위협 모델, 즉 피싱, 자격 증명 탈취, 재전송은 동기화된 패스키가 제공하는 챌린지/오리진 바인딩과 서명 검증만으로 충분히 해결됩니다. 기기 attestation은 그 지점을 크게 바꾸지 못하며, 이것이 우리가 이를 요구하지 않는 이유입니다.
Attestation과 동기화된 패스키는 근본적으로 서로 충돌하며, attestation을 선택하지 않는 것이야말로 사용자가 실제로 가지고 있는 패스키를 가져와 사용할 수 있게 해줍니다. Attestation 없음과 패스키 없음 중 하나를 고르라면, 여러분은 무엇을 선택하시겠습니까?
모든 사람은 패스키를 사용하고 있거나, 최소한 사용을 목표로 해야 합니다. 하지만 존재하는 위험을 인정하고 이를 완화하기 위한 조치를 취할 필요가 있습니다. 아래는 패스키 배포를 더 강하게 만드는 데 도움이 될 실질적인 지침입니다.
이 부분은 까다로울 수 있습니다. 저는 많은 사이트가 패스키를 비밀번호 대체 수단으로 사용하는 것을 봤지만, Report URI에서는 그렇게 하지 않았고 패스키를 2FA 메커니즘으로 사용합니다. 계정에 새 패스키를 등록하려고 할 때는 현재 계정 비밀번호가 필요합니다. 이는 JavaScript가 조용히 새 패스키를 등록할 수 없다는 뜻입니다. 이 외에도 다른 2FA 수단, 이메일을 통한 magic-link, 또는 다른 추가 인증 메커니즘을 요구할 수 있습니다.

강력한 Content Security Policy는 여기서 큰 도움이 되며, 이 공격을 막는 최선의 방법은 XSS를 근원에서 차단하는 것입니다. 또한 가능하다면 어디서든 Subresource Integrity를 사용해 서드파티 의존성을 보호해야 합니다. 방문자에게 패스키를 등록하기 시작한 분석 스크립트가 변질되면 어떤 일이 벌어지는지 보려면 Passkeys Demo 3를 확인해 보세요.

Permissions Policy를 사용하면 사이트의 어떤 페이지와 어떤 서드파티 스크립트가 navigator.credentials.create() 및 navigator.credentials.get() API 호출에 접근해 패스키를 등록하고 패스키로 인증할 수 있는지 제어할 수 있습니다. 현실적으로 보면, 우리 사이트에서 패스키를 건드릴 필요가 있는 페이지는 아마 매우 적고, 그런 능력을 부여하고 싶은 서드파티 스크립트는 더 적을 것입니다. 이것만으로는 위에서 설명한 직접적인 /register_finish/ 공격을 막을 수는 없습니다. 그 공격은 WebAuthn API를 필요로 하지 않기 때문입니다. 하지만 악성 JavaScript가 합법적인 패스키 절차를 방해할 수 있는 위치의 수를 줄여주기는 합니다.

사용자 계정에 새 패스키가 추가되면, 반드시 그 사실을 알려야 합니다. 새 패스키가 계정에 추가되는 즉시 이메일이나 다른 수단으로 사용자에게 알림을 보내세요. 사용자가 이것을 예상하지 못했다면, 즉시 계정을 보호하기 위한 조치를 취할 수 있습니다.

전문화된 클라이언트 사이드 보호 플랫폼으로서, Report URI가 이러한 클라이언트 사이드 공격에 대한 방어를 도와드릴 수 있다는 것은 자연스러운 일입니다. 이 글의 주된 목적은 위의 위험을 강조하는 것이므로 여기서는 짧게만 다루겠지만, 이것은 우리가 많은 연구를 수행해 온 주제이며 실제 가치를 제공할 수 있습니다.
실질적인 지침을 통해 이런 위험을 찾아내고 줄이려는 팀을 위해 전용 Passkeys solutions page와 화이트페이퍼도 준비해 두었습니다.
attestation: "none"을 사용하는 것 자체가 문제는 아닙니다. 그것은 보안과 편의성 사이의 트레이드오프입니다. 숨겨진 위험은 페이지 수준의 적대자를 간과하는 데 있으며, 그런 적은 항상 문제를 일으키겠지만 패스키와 관련해서는 특히 큰 문제를 만들 수 있습니다.
패스키는 여전히 올바른 방향이며, 저는 그것이 널리 채택되기를 바랍니다. 하지만 패스키가 대체하는 보안 경계인 비밀번호는 네트워크를 통해 전달되는 단일 비밀이었습니다. 반면 패스키가 도입하는 경계는 사용자 에이전트가 중개하는 절차이며, 이 경계는 사용자 에이전트와 그 안에 주입된 모든 것을 신뢰할 수 있을 때만 유지됩니다. 이것이 바로 XSS가 패스키에 치명적이 되는 이유입니다.
이 글이 마음에 드셨거나 도움이 되었나요?
☕️ 감사의 뜻으로 커피 한 잔 사주기를 고려해 주세요!
🔔 새 글을 발행할 때 무료 알림을 구독하세요!
🤩 멤버가 되어 제 콘텐츠를 지원해 주세요! 태그: XSS, Passkeys, Report URI