构建重启后依然可用的 Windows 服务

作者:Michael Haephrati, Ruth Haephrati
  • 2023-03-09
    北京
  • 本文字数:15192 字

    阅读完需:约 50 分钟

当使用 C++为 Windows 编程时,使用 Windows 服务(Windows Services)几乎是难以避免的。在微软 Windows 操作系统中,Windows 服务发挥着重要的作用,它们能够创建和管理长时间运行的进程,这些进程能够在睡眠、休眠、重启和关机的过程中幸存下来。但是,如果无法做到这一点会怎样呢?在选中快速启动(Fast Startup)时,关闭计算机会导致服务无法重启,这会给程序带来灾难性的后果。微软在 Windows Vista 中引入的Service Isolation可能会导致这类灾难性的后果,在本文中将会阐述如何解决它。

感谢 Windows 服务

多年以来,我们一直在使用 Windows 服务,但是不管我们觉得有多么了解服务,或者有多么自信能够处理它,却始终会遇到更多的问题、挑战和麻烦。其中有些问题根本是没有文档的,或者我们“幸运”一点的话,会有一点糟糕的文档。

自从微软引入服务隔离之后,我们遇到的最令人恼火的问题之一就是当快速启动选中时,计算机关闭后,无法重启服务。鉴于我们没有找到现成的解决方案,所以我们决定自动动手实现一个,这促成了持久化服务的开发。

但是,在深入研究和解释我们的解决方案之前,我们首先从基础知识开始,解释什么是服务,以及为什么要使用 Windows 服务。

NT 服务(也叫做 Windows 服务)指的是由 NT 内核的服务控制管理器(Service Control Manager)加载的特殊进程,它会在 Windows 启动(在用户登录前)立即在后台运行。我们使用服务来执行核心和底层的操作系统任务,比如 Web 服务、事件日志、文件服务、帮助和支持、打印、加密和错误报告。

此外,服务使我们能够创建可执行的、长时间运行的应用程序。原因在于服务会在自己的 Windows 会话环境中运行,所以它不会干扰应用程序的其他组件或会话。显然,我们期望服务会在计算机启动后也自动启动,我们马上就会讨论该问题。

进一步来讲,这里显然有一个问题:我们为什么需要持久化的服务?答案很明显,服务应该能够:

  • 持续在后台运行。

  • 在已登录用户的会话中,调用自身。

  • 作为一个看门狗(watchdog),确保给定的应用程序一直在运行。

Windows 服务需要能够在睡眠、休眠、重启和关机时依然能够存活。但是,正如前文所述,当选中“快速启动”时,计算机关机再启动的话,会出现一些特定的危险问题。在大多数场景中,服务无法重新启动。

因为我们正在开发的是一个反病毒软件,它应该在重启或关机后重新启动,这种情况造成了一个严重的问题,我们迫切需要解决它。

实现良好的服务

为了创建近乎完美的持久化 Windows 服务,我们必须首先解决几个底层的问题。

其中一个问题与服务隔离有关,被隔离的服务无法访问与任何特定用户相关的上下文。我们某个软件产品将数据存储到了c:\users\\appdata\local\中,但是当它从我们的服务中运行的话,这个路径就是无效的,因为服务是在 Session 0 中运行的。除此之外,在重启后,服务会在所有用户登录之前启动,这形成了解决方案的第一部分:等待用户登录。

为了弄清如何做到这一点,我们在这里发布了遇到的问题。

事实证明,这是一个没有完美解决方案的问题,但是,本文附带的代码已经得到了应用,并且经过了全面的测试,没有任何的问题。

基础知识

我们的代码结构和流程可能看起来很复杂,但是这是有一定原因的。在过去的十年间,服务已经与其他进程隔离。从那时开始,Windows 服务会在SYSTEM用户账号下运行,而不是其他的用户账号,并且是隔离运行的

隔离运行的原因在于,服务的功能很强大,可能是潜在的安全风险。正因为如此,微软引入了服务隔离。在这个变化之前,所有的服务会与应用一起在 Session 0 中运行。

但是,在引入了隔离之后(这是在 Windows Vista 中引入的),情况发生了变化。我们的代码背后的想法是通过调用CreateProcessAsUserW,让 Windows 服务以某个用户的身份启动自己,这一点将在后文详细阐述。我们的服务叫做SG_RevealerService,它有多个命令,当使用如下的命令行参数调用时,它们会采取相应的行为。

#define SERVICE_COMMAND_INSTALL L"Install"             // The command line argument                                                       // for installing the service#define SERVICE_COMMAND_LAUNCHER L"ServiceIsLauncher"  // Launcher command for                                                       // NT service
复制代码

当调用SG_RevealerService时,有三个选项:

选项 1:不带有任何命令行参数进行调用。在这种情况下什么都不会发生。

选项 2:带有Install命令行参数进行调用。在这种情况下,服务将自行安装,如果在哈希分隔符(#)添加了有效的可执行路径,服务将会启动,Windows 看门狗会保持其一直运行。

然后,Service 会使用CreateProcessAsUserW()运行自身,新的进程会在用户账号下运行。这给了 Service 访问上下文的能力,因为 Service Isolation,调用实例是无法访问该上下文的。

选项 3:使用 ServiceIsLauncher 命令行参数进行调用。服务客户端主应用将会启动。此时,入口函数表明服务已经以当前用户的权限启动了自身。现在,在 Task Manager 中,我们会看到SG_RevealerService的两个实例,其中一个在SYSTEM用户下,另一个在当前登录用户下。

/*RunHost*/BOOL RunHost(LPWSTR HostExePath,LPWSTR CommandLineArguments){    WriteToLog(L"RunHost '%s'",HostExePath);    STARTUPINFO startupInfo = {};    startupInfo.cb = sizeof(STARTUPINFO);    startupInfo.lpDesktop = (LPTSTR)_T("winsta0\\default");    HANDLE hToken = 0;    BOOL bRes = FALSE;    LPVOID pEnv = NULL;    CreateEnvironmentBlock(&pEnv, hToken, TRUE);    PROCESS_INFORMATION processInfoAgent = {};    PROCESS_INFORMATION processInfoHideProcess = {};    PROCESS_INFORMATION processInfoHideProcess32 = {};    if (PathFileExists(HostExePath))    {        std::wstring commandLine;        commandLine.reserve(1024);        commandLine += L"\"";        commandLine += HostExePath;        commandLine += L"\" \"";        commandLine += CommandLineArguments;        commandLine += L"\"";        WriteToLog(L"launch host with CreateProcessAsUser ...  %s",                     commandLine.c_str());        bRes = CreateProcessAsUserW(hToken, NULL, &commandLine[0],               NULL, NULL, FALSE, NORMAL_PRIORITY_CLASS |               CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE |               CREATE_DEFAULT_ERROR_MODE, pEnv,            NULL, &startupInfo, &processInfoAgent);        if (bRes == FALSE)        {            DWORD   dwLastError = ::GetLastError();            TCHAR   lpBuffer[256] = _T("?");            if (dwLastError != 0)    // Don't want to see an                                     // "operation done successfully" error ;-)            {                ::FormatMessage(FORMAT_MESSAGE_FROM_SYSTEM,    // It's a system error                    NULL,                                      // No string to be                                                               // formatted needed                    dwLastError,                               // Hey Windows: Please                                                               // explain this error!                    MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), // Do it in the standard                                                               // language                    lpBuffer,              // Put the message here                    255,                   // Number of bytes to store the message                    NULL);            }            WriteToLog(L"CreateProcessAsUser failed - Command Line = %s Error : %s",                         commandLine, lpBuffer);        }        else        {            if (!writeStringInRegistry(HKEY_LOCAL_MACHINE,               (PWCHAR)SERVICE_REG_KEY, (PWCHAR)SERVICE_KEY_NAME, HostExePath))            {                WriteToLog(L"Failed to write registry");            }        }    }    else    {        WriteToLog(L"RunHost failed because path '%s' does not exists", HostExePath);    }    hPrevAppProcess = processInfoAgent.hProcess;        CloseHandle(hToken);    WriteToLog(L"Run host end!");    return bRes;}
复制代码

探测用户登录

第一个挑战是仅在用户登录时,才启动一些动作。为了探测用户的登录,我们首先定义一个全局变量。

bool g_bLoggedIn = false;
复制代码

当用户登录时,它的值应该被设置为true

订阅登录事件

我们定义了如下的Preprocesor Directives

#define EVENT_SUBSCRIBE_PATH    L"Security"#define EVENT_SUBSCRIBE_QUERY    L"Event/System[EventID=4624]"
复制代码

当 Service 启动后,我们订阅登录事件,所以当用户登录时,我们会通过设置的回调函数得到一个告警,然后我们就可以继续后面的操作了。为了实现这一点,我们需要一个类来处理订阅的创建并等待事件回调。

class UserLoginListner{    HANDLE hWait = NULL;    HANDLE hSubscription = NULL;public:    ~UserLoginListner()    {        CloseHandle(hWait);        EvtClose(hSubscription);    }    UserLoginListner()    {        const wchar_t* pwsPath = EVENT_SUBSCRIBE_PATH;        const wchar_t* pwsQuery = EVENT_SUBSCRIBE_QUERY;        hWait = CreateEvent(NULL, FALSE, FALSE, NULL);        hSubscription = EvtSubscribe(NULL, NULL,            pwsPath, pwsQuery,            NULL,            hWait,            (EVT_SUBSCRIBE_CALLBACK)UserLoginListner::SubscriptionCallback,            EvtSubscribeToFutureEvents);        if (hSubscription == NULL)        {            DWORD status = GetLastError();            if (ERROR_EVT_CHANNEL_NOT_FOUND == status)                WriteToLog(L"Channel %s was not found.\n", pwsPath);            else if (ERROR_EVT_INVALID_QUERY == status)                WriteToLog(L"The query \"%s\" is not valid.\n", pwsQuery);            else                WriteToLog(L"EvtSubscribe failed with %lu.\n", status);            CloseHandle(hWait);        }    }
复制代码

然后,我们需要一个函数实现等待:

void WaitForUserToLogIn(){    WriteToLog(L"Waiting for a user to log in...");    WaitForSingleObject(hWait, INFINITE);    WriteToLog(L"Received a Logon event - a user has logged in");}
复制代码

我们还需要一个回调函数:

static DWORD WINAPI SubscriptionCallback(EVT_SUBSCRIBE_NOTIFY_ACTION action, PVOID       pContext, EVT_HANDLE hEvent){    if (action == EvtSubscribeActionDeliver)    {        WriteToLog(L"SubscriptionCallback invoked.");        HANDLE Handle = (HANDLE)(LONG_PTR)pContext;        SetEvent(Handle);    }    return ERROR_SUCCESS;}
复制代码

接下来,需要做的就是添加具有如下内容的代码块:

WriteToLog(L"Launch client\n"); // launch client ...{    UserLoginListner WaitTillAUserLogins;    WaitTillAUserLogins.WaitForUserToLogIn();}
复制代码

到达代码块的底部时,我们就可以确信一个用户已经登录了。

在本文后面的内容中,我们将会介绍如何检索登录用户的账号/用户名,以及如何使用GetLoggedInUser()函数。

冒充用户

当确定一个用户已经登录时,我们需要冒充他们。

如下的函数完成了这项工作。它不仅冒充了用户,还调用了CreateProcessAsUserW(),以该用户的身份运行自己。通过这种方式,我们能够让服务访问用户的上下文,包括文档、桌面等,并允许服务使用用户界面,这对于从 Session 0 运行服务来讲是无法实现的。

CreateProcessAsUserW创建了一个新的进程及其主线程,它会在给定用户的上下文中运行。

//Function to run a process as active user from Windows servicevoid ImpersonateActiveUserAndRun(){    DWORD session_id = -1;    DWORD session_count = 0;    WTS_SESSION_INFOW *pSession = NULL;    if (WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, &pSession, &session_count))    {        WriteToLog(L"WTSEnumerateSessions - success");    }    else    {        WriteToLog(L"WTSEnumerateSessions - failed. Error %d",GetLastError());        return;    }    TCHAR szCurModule[MAX_PATH] = { 0 };    GetModuleFileName(NULL, szCurModule, MAX_PATH);    for (size_t i = 0; i < session_count; i++)    {        session_id = pSession[i].SessionId;        WTS_CONNECTSTATE_CLASS wts_connect_state = WTSDisconnected;        WTS_CONNECTSTATE_CLASS* ptr_wts_connect_state = NULL;        DWORD bytes_returned = 0;        if (::WTSQuerySessionInformation(            WTS_CURRENT_SERVER_HANDLE,            session_id,            WTSConnectState,            reinterpret_cast<LPTSTR*>(&ptr_wts_connect_state),            &bytes_returned))        {            wts_connect_state = *ptr_wts_connect_state;            ::WTSFreeMemory(ptr_wts_connect_state);            if (wts_connect_state != WTSActive) continue;        }        else        {            continue;        }        HANDLE hImpersonationToken;        if (!WTSQueryUserToken(session_id, &hImpersonationToken))        {            continue;        }        //Get the actual token from impersonation one        DWORD neededSize1 = 0;        HANDLE *realToken = new HANDLE;        if (GetTokenInformation(hImpersonationToken, (::TOKEN_INFORMATION_CLASS) TokenLinkedToken, realToken, sizeof(HANDLE), &neededSize1))        {            CloseHandle(hImpersonationToken);            hImpersonationToken = *realToken;        }        else        {            continue;        }        HANDLE hUserToken;        if (!DuplicateTokenEx(hImpersonationToken,            TOKEN_ASSIGN_PRIMARY | TOKEN_ALL_ACCESS | MAXIMUM_ALLOWED,            NULL,            SecurityImpersonation,            TokenPrimary,            &hUserToken))        {            continue;        }        // Get user name of this process        WCHAR* pUserName;        DWORD user_name_len = 0;        if (WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session_id, WTSUserName, &pUserName, &user_name_len))        {            //Now we got the user name stored in pUserName        }        // Free allocated memory                                 if (pUserName) WTSFreeMemory(pUserName);        ImpersonateLoggedOnUser(hUserToken);        STARTUPINFOW StartupInfo;        GetStartupInfoW(&StartupInfo);        StartupInfo.cb = sizeof(STARTUPINFOW);        PROCESS_INFORMATION processInfo;        SECURITY_ATTRIBUTES Security1;        Security1.nLength = sizeof SECURITY_ATTRIBUTES;        SECURITY_ATTRIBUTES Security2;        Security2.nLength = sizeof SECURITY_ATTRIBUTES;        void* lpEnvironment = NULL;        // Obtain all needed necessary environment variables of the logged in user.        // They will then be passed to the new process we create.        BOOL resultEnv = CreateEnvironmentBlock(&lpEnvironment, hUserToken, FALSE);        if (!resultEnv)        {            WriteToLog(L"CreateEnvironmentBlock - failed. Error %d",GetLastError());            continue;        }        std::wstring commandLine;        commandLine.reserve(1024);        commandLine += L"\"";        commandLine += szCurModule;        commandLine += L"\" \"";        commandLine += SERVICE_COMMAND_Launcher;        commandLine += L"\"";        WCHAR PP[1024]; //path and parameters        ZeroMemory(PP, 1024 * sizeof WCHAR);        wcscpy_s(PP, commandLine.c_str());        // Next we impersonate - by starting the process as if the current logged in user, has started it        BOOL result = CreateProcessAsUserW(hUserToken,            NULL,            PP,            NULL,            NULL,            FALSE,            NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE,            NULL,            NULL,            &StartupInfo,            &processInfo);        if (!result)        {            WriteToLog(L"CreateProcessAsUser - failed. Error %d",GetLastError());        }        else        {            WriteToLog(L"CreateProcessAsUser - success");        }        DestroyEnvironmentBlock(lpEnvironment);        CloseHandle(hImpersonationToken);        CloseHandle(hUserToken);        CloseHandle(realToken);        RevertToSelf();    }    WTSFreeMemory(pSession);}
复制代码

寻找已登录的用户

为了寻找已登录用户的账号名,我们会使用如下的函数:

std::wstring GetLoggedInUser(){    std::wstring user{L""};    WTS_SESSION_INFO *SessionInfo;    unsigned long SessionCount;    unsigned long ActiveSessionId = -1;    if(WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE,                            0, 1, &SessionInfo, &SessionCount))    {        for (size_t i = 0; i < SessionCount; i++)        {            if (SessionInfo[i].State == WTSActive ||                SessionInfo[i].State == WTSConnected)            {                ActiveSessionId = SessionInfo[i].SessionId;                break;            }        }        wchar_t *UserName;        if (ActiveSessionId != -1)        {            unsigned long BytesReturned;            if (WTSQuerySessionInformation(WTS_CURRENT_SERVER_HANDLE,                ActiveSessionId, WTSUserName, &UserName, &BytesReturned))            {                user = UserName;        // Now we have the logged in user name                WTSFreeMemory(UserName);                }        }        WTSFreeMemory(SessionInfo);    }    return user;}
复制代码

在服务启动后不久,我们就要使用该函数。只要没有用户登录,这个函数就会返回一个空字符串,如果这样的话,我们就知道应该继续等待。

看门狗是 Service 的好朋友

Service 与看门狗机制协同使用是很理想的方案。

这种机制将确保一个给定应用始终处于运行状态,如果它异常关闭的话,看门狗会重新启动它。我们要始终记住,如果用户通过Quit退出的话,我们不希望重启进程。但是,如果进程是通过Task Manager或其他方式被停掉的,我们会希望重启它。一个很好的例子是反病毒程序。我们想要确保恶意软件不能终止本应检测它的反病毒程序。

为了实现这一点,我们需要该 Service 为使用它的程序提供某种 API,当该程序的用户选择“Quit”,程序会告知 Service,程序的工作已经完成了,Service 可以卸载自己了。

一些构建基块

接下来,我们介绍一些构建基块,要理解本文的代码,它们是必备的。

GetExePath

为了获取我们的 Service 或其他可执行文件的路径,如下的函数是非常便利的。

/** * GetExePath() - returns the full path of the current executable. * * @param values - none. * @return a std::wstring containing the full path of the current executable. */std::wstring GetExePath(){    wchar_t buffer[65536];    GetModuleFileName(NULL, buffer, sizeof(buffer) / sizeof(*buffer));    int pos = -1;    int index = 0;    while (buffer[index])    {        if (buffer[index] == L'\\' || buffer[index] == L'/')        {            pos = index;        }        index++;    }    buffer[pos + 1] = 0;    return buffer;}
复制代码

WriteLogFile

当开发 Windows Service 时(以及其他任何软件),拥有一个日志机制都是很重要的。我们有一个非常复杂的日志机制,但是就本文而言,我添加了一个最小的日志函数,名为WriteToLog。它的运行机制类似于printf ,但是所有发送给它的内容不仅会被格式化,还会存储在一个日志文件中,以备日后检查。这个日志文件的大小会不断增长,因为会有新的日志条目追加到上面。

日志文件的路径,通常会位于 Service 的 EXE 的路径,但是,由于 Service Isolation,在重启计算机后的一小段时间内,这个路径会变成 c:\Windows\System32,我们并不希望如此。所以,我们的日志函数会检查 exe 的路径,并且不会假设Current Directory在 Service 的生命周期内会保持不变。

/** * WriteToLog() - writes formatted text into a log file, and on screen (console) * * @param values - formatted text, such as L"The result is %d",result. * @return - none */void WriteToLog(LPCTSTR lpText, ...){    FILE *fp;    wchar_t log_file[MAX_PATH]{L""};    if(wcscmp(log_file,L"") == NULL)    {        wcscpy(log_file,GetExePath().c_str());        wcscat(log_file,L"log.txt");    }    // find gmt time, and store in buf_time    time_t rawtime;    struct tm* ptm;    wchar_t buf_time[DATETIME_BUFFER_SIZE];    time(&rawtime);    ptm = gmtime(&rawtime);    wcsftime(buf_time, sizeof(buf_time) / sizeof(*buf_time), L"%d.%m.%Y %H:%M", ptm);    // store passed messsage (lpText) to buffer_in    wchar_t buffer_in[BUFFER_SIZE];    va_list ptr;    va_start(ptr, lpText);    vswprintf(buffer_in, BUFFER_SIZE, lpText, ptr);    va_end(ptr);    // store output message to buffer_out - enabled multiple parameters in swprintf    wchar_t buffer_out[BUFFER_SIZE];    swprintf(buffer_out, BUFFER_SIZE, L"%s %s\n", buf_time, buffer_in);    _wfopen_s(&fp, log_file, L"a,ccs=UTF-8");    if (fp)    {        fwprintf(fp, L"%s\n", buffer_out);        fclose(fp);    }    wcscat(buffer_out,L"\n");HANDLE stdOut = GetStdHandle(STD_OUTPUT_HANDLE);    if (stdOut != NULL && stdOut != INVALID_HANDLE_VALUE)    {        DWORD written = 0;        WriteConsole(stdOut, buffer_out, wcslen(buffer_out), &written, NULL);    }}
复制代码

更多的构建基块:注册表相关的内容

下面是一些我们用来存储看门狗可执行文件路径的函数,所以当计算机重启后,Service 重新启动时,就能使用该路径。

BOOL CreateRegistryKey(HKEY hKeyParent, PWCHAR subkey){    DWORD dwDisposition; //Verify new key is created or open existing key    HKEY  hKey;    DWORD Ret;    Ret =        RegCreateKeyEx(            hKeyParent,            subkey,            0,            NULL,            REG_OPTION_NON_VOLATILE,            KEY_ALL_ACCESS,            NULL,            &hKey,            &dwDisposition);    if (Ret != ERROR_SUCCESS)    {        WriteToLog(L"Error opening or creating new key\n");        return FALSE;    }    RegCloseKey(hKey); //close the key    return TRUE;}BOOL writeStringInRegistry(HKEY hKeyParent, PWCHAR subkey,                           PWCHAR valueName, PWCHAR strData){    DWORD Ret;    HKEY hKey;    //Check if the registry exists    Ret = RegOpenKeyEx(        hKeyParent,        subkey,        0,        KEY_WRITE,        &hKey    );    if (Ret == ERROR_SUCCESS)    {        if (ERROR_SUCCESS !=            RegSetValueEx(                hKey,                valueName,                0,                REG_SZ,                (LPBYTE)(strData),                ((((DWORD)lstrlen(strData) + 1)) * 2)))        {            RegCloseKey(hKey);            return FALSE;        }        RegCloseKey(hKey);        return TRUE;    }    return FALSE;}LONG GetStringRegKey(HKEY hKey, const std::wstring &strValueName,                     std::wstring &strValue, const std::wstring &strDefaultValue){    strValue = strDefaultValue;    TCHAR szBuffer[MAX_PATH];    DWORD dwBufferSize = sizeof(szBuffer);    ULONG nError;    nError = RegQueryValueEx(hKey, strValueName.c_str(), 0, NULL,             (LPBYTE)szBuffer, &dwBufferSize);    if (nError == ERROR_SUCCESS)    {        strValue = szBuffer;        if (strValue.front() == _T('"') && strValue.back() == _T('"'))        {            strValue.erase(0, 1); // erase the first character            strValue.erase(strValue.size() - 1); // erase the last character        }    }    return nError;}BOOL readStringFromRegistry(HKEY hKeyParent, PWCHAR subkey,                            PWCHAR valueName, std::wstring& readData){    HKEY hKey;    DWORD len = 1024;    DWORD readDataLen = len;    PWCHAR readBuffer = (PWCHAR)malloc(sizeof(PWCHAR) * len);    if (readBuffer == NULL)        return FALSE;    //Check if the registry exists    DWORD Ret = RegOpenKeyEx(        hKeyParent,        subkey,        0,        KEY_READ,        &hKey    );    if (Ret == ERROR_SUCCESS)    {        Ret = RegQueryValueEx(            hKey,            valueName,            NULL,            NULL,            (BYTE*)readBuffer,            &readDataLen        );        while (Ret == ERROR_MORE_DATA)        {            // Get a buffer that is big enough.            len += 1024;            readBuffer = (PWCHAR)realloc(readBuffer, len);            readDataLen = len;            Ret = RegQueryValueEx(                hKey,                valueName,                NULL,                NULL,                (BYTE*)readBuffer,                &readDataLen            );        }        if (Ret != ERROR_SUCCESS)        {            RegCloseKey(hKey);            return false;;        }        readData = readBuffer;        RegCloseKey(hKey);        return true;    }    else    {        return false;    }}
复制代码

检查宿主(Host)是否在运行

本文中的程序有一项核心能力,那就是保护我们的SampleApp(我们将其称为宿主),当它未运行时,就重新启动它(所以叫做看门狗)。在真实场景中,我们会检查宿主是被用户终止的(这是允许的),还是被恶意软件终止的(这是不允许的),在后一种情况下,我们将会重启它(否则,如果用户选择Quit,但应用程序将继续“骚扰”系统并反复执行)。

如下是它如何实现的:

我们创建了一个Timer事件,每隔一定的时间(不应该过于频繁),我们会检查宿主的进程是否在运行,如果没有的话,我们就启动它。我们使用了一个静态布尔型标记(is_running),用来表明我们已经处于该代码块中了,所以在处理过程中时,能够避免再次调用。这是在WM_TIMER代码块中始终要做的事情,因为当定时器设置的频率过高的话,代码块在调用时,前一个WM_TIMER事件的代码依然在执行。

我们还通过检查g_bLoggedIn布尔标记来判断是否有用户登录。

  case WM_TIMER:        {            if (is_running) break;            WriteToLog(L"Timer event");            is_running = true;            HANDLE hProcessSnap;            PROCESSENTRY32 pe32;            bool found{ false };            WriteToLog(L"Enumerating all processess...");            // Take a snapshot of all processes in the system.            hProcessSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);            if (hProcessSnap == INVALID_HANDLE_VALUE)            {                WriteToLog(L"Failed to call CreateToolhelp32Snapshot(). Error code %d",GetLastError());                is_running = false;                return 1;            }            // Set the size of the structure before using it.            pe32.dwSize = sizeof(PROCESSENTRY32);            // Retrieve information about the first process,            // and exit if unsuccessful            if (!Process32First(hProcessSnap, &pe32))            {                WriteToLog(L"Failed to call Process32First(). Error code %d",GetLastError());                CloseHandle(hProcessSnap);          // clean the snapshot object                is_running=false;                break;            }            // Now walk the snapshot of processes, and            // display information about each process in turn            DWORD svchost_parent_pid = 0;            DWORD dllhost_parent_pid = 0;            std::wstring szPath = L"";            if (readStringFromRegistry(HKEY_LOCAL_MACHINE, (PWCHAR)SERVICE_REG_KEY, (PWCHAR)SERVICE_KEY_NAME, szPath))            {                m_szExeToFind = szPath.substr(szPath.find_last_of(L"/\\") + 1);    // The process name is the executable name only                m_szExeToRun = szPath;                                            // The executable to run is the full path            }            else            {                WriteToLog(L"Error reading ExeToFind from the Registry");            }            do            {                if (wcsstr( m_szExeToFind.c_str(), pe32.szExeFile))                {                    WriteToLog(L"%s is running",m_szExeToFind.c_str());                    found = true;                    is_running=false;                    break;                }                if (!g_bLoggedIn)                {                    WriteToLog(L"WatchDog isn't starting '%s' because user isn't logged in",m_szExeToFind.c_str());                    return 1;                }            }            while (Process32Next(hProcessSnap, &pe32));            if (!found)            {                WriteToLog(L"'%s' is not running. Need to start it",m_szExeToFind.c_str());                if (!m_szExeToRun.empty())    // watchdog start the host app                {                    if (!g_bLoggedIn)                    {                        WriteToLog(L"WatchDog isn't starting '%s' because user isn't logged in",m_szExeToFind.c_str());                        return 1;                    }                    ImpersonateActiveUserAndRun();                    RunHost((LPWSTR)m_szExeToRun.c_str(), (LPWSTR)L"");                }                else                {                    WriteToLog(L"m_szExeToRun is empty");                }            }            CloseHandle(hProcessSnap);        }        is_running=false;        break;
复制代码

如何测试 Service

当我们想要测试这个解决方案时,我们雇佣了 20 个资深的和协作的测试人员。在整个工作过程中,越来多的测试均成功了。在某些时候,它在我们自己的 Surface Pro 笔记本电脑上运行地非常完美,但是,我们的一位员工报告说,在他的计算机上,在关闭之后,服务没有再次启动,或者在Ring 3下没有启动自身。这是一个好消息,因为在开发过程中,当你怀疑某个地方存在缺陷的时候,最糟糕的事情就是无法找到它,也无法重现它。总而言之,10%的测试者报告了问题。因此,这里发布的版本在我们员工的电脑上运行完美,然而 2%的测试者仍然不时报告问题。换句话说,SampleApp在关闭计算机并打开后无法启动。

如下是对测试服务和看门狗的说明。

SampleApp

我们包含了一个由 Visual Studio Wizard 生成的样例应用,作为“宿主”应用,它会被看门狗确保一直运行。你可以单独运行它,外观如下面的图片所示。该应用没有做太多的事情。实际上,它一无是处……

在后面的内容中,我们将提供测试服务和看门狗的指南。你可以在GitHub下载源码。

从 CMD 中运行

以管理员身份打开 CMD。将当前目录变更至 Service 的 EXE 所在的路径并输入:

SG_RevealerService.exe Install#SampleApp.exe

你可以看到,我们有两个元素:

  • command 元素,这里是Install

  • argument 元素,通过哈希分隔符(#)连接至命令元素,应该是我们希望看门狗观察的可执行文件。

Service 首先会启动 SampleApp,从此之后,如果你尝试终止或杀死SampleApp的话,看门狗会在几秒钟后重启它。如果重启,关掉计算机并再次启动,你会发现 Service 会再次出现并启动SampleApp。这就是我们的 Service 的目标和功能。

卸载

最后,如果要停止和卸载服务,我们包含了一个uninstall.bat脚本,它如下所示:

sc stop sg_revealerservicesc delete sg_revealerservicetaskkill /f /im sampleapp.exetaskkill /f /im sg_revealerservice.exe
复制代码

结论

  • Windows Service 在微软 Windows 操作系统中起着关键作用,它支持创建和管理长期运行的进程。

  • 在有些场景下,如果勾选了“快速启动”,在正常关闭并重启计算机后,服务往往无法重启。

  • 本文的目的是创建一个持久化的服务,在 Windows 重新启动或关机后,能够始终运行并重新启动。

  • 其中一个主要的问题与 Service Isolation 有关。隔离本身(在 Windows Vista 版本中引入)是很重要和强大的,然而,当我们需要与用户空间交互时,这会产生一些限制。

  • 当服务重新启动时,我们希望它能与用户空间进行交互,然而它不能发生地太早(在任何用户登录之前)。不过,你可以通过订阅登录事件来解决这个问题。

  • Service 与看门狗机制协同使用是很理想的方案。这种机制能够确保给定的应用一直在运行,并且在异常关闭时,它将重新启动。我们在前面描述的方法的基础上,成功地开发了这个机制,这使得它可以一直运行,在用户登录时得到提醒,并且能够与用户空间进行交互。

  • 定时器事件能够用来监控被观察进程的运行。

  • 在开发过程中,好的日志机制始终是非常有用的,我们可以使用简单的日志工具,并在需要的时候,使用更为复杂的工具。

  • 最终的解决方案必须要进行测试。代码被确认并验证可以运行后,多达 2%的测试人员依然可能会报告错误,这是有一定原因的。

作者简介:

Michael Haephrati 是 Secured Globe, Inc.的联合创始人和首席执行官,该公司于 2008 年与他的妻子 Ruth Haephrati 一起创建。Michael 是一位音乐作曲家、发明家,也是一位专门从事软件开发和信息安全的专家。凭借 30 多年的经验,Michael 形成了独特的视角,将技术和创新结合起来,并强调终端用户的体验。多年来,Michael 领导了各种客户的创新项目和技术。他是“Learning C++”(https://www.manning.com/books/learning-c-plus-plus)的作者,该书由 Manning Publications 出版。

Ruth Haephrati 是 Secured Globe, Inc.的联合创始人和首席执行官,该公司于 2008 年与她的丈夫 Michael Haephrati 一起创建。Ruth 是一位作家、演讲者、企业家、网络安全和网络取证专家。在过去的 25 年里,Ruth 与微软和 IBM 等领先公司合作,担任顾问和 C++实践专家。她最近参与了为一个国际客户开发的最先进的反恶意软件技术。在业余时间,Ruth 是一位插画家、画家、野生动物摄影师和世界旅行者。

原文链接:

The Service and the Beast: Building a Windows Service that Does Not Fail to Restart

相关阅读:

Windows 11发布重大更新:ChatGPT版Bing集成到任务栏中,可快速访问AI聊天功能

Kubernetes 1.26 版本正式发布:改进 Windows 支持,加强网络安全和管理功能