オープンソース・ソリューション・テクノロジ株式会社
Open Source Solution Technology Corporation

OpenAMにSAMLアサーションを複数送ってみた

2021-06-14 - 相本 智仁

はじめに

Lasso の脆弱性はSAMLアサーションが複数存在した時の署名検証の不備でした。 OpenAM は Lasso を使っていませんので Lasso の脆弱性は当てはまりませんが、 SAML の SP として動作することが出来ます。 OpenAM の実装としてSAMLアサーションが複数送られてきた場合どのような挙動(仕様)なのかを調べてみました。

結論

OpenAMには Lasso の脆弱性(CVE-2021-28901)にあたる問題はありませんでした。

  • OpenAM はSAMLアサーションが複数送られてくることを許容する
  • 複数送られてきたSAMLアサーションの全ての署名を検証する
    • 一つでも「署名されていない」「署名の検証に失敗」すればエラーとする
  • 複数のSAMLアサーションが存在しても、使用するアサーションは一つ
    • 最初に有効と判定したアサーションのみを使ってアカウントのマッピングを行う

検証内容

以下の内容の検証を実施しました。

  • OpenAM を SAML SP として構築(SAML2認証モジュールを使用)
  • SAMLアサーションが2つ存在するSAMLレスポンスを受けった場合のOpenAMの挙動を確認する
    • パターン1 -> 署名あり、署名なしの順
    • パターン2 -> 署名なし、署名ありの順
    • パターン3 -> 署名あり、署名ありの順

パターン1,パターン2のケース

どちらも Federation ログに以下のエラーが出力されて認証失敗となりました。

libSAML2:06/10/2021 07:27:40:748 AM UTC: Thread[https-jsse-nio-8443-exec-9,5,main]: TransactionId[ffdf7b49-66f5-4463-ac45-32022fb927b5-1587]
ERROR: SAML2Utils.verifyResponse:WantAssertionsSigned is true but some or all assertions are not signed

パターン3

OpenAMでエラーとならず、認証に成功しました。

ソースコードで確認する

検証で実際の動作は確認出来たので、ソースコードでも確認してみます。 コードは該当箇所のみ抜粋します。

全部を見たい方はOpenAMコンソーシアムのソースコードで確認可能です。

 280     public static Map verifyResponse(
 281             final HttpServletRequest httpRequest,
 282             final HttpServletResponse httpResponse,
 283             final Response response,
 284             final String orgName,
 285             final String hostEntityId,
 286             final String profileBinding)
 287             throws SAML2Exception {
〜
 499         Map smap = null;
〜
 504         for (Assertion assertion : assertions) {
 505             String assertionID = assertion.getID();
〜
 535             if (assertion.isSigned()) {
 536                 if (verificationCerts == null) {
 537                     idp = saml2MetaManager.getIDPSSODescriptor(
 538                             orgName, idpEntityId);
 539                     verificationCerts = KeyUtil.getVerificationCerts(idp, idpEntityId, SAML2Constants.IDP_ROLE);
 540                 }
 541                 if (CollectionUtils.isEmpty(verificationCerts) || !assertion.isSignatureValid(verificationCerts)) {
 542                     debug.error(method +
 543                             "Assertion is not signed or signature is not valid.");
 544                     String[] data = {assertionID};
 545                     LogUtil.error(Level.INFO,
 546                             LogUtil.INVALID_SIGNATURE_ASSERTION,
 547                             data,
 548                             null);
 549                     throw new SAML2Exception(bundle.getString(
 550                             "invalidSignatureOnAssertion"));
 551                 }
 552             } else {
 553                 allAssertionsSigned = false;
 554             }
 555             List authnStmts = assertion.getAuthnStatements();
 556             if (authnStmts != null && !authnStmts.isEmpty()) {
〜
 600                 if (smap == null) {
 601                     smap = fillMap(authnStmts,
 602                             subject,
 603                             assertion,
 604                             assertions,
 605                             reqInfo,
 606                             inRespToResp,
 607                             orgName,
 608                             hostEntityId,
 609                             idpEntityId,
 610                             spConfig,
 611                             (Date) bearerMap.get(SAML2Constants.NOTONORAFTER));
 612                 }
 613             } // end of having authnStmt
 614         }
〜
 644             if (!responseIsSigned && !allAssertionsSigned) {
 645                 debug.error(method + "WantAssertionsSigned is true but some or all assertions are not signed");
 646                 String[] data = { orgName, hostEntityId, idpEntityId };
 647                 LogUtil.error(Level.INFO, LogUtil.INVALID_SIGNATURE_ASSERTION, data, null);
 648                 throw new SAML2Exception(bundle.getString("assertionNotSigned"));
 649             }
 650         }
 651 
 652         return smap;
 653     }

受け取ったSAMLレスポンスを検証するverifyResponseメソッドのコードです。

504行目のfor文が送られてきたアサーションの数だけ繰り返されます。複数アサーションが考慮されていることが見て取れます。

535行目でアサーションが署名されていれば真となります。 真の場合、IdPのメタデータからサーバーの証明書を取り出して署名検証を行います。 署名検証に失敗した場合は SAML2Exceptionを throw します。 偽の場合(アサーションが署名されていない場合)、 553行目で allAssertionsSignedfalse がセットされます。

この一連の処理はfor文の中ですので一つでもアサーションに署名されていなければ allAssertionsSignedfalse がセットされます。

504行目のfor文を抜けたあとの644行目を見るとallAssertionsSignedfalseなら SAML2Exception が throw されることが分かります。 パターン1、パターン2の検証は644行目のif文が真となりエラーがthrowされるコードを通っています。

644行目の responseIsSigned は、SAMLレスポンスのXMLが署名されていて検証に成功すれば true がセットされています。 XML自体の署名の確認が取れていれば、SAMLレスポンスは改ざんはされていないことが保証されますので個々のアサーションでは 署名が無くても OK ということになっています。

終わりに

ソースコードは署名検証にフォーカスして説明しました。 2つの署名が有効なSAMLアサーションが送られてきた場合は、 verifyResponseメソッドとこのメソッドを呼び出しているコードを見れば確認出来ますので興味がある方はぜひご自分で確認してみてください。

ソフトウェアがどういう挙動(仕様)なのかを把握するのに、「実際に検証してみる」と「ソースコード」の両面から確認することで深く理解することが出来ました。 今後も疑問に思ったことは、実際に検証し、ソースコードで裏を取るというのを心がけて行きたいと思います。