
はじめに
こんにちは、GMO Flatt Security株式会社 セキュリティエンジニアの小武です。
先日公開した記事「Passkey認証の実装ミスに起因する脆弱性・セキュリティリスク」では、「8. Non Discoverable Credentialのフローとの混在」において、StrongKey FIDO Serverのアカウント乗っ取りが可能な脆弱性について言及しました。StrongKey FIDO Serverはオープンソースで提供されているFIDOサーバー製品です。
この脆弱性は、様々なセキュリティ的利点を持つPasskey認証であっても、実装ミスによって脆弱になり得るということを示しています。本記事では、この脆弱性がソースコードレベルでどのような問題を含み、どのように修正されたのかを詳しく解説します。
また、GMO Flatt SecurityはPasskey認証に特化した脆弱性診断も提供しています。本記事で紹介するようなリスクの検証に有効ですので、お気軽にお問合せください。
脆弱性の概要
本記事で紹介するCVE-2025-26788は、認証器とユーザーの紐付けが不適切なため、攻撃者が用意した認証器で生成されたアサーションを使用し、被害者のアカウント乗っ取りが可能となる脆弱性です。
この脆弱性の問題点は以下の通りです。
- サーバーが、Passkey(Discoverable Credential)と、Non Discoverable Credentialsを区別しない。
- サーバーは、アサーションに含まれる攻撃者のCredential IDに関連付けられた公開鍵を使用して署名を検証する。
- しかし、署名検証後のログイン処理は、Non Discoverable Credentialsで認証するために指定されたユーザーとしてシステムを使用するための認証トークンを作成してしまう。
- 結果として、攻撃者は被害者ユーザーとして認証を通過できてしまう。
sequenceDiagram
participant Attacker as 攻撃者
participant Authenticator as 認証器
participant Client as クライアント(Webブラウザ)
participant RP Front as RPフロントエンド(クライアントサイドアプリ)
participant RP Server as StrongKey FIDO Server (脆弱性を含むバージョン)
Attacker->>RP Front: ログイン開始 (被害者のユーザー名)
RP Front->>RP Server: ユーザー名
RP Server->>RP Server: 1.認証を要求するユーザーの特定と認証フローの識別
RP Server->>RP Front: チャレンジと被害者のCredential ID
RP Front-->RP Front: 攻撃者はレスポンスを改ざんし<br>自身が所有するCredential IDを指定する
RP Front->>Client: チャレンジと攻撃者のCredential ID
Client->>Authenticator: チャレンジと攻撃者のCredential IDで認証を要求
Authenticator->>Client: 攻撃者のCredential IDに紐づく秘密鍵で署名を生成
Client->>RP Front: 攻撃者の秘密鍵で署名したアサーション
RP Front->>RP Server: 攻撃者の秘密鍵で署名したアサーションを送信
RP Server->>RP Server: 2.署名検証に使用する公開鍵の特定と署名の検証
RP Server->>RP Server: 3.ログインするユーザーの特定とログイン処理
RP Server->>RP Front: 被害者のアカウントのセッション
RP Front->>Attacker: 被害者アカウントの制御
この脆弱性は、StrongKey FIDO ServerのPasskey認証(Discoverable credentials)が導入されたバージョン4.10.0以降、修正が行われる前の4.15.0までのバージョンに影響を与えました。修正はバージョン4.15.1で実施されています。
ソースコードから脆弱性の原因を探る
ここからはどのような実装が脆弱性につながったのかを、ソースコードから詳しく見ていきます。
ここでは、脆弱性が発見された当時のバージョンである4.15.0のソースコードを具体例として用いて解説を行います。このバージョンにおける実装を確認し、攻撃者が被害者の認証トークンを取得できてしまうのかを以下のステップに分けて確認します。
- 認証を要求するユーザーの特定と認証フローの識別
- 署名検証に使用する公開鍵の特定と署名検証
- ログインするユーザーの特定とログイン処理
1. 認証を要求するユーザーの特定と認証フローの識別
まず、後述のシーケンス図で強調されている認証ユーザーの特定方法と、認証フローの判定方法について確認します。
以降の解説では、攻撃者が以下の状態になる認証オプションの要求リクエストを送信したことを前提に進めます。
- 変数
discoverableCredにFalseが代入された状態、つまりNon Discoverable Credentialによる認証とします。 - 変数
usernameには被害者のユーザー名(例としてvictim)が代入された状態とします。
sequenceDiagram
participant Attacker as 攻撃者
participant Authenticator as 認証器
participant Client as クライアント(Webブラウザ)
participant RP Front as RPフロントエンド(クライアントサイドアプリ)
participant RP Server as StrongKey FIDO Server (脆弱性を含むバージョン)
rect rgb(255, 255, 200)
Attacker->>RP Front: ログイン開始 (被害者のユーザー名)
RP Front->>RP Server: ユーザー名
RP Server->>RP Server: 1.認証を要求するユーザーの特定と認証フローの識別
end
RP Server->>RP Front: チャレンジと被害者のCredential ID
RP Front-->RP Front: 攻撃者はレスポンスを改ざんし<br>自身が所有するCredential IDを指定する
RP Front->>Client: チャレンジと攻撃者のCredential ID
Client->>Authenticator: チャレンジと攻撃者のCredential IDで認証を要求
Authenticator->>Client: 攻撃者のCredential IDに紐づく秘密鍵で署名を生成
Client->>RP Front: 攻撃者の秘密鍵で署名したアサーション
RP Front->>RP Server: 攻撃者の秘密鍵で署名したアサーションを送信
RP Server->>RP Server: 2.署名検証に使用する公開鍵の特定と署名の検証
RP Server->>RP Server: 3.ログインするユーザーの特定とログイン処理
RP Server->>RP Front: 被害者のアカウントのセッション
public Response preauthenticate(String input) { JsonObject inputJson = SKFSCommon.getJsonObjectFromString(input); Boolean discoverableCred = Boolean.FALSE; [省略] //convert payload to pre auth object PreauthenticationRequest pauthreq = new PreauthenticationRequest(); //CHANGE FOR USERNAMELESS FLOW if (preauthpayload.containsKey("username")) { if (!preauthpayload.isNull("username")) { if (preauthpayload.getString("username").trim().isEmpty()) { discoverableCred = Boolean.TRUE; } else { pauthreq.setUsername(preauthpayload.getString("username")); } } else { discoverableCred = Boolean.TRUE; } } else { discoverableCred = Boolean.TRUE; }
この処理は、WebAuthnのパスワードレス認証を行う際の認証オプションの要求リクエストを処理するサーブレットです。
認証オプションの要求リクエストにおいて、usernameがパラメータとして存在しない場合や、usernameが空文字やNullの場合にのみ、変数discoverableCredにTrueが代入されます。つまり、usernameを指定して開始した認証フローは、Non Discoverable Credentialを使用した認証フローと判断されます。
@Override public JsonObject execute(String ID, Long did, String username, JsonObject options, JsonObject extensions, Boolean discoverableCred) throws SKFEException{ [省略] if (!sendfakekeyhandles) { //Place challenge in map. // for (FidoKeys fk : fks) { UserSessionInfo session = new UserSessionInfo(username, challenge, null, SKFSConstants.FIDO_USERSESSION_AUTH, null, ""); session.setSid(applianceCommon.getServerId().shortValue()); session.setuserVerificationReq(userVerificationPref); session.setPolicyMapKey(fidoPolicy.getPolicyMapKey()); session.setDiscoverableCred(discoverableCred); String noncehash = SKFSCommon.getDigest(challenge, "SHA-256"); skceMaps.getMapObj().put(SKFSConstants.MAP_USER_SESSION_INFO, noncehash, session);
SKFSServlet#preauthenticateで呼び出す後続の処理で、認証の一連の流れを識別するセッション変数に、usernameと認証フローを識別する変数discoverableCredを保存しています。
後続の認証処理においては、このセッション変数に保存された変数discoverableCredを参照します。
2. 署名検証に使用する公開鍵の特定と署名検証
ここまでで、以下の状態になっていることを前提として、署名の検証に使用される公開鍵はどのように特定されるのかを確認していきます。
- 認証フローはNon Discoverable Credential(変数
discoverableCredがFalseの状態)を使用する状態になっていること - 認証フローを管理するセッションの
usernameはvictimが設定されていること
ユーザーが認証器での認証を完了した後の処理について詳しく見ていきます。サーバーが認証器で生成されたアサーションを検証するために、検証に使用する公開鍵をどのように特定し、使用するのかを確認します。
ここでは、シーケンス図にあるとおり、攻撃者がレスポンス改ざんを行なった結果、攻撃者が所有するCredential IDに紐づく秘密鍵で生成されたアサーションがリクエストに含まれるとします。
sequenceDiagram
participant Attacker as 攻撃者
participant Authenticator as 認証器
participant Client as クライアント(Webブラウザ)
participant RP Front as RPフロントエンド(クライアントサイドアプリ)
participant RP Server as StrongKey FIDO Server (脆弱性を含むバージョン)
Attacker->>RP Front: ログイン開始 (被害者のユーザー名)
RP Front->>RP Server: ユーザー名
RP Server->>RP Server: 1.認証を要求するユーザーの特定と認証フローの識別
RP Server->>RP Front: チャレンジと被害者のCredential ID
rect rgb(255, 255, 200)
RP Front-->RP Front: 攻撃者はレスポンスを改ざんし<br>自身が所有するCredential IDを指定する
RP Front->>Client: チャレンジと攻撃者のCredential ID
Client->>Authenticator: チャレンジと攻撃者のCredential IDで認証を要求
Authenticator->>Client: 攻撃者のCredential IDに紐づく秘密鍵で署名を生成
Client->>RP Front: 攻撃者の秘密鍵で署名したアサーション
RP Front->>RP Server: 攻撃者の秘密鍵で署名したアサーションを送信
RP Server->>RP Server: 2.署名検証に使用する公開鍵の特定と署名検証
end
RP Server->>RP Server: 3.ログインするユーザーの特定とログイン処理
RP Server->>RP Front: 被害者のアカウントのセッション
RP Front->>Attacker: 被害者アカウントの制御
@Override public SKFSreturn execute(String ID, Long did, String authresponse, String authmetadata, String method, String txid, String txpayload, String agent, String cip, String samlrequest) { [省略] String id = (String) applianceCommon.getJsonValue(authresponse, SKFSConstants.JSON_KEY_ID, "String"); [省略] String kh = id; [省略] // 3. Do processing try { key = getkeybean.getByKH(ID, did, kh); } catch (SKFEException ex) {
署名の検証に使用する公開鍵の特定は、変数discoverableCredの状態に関係なく、getFidoKeysBean#getByKHメソッドを呼び出します。 変数khにはアサーションに含まれるCredential IDを示すidの値が代入されます。idに代入される値は先ほどの前提のとおり、攻撃者がレスポンス改ざんをしたCredential IDが使用されると仮定して読み進めます。
@Override public FidoKeys getByKH(String ID, Long did, String KH) throws SKFEException { try { Query q = em.createNamedQuery("FidoKeys.findBydidKeyhandle"); q.setHint("jakarta.persistence.cache.storeMode", "REFRESH"); q.setParameter("did", did); q.setParameter("keyhandle", KH); FidoKeys rk = (FidoKeys) q.getSingleResult(); if (rk != null) { verifyDBRecordSignature(ID, did, rk); } return rk; } catch (NoResultException ex) { return null; } }
getByKHでは変数KH(Credential ID)をもとに、署名の検証に使用する公開鍵をDBから取得します。 verifyDBRecordSignatureはDBのレコード自体が改ざんされていないかを検証する処理のため、ここでの説明は割愛します。
if (key != null) { if (discoverableCred && !authenticationretained){ username = key.getFidoKeysPK().getUsername(); } userpublickey = key.getPublickey(); regkeyid = key.getFidoKeysPK().getFkid(); serverid = key.getFidoKeysPK().getSid(); }
Credential IDに紐づくレコードが取得できた場合、そのレコードから公開鍵を取得します。Non Discoverable Credentialを使用する認証フローの場合、変数usernameを更新する処理はありません。
補足ですが、Passkey認証の場合(discoverableCredがTrue)、Credential IDから取得したレコードに紐づくユーザー名を取得しています。「https://blog.flatt.tech/entry/passkey_security」で説明している「7. ユーザーの検証不備」には不備がないと言えます。
if (key != null) { rs = RegistrationSettings.parse(key.getRegistrationSettings(), key.getRegistrationSettingsVersion()); signingKeyType = getKeyTypeFromRegSettings(rs); byte[] publickeyBytes = java.util.Base64.getUrlDecoder().decode(userpublickey); Boolean isSignatureValid; aaguid = key.getAaguid(); KeyFactory kf = KeyFactory.getInstance(signingKeyType, "BCFIPS"); X509EncodedKeySpec pubKeySpec = new X509EncodedKeySpec(publickeyBytes); PublicKey pub = kf.generatePublic(pubKeySpec); isSignatureValid = cryptoCommon.verifySignature(ID, java.util.Base64.getUrlDecoder().decode(signature), pub, signedBytes, SKFSCommon.getAlgFromIANACOSEAlg(rs.getAlg())); if (!isSignatureValid) { SKFSLogger.logp(SKFSConstants.SKFE_LOGGER, Level.SEVERE, classname, "execute", "FIDO-MSG-2001", "Authentication Signature verification : " + isSignatureValid + ", [TXID=" + ID + "]"); throw new SKIllegalArgumentException(SKFSCommon.buildReturn(SKFSCommon.getMessageProperty("FIDO-ERR-2001") + "Authentication Signature verification : " + isSignatureValid)); }
攻撃者が指定したCredential IDに紐づく公開鍵を使用して署名の検証を行います。攻撃者が保有する秘密鍵で署名されたアサーションが、対応する公開鍵によって検証されるため、サーバーは正しい認証情報として処理します。
3. ログインするユーザーの特定とログイン処理
ここまでで、以下の状態になっていることを前提として、ログインを行うユーザーはどのように特定されるかを確認していきます。
- 認証フローはNon Discoverable Credential(変数
discoverableCredがFalseの状態)を使用する状態になっていること - 認証フローを管理するセッションの
usernameはvictimが設定されていること - 攻撃者の認証器で生成したアサーションは正規の認証情報として検証済みであること
sequenceDiagram
participant Attacker as 攻撃者
participant Authenticator as 認証器
participant Client as クライアント(Webブラウザ)
participant RP Front as RPフロントエンド(クライアントサイドアプリ)
participant RP Server as StrongKey FIDO Server (脆弱性を含むバージョン)
Attacker->>RP Front: ログイン開始 (被害者のユーザー名)
RP Front->>RP Server: ユーザー名
RP Server->>RP Server: 1.認証を要求するユーザーの特定と認証フローの識別
RP Server->>RP Front: チャレンジと被害者のCredential ID
RP Front-->RP Front: 攻撃者はレスポンスを改ざんし<br>自身が所有するCredential IDを指定する
RP Front->>Client: チャレンジと攻撃者のCredential ID
Client->>Authenticator: チャレンジと攻撃者のCredential IDで認証を要求
Authenticator->>Client: 攻撃者のCredential IDに紐づく秘密鍵で署名を生成
Client->>RP Front: 攻撃者の秘密鍵で署名したアサーション
RP Front->>RP Server: 攻撃者の秘密鍵で署名したアサーションを送信
RP Server->>RP Server: 2.署名検証に使用する公開鍵の特定と署名検証
rect rgb(255, 255, 200)
RP Server->>RP Server: 3.ログインするユーザーの特定とログイン処理
RP Server->>RP Front: 被害者のアカウントのセッション
RP Front->>Attacker: 被害者アカウントの制御
end
String username = ""; UserSessionInfo user; String challengeHash = SKFSCommon.getDigest(bdnonce, "SHA-256"); user = (UserSessionInfo) skceMaps.getMapObj().get(SKFSConstants.MAP_USER_SESSION_INFO, challengeHash); boolean discoverableCred = false; boolean authenticationretained = false; if (user == null) { SKFSLogger.log(SKFSConstants.SKFE_LOGGER, Level.SEVERE, "FIDO-ERR-0006", "[TXID=" + ID + "]"); throw new SKIllegalArgumentException(SKFSCommon.getMessageProperty("FIDO-ERR-0006").replace("{0}", "")); } else if (user.getSessiontype().equalsIgnoreCase(SKFSConstants.FIDO_USERSESSION_AUTH) || user.getSessiontype().equalsIgnoreCase(SKFSConstants.FIDO_USERSESSION_AUTHORIZE)) { discoverableCred = user.getDiscoverableCred(); authenticationretained = user.getAuthenticationComplete(); if (!discoverableCred) { username = user.getUsername(); } SKFSLogger.logp(SKFSConstants.SKFE_LOGGER, Level.FINE, classname, "execute", "FIDO-MSG-0022", "username=" + username + ", [TXID=" + ID + "]"); challenge = user.getNonce(); }
認証オプションの要求リクエストで発行したチャレンジをキーとして、認証を行うユーザーの特定を行います。
ここでは、Non Discoverable Credentialを使用する認証フローと仮定しているため、 変数usernameにはvictimが代入されます。
switch (method) { case "authentication": wsresponse = "Successfully processed authentication response"; if (jwtEnabled.equalsIgnoreCase("true")) { jwt = createJWT.execute(ID, did.toString(), username, userAgent, clientIP, rpidServletExtracted); }
その後、ユーザーを識別する情報として、usernameを含むJWTが生成されます。ここでは、被害者ユーザーであるvictimが使用されます。
ここまでの処理で、Non Discoverable Credentialが使用される認証フローにおいて、認証を要求したユーザーが、アサーションを検証するための公開鍵を保持しているかどうかの検証がおこなわれていないことがわかりました。結果として、攻撃者は攻撃者のアサーションを使用して被害者としてシステムにアクセスするための認証トークンを取得できてしまうことがソースコードからは確認できました。
どのように修正されたか?
バージョン4.15.1では、Non Discoverable Credentialを使用したフローとPasskey認証とで、鍵情報レコードを取得する処理に異なる関数が使用されるように修正されました。
この章ではバージョン4.15.1のソースコードを使用します。
if (discoverableCred) { key = getkeybean.getByCredentialId(ID, did, credentialId); if (key == null) { SKFSLogger.logp(SKFSConstants.SKFE_LOGGER, Level.SEVERE, classname, "execute", "FIDO-ERR-7003", "[DID=" + did + ", CREDID=" + SKFSCommon.obfuscateString(credentialId) + "] [TXID=" + ID + "]"); throw new SKIllegalArgumentException(SKFSCommon.getMessageWithParam("FIDO-ERR-7003", "[DID=" + did + ", CREDID=" + SKFSCommon.obfuscateString(credentialId) + "]")); } } [省略] else { key = getkeybean.getByUsernameCredentialId(ID, did, username, credentialId); if (key == null) { SKFSLogger.logp(SKFSConstants.SKFE_LOGGER, Level.SEVERE, classname, "execute", "FIDO-ERR-7004", "[DID=" + did + ", USERNAME=" + username + ", CREDID=" + SKFSCommon.obfuscateString(credentialId) + "] [TXID=" + ID + "]"); throw new SKIllegalArgumentException(SKFSCommon.getMessageWithParam("FIDO-ERR-7004", "[DID=" + did + ", USERNAME=" + username + ", CREDID=" + SKFSCommon.obfuscateString(credentialId) + "]")); } }
Non Discoverable credentialを使用する認証フローでは、getkeybean.getByUsernameCredentialIdメソッドを使用して署名の検証に使用する公開鍵の取得を行うよう修正されました。
public FidoKeys getByUsernameCredentialId(String ID, Long did, String username, String CredentialId) throws SKFEException { try { Query q = em.createNamedQuery("FidoKeys.findByUsernameKH"); q.setHint("jakarta.persistence.cache.storeMode", "REFRESH"); q.setParameter("username", username); q.setParameter("did", did); q.setParameter("keyhandle", CredentialId); FidoKeys rk = (FidoKeys) q.getSingleResult(); if (rk != null) { verifyDBRecordSignature(ID, did, rk); } return rk; } catch (NoResultException ex) { return null; } }
getByUsernameCredentialIdメソッドは、CredentialIdとusernameの2つの情報をもとにレコードを取得するように修正されました。
これにより、攻撃者が保有するCredential IDと被害者のusernameをもとにクエリを実行しても、検索条件に一致するレコードが存在しないため、安全性が向上しました。
おわりに
本記事では、CVE-2025-26788を例に、Non Discoverable Credentialのフローにおける問題点を解説しました。この事例が示すように、WebAuthnにおけるパスワードレス認証を安全に実装するためには、「署名の検証に使う公開鍵が、本当にそのユーザーのものであるか」をサーバー側で検証するプロセスが必要不可欠です。
本記事が、皆様のサービスで安全なPasskey認証をサービスに導入する上での参考になれば幸いです。
さて、冒頭でもご紹介したとおり、GMO Flatt SecurityはPasskey認証診断をリリースいたしました。Passkey認証診断はもちろんのこと、その他の認証方法に関しても柔軟に診断を実施します。ぜひ、お問い合わせください。
また、日本初のセキュリティ診断AIエージェント「Takumi」を開発・提供しています。Takumiを雇用することで、高度なセキュリティレビューを月額7万円(税別)でAIに任せることができます。
今後ともGMO Flatt Securityは高度な専門性とAI製品の提供により開発組織にとって最適なセキュリティサービスを提供していきます。公式Xをフォローして最新情報をご確認ください!
ここまでお読みいただき、ありがとうございました。