深度剖析PsExec

0x1 前言

      本文主要通过逆向分析的方法分析PsExec的技术实现和有关PsExec和类PsExec工具的检测方法,当然其中也会掺杂一些乱七八糟的点,都是学习PsExec的一些总结。本文行文仓促,如有错误,请各位积极指正。

0x2 什么是PsExec

      在微软文档中,PsExec 是一种轻量级的 telnet 替代品,可让您在其他系统上执行进程,并与控制台应用程序完全交互,而无需手动安装客户端软件。国内也有很多大佬写过PsExec原理分析的文章,RcoIl通过分析数据包的方式为我们介绍了PsExec实现的原理。具体可以参考http://rcoil.me/2019/08/【知识回顾】深入了解%20PsExec/

      RcoIl通过分析流量,大致将PsExec的执行过程分为3个部分,而PSEXESVC 服务充当一个重定向器(包装器)。它在远程系统上运行指定的可执行文件

  • 将 PSEXESVC.exe 上传到 ADMIN$ (指向 /admin$/system32/PSEXESVC.EXE)共享文件夹内
  • 远程创建用于运行 PSEXESVC.exe 的服务
  • 远程启动服务

      总结一下,就是PsExec通过ADMIN$将一个PSEXESVC文件上传到目标机器,然后通过命名管道的机制,将攻击者输入的命令重定向执行被攻击者机器的文件。但是实际上我还是不知道PsExec是怎么通过ADMIN$进行上传文件的,也不知道怎么通过管道机制使被攻击者机器执行命令的。

0x3 如何传递PsExecSvr

      PsExec是Mark Russinovich编写的 Sysinternals Suite中的工具,可以在https://docs.microsoft.com/en-us/sysinternals/downloads/psexec处下载。

      除去对参数,和运行环境做一些校验外,直接定位到PsExec最为关键的代码sub_7040。
mark

      在sub_7040,首先会构造远程文件的地址,然后将服务名,服务文件路径,用户名,密码等相关信息作为参数传入sub_41D0。而sub_41D0这个函数正是投送PSEXESVC.exe的原因所在。

      首先,sub_41D0会将参数重新赋值到局部变量,这是很常见的操作,这些变量分别是

  • filepath_svcpe:即PSEXESVC.exe文件的路径
  • lpDisplayName:即服务的DisplayName,友好名称
  • psz_PSEXESVC:就是那串字符串,表示文件名(一般为PSEXESVC.exe)
  • RemoteComputerName:远程主机名
  • lpUserName:用户名
  • lpPassWord:密码
  • lpServiceName:服务名
    mark

      接着判断远程主机名是否就是本地主机名,如果是本地主机的话,就会通过gethostname,gethostbyname,inet_ntoa等函数转化为点分十进制地址,然后进行下一步的链接。当然,如果远程主机就是本地主机的话是不需要将PSEXESVC.exe直接上传到远程主机的,所以直接创建进程
mark

      如果待定的远程主机名不是本地主机名的话,执行开线程链接。

      在Thread_ConnectRemoteHostBySMB2(相对地址为0x4A50)中,首先建立IPC$连接,并从资源中释放PSEXESVC.exe
mark
mark

      当PSEXESVC.exe释放之后,创建DisplayName = “PSEXESVC”的服务
mark

      然后向远程主机创建authentication key
mark

      接着通过CopyFileW直接复制到远程主机路径,亦可通过WriteFile写入远程主机
mark
mark

      接着创建三个命名管道用于和PSEXESVC进行通讯,分别是stdin,stdout,stderr
mark

      随后分别拉起三个线程,用于三个命名管道的数据传输(并不是这三个管道之间进行传输),第一个参数为phKey,如果有phkey则需要将数据解密在进行传输,否则的话直接进行传输即可,第二个参数管道句柄。
mark
mark

      剩下的就是一些退出操作,比如停止及删除服务,删除文件等操作
mark
mark

      值得注意的是,PsExec使用了ADMIN$传输PSEXESVC.exe,但是仅仅使用WNetAddConnection2W添加了一个关于IPC$的链接,并在退出之时关闭了IPC$的链接。
mark

      总结一下,其实PsExec就是通过Admin$共享将文件传输到远程主机,Admin$相当于虚拟了一个文件夹,也可以理解成Admin$是C:\Windows的符号链接。如果将Admin$理解成一个符号链接,并指向的是C:\Windows,那么我们可以在上面执行任何关于文件的操作,包括创建,写入,删除文件,这些都是可以通过Windows提供的API实现。Admin$本质其实是利用SMB协议实现的,当我去链接windowx xp的机器的时候,可以看到使用的是SMB1的协议,而当我链接windows7的时候使用的却是SMB2的协议。
mark

      有师傅也注意到了,为什么使用Admin$上传文件,但是为什么不需要使用WNetAddConnection2W添加一个Admin$链接。这也是我在分析时候的一个困惑,于是,我做了以下实验。

      首先,使用net use \192.168.80.128\ipc$ “password” /user:”domain/username”建立一个远程连接。
mark

      然后上传一份文件上去,可以看见文件以及上传成功。

1
CopyFile("图19.png","\\\\192.168.80.128\\admin$\\aaa.png",FALSE)

      
mark

      接着,我删除上述链接,并创建一个Admin$的远程链接,然后在上传一份bbb.png的文件,可以发现无论ipc$还是admin$都是可以上传成功的。我觉得ipc$亦或是admin$在此处的作用都是为了验证用户名和密码是否有效,验证完成之后,可以直接通过Admin$上传文件。这也是有的文章上面说可以先建立一个ipc$链接,然后在执行PsExec的原因所在了。
mark

      

      除此以外,为什么一定是要利用创建服务的方式去实现呢,实现驻留的方法也有很多,但为什么一定是服务呢,我在这篇文章中找到了原因,当使用Admin$共享的时候,如果要执行复制到远程主机的文件的时候,这些文件之一必须是服务。

0x4 如何执行命令

      传递和返回结果的原理就很简单了,本质就如很多文章所说的,通过命名管道实现从本地主机到远程主机发送和接收数据。具体是这样的。

      首先分别创建stdin,stdout,stderr三个管道,并连接这三个命名管道用于接收和传递数据!
mark

      然后传递一些参数进sub_67B0中,这些参数分别是:

  • phKey,
  • &Msg,
  • hPipe_Service,
  • hNamedPipe_stdin,
  • hNamedPipe_stdout,
  • hNamedPipe_stderr,
  • hEvent,
  • hHandle
    mark

      首先phKey是表示秘钥句柄,其可能是通过sub_404420中的CryptDeriveKey,或者是sub_4059C0中的CryptImportKey函数产生。
mark

      了解&Msg参数,其实是需要了解第三个参数hPipe_Service。hPipe_Service其实是服务于服务本身的一个管道,PsExec一共会创建四个管道,其中一个用于服务自身,另外三个管道用于重定向。因为这个管道是以模块名为管道名称的,所以这个管道是服务于自身的一个管道。
mark

      接着看Msg就很好理解了,在sub_404720中,第二个参数是服务本身的管道,显然,这是从管道中读取相关信息。
mark

      接下来就是三个用于转发的管道,和事件句柄和线程句柄,和本文并没有多少关系。
mark

      在sub_405AF0函数中,创建三个普通的管道,这三个管道并不是命名管道,作用是用于向进程发送数据,以及接受返回结果的作用。可以看到StartupInfo的dwFlags是USESHOWINDOW & USESTDHANDLES。USESTDHANDLES表示着需要使用进程的三个管道。
mark

      接着拉起三个线程,用于从普通管道读取数据,然后将数据写回到命名管道中,完成数据的传递。
mark

      最后创建进程,后续三个线程监听管道的数据即可。
mark

0x5 如何检测PsExec

      在前面,我们了解到PsExec通过SMB协议将PSEXESVC.exe传递到远程主机,并将其创建为一个服务,然后通过创建四个命名管道进行数据通信,然后通过CreateProcessAsUser创建带有管道的进程来监听或者发送命令。所以综上,我们可以通过三个维度来检测是否是PsExec程序。

  • 创建的子进程是否存在管道
  • 父进程也就是PSEXESVC.exe,是否是服务
  • 父进程是否存在命名管道

      经过测试,对于类似于PsExec一样是可以检测的。

      根据之前的分析,PsExec最终会调用CreateProcessAsUserW拉起进程,并在StartupInfo设置重定向管道。所以,我们可以在通过Hook CreateProcessAsUserW函数或者类似函数CreateProcess,判断StartUpinfo是否存在重定向管道。

      通过分析CreateProcessAsUserW函数和CreateProcess函数发现,其最终会调用CreateProcessInternalW函数。并发现,其最终会调用Nt层的函数NtCreateUserProcess实现进程创建。
mark
mark

      因为不仅要考虑PsExec,还要考虑其他类似于PsExec的工具,我打算在Nt层进行Hook。所以,我决定将NtCreateUserProcess作为我的目标

      接下来就是确定参数StartUpInfo这个参数是如何传递给NtCreateUserProcess的,通过分析lpStartupInfo会最终传递到BasepCreateProcessParameters函数处理,然后返回。然后再将返回的ProcessParameters作为参数传递给NtCreateUserProcess。
mark
mark

      在BasepCreateProcessParameters中,发现其就是在StartupInfo做了校验和复制。
mark

      对于了解windows内核的师傅,肯定了解其中的运行机制,也了解ProcessParameters结构的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
PVOID ConsoleHandle;
ULONG ConsoleFlags;
PVOID StandardInput;
PVOID StandardOutput;
PVOID StandardError;
CURDIR CurrentDirectory;
UNICODE_STRING DllPath;
UNICODE_STRING ImagePathName;
UNICODE_STRING CommandLine;
PVOID Environment;
ULONG StartingX;
ULONG StartingY;
ULONG CountX;
ULONG CountY;
ULONG CountCharsX;
ULONG CountCharsY;
ULONG FillAttribute;
ULONG WindowFlags;
ULONG ShowWindowFlags;
UNICODE_STRING WindowTitle;
UNICODE_STRING DesktopInfo;
UNICODE_STRING ShellInfo;
UNICODE_STRING RuntimeData;
RTL_DRIVE_LETTER_CURDIR CurrentDirectores[32];
ULONG EnvironmentSize;
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;

      因为,我需要对系统全局的进程创建进行Hook,所以我决定采用SSDT Hook来达成这一目的,最重要的原因是我之前写过类似的Demo,可以直接使用。当然,使用回调一样是可以解决问题的,关于SSDTHook原理和编程,网络上也有很多讲解。当Hook NtCreateUserProcess之后,继而检查ProcessParameters->StandardInput,ProcessParameters->StandardOutput,ProcessParameters->StandardError是否为空即可。

      相对于PsExecSvc.exe,NtCreateUserProcess创建的进程是其子进程,所以PsExecSvc.exe算是NtCreateUserProcess创建的进程的父进程。NtCreateUserProcess函数原型如下,很显然,第一个参数是创建进程的ProcessHandle。所以,我们需要函数返回的ProcessHandle获取其父进程的进程数据(Pid或者Handle)

1
2
3
4
5
6
7
8
9
10
11
12
13
NTSTATUS NTAPI NtCreateUserProcess(
OUT PHANDLE ProcessHandle,
OUT PHANDLE ThreadHandle,
IN ACCESS_MASK ProcessDesiredAccess,
IN ACCESS_MASK ThreadDesiredAccess,
IN POBJECT_ATTRIBUTES ProcessObjectAttributes OPTIONAL,
IN POBJECT_ATTRIBUTES ThreadObjectAttributes OPTIONAL,
IN ULONG CreateProcessFlags,
IN ULONG CreateThreadFlags,
IN PRTL_USER_PROCESS_PARAMETERS ProcessParameters,
IN PVOID Parameter9,
IN PNT_PROC_THREAD_ATTRIBUTE_LIST AttributeList
)

      可以通过 ZwQueryInformationProcess 获取父进程Pid,具体可以这样做。

1
2
3
4
5
6
7
8
9
10
PROCESS_BASIC_INFORMATION pbi;
UNICODE_STRING routineName;
RtlInitUnicodeString(&routineName, L"ZwQueryInformationProcess");
ZwQueryInformationProcess = (QUERY_INFO_PROCESS)MmGetSystemRoutineAddress(&routineName);
ntStatus = ZwQueryInformationProcess(*ProcessHandle, 0, (PVOID)&pbi, sizeof(PROCESS_BASIC_INFORMATION), NULL);
if (!ntStatus)
{
upPid = pbi.InheritedFromUniqueProcessId;
}

      接下来,就可以判断返回的Pid的进程是否是服务,这一步,网上也有现成的代码。具体来说,就是通过EnumServicesStatusEx函数获取整个服务列表的LPENUM_SERVICE_STATUS_PROCESS,而LPENUM_SERVICE_STATUS_PROCESS保存着对于进程的Pid。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
BOOL IsServicesByPid(DWORD dwPid)
{
SC_HANDLE hSCM = NULL;
hSCM = OpenSCManager(NULL, NULL, SC_MANAGER_ENUMERATE_SERVICE | SC_MANAGER_CONNECT);
if (hSCM == NULL)
{
printf("[!] OpenSCManager:%d", GetLastError());
return FALSE;
}
DWORD dwBufSize = 0; // 传入的缓冲长度
DWORD dwBufNeed = 0; // 需要的缓冲长度
DWORD dwNumberOfService = 0; // 返回的服务个数
EnumServicesStatusEx(hSCM, SC_ENUM_PROCESS_INFO, SERVICE_WIN32, SERVICE_STATE_ALL,
NULL, dwBufSize, &dwBufNeed, &dwNumberOfService, NULL, NULL);
char *pBuf = NULL;
dwBufSize = dwBufNeed + sizeof(ENUM_SERVICE_STATUS_PROCESS);
pBuf = (char *)malloc(dwBufSize);
memset(pBuf, 0, dwBufSize);
BOOL bRet = FALSE;
bRet = EnumServicesStatusEx(hSCM, SC_ENUM_PROCESS_INFO, SERVICE_WIN32, SERVICE_STATE_ALL,
(LPBYTE)pBuf, dwBufSize, &dwBufNeed, &dwNumberOfService, NULL, NULL);
if (bRet == FALSE)
{
printf(" EnumServicesStatusEx %d", GetLastError());
return FALSE;
}
LPENUM_SERVICE_STATUS_PROCESS pServiceInfo = (LPENUM_SERVICE_STATUS_PROCESS)pBuf;
for (unsigned int i = 0; i < dwNumberOfService; i++)
{
if (dwPid == pServiceInfo[i].ServiceStatusProcess.dwProcessId)
{
printf("[*]Find Service Name %s Of ProcessId", pServiceInfo[i].lpDisplayName);
return TRUE;
}
}
return FALSE;
}

      接下来,就是判断PsExecSvc.exe是否具有命名管道,命名管道实际上就是Type为”File”的句柄,所以,只需要遍历进程的句柄表,然后检查句柄类型为File,且句柄名称带有NamedPipe的,就是存在命名管道。可以使用Process Explorer来搜索命名管道。
mark

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//获取句柄名
Status = ZwQueryObject((HANDLE)hDuplicate,
ObjectNameInformation,
BufferForObjectName,
sizeof(BufferForObjectName),
NULL);
ObjectName = (POBJECT_NAME_INFORMATION)BufferForObjectName;
if (Status == STATUS_INFO_LENGTH_MISMATCH || !NT_SUCCESS(Status))
continue;
PWCHAR HandleType_File = L"File";
CHAR cObjectName[MAX_PATH] = { 0 };
if (wcscmp((PWCHAR)ObjectType->TypeName.Buffer, HandleType_File) == 0)
{
wsprintfA(cObjectName, "%S", (PWCHAR)ObjectName->Name.Buffer);
if (strstr(cObjectName, "\\Device\\NamedPipe"))
{
printf("[*]Type:%ls|Name:%ls|Handle:%X\n", ObjectType->TypeName.Buffer, ObjectName->Name.Buffer, (DWORD)dwHandle);
return TRUE;
}
}

      只需要将获取的Pid传到R3,然后检查服务和命名管道符合这三个维度就可以基本判断其可能是PsExec类似软件。
mark
mark
mark