[安全] - 用户模拟






4.89/5 (13投票s)
用于用户模拟的简单 C++ 包装类

引言
本文介绍了一个简单的 C++ 类,它支持用户模拟,允许应用程序模拟任何其他用户(并自动清理)。ImpersonateUser
类是围绕 LogonUser()
、ImpersonateLoggedOnUser()
和 RevertToSelf()
Windows API 函数的一个轻量级包装器(利用 RAII)。
演示项目
本文随附的演示项目非常简单,不要期望它能有多么出色。它只是一个示例,展示了该类的使用。它是一个控制台应用程序,接受多个命令行参数,包括要模拟的用户名/密码。您还可以指定一个文件名(可以是网络共享上的文件),演示项目将尝试通过模拟您指定的详细信息来访问该文件。然后,它将指示是否成功,如下面的屏幕截图所示。
什么是用户模拟?
用户模拟允许应用程序使用另一个用户的安全上下文来执行任务。例如,一个以 LocalSystem
身份运行的服务可以通过模拟特定用户帐户来访问网络资源。该帐户将配置有访问网络资源的必要权限,而服务本身将无法做到这一点。
操作系统支持
Windows 9x
ImpersonateUser
在 Windows 95、98 或 ME 上不受支持,因为这些操作系统不支持用户或用户安全令牌。要使模拟正常工作,操作系统必须支持 Kerberos 安全提供程序,例如 Windows NT/2000 及更高版本提供的。
Windows NT/2000
要在 Windows NT/2000 上使用 LogonUser
API,调用线程必须拥有 SE_TCB_NAME
权限。这可以通过确保用户能够“充当操作系统的一部分”来获得,这可以通过 Windows 控制面板中的用户管理器进行配置。或者,应用程序本身可以请求 SE_TCB_NAME
权限。此权限会自动授予以 LocalSystem
帐户运行的 Windows 服务。这是使用 LogonUser
的推荐方式,即从 Windows 服务调用。
如果调用线程没有此权限,则 LogonUser
调用将失败,GetLastError()
返回 ERROR_PRIVILEGE_NOT_HELD
。
Windows XP, 2K, 2K3, Vista 等。
随着 Windows XP 的推出,不再需要拥有 SE_TCB_NAME
权限。
背景
编写此类的主要原因是我需要在项目中支持模拟。我一直试图编写可重用代码,因此我创建了 ImpersonateUser
,它封装了执行模拟所需的调用。
该类的析构函数提供自动清理,因此使用该类最简单的方法是在堆栈上创建一个实例。如果您更喜欢动态分配该类,那么您应该使用异常处理来确保正确清理(或者当然是使用智能指针包装器,例如 Boost
库提供的其中一个)。我建议在堆栈上实例化该类,这样您可以更轻松地控制模拟的范围。
我意识到 The Code Project 上已经发布了许多关于模拟的文章。但是,据我所知,它们都没有使用纯 C++,尽管如果存在,我确定有人会向我指出这一点。
LogonUser API
LogonUser
只能用于登录本地计算机;它不能用于登录远程计算机。您在 LogonUser()
调用中使用的帐户也必须为本地计算机所知,无论是作为本地帐户还是作为本地计算机可见的域帐户。如果 LogonUser
成功,它将为您提供一个访问令牌,该令牌指定您选择的用户帐户的凭据。
令牌类型
LogonUser()
可以返回两种不同类型的令牌:primary
(主令牌)和 impersonation
(模拟令牌)。
- 主令牌:此令牌通常分配给进程,并成为该进程的默认安全令牌。
- 模拟令牌:此令牌用于获取客户端进程的安全信息,允许您在执行安全相关任务(如访问网络上的共享文件夹)时模拟客户端进程。
通常,从 LogonUser
返回的令牌是主令牌,可以使用 CreateProcessAsUser()
API 以另一个用户的身份创建进程。如果您在 LogonUser()
调用中将 LOGON32_LOGON_NETWORK
指定为 LogonType
(见下文),那么您将获得一个模拟令牌而不是默认的主令牌。您不能在调用 CreateProcessAsUser()
来模拟用户时使用它,除非您首先使用 DuplicateTokenEx()
API 调用将其转换为主令牌。
然后,您可以将此令牌传递给 ImpersonateLoggedOnUser()
,它将允许调用线程模拟已登录用户的安全上下文。如果您希望允许 Windows 服务访问网络资源,而不希望授予所有人完全访问该资源的权限,这会很有用。您只需为特定用户帐户授予访问网络资源的必要权限,然后让 Windows 服务模拟该用户帐户,访问资源,然后通过恢复服务最初运行的安全上下文来撤销模拟。
以下部分解释了 Windows API 函数 LogonUser()
调用中最重要的参数。我不会描述这些参数的所有支持值,只描述与用户模拟直接相关的参数。有关剩余类型的完整说明,请参阅 MSDN 文档。
用户名参数:lpszUsername
这指定了要进行身份验证的用户名,即要登录的用户的名称。如果以用户主体名称 (UPN) 格式输入——例如,accountname@example.com——那么 lpszDomain
参数必须是 NULL
。
域参数:lpszDomain
这指定了用于验证用户帐户的域名称。这可以是域,也可以是网络上工作站或服务器的名称。如果此参数为 NULL
,则 lpszUsername
参数 **必须** 以 UPN 格式指定。使用 LogonUser
最简单的方法是将用户名和域指定为单独的参数,在这种情况下,域名称应以 NetBIOS
格式指定,例如 MyDomain
。要仅使用本地系统帐户数据库验证用户帐户,请将 "."
指定为域参数。
密码参数:lpszPassword
为了降低密码泄露的风险,您应该在调用 LogonUser()
后立即从内存中清除密码。不要依赖 memset()
来执行此操作,因为优化编译器很容易将其从您的代码中删除。您应该改用 SecureZeroMemory()
函数,因为它不会被优化掉。
登录类型参数:dwLogonType
此参数控制 LogonUser()
执行的登录类型。
LOGON32_LOGON_BATCH
:返回主令牌。它适用于需要高吞吐量身份验证且无需依赖凭据缓存的服务器,例如邮件服务器。LOGON32_LOGON_INTERACTIVE
:返回主令牌,需要“本地登录”权限。用户必须拥有在目标计算机上本地登录的权利。用户凭据会被缓存,因此与LOGON32_LOGON_BATCH
相比,性能可能会略有下降。LOGON32_LOGON_NEW_CREDENTIALS
:返回主令牌。此类型允许调用者复制其当前令牌并为出站连接指定新凭据。它需要LOGON32_PROVIDER_WINNT50
登录提供程序,并且仅在 Windows 2000 及更高版本上受支持。LOGON32_LOGON_NETWORK_CLEARTEXT
:返回主令牌,需要LOGON32_PROVIDER_WINNT50
登录提供程序。仅在 Windows 2000 及更高版本上受支持。LOGON32_LOGON_NETWORK
:返回模拟令牌。这是一种高速身份验证方法,需要“从网络访问此计算机”权限。
LOGON32_LOGON_NETWORK
和 LOGON32_LOGON_NETWORK_CLEARTEXT
之间的主要区别在于,LOGON32_LOGON_NETWORK_CLEARTEXT
允许本地计算机上的身份验证提供程序缓存您指定的登录信息。这允许该计算机代表您进行模拟,如果它必须将其用于任何其他网络操作。这并不意味着密码以未加密的方式通过网络发送。
如果您不打算模拟已登录用户,那么您应该使用 LOGON32_LOGON_NETWORK
登录类型。这是因为它是一种最快的身份验证类型,因为身份验证提供程序不会缓存任何凭据。尽管名称如此,LOGON32_LOGON_NETWORK
类型返回的令牌不支持访问网络资源。但是,如果您只想对用户安全详细信息进行身份验证,这是最快的方法。如果您将 LOGON32_LOGON_NETWORK
登录转换为主令牌,然后使用它通过 CreateProcessAsUser()
启动进程,那么新进程将无法访问网络资源,除非网络资源不受访问控制。
登录提供程序参数:dwLogonProvider
此参数负责指定用于验证用户安全详细信息的登录提供程序的名称。您必须确保您网络上的所有域控制器都支持相同的登录提供程序。您的应用程序可能会联系域控制器进行身份验证,但会失败,因为该特定域控制器不支持请求的身份验证提供程序。这主要是 LOGON32_LOGON_NEW_CREDENTIALS
和 LOGON32_LOGON_NETWORK_CLEARTEXT
提供程序的问题,因为它们在 Windows NT 上不受支持。
LOGON32_PROVIDER_DEFAULT
:根据域控制器上运行的操作系统版本使用系统的默认登录提供程序。这适用于 Windows NT 4 及更高版本的域控制器,但如果您有任何 Windows NT 3.51 控制器在您的域中,那么您应该使用LOGON32_PROVIDER_WINNT35
。这会使用协商提供程序,除非您将域传递为NULL
并且用户名不是用户主体名称 (UPN) 格式(例如 accountname@example.com),在这种情况下,提供程序默认为NTLM
。LOGON32_PROVIDER_WINNT35
:如果您要针对 Windows NT 3.51 域控制器进行身份验证(使用 NT 3.51 登录提供程序),请使用此提供程序。LOGON32_PROVIDER_WINNT40
:如果您要针对 Windows NT 4 域控制器进行身份验证(使用 NTLM 登录提供程序),请使用此提供程序。LOGON32_PROVIDER_WINNT50
:使用协商登录提供程序,Windows XP 及更高版本支持。
意外行为:来宾用户帐户
要记住的一件重要事情(因为它起初可能有点奇怪)是,如果您传入一个错误的用户名和密码,LogonUser
仍会成功,**但前提是** 域上的来宾帐户已启用(且没有密码)。当您第一次使用 LogonUser
时,这可能会造成混淆,因为它可以在您指定了无效的用户名和密码时正确地对无效的登录尝试进行身份验证。最安全的方法是禁用来宾帐户并确保其密码非空。
示例测试用例
本节展示了一些我在测试示例代码时使用的测试用例,以及在不同情况下的结果。它展示了如何使用 ImpersonateUser
工具,以及您应该看到的结果。
案例 1:成功模拟 - 文件找到
在此示例中,我们指定了用户名、密码和一个我们将尝试访问的文件名。模拟成功,文件被打开,即使“darka”用户在系统上不存在。这是因为测试 PC 上的“guest”帐户已启用,并且没有设置密码。

案例 2:成功模拟 - 文件未找到
在此示例中,我们指定了有效的用户名、密码和一个不存在的文件名。模拟成功,但我们未能打开文件,这是我们预期的。

案例 3:模拟失败
此示例显示了一个尝试,我们指定了一个无效的域名称,其他所有内容都有效。模拟失败,因此打开文件的尝试被中止。

代码
这个示例是该类头文件的稍微精简的版本,显示了可用的方法。
namespace darka
{
class ImpersonateUser
{
private:
bool init_;
HANDLE userToken_;
DWORD errorCode_;
public:
ImpersonateUser() : init_(false), userToken_(NULL), errorCode_(0) {}
~ImpersonateUser();
bool Logon(const std::string& userName,
const std::string& domain, const std::string& password);
void Logoff();
// Misc Methods
DWORD GetErrorCode() const { return errorCode_; }
};
} // namespace
Using the Code
// Instantiate our Impersonate Class
ImpersonateUser obLogon;
// Impersonate the user
if(!obLogon.Logon(userName, domain, password))
{
const string szErr = FormatSysError(obLogon.GetErrorCode());
cout << _T("\tUser Impersonation Failed!\r\n\t");
cout << szErr;
return -1;
}
else
cout << _T("\tUser Impersonated Successfully\r\n\t");
关注点
实际上没有,只是代码应该可以在警告级别 4(Visual Studio 2003)下干净地编译,并且使用 PCLint 编译时有 0 个警告/错误。
参考文献
- MSDN - LogonUser()
- MSDN - 如何在 Windows 操作系统上验证用户凭据
- MSDN - 用户主体名称
- Kerberos (协议)
- Brian Desmond 的博客
- “一个用于模拟用户的 C# 小类”,作者 Uwe Keim
历史
- 1.20 (2008 年 2 月 15 日) - 上传了 zip 文件的新版本(使用 7Zip 编码),因为许多人难以解压缩原始文件。
- 1.20 (2007 年 10 月 14 日) - 为在 The Code Project 上发布文章做准备
- 1.10 (2007 年 7 月 26 日) - 文章的首次公开发布和更新的包装器类,增加了 STL 支持
- 1.00 (2004 年 9 月 13 日) - 编写了初始包装器类