LDAP 注入攻击防御实战:目录服务加固配置

LDAP 注入攻击防御实战:目录服务加固配置

LDAP 注入攻击防御是企业统一认证体系必须补齐的短板。当登录接口把用户输入直接拼进 LDAP 搜索过滤器,攻击者用一个 *)(uid=* 就能绕过口令校验,或通过布尔盲注逐字符枚举整个目录树。本文给出从暴露面核查、RFC 4515 转义、先搜索后绑定改造到 OpenLDAP 服务端加固的完整配置。

适用场景

  • 企业使用 OpenLDAP / Active Directory 做统一登录,自研应用通过 LDAP 认证。
  • 系统存在「域账号登录」「员工信息查询」「通讯录检索」等直接暴露 filter 参数的功能。
  • 需要满足内控对身份认证链路的审计要求。

前置条件

  • OpenLDAP 2.4+ 或 Windows AD,具备 cn=admin 或域管理员权限。
  • 应用侧有可改的认证代码(PHP / Java / Python 任一栈)。
  • 已确认 389/636 端口仅在内网可达。

原理说明

LDAP 查询由过滤器(filter)表达,语法形如 (&(uid=admin)(userPassword=xxx))。若应用把用户输入原样拼接,攻击者可注入过滤器的元字符:

  • 认证绕过:用户名输入 *)(uid=*))(|(uid=*,过滤条件被改写为恒真,绑定成功即登录成功。
  • 属性枚举:输入 admin)(& 触发语法错误,通过响应差异判断账号是否存在。
  • 布尔盲注:结合 (sn=A*) 一类条件,按返回结果逐位推断属性值,可批量拖取目录中的手机号与部门信息。

根因有两条:输入未做 RFC 4515 转义,以及用 filter 直接比对密码。后者尤其危险——LDAP 的 userPassword 比对不应作为认证手段,正确做法是先搜索定位 DN,再用该 DN 执行 bind。

操作步骤

第一步:暴露面与匿名绑定核查

# 端口暴露检查
nmap -Pn -p389,636 --script ldap-search,ldap-rootdse 10.0.0.10

# 匿名绑定测试(期望被拒绝)
ldapsearch -x -H ldap://10.0.0.10:389 -b "dc=corp,dc=local" -s base "(objectclass=*)"
# 返回 "ldap_bind: Invalid credentials (49)" 为正常
# 若返回整棵目录树,说明允许匿名读取,需立即关闭

同时用 ldapsearch -x -D "cn=admin,dc=corp,dc=local" -W 确认管理账号可用,便于后续改配置。

第二步:代码层 RFC 4515 转义(最关键一步)

必须转义的字符共五个,对应关系如下:

  • \\5c
  • *\2a
  • (\28
  • )\29
  • NUL\00

PHP 实现:

function ldap_escape_filter($s) {
    $map = ['\\' => '\\5c', '*' => '\\2a', '(' => '\\28', ')' => '\\29', "\0" => '\\00'];
    // 先清除非 UTF-8 字节,避免转义后产生非法序列
    $s = mb_convert_encoding($s, 'UTF-8', 'UTF-8');
    return strtr($s, $map);
}
$uid = ldap_escape_filter($_POST['username']);
$filter = "(&(objectClass=person)(uid={$uid}))";

Python 实现:

def ldap_escape(s: str) -> str:
    out = []
    for ch in s:
        if ch in ('\\', '*', '(', ')', '\x00'):
            out.append('\\%02x' % ord(ch))
        else:
            out.append(ch)
    return ''.join(out)

safe = ldap_escape(username)
flt = f"(&(objectClass=person)(uid={safe}))"

Java 直接使用现成 API,不要手写:

import org.springframework.ldap.support.LdapEncoder;
String safe = LdapEncoder.filterEncode(username);

要点:转义必须在构造 filter 之前完成,且只对「值」转义,不要对整条 filter 模板转义,否则会导致语法错误。

第三步:认证逻辑改造为「先搜索后绑定」

错误做法(filter 直达 bind,客户端可控):

$filter = "(&(uid={$_POST['u']})(userPassword={$_POST['p']}))";
ldap_bind($conn, $filter, $_POST['p']);

正确做法(搜索 DN → 用 DN 绑定 → 校验结果):

$uid   = ldap_escape_filter($_POST['username']);
$pass  = $_POST['password'];
$conn  = ldap_connect('ldaps://10.0.0.10:636');
ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);

// 1. 用只读服务账号搜索用户 DN
ldap_bind($conn, 'cn=svc-ro,ou=svc,dc=corp,dc=local', $svcPass);
$res = ldap_search($conn, 'ou=people,dc=corp,dc=local', "(&(objectClass=person)(uid={$uid}))", ['dn']);
$entries = ldap_get_entries($conn, $res);
if ($entries['count'] !== 1) { exit('认证失败'); }   // 0 个或 >1 个均拒绝

// 2. 用该 DN 重新绑定校验口令
$dn = $entries[0]['dn'];
$ok = @ldap_bind($conn, $dn, $pass);
if (!$ok) { exit('认证失败'); }

三点关键约束:count 必须严格等于 1,否则存在同名歧义可被利用;绑定失败信息统一返回,不区分「账号不存在」与「口令错误」,避免用户名枚举;空口令必须先拦,LDAP 中空口令的 bind 是匿名绑定,会直接返回成功——这是最经典的逻辑漏洞。

if ($pass === '' || $pass === null) { exit('认证失败'); }

第四步:服务账号最小权限

认证流程只使用只读账号。在 OpenLDAP 中以 slapd.conf 或动态配置限制其能力:

# 只读服务账号 ACL(olcAccess,反序生效,先写拒绝再写允许)
olcAccess: {0}to attrs=userPassword
  by self write
  by anonymous auth
  by dn.exact="cn=svc-ro,ou=svc,dc=corp,dc=local" none
  by * none
olcAccess: {1}to dn.subtree="ou=people,dc=corp,dc=local"
  by dn.exact="cn=svc-ro,ou=svc,dc=corp,dc=local" read
  by users read
  by * none

svc-ro 对 userPassword 必须是 none,只允许读普通属性;by * none 关闭默认放行,避免 ACL 漏配导致全量可读。修改后执行 ldapmodify -Y EXTERNAL -H ldapi:/// -f acl.ldif

第五步:禁用匿名绑定并强制加密

# 1. 禁止匿名绑定
ldapmodify -Y EXTERNAL -H ldapi:/// <<'EOF'
dn: cn=config
changetype: modify
add: olcDisallows
olcDisallows: bind_anon

dn: cn=config
changetype: modify
add: olcRequires
olcRequires: authc
EOF

# 2. 强制 StartTLS
ldapmodify -Y EXTERNAL -H ldapi:/// <<'EOF'
dn: cn=config
changetype: modify
replace: olcTLSCertificateFile
olcTLSCertificateFile: /etc/ldap/ssl/ldap.crt
-
replace: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/ssl/ldap.key
EOF

# 3. 应用侧一律改用 ldaps:// 并校验证书
# php.ini
ldap.tls_reqcert = demand

ldap.tls_reqcert = demand 要求校验服务端证书,防止中间人嗅探明文口令。仅开 636 而不校验证书等于只加密了信道、未验证对端。

第六步:连接限制与操作审计

# 限制查询规模与并发,抑制批量拖取
limits dn.exact="cn=svc-ro,ou=svc,dc=corp,dc=local"
  size.soft=200 size.hard=500 time.soft=30 time.hard=60

# 日志级别
loglevel stats
# slapd 日志与访问审计
logfile /var/log/slapd/slapd.log
loglevel 256    # stats 级别可记录每次查询的 filter

开启 stats 后,slapd.log 会出现完整 filter 记录,用于回溯「谁在什么时候执行了含 * 的异常查询」,也是检测注入尝试的直接依据。

配置验证

# 1. 匿名绑定被拒(期望 Invalid credentials 49)
ldapsearch -x -H ldap://10.0.0.10 -b "dc=corp,dc=local" -s base "(objectclass=*)"

# 2. 注入 payload 返回 0 结果而非全部条目
ldapsearch -x -H ldap://10.0.0.10 -D "cn=svc-ro,ou=svc,dc=corp,dc=local" -W \
  -b "ou=people,dc=corp,dc=local" "(&(objectClass=person)(uid=*))" dn
# 若此前可返回大量条目,说明转义未生效,需回查第二步

# 3. 明文 389 上的敏感操作被拒绝(期望 confidentiality required 13)
ldapsearch -x -H ldap://10.0.0.10 -D "cn=svc-ro,ou=svc,dc=corp,dc=local" -W -b "dc=corp,dc=local"

# 4. TLS 证书校验生效
ldapsearch -x -H ldaps://10.0.0.10:636 -b "dc=corp,dc=local" -s base "(objectclass=*)"

# 5. 应用侧登录回归
curl -s -o /dev/null -w "%{http_code}\n" -d "username=admin&password=wrong" https://app.corp.local/login
# 期望统一返回认证失败,且不暴露账号是否存在

# 6. 异常查询审计
grep -E "filter|SEARCH" /var/log/slapd/slapd.log | tail -30

常见问题

Q1:转义后仍报 filter 语法错误(error 87 / Bad search filter)怎么办?

三类常见原因:一是对整条 filter 模板做了转义,把 ( ) 也转成了 \28 \29,必须只转义值;二是输入含非 UTF-8 字节,转义后形成非法序列,需先做编码归一化(见 PHP 示例中的 mb_convert_encoding);三是值与模板之间多了一个空格,LDAP 会把它当作属性名的一部分。调试时把最终 filter 打印到日志再比对,比凭猜测修改更快定位。

Q2:在 AD 环境禁用匿名绑定后,哪些功能会受影响?

以下场景依赖匿名读取,需提前评估:Linux 主机用 SSSD 加入域若未配置服务账号会失败;部分打印机、扫描仪、NAS 通过匿名 LDAP 拉取通讯录;监控系统的 LDAP 健康探测。处理方式是为这些设备单独创建只读服务账号并写入配置,而不是重新打开匿名。健康探测可改为直接探测 636 端口握手成功与否。AD 侧对应操作是修改 dsHeuristics 或域控的匿名绑定策略,改前务必在测试域验证。

Q3:应用已改用 ldaps:// 636,但仍偶发认证失败或超时?

优先排查三点:一是服务端证书链不完整,客户端只收到叶证书,需把中间 CA 追加到 olcTLSCertificateFile 或系统信任库;二是内网 WAF / 负载均衡代理了 636 且未开启 TCP 透传,导致 TLS 握手被中断,应改为四层转发;三是连接未复用,高并发下触发 slapd 连接数上限,加连接池并在 slapd 侧检查 olcConcurrencylimits 配置。抓包确认握手阶段是否完整走完 ClientHello → ServerHello → Certificate,可快速区分是证书问题还是链路问题。

总结

LDAP 注入的修复成本很低,收益很高。只要落实四条:对所有进入 filter 的值做 RFC 4515 五字符转义认证改为搜索 DN 后再 bind 并强制 count==1显式拒绝空口令服务账号仅授予只读且无法读取 userPassword,注入类攻击即基本失效。再叠加服务端禁用匿名绑定、强制 ldaps 与证书校验、开启 stats 日志审计,即可形成代码层、配置层、审计层三层闭环。