LDAP 原理与报文 — DIT 目录树、DN 命名与 LDAPMessage 结构
LDAP 工作原理:目录信息树、DN/RDN、objectClass 与 Schema、LDAPMessage/十种操作、SearchRequest scope 与 RFC 4515 过滤器、Bind 与 StartTLS / TCP 389 / RFC 4511
先建立直觉
LDAP 做的事情,说白了就是"查通讯录"和"核对身份"两件事。
想象公司有一本按部门分层整理好的花名册:翻到某一层能看到这个部门下有哪些人,每个人名下记着邮箱、电话、属于哪几个小组。你可以说"从整个公司范围里,找出所有研发部且有邮箱的人",它就把符合条件的一批人给你列出来。
同时它还兼职门卫:你把名字和口令报给它,它自己去核对,只回你"对"或"不对",从不把口令本身给你看。所有需要登录的系统都来问同一个门卫,公司的账号就统一了。
它解决什么问题
如果没有 LDAP,每个系统都得自己养一份用户名单:邮箱一份、VPN 一份、代码仓库一份、OA 一份。新人入职要建七八个账号,离职了总有一两个忘了删——那就是安全漏洞。
而且这些名单天生是有层级的(公司下面是部门,部门下面是小组),还特别"参差不齐":有人有三个邮箱,有人一个都没有。用普通的表格来装,要么列多到离谱,要么全是空格。
LDAP 就是专门为这种"层级 + 稀疏 + 读得多改得少 + 大家共用"的数据设计的,顺便把"验密码"这件事也统一收了过去。
工作流程(简化版)
- 客户端跟服务器建一条 TCP 连接,通常先把这条连接加密(StartTLS 或直接连 636)。
- 客户端报上身份做一次认证(Bind),之后这条连接上的所有操作都以这个身份进行。
- 客户端发起搜索:说清楚"从哪儿开始找"“找多深"“按什么条件筛"“要哪些字段”。
- 服务器把命中的条目一条一条发回来,最后再发一条"搜完了"作为结束标志。
- 需要验证某个用户的密码时,就拿这个用户的完整路径(DN)加上他输入的密码再 Bind 一次——服务器说成功,就说明密码对。
报文 / 头部长什么样
LDAP 报文用 BER(Basic Encoding Rules)编码 ASN.1,与 SNMP 同源。
先看数据长什么样:DIT 目录信息树
看这个结构前先记住:LDAP 的数据不是表,是一棵树;树上每个节点叫"条目”,条目里的每个属性都可以有多个值。
LDAP 服务器维护一棵 DIT(Directory Information Tree,目录信息树):
| |
命名规则:
看这张表前先记住:RDN 是"小名”,DN 是"全名";全名 = 一路把小名从自己拼到树根,而且是从叶到根倒着写的。
| 概念 | 例子 | 说明 |
|---|---|---|
| RDN(Relative Distinguished Name,相对可辨识名) | uid=zhangsan | 条目在同一父节点下必须唯一;可以是多值 RDN,用 + 连接:cn=张三+uid=zs |
| DN(Distinguished Name,可辨识名) | uid=zhangsan,ou=people,dc=example,dc=com | RDN 从叶到根用 , 连接,全局唯一,相当于主键 |
| Base DN / Suffix(基准 DN / 后缀) | dc=example,dc=com | 该服务器管辖的子树根 |
| 常用命名属性 | dc(domainComponent,域名分量)ou(organizationalUnit,组织单元)o(organization,组织)cn(commonName,通用名)uid(userid,用户标识)c(country,国家) | AD 中常用 CN= 和 OU=,OpenLDAP 常用 uid= 和 ou= |
顺序陷阱:DN 是从叶到根书写的,与文件路径
/dc/example/ou/people方向相反。cn=admin,dc=example,dc=com中,dc=com是最顶层。
Schema:objectClass 决定条目长什么样
看这张表前先记住:objectClass 就是条目的"表结构声明"——它规定这类条目必须有哪些属性、可以有哪些属性。每个条目必须至少声明一个。
每个条目必须至少有一个 objectClass 属性,它决定了这个条目允许/必须拥有哪些属性。
| 概念 | 说明 |
|---|---|
| AttributeType(属性类型) | 定义一个属性的 OID、名称、语法(syntax)、匹配规则(EQUALITY / SUBSTR / ORDERING)、是否单值 |
| objectClass(对象类) | 定义一组属性的集合,分 MUST(必需)与 MAY(可选) |
| STRUCTURAL(结构类) | 结构类,决定条目"是什么",每个条目有且仅有一条结构类继承链(如 inetOrgPerson) |
| AUXILIARY(辅助类) | 辅助类,给条目"加料",可叠加多个(如 posixAccount 加 Unix 属性、shadowAccount 加密码策略) |
| ABSTRACT(抽象类) | 抽象类,只能被继承,如所有类的根 top |
常见继承链:top → person(MUST: cn, sn)→ organizationalPerson → inetOrgPerson(MAY: mail, uid, employeeNumber, jpegPhoto…)
匹配规则很关键(决定搜索行为):
看这张表前先记住:同样是"等于",不同属性的比法不一样——文本类多半忽略大小写,数字类按数值大小比,这直接决定了你的过滤器能不能命中。
| 属性 | EQUALITY 匹配规则 | 效果 |
|---|---|---|
cn, mail, uid | caseIgnoreMatch | 大小写不敏感,ZhangSan 能匹配 zhangsan |
userPassword | octetStringMatch | 二进制精确匹配 |
uidNumber | integerMatch | 数值比较,10002 > 999 成立(若按字符串则不成立) |
一条 TCP 连接上的多路复用
LDAP 是有状态协议,但一条 TCP 连接上可以并发发起多个操作:
- 每个请求带一个 messageID(客户端递增分配),响应用同一个 messageID 关联;
- 服务器可以乱序返回,客户端靠 messageID 对号入座;
- 一次 Search 的结果由多条消息组成:N 个
SearchResultEntry+ 0~N 个SearchResultReference+ 1 个SearchResultDone(收到它才算搜完); - 客户端可用
AbandonRequest中途放弃一个操作(无响应)。
连接的认证状态是连接级的:Bind 一次后,该连接上后续所有操作都以该身份执行。再次 Bind 会替换当前身份。
LDAPMessage 外层(RFC 4511 §4.1.1)
看这张表前先记住:每条 LDAP 消息都是"编号 + 干什么 + 附加选项"三段式,编号用来对号入座,附加选项(controls)是协议留的万能扩展口。
| |
| 字段 | 含义 |
|---|---|
messageID(消息标识) | 请求/响应配对标识。同一连接内对未完成的操作必须唯一;0 保留给 Unsolicited Notification(服务器主动通知,如"即将断开") |
protocolOp(协议操作) | 具体操作,由 BER 应用类标签区分(见下表) |
controls(控制项) | 控制项,协议的扩展点。携带 OID + 值,如分页(1.2.840.113556.1.4.319)、排序、AD 的 LDAP_SERVER_SHOW_DELETED_OID |
protocolOp 应用标签一览
看这张表前先记住:LDAP 全部能力就这十来种操作,其中真正天天用的只有 Bind(认证)和 Search(查询)两个;注意 Unbind 和 Abandon 是不回响应的。
| Tag | 操作 | 方向 | 说明 |
|---|---|---|---|
[APPLICATION 0] | BindRequest | C→S | 认证 |
[APPLICATION 1] | BindResponse | S→C | |
[APPLICATION 2] | UnbindRequest | C→S | 无响应,通知服务器关闭连接 |
[APPLICATION 3] | SearchRequest | C→S | 查询 |
[APPLICATION 4] | SearchResultEntry | S→C | 每条结果一个 |
[APPLICATION 5] | SearchResultDone | S→C | 搜索结束标志 + 结果码 |
[APPLICATION 6] | ModifyRequest | C→S | 改属性 |
[APPLICATION 7] | ModifyResponse | S→C | |
[APPLICATION 8] | AddRequest | C→S | 加条目 |
[APPLICATION 9] | AddResponse | S→C | |
[APPLICATION 10] | DelRequest | C→S | 删条目(只能删叶子节点) |
[APPLICATION 11] | DelResponse | S→C | |
[APPLICATION 12] | ModifyDNRequest | C→S | 改名 / 移动子树 |
[APPLICATION 13] | ModifyDNResponse | S→C | |
[APPLICATION 14] | CompareRequest | C→S | 比较属性值(不返回值,只返回真假) |
[APPLICATION 15] | CompareResponse | S→C | |
[APPLICATION 16] | AbandonRequest | C→S | 放弃操作,无响应 |
[APPLICATION 19] | SearchResultReference | S→C | 引用(referral),指向别的服务器 |
[APPLICATION 23] | ExtendedRequest | C→S | 扩展操作(StartTLS、改密码、Whoami) |
[APPLICATION 24] | ExtendedResponse | S→C | |
[APPLICATION 25] | IntermediateResponse | S→C | 中间响应(如同步复制推送) |
BindRequest 字段
看这张表前先记住:Bind 就是"我是谁 + 我怎么证明"——name 填完整 DN,authentication 决定是把明文密码直接甩过去(simple)还是走安全机制(sasl)。
| 字段 | 类型 | 含义 |
|---|---|---|
version(协议版本) | INTEGER (1..127) | 协议版本,LDAPv3 固定为 3 |
name(认证身份 DN) | LDAPDN | 要认证的 DN(如 cn=admin,dc=example,dc=com)。匿名 Bind 时为空串 |
authentication(认证信息) | CHOICE | simple [0] OCTET STRING(明文密码!)或 sasl [3] SaslCredentials{ mechanism, credentials } |
三种 Bind 形态:
| 形态 | name | 密码 | 安全性 |
|---|---|---|---|
| 匿名 Bind | 空 | 空 | 只能访问允许匿名的公开条目 |
| 未认证 Bind | 有 DN | 空串 | RFC 4513 明确警告:许多服务器会误判为"匿名成功",是经典漏洞。客户端必须自己拒绝空密码 |
| Simple Bind | 有 DN | 明文 | 裸奔,必须叠加 TLS |
| SASL Bind | 空(身份在 SASL 里) | mechanism=GSSAPI/EXTERNAL/DIGEST-MD5 | 推荐。GSSAPI 即 Kerberos,EXTERNAL 即客户端证书 |
SearchRequest 字段(最重要)
看这张表前先记住:一次搜索 = 从哪儿开始(base)+ 找多深(scope)+ 筛什么(filter)+ 要哪些字段(attributes),其余都是限流参数。
| 字段 | 类型 | 含义 |
|---|---|---|
baseObject(搜索基准) | LDAPDN | 搜索起点,从哪个 DN 开始 |
scope(搜索范围) | ENUMERATED | 搜索范围:0 baseObject(只查该条目本身)/ 1 singleLevel(只查直接子节点,不含自己)/ 2 wholeSubtree(整棵子树,含自己) |
derefAliases(别名解引用) | ENUMERATED | 别名解引用策略:0 neverDerefAliases / 1 derefInSearching / 2 derefFindingBaseObj / 3 derefAlways |
sizeLimit(条数上限) | INTEGER | 客户端要求的最大返回条数(0=无限制)。服务器端还有自己的硬上限,取两者较小值 |
timeLimit(时间上限) | INTEGER | 秒数上限 |
typesOnly(仅返回类型) | BOOLEAN | TRUE 时只返回属性名不返回值(探测 Schema 用) |
filter(过滤器) | Filter | 过滤器(见下节) |
attributes(返回属性列表) | SEQUENCE OF | 要返回的属性列表。空列表 = 返回所有用户属性;* = 所有用户属性;+ = 所有操作属性(如 createTimestamp、entryUUID);1.1 = 不返回任何属性(只要 DN) |
过滤器语法(RFC 4515)
看这张表前先记住:LDAP 过滤器是"运算符写在前面"的——不是 A and B,而是 (&(A)(B));而且每一层都要用括号包起来。
前缀(波兰)表示法,整体必须用括号包裹:
| 类型 | 语法 | 例子 |
|---|---|---|
| 等于 | (attr=value) | (uid=zhangsan) |
| 存在 | (attr=*) | (mail=*) — 有邮箱的条目 |
| 子串 | (attr=前*中*后) | (cn=张*)、(mail=*@example.com) |
| 大于等于 | (attr>=value) | (uidNumber>=10000)(没有 >,只有 >=) |
| 小于等于 | (attr<=value) | (uidNumber<=19999) |
| 近似 | (attr~=value) | (cn~=zhangsan)(实现依赖,很少用) |
| 与 | (&(A)(B)(C)) | (&(objectClass=person)(department=研发)) |
| 或 | `( | (A)(B))` |
| 非 | (!(A)) | (!(objectClass=computer)) |
| 扩展匹配 | (attr:rule:=value) | AD 递归查组:(memberOf:1.2.840.113556.1.4.1941:=CN=dev,...) |
必须转义的字符(RFC 4515 §3):
看这张表前先记住:这五个字符是过滤器的"语法字符",用户输入里带了它们又不转义,就是 LDAP 注入的入口。
| 字符 | 转义 |
|---|---|
* | \2a |
( | \28 |
) | \29 |
\ | \5c |
| NUL | \00 |
LDAP 注入:把用户输入直接拼进过滤器是高危漏洞。用户输入
*)(uid=*))(|(uid=*可以让(uid=<输入>)变成永真式,绕过认证。必须对上述字符转义。
常见结果码(BindResponse / SearchResultDone 等共用)
看这张表前先记住:排错时先看结果码,它基本就是答案——最常撞见的是 49(凭据错)、32(Base DN 错)、50(权限不足)这三个。
| 码 | 名称 | 含义 / 排错方向 |
|---|---|---|
| 0 | success | 成功 |
| 1 | operationsError | 操作顺序错(如 StartTLS 后又 StartTLS) |
| 3 | timeLimitExceeded | 超时,缩小搜索范围或加索引 |
| 4 | sizeLimitExceeded | 结果超过上限,需要分页 |
| 7 | authMethodNotSupported | 认证机制不支持 |
| 8 | strongerAuthRequired | 服务器要求 TLS/SASL(ldap_bind: Confidentiality required) |
| 10 | referral | 条目在别的服务器上,跟随引用 |
| 11 | adminLimitExceeded | AD 默认单次最多返回 1000 条 |
| 16 | noSuchAttribute | 要改/删的属性不存在 |
| 17 | undefinedAttributeType | Schema 里没这个属性 |
| 19 | constraintViolation | 违反约束(如密码复杂度不足、单值属性给了多值) |
| 20 | attributeOrValueExists | 添加了已存在的属性值 |
| 21 | invalidAttributeSyntax | 值格式不符合语法 |
| 32 | noSuchObject | Base DN 写错(最高频错误之一) |
| 34 | invalidDNSyntax | DN 格式错误(少逗号、多空格) |
| 49 | invalidCredentials | 用户名或密码错(最高频) |
| 50 | insufficientAccessRights | 权限不足,检查 ACL |
| 53 | unwillingToPerform | 服务器拒绝(如账号禁用、试图删非空子树、AD 无 SSL 改密码) |
| 64 | namingViolation | RDN 与条目属性不匹配 |
| 65 | objectClassViolation | 缺少 MUST 属性或 objectClass 组合非法 |
| 66 | notAllowedOnNonLeaf | 试图删除有子节点的条目 |
| 68 | entryAlreadyExists | DN 已存在 |
交互时序
一句话看懂这张图:先加密、再认证、然后"搜到人 → 用这个人的 DN 再 Bind 一次验密码"——阶段三和阶段四合起来就是所有应用对接 LDAP 的标准套路(两次 Bind)。
| |
关键机制 / 变体
1. “两次 Bind” —— 应用集成 LDAP 的标准姿势
这是干什么的:解决"用户只会输一个短用户名,但 Bind 必须用完整 DN"这个矛盾。
几乎所有应用(GitLab、Jenkins、Grafana)对接 LDAP 都是这个流程:
为什么不直接用用户名 Bind? 因为 Bind 需要完整 DN,而用户只会输 zhangsan。不同组织的 DN 结构千差万别(可能在 ou=people 也可能在 ou=北京,ou=分公司),只能先搜。
为什么不直接读 userPassword 属性比对? ① 密码通常是加盐哈希({SSHA}...),格式不定;② 服务器一般禁止读取 userPassword;③ AD 根本不通过 LDAP 暴露密码哈希。用 Bind 让服务器自己验证是唯一正确做法。
2. Scope 的三种范围(最易错的概念)
这是干什么的:控制"这次搜索往下挖多深",挖浅了搜不到,挖深了拖垮服务器。
以 base = ou=people,dc=example,dc=com 为例:
| scope | 搜索范围 | 是否含 base 自身 |
|---|---|---|
base(0) | 仅 ou=people 这一个条目 | ✅ 只有它 |
one(1) | ou=people 的直接子节点(uid=zhangsan、uid=lisi),不含孙节点,不含自己 | ❌ |
sub(2) | ou=people 及其所有后代,无限深度 | ✅ |
实践:应用集成一律用
sub;只想验证某个 DN 是否存在用base+filter=(objectClass=*);列举某 OU 下的直接成员用one。
3. 分页控制(Simple Paged Results,RFC 2696)
这是干什么的:绕开服务器"单次最多返回 N 条"的硬限制,分批把全量数据拉完。
服务器有硬性返回上限(AD 默认 1000 条,OpenLDAP sizelimit 默认 500)。超过就返回 sizeLimitExceeded(4) 或 adminLimitExceeded(11),且已返回的部分结果不完整。
解法是分页控制(Control OID 1.2.840.113556.1.4.319):
命令行:ldapsearch -E pr=500/noprompt ...
4. StartTLS vs LDAPS
这是干什么的:给 LDAP 加密的两条不同路子——一条是"先明文连上再升级",一条是"专用端口一上来就加密"。
| 维度 | StartTLS | LDAPS |
|---|---|---|
| 端口 | 389(与明文同端口) | 636(独立端口) |
| 机制 | 先建明文 TCP,再用 ExtendedRequest(OID 1.3.6.1.4.1.1466.20037)协商升级 | 连上就直接 TLS 握手(隐式 TLS) |
| 标准化 | RFC 4513 正式定义,官方推荐 | 无 RFC,事实标准(源自 LDAPv2 时代) |
| 风险 | 存在降级攻击风险(中间人剥离 StartTLS 能力),客户端必须强制要求成功 | 无降级风险,但需额外端口 |
| URI | ldap://host:389 + -ZZ | ldaps://host:636 |
必须加密的理由:Simple Bind 密码是明文的。抓包直接就能看到。RFC 4513 明确要求:不在受保护的传输上不得使用 Simple Bind。
5. Referral(引用)与 Alias(别名)
这是干什么的:处理"数据不在我这台服务器上"以及"给条目做个快捷方式"这两种跳转情况。
- Referral:服务器说"我这儿没有,去
ldap://other.example.com/dc=sub,dc=example,dc=com找"。返回resultCode=10或SearchResultReference。客户端需自己发起新连接(ldapsearch -C开启自动跟随)。AD 的多域森林大量使用 referral,跨域搜索时容易报错。 - Alias:条目级的"符号链接"(objectClass=alias + aliasedObjectName)。由
derefAliases参数控制是否解引用。易造成环路,生产环境慎用。
6. AD 与标准 LDAP 的关键差异
这是干什么的:告诉你同一套协议在微软那边换了哪些"名词"——不知道这些,照抄 OpenLDAP 的过滤器到 AD 上一条都搜不到。
| 项 | OpenLDAP(标准) | Active Directory |
|---|---|---|
| 用户 objectClass | inetOrgPerson / posixAccount | user(结构类是 user,person→organizationalPerson→user) |
| 组 objectClass | groupOfNames / posixGroup | group |
| 登录名属性 | uid | sAMAccountName(NetBIOS 名,≤20 字符)userPrincipalName(UPN,形如 zhangsan@example.com) |
| 组成员 | member(组上)/ memberOf(overlay 提供) | member + memberOf(自动维护,只读) |
| 递归查组 | 需客户端自己递归 | 扩展匹配规则 1.2.840.113556.1.4.1941(LDAP_MATCHING_RULE_IN_CHAIN) |
| 账号禁用 | 各实现不同 | userAccountControl 位掩码,& 2 为禁用;过滤禁用账号:(!(userAccountControl:1.2.840.113556.1.4.803:=2)) |
| 改密码 | ExtendedOp(RFC 3062)或直接改 userPassword | 必须走 LDAPS/StartTLS,写 unicodePwd(UTF-16LE + 双引号包裹 + Base64) |
| 单次返回上限 | sizelimit(默认 500) | MaxPageSize 默认 1000 |
| 匿名访问 | 可配 | 默认禁止,必须 Bind |
| 全局编录 | 无 | 端口 3268/3269,跨域搜索但只含部分属性 |
常见误区
“LDAP 是一种数据库” —— 不准确。它是目录:为读优化、无跨条目事务、写和复制代价高。把业务数据往里塞,迟早会被写性能和一致性问题反噬。
“DN 就像文件路径” —— 方向是反的。文件路径
/dc/example/ou/people从根写到叶,而 DNuid=zhangsan,ou=people,dc=example,dc=com是从叶写到根,最后一段dc=com才是最顶层。“读用户密码来比对” —— 做不到也不该做。密码是加盐哈希(
{SSHA}...)、服务器通常禁止读取userPassword、AD 干脆不通过 LDAP 暴露密码哈希。唯一正确做法是用户 DN + 密码去 Bind,让服务器自己判定。“密码留空 Bind 失败了就安全” —— 恰恰相反。带 DN 但密码为空串的"未认证 Bind",在许多服务器上会被当作匿名 Bind 成功返回
resultCode=0,应用误以为密码正确就放行了。RFC 4513 专门警告过这一点,客户端必须自己拒绝空密码。“搜不到就是没这条数据” —— 也可能是 scope 用了
base/one挖得不够深,或者 ACL 让你 Bind 得进来却读不到,或者 objectClass 名字在 AD 那边根本不叫这个。
速记口诀
- 命名口诀:RDN 是小名,DN 是全名;从叶写到根,逗号串一串。
- 范围口诀:base 只看自己,one 只看儿子,sub 儿孙全要——应用集成认准 sub。
- 过滤口诀:括号包一切,符号写在前;
&与|或!非,>=有>无。 - 登录口诀:一 Bind 服务号,二搜拿 DN,三 Bind 验密码。
- 加密口诀:389 先连后升级(StartTLS),636 一连就加密(LDAPS);Simple Bind 不加密 = 明文送人头。
知识框架
| |