在新的内容开始前,我想整理一些旧文,这一框题展示了在以前的系统上实现在用户关机/重启/注销时弹出对话框的功能。为什么需要先讲这个部分?因为这一部分需要拦截的函数是截至 Win 8 系统,微软所采用的关机/重启等途径上的关键函数,这有助于我们理解后续的拦截任意 Winlogon 操作方法分析。
【提示】系列文章为了有助于不了解 Winlogon 的读者初步理解我们在完成的事情,主要分为(上)、(中)、(下)三部分 ,链接会在下面列出。(需要哪个部分的请自行跳转 ☆*: .。. o(≧▽≦)o .。.:*☆)
系列文章:
屏蔽系统热键(上)传送门 | ID:133801527 |
屏蔽系统热键(中)传送门 | ID:135907307 |
屏蔽系统热键(下)传送门 | ID:135907201 |
Winlogon 和 Explorer 在执行操作前会检查或设置多个注册表位置下的键值,我们常常通过 Process Monitor 等工具分析注册表操作来挖掘可用信息。
在操作系统中,用户可以通过“开始”菜单的电源按钮,打开电源选项卡。电源选项中一般有注销、重启、关机、睡眠/休眠几个选项。下图展示了在 Win 11 上电源选项卡的弹出式窗口内容:
如果您的电脑需要长期运行而不关机,为了防止误关机,可以手动隐藏这些按钮。下面介绍如何通过修改注册表实现隐藏电源按钮。在注册表中,我们可以分别隐藏关机、重启、睡眠、电源按钮、休眠、注销、切换用户等控件/选项。
在“运行”对话框(快捷键 WIN + R) 或开始菜单中输入 Regedit 并回车,打开注册表编辑器。定位至以下路径:
[注册表路径]
计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\default\Start
在 Start 主键下,可以看到一部分名称以 Hide 开头的子键,通过名称我们就知道它们对应的功能,比如 HideShutDown 表示隐藏关机选项、HidePowerButton 表示隐藏电源按钮。
在这些以 Hide 开头的键下,有多个固定名称的值项,其中名为"value" 的值用于表示是否隐藏,值的数据为 1 表示隐藏,值的数据为 0(默认)表示始终显示。
对这些项的大多数修改会立即生效,部分修改(如隐藏电源按钮)需要重启资源管理器。这里以隐藏关机选项为例,将 HideShutDown 下的 value 的值修改为 1,打开开始菜单的电源按钮,可以观察到“关机”选项已经消失:
并且在资源管理器的桌面或任务栏击中 Alt + F4 热键。在弹出的“关闭 Windows”对话框中,也不能找到“关机”选项:
此外,还可以通过组策略编辑器实现禁用电源选项:通过运行 gpedit.msc 打开组策略编辑器,展开“计算机配置 - 管理模板 - 开始菜单和任务栏”树结构,在右边的设置栏中可以找到“删除并阻止访问关机、重新启动、睡眠和休眠命令”这一项。
双击编辑属性,点击“已禁用”,点击“应用”,点击确定关闭对话框:
此设置将会同时隐藏关机、重新启动、睡眠和休眠命令。当然,也可以通过注册表修改组策略:
在注册表中打开如下路径
[注册表路径] —— 当前计算机
计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion
\Policies\Explorer
[注册表路径] —— 当前用户
计算机\HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion
\Policies\Explorer
找到 HidePowerOptions 值项,如果没有可以打开右键菜单,选择“新建 - DWORD(32位)值”,并命名为 HidePowerOptions,我们需要修改其数据为1。
这会禁用电源按钮中的所有电源选项,但不会在 Alt+ F4 中生效。
此外,通过组策略或者注册表可以禁用登陆界面的电源按钮,可以通过如下方法完成:
(1)组策略
打开“运行”对话框,并输入 gpedit.msc,打开“本地组策略编辑器”;在本地组策略编辑器的左侧窗格中,向下展开到“计算机配置”>“ Windows设置”>“安全设置”>“本地策略”>“安全选项”。 在右侧,找到“关闭:无需登录即可关闭系统”项,然后双击它。
在属性设置页面点击“已启用”,点击“应用”,随后点击“确定”关闭页面,然后重启计算机即可生效。
(2)注册表
首先,点击开始并键入“regedit”,打开注册表编辑器。 按 Enter 键打开注册表编辑器,并授予其对 PC 进行更改的权限。 在注册表编辑器中,使用左侧边栏导航至以下键:
[注册表路径]
计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion
\Policies\System
在右侧的项列表中,找到 ShutdownWithoutLogon
值,然后双击它(如果没有,请创建它)。
在“数值数据”框中将值设置为0
,然后单击“确定”。
退出注册表编辑器,然后重新启动计算机以查看更改。 重新登录后,应该不再在登录屏幕的右下角看到“关机”按钮。
上面所提到的注册表修改方式可以通过编程修改,道理是一样的。
由于注册表只是让 GUI 界面的按钮隐藏,而并不是阻止相关的过程,调用和电源有关的接口的进程依然可以关闭计算机。
一些程序通过挂起 winlogon.exe 进程来阻滞一切由其控制的操作,这方便于他们所要的功能的实现。在 Win 7 时,很多工具在后台挂起该进程达到阻止任务管理器 (Taskmgr) 启动的作用
打开 Procexp (Process Explorer) 找到登陆应用程序的进程,在右键菜单中可以看到挂起进程 ( Suspend) 选项:
看到进程状态被标记为 Suspended(已挂起),表示进程将暂停执行过程。
这时候,你可以尝试按下 Ctrl + Alt + Esc ,这将阻止任务管理器的启动。但是这个方法在 Win 10/11 上行不通,挂起进程会导致一切“以管理员身份启动”操作都被阻滞。并且影响 Winlogon 的稳定性。
微软提供了一些函数接口用于电源状态改变时程序能够及时得到通知。下面我们简单分析关机拦截API 的功能。
对于 GUI 应用程序,只有关闭和重新启动才被视为挂起操作。进入睡眠模式并不是一个非常重要的事件,因为它不会改变 GUI 应用程序工作中的任何内容。在某些情况下,我们可能需要检测睡眠模式,以便向其他系统组件发送 PC 将进入睡眠状态的消息。但是,大多数 GUI 应用只需要检测操作系统关闭和重启的机制。让我们看看这些机制是如何工作的:
GUI 应用程序通过窗口消息接收有关目标事件的信息。这就是为什么我们需要 WM_QUERYENDSESSION 和 WM_POWERBROADCAST 消息来使 GUI 应用程序能够检测操作系统关闭。
Windows 在用户启动用户会话关闭过程时发送 WM_QUERYENDSESSION 窗口消息。关闭计算机并重新启动也会导致用户会话结束。因此,这些事件的消息通过同一个窗口消息传递。
我们使用 WM_POWERBROADCAST 消息来获取有关系统暂停的信息。以下是我们在 GUI 应用程序中处理此消息的方式:
//...
case WM_POWERBROADCAST:
{
if (wParam == PBT_APMSUSPEND)
// 计算机正在挂起
break;
}
case WM_QUERYENDSESSION:
{
if (lParam == 0)
// 计算机正在关闭
if ((lParam & ENDSESSION_LOGOFF) == ENDSESSION_LOGOFF)
// 正在注销用户
break;
}
//...
WM_POWERBROADCAST 中的 lParam 参数包含各种系统事件的标识符,包括关闭。对于WM_QUERYENDSESSION 窗口消息,值为 0 表示重新启动或关闭,而其他值表示其他事件。
(备注:通过模拟发送该消息和 WM_ENDSEESION ,可以实现关机前准备过程)
请注意,我们单独处理关机和注销事件,因为它们不一定是关联的。
收到 WM_QUERYENDSESSION 后,我们能做什么?
case WM_QUERYENDSESSION:
{
if (lParam == 0)
{
// 计算机正在关机
ShutdownBlockReasonCreateW(hwnd, L"Please, don't kill me");
}
break;
}
如果我们不执行任何操作,Windows 将显示一条警告消息,指出这些应用程序正在阻止关闭,用户可以取消关闭或强制继续关闭,无论等待的应用程序如何。在这种情况下,我们的应用程序可以通过下面两种方式之一运行:
这适用于 Windows Vista 和更高版本的 Windows。
运行该程序,在关机时可以拉起一个等待列表:
如果我们不希望其他应用程序在我们的程序响应关机消息前被关闭,我们可以调用 SetProcessShutdownParameters 函数,并将 dwLevel 参数设置为 0x4FF,这对应于系统关机序列的最高优先级。在此函数中,特别注意参数 dwFlags
。如果我们将其值更改为 SHUTDOWN_NORETRY
,我们的 GUI 应用程序不会阻止关闭。
dwLevel
参数表示相对于系统中其他进程的进程关闭优先级。 系统会将进程从高 dwLevel 值关闭为低值。 最高和最低关闭优先级是为系统组件保留的。 此参数必须位于以下值范围内。
值 | 含义 |
---|---|
000-0FF |
系统保留上次关闭范围。 |
100-1FF |
应用程序保留的最后一个关闭范围。 |
200-2FF |
应用程序保留的“介于”关机范围内。 |
300-3FF |
应用程序保留的第一个关闭范围。 |
400-4FF |
系统保留第一个关机范围。 |
需要注意的是,所有用户进程都默认从 0x280 关机级别启动。
所以正确的调用方法为:
SetProcessShutdownParameters(0x4FF, 0);// 设置关机列表优先级
下面给出一个用对话框实现的电源事件拦截器实例:
resources.h
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by ShutdownBlocker.rc
//
#define IDD_MAINDIALOG 101
#define IDC_BUTTON_BLOCK 1001
#define IDC_BUTTON_UNBLOCK 1002
#define IDC_STATIC_STATUS 1005
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 102
#define _APS_NEXT_COMMAND_VALUE 40001
#define _APS_NEXT_CONTROL_VALUE 1006
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif
ShutdownBlocker.rc
// Microsoft Visual C++ generated resource script.
//
#include "resource.h"
#define APSTUDIO_READONLY_SYMBOLS
/
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "afxres.h"
/
#undef APSTUDIO_READONLY_SYMBOLS
/
// 中文(简体,中国) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
#pragma code_page(936)
#ifdef APSTUDIO_INVOKED
/
//
// TEXTINCLUDE
//
1 TEXTINCLUDE
BEGIN
"resource.h\0"
END
2 TEXTINCLUDE
BEGIN
"#include ""afxres.h""\r\n"
"\0"
END
3 TEXTINCLUDE
BEGIN
"\r\n"
"\0"
END
#endif // APSTUDIO_INVOKED
/
//
// Dialog
//
IDD_MAINDIALOG DIALOGEX 0, 0, 344, 188
STYLE DS_SETFONT | DS_NOIDLEMSG | DS_SETFOREGROUND | DS_FIXEDSYS | DS_CENTER | WS_MAXIMIZEBOX | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
EXSTYLE WS_EX_APPWINDOW
CAPTION "ShutdownBlocker"
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
PUSHBUTTON "阻止关机",IDC_BUTTON_BLOCK,71,129,93,29
PUSHBUTTON "解除阻止",IDC_BUTTON_UNBLOCK,180,129,93,29
CTEXT "静态",IDC_STATIC_STATUS,100,60,139,20,SS_PATHELLIPSIS | NOT WS_GROUP
GROUPBOX "当前状态",IDC_STATIC,82,48,168,40
END
/
//
// DESIGNINFO
//
#ifdef APSTUDIO_INVOKED
GUIDELINES DESIGNINFO
BEGIN
IDD_MAINDIALOG, DIALOG
BEGIN
LEFTMARGIN, 7
RIGHTMARGIN, 337
TOPMARGIN, 5
BOTTOMMARGIN, 181
END
END
#endif // APSTUDIO_INVOKED
/
//
// AFX_DIALOG_LAYOUT
//
IDD_MAINDIALOG AFX_DIALOG_LAYOUT
BEGIN
0
END
#endif // 中文(简体,中国) resources
/
#ifndef APSTUDIO_INVOKED
/
//
// Generated from the TEXTINCLUDE 3 resource.
//
/
#endif // not APSTUDIO_INVOKED
ShutdownBlocker.cpp
#include
#include
#include "resource.h"
// 标记是否已经阻止关机,默认为未阻止
BOOL blockedFlag = FALSE;
// 调用注册关机阻滞原因的接口
BOOL BlockShutdown(HWND hwnd)
{
if (ShutdownBlockReasonCreate(hwnd, L"当前正在保存数据,请勿关机!"))
{
return TRUE;
}
return FALSE;
}
BOOL UnblockShutdown(HWND hwnd)
{
if (ShutdownBlockReasonDestroy(hwnd))
{
return TRUE;
}
return FALSE;
}
INT_PTR CALLBACK MainDialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
HWND hcurwnd = NULL;
switch(uMsg)
{
case WM_INITDIALOG:
SetDlgItemTextW(hwndDlg, IDC_STATIC_STATUS, L"未阻止关机");
return TRUE;
case WM_CLOSE:
if (blockedFlag)
{
if (IDYES == MessageBoxW(NULL,
L"还未解除阻止,还要继续关闭程序么?",
L"提示", MB_YESNO))
{
if (UnblockShutdown(hwndDlg))
{
EndDialog(hwndDlg, 0);
break;
}
}
else {
return 0;
}
}
EndDialog(hwndDlg, 0);
break;
case WM_QUERYENDSESSION:
// 拦截 WM_QUERYENDSESSION 消息
if (blockedFlag)
{
return TRUE;
}
return FALSE;
case WM_COMMAND:
switch(LOWORD(wParam))
{
case IDC_BUTTON_BLOCK:
if (!blockedFlag)
{
if (BlockShutdown(hwndDlg))
{
SetDlgItemTextW(hwndDlg, IDC_STATIC_STATUS, L"已经阻止关机");
blockedFlag = TRUE;
}
else
{
MessageBoxW(hwndDlg,L"阻止关机失败了……", L"提示", MB_OK);
}
}
return TRUE;
case IDC_BUTTON_UNBLOCK:
if (blockedFlag)
{
if (UnblockShutdown(hwndDlg))
{
SetDlgItemTextW(hwndDlg, IDC_STATIC_STATUS, L"未阻止关机");
blockedFlag = FALSE;
}
else
{
MessageBoxW(hwndDlg, L"解除阻止失败了……", L"提示", MB_OK);
}
}
return TRUE;
default:
return DefWindowProcW(hwndDlg, uMsg, wParam, lParam);
}
default:
return 0;
}
}
int WINAPI WinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nShowCmd
)
{
// 设置进程关机优先级
SetProcessShutdownParameters(0x4FF, 0);
DialogBoxW(hInstance,
MAKEINTRESOURCE(IDD_MAINDIALOG),
NULL,
MainDialogProc
);
return 0;
}
程序执行的界面如图所示:
这个实例是网络上转载较多的一个,由于来源已经不可考证,所以就暂且当作是开源的,我稍做了改动。
在 Windows 发生关机时,系统都进行了哪些操作?一直以来,这都困惑着我。我们总是猜测关机的响应和系统进程有关,而与外壳进程 (explorer.exe) 无关,因为任何程序都可以发起关机,只要它调用正确的例程。例如第三方程序 FastShutdown 可以实现多种电源操作,它是通过什么方法实现的呢?
随后我们通过在 IDA 反汇编,不难定位到如下函数处:
可以明显地看出关机通过 ExitWindowsEx 实现,在 MSDN 上可以查找到关于这个函数的信息。
ExitWindowsEx 函数用于注销交互式用户、关闭系统、或关闭并重启系统。 它将 WM_QUERYENDSESSION 消息发送到所有应用程序,以确定它们是否可以终止。
这个函数有两个参数,uFlags 和 dwReason,前者表示电源操作的组合,后者表示关机/重启原因:
BOOL ExitWindowsEx(
[in] UINT uFlags,
[in] DWORD dwReason
);
我们在虚拟机中使用 API Monitor V2 对 winlogon.exe、wininit.exe 等进程进行监视发现在关机时 winlogon.exe 也会调用 ExitWindowsEx 函数:
另外一个程序 shutdown.exe 由微软提供,位于 %systemdrive%\Windows\System32\ 下,通过对它的反汇编可以找到和电源有关的多个函数:
(1)ExitWindowsEx【关机/注销/重启】
(2) NtInitiatePowerAction【睡眠/休眠】
(3)InitiateShutdownW【关机/重启的高级操作】
(4)InitiateSystemShutdownExW【InitiateShutdownW 的扩展】
(5)此外还有 NtSetSystemPowerState 等强制关机函数:
这个函数和公开文档的 SetSystemPowerState 不是一个函数,NtSetSystemPowerState 有较多参数,下面是该函数的简单封装,其参数和 NtInitiatePowerAction 类似:
BOOL SystemPowerdown(IN POWER_ACTION SystemAction, IN SYSTEM_POWER_STATE MinSystemState, IN ULONG dwFlags)
{
if (!NtSetSystemPowerState)
return FALSE;
DWORD dwRet = NtSetSystemPowerState(SystemAction, MinSystemState, dwFlags);
if (dwRet == 0)
return TRUE;
else
return FALSE;
}
综上,我们想到应该可以挂钩这类函数实现关机的拦截,下面逐一分析这些函数该如何拦截。
Detours 是一个在 Windows 平台上截获任意 Win32 函数调用的工具库。Detours 使用一个无条件转移指令来替换目标函数的最初几条指令,将控制流转移到一个用户提供的钩子拦截函数。而目标函数中的一些指令被保存在一个被称为“trampoline” (跳板)的函数中。
这些指令包括目标函数中被替换的代码以及一个重新跳转到目标函数的无条件分支。而钩子拦截函数可以替换目标函数,或者通过执行“trampoline”函数的时候将目标函数作为子程序来调用的办法来扩展功能。
Detours 定义了三个概念:
在 x86 平台上,Detours 在 Target 函数的开头加入 JMP Address_of_ Detour_ Function 指令(共5个字节)把对 Target 函数 的调用引导到自己的 Detour 函数, 并把 Target 函数的开头的5个字节加上 JMP Address_of_ Target _ Function + 5 共10个字节作为 Trampoline 函数保存下来。
在进行内联挂钩的时候,要特别注意多核 CPU 在 Hook & Replace 过程中的影响,因为多个线程有可能"同时"调用同一个函数地址,为了解决这个问题,一个好的做法是在 Inline Hook 的过程中,把当前进程的所有线程都挂起。通过 CreateToolhelp32Snapshot 和 SuspendThread 的配合,在完成 Inline Hook 后再恢复线程。
下面以挂钩 ExitWindowsEx 为例,讲解如何进行内联挂钩。
方法一:我们可以根据 Inline Hook 的原理手动实现挂钩,例如:
/*__declspec(naked)*/ void MyExitWindowsEx(){
__asm
{
call testMsgBox;
jmp _ExitWindowsExAddTwoByte
}
}
// 适用于 Win7 上 User32.dll 的内联挂钩
void hook_ExitWindowsEx() {
HMODULE hUser32 = GetModuleHandleW(L"user32.dll");
char* pOldExitWindowsEx = reinterpret_cast(GetProcAddress(hUser32, "ExitWindowsEx"));
// NOP 掉 5 字节
const int iLengthCopy = 7;
if (pOldExitWindowsEx != nullptr) {
_copyNtShutdownSystem = VirtualAlloc(0, 1024, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
char* pNewAddr = reinterpret_cast(_copyNtShutdownSystem);
char* pnop = pOldExitWindowsEx - 5;
char aa = *pOldExitWindowsEx;
char bb = *(pOldExitWindowsEx + 1);
if (static_cast(0x8b) == *pOldExitWindowsEx && static_cast(0xff) == *(pOldExitWindowsEx + 1)) {
DWORD oldshutdownProtect = 0;
if (VirtualProtect(pOldExitWindowsEx - 5, iLengthCopy, PAGE_EXECUTE_READWRITE, &oldshutdownProtect)) {
*pOldExitWindowsEx = static_cast(0xeB); // jmp short
*reinterpret_cast(pOldExitWindowsEx + 1) = static_cast(-0x7); // addr
*pnop = static_cast(0xe9); // jmp
*reinterpret_cast(pnop + 1) = reinterpret_cast(MyExitWindowsEx) - reinterpret_cast(pnop + 5); // addr
_ExitWindowsExAddTwoByte = pOldExitWindowsEx + 2;
VirtualProtect(pOldExitWindowsEx - 5, iLengthCopy, oldshutdownProtect, nullptr);
}
}
}
return;
}
方法二:我们可以使用上文提到的比较成熟的第三方库,如 Detours 或者 MinHook 等,他们在实现内联挂钩方面的代码非常稳定。 首先,创建一个 Win32 动态链接库项目,然后需要在 NuGet 中为当前项目安装 detours 库。
随后,引入头文件和链接库文件:
#include "detours.h"
#pragma comment(lib, "detours.lib")
随后我们需要一个指针存放原函数地址:
PVOID fpExitWindowsEx = NULL;
随后我们需要定义一个和目标函数参数一样的函数,同时给出目标函数的定义:
// 目标函数的定义
typedef BOOL (WINAPI* __funcExitWindowsEx)(
_In_ UINT uFlags,
_In_ DWORD dwReason
);
// 拦截注销/重启之类的钩子函数
BOOL WINAPI HookedExitWindowsEx(
_In_ UINT uFlags,
_In_ DWORD dwReason
);
随后,我们需要编写钩子的事务过程函数:
挂钩时,首先使用 DetourFindFunction 获取目标函数的地址,并保存到 fpExitWindowsEx 中。
然后,调用 DetourAttach 将钩子打到目标函数入口处。
// 挂钩过程
void StartHookingFunction()
{
// 开始事务
DetourTransactionBegin();
// 更新线程信息
DetourUpdateThread(GetCurrentThread());
fpExitWindowsEx =
DetourFindFunction(
"user32.dll",
"ExitWindowsEx");
// 将拦截的函数附加到原函数的地址上,
// 这里可以拦截多个函数。
DetourAttach(&(PVOID&)fpExitWindowsEx,
HookedExitWindowsEx);
// 结束事务
DetourTransactionCommit();
}
卸载/脱钩的过程也要写好:
脱钩主要通过 DetourDetach 并传递相同的参数来实现。
// 脱钩过程
void UnmappHookedFunction()
{
//开始事务
DetourTransactionBegin();
//更新线程信息
DetourUpdateThread(GetCurrentThread());
//将拦截的函数从原函数的地址上解除,这里可以解除多个函数。
DetourDetach(&(PVOID&)fpExitWindowsEx,
HookedExitWindowsEx);
//结束事务
DetourTransactionCommit();
}
随后,我们可以编写好我们的钩子函数:
BOOL WINAPI HookedExitWindowsEx(
_In_ UINT uFlags,
_In_ DWORD dwReason
)
{
WCHAR lpMsg[64]{};
WCHAR lpCap[] = L"Windows LogonManager";
DWORD Result = 0;
/* uFlags == 65536(win8注销),什么鬼?
win 8.1 关机 4268041, 重启 73731, 注销 65536
win 7 关机 65545, 重启65539, 注销 65536
Vista 开始菜单关机 65545, 注销 65536, 重启 65539
(^ 同 Win7)
*/
switch(uFlags){
case 65536:
wsprintf(lpMsg, L"正在取消注销计算机的任务计划,请稍后......\n",
uFlags);
break;
case 4268041: case 65545:
wsprintf(lpMsg, L"正在取消关闭计算机的任务计划,请稍后......\n",
uFlags);
break;
case 73731: case 65539:
wsprintf(lpMsg, L"正在取消重启计算机的任务计划,请稍后......\n",
uFlags);
break;
default:
wsprintf(lpMsg,
L"正在取消用户发起的电源操作,请稍后......\n未知参数 uFlags [%d]\n",
uFlags);
break;
}
// 发出阻滞对话框
SvcMessageBox(lpCap, lpMsg,
MB_OK | MB_APPLMODAL | MB_ICONINFORMATION, TRUE, Result);
SetLastError(995);//995 = 由于线程退出或应用程序请求,I/O 操作已中止。
return FALSE;
}
其中,消息对话框不能使用 MessageBox 函数。因为 Winlogon 进程位于安全桌面, MessageBox 函数会在当前线程桌面下创建窗口,而 Winlogon 桌面是当前活动桌面,我们的窗口无法穿透桌面曾而被我们看到。窗口无法在桌面上绘制,但是对话框确实阻滞了进程,此时我们将无法继续鼠标操作。于是,我们考虑采用 WTSGetActiveConsoleSessionId 和 WTSSendMessageW,将对话框发送到指定的会话(Session),也就是类似于 Session 0 穿透。
#include
#pragma comment(lib, "WtsApi32.lib")
BOOL SvcMessageBox(LPWSTR lpCap, LPWSTR lpMsg, DWORD style, BOOL bWait, DWORD& result)
{
if (NULL == lpMsg || NULL == lpCap)
return FALSE;
result = 0;
DWORD sessionXId = WTSGetActiveConsoleSessionId();
return WTSSendMessageW(WTS_CURRENT_SERVER_HANDLE, sessionXId,
lpCap, (DWORD)wcslen(lpCap) * sizeof(DWORD),
lpMsg, (DWORD)wcslen(lpMsg) * sizeof(DWORD),
style, 0, &result, bWait);
}
关于 uFlags 为什么不是公开文档中的值的组合,这可能涉及到未公开的内容。我们只需要,分析出每个系统版本上的参数的值和对应的作用,然后对不同操作进行分类即可。
然后,我们只需要在主函数中调用钩子过程以进行拦截。
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
DisableThreadLibraryCalls(hModule);
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
StartHookingFunction();
break;
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
UnmappHookedFunction();
break;
}
return TRUE;
}
下图展示了在 Win XP 上的测试效果:
这是在 Win 8 的效果:
使用内联挂钩修改这个函数,在 Win 8 之前的操作系统上普遍适用,但是在 Win 10 / 11 上则起不到作用。起初,我以为是不再使用该函数了,随后,在反汇编中依然可以看到该函数,只不过变成了延迟加载的函数( __imp_ DelayLoadFunction )。延迟调用就是在程序启动时不自动加载函数所在的链接库,而是等到需要使用的时候再加载,也就是说在调用前,程序是没有这个函数的,所以 Detours 挂钩会失效,这时候我们考虑到使用 IAT HOOK 和 Delay IAT Hook 挂钩模块导入表和延迟加载导入表。
下面的代码实现了 IAT Hook ExitWindowsEx:
#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers
// Windows Header Files
#include
#include
#include
#include
#include
#include
#include
#pragma comment(lib, "Advapi32.lib")
#pragma comment(lib, "Shlwapi.lib")
#pragma warning(disable:4996)
LPVOID _copyNtShutdownSystem = NULL;
LPVOID _ExitWindowsExAddTwoByte = NULL;
typedef BOOL(WINAPI* FuncExitWindowsEx)(_In_ UINT uFlags, _In_ DWORD dwReason);
FuncExitWindowsEx _OldExitWindowsEx = NULL;
BOOL WINAPI IATHookedFun(_In_ UINT uFlags, _In_ DWORD dwReason) {
BOOL bRet = FALSE;
static BOOL bNeedWarning = FALSE;
if (bNeedWarning) {
MessageBoxW(NULL, _TEXT("弹框提示"), _TEXT("提示"), MB_ICONINFORMATION | MB_OK);
}
// 调用原函数
bRet = _OldExitWindowsEx(uFlags, dwReason);
if (bRet) {
bNeedWarning = TRUE;
}
return bRet;
//return FALSE;
}
BYTE* getNtHdrs(BYTE* pe_buffer) {
if (pe_buffer == NULL) return NULL;
// 将 PE 缓冲区转换为 DOS 头结构
IMAGE_DOS_HEADER* idh = (IMAGE_DOS_HEADER*)pe_buffer;
// 验证 DOS 头的签名,以确保它是有效的 PE 文件
if (idh->e_magic != IMAGE_DOS_SIGNATURE) {
return NULL;
}
// 定义 PE 头允许的最大偏移量
const LONG kMaxOffset = 1024;
// 获取从 DOS 标头到 PE 标头的偏移量
LONG pe_offset = idh->e_lfanew;
// 验证到PE头的偏移量是否在允许的范围内
if (pe_offset > kMaxOffset) return NULL;
// 将偏移后的缓冲区地址转换为指向 PE 头结构体的指针
IMAGE_NT_HEADERS32* inh = (IMAGE_NT_HEADERS32*)((BYTE*)pe_buffer + pe_offset);
if (inh->Signature != IMAGE_NT_SIGNATURE) return NULL;
return (BYTE*)inh;
}
IMAGE_DATA_DIRECTORY* getPeDir(PVOID pe_buffer, size_t dir_id) {
if (dir_id >= IMAGE_NUMBEROF_DIRECTORY_ENTRIES) return NULL;
// 从 PE 缓冲区获取 NT 头结构体的指针
BYTE* nt_headers = getNtHdrs((BYTE*)pe_buffer);
// 验证是否可以获得 NT 头
if (nt_headers == NULL) return NULL;
// 指向 PE 文件数据目录的指针
IMAGE_DATA_DIRECTORY* peDir = NULL;
// 将 NT 头转换为适当的结构体指针
IMAGE_NT_HEADERS* nt_header = (IMAGE_NT_HEADERS*)nt_headers;
// 获取具有指定 ID 的数据表地址
peDir = &(nt_header->OptionalHeader.DataDirectory[dir_id]);
if (peDir->VirtualAddress == NULL) {
return NULL;
}
return peDir;
}
bool FixDelayIATHook(PVOID modulePtr) {
// 获取模块导入表的地址
IMAGE_DATA_DIRECTORY* importsDir = getPeDir(modulePtr, IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT);
if (importsDir == nullptr)
return false;
size_t maxSize = importsDir->Size; // 导入表大小
size_t impAddr = importsDir->VirtualAddress; // 导入表首地址
// 从 User32.dll 库获取 ExitWindowSex 函数的地址
size_t addrExitWindowsEx = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"User32"), "ExitWindowsEx"));
// 迭代延迟导入描述符
for (size_t parsedSize = 0; parsedSize < maxSize; parsedSize += sizeof(IMAGE_DELAYLOAD_DESCRIPTOR)) {
IMAGE_DELAYLOAD_DESCRIPTOR* lib_desc = reinterpret_cast
(impAddr + parsedSize + reinterpret_cast(modulePtr));
// 检查延迟导入描述符是否为空
if (lib_desc->ImportAddressTableRVA == 0 && lib_desc->ImportNameTableRVA == 0)
break;
// 获取链接库名称
LPSTR lib_name = reinterpret_cast(reinterpret_cast(modulePtr) + lib_desc->DllNameRVA);
size_t call_via = lib_desc->ImportAddressTableRVA;
size_t thunk_addr = lib_desc->ImportNameTableRVA;
// 如果名称表的偏移量为0,使用地址表
if (thunk_addr == 0)
thunk_addr = lib_desc->ImportAddressTableRVA;
// 迭代导入表中的字段
for (size_t offsetField = 0, offsetThunk = 0;; offsetField += sizeof(IMAGE_THUNK_DATA), offsetThunk += sizeof(IMAGE_THUNK_DATA)) {
IMAGE_THUNK_DATA* fieldThunk = reinterpret_cast(reinterpret_cast(modulePtr) + offsetField + call_via);
IMAGE_THUNK_DATA* orginThunk = reinterpret_cast(reinterpret_cast(modulePtr) + offsetThunk + thunk_addr);
// 检查两个字段是否都为空以退出循环
if (fieldThunk->u1.Function == 0 && orginThunk->u1.Function == 0)
break;
// 检查是否使用序号来获取函数的地址
if (orginThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG32 || orginThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG64) {
// 函数的地址也可以通过获取序号的后两个字节来获得
size_t addrOld = reinterpret_cast(GetProcAddress(LoadLibraryA(lib_name),
reinterpret_cast(orginThunk->u1.Ordinal & 0xFFFF)));
continue;
}
else { // 使用函数名获取函数地址
PIMAGE_IMPORT_BY_NAME by_name = reinterpret_cast(
reinterpret_cast(modulePtr) + orginThunk->u1.AddressOfData);
LPSTR func_name = reinterpret_cast(by_name->Name);
size_t addrOld = reinterpret_cast(GetProcAddress(LoadLibraryA(lib_name), func_name));
// 如果函数是“ExitWindowSex”,则执行钩子并解除内存保护
if (_stricmp(func_name, "ExitWindowsEx") == 0) {
DWORD dOldProtect = 0;
size_t* pFuncAddr = reinterpret_cast(&fieldThunk->u1.Function);
if (VirtualProtect(pFuncAddr, sizeof(size_t), PAGE_EXECUTE_READWRITE, &dOldProtect)) {
fieldThunk->u1.Function = reinterpret_cast(IATHookedFun); // 钩子函数
VirtualProtect(pFuncAddr, sizeof(size_t), dOldProtect, &dOldProtect);
_OldExitWindowsEx = reinterpret_cast(addrExitWindowsEx); // 存储原始函数地址
return true;
}
break;
}
}
}
}
return true;
}
bool FixIATHook(PVOID modulePtr) {
// 获取模块导入表的地址
IMAGE_DATA_DIRECTORY* importsDir = getPeDir(modulePtr, IMAGE_DIRECTORY_ENTRY_IMPORT);
if (importsDir == NULL)
return false;
// 获取导入表的大小和虚拟地址
size_t maxSize = importsDir->Size;
size_t impAddr = importsDir->VirtualAddress;
// 从 User32 库获取“ExitWindowSex”函数的地址
size_t addrExitWindowsEx = (size_t)GetProcAddress(GetModuleHandleW(L"User32.dll"), "ExitWindowsEx");
// 迭代导入表中的导入库的描述符
for (size_t parsedSize = 0; parsedSize < maxSize; parsedSize += sizeof(IMAGE_IMPORT_DESCRIPTOR)) {
// 获取当前导入库的描述符
IMAGE_IMPORT_DESCRIPTOR* lib_desc = (IMAGE_IMPORT_DESCRIPTOR*)(impAddr + parsedSize + (ULONG_PTR)modulePtr);
if (lib_desc->OriginalFirstThunk == NULL && lib_desc->FirstThunk == NULL)
break;
// 获取导入库的名称
LPSTR lib_name = (LPSTR)((size_t)modulePtr + lib_desc->Name);
// 获取 thunks 的调用地址和函数指针
size_t call_via = lib_desc->FirstThunk;
size_t thunk_addr = lib_desc->OriginalFirstThunk;
if (thunk_addr == NULL)
thunk_addr = lib_desc->FirstThunk;
// 迭代原始 thunk 和 thunk 字段
for (size_t offsetField = 0, offsetThunk = 0;; offsetField += sizeof(IMAGE_THUNK_DATA), offsetThunk += sizeof(IMAGE_THUNK_DATA)) {
// 获得当前 thunks
IMAGE_THUNK_DATA* fieldThunk = (IMAGE_THUNK_DATA*)(size_t(modulePtr) + offsetField + call_via);
IMAGE_THUNK_DATA* orginThunk = (IMAGE_THUNK_DATA*)(size_t(modulePtr) + offsetThunk + thunk_addr);
// 验证是否已到达 thunks 的结尾
if (fieldThunk->u1.Function == 0 && orginThunk->u1.Function == 0)
break;
PIMAGE_IMPORT_BY_NAME by_name = nullptr;
LPSTR func_name = nullptr;
size_t addrOld = NULL;
// 验证是否按顺序或名称导入函数
if (orginThunk->u1.Ordinal & (IMAGE_ORDINAL_FLAG32 | IMAGE_ORDINAL_FLAG64)) {
// 按序号导入
addrOld = (size_t)GetProcAddress(LoadLibraryA(lib_name), (char*)(orginThunk->u1.Ordinal & 0xFFFF));
continue;
}
else {
// 按名称导入
by_name = (PIMAGE_IMPORT_BY_NAME)(size_t(modulePtr) + orginThunk->u1.AddressOfData);
func_name = (LPSTR)by_name->Name;
addrOld = (size_t)GetProcAddress(LoadLibraryA(lib_name), func_name);
}
// HOOK
if (strcmpi(func_name, "ExitWindowsEx") == 0) {
// 更改为指向钩子函数的地址
DWORD dOldProtect = 0;
size_t* pFuncAddr = (size_t*)&fieldThunk->u1.Function;
if (VirtualProtect(pFuncAddr, sizeof(size_t), PAGE_EXECUTE_READWRITE, &dOldProtect)) {
fieldThunk->u1.Function = (size_t)IATHookedFun; // 钩子函数
VirtualProtect(pFuncAddr, sizeof(size_t), dOldProtect, &dOldProtect);
_OldExitWindowsEx = (FuncExitWindowsEx)addrExitWindowsEx; // 存储原始函数地址
return true;
}
}
}
}
return true;
}
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH: {
HMODULE exeModule = GetModuleHandleW(NULL);
// 调用挂钩过程
FixIATHook(exeModule);
FixDelayIATHook(exeModule);
break;
}
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
然而,结果是并不能够真正拦截关机,因为在 Win 10/11 调用 ExitWindowsEx 时,已经进入了关机的中间阶段,此时你会看到正在关机的全屏界面(这是 LogonUI.exe 的界面)。所以,挂钩这个函数只能实现在关机页面短暂地弹出一个对话框,并随着关机到最后一步 Winlogon 的退出而消失。
在 Win 8/8.1 上,挂钩 ExitWindowsEx 会导致卡在“请稍后”页面(也是 LogonUI.exe)拉起的,而且使用 Alt+F4 等一些关机方式不会调用 ExitWindowsEx ,而是调用 InitiateShutdownW 。
所以我们需要把这个函数也挂钩起来即可,但需要有一个技巧,要把 LogonUI.exe 也杀死。下面是用于杀死进程的代码:
// 通过调用外部程序实现终止进程,比如taskkill.exe
inline void KillLogonUIProcess()
{
WCHAR lpExePath[] = L"cmd.exe /c taskkill /F /IM LogonUI.exe";
/* 根据进程名获取任意进程Id */
DWORD pid = 512;
HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
/* 创建启动信息结构体 */
STARTUPINFOEXW si;
/* 初始化结构体 */
ZeroMemory(&si, sizeof(si));
/* 设置结构体成员 */
si.StartupInfo.cb = sizeof(si);
SIZE_T lpsize = 0;
/* 用微软规定的特定的函数初始化结构体 */
InitializeProcThreadAttributeList(NULL, 1, 0, &lpsize);
/* 转换指针到正确类型 */
char* temp = new char[lpsize];
LPPROC_THREAD_ATTRIBUTE_LIST AttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)temp;
/* 真正为结构体初始化属性参数 */
InitializeProcThreadAttributeList(AttributeList, 1, 0, &lpsize);
/* 用已构造的属性结构体更新属性表 */
if (!UpdateProcThreadAttribute(AttributeList, 0, PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, &handle, sizeof(HANDLE), NULL, NULL)) {
return;
}
si.lpAttributeList = AttributeList;
PROCESS_INFORMATION pi;
ZeroMemory(&pi, sizeof(pi));
((__CreateProcessAsUserW)fpCreateProcessAsUserW)(
NULL, 0, lpExePath, 0, 0, 0,
EXTENDED_STARTUPINFO_PRESENT,
0, 0, (LPSTARTUPINFOW)&si, &pi);
DeleteProcThreadAttributeList(AttributeList);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
delete[] temp;
}
那我们如何知道 LogonUI.exe 启动了呢?别急,我之前在 R3 下挂钩 AppInfo Service 的进程创建(用于创建管理员进程,他会拉起 consent.exe 进程)一文里面说到系统进程创建子进程用的是 CreateProcessAsUserW 函数,我们只要把这个函数顺便也挂钩起来即可(而且这里调用不会并发,不会出现问题):
// 全局变量用于保存进程句柄
HANDLE lpProcessHandle = NULL;
// 挂钩 CreateProcessAsUserW 监控进程创建
BOOL WINAPI HookedCreateProcessAsUserW(
_In_opt_ HANDLE hToken,
_In_opt_ LPCWSTR lpApplicationName,
_Inout_opt_ LPWSTR lpCommandLine,
_In_opt_ LPSECURITY_ATTRIBUTES lpProcessAttributes,
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ BOOL bInheritHandles,
_In_ DWORD dwCreationFlags,
_In_opt_ LPVOID lpEnvironment,
_In_opt_ LPCWSTR lpCurrentDirectory,
_In_ LPSTARTUPINFOW lpStartupInfo,
_Out_ LPPROCESS_INFORMATION lpProcessInformation
)
{
BOOL ret = FALSE;
// 先调用原函数
ret = ((__CreateProcessAsUserW)fpCreateProcessAsUserW)
(hToken, lpApplicationName, lpCommandLine, lpProcessAttributes,
lpThreadAttributes, bInheritHandles, dwCreationFlags,
lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation);
if(lpCommandLine != nullptr)
if (wcsstr(lpCommandLine, L"LogonUI.exe"))
lpProcessHandle = lpProcessInformation->hProcess;// 保存进程句柄
return ret;// 返回函数
}
然后,在 HOOK InitiateShutdownW 时,只需要顺便关闭进程即可:
DWORD WINAPI HookedInitiateShutdownW(
_In_opt_ LPWSTR lpMachineName,
_In_opt_ LPWSTR lpMessage,
_In_ DWORD dwGracePeriod,
_In_ DWORD dwShutdownFlags,
_In_ DWORD dwReason
)
{
WCHAR lpMsg[] = L"正在取消关闭/注销/重启计算机的任务计划,请稍后......";
WCHAR lpCap[] = L"Windows LogonManager";
DWORD Result = 0;
BOOL IsKilled = FALSE;
// 发出阻滞对话框
SvcMessageBox(lpCap, lpMsg, MB_OK | MB_APPLMODAL | MB_ICONINFORMATION, TRUE, Result);
// 尝试结束进程
if(lpProcessHandle != INVALID_HANDLE_VALUE)
IsKilled = TerminateProcess(lpProcessHandle, 0);
if(!IsKilled)
{
KillLogonUIProcess();// 后手,如果前面失败就这个
}
return ERROR_INVALID_PARAMETER;
}
强制关机/重启时,winlogon.exe 不会立即处理,而是由 wininit.exe 进程首先进行处理。wininit.exe 进程通过 NtShutdownSystem 函数部署关机/重启例程,我们只需要再注入 WinInit 过程并挂钩该函数即可。
NtShutdownSystem 函数是无文档函数,它的定义如下:
typedef enum _SHUTDOWN_ACTION
{
ShutdownNoReboot,
ShutdownReboot,
ShutdownPowerOff
} SHUTDOWN_ACTION, * PSHUTDOWN_ACTION;
typedef NTSTATUS (NTAPI* __NtShutdownSystem)(
SHUTDOWN_ACTION dwAction
);
我们只需要在钩子函数中,返回 STATUS_INVALID_PARAMETER 而不做任何操作即可。
NTSTATUS NTAPI HookedNtShutdownSystem(
SHUTDOWN_ACTION dwAction
)
{
return STATUS_INVALID_PARAMETER;
}
想要在 Win10 上注入 Wininit 进程不是一件容易的事情。
Windows 从 Vista 版本引入一种进程保护机制 (Process Protection),用于更进一步的控制进程的访问级别,在此之前,用户只需要使用 SeDebugPrivilege
令牌权限即可获取任意进程的所有访问权限;随后 Windows 8.1 在此进程保护的基础上,扩展引入了进程保护-轻量级机制 (Protected Process Light),简称 PPL
机制,其能提供更加细粒度化的进程访问权限控制。
关于进程保护机制的更为详细的介绍和研究可以看 itm4n 大佬的系列文章:
1.Do You Really Know About LSA Protection (RunAsPPL)?---- Posted Apr 7, 2021;
2.Bypassing LSA Protection in Userland ---- Posted Apr 22, 2021;
3.The End of PPLdump ---- Posted Jul 24, 2022;
4.Debugging Protected Processes ---- Posted Dec 4, 2022;
5.Bypassing PPL in Userland (again) ---- Posted Mar 17, 2023 Updated Jul 24, 2023.
文章最早从 LSA (lsass)进程的保护机制开始谈起,最后衍生到 Red Cursor 团队开发的 PPLKiller的技术分析,具体的限于篇幅和权限,这里就不再详细叙述。
你可以在这里获取到项目:PPLKiller(Red Cursor) ,另外 itm4n 还开发了扩展工具,可以对 PP/PPL 进行任意级别的升/降级,在 Github 就可以获取:PPLcontrol( itm4n ),下面是这个工具的演示。
C:\Temp>PPLcontrol.exe list
PID | Level | Signer
-------+---------+----------------
4 | PP (2) | WinSystem (7)
108 | PP (2) | WinSystem (7)
392 | PPL (1) | WinTcb (6)
520 | PPL (1) | WinTcb (6)
600 | PPL (1) | WinTcb (6)
608 | PPL (1) | WinTcb (6)
756 | PPL (1) | WinTcb (6)
2092 | PP (2) | WinSystem (7)
3680 | PPL (1) | Antimalware (3)
5840 | PPL (1) | Antimalware (3)
7264 | PPL (1) | Windows (5)
9508 | PP (2) | WinTcb (6)
1744 | PPL (1) | Windows (5)
[+] Enumerated 13 protected processes.
首先添加相同级别的进程保护之前,附加调试进程失败:
然后,取消保护或者添加相同级别的保护,成功附加调试进程:
综上,我们在 Win 10/ 11 上,只有取消进程保护,才可以注入 wininit.exe 进程。Win 10 上可以使用利用 CVE-2019-16098 (签名漏洞移除内核回调),也就是这里 PPLKiller(Red Cursor) 版本,Win 11 上目前只能使用传统的 PPLKiller (Mattiwatti) 利用测试模式加载驱动程序在内核中取消回调,这种方法是相对复杂的,尤其是由 UEFI 安全模式启动的计算机,需要关闭 BIOS 中的安全启动,然后才能开启测试模式。
注意:PPLKiller(Red Cursor) 没有验证 DeviceIoControl 获得的地址是否为 NULL,在 Win 11 上运行会导致触发控制流防护(CFG),而蓝屏。
以上的挂钩方法挂钩的函数较多,并且在 Win 10 / 11 上效果不是太好,Win 10 需要通过微星驱动程序的 I/O Read/Write 漏洞来首先 Bypass PP/PPL 保护,Win 11 由于修复了 DeviceIoControl 这个漏洞滥用 API,我们则必须使用测试模式加载驱动来干掉内核的 PPL 回调。微软推出了 PPL(Protected Process Light) 机制后,向 lsass、wininit 等进程注入代码变得极为困难。此外,过滤系统热键需要通过底层钩子来实现,有些热键依然无法捕获到。这使得我们之前的工作变得荒废。我迫切地需要一个方法来实现更高效的拦截,通过一段时间的研究,我找到了解决问题的一个切入点—— 那就是 RPC 调用,在 Win 10/ Win 11 上,甚至在之前的系统上,RPC 调用才是真正在关机时多进程调度的根源。
本文被拆解为上中下三部分,这是系列的第二部分。
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_59075481/article/details/135907307
文章发布于:2024.01.29,文章更新于:2024.01.29