MS-SAMR协议在域环境中的账户枚举与安全检测实践
我第一次认真读MS-SAMR协议规范是因为一次内网资产盘点翻车。当时准备用LDAP查询域内所有员工账号结果某个策略限制导致分页查询被拦得死死的审计要求又必须拿到全量账号清单。后来改从另一条路走通过SAMR协议做远程枚举竟然把LDAP看不到的几个隐藏OU账号也捞了出来。这件事让我意识到很多域环境下账户枚举这个动作真正的底层通道往往不是查询目录而是一个名叫MS-SAMR的老协议。这篇算进阶向复盘。默认你已经知道SAMR是Security Account Manager Remote的缩写也知道它可以远程枚举用户、查询密码策略甚至创建账户。这里的再探想聊的是更底层的东西协议为什么这样设计、句柄与状态码背后的逻辑、工具到底在调哪些接口、以及防守侧应该怎么从流量和日志里发现这类动作。1. SAMR一个比想象中话多的远程管理协议1.1 协议在域环境里的位置在Windows的账号生态里SAM数据库是本地账户的户口本。Windows NT时代管理员可以坐在任意一台机器前通过远程过程调用管理其他机器的本地账户这就是MS-SAMR的起源。进入Active Directory时代后域内账户其实存放在目录服务里但出于兼容性考虑微软仍然把一套基于RPC的账户管理接口保留了下来并通过\PIPE\samr这个命名管道向外提供。也就是说你在域环境里做的很多看似理所当然的操作底层可能都在走SAMR。举例net user /domain列出域用户最终要走Netlogon认证但用户信息的分页枚举则依赖SAMR。各种域内用户枚举工具大多是在调用SAMR的SamrEnumerateUsersInDomain或者先枚举组再查组成员。查询域密码策略例如密码最小长度、锁定阈值也可以通过SamrQueryInformationDomain直接取。域内某台机器想要判断一个用户名是否存在最常见的做法是SamrLookupNamesInDomain输入名字返回RID返回成功就是存在。理解了这一点你就能明白为什么很多安全评估工具会把SAMR和SMB、LDAP绑定在一起使用。它们不是一回事但都跑在445或135端口之上抓包时经常混在一起出现。1.2 五个接口族的分工服务端、域、组、别名与用户MS-SAMR的RPC接口可以粗分为五个家族理解了这五个家族你读工具的代码就不会晕Server类接口以SamrConnect为代表负责建立到目标机器SAM服务的会话返回一个服务端句柄。Domain类接口以SamrOpenDomain为代表通过服务端句柄打开某个域得到域句柄。后续枚举用户、查询策略基本都挂在域句柄之下。User类接口以SamrOpenUser、SamrQueryInformationUser为代表在已有域句柄的前提下按RID打开某个用户并查询属性。Group类接口以SamrEnumerateGroupsInDomain、SamrOpenGroup、SamrGetMembersInGroup为代表负责枚举本地组和域组并读取组成员。Alias类接口以SamrEnumerateAliasesInDomain为代表处理另一种别名组很多人忽略这一族接口导致枚举结果不全。你可能会问为什么要分用户和组两套路径因为SAMR把账户存储建模成了两层用户是顶层实体组是用户的分组容器而组内部又区分普通组Group和别名Alias。普通组可以包含用户也可以包含其他组别名则主要用于邮件分发和权限集合。一个完整的账户清单光枚举用户还不够还要把每个组的成员关系拼起来否则你会漏掉很多通过组成员身份暴露出来的账户信息。这也是很多简化版工具的用户列表比预期少的原因。2. 从句柄到状态码SAMR的调用链与那些奇怪的魔数2.1 所有调用的起点SamrConnect与句柄继承关系RPC协议和熟悉的HTTP不同它很讲究句柄这种资源管理方式。你可以把SAMR的调用链理解成一次现场办事流程走进政务大厅就是SamrConnect出示证件要办哪个窗口的业务就是SamrOpenDomain再拿着窗口给的号去查特定用户就是SamrOpenUser。整个流程里句柄是一层层传递的SamrConnect - ServerHandle SamrOpenDomain(ServerHandle) - DomainHandle SamrOpenUser(DomainHandle, RID) - UserHandle所以当你写脚本时不要试图直接调用SamrOpenUser——你没有域句柄RPC服务端根本不知道你要操作哪个域。我第一次用impacket调SAMR时就犯了这种错误只绑定了接口没先拿到服务端句柄就去SamrOpenDomain结果一路报STATUS_INVALID_HANDLE0xC0000008。这不是权限问题而是调用顺序不对。在实际工具里SamrConnect的第一个参数通常是\\\\目标主机名第二个参数是访问掩码。impacket示例里常见samr.MAXIMUM_ALLOWED意思就是给我当前身份能拿到的最大权限。在实验环境里这没问题但在真实审计中我更推荐按最小权限传访问掩码比如LOOKUP_DOMAIN、ENUMERATE_DOMAINS这类。原因有两个一是权限太大容易被目标侧的高敏感度审计规则盯上二是你只做枚举的话根本不需要CREATE_USER这种写权限。2.2 枚举的翻页机制为什么总是在处理STATUS_MORE_ENTRIESSamrEnumerateUsersInDomain这类接口不是一次调用就把所有用户返回给你。它采用的是偏好大小上下文指针的分页模型你传入一个EnumerationContext初始值0。服务端返回一批条目同时返回一个新的EnumerationContext。如果条目没取完返回码是STATUS_MORE_ENTRIES0x105。你带着新的EnumerationContext再调一次直到返回成功STATUS_SUCCESS。很多半路出家的脚本都死在这一步。常见Bug是把单次返回结果当全量数据或者没处理STATUS_MORE_ENTRIES导致只拿到前100个用户。我见过某次内部检查中的数据不一致事后定位发现是某个第三方工具在SAMR分页循环里忘记更新上下文导致每次枚举都从第0页开始服务端反复返回同一批数据形成死循环。正确写法应该是这样的逻辑骨架enumContext 0 while True: resp samr.hSamrEnumerateUsersInDomain(dce, domainHandle, enumContext, 500) # 处理 resp[Buffer] 里的条目 enumContext resp[EnumerationContext] if resp[ErrorCode] ! 0x105: # STATUS_MORE_ENTRIES break这里的500是偏好条目数不是硬性上限。改成1000也不会报错但单次RPC包会更大UDP/长帧环境下可能出现超时。我习惯在域控上用256在低版本Windows工作站上用128平稳且不容易触发RPC层面的缓冲区问题。2.3 不直接枚举用户时从组到成员的反推路线需要注意SamrEnumerateUsersInDomain并不是在所有权限下都能成功。普通域用户在某些配置里会被直接拒绝调用返回STATUS_ACCESS_DENIED0xC0000022。但组枚举往往放得更开所以很多工具会绕一条路先枚举域内所有组再对每个组调用SamrGetMembersInGroup取成员把成员SID汇总去重最终拼出用户清单。这就是知名工具samrdump的核心思路。它在权限受限时仍然能拿到大量账户名原因是查询组成员的权限要求往往低于直接枚举用户的权限要求。你可以在实验环境里对比一下用一个普通域账号直接调SamrEnumerateUsersInDomain大概率失败但改用枚举组→遍历成员→LookupIds转名字的路径通常能成功。这条路线还有一个额外好处你能拿到用户到组的归属关系。对于权限梳理、账号清理组关系往往比单纯用户名列表更有价值。2.4 不用翻页的秘密LookupNames与LookupIds除了标准枚举SAMR还有两个查询接口值得单独说SamrLookupNamesInDomain和SamrLookupIdsInDomain。前者输入一组用户名返回对应的RID后者输入一组RID返回对应的用户名。它们看起来只是翻译但在安全视角里这两个接口是攻击者做用户字典探测的首选不需要枚举全量只需要用字典批量调用SamrLookupNamesInDomain名字存在就会返回RID不存在则返回错误。这种方式的流量特征是批量短调用与拉全量用户列表的长枚举有明显差异。从防守方看这两类接口比全量枚举更隐蔽因为它们单次调用包很小、速度很快很容易淹没在海量正常RPC流量里。我后面会讲怎么在流量侧做区分。3. 工具与实战常见信息收集背后的SAMR语义3.1 samrdump里的组-成员拼接法先拿impacket套件里的samrdump做例子。它的大致流程是通过SMB命名管道绑定SAMR接口。SamrConnect拿到服务端句柄。SamrOpenDomain拿到域句柄。SamrEnumerateGroupsInDomain枚举所有普通组。对每个组打开组句柄SamrGetMembersInGroup取出成员SID数组。通过SamrLookupIdsInDomain把SID转成用户名和域名。这套逻辑不依赖LDAP因此在域内目录服务被部分锁死的环境里依然有效。我在做资产基线梳理时遇到LDAP分页被策略卡住的情况就经常切到SAMR这条冗余通道。当然LDAP能查到的属性远比SAMR多SAMR给的是基础账户信息组关系更像一个应急的、不受目录策略干扰的旁路。3.2 密码策略提取SamrQueryInformationDomain的隐蔽价值除了用户枚举SAMR还能查域密码策略。SamrQueryInformationDomain支持查询多个信息类其中密码策略相关的结构包含最小密码长度密码复杂度设置锁定阈值锁定观察时间在一次完全合法的内网基线核查里用SAMR查策略比读组策略文件更直接你不需要解析SYSVOL里的GPO不需要关心策略继承关系直接问域控当前生效的域密码策略是什么就行。但这里有个明显的安全提示攻击者在拿到一个低权限域账号后也会先用SAMR探密码策略来评估暴力猜解的可行性。比如锁定阈值为0不锁定且复杂度弱时密码喷洒的失败风险会大幅下降。防守方如果不追踪SAMR查询事件这类侦查动作基本无声无息。3.3 SAMR与LDAP枚举的实测差异我把两者放在一张表里做个对照维度SAMR枚举LDAP枚举通道\PIPE\samr走RPC走LDAP TCP 389/636依赖认证支持匿名老版本到低权限账户通常需要认证权限策略丰富能拿到的信息用户名、SID/RID、组关系、部分账户属性几乎所有目录属性邮箱、手机号、OU路径等典型干扰分页状态码、句柄错误分页大小限制、搜索阈值日志可见性默认审计粒度一般容易漏可配合目录访问审计相对完整典型抗封锁性受匿名访问策略影响但普通用户常常可用经常被ACL、LDAP策略限制实战中我一般这样选需要全量账户覆盖同时LDAP被策略限制就优先SAMR。需要邮箱、部门、描述信息就不得不用LDAP。两者不是替代关系而是互补。对防守方而言意味着你不能只审LDAP查询日志SAMR这条线也要纳入监控视野。4. 安全基线视角SAMR为什么让防守方头疼4.1 匿名访问的历史让步与现代限制早期Windows系统里SAMR在匿名访问下放得很开任意人都可以远程查询目标机器的本地用户列表这也是SAM枚举这类经典漏洞的来源。后来微软做了大量收紧默认策略网络访问: 不允许对SAM账户和共享进行匿名枚举基本上把匿名这条路堵死了。但要注意域环境和本地环境的差异。域内一个普通域账号在大多数默认配置下仍然能枚举域用户和组这是为了兼容管理工具不是安全漏洞。然而对防守方来说这种合法却不怀好意的行为最难检测单看每一次RPC调用都符合协议规范批量用工具跑却暴露出了侦查意图。比较棘手的还有一行式命令net user /domain。这条命令背地里同样会通过SAMR做用户枚举一条命令就能触发整个枚举链路。你可以用一条命令做基线验证但要靠日志发现谁用了这条命令并不容易因为操作系统默认不记录SAMR查询的安全审计。4.2 从流量侧识别SAMR枚举流量侧是目前比较可靠的检测视角。SAMR接口的UUID在DCE/RPC绑定里非常固定抓包时直接看RPC绑定字段就行。我通常关注以下几个特征目标端口445且绑定UUID对应SAMR同时出现\PIPE\samr。短时间窗口内出现大量SamrEnumerateUsersInDomain或SamrEnumerateGroupsInDomain请求配合STATUS_MORE_ENTRIES的分页往返。连续出现SamrOpenUser调用RID递增看起来像在遍历用户。批量SamrLookupNamesInDomain名字来自字典特征表单次请求数量大。如果是先枚举组再查成员的模式则会呈现一次枚举组请求→N次GetMembers请求的扇形结构。写检测规则时可以用单位时间内单独一个源IP的SAMR接口调用次数超过阈值作为粗筛再叠加目标为域控这个条件。4.3 主机侧加固与日志补位主机侧能做的加固有几个方向。首先确认系统默认策略没有被放宽检查网络访问: 不允许对SAM账户和共享进行匿名枚举是否为已启用。其次如果有必要可以在域控上针对SAMR相关对象配置更严格的ACL审计通过自定义安全描述符把枚举类访问权限从默认组里移除但这一步必须先在测试环境验证因为很多老管理工具会因此异常。日志补位方面默认安全日志里SAMR枚举不一定直接产生记录真正会产生的是账户创建/删除/组变更事件比如4720用户已创建、4732已将成员加入本地组、4735本地组已更改。这些动作如果和前面的SAMR枚举在时间窗口内联动基本可以断定存在可疑行为。我的经验是安全日志不能只盯登录事件4624账户管理类事件和RPC动态端点审计也要开否则这种只读型协议动作容易成为监控真空区。5. 自己动手复现两次眼神确认的实验记录5.1 最小化实验环境拓扑与准备我验证SAMR行为时搭了一套最小环境一台域控Windows Server 2022域名为corp.example.org一台加入域的工作站一个普通域账号userA一个域管账号adminA。网络层面只开放445和135关掉不必要的服务保证抓包干净。准备阶段记住三件事用快照保存初始状态SAMR枚举虽然只读但后续做写操作实验时必须有回滚点。Wireshark过滤条件可预设为dcerpc smb2 nbss或者直接过滤uuid 12345778-1234-ABCD-EF00-0123456789AB这个UUID就是SAMR接口的标识。在域控上单独开一个管理员命令行方便随时查账号数量做比对。5.2 最小枚举代码从SamrConnect到SamrEnumerateGroupsInDomain这里给一段基于impacket的最小示例展示组→成员路线的核心调用。注意这只是演示协议流程实际使用时要自行适配环境参数。from impacket.dcerpc.v5 import transport, samr # 目标、凭据按实际情况填 remote dc01.corp.example.org username userA password YourPassword domain corp.example.org rpctransport transport.SMBTransport( remote, 445, r\samr, usernameusername, passwordpassword, domaindomain ) dce rpctransport.get_dce_rpc() dce.connect() dce.bind(samr.MSRPC_UUID_SAMR) # 1. 连接SAMR服务 resp samr.hSamrConnect(dce, \\\\%s % remote, samr.MAXIMUM_ALLOWED) server_handle resp[ServerHandle] # 2. 打开域 # 域SID建议先通过其他途径确认示例里省略 domain_sid S-1-5-21-xxxxxxxxxxxxxxxx-xxxxxxxxxx resp samr.hSamrOpenDomain(dce, server_handleserver_handle, desiredAccesssamr.MAXIMUM_ALLOWED, domainIddomain_sid) domain_handle resp[DomainHandle] # 3. 枚举所有普通组 enum_context 0 group_rids [] while True: resp samr.hSamrEnumerateGroupsInDomain( dce, domain_handle, enum_context, 256 ) # 假设resp里包含 Buffer/EnumerationContext/ErrorCode if resp[Buffer]: for item in resp[Buffer][Buffer]: group_rids.append(item[RelativeId]) enum_context resp[EnumerationContext] if resp[ErrorCode] ! 0x105: break # 4. 对每个组取成员 for rid in group_rids: resp samr.hSamrOpenGroup(dce, domain_handle, desiredAccesssamr.MAXIMUM_ALLOWED, groupIdrid) group_handle resp[GroupHandle] members_resp samr.hSamrGetMembersInGroup(dce, group_handle) # 遍历 members_resp[Members] 里的 SID再调用 hSamrLookupIdsInDomain 转成名字 # 这里省略了 SID - 用户名的转换步骤需要注意impacket不同版本对hSamrEnumerateGroupsInDomain返回结构字段的命名略有差异我上面的写法是示意性的。如果你在跑的时候字段名报错最简单的方式是把resp字典实际打印出来按dir()或keys()来调整。5.3 抓包验证的四个关键点我抓包时重点确认了四件事绑定阶段确实出现了SAMR的UUID且访问路径是\PIPE\samr。每次SamrEnumerate*响应后面都跟着一个EnumerationContext并且经过两三次翻页才结束。展开组成员请求时一条SamrGetMembersInGroup响应里的成员数量与域控上实际组内成员数量一致。使用普通域账号userA成功的场景里SamrEnumerateGroupsInDomain没有被拒绝但SamrEnumerateUsersInDomain返回了STATUS_ACCESS_DENIED。最后这一点让我印象很深同样的凭据直接枚举用户失败绕到组里反而成功。这进一步解释了为什么生产工具普遍采用组-成员路线而不是硬啃用户枚举接口。5.4 实验中遇到的报错与解决第一次跑通前我连着踩了三个坑ERROR_DATABASE_INVALID或STATUS_INVALID_HANDLE原因几乎都是没有先拿ServerHandle或者SamrOpenDomain传了错误的域SID。STATUS_NO_SUCH_DOMAIN域SID写错常见于把域控的机器SID当成域SID复制了。正确做法是查域本身的SID不是域控账户的SID。分页死循环忘记把返回的EnumerationContext更新到下一轮请求导致程序卡住。我在代码里加了最大循环次数保护超过后就告警退出养成这个习惯能省很多排查时间。6. 再探之后我对SAMR的几个新理解6.1 SAMR与LSAT的存在感差异SAMR接口的UUID以AB结尾另一个常被弄混的LSAT接口以AC结尾。LSAT负责LSA机密信息和策略查询比如LSARQueryInformationPolicy。两者都走RPC都在\PIPE\命名管道下有对应端点但语义完全不同SAMR管账户信息LSAT管安全策略与密钥。工具代码里有时会同时绑定两个接口先用SAMR枚举用户再用LSAT查询域SID和策略。所以你在抓包时如果看到ABAC两个UUID交替出现基本可以判断对方在做完整的域信息收集而不是普通管理操作。6.2 内置账户为什么容易隐身使用组-成员路线枚举用户时一些内置账户很容易被忽略。例如域管理员Administrator的RID是500Guest是501krbtgt是502。这些内置账户通常不会出现在常规组的成员列表里所以枚举组→遍历成员这条路天然拿不到它们。要拿到完整清单最终还是得回到SamrEnumerateUsersInDomain但正如前面所说普通账号经常被拒。我在做基线核查时通常两条腿走路先用SAMR拿大多数普通账户再通过域管查询或本地安全数据库补齐内置账户。这也是为什么我一直强调覆盖完整账户清单时不能只依赖一种协议。6.3 版本演进下匿名行为的静默变化从早期Windows到Windows Server 2022SAMR的匿名访问行为一直在收缩。老系统里匿名用户就能枚举本地用户后来默认关闭匿名枚举再往后就算你用本地空会话连接服务端也会明确拒绝查询类调用。但这个演进过程并不统一不同补丁级别、不同安全基线配置下表现差异很大。我建议每个域环境都做一次最小权限实测用一个普通域账号记录它能通过SAMR拿到多少信息。这既是防守侧的暴露面确认也能让你在写检测规则时知道正常业务账号的SAMR行为基线长什么样。基线不清任何异常检测都是空中楼阁。最后再分享一个小技巧在做域内账户盘点时别只盯着net user /domain的输出。如果你用SAMR的组关系把谁在哪个组、组成员里有哪些账户完整拉一遍很多时候会发现LDAP索引之外的信息死角。这个协议虽然老但在实战里依然有超出预期的生命力。