LDAPインジェクション

C#のLDAP検索フィルターインジェクション

説明

LDAP検索フィルターは、&、|、!、*、括弧などを使って条件を表す構文です。リクエストの値をSystem.DirectoryServices.DirectorySearcher.Filter、DirectorySearcherコンストラクターのフィルター引数、System.DirectoryServices.Protocols.SearchRequestのフィルターにそのまま連結すると、攻撃者が意図した条件の外に演算子やワイルドカードを挿入できる可能性があります。

例えば、(cn= + ユーザー名 + )を組み立てる場合、ユーザー名が*なら結果は(cn=*)となり、cn属性が存在する項目を選択します。より複雑な入力で、アプリケーションが付け加えたユーザー、グループ、テナントの条件を変えられる可能性もあります。

LDAPフィルターの値と識別名(DN)は異なる文脈です。フィルターのアサーション値はRFC 4515、DNの属性値はRFC 4514に従ってエスケープしてください。一方のエンコーダーを他方に流用できません。

想定される影響

  • ユーザーやグループの検索範囲を広げ、認証や認可の確認を回避される可能性があります。
  • アカウント、グループ、メールアドレスなど、権限のないディレクトリ情報を取得される可能性があります。
  • 負荷の高いフィルターでLDAPサーバーやアプリケーションの資源を消費される可能性があります。
  • 書き込みや管理処理が同じ動的フィルターを信頼すると、意図しないオブジェクトを変更される可能性があります。

対処方法

  1. 完成したフィルター、属性名、演算子をクライアントに指定させないでください。検索を選ぶ短い識別子を、サーバーが管理する固定のフィルターに対応付けます。
  2. 外部の値をアサーション値に使う場合は、保守されているフィルタービルダーかRFC 4515エンコーダーを使ってください。*、(、)、\、NULを正しい\hhのオクテット表現に変換します。
  3. .NET 10のSystem.DirectoryServicesとSystem.DirectoryServices.Protocolsには公開された標準のフィルターエスケーパーがありません。適切な保守済みライブラリーがなければ、組織で承認したRFC 4515ヘルパーに集約し、RFCの例やASP.NET Core 10の内部実装を基にテストしてください。
  4. RFC 4514のDNエンコーダー、HTML・URLエンコーダー、部分的なReplace、名前がSanitizeやEscapeというだけのヘルパーで代用しないでください。
  5. LDAPのバインドアカウントを最小権限にし、検索範囲、返却数、ページサイズ、時間を制限してください。影響を減らす対策であり、エンコーディングの代わりにはなりません。

OWASPのLDAPチートシートは文脈に適したエンコードを勧めていますが、C#の例では2014年に最後のリリースがあったAntiXSS 4.3.0を参照しています。既存の.NET FrameworkではMicrosoft.Security.Application.Encoder.LdapFilterEncodeを従来のフィルター値用APIとして検討できます。ただし、新しい.NET 10アプリケーションの標準的な対策として、この古いパッケージを追加しないでください。

例

変更前

csharp
using Microsoft.AspNetCore.Mvc;
using System.DirectoryServices;

public sealed class DirectoryController
{
    public SearchResult? Find(
        [FromQuery] string username,
        DirectorySearcher searcher)
    {
        searcher.Filter =
            "(&(objectClass=user)(cn=" + username + "))";

        return searcher.FindOne();
    }
}

変更後

csharp
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.DirectoryServices;

public sealed class DirectoryController
{
    private static readonly IReadOnlyDictionary<string, string> Filters =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["active-users"] = "(&(objectClass=user)(accountStatus=active))",
            ["disabled-users"] = "(&(objectClass=user)(accountStatus=disabled))"
        };

    public SearchResult? Find(
        [FromQuery] string filterKey,
        DirectorySearcher searcher)
    {
        if (!Filters.TryGetValue(filterKey, out var filter))
        {
            throw new ArgumentException("Unsupported filter.", nameof(filterKey));
        }

        searcher.Filter = filter;
        searcher.SizeLimit = 100;
        searcher.ServerTimeLimit = TimeSpan.FromSeconds(2);
        return searcher.FindOne();
    }
}

変更前はusernameをフィルターの構文に直接挿入します。変更後は、サーバーが作成した2種類のフィルターを選ぶキーとしてだけ使い、結果数とサーバーの処理時間も制限します。accountStatusは実際のディレクトリスキーマに合わせ、検索ごとの権限も別途確認してください。

自由形式の値が必要で、承認済みライブラリーがない場合は、RFC 4515のフィルター値専用ヘルパーに処理を集約できます。次の例は、ASP.NET Core 10のLDAPクレーム検索の内部実装と同じ必須文字をエスケープします。

csharp
using System;
using System.Text;

internal static class LdapFilterValue
{
    internal static string Encode(string value)
    {
        ArgumentNullException.ThrowIfNull(value);

        var escaped = new StringBuilder(value.Length);
        foreach (var character in value)
        {
            escaped.Append(character switch
            {
                '\\' => @"\5c",
                '*' => @"\2a",
                '(' => @"\28",
                ')' => @"\29",
                '\0' => @"\00",
                _ => character.ToString()
            });
        }

        return escaped.ToString();
    }
}

このヘルパーは、完成したフィルター、属性名、演算子、DNを安全にするものではありません。構造を固定し、1つのアサーション値だけに適用してください。*、括弧、バックスラッシュ、NUL、Unicodeの入力をテストしてください。

参考資料