使用 Win32 Mutex 确定应用程序实例是否为第一个实例






4.88/5 (13投票s)
本文介绍如何在使用应用程序启动时通过 Win32 Mutex 来确定应用程序实例是否为第一个实例。
引言
这个问题出现在 Microsoft 讨论论坛中,虽然答案相对简单(使用 Mutex),但实际实现需要一些对 Windows 工作原理的理解。出于好奇,我自己实现了一个,并决定分享我的结果。
我的实现直接使用了 Win32 API 来处理 Mutex。其余部分,我使用了 Win32 辅助库,并随着每一篇文章的更新而扩展它,以使生活更轻松,因为每篇文章都使用前一篇文章的某些功能。源代码包含在本篇文章中,一旦我完成最后的清理工作,我就会在 Github 上公开。
背景
在实现 Mutex 时,思路很简单。我们实际上并不使用 Mutex 进行信号传递。相反,我们使用它是因为它是一个创建在内核命名空间中的对象。当它被创建时,API 会告诉我们它是新创建的还是已存在的。有几个细节很重要。
控制台会话
当用户登录 Windows 时,他们会被分配一个会话 ID。在任何给定时间,系统上可能存在多个活动用户。他们是远程登录还是直接在实际物理控制台上登录,这只是一个技术细节。如果我们关注“是否为第一个实例”的要求,我们需要超越我们自己的会话,也要考虑其他会话。获取当前会话 ID 的 API 是 `WTSGetActiveConsoleSessionId`。
内核命名空间
内核对象存在于内核命名空间中。它们有一个唯一的名称,在同一命名空间中不能被另一个内核对象重用。命名空间分为两部分:全局命名空间和本地命名空间。全局命名空间由所有用户会话共享。本地命名空间对每个会话都是唯一的。
假设我们创建一个名为“`bob`”的 Mutex。那么“`Global\bob`”将是全局命名空间中 Mutex bob 的名称。“`Local\bob`”将是本地命名空间中 Mutex bob 的名称。请注意,这些名称是区分大小写的,并且需要按所示拼写。如果我们只使用“`bob`”,那么它会自动假定为本地名称并放在本地命名空间中。
创建 Mutex
Mutex 是通过 API 调用创建的
HANDLE CreateMutexA(
[in, optional] LPSECURITY_ATTRIBUTES lpMutexAttributes,
[in] BOOL bInitialOwner,
[in, optional] LPCSTR lpName
);
就我们而言,名称是识别我们应用程序的关键。稍后我们将详细讨论名称。例如,这可以是“`Global\bob`”。`bInitialOwner` 指定了在创建 Mutex 时,创建它的线程是否也是 Mutex 的初始所有者。我们对此并不关心,因为我们不使用它进行信号传递。
假设我们成功地使用此调用创建了一个 Mutex。随后的 `GetLastError` 调用将告诉我们 Mutex 是新创建的(`NO_ERROR`),还是打开了一个现有 Mutex 的句柄(`ERROR_ALREADY_EXISTS`)。这已经是“是否为应用程序的第一个实例”这个问题的答案的一半了。
`lpMutexAttributes` 主要对于全局命名空间中的对象很重要。任何在没有特定安全属性的情况下创建的 Mutex 都会获得默认的安全描述符。对于本地命名空间中的对象,这通常不重要,因为使用该 Mutex 的应用程序都以相同的用户令牌运行。
然而,对于全局命名空间中的对象,情况并非如此。如果一个以 `LocalSystem` 身份运行的进程在全局命名空间中创建一个没有指定安全属性的 Mutex,由于安全限制,其他进程可能无法打开该 Mutex 的句柄。对我们来说,这并不重要,因为这种失败提供了信息。如果我们由于安全限制而无法打开一个具有该名称的 Mutex 的句柄,我们也知道我们绝对不是第一个尝试创建它的进程。
选择 Mutex 名称
通过这两个命名空间,我们可以轻松确定应用程序是否是当前用户会话中的第一个,以及它是否是整个计算机上的第一个。
不过,将 Mutex 命名为“`bob`”并不是一个非常好的主意。并非不可能计算机上的另一个应用程序也使用相同的名称。理想情况下,Mutex 应该有一个唯一的名称。幸运的是,我们有办法做到这一点:GUID。GUID 保证是唯一的。
然而,这还不够。在某些情况下,您可能希望禁止应用程序并发运行多个实例,除非该应用程序是另一个位置或具有另一个名称的应用程序的副本。例如,如果应用程序是 _c:\temp\myapp.exe_,那么在某些场景下,您可能希望也允许 _c:\temp\copy_of_myapp.exe_ 与之并行运行。因此,可选地,我们需要考虑将模块路径作为 Mutex 名称的一部分。
问题在于,这样我们会得到可能非常长的 Mutex 名称。并且“`\`”不是 Mutex 名称中允许的字符。但有一个非常简单的解决方案:我们只需将所有这些信息(GUID 和完整的模块路径)通过哈希算法进行处理,并使用生成的哈希十六进制字符串作为 Mutex 名称。
CAppInstance 类
实现包含在具有以下声明的类中
private:
CHandle m_LocalMutex;
CHandle m_GlobalMutex;
public:
CAppInstance(
LPCWSTR instanceName,
bool allowCopiesToRun);
~CAppInstance();
bool IsFirstInSession();
bool IsFirstOnComputer();
};
关于它没什么可说的,除了在构造时,我们初始化了两个 Mutex,然后以后可以检查它们以确定进程是否是该应用程序的第一个实例。`CHandle` 是一个包装实际 `HANDLE` 的类,仅用于确保它最终会被关闭并检查它是否是有效句柄。
确定名称
这就是繁重的工作所在。首先,我们确定要使用的名称
wstring globalMutexName = L"Global\\MUTEX_";
wstring localMutexName = L"Local\\MUTEX_";
CBCryptProvider hashProvider;
CBCryptHashObject hashObject(hashProvider);
//Guarantee global uniquess by hashing the GUID
AddDataToHash(hashObject, wstring(instanceGuid));
//If copies are allowed to run but not the same image on disk,
//then we add the module path to the hash,
//guaranteeing a uniqueness per each copy of the image.
if (allowCopiesToRun) {
TCHAR modulePath[4096];
DWORD numChars = GetModuleFileName(NULL, modulePath,
sizeof(modulePath) / sizeof(TCHAR));
if (0 == numChars) {
throw ExWin32Error();
}
AddDataToHash(hashObject, wstring(modulePath));
}
wstring name = hashObject.GetHashHexString();
globalMutexName += name;
localMutexName += name;
我们需要创建两个名称:一个在全局命名空间,一个在本地命名空间。因为最终名称将是一个不可读的十六进制字符串,所以我们使用“`MUTEX_`”前缀。如果我们稍后进行故障排除并在命名空间中查看命名对象,至少我们知道我们正在查看一个 Mutex。
为了创建哈希,我们使用 `CBCryptProvider` 和 `CBCryptHashObject` 类,它们是在之前的文章中实现的。确切的哈希方法无关紧要,因此它默认为 `BCRYPT_SHA256_ALGORITHM`,而且我们也不必关心哈希密钥,因为我们只将其用作一个经过改进的、统计上唯一的校验和。
首先,我们将 GUID 添加到哈希中。如果我们只想允许运行一个实例,而不管它是否在磁盘上被复制,那么我们就停止在这里。无论图像从何处启动,如果 Mutex 仅使用该 GUID 创建,则只能有一个。
现在,如果我们想允许磁盘上的特定映像只运行一个实例,但允许例如 _c:\temp\myapp.exe_ 和 _c:\temp\copy_of_myapp.exe_ 并行运行,那么我们只需将模块路径推入哈希。这将确保每个模块的该 GUID 都有一个唯一的名称。
检测实例
之前,我们讨论过,如果我们创建一个 Mutex 句柄,可能会发生三件事
- 它在没有特殊错误信息的情况下创建。在这种情况下,它以前不存在。
- 它被创建,错误状态为 `ERROR_ALREADY_EXISTS`。这意味着它已经存在。
- 发生错误,Mutex 句柄无效。这也意味着它已经存在。
DWORD retVal = NO_ERROR;
m_GlobalMutex = CreateMutex(NULL, TRUE, globalMutexName.c_str());
retVal = GetLastError();
if (m_GlobalMutex.IsValid() && ERROR_ALREADY_EXISTS == GetLastError()) {
m_GlobalMutex.CloseHandle();
}
m_LocalMutex = CreateMutex(NULL, TRUE, localMutexName.c_str());
retVal = GetLastError();
if (m_LocalMutex != NULL && ERROR_ALREADY_EXISTS == GetLastError()) {
m_LocalMutex.CloseHandle();
}
在那一部分的结尾,拥有一个给定范围的有效 Mutex 将是该实例在该范围内的第一个证据。
如果应用程序希望使用该信息来做出决定,它可以调用以下方法
//Is this the first instance in this particular session?
bool CAppInstance::IsFirstInSession() {
return m_LocalMutex.IsValid();
}
//Is this the first instance on this particular computer?
bool CAppInstance::IsFirstOnComputer() {
return m_GlobalMutex.IsValid();
}
使用 CAppInstance 类
使用该类非常简单
try {
CAppInstance g_AppInstance(L"406B6F5D-4A7B-43A7-8CF8-1E44B3C938BE", false);
wcout << L"Started process as user " << w32_GetCurrentUserName()
<< L" in session " << WTSGetActiveConsoleSessionId() << endl;
cout << "App is first in session: " << g_AppInstance.IsFirstInSession() << endl;
cout << "App is first on computer: " <<
g_AppInstance.IsFirstOnComputer() << endl;
cout << "Press any key to end the application." << endl;
string input;
getline(cin, input);
}
catch (exception& ex) {
cout << ex.what() << endl;
}
对于我们的应用程序,我们使用 `GUIDGen` 创建一个 GUID 并将其粘贴为实例名称。我们还表明,我们不希望磁盘上的应用程序副本被识别为不同的应用程序。
请注意,GUID 的格式无关紧要。所有内容都进入哈希算法。只要 GUID 在其中,任何空格或特殊字符都不会削弱其“唯一性”。
当我测试程序时,我首先以管理员身份运行它
然后两次以我的身份运行
关注点
除了可以选择这样限制实例之外,还有实现附加限制的可能性,无论它们看起来多么牵强。这取决于我们向哈希中输入什么。如果我们添加年份和一年中的第几天,那么我们可以实现一项策略,即每个实例每天只能启动一次,并且只要它们在不同的日期启动,就可以运行多个实例。
当然,这不太有用,但我只是想表明您可以定义任何您想要的唯一性,并根据该唯一性定义做出运行时决策。
需要注意的是,这种构造可能会导致漏洞。全局和本地命名空间都可以被检查,如果攻击者知道正在使用的 Mutex 的名称,则可能导致他们创建 Mutex 以阻止您的应用程序运行(拒绝服务)。一些缓解措施是可能的,但在首次创建具有该名称的 Mutex 后,它就不再保证是秘密了。
历史
- 2022年 11月10日:添加了关于潜在拒绝服务的说明。
- 2022年10月21日:初版