深入探讨任务计划程序的服务账户使用技巧

本文详细分析了Windows任务计划程序中服务账户的使用机制,探讨了如何利用服务SID和任务调度功能实现权限提升的技术细节,并揭示了虚拟服务账户与LOCAL SERVICE/NETWORK SERVICE在令牌生成上的关键差异。

最近我在研究一个以完整虚拟服务账户(而非LOCAL SERVICE或NETWORK SERVICE)运行的服务,但该服务移除了SeImpersonatePrivilege权限。在寻找解决方案时,我回忆起Andrea Pierini曾发表过关于使用虚拟服务账户的博客,于是决定从中寻找灵感。有趣的是,他提到Clément Labro发现的滥用任务计划程序的技术(适用于LS或NS账户)在虚拟服务账户上无效。出于好奇,我决定深入研究这个问题,并在此过程中发现了一个可用于其他目的的隐蔽技术。

我已经写过关于任务计划程序使用服务账户的博客。特别是在之前的文章中,我讨论了如何通过使用服务SID运行计划任务来获取TrustedInstaller组权限。由于服务SID与虚拟服务账户使用的名称相同,问题显然出在该功能的实现方式上,并且很可能与LS或NS令牌的创建方式不同。

Windows 10中任务计划程序的核心进程创建代码实际上位于统一后台进程管理器(UBPM)DLL中,而不是任务计划程序本身。快速查看该DLL会发现以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
HANDLE UbpmpTokenGetNonInteractiveToken(PSID PrincipalSid) {
  // ...
  if (UbpmUtilsIsServiceSid(PrinicpalSid)) {
    return UbpmpTokenGetServiceAccountToken(PrinicpalSid);
  }
  if (EqualSid(PrinicpalSid, kNetworkService)) {
    Domain = L"NT AUTHORITY";
    User = L"NetworkService";
  } else if (EqualSid(PrinicpalSid, kLocalService)) {
    Domain = L"NT AUTHORITY";
    User = L"LocalService";
  }
  HANDLE Token;
  if (LogonUserExExW(User, Domain, Password,
     LOGON32_LOGON_SERVICE,
     LOGON32_PROVIDER_DEFAULT, &Token)) {
    return Token;
  }
  // ...
}

这个UbpmpTokenGetNonInteractiveToken函数从任务注册或传递给RunEx的主体SID中确定其代表的内容以获取令牌。它检查SID是否是服务SID(即我们在之前博客文章中使用的NT SERVICE\NAME SID)。如果是,则调用单独的函数UbpmpTokenGetServiceAccountToken来获取服务令牌。

否则,如果SID是NS或LS,则指定这些SID的已知名称并使用LOGON32_LOGON_SERVICE类型调用LogonUserExExUbpmpTokenGetServiceAccountToken函数执行以下操作:

1
2
3
4
5
6
7
8
TOKEN UbpmpTokenGetServiceAccountToken(PSID PrincipalSid) {
  LPCWSTR Name = UbpmUtilsGetAccountNamesFromSid(PrincipalSid);
  SC_HANDLE scm = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT);
  SC_HANDLE service = OpenService(scm, Name, SERVICE_ALL_ACCESS);
  HANDLE Token;
  GetServiceProcessToken(g_ScheduleServiceHandle, service, &Token);
  return Token;
}

此函数从服务SID中获取名称(即服务本身的名称),并为其打开所有访问权限(SERVICE_ALL_ACCESS)。如果成功,则将服务句柄传递给未记录的SCM API GetServiceProcessToken,该API返回服务的令牌。查看SCM中的实现,基本上使用与启动服务时创建令牌完全相同的代码。

这就是为什么在使用Clément的技术时,LS/NS与虚拟服务账户之间存在区别。如果使用LS/NS,任务计划程序会从LSA获取一个新令牌,而不考虑服务的配置方式。因此,新令牌具有SeImpersonatePrivilege(或其他允许的权限)。然而,对于虚拟服务账户,服务会向SCM请求服务令牌,由于SCM知道已设置的限制,它会遵循诸如权限或SID类型等内容。因此,返回的令牌将再次被剥离SeImpersonatePrivilege,尽管从技术上讲它与当前运行的服务是不同的令牌。

为什么任务计划程序需要一些未记录的函数来获取服务令牌?正如我在之前关于虚拟账户的博客文章中提到的那样,只有SCM(严格来说是第一个声明自己是SCM的进程)被允许使用虚拟服务账户验证令牌。在我看来这有点毫无意义,因为你已经需要SeTcbPrivilege来创建服务令牌,但事实就是如此。

好的,现在我们知道了为什么Clément的技术无法恢复任何权限。你现在可能会问,那又怎样?在研究任务计划程序如何确定是否允许将服务SID指定为主体时,我发现了一个有趣的行为。在我关于创建以TrustedInstaller身份运行的任务的博客文章中,我暗示需要管理员访问权限,这在一定程度上是正确的,但也不完全正确。让我们看看任务计划程序用来确定调用者是否被允许以指定主体身份运行任务的函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BOOL IsPrincipalAllowed(User& principal) {
  RpcAutoImpersonate::RpcAutoImpersonate();
  User caller;
  User::FromImpersonationToken(&caller);
  RpcRevertToSelf();
  if (tsched::IsUserAdmin(caller) ||
       caller.IsLocalSystem(caller)) {
    return TRUE;
  }    
  if (principal == caller) {
    return TRUE;
  }
  if (principal.IsServiceSid()) {
    LPCWSTR Name = principal.GetAccount();
    RpcAutoImpersonate::RpcAutoImpersonate();
    SC_HANDLE scm = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT);
    SC_HANDLE service = OpenService(scm, Name, SERVICE_ALL_ACCESS);
    RpcRevertToSelf();
    if (service) {
      return TRUE;
    }
  }
  return FALSE;
}

IsPrincipalAllowed函数首先检查调用者是否是管理员或SYSTEM。如果是,则允许任何主体(同样不完全正确,但足够好)。接下来检查主体的用户SID是否与我们设置的SID匹配。这将允许NS/LS或虚拟服务账户指定以自己用户账户身份运行的任务。

最后,如果主体是服务SID,则尝试在模拟调用者的同时为服务打开完全访问权限。如果成功,则允许使用服务SID作为主体。这种行为很有趣,因为它为滥用配置不当的服务提供了一种隐蔽的方式。

检查权限提升的一个众所周知的方法是枚举所有本地服务,并查看是否有任何服务授予普通用户特权访问权限(主要是SERVICE_CHANGE_CONFIG)。这足以劫持服务并让任意代码以服务账户身份运行。一个常见的技巧是更改可执行路径并重新启动服务,但这有几个不好的原因:

  1. 更改可执行路径很容易被发现。
  2. 你可能希望之后将路径修复回来,这很麻烦。
  3. 如果服务当前正在运行,你需要停止服务,然后重新启动修改后的服务以获得代码执行。

但是,只要你的账户被授予对服务的完全访问权限,你就可以使用任务计划程序(即使不是管理员)以服务用户账户(如SYSTEM)身份运行代码,而无需直接修改服务的配置或停止/启动服务。这更加隐蔽。当然,这意味着任务运行的令牌可能会被剥离权限等,但这很容易处理(只要它不是写限制的)。

这是一个很好的教训,告诉我们永远不要只看表面价值。我假设调用者需要管理员权限才能将服务账户设置为任务的主体。但深入研究代码后,似乎实际上并不需要。希望有人会觉得这有用。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计