65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (13投票s)

2022年10月21日

MIT

8分钟阅读

viewsIcon

12234

downloadIcon

277

本文介绍如何在使用应用程序启动时通过 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 句柄,可能会发生三件事

  1. 它在没有特殊错误信息的情况下创建。在这种情况下,它以前不存在。
  2. 它被创建,错误状态为 `ERROR_ALREADY_EXISTS`。这意味着它已经存在。
  3. 发生错误,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日:初版
© . All rights reserved.