LDAP 实战与排错 — ldapsearch 抓包、AD 对接与错误码定位

LDAP 动手实战:Wireshark 过滤式、ldapsearch/ldapadd/ldappasswd 命令族、LDIF 编写、invalidCredentials/noSuchObject/sizeLimit 排错、与 SQL/Kerberos 对比 / TCP 389

这篇你能学到

  • 用 Wireshark / tshark 把 LDAP 交互看穿:Simple Bind 的明文密码长什么样、怎么从抓包里揪出暴力破解。
  • ldapsearch 全套用法(从匿名探测 RootDSE 到分页拉全量)、LDIF 增删改、AD 专属查询(位掩码、递归查组)、Linux 用 sssd 接入 LDAP 认证。
  • 九类高频故障的定位套路:49 凭据错怎么细分、32 Base DN 错怎么找对、搜到 0 条怎么逐层放宽、结果被截断怎么分页、TLS 报错怎么查证书,外加 LDAP 注入防御、协议对比、速查表与面试题。

抓包观察

Wireshark 显示过滤式

下面这些是抓包排错的"筛子"。先记两条最有用的ldap.resultCode != 0 一眼看到所有失败操作;ldap.simple 一旦能过滤出内容,就说明你的 LDAP 是明文裸奔的。

过滤式用途
ldap所有 LDAP 报文(Wireshark 自动解 BER)
tcp.port == 389明文 LDAP + StartTLS
tcp.port == 636LDAPS(内容已加密,只能看握手
tcp.port == 3268 || tcp.port == 3269AD 全局编录(Global Catalog)
ldap.protocolOp == 0只看 BindRequest
ldap.protocolOp == 3只看 SearchRequest
ldap.resultCode != 0只看失败的操作,排错第一过滤式
ldap.resultCode == 49只看 invalidCredentials(暴力破解检测
ldap.simple明文密码字段! 抓到即说明未加密
ldap.bindRequest_elementBind 请求详情
ldap.filter contains "uid"按过滤器内容筛
ldap.baseObject contains "dc=example"按 Base DN 筛
ldap.messageID == 2追踪某一次操作的完整请求+响应
ldap.attributeDesc == "userPassword"谁在读密码属性

命令行抓包

这四条命令覆盖了排错和审计的常见诉求:把包存下来、把明文口令提出来、统计谁在猛试密码、实时看应用到底发了什么查询。第四条在排查"应用说搜不到用户"时特别管用——直接看它发的 Base DN 和过滤器对不对。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 抓明文 LDAP
sudo tcpdump -i eth0 -s 0 -w ldap.pcap 'tcp port 389 or tcp port 636'

# 直接提取所有 Simple Bind 的 DN 与明文密码(安全审计/演示用)
tshark -r ldap.pcap -Y 'ldap.simple' -T fields \
  -e ip.src -e ip.dst -e ldap.name -e ldap.simple

# 统计失败的 Bind(发现暴力破解)
tshark -r ldap.pcap -Y 'ldap.resultCode == 49' -T fields -e ip.src | sort | uniq -c | sort -rn

# 实时看搜索过滤器(排查应用发了什么查询)
sudo tshark -i eth0 -Y 'ldap.protocolOp == 3' -T fields -e ldap.baseObject -e ldap.filter

典型字段说明

一个 Simple Bind 在 Wireshark 中展开:

1
2
3
4
5
6
7
8
9
Lightweight Directory Access Protocol
    LDAPMessage bindRequest(1) "cn=admin,dc=example,dc=com" simple
        messageID: 1
        protocolOp: bindRequest (0)
            bindRequest
                version: 3 ← LDAPv3 固定为 3
                name: cn=admin,dc=example,dc=com ← 认证身份
                authentication: simple (0)
                    simple: adminP@ssw0rd ← 明文密码,一览无余

对应 SearchRequest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
        protocolOp: searchRequest (3)
            searchRequest
                baseObject: dc=example,dc=com
                scope: wholeSubtree (2) ← 0=base 1=one 2=sub
                derefAliases: neverDerefAliases (0)
                sizeLimit: 0 ← 0 表示不限(受服务器上限约束)
                timeLimit: 0
                typesOnly: False
                Filter: (&(objectClass=inetOrgPerson)(uid=zhangsan))
                attributes: 4 items ← 请求返回的属性

排错三看

  1. resultCodeSearchResultDone / BindResponse 里的结果码直接指明问题(49=密码错、32=Base DN 错、50=权限不足)。
  2. diagnosticMessage:服务器附带的文字说明,AD 会在这里给出子错误码(见下文故障 1)。
  3. 看有没有 ldap.simple 字段:能看到 = 未加密 = 高危。生产环境应当只能看到 TLS 握手。

常用命令 / 配置

安装客户端工具

装的是 ldapsearchldapadd 这一族命令行客户端,服务端不需要它们。

1
2
3
4
5
6
7
# Debian / Ubuntu
sudo apt install ldap-utils

# RHEL / Rocky
sudo yum install openldap-clients

# macOS 自带 ldapsearch;Windows 可用 ldp.exe(AD 自带)或 dsquery

ldapsearch —— 最核心的命令

这是 LDAP 世界里最重要的一条命令,读、探测、验证全靠它。其中第 ① 条是"到了陌生服务器先做的事":不用任何账号就能问出"你有哪些 Base DN、支不支持 StartTLS、支不支持分页"。后面几条依次是查 Schema、认证后搜人、生产可用的组合、只要 DN、分页拉全量、查组、查人所属组、用 Kerberos 免密登录、验证"我现在是谁"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# 语法:ldapsearch [连接选项] [认证选项] [搜索选项] '<过滤器>' [要返回的属性...]

# ① 匿名查 RootDSE —— 探测服务器能力(第一步永远做这个)
ldapsearch -x -H ldap://ldap.example.com -s base -b "" '(objectClass=*)' '+'
# 能看到:namingContexts(有哪些 Base DN)、supportedLDAPVersion、
# supportedSASLMechanisms、supportedExtension(有没有 StartTLS)、
# supportedControl(支不支持分页)

# ② 查 Schema
ldapsearch -x -H ldap://ldap.example.com -s base -b "cn=subschema" '(objectClass=*)' \
  objectClasses attributeTypes

# ③ 认证后搜索单个用户
ldapsearch -x -H ldap://ldap.example.com \
  -D "cn=admin,dc=example,dc=com" -W \
  -b "dc=example,dc=com" -s sub \
  '(uid=zhangsan)'

# 参数说明:
# -x 使用 Simple 认证(不加则默认 SASL)
# -H 服务器 URI(ldap:// 或 ldaps://)
# -D Bind DN
# -w <pass> 命令行给密码(会进 shell history 和 ps 输出)
# -W 交互式提示输入密码(推荐)
# -y <file> 从文件读密码(脚本推荐)
# -b Base DN
# -s base|one|sub 搜索 scope
# -Z 尝试 StartTLS;-ZZ 强制 StartTLS(失败即退出,防降级)
# -LLL 精简 LDIF 输出(去掉版本行和注释)
# -o ldif-wrap=no 不换行折叠(长 DN 便于 grep)
# -E pr=500/noprompt 启用分页
# -C 自动跟随 referral

# ④ 最实用的组合:精简输出 + 强制加密 + 不折行
ldapsearch -x -LLL -o ldif-wrap=no -ZZ \
  -H ldap://ldap.example.com \
  -D "cn=readonly,dc=example,dc=com" -y /etc/ldap.secret \
  -b "dc=example,dc=com" \
  '(&(objectClass=inetOrgPerson)(mail=*))' uid cn mail memberOf

# ⑤ 只要 DN,不要属性(1.1 是特殊值)
ldapsearch -x -LLL -H ldap://host -b "dc=example,dc=com" '(objectClass=person)' 1.1

# ⑥ 分页拉取全量(应对 sizeLimitExceeded)
ldapsearch -x -LLL -E pr=500/noprompt \
  -H ldap://host -D "..." -W -b "dc=example,dc=com" '(objectClass=*)'

# ⑦ 查组成员
ldapsearch -x -LLL -H ldap://host -b "dc=example,dc=com" '(cn=dev-team)' member

# ⑧ 查某人所属的组(OpenLDAP,需 memberOf overlay)
ldapsearch -x -LLL -H ldap://host -b "dc=example,dc=com" '(uid=zhangsan)' memberOf

# ⑨ SASL/GSSAPI(Kerberos)认证,无需明文密码
kinit zhangsan@EXAMPLE.COM
ldapsearch -H ldap://dc.example.com -Y GSSAPI -b "dc=example,dc=com" '(uid=zhangsan)'

# ⑩ 我是谁(Whoami 扩展操作,验证 Bind 身份)
ldapwhoami -x -H ldap://host -D "cn=admin,dc=example,dc=com" -W

Active Directory 常用查询

AD 换了一套属性名,所以查询写法也不一样。下面这组分别解决:按登录名找人、只列启用的账号、把嵌套组里的人全挖出来、揪出密码永不过期的高危账号、列出所有域控、以及不知道域控地址时用 DNS 自动发现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
AD=ldap://dc01.example.com
BASE="dc=example,dc=com"
BIND="CN=svc-ldap,OU=Service,DC=example,DC=com"

# 按登录名查用户
ldapsearch -x -LLL -H $AD -D "$BIND" -W -b "$BASE" \
  "(&(objectClass=user)(sAMAccountName=zhangsan))" \
  distinguishedName displayName mail memberOf userAccountControl

# 只查【启用】的用户(排除禁用位 0x2)
ldapsearch -x -LLL -H $AD -D "$BIND" -W -b "$BASE" \
  "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" \
  sAMAccountName mail

# 递归查某组的所有成员(含嵌套组,AD 专属扩展匹配规则 1941)
ldapsearch -x -LLL -H $AD -D "$BIND" -W -b "$BASE" \
  "(memberOf:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=example,DC=com)" \
  sAMAccountName

# 查密码永不过期的账号(安全审计,位 0x10000)
ldapsearch -x -LLL -H $AD -D "$BIND" -W -b "$BASE" \
  "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=65536))" sAMAccountName

# 查所有域控
ldapsearch -x -LLL -H $AD -D "$BIND" -W -b "$BASE" \
  "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))" dNSHostName

# 通过 DNS SRV 自动发现域控
dig +short SRV _ldap._tcp.dc._msdcs.example.com

userAccountControl 常用位掩码速查(配合 :1.2.840.113556.1.4.803:= 位与匹配):

含义
2 (0x2)账号已禁用
16 (0x10)账号已锁定
512 (0x200)普通启用账号
65536 (0x10000)密码永不过期
8192 (0x2000)域控计算机账号
4194304 (0x400000)不要求 Kerberos 预认证(AS-REP Roasting 风险

写操作:LDIF 与 ldapadd / ldapmodify

LDIF(LDAP Data Interchange Format,RFC 2849)是 LDAP 的标准文本交换格式。

下面第一个文件是"新建一个用户"的完整模板:第一行是这个条目的完整路径,接着声明它属于哪几个类,然后才是具体属性(注意 mail 写了两行——这就是多值属性)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# ---- add-user.ldif : 新增一个用户 ----
dn: uid=zhangsan,ou=people,dc=example,dc=com
objectClass: top
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: zhangsan
cn: 张三
sn: 张
givenName: 三
mail: zhangsan@example.com
mail: z.san@example.com
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/zhangsan
loginShell: /bin/bash
userPassword: {SSHA}xxxxxxxxxxxxxxxxxxxxxxxxxxxx

改属性的文件格式不一样:要写 changetype: modify,然后用 replace(整体替换)/ add(追加)/ delete(删除)三种动作,每个动作之间用单独一行的 - 分隔。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ---- modify-user.ldif : 修改属性 ----
dn: uid=zhangsan,ou=people,dc=example,dc=com
changetype: modify
replace: mail
mail: new@example.com
-
add: telephoneNumber
telephoneNumber: 13800138000
-
delete: description

改名和"把人挪到另一个部门"用的是同一个操作:newrdn 是新的小名,newsuperior 是新的父节点——员工离职挪到 ou=leavers 就是这么做的。

1
2
3
4
5
6
# ---- 移动/改名 ----
dn: uid=zhangsan,ou=people,dc=example,dc=com
changetype: modrdn
newrdn: uid=zhangsan
deleteoldrdn: 1
newsuperior: ou=leavers,dc=example,dc=com

写好 LDIF 后用下面的命令执行。注意改密码不要自己算哈希再写属性,用 ldappasswd 走标准扩展操作让服务器自己处理最稳妥;最后一条则是把整棵树导出成 LDIF 做备份。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 执行
ldapadd -x -H ldap://host -D "cn=admin,dc=example,dc=com" -W -f add-user.ldif
ldapmodify -x -H ldap://host -D "cn=admin,dc=example,dc=com" -W -f modify-user.ldif
ldapdelete -x -H ldap://host -D "cn=admin,dc=example,dc=com" -W \
  "uid=zhangsan,ou=people,dc=example,dc=com"

# 改密码(走 RFC 3062 扩展操作,服务器负责哈希)
ldappasswd -x -H ldaps://host -D "cn=admin,dc=example,dc=com" -W \
  -S "uid=zhangsan,ou=people,dc=example,dc=com"

# 生成密码哈希(OpenLDAP)
slappasswd -h {SSHA} -s 'MyP@ssw0rd'

# 导出全量数据备份
ldapsearch -x -LLL -H ldap://host -D "cn=admin,dc=example,dc=com" -W \
  -b "dc=example,dc=com" '(objectClass=*)' > backup.ldif

服务端:快速起一个测试 OpenLDAP

学 LDAP 最快的方式是自己起一个来练手。下面一条 docker 命令就能得到一台可读写的目录服务器,第二条验证它活了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
docker run -d --name openldap -p 389:389 -p 636:636 \
  -e LDAP_ORGANISATION="Example Inc" \
  -e LDAP_DOMAIN="example.com" \
  -e LDAP_ADMIN_PASSWORD="admin123" \
  osixia/openldap:latest

# 验证
ldapsearch -x -LLL -H ldap://localhost \
  -D "cn=admin,dc=example,dc=com" -w admin123 \
  -b "dc=example,dc=com" '(objectClass=*)'

Linux 主机接入 LDAP 认证(sssd)

这份配置的作用是:让这台 Linux 不再只认 /etc/passwd 里的本地账号,而是把"用户存不存在"“密码对不对"都交给 LDAP 服务器判断。配好之后,目录里的员工可以直接 ssh 登录这台机器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# /etc/sssd/sssd.conf (权限必须是 0600)
[sssd]
services = nss, pam
domains = EXAMPLE

[domain/EXAMPLE]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example.com:636
ldap_search_base = dc=example,dc=com
ldap_default_bind_dn = cn=readonly,dc=example,dc=com
ldap_default_authtok = ReadOnlyPass
ldap_tls_reqcert = demand
ldap_tls_cacert = /etc/ssl/certs/ca.crt
cache_credentials = true
enumerate = false

改完必须收紧文件权限(否则 sssd 拒绝启动)再重启,然后用下面三条验证:getent 看能不能查到人,id 看组映射对不对,sssctl 是官方诊断工具。

1
2
3
4
5
sudo chmod 600 /etc/sssd/sssd.conf
sudo systemctl restart sssd
getent passwd zhangsan # 能查到即成功
id zhangsan # 看组映射
sudo sssctl user-checks zhangsan # 官方诊断工具

常见故障与排错

故障 1:ldap_bind: Invalid credentials (49)

先记住结论:49 不等于"密码打错了”。 它是个大杂烩——DN 写错、账号被禁、密码过期、空密码,全都报 49。在 AD 上一定要去看 diagnosticMessage 里的 data xxx 子码,那才是真正的原因。

现象:Bind 失败。但"凭据无效"这个提示很笼统,实际原因很多。

原因排查
密码真错了换个已知正确的账号试
Bind DN 写错Bind 要用完整 DNcn=admin,dc=example,dc=com),不是用户名 admin
DN 里有多余空格/逗号ldapwhoami 验证;注意 dc=example, dc=com 中间的空格在某些实现会出问题
账号被锁定/禁用/过期AD 看 userAccountControl;OpenLDAP 看 pwdAccountLockedTime
未认证 Bind(密码为空)空密码在部分服务器会被当成匿名 Bind"成功",反而更危险

AD 的诊断消息带子错误码,非常有用:

1
2
80090308: LdapErr: DSID-0C0903A9, comment: AcceptSecurityContext error, data 52e, v3839
                                                                              ^^^^
data 码含义
525用户不存在
52e用户名正确但密码错误
530不允许在此时间登录
531不允许在此工作站登录
532密码已过期
533账号已禁用
701账号已过期
773用户必须修改密码(首次登录)
775账号已锁定

用这个码就能区分"密码错"和"账号被禁",避免误判。

故障 2:No such object (32) —— 搜不到任何东西

先记住结论:九成是 Base DN 写错了,而不是数据没了。 别猜,直接匿名查一下 RootDSE 的 namingContexts,服务器会告诉你它到底管着哪几棵树。

原因排查
Base DN 错误(最常见)先查 RootDSE 拿正确的 namingContexts:
ldapsearch -x -H ldap://host -s base -b "" namingContexts
Base DN 大小写/空格LDAP 的 DN 属性名大小写不敏感,但值的空格敏感
域名转 DN 转错example.comdc=example,dc=comcorp.example.comdc=corp,dc=example,dc=com
条目在别的域/分区AD 多域森林要用全局编录 3268 端口跨域搜

故障 3:搜索返回 0 条但 Base DN 没错

先记住结论:这时候要怀疑的不是数据,而是你的三个参数——scope 挖得够不够深、objectClass 名字在这个实现里对不对、以及你这个账号有没有读的权限。

原因排查
scope 用了 baseone改成 -s sub
过滤器 objectClass 写错OpenLDAP 是 inetOrgPersonAD 是 user;换成 (objectClass=*) 先看有没有东西
ACL 限制:能 Bind 但读不到用管理员账号对比测试;OpenLDAP 检查 olcAccess
属性名大小写/拼写sAMAccountName 不是 samaccountname(虽然多数实现不敏感,但个别客户端敏感)
中文/特殊字符编码LDAP 使用 UTF-8,确保终端 locale 是 UTF-8

万能排查法:逐层放宽

思路是从"最宽松"开始一步步收紧,哪一步开始搜不到,问题就在那一步引入的条件上。

1
2
3
4
5
6
7
# 第 1 步:Base DN 存在吗
ldapsearch -x -LLL -H $H -D "$D" -W -b "$BASE" -s base '(objectClass=*)' dn
# 第 2 步:子树下有东西吗(先只要 DN,避免刷屏)
ldapsearch -x -LLL -H $H -D "$D" -W -b "$BASE" -s sub '(objectClass=*)' dn | head -50
# 第 3 步:逐步加过滤条件
ldapsearch ... '(objectClass=person)' dn | head
ldapsearch ... '(&(objectClass=person)(uid=zhangsan))' dn

故障 4:Size limit exceeded (4) / Administrative limit exceeded (11)

先记住结论:这不是"查询失败",而是"结果被悄悄截断了"——最危险的地方在于前面那部分数据看起来是正常返回的。 如果你的同步脚本忽略了这个错误码,就会把上千个用户当成"已删除"处理。

结果集超过服务器上限(AD 默认 MaxPageSize=1000,OpenLDAP sizelimit 默认 500)。注意此时返回的结果是不完整的,直接用会导致数据丢失。

三种解法按推荐度排序:分页是标准做法;按 OU 分批是不改服务端时的变通;调服务端上限需要管理员权限且治标不治本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 解法一:分页(推荐)
ldapsearch -E pr=500/noprompt ...

# 解法二:缩小范围,按 OU 分批
for ou in beijing shanghai shenzhen; do
  ldapsearch -x -LLL -b "ou=$ou,dc=example,dc=com" '(objectClass=person)' uid
done

# 解法三(服务端,需管理员权限):调高上限
# OpenLDAP: olcSizeLimit: 5000
# AD: ntdsutil "LDAP policies" connections "connect to server DC01" q \
# "set MaxPageSize to 5000" commit changes q q

故障 5:Confidentiality required (13) / Strong(er) auth required (8)

先记住结论:服务器在说"你没加密,我不跟你谈"——这不是配置坏了,是安全策略生效了,正确做法是把连接加密,而不是想办法关掉这个限制。

服务器强制要求加密连接(AD 开启 LDAP Signing/Channel Binding 后是默认行为;改密码操作也必须加密)。

两条路二选一:在 389 上升级成 TLS,或者直接连 636。

1
2
3
4
# 用 StartTLS
ldapsearch -x -ZZ -H ldap://host ...
# 或直接 LDAPS
ldapsearch -x -H ldaps://host:636 ...

故障 6:TLS 证书验证失败

先记住结论:绝大多数 TLS 报错只有三种根因——CA 没被信任、证书上的名字和你连的主机名对不上、或者证书本身过期了。 先把服务器证书打出来看一眼,比盲试快得多。

1
2
ldap_start_tls: Connect error (-11)
  additional info: TLS error -8172:Peer's certificate issuer has been marked as not trusted
排查命令
看服务器证书链openssl s_client -connect host:636 -showcerts </dev/null
StartTLS 方式看证书openssl s_client -connect host:389 -starttls ldap </dev/null
证书 CN/SAN 与连接主机名不符必须用证书里的名字连(不能用 IP 连一个只签了域名的证书)
CA 未信任把 CA 加入 /etc/openldap/ldap.confTLS_CACERT /path/ca.crt
临时跳过(仅调试LDAPTLS_REQCERT=never ldapsearch ... 或 ldap.conf 里 TLS_REQCERT allow

故障 7:Object class violation (65) —— 添加条目失败

先记住结论:这是 Schema 在拦你——你声明的 objectClass 要求的必填属性没给全,或者你给条目安了两个互斥的结构类。 属于"数据格式不合法",跟权限、网络都没关系。

原因排查
缺 MUST 属性inetOrgPerson 必须有 cnsn
两个 STRUCTURAL 类冲突一个条目只能有一条结构类继承链,不能同时是 inetOrgPersongroupOfNames
top多数实现要求带上 objectClass: top
RDN 属性未在条目中出现DN 是 uid=zhangsan,... 就必须有 uid: zhangsan 属性(否则报 namingViolation(64)

故障 8:Not allowed on non-leaf (66) —— 删不掉

先记住结论:LDAP 没有"级联删除"这回事,只能从最深的叶子开始一层层往上删。 想删一整个 OU,必须先把里面的条目清空。

LDAP 的 DelRequest 只能删叶子节点。要删整个 OU 必须先递归删光子节点:

下面这条流水线的思路是:把子树里所有 DN 列出来,按逗号个数(也就是层级深度)从多到少排序,深的先删,最后才轮到 OU 本身。

1
2
3
4
5
# 先列出子树所有 DN(按深度倒序),再逐个删
ldapsearch -x -LLL -H $H -D "$D" -W -b "ou=old,dc=example,dc=com" \
  '(objectClass=*)' dn | grep '^dn: ' | sed 's/^dn: //' | \
  awk '{print gsub(/,/,",") "\t" $0}' | sort -rn | cut -f2 | \
  while read d; do ldapdelete -x -H $H -D "$D" -w "$P" "$d"; done

故障 9:安全 —— LDAP 注入

先记住结论:只要你是用字符串拼接来构造过滤器的,你就有 LDAP 注入漏洞——修复方式只有一个,对用户输入做 RFC 4515 转义。

下面的代码对比了错误写法和正确写法:上半段演示攻击者如何把 (uid=X) 变成永真式,下半段是必须加上的转义函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ❌ 危险:直接拼接
filter = f"(uid={username})"
# 用户输入 username = "*)(uid=*))(|(uid=*"
# 得到 (uid=*)(uid=*))(|(uid=*) → 永真,任意用户都能"查到"

# ✅ 正确:转义特殊字符
import re
def escape_ldap_filter(s: str) -> str:
    mapping = {'\\': r'\5c', '*': r'\2a', '(': r'\28', ')': r'\29', '\0': r'\00'}
    return ''.join(mapping.get(c, c) for c in s)

filter = f"(uid={escape_ldap_filter(username)})"

其它安全要点

  • 服务账号只给只读权限,且限制可读子树;
  • 禁用匿名 Bind 与空密码 Bind(应用层必须显式拒绝空密码);
  • 全面启用 StartTLS/LDAPS,AD 开启 LDAP Signing + Channel Binding;
  • 监控 resultCode=49 的高频来源,识别口令喷洒(Password Spraying);
  • 不要在 LDAP 里存业务敏感数据(目录数据往往对全公司可读)。

与其他协议对比

维度LDAP关系数据库(SQL)KerberosSAML / OIDCSCIM
定位目录查询 + 认证通用数据存储票据认证Web SSO 联邦账号供给/同步
数据模型树形 DIT,稀疏多值属性关系表,行列固定无(只管票据)断言/令牌JSON 资源
端口TCP 389 / 6363306 / 5432 等88(TCP/UDP)HTTPS 443HTTPS 443
编码BER(ASN.1 二进制)各自私有协议ASN.1XML / JWTJSON
读写倾向读多写少,为读优化读写均衡写为主
事务无跨条目事务ACID
认证方式Bind(Simple / SASL)用户名密码票据(TGT/ST)令牌Bearer Token
是否携带密码Simple Bind 传明文密码传密码从不传密码(挑战响应)不传(重定向到 IdP)不传
单点登录❌ 每次都要 Bind✅ 票据可复用
典型场景企业通讯录、统一账号库业务数据Windows 域登录云应用登录云端账号同步

在 AD 域里三者如何配合

1
2
3
4
5
用户登录 Windows
  → Kerberos AS-REQ/TGS-REQ(88 端口)拿票据 ← 认证
  → LDAP 查用户所属组、组策略链接(389 端口) ← 授权与目录
  → SMB 从 SYSVOL 拉组策略文件(445 端口) ← 策略分发
  → DNS SRV 记录定位域控(53 端口) ← 服务发现

速查表 / 常见面试题

端口 / 关键值速查

LDAP 明文 / StartTLSTCP 389
LDAPS(隐式 TLS)TCP 636
AD 全局编录TCP 3268 / SSL 3269
AD Kerberos88
StartTLS 扩展 OID1.3.6.1.4.1.1466.20037
分页控制 OID1.2.840.113556.1.4.319
AD 递归查组 OID1.2.840.113556.1.4.1941
AD 位与匹配 OID1.2.840.113556.1.4.803
协议版本号LDAPv3 = 3
AD 默认返回上限1000
特殊属性列表* 全部用户属性 / + 全部操作属性 / 1.1 只要 DN

过滤器速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(uid=zhangsan) 精确匹配
(cn=张*) 前缀匹配
(mail=*) 属性存在
(!(mail=*)) 属性不存在
(&(objectClass=person)(department=研发)) 与
(|(uid=zs)(uid=ls)) 或
(uidNumber>=10000) 大于等于(无 > 运算符)
(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))) AD 启用账号
(memberOf:1.2.840.113556.1.4.1941:=CN=dev,OU=G,DC=example,DC=com) AD 递归组成员
转义:*→\2a (→\28 )→\29 \→\5c NUL→\00

结果码速查

名称一句话
0success成功
4sizeLimitExceeded结果太多 → 分页
10referral去别的服务器找
11adminLimitExceededAD 超 1000 条
13confidentialityRequired必须加密
32noSuchObjectBase DN 错
34invalidDNSyntaxDN 格式错
49invalidCredentials账号或密码错
50insufficientAccessRights权限不足
53unwillingToPerform服务器拒绝
65objectClassViolation缺 MUST 属性
66notAllowedOnNonLeaf删不了非叶子
68entryAlreadyExistsDN 已存在

常见面试题

Q1:DN 和 RDN 的区别? A:RDN 是条目在父节点下的相对名(uid=zhangsan),只要求在兄弟节点间唯一;DN 是从条目到树根拼接的完整路径(uid=zhangsan,ou=people,dc=example,dc=com),全局唯一,相当于主键。书写顺序是从叶到根,与文件路径相反。

Q2:LDAP 的 scope 有哪三种?各查什么? A:base 只查 Base DN 这一个条目;one 查它的直接子节点(不含自己、不含孙节点);sub 查它及所有后代。应用集成一般用 sub;验证某 DN 是否存在用 base

Q3:LDAP 和关系数据库有什么本质区别?为什么不用 MySQL 存用户? A:① 数据模型:LDAP 是树形 + 稀疏多值属性,天然贴合组织架构;SQL 是固定行列,存稀疏属性会退化成大宽表或 EAV。② 读写倾向:LDAP 为读优化(倒排索引),写和复制代价高;SQL 读写均衡。③ 标准化协议:LDAP 有统一的协议和 Schema,几百种应用开箱即支持"对接 LDAP";用 MySQL 存用户则每个应用都要自己写适配。④ 无事务:LDAP 没有跨条目事务,所以不适合做业务数据库

Q4:应用集成 LDAP 的"两次 Bind"是什么?为什么不能直接读密码比对? A:① 用服务账号 Bind 获得搜索权限;② Search 找到用户的完整 DN;③ 用该 DN + 用户输入的密码再 Bind,成功即认证通过。不能直接读 userPassword 是因为:密码通常是加盐哈希({SSHA})且算法不定、服务器一般禁止读取该属性、AD 根本不通过 LDAP 暴露密码。让服务器自己验证是唯一正确方式

Q5:StartTLS 和 LDAPS 有什么区别?该用哪个? A:LDAPS 走 636 端口,连上就 TLS 握手(隐式 TLS),无 RFC 但是事实标准;StartTLS 走 389 端口,先明文连接再用扩展操作(OID 1.3.6.1.4.1.1466.20037)升级,RFC 4513 官方推荐。StartTLS 的风险是可能被中间人剥离(降级攻击),所以客户端必须用 -ZZ 强制成功。现代实践两者都可以,关键是必须加密——Simple Bind 传的是明文密码。

Q6:LDAP 和 Active Directory 是什么关系? A:LDAP 是协议,AD 是微软的目录服务产品。AD 用 LDAP 作为主要访问协议,但 AD 远不止 LDAP——它还整合了 Kerberos(88,认证)、DNS(53,SRV 服务发现)、SMB(445,SYSVOL 组策略分发)、全局编录(3268)、复制协议(DRSUAPI/RPC)。反过来 OpenLDAP 是纯 LDAP 实现,没有 Kerberos 和组策略。

Q7:什么是 LDAP 注入?怎么防? A:把用户输入直接拼进过滤器字符串。输入 *)(uid=*))(|(uid=* 会把 (uid=<输入>) 变成永真式,可能绕过认证或越权查询。防御是* ( ) \ NUL 五个字符按 RFC 4515 转义为 \2a \28 \29 \5c \00,或使用支持参数化的 LDAP 库。

Q8:为什么删除一个 OU 会报错 66? A:notAllowedOnNonLeaf —— LDAP 的 DelRequest 语义上只能删除叶子节点,不存在"级联删除"。必须先递归删光所有子条目(按 DN 深度从深到浅),最后才能删这个 OU。

Q9:AD 搜索用户只返回 1000 条怎么办? A:AD 的 MaxPageSize 默认 1000,超过会返回 adminLimitExceeded(11)结果不完整。正确做法是启用分页控制(Control OID 1.2.840.113556.1.4.319),命令行用 ldapsearch -E pr=500/noprompt,代码里用库的 paged search API。也可以按 OU 分批查询规避。

Q10:怎么快速摸清一台陌生 LDAP 服务器? A:查 RootDSE-s base -b "")。它是匿名可读的元数据条目,能拿到:namingContexts(有哪些 Base DN)、supportedLDAPVersionsupportedSASLMechanisms(支持哪些认证)、supportedExtension(有没有 StartTLS)、supportedControl(支不支持分页)、subschemaSubentry(Schema 在哪)。这是所有 LDAP 排错和渗透的第一步。