1. 为什么我又回头研究 SAMR 协议
先放个结论:MS-SAMR 这个协议没死,反而比很多新协议更值得深挖。你可能每天都在间接使用它,却从来没意识到它的存在。比如在你电脑上敲 net user 看用户列表,比如运维平台里某个按钮执行“重置用户密码”,再比如安全团队扫描系统里的本地管理员账号,这些动作的背后,大概率都有 SAMR 的影子。
MS-SAMR 全称是 Security Account Manager Remote,是一组用于远程访问安全账户管理器(SAM)数据库的 RPC 接口。它可以直接查询和修改用户、用户组、别名,也能读取和设置账户策略。很多人听到这个词是在安全相关的报告里,但我不太喜欢把它只当一个“安全话题”来聊。我更愿意把它当运维基础设施看待:凡是需要跟 Windows 账户数据库打交道的自动化平台,绕不开它。
这篇文章之所以叫“再探”,是因为我最早读协议文档时也是按部就班看方法列表、看数据结构,看完觉得“不过如此”。后来到真实环境里做账户基线核查、做运维自动化接口,才被各种实际行为反复教育:同一个枚举接口,为什么在某台老系统上返回的数据怪怪的?为什么我的脚本在域成员机上只列出本机用户?为什么新装好的系统,用管理员账号连过去还是报拒绝访问?
这些问题的答案,大多不在协议文档的“方法定义”里,而藏在系统版本演进、安全策略默认值和调用参数的具体选择里。如果只是照着网上现成代码抄一遍,遇到问题就只能瞎猜。这篇内容的目的,就是把我踩过的坑和一些底层逻辑讲清楚,让读者少走点弯路。
适合读这篇的人有三类:第一类是在写自动化运维平台的开发同学,尤其是要维护账户管理功能的;第二类是负责系统安全基线评审、需要搞明白“SAMR 远程调用到底暴露了什么”的同事;第三类是刚接触 Windows 账户体系、想弄懂 net user 这类命令背后原理的新人。下面内容不会给那种“照着敲一遍就能通”的空泛代码,重点是把设计逻辑和真实环境里的坑讲明白。
这里先做一个重要提醒:下文所有演示,都基于管理员在授权范围内维护自己系统的场景。脚本只做只读审计,不涉及任何绕过身份验证的内容。真要拿到生产环境,请先确认你具备足够的授权。
2. 协议定位与整体设计思路
2.1 协议到底负责什么
先解释一个最基本的背景。Windows 上有一套本地安全体系,上半部分叫 LSA,负责登录验证、令牌生成、审计策略;下半部分叫 SAM,负责把用户、组、密码摘要存进数据库。这个分层看起来清晰,但它毕竟是本地架构。一旦我们需要从远程机器去管理另一台机器的账户,就得有协议把这些内部调用转移到网络上去。
MS-SAMR 做的就是这件事。它定义了客户端如何远程打开服务器上的 SAM 对象、枚举域、查询用户属性、创建和删除账户、修改密码、设置域策略。这里有一个特别容易误会的点:协议里的“域”概念,在实际环境里有两个层面。在域控制器上,SAMR 打开的是 Active Directory 域对象;在普通成员服务器上,打开的则是本机 SAM 数据库。所以你写的同一个程序,跑在不同类型的机器上,看到的数据范围完全不同。
很多人会问:有了 LDAP 和 Kerberos,为什么还要保留 SAMR?这要从兼容性讲起。AD 域里的账户管理主要走 LDAP,但本地账户、本地组成员关系、某些登录相关信息的查询和修改,仍然要落到 SAM 层。在跨版本、跨环境、以及一些特殊管理工具的内部实现里,SAMR 是那个兜底的通用通道。协议没死,一方面有历史包袱,另一方面是它在底层场景里的确不可替代。
2.2 从绑定到调用:一条调用链的拆解
一个典型 SAMR 调用的完整路径,我在项目里经常要画给同事看。画完大家就明白:为什么一个问题要排查那么多层。下面把完整链路列出来:
- 建立传输会话。绝大多数场景走 SMB,客户端连接目标机器的 445 端口,完成身份认证,建立 IPC 连接。
- 绑定 RPC 接口。在 SMB 共享上创建命名管道,通常是
\pipe\samr,然后在管道上发起 RPC bind 请求,指定 SAMR 服务对应的接口 UUID。 - 执行业务调用。调用具体方法,比如枚举域、打开域、枚举用户。
- 释放资源,断开绑定。关闭打开的句柄,断开 RPC 绑定,释放网络会话。
这里有个容易忽略的细节:SAMR 的许多调用需要先拿到上一层对象的句柄。你连上服务器后,先要用 SamrConnect 拿服务器句柄;想枚举用户,得先打开域句柄;想查某个用户,还要在域句柄基础上用相对标识符(RID)再打开用户句柄。整个过程很像从文件系统根目录逐层进入子目录,每一步都返回句柄,用完必须关闭。
有同事经常问,为什么不直接拿着对象引用到处传?原因很简单,句柄是经过权限校验的引用。你在 SamrConnect 时声明想要的访问权限,后续所有操作都基于这个范围,系统不会每次都重新检查你“能不能做这件事”。这种设计减少了重复校验,但也意味着,如果你一开始把权限要小了,后续调用会在更深的地方失败;一开始要大了,又可能触发安全告警。权限粒度怎么选,要提前想清楚。
2.3 版本演进与行为差异
SAMR 不是一成不变的。协议规范里不同版本的方法并存,最典型的是 SamrConnect 系列。老版本接口在早期系统上很常见,后来随着安全需求和权限表达细化,新系统逐步引入了带更多参数的新版本接口。这种演进的表面结果是“文档变厚”,实际影响是:同一段调用代码在不同系统上的表现会有差异。
另一个重要演进是安全策略的收紧。新版系统默认禁止匿名访问 SAM,并且在本地安全策略里增加了“网络访问:对 SAM 的远程调用进行限制”这个选项。如果客户端请求不是来自管理员组成员,目标机器会在 RPC 层直接返回拒绝访问,而不会告诉你具体原因。很多自动化脚本第一次跑就会撞上这道墙。
还有一种行为差异来自账户令牌过滤。部分系统对本地管理员做远程登录时,会先过滤掉一部分高权限组成员身份,只保留普通用户权限。这意味着即使你用管理员账号连接,目标也不一定给你完整的管理权限。很多跨机器管理脚本在 A 机器上测试正常,换到 B 机器就报权限不足,多半就是这类差异造成的。
3. 核心操作与实现细节
3.1 服务器句柄与域对象的获取
看完整流程的第一段:连接服务器、枚举域、打开域。我刻意用“打开”而不是“查询”,因为 SAMR 的对象模型面向句柄。
- SamrConnect / SamrConnect5:建立服务器会话,返回 ServerHandle。
- SamrEnumDomains:枚举服务器可见的域列表,返回域名和域 SID。
- SamrLookupDomain:按名称查找域 SID。
- SamrOpenDomain:基于域 SID 打开域句柄。
这里最关键的参数是访问掩码(DesiredAccess)。SAMR 把权限拆得特别细,粒度比普通文件读取权限还要细致。实际工程里常见的现象是:程序员为了让代码“肯定能跑通”,直接传最大权限值。这样做有两个坏处,一是容易触发目标侧安全监控的“高位权限调用”告警;二是权限过大,客户端一旦被滥用,影响范围就会扩大。
我的项目习惯是,连接和原型验证阶段可以先用最大权限把链路通起来,确认代码本身没有写错;但一旦进入生产脚本,必须换成最小够用的权限。先明确业务逻辑只需要“读”还是需要“写”。只做账户清单导出,用只读标志就够;要做密码重置,那才需要高权限位。不同版本系统对权限位具体取值的定义略有差异,动手前翻一眼对应版本的枚举定义,成本很低,回报很高。
3.2 用户枚举与分页机制
拿到域句柄后,最常见的操作是枚举用户列表。这里有一个全网资料讲得比较少、但在生产环境里特别重要的机制:分页。
SAMR 的枚举接口不是一次性把全部用户返回给你的。它的调用参数里包含类似“枚举上下文”的东西。第一次调用时,客户端传初始值;服务器返回一批结果,同时回填一个用于继续提取后续内容的上下文值。如果客户端忽略这个回填值,直接再用初始值调用,拿到的可能永远是第一页数据,最终导出的用户数量会明显少于实际人数。
我曾接手过一个内部用户数据核对项目,当时的脚本会漏掉约 15% 的账户,问题就出在这里。排查时一开始怀疑过滤条件写错,把日志打出来才发现,每次枚举请求时上下文的值都是同一个。这说明代码里根本没处理续传逻辑。改成按返回值继续调用之后,数量立刻对上了。
除了分页,枚举参数里通常还有过滤标志,比如只枚举普通用户账户、不关心内置账户和机器账户。生产环境建议显式设置过滤条件,避免把无关对象带进报表。如果你要导出完整账户列表做审计,就要把过滤范围理解清楚,别把“故意过滤”当成“数据缺失”。
3.3 用户属性查询与常见字段解读
拿到用户 RID 列表后,需要先用 SamrOpenUser 打开用户句柄,再用不同信息级别查询具体数据。查询时通过“信息级别”决定返回哪种复合结构,这一点很像 SQL 里 select 不同字段组合的视角,只是这里的结构由协议固定。
查询结果里,我平时最关注这几个字段:
- 用户 SID:区分对象的关键标识。
- UserAccountControl:位掩码,里面藏着账户启用状态、是否锁定、是否密码永不过期等标志。
- PasswordLastSet:上次设置密码的时间,通常是 64 位整数格式,需要换算。
- PrimaryGroupId:主组 RID,用于判断账户归属。
- AccountExpires:账户过期时间,很多清理任务会定期扫描这个字段。
我的建议是先画一张字段对照表,把协议字段和业务字段的对应关系固定下来,再写解析代码。比如“账户是否禁用”在 UserAccountControl 中对应某一位,代码里别用“等于”去判断整个掩码,而是用“按位与”检查目标位是否被设置。否则账户上挂了其他标志位时,判断结果会被干扰,报表数据也就不准了。
3.4 密码操作与策略设置
账户管理里绕不开密码操作。SAMR 提供两类接口:一类是用户本人在知道旧密码的情况下修改密码,另一类是管理员直接设置新密码。后者需要的权限级别更高,实际调用还会受目标侧密码策略的限制。
我见过不少运维平台在密码重置功能上踩坑:明明用管理员凭据调用 SAMR,却被域策略拦下来,返回特定错误码。原因往往是目标域开启了密码复杂性校验,或者账户启用了强制密码历史。协议文档只会告诉你“服务器可能返回错误”,但一个会排查的人,会同步检查密码策略里是否有最小长度、复杂性、历史记录等要求,把策略限制和协议错误对齐。
域策略的读取和设置,也有一部分由 SAMR 操作承载,比如读取账户锁定阈值、密码最长使用期限。不过在域环境里,这类操作通常走更贴近目录服务的通道,SAMR 更多作为本地策略兜底。工程上需要先确认目标对象是域账户还是本机账户,再决定调用路径,避免用错通道导致权限校验失败。
4. 实操:写一个最小可用的账户审计脚本
4.1 开发准备与依赖选择
下面用一个实际可参考的例子,把上面的逻辑落地。场景是:一台内部服务器,需要批量导出账户状态,生成只读基线报表。这个需求不能改动任何账户属性,但要抓出上次设置密码时间、账户是否启用等信息。
我选 Python 加常用网络协议库来演示。这类库把 RPC 绑定、数据类型解码都封装好了,适合做原型验证。先把依赖装上:
pip install impacket接着准备目标机器 IP 和账号。这里特别提醒,不要拿域管理员账号去跑这种只读任务。更合理的做法是准备一个只被授权了“读取账户信息”的专用服务账号。万一脚本被误用,影响可控,审计日志也容易定位。下面代码里的凭据参数只是示例,务必替换成你自己的专用账号。
4.2 脚本设计骨架与权限位选择
整个脚本分五步:
- 通过 SMB 传输封装创建 RPC 传输层。
- 绑定 SAMR 接口。
- 枚举域并打开域句柄。
- 枚举用户并读取关键属性。
- 输出结构化结果。
先看连接部分的骨架:
from impacket.dcerpc.v5 import transport, samr def build_samr_connection(target, user, password): rpc_transport = transport.SMBTransport( target, 445, r"\samr", username=user, password=password, ) dce = rpc_transport.get_dce_rpc() dce.connect() dce.bind(samr.MSRPC_UUID_SAMR) return dce这段代码把 SMB 认证、管道路径、RPC 绑定都封装在一个函数里。之后调用方只关心业务逻辑,不必再碰底层细节。这种拆分在实际项目里很有用:以后如果要换传输方式,只需要换传输对象,查询逻辑基本不用动。
连接建立后,打开服务器句柄:
resp = samr.hSamrConnect5(dce, r"\\target_host", samr.MAXIMUM_ALLOWED) server_handle = resp["ServerHandle"]先把话说清楚:这里用 MAXIMUM_ALLOWED 只是为了原型阶段快速打通链路,缩小排查范围。真正进生产环境前,必须把它改成符合“最小权限原则”的只读访问常量。在 impacket 库里,各种权限常量名都能直接查到大写定义,照着业务范围去挑就行,这一步省不得。
4.3 枚举域、用户与分页处理
有了服务器句柄,先枚举域,拿到域名和 SID:
enum_resp = samr.hSamrEnumDomains(dce, server_handle, 0, 100) for item in enum_resp["Buffer"]["Buffer"]: domain_name = item["DomainInfo"]["Name"] domain_sid = item["DomainInfo"]["Sid"] print("domain:", domain_name, domain_sid)拿到域 SID 之后,可以打开域句柄,继续枚举用户。我这里把枚举写成循环,关键就是处理分页:
def page_through_users(dce, domain_handle): resume_handle = 0 while True: users_resp = samr.hSamrEnumUsers( dce, domain_handle, 0, samr.USER_NORMAL_ACCOUNT, resume_handle, 100, ) for u in users_resp["Buffer"]["Buffer"]: print(u["RelativeId"], u["Name"]) if users_resp["ResumptionHandle"]: resume_handle = users_resp["ResumptionHandle"] else: break这段代码的关键点在最后几行。每次调用后都去读返回的 ResumptionHandle,如果有值就带进下一次调用。如果忽略它,接口会认为你又要从头开始,最终结果就是漏数据。这是我在真实项目里排查过很久的问题,也是我觉得写 SAMR 枚举时必须放进代码模板的标准结构。
4.4 用户属性读取与日期换算
枚举到 RID 和账户名后,需要打开用户句柄再查属性。实际操作时,我建议查询完一个用户就立即关闭句柄,再处理下一个,避免句柄长时间占用。
核心调用思路如下:
user_handle = samr.hSamrOpenUser(dce, domain_handle, read_access, rid)["UserHandle"] info_resp = samr.hSamrQueryInformationUser(dce, user_handle, some_level) # 处理完成后: samr.hSamrCloseHandle(dce, user_handle)返回结构里最容易看错的是密码设置时间。协议在传输层通常把它表示成一个 64 位整数,很多新人会当 Unix 时间戳去处理,结果得到一个完全离谱的日期。实际上,这类字段常常需要做基准日期换算,转成 100 纳秒间隔后再计算。你一旦发现日期数值明显不对,第一反应就应该是换算公式或起始基准用错了,而不是怀疑目标机器时间错了。
4.5 输出与基线做法
脚本最后把数据写进 CSV,或者直接存到数据表格里就能用。我真正想让读者关注的不是输出格式,而是“基线”的思路:首次全量导出后,存成基线文件;之后定期再导一次,用差异对比找出新增、禁用、密码临期的账户。这个过程既能发现异常账户,也是账户生命周期管理的前置步骤。很多团队做账户管理只停留在“能查”,差的就是这种定期比对机制。
5. 常见问题与排查技巧实录
5.1 连接被拒绝:权限与策略双因素
症状:脚本连接时报访问拒绝,甚至换本机管理员也不行。
排查思路:
- 先确认账号确实在目标机器管理员组里。远程登录场景下,管理员组成员身份会被安全策略二次加工。
- 检查“网络访问:对 SAM 的远程调用进行限制”。这个策略如果只允许管理员,普通账号必然失败;即使账号是管理员,同时命中更严格的共享模型配置时,也可能被过滤。
- 看目标系统版本。不同版本的默认策略不一样,有些环境需要额外调整。
我遇到过一个很好玩的案例:同一个账号在 A 机可以登录,在 B 机却无法远程查 SAM。最终发现是 B 机的限制策略没有配置好,跟代码完全无关。排查这类问题,最重要的不是反复改代码,而是先确认策略状态。
5.2 枚举漏项:问题一定出在分页吗
不一定。漏项除了分页,还可能出在过滤条件上。SAMR 枚举接口允许按账户类型过滤,如果你传入的过滤值排除了某种账户,比如机器账户或禁用账户,结果里自然看不到这些对象。正确的排查顺序是先看代码里的过滤参数,再谈分页。
另外,部分工具会做慢速枚举来规避检测,但在合法运维中我不建议这么干。生产环境做全量账户扫描时,应当分批拉取并记录断点,既降低目标负载,也方便中途失败后接着跑。
5.3 新旧系统返回差异
对老系统调用高版本接口,可能得到“接口不可用”类错误。反过来,系统版本太新,对老调用方式也可能更保守。经验上做到两点:一是准备方法降级逻辑,比如比较新的接口失败时自动尝试老版本;二是把错误码完整透传到日志,不要吞掉。否则你只会看到“调用失败”,却不知道是接口版本不匹配还是权限不足。
5.4 域控制器与成员机看到的世界不一样
这是个特别容易忽略的问题。同一段代码连成员服务器,枚举的是本机用户;连域控制器,枚举的是整个域用户。如果你的目标是“全域账户清单”,却错误地连到一台成员服务器,拿到的数据自然会少一大截。
我在做跨机器数据核对时,专门在配置表里加了一个“目标类型”字段,区分域控和成员机,然后把结果和已知总用户数做交叉验证。没有这个校验,漏连一台机器可能很久都发现不了。
5.5 部分常见错误码速查
下面整理了一些常见错误码和对应的排查方向,方便现场快速定位:
| 错误码 | 典型含义 | 排查方向 |
|---|---|---|
| 0xC0000022 | 访问被拒绝 | 检查权限位、SAM 远程调用限制策略 |
| 0xC000006A | 密码错误 | 核对旧密码是否正确、是否过期 |
| 0xC000006D | 密码不符合策略 | 检查密码复杂性、历史、长度要求 |
| 0xC0000225 | 对象未找到 | 检查 RID、域名、过滤条件是否匹配 |
| 0x00000715 | 密码过期/不可用 | 查看账户策略与过期时间 |
这张表只是起点,真实环境还有更多状态码。遇到陌生错误码时,建议先解析成标准 NTSTATUS 文本,再顺着含义反向查策略,比一句句翻译十六进制快得多。
6. 从安全配置角度限制远程 SAMR 调用
6.1 策略基线与推荐配置
如果我不是在写脚本,而是在给新系统做安全加固,SAMR 相关配置一定是重点之一。最直接的策略项是“网络访问:对 SAM 的远程调用进行限制”。它的用途是明确哪些主体可以通过 SAMR 远程访问安全账户对象。推荐基线是把该项设为“管理员组”,既保留正常运维入口,又避免任意普通账号都能枚举账户信息。
另一个相关策略是本地账户的共享和安全模型项。它影响本地账户通过网络登录目标机器时的权限映射方式。在受攻击面较大的场景里,通常建议按更严格的模型配置。配置完成后,最好用独立账号实测一次,确认管理员远程管理功能没有被影响。这类策略改起来容易,但一旦改过头,运维平台可能大面积报错。
6.2 监控视角:看哪些日志
SAMR 调用本身不一定会产生独立的安全审计日志,它的效果更多体现在账户枚举、权限使用和登录事件的周边。建立监控时,我习惯分三层看:
- 登录层:关注来自异常来源地址的登录与权限事件,尤其是带管理员组成员身份的远程登录。
- 访问层:在网络层记录对 445 端口和 SAMR 管道的连接情况,构建流向基线。
- 进程层:借助终端检测响应类工具,检测可疑进程发起的批量远程管理调用。
我做账户基线审计时,会先收集两周正常流量的访问来源清单,再基于清单预警突发的陌生来源批量访问。只盯单个事件很容易被正常业务流量干扰,反而是“偏离基线”的信号更有价值。
6.3 几个常见误区
第一个误区:以为把 SAMR 相关接口全部禁用就彻底安全了。实际上,大量管理功能依赖它,一刀切会引发大面积故障。更合理的设计是限制调用主体,而不是关闭能力。
第二个误区:把“能枚举用户”等同于“拿到了系统权限”。枚举用户只是信息收集中的一段,不等于接管系统。安全评审里如果出现这种说法,其实是没把协议暴露面和真实攻击成本讲清楚。
第三个误区:把 SAMR 访问一律当作可疑。域内正常业务、日志采集、合规检查都会产生这类连接,全部告警只会把真正的问题淹没。先分层建基线,再对偏离基线的行为做响应,才是可落地的策略。
7. 一些个人操作体会
写到这里,我最想分享的其实是一件很朴素的事:协议文档只是起点,真正值钱的是把它放进真实环境后的适配经验。我第一次把账户管理项目从原型推到生产,连续三个晚上都卡在远程受限策略上。当时一度怀疑是代码问题,后来发现目标机器上放开管理侧策略后一切正常。从那以后,我养成了两个习惯。
第一个习惯是永远把“连接层”和“协议层”分开排查。遇到问题先确认能不能正常建立带身份验证的网络会话,再看最终调用失败发生在哪一层。第二个习惯是生产脚本里绝不用最大权限去试探。虽然这样会让调试多几步,但后期安全评审能省很多口舌。
如果你正准备把远程账户查询能力接入自己的运维平台,建议先建一个小范围试点,用最少的权限把通路上每个环节的真正限制摸清楚,再往大范围推。SAMR 这个协议名字里带着“老”字,实际场景里它仍然是硬需求,值得每个做系统和运维的人认真对待。至少对我来说,把它从“看过文档”变成“真正会用”,是在生产环境里踩了无数个坑之后才完成的。希望这篇再探笔记,能帮你把这个过程缩短一点。