为什么要学 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-DomainUser、Get-DomainComputer、Find-DomainShare等友好的接口。 -
ldapsearch:Linux 下的命令行 LDAP 客户端,发起原始的明文 LDAP 查询,直观且透明,适合学习和调试。
-
impacket 系列工具:
GetUserSPNs.py、GetNPUsers.py、secretsdump.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……每个对象就是一个条目,sAMAccountName、memberOf、userAccountControl 这些都是属性。
从 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=Users 和 CN=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 抓包判断。
谁决定要不要加密?
这取决于两方面的设置:
-
客户端要求(LDAP Client Signing Requirements):客户端在 SASL Bind 时通过
SecurityLayer标志声明自己希望的安全级别(None / Integrity / Privacy)。 -
服务端策略(Domain Controller: LDAP server signing requirements):这个组策略设置位于
计算机配置 → Windows 设置 → 安全设置 → 本地策略 → 安全选项。可设置为: 无(默认):服务端接受任何安全级别需要签名:强制要求 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.131 或 ldaps://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 进行认证。
这在渗透测试场景中是一个致命缺陷——通过 secretsdump、lsass 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(如 GenericWrite、WriteDACL、WriteOwner),就可以直接通过 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 写操作的工具:
- impacket:
rbcd.py(配置 RBCD)、addcomputer.py(创建机器账户)、dacledit.py(修改 DACL)、owneredit.py(修改所有者) - PowerView:
Set-DomainObject(修改任意属性)、Add-DomainObjectAcl(修改 ACL) - bloodyAD:专注于 AD 权限滥用的工具,支持通过 LDAP 对属性和 ACL 进行增删改
这些写操作能否成功,完全取决于攻击者对目标对象持有的 ACE,而这些 ACE 通常来自错误配置或历史遗留权限。如何发现这些可利用的权限路径,以及具体的利用链,将在后续的文章进行介绍。