LDAPインジェクション

LDAPインジェクション

説明

LDAPインジェクションは、信頼できないデータが検索フィルターや識別名(Distinguished Name、DN)の値ではなく構文として解釈される問題です。これらは異なる文法を使います。

  • 検索フィルターはRFC 4515に従います。*、(、)、\、NULなどの文字が条件式を変えます。属性名や照合規則を攻撃者が選べる場合も、意味が変わるおそれがあります。
  • DNや検索ベースはRFC 4514に従います。,、+、=、\などの区切り文字やメタ文字によって、対象エントリや検索する部分木が変わる可能性があります。

このため、フィルター用のエンコードをDNへ流用したり、攻撃者が渡した完全なフィルター・DNを解析したりするだけでは、安全になりません。

想定される影響

ディレクトリの権限と検索結果の使い方によって、次の問題が生じる可能性があります。

  • 認証や認可の条件の回避
  • 意図しないディレクトリエントリの取得・変更
  • 検索範囲の拡大による情報公開やリソース消費

対処方法

1. LDAPの文法を固定し、値だけを渡す

JNDIでは、開発者が定義した固定フィルターテンプレートとfilterArgsオーバーロードを使ってください。文字列の引数はフィルターのメタ文字をエスケープして代入されます。JNDIのプレースホルダーは属性や照合規則の位置にも置けるため、アサーション値の位置だけに使用し、属性、照合規則、演算子、プレースホルダーの位置を固定してください。

Spring LDAP 4.1.1では、LdapQueryBuilder.where(...).is(value)、filter(fixedTemplate, values...)、構造化されたFilterオブジェクトを使ってください。1引数のfilter(String)は入力を検証・エスケープしないため、信頼できない文字列を渡さないでください。LdapQueryBuilderはSpring Securityではなく、Spring LDAPのAPIです。

UnboundID LDAP SDKでは、固定属性と値からFilter.createEqualityFilter("uid", value)などのFilterを作り、オブジェクトを受け取るオーバーロードへ渡してください。Apache DirectoryではFilterBuilderなどの構造化APIを使ってください。

2. DNを構成要素から組み立てる

DNと検索ベースは、固定した属性の種類と値から構成してください。JNDIのRdn/LdapName、Spring LDAPのLdapNameBuilder、UnboundIDのRDN/DNなどを使えます。new LdapName(untrustedCompleteDn)で攻撃者の完全なDNを解析しても、構文はそのまま保たれ、無害化されません。

JNDIのContextメソッドへ渡すString名は、LDAPのDN文法に加えてJNDIの複合名(composite name)としても解釈されます。二重エスケープの誤りを避けるため、LdapNameを文字列へ変換せず、Nameオーバーロードへ直接渡してください。Spring LDAPのLdapEncoder.nameEncodeはLDAP DN用であり、JNDIの複合名用ではありません。JNDIのString名に対する唯一の対策として使わないでください。

3. 文脈別のエンコードを値だけに適用する

構造化APIやパラメーター化APIを使えない場合に限り、保守されている文脈別のエンコーダーを使ってください。

  • フィルターのアサーション値:SpringのLdapEncoder.filterEncode、ESAPIのencodeForLDAP(value)またはencodeForLDAP(value, true)など、RFC 4515向けの処理を使用します。
  • DNの値:RFC 4514向けのDN値エンコードを別途使います。JNDIには、可能であればエンコード済み文字列ではなく構造化されたNameを渡してください。

ESAPIのencodeForLDAP(value, false)はワイルドカードを残すため、信頼できない完全一致の値を処理する代替にはなりません。ワイルドカード検索が必要なら、利用者にフィルター構文を渡させず、サーバー定義の明示的な検索モードへ対応付け、件数と時間を制限してください。

4. 構文の選択肢と権限を制限する

ユーザーが選ぶ属性、照合規則、検索範囲などの構文要素は、有限のサーバー管理の許可リストへ対応付けてください。LDAPには最小権限のアカウントでバインドし、クライアントとサーバーの両方で結果サイズと時間を制限してください。これらは影響を抑える多層防御であり、LDAP構文を無害化するものではありません。

例

JNDI検索フィルター

変更前

java
import jakarta.servlet.http.HttpServletRequest;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

NamingEnumeration<SearchResult> searchUser(
        DirContext context, HttpServletRequest request) throws NamingException {
    String username = request.getParameter("username");
    SearchControls controls = new SearchControls();
    controls.setSearchScope(SearchControls.SUBTREE_SCOPE);

    String filter = "(&(uid=" + username + ")(objectClass=person))";
    return context.search(
            "ou=users,dc=example,dc=com", filter, controls);
}

usernameのRFC 4515メタ文字が、フィルター演算子や追加条件として解釈されるおそれがあります。

変更後

java
import jakarta.servlet.http.HttpServletRequest;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

NamingEnumeration<SearchResult> searchUser(
        DirContext context, HttpServletRequest request) throws NamingException {
    String username = request.getParameter("username");
    SearchControls controls = new SearchControls();
    controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
    controls.setCountLimit(100);
    controls.setTimeLimit(2_000);

    String fixedFilter = "(&(uid={0})(objectClass=person))";
    return context.search(
            "ou=users,dc=example,dc=com",
            fixedFilter,
            new Object[] {username},
            controls);
}

フィルターの文法と検索ベースをコードで固定し、usernameをアサーション値としてだけ代入します。

JNDIのDNの構成

java
import jakarta.servlet.http.HttpServletRequest;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;

Object lookupUser(DirContext context, HttpServletRequest request) throws NamingException {
    String username = request.getParameter("username");
    LdapName userDn = new LdapName("ou=users,dc=example,dc=com");
    userDn.add(new Rdn("uid", username));
    return context.lookup(userDn);
}

RdnはusernameをDNの値として扱い、lookup(Name)は構造化された名前をJNDI複合名の文字列として再解釈しません。

Spring LDAPのクエリ

java
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.ldap.query.LdapQuery;

import static org.springframework.ldap.query.LdapQueryBuilder.query;

LdapQuery userQuery(HttpServletRequest request) {
    String username = request.getParameter("username");
    return query()
            .base("ou=users,dc=example,dc=com")
            .countLimit(100)
            .timeLimit(2_000)
            .where("uid").is(username)
            .and("objectClass").is("person");
}

属性名とフィルター構造はサーバーが定義し、ビルダーがusernameを値の文脈に合わせて処理します。

運用設定の確認

コードとともに、ディレクトリACL、バインドするアカウントの権限、サーバー側の検索制限を確認してください。検索結果を認証や認可に使う場合は、その判断も別途確認してください。

参考資料