借助计划任务执行需要管理员权限运行的程序

在多次挣扎之后,我们的新项目最终决定放弃在watchdog上缝缝补补,在Windows平台上改回成用USN日志的方式监控文件变动。随之带来一个问题:拥有管理员权限的进程才能读取USN日志。
旧项目的方案是额外注册一个系统服务,读取usn日志,并通过管道与主进程通讯。但上线之后发现,系统服务很容易被各种电脑管家误删除,导致程序工作异常。新项目打算在主进程中直接读取usn日志。这就要求使用管理员权限运行主进程。而我们又不想让用户每次启动程序时都弹出一个UAC提示框。最后决定借助计划任务来实现我们的需求。

原理

运行等级(Principal.RunLevel)为TASK_RUNLEVEL_HIGHEST的计划任务,在执行时,被调用的进程会以管理员权限运行。
这种计划任务仅在添加时需要管理员权限。在被触发时,不需要管理员权限,也就不会弹出UAC提示。

实现

创建计划任务

通过com组件ITaskService添加计划任务
重点是把Principal.RunLevel设为TASK_RUNLEVEL_HIGHEST
下列代码展示了如何用Python创建最高权限的计划任务。
注意:运行下列代码时需要管理员权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import sys
import os

import win32com.client

NORMAL_PRIORITY_CLASS = 4
TASK_ACTION_EXEC = 0
TASK_CREATE_OR_UPDATE = 6
TASK_INSTANCES_PARALLEL = 0
TASK_LOGON_INTERACTIVE_TOKEN = 3
TASK_RUNLEVEL_HIGHEST = 1

schedule_service = win32com.client.Dispatch('Schedule.Service')
schedule_service.Connect()

task_definition = schedule_service.NewTask(0)
task_definition.Principal.LogonType = TASK_LOGON_INTERACTIVE_TOKEN
task_definition.Principal.RunLevel = TASK_RUNLEVEL_HIGHEST
task_definition.RegistrationInfo.Author = 'Daniel'
task_definition.Settings.DisallowStartIfOnBatteries = False
task_definition.Settings.DisallowStartOnRemoteAppSession = False
task_definition.Settings.Enabled = True
task_definition.Settings.ExecutionTimeLimit = 'PT0S'
task_definition.Settings.MultipleInstances = TASK_INSTANCES_PARALLEL
task_definition.Settings.Priority = NORMAL_PRIORITY_CLASS
task_definition.Settings.StopIfGoingOnBatteries = False
task_definition.Settings.UseUnifiedSchedulingEngine = True

exec_action = task_definition.Actions.Create(TASK_ACTION_EXEC)
exec_action.Path = sys.executable
exec_action.WorkingDirectory = os.getcwd()
exec_action.Arguments = '$(Arg0)' # $(Arg0)会在任务执行时被替换

task_name = f'{os.path.splitext(os.path.basename(sys.executable))[0]}_skip_uac'
root_folder = schedule_service.GetFolder('\\')
root_folder.RegisterTaskDefinition(
task_name,
task_definition,
TASK_CREATE_OR_UPDATE,
None,
None,
TASK_LOGON_INTERACTIVE_TOKEN,
)

执行计划任务

同样的,通过com组件ITaskService启动计划任务。此时就不需要管理员权限了。

1
2
3
exist_task = root_folder.GetTask(f'{task_name}')  # 如果任务不存在,此处会抛出异常
start_args = ['banana', 'kuma~']
exist_task.Run(' '.join(f'"{arg}"' for arg in start_args)) # 参数会在计划任务启动时作为argv传入

最终流程

启动时检查当前进程是否拥有管理员权限

  • 如果没有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    • 如果没有,则使用ShellExecute/ShellExecuteEx的方式,启动带管理员权限的进程(会弹出UAC对话框),并退出当前进程。
    • 如果有,则启动对应的计划任务,并退出当前进程
  • 如果有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    如果不存在,则需要创建/修复计划任务。因为此时程序已经拥有管理员权限,可以直接创建对应的计划任务

其他

方案还需要实现一些辅助的功能,这边也一并列出对应的Python实现

检查当前进程是否有管理员权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def check_privileges() -> bool
class SID_IDENTIFIER_AUTHORITY(ctypes.Structure):
_fields_ = [
("byte0", ctypes.c_byte),
("byte1", ctypes.c_byte),
("byte2", ctypes.c_byte),
("byte3", ctypes.c_byte),
("byte4", ctypes.c_byte),
("byte5", ctypes.c_byte),
]

nt_authority = SID_IDENTIFIER_AUTHORITY()
nt_authority.byte5 = SECURITY_NT_AUTHORITY

administrators_group = ctypes.c_void_p()
if ctypes.windll.advapi32.AllocateAndInitializeSid(
ctypes.byref(nt_authority),
2,
SECURITY_BUILTIN_DOMAIN_RID,
DOMAIN_ALIAS_RID_ADMINS,
0,
0,
0,
0,
0,
0,
ctypes.byref(administrators_group),
):
is_admin = BOOL()
if ctypes.windll.advapi32.CheckTokenMembership(
None,
administrators_group,
ctypes.byref(is_admin),
):
result = is_admin.value != 0
else:
# CheckTokenMembership错误,视为无管理员权限
result = False
ctypes.windll.advapi32.FreeSid(administrators_group)
else:
# AllocateAndInitializeSid错误,视为无管理员权限
result = False
return result

使用ShellExecuteEx启动带管理员权限的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
start_args = ['banana', 'kuma~']
ShellExecuteEx(
# 进程启动后的表现
nShow=SW_HIDE,
# 以管理员权限启动
lpVerb='runas',
# 可执行程序的路径
lpFile=sys.executable,
# 传入的参数,注意所有的参数都需要以双引号包起来
lpParameters=' '.join(f'"{arg}"' for arg in start_args),
# 工作目录
lpDirectory=os.getcwd(),
)