首页
社区
课程
招聘
[原创]Windows远端线程执行任意API的设计与实现
2022-7-7 23:08 7547

[原创]Windows远端线程执行任意API的设计与实现

2022-7-7 23:08
7547

一、摘要

在本进程空间内我们可以做很多事,毕竟是自己的地儿。比如调用SetProcessDPIAware设置一下自己进程的DPI模式,调用GetWindowLongPtr(hWnd, GWLP_WNDPROC)获取本进程所创建窗口的窗口过程等等,但如果我们想操作其它进程就难了。虽然可以注入DLL到目标进程空间进行操作,然后再把结果反馈回来,但这样的方式比较复杂:一是涉及DLL和注入,二是涉及进程间通信。本文给出基于远端线程注入(CreateRemoteThread)的方式,实现在目标进程中执行任意API,并附源码及成品lib可供调用。


二、遇到问题的场景

我开发AlleyWind(一个窗口管理工具)时想实现一个功能,可以使目标(任意)顶层窗口防捕获(截屏)。Win7开始为DRM(Digital Rights Management,数字版权管理)提供了SetWindowDisplayAffinity函数。通过调用该函数,可使指定的窗口无法被截屏,以保护窗口显示内容不被随意外泄(当然也可以拿来Anti一些监控软件的截屏,再也不用担心划水摸鱼被截屏监控了?)。

SetWindowDisplayAffinity也很简单,一参窗口句柄,二参显示掩码,指定为WDA_MONITOR或WDA_EXCLUDEFROMCAPTURE它就再也不会被截屏了:

BOOL SetWindowDisplayAffinity(
  [in] HWND  hWnd,
  [in] DWORD dwAffinity
);

看上去水到渠成,可是正如MSDN文档里说的,hWnd指定的窗口必须属于本进程。如果窗口是人家的,咱们调用这个函数就不顶用了。

如果我们能让目标进程调用它,问题就能迎刃而解。前面说了,如果注入DLL到目标进程中来执行它,会涉及DLL注入和进程间通信。如果我们使用CreateRemoteThread,能否直接让远端线程执行这个函数呢?理论上,行得通,接下来看看该如何设计方案。


三、方案设计

我们拿SetWindowDisplayAffinity为例,当然最终的方案要可以推广到任意API上。


这里先简单介绍本方案用到的一些其它技术能力,尤其是ShellCode相关。不具体展开描述,后面直接利用。


【C语言编写ShellCode】

比起使用汇编编写ShellCode,用C编写有不少优势:

>编写复杂的ShellCode更加容易

>一份C源码,即可同时编译出运行于x86、x64,甚至ARM等不同目标平台的ShellCode

>配合Precomp4C将不同平台的ShellCode放到源文件里,x64进程向x86进程注入x86 ShellCode也非常方便

实现方案(Precomp4C)如下图所示:



【在目标进程中调用ShellCode】

ShellCode有了,我们将ShellCode写入目标进程,执行完毕后我们也能读取目标进程,将ShellCode返回的内容读取回来,善始善终。

实现方案NTAssassin!Hijack_ExecShellcode()函数原型如下:

/// <summary>
/// Injects shellcode and starts new thread to execute in remote process
/// </summary>
/// <param name="ProcessHandle">Handle to the process</param>
/// <param name="ShellCode">Pointer to the shellcode</param>
/// <param name="ShellCodeSize">Size of shellcode in bytes</param>
/// <param name="Param">User defined parameter passed to the remote thread</param>
/// <param name="ParamSize">Size of Param in bytes</param>
/// <param name="ExitCode">Pointer to variable to receive remote thread exit code</param>
/// <param name="Timeout">Timeout in milliseconds</param>
/// <returns>TRUE if succeeded, or FALSE if failed, error code storaged in last STATUS</returns>
/// <remarks>
/// HIJACK_PROCESS_ACCESS access is required
/// if Timeout is 0, ExitCode always returns STILL_ACTIVE
/// </remarks>
_Success_(return != FALSE) NTA_API BOOL NTAPI Hijack_ExecShellcode(_In_ HANDLE ProcessHandle, _In_reads_bytes_(ShellCodeSize) PVOID ShellCode, SIZE_T ShellCodeSize, _In_reads_bytes_opt_(ParamSize) PVOID Param, SIZE_T ParamSize, _Out_opt_ PDWORD ExitCode, DWORD Timeout);

ProcessHandle:目标进程句柄

ShellCode:指向要注入并执行的ShellCode二进制字节码

ShellCodeSize:ShellCode大小,计以字节

Param:传递给ShellCode的参数,指向本进程的一片内存区域,内存区域会映射到目标进程,并作为远端线程(LPTHREAD_START_ROUTINE)的入参。远端线程执行完ShellCode后还会将这片内存区域写回本进程。既是入参也是出参,实现和ShellCode交互,这里的SAL批注“_In_reads_bytes_opt_(ParamSize)”有误,后续会修正

ParamSize:Param大小,计以字节

ExitCode:可选,接收远端线程的退出码

Timeout:等待远端线程的超时,计以毫秒。可选,若为0则不等待,INFINITE无限等待。如果为0的同时传入了ExitCode,则固定返回退出码为STILL_ACTIVE

一些内存操作、线程函数即可实现,并不复杂。重点是实现与ShellCode交互,通过内存读写和线程等待,实现既能给ShellCode提供花式入参,也能接收ShellCode的花式出参,也就是Param参数指向的那片内存区域写过去等ShellCode执行完再读回来。


实际应用中还得考虑一下特殊情况:

>如果ShellCode执行很慢直到超时,此时不能释放目标进程的内存,否则会导致崩溃

>如果目标为被系统挂起的UWP,那么创建的远端线程也会被挂起,并且无法唤醒


好了,有以上两个技术储备,我们接下来着手实现目标。


1. 如何获取函数在目标进程中的地址?

首先我们得知道SetWindowDisplayAffinity在目标进程的地址,才能让远端线程调用。如果目标函数所在的DLL未加载,则我们加载该DLL再寻址(为了推广到支持任意API)。有了前文提到的【C语言编写ShellCode】和【在目标进程中执行ShellCode】技术储备,这个实现很容易。

目标函数所在的DLL名与目标函数名作为入参传给我们的ShellCode,ShellCode负责寻址并调用LoadLibraryGetProcAddress,再将获取到的目标函数地址传回来即可。

ShellCode的C语言实现可参考NTAssassin!Hijack_LoadProcAddr_InjectThread()。可以看到Hijack_LoadProcAddr_InjectThread函数是一个远端线程函数,进行了以下操作:接收入参(DLL名称、函数名称)->遍历进程DLL链表(按加载顺序)->找到第一个加载的DLL(必定是ntdll.dll)->寻址LdrLoadDll和LdrGetProcedureAddress->调用这俩获得目标函数地址->反馈。当然用kernel32的LoadLibrary和GetProcAddress也一样,看心情就好了。

这套流程封装成函数,原型如下:

/// <summary>
/// Gets procedure address in remote process space, if specified library not loaded in remote process, will be load
/// </summary>
/// <param name="ProcessHandle">Handle to the process</param>
/// <param name="LibName">Library name</param>
/// <param name="ProcName">Procedure name, can be NULL if loads library only</param>
/// <param name="ProcAddr">Pointer to a pointer variable to receive remote procedure address, can be NULL only if ProcName also is NULL</param>
/// <param name="Timeout">Timeout in milliseconds</param>
/// <returns>TRUE if succeeded, or FALSE if failed, error code storaged in last STATUS</returns>
/// <remarks>HIJACK_PROCESS_ACCESS access is required</remarks>
NTA_API BOOL NTAPI Hijack_LoadProcAddr(_In_ HANDLE ProcessHandle, _In_z_ PCWSTR LibName, _In_opt_z_ PCSTR ProcName, _When_(ProcName != NULL, _Notnull_) PVOID64* ProcAddr, DWORD Timeout);

ProcessHandle:目标进程句柄

LibName:DLL名称或路径

ProcName:函数名,如果为NULL则只加载DLL,不寻址函数

ProcAddr:指向一个指针变量,接收要寻址函数的地址

Timeout:等待远端线程的超时,计以毫秒。可选,若为0则不等待,INFINITE无限等待

实现源码在NTAssassin!Hijack_LoadProcAddr()中,基于上述实现。

Hijack_LoadProcAddr(hProc, L"user32.dll", "SetWindowDisplayAffinity", &pfnSetWindowDisplayAffinity, 5000);

简简单单一行,即可获取目标进程(hProc)空间中SetWindowDisplayAffinity函数地址,返回于pfnSetWindowDisplayAffinity中。



2. 如何在目标进程中调用函数?

函数在目标进程中的地址已经得到,那么我们如何调用它呢?

如果我们创建一个CREATE_SUSPENDED的远端线程,然后修改线程上下文(SetThreadContext),PC(EIP/RIP)指向目标函数,根据函数调用约定构造入参,然后执行……似乎可行,但“善始”容易“善后”难。我们只能获取到函数的返回值(也就是远端线程的退出码),获取不到LastError。

但我们可以故技重施,再度利用ShellCode。我们将目标函数的入参构造好,传递给ShellCode,ShellCode来调用目标函数,目标函数返回后,ShellCode获取LastError再回传给我们。这样,即使在目标进程中调用函数失败了,也能失败得明明白白。

剩下的就是如何传参的问题。

首先看调用约定。Windows在x64中调用约定一致,都是微软Style的FASTCALL,前四个参数用寄存器传递,后面的压栈。在x86下Windows API基本都是STDCALL,我们暂时只支持它,参数从右往左依次入栈即可。要支持其它调用约定也很容易,根据约定把参数按顺序传到该传的地方就行了。

再看参数类型。如果参数为立即数,那最简单了,直接赋值给寄存器/压栈即可。注意如果是浮点数,x64的FASTCALL是用xmm寄存器传递的。如果为指针,那么通过内存操作,在目标进程里开辟内存空间,将指针指向的内容写入,再传入这个内存地址即可。

传递给ShellCode的参数如何构造,来描述要调用的函数地址、调用约定、各个入参,这个仁者见仁智者见智了,两端对齐即可。注意C的默认结构体字节对齐和汇编是不同的,可以用“#pragma pack”指令进行操作。

这里的ShellCode需要直接操作寄存器,咱们还是得用汇编来写,也是方案核心实现的一部分。这里贴x86版本的汇编代码作示例:


头文件(NTAssassin - HijackASM.inc

INCLUDELIB OLDNAMES
IFDEF _DEBUG
    IFDEF _DLL
        INCLUDELIB msvcrtd.lib
        INCLUDELIB vcruntimed.lib
        INCLUDELIB ucrtd.lib
    ELSE
        INCLUDELIB libcmtd.lib
        INCLUDELIB libvcruntimed.lib
        INCLUDELIB libucrtd.lib
    ENDIF
ELSE
    IFDEF _DLL
        INCLUDELIB msvcrt.lib
        INCLUDELIB vcruntime.lib
        INCLUDELIB ucrt.lib
    ELSE
        INCLUDELIB libcmt.lib
        INCLUDELIB libvcruntime.lib
        INCLUDELIB libucrt.lib
    ENDIF
ENDIF

STATUS_NOT_IMPLEMENTED equ 0C0000002h

CC_FASTCALL equ 0
CC_CDECL equ 1
CC_MSCPASCAL equ 2
CC_PASCAL equ 2
CC_MACPASCAL equ 3
CC_STDCALL equ 4
CC_FPFASTCALL equ 5
CC_SYSCALL equ 6
CC_MPWCDECL equ 7
CC_MPWPASCAL equ 8
CC_MAX equ 9

HIJACK_CALLPROCHEADER STRUCT
    Procedure       DWORD ?
    Padding0        DWORD ?
    CallConvention  DWORD ?
    RetValue        DWORD ? 
    Padding1        DWORD ? 
    LastError       DWORD ?
    LastStatus      DWORD ?
    ExceptionCode   DWORD ?
    ParamCount      DWORD ?
HIJACK_CALLPROCHEADER ENDS

HIJACK_CALLPROCPARAM STRUCT
    _Address  DWORD  ?
    Padding0  DWORD  ?
    _Size     DWORD  ?
    Padding1  DWORD  ?
    _Out      DWORD  ?
HIJACK_CALLPROCPARAM ENDS

HIJACK_CALLPROCHEADER是ShellCode接收的输入结构体,里面Procedure描述要调用的目标函数地址,CallConvention描述目标函数调用约定,直接照搬Windows SDK里的CC_*定义即可。RetValue接收函数返回值,LastError、LastStatus、ExceptionCode接收函数执行完后的信息,这几个字段都在TEB(线程环境块)里。ParamCount是参数的数量,也就是跟在HIJACK_CALLPROCHEADER后面HIJACK_CALLPROCPARAM结构体的数量。

HIJACK_CALLPROCPARAM结构体描述目标函数的各个参数,若_Size为0,则_Address为立即数;若_Size为-1,则_Address为浮点数;否则_Address为指针,_Size指定其大小。ShellCode无需关注参数类型,只管把_Address传入即可。调用者据此构造参数(为指针的时候要在目标进程里开辟内存并写值)。

里面的Padding是为了兼顾x64,可惜ml64似乎还不支持结构体,只能硬编码偏移量。


源文件(NTAssassin!Hijack_CallProc_InjectThread_x86):

.686P
.XMM
.model flat, stdcall

include HijackASM.inc

.code
assume  fs:nothing

; DWORD WINAPI Hijack_CallProc_InjectThread_x86(LPVOID lParam)
Hijack_CallProc_InjectThread_x86 PROC USES ebx edi esi lParam
    ; edi point to HIJACK_CALLPROCHEADER
    xor eax, eax
    mov     edi, lParam
    assume  edi:ptr HIJACK_CALLPROCHEADER
    ; Support stdcall(CC_STDCALL) only
    .if [edi].CallConvention != CC_STDCALL
        mov     eax, STATUS_NOT_IMPLEMENTED
        ret
    .endif

    ; esi point to HIJACK_CALLPROCPARAM array, ebx point to random parameters
    lea     esi, [edi + sizeof HIJACK_CALLPROCHEADER]
    assume  esi:ptr HIJACK_CALLPROCPARAM
    mov     ecx, [edi].ParamCount
    mov     eax, sizeof HIJACK_CALLPROCPARAM
    mul     ecx
    lea     ebx, [esi + eax]
    
    ; Enum HIJACK_CALLPROCPARAM
@@:
    mov     eax, [esi]._Size
    .if eax && eax != -1
        ; edx = address to random parameter
        mov     edx, ebx
        ; Align size of random parameter to 4
        add     eax, 3
        and     eax, -4
        ; ebx point to the next random parameter
        add     ebx, eax
    .else
        mov     edx, [esi]._Address
    .endif
    ; Push parameter
    push    edx
    add     esi, sizeof HIJACK_CALLPROCPARAM
    loop    @b

    ; Clear LastError, LastStatus and ExceptionCode
    xor     eax, eax
    mov     fs:[34h], eax
    mov     fs:[0BF4h], eax
    mov     fs:[1A4h], eax
    ; Call procedure
    call    [edi].Procedure
    ; Write RetValue, LastError, LastStatus and ExceptionCode
    mov     [edi].RetValue, eax
    mov     eax, fs:[34h]
    mov     [edi].LastError, eax
    mov     eax, fs:[0BF4h]
    mov     [edi].LastStatus, eax
    mov     eax, fs:[1A4h]
    mov     [edi].ExceptionCode, eax
    assume  edi:nothing, esi:nothing
    ; Return
    xor     eax, eax
    ret
Hijack_CallProc_InjectThread_x86 ENDP

END

ShellCode远端线程入口,edi指向调用者传来的内存区域,以HIJACK_CALLPROCHEADER结构体开头。esi指向其后紧随的HIJACK_CALLPROCPARAM结构体数组,是函数的各个参数。进行以下操作:

>检查调用约定为支持的STDCALL

>loop指令遍历HIJACK_CALLPROCPARAM数组并将参数压栈

>清空TEB的LastError、LastStatus、ExceptionCode

>调用目标函数

>反馈返回值(eax)、LastError、LastStatus、ExceptionCode


x64版本的汇编ShellCode复杂一点,因为FASTCALL前四个参数要放在不同寄存器里,还要考虑是不是浮点数,不像STDCALL一个loop指令一股脑压栈就完事了。还有,ml64写得也硌手。总体思路是一致的,就不贴上来辣眼睛了,源文件NTAssassin!Hijack_CallProc_InjectThread_x64


这段ShellCode的调用者通过内存操作,构造好ShellCode入参和指针类型的参数,写入ShellCode并执行再接收其返回内容即可。我们封装为Hijack_CallProc,如下文所示。


四、应用效果

最终封装简单很多了

/// <summary>
/// Starts a thread and calls a procedure in remote process
/// </summary>
/// <param name="ProcessHandle">Handle to the process</param>
/// <param name="CallProcHeader">Pointer to a HIJACK_CALLPROCHEADER structure contains procedure information and receives return values</param>
/// <param name="Params">Pointer to a HIJACK_CALLPROCPARAM array, corresponding to each parameters of procedure to call</param>
/// <param name="Timeout">Timeout in milliseconds</param>
/// <returns>TRUE if succeeded, or FALSE if failed, error code storaged in last STATUS</returns>
/// <remarks>HIJACK_PROCESS_ACCESS access is required</remarks>
NTA_API BOOL NTAPI Hijack_CallProc(_In_ HANDLE ProcessHandle, _Inout_ PHIJACK_CALLPROCHEADER CallProcHeader, _In_opt_ PHIJACK_CALLPROCPARAM Params, DWORD Timeout);

ProcessHandle:目标进程句柄

CallProcHeader:指向HIJACK_CALLPROCHEADER结构体。成员含义上文已说明,Procedure(目标函数地址)、CallConvention(调用约定)、ParamCount(参数数量)为入参,RetValue(返回值)、LastError、LastStatus、ExceptionCode为出参,共用一个结构体。

Params:指向HIJACK_CALLPROCPARAM结构体数组,为目标函数的各个入参(按顺序)

Timeout:等待远端线程的超时,计以毫秒,INFINITE表示无限等待


调用方式参考AlleyWind在其它进程空间中调用SetWindowDisplayAffinity给其它进程的窗口设置显示掩码实现防捕获(AlleyWind - Operation.c):

可以看到,经过封装,让目标进程调用任意API已经变得很容易了,用AlleyWind让记事本反截屏:

可以看到,AlleyWind中勾选防捕获(Anti Capture)后,我们的远端线程注入到记事本里,让记事本调用SetWindowDisplayAffinity,为自己的窗口开启了防捕获功能。于是截图工具一开始截图,记事本窗口就立即消失了。


五、总结

经过封装,像上面的代码仅需几行便能实现。C语言编写ShellCode -> 在目标进程中调用ShellCode并支持花式入参出参 -> 远端线程执行任意API,一路走来不容易。这样的技术可以“借花献佛”,也可以“借刀杀人”——让其它进程调用API执行进行恶意操作,一切审计结果都会算到其它进程头上,毕竟我们连DLL也没注入。唯一能让远端线程留痕的,只有安装的第三方安防软件了,如SysMon的审计。


本文实现的Hijack_LoadProcAddr(获取目标进程函数地址)与Hijack_CallProc(调用目标进程函数)都是NTAssassin导出的函数,GitHub源码和lib都有,欢迎一键三连,更欢迎指正与交流。



Ratin/ratin@knsoft.org

国家认证 系统架构设计师


[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

收藏
点赞8
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回