域环境下的 LDAP 协议

为什么要学 LDAP?

域渗透的第一步永远是信息收集,而信息收集的核心就是向域控发起 LDAP 查询。例如 Kerberoasting 需要先通过 LDAP 枚举 SPN,委派攻击需要先通过 LDAP 找到委派配置,ACL 滥用需要先通过 LDAP 读取 nTSecurityDescriptor。掌握 LDAP 查询,就掌握了域内信息收集的核心能力。

域控本质上是一个 LDAP 数据库

Active Directory 在底层将所有目录数据存储在 ntds.dit 这个 ESE(Extensible Storage Engine)数据库文件中,而对外暴露的查询接口就是 LDAP(Lightweight Directory Access Protocol)。换句话说,域控就是一台巨型 LDAP 服务器,域内所有的用户、计算机、组、GPO、信任关系、权限配置,全部可以通过 LDAP 查询来获取。

通过 LDAP,一个普通域用户可以查到:

类别 可查到的信息
用户对象 所有域用户的账户名、显示名、邮件、描述、上次登录时间、密码过期时间、所属组
计算机对象 所有已加域的计算机、操作系统版本、DNS 名称
组对象 所有域内组及其成员,包括嵌套组关系
域控 域控列表、站点信息、操作主机角色(FSMO)
SPN 账户 注册了服务主体名称的账户(Kerberoasting 的前提)
委派配置 配置了非约束/约束委派的账户和机器
密码策略 最小密码长度、账户锁定阈值、密码过期时间
ACL/ACE 对象的访问控制列表(需要解析 nTSecurityDescriptor
域信任关系 与其他域的信任方向和类型

这些信息对渗透测试来说价值极高:枚举出 SPN 账户可以发起 Kerberoasting,找到 DONT_REQUIRE_PREAUTH 账户可以发起 AS-REP Roasting,分析 ACL 可以寻找 ACL 滥用路径。

信息收集工具的底层大都包含 LDAP

很多工具本质上都是在批量发起 LDAP 查询:

  • BloodHound / SharpHound:在几分钟内发起数百条 LDAP 查询,枚举所有用户、组、ACL、委派配置、会话信息,构建成攻击路径图。在流量层面,一次 BloodHound 采集会产生大量 SearchRequest 数据包,这是 SOC 检测 BloodHound 的主要手段之一。

  • PingCastle:对域的安全健康度评分,底层同样是大量 LDAP 查询。它会查询密码策略、账户状态、委派配置、不安全的 ACL 等,生成一份详细的风险报告。

  • PowerView / ADModule:PowerShell 脚本,封装了各类 LDAP 查询,提供诸如 Get-DomainUserGet-DomainComputerFind-DomainShare 等友好的接口。

  • ldapsearch:Linux 下的命令行 LDAP 客户端,发起原始的明文 LDAP 查询,直观且透明,适合学习和调试。

  • impacket 系列工具GetUserSPNs.pyGetNPUsers.pysecretsdump.py 等工具的域信息收集部分也依赖 LDAP。

LDAP 协议详解

端口

域控默认开放以下 LDAP 相关端口:

端口 服务 说明
389/TCP LDAP 标准 LDAP 端口,明文传输,但 Windows 上也可以协商加密(见下文)
636/TCP LDAPS LDAP over SSL/TLS,全程加密,需要 DC 部署证书
3268/TCP Global Catalog 全局目录,用于跨域查询所有域中的对象(属性有限制)
3269/TCP Global Catalog SSL 全局目录的加密版本
389/UDP CLDAP Connectionless LDAP,无连接模式(见下文)

重要:LDAP 389 端口不等于明文不加密。Windows 上 389 端口的连接在认证阶段之后可以通过 SASL 协商加密,具体取决于客户端和服务端的配置(详见加密章节)。

全局目录(Global Catalog)的特殊性

在多域林环境中,每个域的 LDAP 只包含本域的对象。全局目录(3268 端口)是一个只读的部分副本,包含了整个林中所有域的所有对象,但每个对象只保留部分属性(约 150 个被标记为 GC 属性的字段)。当你进行跨域用户查询时,应当连接 3268 端口而不是 389 端口。

LDAP 数据模型

LDAP 将所有数据组织成一棵树,叫做目录信息树(DIT,Directory Information Tree)。树上的每个节点称为一个条目(Entry),每个条目由若干属性(Attribute)组成。

在 Active Directory 里,这棵树上挂的是各种 AD 对象:用户、计算机、组、GPO、OU……每个对象就是一个条目,sAMAccountNamememberOfuserAccountControl 这些都是属性。

从 RDN 到 DN:如何定位一个对象

树上的每个节点都需要一个在当前层级唯一的名字,叫做 RDN(Relative Distinguished Name,相对专有名称)。RDN 的格式是 属性类型=值,例如:

  • CN=shule(CN 是 Common Name)
  • OU=Staff(OU 是 Organizational Unit)
  • DC=testjd(DC 是 Domain Component)

这几种前缀在 AD 里含义如下:
DC:构成域名的分量,testjd.local 被拆成 DC=testjd,DC=local,专门用在树的根部
OU:组织单元,管理员划分的逻辑分区,可以层层嵌套,可以链接 GPO
CN:通用名称,用于用户、组、计算机、容器等大多数对象

将从叶子节点到根的所有 RDN 用逗号串联,就得到这个对象的全局唯一标识符,叫做 DN(Distinguished Name,专有名称)

CN=shule,OU=Staff,DC=testjd,DC=local

读法:名为 shule 的对象,位于 Staff 这个 OU 里,属于 testjd.local 这个域。

这个 DN 也是 LDAP 查询时 -b 参数(SearchBase)的典型输入——你从哪个节点开始搜,就把哪个节点的 DN 填进去。

整棵树长什么样

testjd.local 这个测试域为例:

DC=testjd,DC=local                    ← 根(域根),BaseDN 就是这个
├── CN=Users                          ← 内置容器(Container),不是 OU
│   ├── CN=Administrator              ← 用户对象
│   ├── CN=krbtgt
│   └── CN=Domain Admins              ← 组对象
├── CN=Computers                      ← 内置计算机容器
├── OU=Domain Controllers             ← 内置 OU,域控都在这里
│   └── CN=DC01
├── OU=Staff                          ← 管理员自建的 OU
│   ├── CN=shule
│   └── OU=Groups                     ← OU 可以嵌套
│       └── CN=G_IT_Staff
└── CN=Configuration,...              ← 配置分区(另一棵子树)

注意 CN=UsersCN=Computers容器(Container),不是 OU。容器和 OU 的区别是:OU 可以链接 GPO,容器不行。

命名上下文(NC):树的根不止一个

上面那棵树只是域 NC。Active Directory 的 LDAP 实际上维护着多棵平行的子树,每棵叫一个命名上下文(Naming Context,NC),它们各自有自己的根 DN,互相独立:

命名上下文 根 DN 存什么
域 NC DC=testjd,DC=local 用户、计算机、组、GPO、OU 等所有日常对象
配置 NC CN=Configuration,DC=testjd,DC=local 站点、服务、复制拓扑等基础设施配置
架构 NC CN=Schema,CN=Configuration,DC=testjd,DC=local 所有对象类和属性的定义(相当于数据库 schema)
DomainDNS NC DC=DomainDnsZones,DC=testjd,DC=local 域内 DNS 区域记录

平时做信息收集,查的几乎全是域 NC,BaseDN 就填 DC=testjd,DC=local。跨域场景才需要连 3268 端口查全局目录,或者去配置 NC 里翻站点信息。

通过查询 RootDSE(空 DN + base 范围)可以一次性看到当前域控暴露的所有 NC:

ldapsearch -x -H ldap://192.168.244.131 -b "" -s base "(objectClass=*)" \
    namingContexts defaultNamingContext dnsHostName domainFunctionality
dn:
namingContexts: DC=testjd,DC=local
namingContexts: CN=Configuration,DC=testjd,DC=local
namingContexts: CN=Schema,CN=Configuration,DC=testjd,DC=local
namingContexts: DC=DomainDnsZones,DC=testjd,DC=local
namingContexts: DC=ForestDnsZones,DC=testjd,DC=local
defaultNamingContext: DC=testjd,DC=local
dnsHostName: dc01.testjd.local
domainFunctionality: 7

domainFunctionality: 7 表示域功能级别为 Windows Server 2016+。

LDAP 加密类型

这是一个容易让人混淆的领域。LDAP 加密的情况取决于认证机制客户端/服务端协商的组合。

核心概念

概念 说明
Simple Bind 用户名+密码明文发送,适合 LDAPS(636)使用,在 389 上是真正的明文
SASL Simple Authentication and Security Layer,一个认证框架,支持多种机制
GSSAPI/Kerberos SASL 的一种机制,使用 Kerberos 票据认证
GSS-SPNEGO 微软扩展,在 GSSAPI 基础上支持协商 NTLM 或 Kerberos
NTLMSSP NTLM 安全服务提供程序,SPNEGO 协商后可能选择 NTLM
LDAP Signing 对 LDAP 消息进行数字签名,防止中间人篡改(不加密,只签名)
LDAP Sealing(Encryption) 在 Signing 基础上加密 LDAP 消息体
StartTLS 在明文 389 连接上升级为 TLS 加密
LDAPS 从一开始就在 SSL/TLS 上建立 LDAP 连接(636 端口)
Windows SSPI Windows 安全支持提供程序接口,是 Kerberos/NTLM 在 Windows 上的统一抽象层
CLDAP Connectionless LDAP(RFC 1798),基于 UDP,仅用于特定的无状态查询(如 DsGetDcName

认证机制与加密的关系

情况一:Simple Bind + LDAP(389)

客户端 ──── TCP 389 ────► 域控
         明文用户名/密码

真正的明文,用户名密码在网络上以明文传输。这是最不安全的方式,通常只在测试环境或使用 LDAPS 时才会用。

情况二:SASL GSS-SPNEGO/Kerberos + LDAP(389)

客户端 ──── TCP 389 ────► 域控
    SASL Bind Request(SPNEGO token)
    SASL Bind Response(challenge/token)
    ... 协商完成 ...
    SearchRequest(消息体可能被加密)

这是域内 Windows 客户端默认的方式。认证本身通过 Kerberos 或 NTLM 完成,认证完成后,SASL 可以选择开启 Integrity(签名)或 Privacy(加密+签名)模式,取决于协商结果。具体场景可以使用 WireShark 抓包判断。

谁决定要不要加密?

这取决于两方面的设置:

  1. 客户端要求(LDAP Client Signing Requirements):客户端在 SASL Bind 时通过 SecurityLayer 标志声明自己希望的安全级别(None / Integrity / Privacy)。

  2. 服务端策略(Domain Controller: LDAP server signing requirements):这个组策略设置位于 计算机配置 → Windows 设置 → 安全设置 → 本地策略 → 安全选项。可设置为:

  3. (默认):服务端接受任何安全级别
  4. 需要签名:强制要求 Integrity

两端取较高的要求为准。如果客户端请求 None、服务端要求 Signing,最终结果是 Signing。

各场景汇总:

连接方式 认证机制 数据加密 典型场景
LDAP 389 + Simple Bind 明文 旧版应用、ldapsearch -x
LDAP 389 + SASL NTLM (GSS-SPNEGO) NTLM 挑战-响应 取决于协商(通常 Privacy) impacket 默认方式
LDAP 389 + SASL Kerberos (GSSAPI) Kerberos 票据 取决于协商(通常 Privacy) Windows 域内默认
LDAP 389 + StartTLS 任意 TLS 全程加密 ldapsearch -ZZ
LDAPS 636 + Simple Bind 明文(在 TLS 内) TLS 全程加密 应用程序 LDAPS 集成
CLDAP 389 UDP 无认证 DC 定位(DsGetDcName

LDAP Signing 对安全的影响

这个设置是防御 LDAP Relay 攻击的关键:
未开启 LDAP Signing:攻击者可以通过 NTLM Relay 将认证流量中继到 LDAP,在域控上执行操作(如创建机器账户、修改 ACL),这是 ntlmrelayx 的核心攻击原理。
开启 LDAP Signing(要求签名):Relay 攻击无法成功,因为攻击者无法伪造有效的签名。

LDAP 查询结构

一个 LDAP 查询由以下几个参数组成:

参数 说明 示例
SearchBase(BaseDN) 搜索的起始节点,查询只在此节点的子树内进行 DC=testjd,DC=local
Scope(范围) base(仅查询 BaseDN 本身)/ one(仅子一级)/ sub(整棵子树,默认) sub
Filter(过滤器) RFC 4515 定义的过滤表达式,决定返回哪些对象 (objectClass=user)
Attributes(属性列表) 希望返回的属性名,* 表示所有用户属性,+ 表示所有操作属性 sAMAccountName memberOf
SizeLimit 返回结果数量上限,服务端可能有自己的上限(默认 1000) 0(不限)
TimeLimit 查询超时时间(秒) 30

过滤器语法

在介绍过滤器之前,先解释一个贯穿 LDAP 的概念:OID(Object Identifier,对象标识符)。OID 是一串用点分隔的数字,用来在全球范围内唯一标识一个”东西”——可以是一种属性、一种匹配规则、一个控制扩展,或者一个协议功能。它的命名空间是树状的,由 ISO/ITU-T 统一分配,越靠前的数字代表越顶层的组织。比如 1.2.840.113556 是微软在 IANA 下注册的根 OID,后面的数字是微软自己继续细分的。

LDAP 大量用 OID 来引用各种能力,这样不同厂商实现同一功能时,大家都能认出对方在说什么,不会因为名字拼法不同而出问题。你会在两个地方频繁看到它:

  • 过滤器里的匹配规则:OID:= 语法,指定用什么方式比较属性值)
  • 请求里的控制扩展(Control,向服务端传递额外指令,如要求返回安全描述符)

https://learn.microsoft.com/zh-cn/windows/win32/ad/object-identifiers

LDAP 过滤器使用前缀表达式:

# 基本比较
(sAMAccountName=administrator)          # 等于
(cn=sh*)                                # 通配符
(badPwdCount>=3)                        # 大于等于
(!(objectClass=computer))               # 取反

# 逻辑组合
(&(objectClass=user)(adminCount=1))     # AND(与)
(|(objectClass=user)(objectClass=group))# OR(或)

# 存在性检查
(servicePrincipalName=*)                # 存在 SPN 属性
(description=*)                         # 描述字段不为空

# 位运算(匹配 userAccountControl 特定标志位)
# 语法:(attribute:OID:=value),OID 1.2.840.113556.1.4.803 = LDAP_MATCHING_RULE_BIT_AND
(userAccountControl:1.2.840.113556.1.4.803:=2)         # 账户被禁用
(userAccountControl:1.2.840.113556.1.4.803:=512)       # 普通域用户
(userAccountControl:1.2.840.113556.1.4.803:=4194304)   # DONT_REQUIRE_PREAUTH
(userAccountControl:1.2.840.113556.1.4.803:=524288)    # 非约束委派
(userAccountControl:1.2.840.113556.1.4.803:=8192)      # 域控机器账户

userAccountControl 常用标志位:

标志位值 含义
0x0002 (2) 账户被禁用
0x0200 (512) 普通用户账户
0x1000 (4096) 工作站/服务器计算机账户
0x2000 (8192) 域控计算机账户
0x10000 (65536) 密码永不过期
0x80000 (524288) 信任此账户进行委派(非约束委派)
0x400000 (4194304) DONT_REQUIRE_PREAUTH(AS-REP Roasting 目标)
0x1000000 (16777216) 使用 DES 密钥类型

重要属性

域内 LDAP 默认情况下,所有经过认证的域用户(Authenticated Users) 对大多数对象有只读访问权限(这是 AD 的默认 ACL 设计)。

普通用户可以查到的关键属性:

属性 类型 说明
sAMAccountName 字符串 登录名(NetBIOS 格式)
userPrincipalName 字符串 UPN 格式登录名(user@domain)
displayName 字符串 显示名
mail 字符串 邮件地址
description 字符串 描述字段(有时包含密码!)
memberOf 多值 DN 所属组列表
member 多值 DN 组的成员列表(组对象上)
servicePrincipalName 多值 SPN,Kerberoasting 关键属性
userAccountControl 整数 账户控制标志位
adminCount 整数 1 表示受 AdminSDHolder 保护的高权限账户
pwdLastSet FILETIME 密码最后设置时间
lastLogonTimestamp FILETIME 上次登录时间(复制延迟约 14 天)
badPwdCount 整数 密码错误次数
objectSid SID 安全标识符
primaryGroupID 整数 主组 RID
dNSHostName 字符串 计算机对象的 DNS 名
operatingSystem 字符串 操作系统版本(计算机对象)
msDS-AllowedToDelegateTo 多值 约束委派目标 SPN 列表
msDS-AllowedToActOnBehalfOfOtherIdentity 安全描述符 RBCD 配置
gPLink 字符串 OU/域上链接的 GPO
nTSecurityDescriptor 二进制 对象的 ACL,普通用户可以读到,但需要附加 SDFlagsControl 才能取到完整内容(见下文)

普通用户默认查不到的属性:

属性 原因
unicodePwd 密码哈希,完全不可读
lmPwdHistory / ntPwdHistory 历史密码哈希,需要 DA 权限
supplementalCredentials 包含可逆加密的凭据,需要 DA 权限
msDS-ManagedPassword(GMSA) 只有被授权的账户可以读取

关于 nTSecurityDescriptor 的特殊说明

直接用 ldapsearch -x 或 impacket 查询 nTSecurityDescriptor 时,即使账户有权限,返回结果也会是空。原因是 AD 对这个属性做了保护:客户端必须在请求中附加一个名为 SDFlagsControl(OID 1.2.840.113556.1.4.801)的 LDAP 控制扩展,明确声明想要哪几部分(Owner、Group、DACL、SACL),服务端才会把内容返回。这个设计的初衷是避免客户端意外读到 SACL(审计 ACL)——SACL 的读取需要 SeSecurityPrivilege,而 DACL 普通用户就能读。

常用的 flag 值:
0x4(DACL_SECURITY_INFORMATION):只要 DACL
0x7(Owner + Group + DACL):常用组合,不包含 SACL

ldapsearch 里,通过 -E 附加控制扩展,OID 后接 BER 编码的参数。flag=7 的 BER 编码是 MAMCAQM=(base64):

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "CN=shule,OU=Staff,DC=testjd,DC=local" \
    -s base "(objectClass=*)" \
    -E '!1.2.840.113556.1.4.801=::MAMCAQM=' \
    nTSecurityDescriptor

返回的值是二进制的 Windows Security Descriptor,ldapsearch 以 base64 输出(属性名后跟 ::)。原始结构是 SECURITY_DESCRIPTOR,包含 Owner SID、Group SID、DACL(自由访问控制列表)、可选的 SACL。

用 impacket 的 SR_SECURITY_DESCRIPTOR 可以把它解析成可读的 ACE 列表:

from impacket.ldap import ldap
from impacket.ldap import ldapasn1 as ldapAsn1
from impacket.ldap.ldaptypes import SR_SECURITY_DESCRIPTOR
import struct

conn = ldap.LDAPConnection(url="ldap://192.168.244.131",
                           baseDN="DC=testjd,DC=local", dstIp="192.168.244.131")
conn.login(user="attacker", password="Bb123456", domain="testjd.local")

sd_control = ldapAsn1.SDFlagsControl(criticality=False, flags=0x7)
results = []
conn.search(
    searchBase="CN=shule,OU=Staff,DC=testjd,DC=local",
    scope=ldapAsn1.Scope("baseObject"),
    searchFilter="(objectClass=*)",
    attributes=["nTSecurityDescriptor"],
    searchControls=[sd_control],
    perRecordCallback=lambda item: results.append(item)
    if isinstance(item, ldapAsn1.SearchResultEntry) else None,
)

for entry in results:
    for attr in entry["attributes"]:
        if str(attr["type"]) == "nTSecurityDescriptor" and attr["vals"]:
            raw = bytes(attr["vals"][0])
            sd = SR_SECURITY_DESCRIPTOR(data=raw)
            print("Owner:", sd["OwnerSid"].formatCanonical())
            dacl = sd["Dacl"]
            if dacl:
                for ace in dacl["Data"]:
                    sid = ace["Ace"]["Sid"].formatCanonical()
                    mask = struct.unpack("<I", ace["Ace"]["Mask"].getData())[0]
                    print(f"  ACE type={ace['AceType']} mask={hex(mask)} sid={sid}")

输出示例(shule 对象 DACL 片段):

Owner: S-1-5-21-2988973551-359497623-2177000135-512
  ACE type=5 mask=0x10  sid=S-1-5-21-...-553   # RAS and IAS Servers: 读取属性
  ACE type=5 mask=0x30  sid=S-1-5-21-...-517   # 策略组: 读写
  ACE type=0 mask=0xf01ff sid=S-1-5-18         # SYSTEM: 完全控制
  ACE type=0 mask=0xf01ff sid=S-1-5-32-544     # Administrators: 完全控制
  ...

ACE type 含义:0 = ACCESS_ALLOWED,1 = ACCESS_DENIED,5 = ACCESS_ALLOWED_OBJECT(带对象 GUID,AD 扩展 ACE)。mask 对应 ADS_RIGHT_* 权限位,0xf01ff 表示完全控制,0x20094 表示 GenericRead。BloodHound 做 ACL 分析时,就是在枚举每个对象的 DACL 寻找 GenericAll、WriteDACL、WriteOwner 等危险权限。

具体操作我们也可以使用 impacket-dacledit 和 impacket-owneredit 进行查询,这个之后再专门写一个系列介绍。

使用 ldapsearch 进行查询

ldapsearch 是 OpenLDAP 工具包中的命令行 LDAP 客户端,对于学习 LDAP 查询来说非常直观,因为它直接对应 LDAP 协议的各个参数。

基本用法

ldapsearch [选项] [过滤器 [属性列表...]]

核心参数速查:

参数 说明
-H <URI> 服务器地址,如 ldap://192.168.244.131ldaps://192.168.244.131
-b <basedn> 搜索基点(BaseDN)
-s <scope> 范围:base / one / sub(默认 sub
-x 使用 Simple Bind(明文认证),不加此参数默认尝试 SASL
-D <binddn> 绑定 DN,如 [email protected]CN=shule,OU=Staff,DC=testjd,DC=local
-w <password> 绑定密码
-W 交互式输入密码
-LLL 输出干净的 LDIF 格式(去掉注释和版本行)
-A 只返回属性名,不返回值
-Z / -ZZ 使用 StartTLS 升级加密(-ZZ 要求必须成功)

实际查询示例

查询 RootDSE(无需认证)

ldapsearch -x -H ldap://192.168.244.131 -b "" -s base "(objectClass=*)" \
    defaultNamingContext dnsHostName domainFunctionality namingContexts
dn:
defaultNamingContext: DC=testjd,DC=local
dnsHostName: dc01.testjd.local
domainFunctionality: 7
namingContexts: DC=testjd,DC=local
namingContexts: CN=Configuration,DC=testjd,DC=local
namingContexts: CN=Schema,CN=Configuration,DC=testjd,DC=local

枚举所有域用户

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(objectClass=user)" \
    sAMAccountName displayName memberOf userAccountControl

查询高权限账户(adminCount=1)

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(adminCount=1)" \
    sAMAccountName memberOf

查询 SPN 账户(Kerberoasting 目标)

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(&(servicePrincipalName=*)(objectClass=user)(!(cn=krbtgt)))" \
    sAMAccountName servicePrincipalName

查询 DONT_REQUIRE_PREAUTH 账户(AS-REP Roasting 目标)

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(userAccountControl:1.2.840.113556.1.4.803:=4194304)" \
    sAMAccountName userAccountControl

查询域控

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))" \
    sAMAccountName dNSHostName operatingSystem

查询密码策略

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    "(objectClass=domainDNS)" \
    minPwdLength pwdHistoryLength lockoutThreshold maxPwdAge

使用分页查询(规避 1000 条限制)

ldapsearch -x -H ldap://192.168.244.131 \
    -D "[email protected]" -w "Bb123456" \
    -b "DC=testjd,DC=local" \
    -E pr=500/noprompt \
    "(objectClass=user)" sAMAccountName

使用 SASL GSSAPI(Kerberos 认证,需要 kinit 获取票据)

# 先获取 Kerberos 票据
kinit [email protected]

# 使用 GSSAPI 认证(不用 -x,不用 -D 和 -w)
ldapsearch -H ldap://192.168.244.131 \
    -b "DC=testjd,DC=local" \
    "(objectClass=user)" sAMAccountName

ldapsearch 的局限性:不支持 Pass-the-Hash

ldapsearch 使用 Simple Bind(-x)时传递的是明文密码,使用 SASL 时底层走的是 GSSAPI/Kerberos(需要系统票据缓存)。

它没有办法直接接受一个 NTLM Hash 进行认证。

这在渗透测试场景中是一个致命缺陷——通过 secretsdumplsass dump 等手段拿到了 NT Hash 但没有明文密码时,无法直接用 ldapsearch 查询 LDAP。这就是为什么要写下面这个脚本。

基于 impacket 的 PTH-LDAP 查询脚本

脚本代码

#!/usr/bin/env python3
"""
ldap_pth.py - 支持 Pass-the-Hash 的 LDAP 查询工具
依赖: pip install impacket

用法示例:
  # 明文密码
  python3 ldap_pth.py -dc 192.168.244.131 -d testjd.local -u attacker -p Bb123456 \
      --filter "(adminCount=1)" --attrs sAMAccountName memberOf

  # Pass-the-Hash
  python3 ldap_pth.py -dc 192.168.244.131 -d testjd.local -u attacker \
      --nthash 67587a19e4c2b479f1fa85b95b544229 \
      --filter "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))" \
      --attrs sAMAccountName dNSHostName operatingSystem
"""

import argparse
import sys
from impacket.ldap import ldap
from impacket.ldap import ldapasn1 as ldapAsn1

UAC_FLAGS = {
    0x0002: "ACCOUNTDISABLE",
    0x0200: "NORMAL_ACCOUNT",
    0x1000: "WORKSTATION_TRUST_ACCOUNT",
    0x2000: "SERVER_TRUST_ACCOUNT",
    0x10000: "DONT_EXPIRE_PASSWORD",
    0x80000: "TRUSTED_FOR_DELEGATION",
    0x100000: "NOT_DELEGATED",
    0x400000: "DONT_REQ_PREAUTH",
    0x800000: "PASSWORD_EXPIRED",
    0x1000000: "TRUSTED_TO_AUTH_FOR_DELEGATION",
}


def decode_uac(value):
    try:
        val = int(value)
    except (ValueError, TypeError):
        return str(value)
    flags = [name for bit, name in UAC_FLAGS.items() if val & bit]
    return f"{val} ({', '.join(flags)})" if flags else str(val)


def format_value(attr_name, value):
    s = str(value)
    if attr_name.lower() == "useraccountcontrol":
        return decode_uac(s)
    if attr_name.lower() in ("pwdlastset", "lastlogontimestamp", "badpasswordtime",
                              "accountexpires", "lastlogon"):
        try:
            ts = int(s)
            if ts == 0:
                return "0 (从未设置/登录)"
            if ts == 9223372036854775807:
                return "永不过期"
            from datetime import datetime, timezone
            dt = datetime.fromtimestamp((ts - 116444736000000000) // 10000000,
                                        tz=timezone.utc)
            return f"{s} ({dt.strftime('%Y-%m-%d %H:%M:%S UTC')})"
        except Exception:
            return s
    return s


def connect(dc_ip, base_dn, username, password, domain, nthash):
    conn = ldap.LDAPConnection(url=f"ldap://{dc_ip}", baseDN=base_dn, dstIp=dc_ip)
    conn.login(user=username, password=password, domain=domain,
               lmhash="", nthash=nthash)
    return conn


def run_query(conn, base_dn, search_filter, attributes, scope_str="sub"):
    scope_map = {
        "base": ldapAsn1.Scope("baseObject"),
        "one":  ldapAsn1.Scope("singleLevel"),
        "sub":  ldapAsn1.Scope("wholeSubtree"),
    }
    results = []

    def callback(item):
        if isinstance(item, ldapAsn1.SearchResultEntry):
            results.append(item)

    conn.search(
        searchBase=base_dn,
        scope=scope_map.get(scope_str, ldapAsn1.Scope("wholeSubtree")),
        searchFilter=search_filter,
        attributes=attributes,
        sizeLimit=0,
        perRecordCallback=callback,
    )
    return results


def print_results(results):
    if not results:
        print("(无结果)")
        return
    for entry in results:
        print(f"dn: {entry['objectName']}")
        for attr in entry["attributes"]:
            attr_name = str(attr["type"])
            for val in attr["vals"]:
                print(f"{attr_name}: {format_value(attr_name, val)}")
        print()


def main():
    parser = argparse.ArgumentParser(
        description="支持 Pass-the-Hash 的 LDAP 查询工具(基于 impacket)"
    )
    parser.add_argument("-dc", "--dc-ip", required=True, help="域控 IP 地址")
    parser.add_argument("-d", "--domain", required=True, help="域名,如 testjd.local")
    parser.add_argument("-u", "--username", required=True, help="用户名(不含域前缀)")
    parser.add_argument("-p", "--password", default="", help="明文密码")
    parser.add_argument("--nthash", default="", help="NT Hash(Pass-the-Hash)")
    parser.add_argument("-b", "--base-dn", help="搜索基点,默认根据域名自动推导")
    parser.add_argument("--filter", default="(objectClass=*)", help="LDAP 过滤器")
    parser.add_argument("--attrs", nargs="+", help="要返回的属性列表(空格分隔)")
    parser.add_argument("-s", "--scope", choices=["base", "one", "sub"],
                        default="sub", help="搜索范围(默认 sub)")

    args = parser.parse_args()

    if not args.password and not args.nthash:
        parser.error("必须提供 --password 或 --nthash 之一")

    base_dn = args.base_dn or ",".join(
        f"DC={p}" for p in args.domain.split(".")
    )
    auth_method = "Pass-the-Hash (NT Hash)" if args.nthash else "明文密码"

    print(f"[*] 目标域控 : {args.dc_ip}")
    print(f"[*] 域       : {args.domain}")
    print(f"[*] 用户     : {args.username}")
    print(f"[*] 认证方式 : {auth_method}")
    print(f"[*] BaseDN   : {base_dn}")

    try:
        conn = connect(args.dc_ip, base_dn, args.username,
                       args.password, args.domain, args.nthash)
        print("[+] 认证成功\n")
    except Exception as e:
        print(f"[-] 连接失败: {e}", file=sys.stderr)
        sys.exit(1)

    print(f"[*] 过滤器: {args.filter}")
    print(f"[*] 属性  : {args.attrs or ['*']}")
    print(f"[*] 范围  : {args.scope}\n")
    print("-" * 60)

    try:
        results = run_query(conn, base_dn, args.filter,
                            args.attrs or ["*"], args.scope)
    except Exception as e:
        print(f"[-] 查询失败: {e}", file=sys.stderr)
        sys.exit(1)

    print(f"[+] 共返回 {len(results)} 条结果\n")
    print_results(results)


if __name__ == "__main__":
    main()

使用示例

明文密码,查询 adminCount=1 的高权限账户:

python3 ldap_pth.py \
    -dc 192.168.244.131 -d testjd.local -u attacker -p Bb123456 \
    --filter "(adminCount=1)" \
    --attrs sAMAccountName memberOf

Pass-the-Hash,查询域控:

python3 ldap_pth.py \
    -dc 192.168.244.131 -d testjd.local -u attacker \
    --nthash 67587a19e4c2b479f1fa85b95b544229 \
    --filter "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))" \
    --attrs sAMAccountName dNSHostName operatingSystem userAccountControl

输出:

[*] 目标域控 : 192.168.244.131
[*] 域       : testjd.local
[*] 用户     : attacker
[*] 认证方式 : Pass-the-Hash (NT Hash)
[*] BaseDN   : DC=testjd,DC=local
[+] 认证成功

[*] 过滤器: (&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))
[*] 属性  : ['sAMAccountName', 'dNSHostName', 'operatingSystem', 'userAccountControl']
[*] 范围  : sub

------------------------------------------------------------
[+] 共返回 1 条结果

dn: CN=DC01,OU=Domain Controllers,DC=testjd,DC=local
sAMAccountName: DC01$
dNSHostName: dc01.testjd.local
operatingSystem: Windows Server 2019 Standard Evaluation
userAccountControl: 532480 (SERVER_TRUST_ACCOUNT, TRUSTED_FOR_DELEGATION)

impacket 则完全在 Python 层面重新实现了 NTLM 认证流程(包括 NTLMv2 的挑战-响应计算),在 SASL 的 GSS-SPNEGO 握手中,它直接用 NT Hash 来计算 NTLMv2 响应,而无需知道明文密码。

PTH 认证流程(impacket 实现):

客户端(impacket)                        域控
    │                                        │
    │──── LDAP Bind Request ────────────────►│
    │     (SASL GSS-SPNEGO NEGOTIATE)        │
    │                                        │
    │◄─── LDAP Bind Response ────────────────│
    │    (SPNEGO challenge + NTLM challenge) │
    │                                        │
    │                                        │
    │                                        │
    │──── LDAP Bind Request ────────────────►│
    │   (NTLM AUTHENTICATE + NTLMv2 response)│
    │                                        │
    │◄───Bind Response (resultCode: success) │

LDAP 写操作与攻击场景

LDAP 是完整的 CRUD 协议,除了读取之外还支持 Add(新增条目)、Delete(删除条目)、Modify(修改属性)、ModifyDN(移动/重命名条目)四类写操作。在域渗透中,只要攻击者对目标对象持有足够的 ACE(如 GenericWriteWriteDACLWriteOwner),就可以直接通过 LDAP 写操作创造攻击条件,而无需利用任何漏洞。

常见的攻击场景包括:

操作类型 攻击场景 所需权限
Modify 修改 userAccountControl,开启 DONT_REQUIRE_PREAUTH 标志位,将目标账户变成 AS-REP Roasting 目标 对用户对象的 GenericWrite
Modify 向目标账户写入 servicePrincipalName,使其成为 Kerberoasting 目标 对用户对象的 GenericWrite
Modify 写入目标机器对象的 msDS-AllowedToActOnBehalfOfOtherIdentity,配置 RBCD(基于资源的约束委派) 对机器对象的 GenericWrite
Modify 修改对象的 nTSecurityDescriptor(DACL),向自身授予更高权限(WriteDACL 滥用) 对目标对象的 WriteDACL
Add 利用 ms-DS-MachineAccountQuota(默认值为 10)创建受控机器账户,为 RBCD 攻击提供委派账户 任意认证域用户

可以执行 LDAP 写操作的工具:

  • impacketrbcd.py(配置 RBCD)、addcomputer.py(创建机器账户)、dacledit.py(修改 DACL)、owneredit.py(修改所有者)
  • PowerViewSet-DomainObject(修改任意属性)、Add-DomainObjectAcl(修改 ACL)
  • bloodyAD:专注于 AD 权限滥用的工具,支持通过 LDAP 对属性和 ACL 进行增删改

这些写操作能否成功,完全取决于攻击者对目标对象持有的 ACE,而这些 ACE 通常来自错误配置或历史遗留权限。如何发现这些可利用的权限路径,以及具体的利用链,将在后续的文章进行介绍。

版权声明:除特殊说明,博客文章均为 Shule 原创,依据 CC BY-SA 4.0 许可证进行授权,转载请附上出处链接及本声明。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇