所有文章 > API设计 > Windows远端线程执行任意API的设计与实现

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

摘要

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

遇到问题的场景

我开发AlleyWind(一个窗口管理工具https://github.com/KNSoft/AlleyWind)时想实现一个功能,可以使目标(任意)顶层窗口防捕获(截屏)。Win7开始为DRM(Digital Rights Management,数字版权管理)提供了SetWindowDisplayAffinity(https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-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,https://github.com/KNSoft/Precomp4C)如下图所示:

【在目标进程中调用ShellCode】

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

实现方案NTAssassin!Hijack_ExecShellcode()函数(https://github.com/KNSoft/NTAssassin/blob/master/Source/NTAssassin/NTAHijack.c)原型如下:

/// <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负责寻址并调用LoadLibrary(https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryw)、GetProcAddress(https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress),再将获取到的目标函数地址传回来即可。

ShellCode的C语言实现可参考NTAssassin!Hijack_LoadProcAddr_InjectThread()(https://github.com/KNSoft/NTAssassin/blob/master/Source/NativeASM/HijackC.c)。可以看到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()(https://github.com/KNSoft/NTAssassin/blob/master/Source/NTAssassin/NTAHijack.c)中,基于上述实现。

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

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

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

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

如果我们创建一个CREATE_SUSPENDED的远端线程,然后修改线程上下文(SetThreadContext,https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-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,https://github.com/KNSoft/NTAssassin/blob/master/Source/NativeASM/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,https://github.com/KNSoft/NTAssassin/blob/master/Source/NativeASM/HijackASM_x86.asm):

.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(https://github.com/KNSoft/NTAssassin/blob/master/Source/NativeASM/HijackASM_x64.asm)。

这段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,https://github.com/KNSoft/AlleyWind/blob/master/Source/AlleyWind/Operation.c):

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

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

总结

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

本文章转载微信公众号@看雪学苑

#你可能也喜欢这些API文章!