【翻译】SensePost - Psexec’ing the right way and why zero trust is mandatory
2021 年,我遇到了两位出色的黑客,Michael和 Reino,我有机会在我的第一次 SenseCon 期间与他们合作。
SenseCon 是一个为期 3 天的内部会议,在此期间我们与人会面、分享知识并享受乐趣。会议还包括一整天的黑客马拉松,我们可以研究自己感兴趣的黑客主题。
在那次黑客马拉松中,我们想深入研究 PsExec.exe,看看是否可以通过 python 脚本与之通信,从而不再依赖 Windows 系统。剧透一下,我们成功了!但由于某些原因,该项目在一个私有仓库中搁置了。
直到几周前,我真的需要这样一个工具来绕过特定的 EDR。
看到它运行得相当好,我想完成这个项目并与这篇博文一起发布,以解释我们是如何实现的。在这篇博文中,我们将一窥 PsExec.exe 的工作原理,我们将编写一个 python 脚本,使我们能够作为合法的 PsExec.exe 客户端运行,最后,我们将了解为什么零信任是网络安全的核心要求。
1 PsExec.exe 如何工作
很多人已经解释过了,但既然我们要模仿它,我认为再解释一次会很有意思。对于不了解的人来说,PsExec.exe 是Sysinternals工具包中的一个二进制文件,该工具包最初由Mark Russinovich于 1996 年发布。截至今天,PsExec.exe 的最新版本是 2.43 版,可以在这里下载。
大多数情况下,你会以两种方式使用 PsExec:
-
获取本地系统权限:
PsExec.exe -s -i cmd
-
作为域用户远程执行命令:
PsExec.exe \dc.whiteflag.local -u WHITEFLAGAdministrateur -p Defte@WF cmd
为了实现远程命令执行,PsExec.exe 依赖于 4 个步骤:
-
提取并上传 PsExeSVC.exe
当你运行 PsExec.exe 时,它首先要做的是提取 PsExeSVC.exe,这是嵌入在 PsExec.exe 中的服务器端组件。请看以下 binwalk 输出:
这个二进制文件随后被上传到 ADMIN$共享(指向 C:Windows)作为 PsExeSVC.exe:
-
启动 PsExeSVC 服务
然后 PsExec.exe(客户端)远程连接到用于管理服务的 SVCCTL RPC 端点,并调用 4 个接口:
-
OpenSCManagerW 用于连接 Service RPC 端点; -
OpenServiceW 用于创建新服务; -
StartServiceW 用于启动新创建的服务; -
QueryServiceStatus 用于确保服务确实在运行。
确实,我们可以看到一个新的服务 PSEXESVC 正在运行:
-
发送初始化数据包
当 PsExeSVC 启动时,它首先创建一个名为 psexecsvc 的命名管道,我们称之为初始化命名管道:
在这个管道建立之后,客户端(PsExec.exe)和服务器(PsExeSVC.exe)之间会立即进行一次收发操作,双方都会发送自己的版本并接收对方的版本:
查看这些数据包的内容,我们会看到它们都发送了 4 字节的数据,其中包含以十六进制存储的数值(小端序):
经过转换,这告诉我们这是 PsExec.exe 和 PsExeSVC.exe 的 1.9 版本:
Little endia = BE000000
Big endian = 0000000BE = 190 = version 1.9
为什么我不使用最新版本?这是因为一旦两个组件都发送了各自的版本信息后,客户端将发送 19032 到 19040 字节的数据(具体取决于 PsExec 的版本)。事实上,从 PsExec 2.20 开始,这些数据以及后续所有通信都是加密的。因此,如果你通过 Wireshark 监控 PsExec.exe v2.43 的通信,你会看到以下内容:
这是使用 1.90 版本时完全相同的数据包:
观察这个数据包,我们就能理解为什么较新版本的 PsExec 要实现加密;因为客户端会通过网络发送明文凭据(我们稍后会解释原因),这容易受到中间人攻击。
我们还可以看到指定了一个程序 cmd.exe,以及执行 PsExec.exe 的计算机名称(我的一个虚拟机,其主机名为 COMMANDO)。注意所有这些明文信息都被空字节包围。这是因为这些数据不是随机数据,而是一个可以看到轮廓的结构:
# Size of the packet to be read by PsExeSVC (19032)
584a0000
# Little endian hexadecimal for 5800 (don't know what it's used for yet)
a8160000
# String (C.O.M.M.A.N.D.O)
43004f004d004d0041004e0044004f00 [ LOTS OF ZEROS ]
# String (C.M.D)
63006d006400 [ LOTS OF ZEROS ]
# Some bytes ??
00000101000000000000000000000000ffffffff0100
# String (W.H.I.T.E.F.L.A.G..A.d.m.i.n.i.s.t.r.a.t.e.u.r)
5700480049005400450046004c00410047005c00410064006d0069006e00690073007400720061007400650075007200 [ LOTS OF ZEROS ]
# String [email protected]
44006500660074006500400057004600 [ LAST ZEROS ]
在 PsExeSVC.exe 接收到该结构后,会创建三个新的命名管道:
-
PSEXECSVC-X-Y-stdin 用于发送我们想要远程运行的命令; -
PSEXECSVC-X-Y-stdout 用于获取命令的输出; -
PSEXECSVC-X-Y-stderr 用于获取错误信息。
其中:
-
X 是一个字符串; -
Y 是一个数值。
使用 PowerShell 列出远程目标上的命名管道:
Get-ChildItem \.\pipe
显示如下命名管道:
那么数值 5800 是什么呢?查看启动 PsExec.exe 的计算机上的任务管理器,我们会看到以下内容:
这意味着该值(前面模式中的 Y)实际上是 PsExec.exe 的 PID。
现在我们明白 psexecsvc 命名管道在这里是用来接收信息的,这些信息将用于创建其他三个命名管道。从示意图上看,我们可以说 PsExec.exe 是这样创建命名管道的:
我们需要了解的最后一件事是这些 PsExec.exe 选项是如何发送的:
由于我不想反编译二进制文件(这不太符合使用条款),我只是想到通过随机切换选项并观察 Wireshark 输出中的差异。就这样做了,直到我发现有一个 32 字节的缓冲区,根据使用 PsExec.exe 的选项不同,它只包含 0 和 1。
此时,我能够定义传递给 PsExeSVC 命名管道的数据结构,如下所示:
class PsExecInit(Structure):
structure = (
('PacketSize', '<I'), # 19032 (size of the init packet)
('PID', '<I'), # PID of the PsExeSVC.py script
('Computer', '520s'), # Hostname of the computer where PsExeSVC.py is run
('Command', '520s'), # Remote binary to call
('Arguments', '520s'), # Arguments
('OthersOptions', '16385s'), # Some space mostly used to copy files
('ElevateToSystem', '1s'), # Whether we elevate to system or not
('Interactif', '1s'), # Whether launch interactive session (no it won't let you type command otherwise)
('LogonUser', '1s'), # Logs the user in remotely (which enables Windows SSO)
('RestrictedToken', '1s'), # Do we want a restricted priv token ? (nope LOL)
('EnableAllPrivs', '1s'), # Do we able all privileges (HELL YEAH!!)
('OthersFlags', '16s'), # Others options we don't really need
('Username', '520s=""'), # DOMAINUsername
('Password', '520s=""'), # Password
('Padding', '18s=""') # Padding
)
正如你所看到的,该结构中存储了大量数据,其中最重要的标志如下:
-
LogonUser 允许我们以认证用户身份获得 shell; -
EnabledAllPrivs 允许我们获得完整的令牌权限; -
ElevateToSystem 允许我们获得 NT AUTHORITYSystem 远程 shell。
将所有这些内容打包到 Impacket 的 psexec.py 脚本的副本中,你就得到了psexecsvc.py,可以通过两种方式使用它:
-
获取 NT AUTHORITYSystem shell(使用 -system 标志):
-
在目标上以提供凭据的用户身份获取远程 shell(使用 -user 标志):
请注意,以域用户身份进行身份验证需要填写明文凭据,因为这些凭据将传递给 PsExeSVC 服务,然后该服务将使用这些凭据在远程目标上进行本地身份验证。
为此,PsExeSVC.exe 依赖于 WinAPI 的 LogonUser 函数,其原型如下:
BOOL LogonUserA(
LPCSTR lpszUsername,
LPCSTR lpszDomain,
LPCSTR lpszPassword,
DWORD dwLogonType,
DWORD dwLogonProvider,
PHANDLE phToken
);
该 API 调用将为你提供一个主令牌,允许你使用 Windows SSO,从而可以从你的远程 shell 连接到其他系统。例如,如果我使用-user 标志连接到 srv.whiteflag.local 服务器,我将能够列出 dc.whiteflag.local 上的 C$共享:
但使用此功能有一个很大的缺陷,因为凭据将存储在 LSASS 进程的内存中,攻击者可以使用例如NetExec等工具劫持这些凭据:
nxc smb serveur.whiteflag.local -u Administrateur -p Defte@WF -M lsassy
这就是为什么你总是会听到人们说,除了使用原始的 PsExec.exe 之外,远程认证时凭据不会存储在 LSASS 中,这是因为 PsExeSVC 服务在远程服务器上执行本地登录。
2 PsExeSVC.py 有什么优势?
是的,你说得对,你可能会说这没什么用,因为我们已经可以通过 psexec.py 远程执行命令了。但首先,作为一个懒惰的黑客,启动一个 Windows 虚拟机对我来说太麻烦了。其次,psexec.py 在远程 Windows 系统上部署RemCom服务确实很好...但会被标记:
而 PsExeSVC.exe 则不会:
RemCom 被标记的原因是因为 Impacket 嵌入了一个已编译版本,这个版本过去和现在都被攻击者使用。当然,你可以通过编译自己的 RemCom 版本来降低检测率。但使用合法的远程管理工具更好,这是因为有这个证书:
这个证书用于签名二进制文件并证明它是 Microsoft 分发的工具包的一部分。仅这个证书就意味着该二进制文件不应该是恶意的,也不应该被阻止。
因此,你经常会看到 EDR 会阻止 wmiexec.py 和 psexec.py,但它们不会总是阻止 PsExeSVC.py,因为它依赖于一个合法且受信任的工具(PsExeSVC.exe 二进制文件)!
这种机制被称为白名单,这是我经常看到的一种机制,它让我能够攻破我遇到的最强化客户的许多内部网络。
现在,如果你读过我写的关于构建自己的 EDR的博文,也许你玩过我同时发布的挑战。在这个挑战中,我添加了这样一个白名单机制,让我惊讶的是,很多人给我发来了他们的解决方案,有很多令人惊叹的好主意,但我制造的这个逻辑漏洞却没有被利用。
这个漏洞位于静态分析代理的代码中,看看 main 函数:
int main() {
LPCWSTR pipeName = L"\\.\pipe\dumbedr-analyzer";
DWORD bytesRead = 0;
wchar_t target_binary_file[MESSAGE_SIZE] = { 0 };
printf("Launching analyzer named pipe servern");
// Creates a named pipe
HANDLE hServerPipe = CreateNamedPipe(
pipeName, // Pipe name to create
PIPE_ACCESS_DUPLEX, // Whether the pipe is supposed to receive or send data (can be both)
PIPE_TYPE_MESSAGE, // Pipe mode (whether or not the pipe is waiting for data)
PIPE_UNLIMITED_INSTANCES, // Maximum number of instances from 1 to PIPE_UNLIMITED_INSTANCES
MESSAGE_SIZE, // Number of bytes for output buffer
MESSAGE_SIZE, // Number of bytes for input buffer
0, // Pipe timeout
NULL // Security attributes (anonymous connection or may be needs credentials. )
);
while (TRUE) {
// ConnectNamedPipe enables a named pipe server to start listening for incoming connections
BOOL isPipeConnected = ConnectNamedPipe(
hServerPipe, // Handle to the named pipe
NULL // Whether or not the pipe supports overlapped operations
);
wchar_t target_binary_file[MESSAGE_SIZE] = { 0 };
if (isPipeConnected) {
// Read from the named pipe
ReadFile(
hServerPipe, // Handle to the named pipe
&target_binary_file, // Target buffer where to stock the output
MESSAGE_SIZE, // Size of the buffer
&bytesRead, // Number of bytes read from ReadFile
NULL // Whether or not the pipe supports overlapped operations
);
printf("~> Received binary file %wsn", target_binary_file);
int res = 0;
BOOL isSeDebugPrivilegeStringPresent = lookForSeDebugPrivilegeString(target_binary_file);
if (isSeDebugPrivilegeStringPresent == TRUE) {
printf("t�33[31mFound SeDebugPrivilege string.�33[0mn");
}
else {
printf("t�33[32mSeDebugPrivilege string not found.�33[0mn");
}
BOOL isDangerousFunctionsFound = ListImportedFunctions(target_binary_file);
if (isDangerousFunctionsFound == TRUE) {
printf("t�33[31mDangerous functions found.�33[0mn");
}
else {
printf("t�33[32mNo dangerous functions found.�33[0mn");
}
BOOL isSigned = VerifyEmbeddedSignature(target_binary_file);
if (isSigned == TRUE) {
printf("t�33[32mBinary is signed.�33[0mn");
}
else {
printf("t�33[31mBinary is not signed.�33[0mn");
}
// Here there is a logical bug. If the binary is signed, all others checks are ignored
wchar_t response[MESSAGE_SIZE] = { 0 };
if (isSigned == TRUE) {
swprintf_s(response, MESSAGE_SIZE, L"OK�");
printf("t�33[32mStaticAnalyzer allows�33[0mn");
}
else {
// If the following conditions are met, the binary is blocked
if (isDangerousFunctionsFound || isSeDebugPrivilegeStringPresent) {
swprintf_s(response, MESSAGE_SIZE, L"KO�");
printf("nt�33[31mStaticAnalyzer denies�33[0mn");
}
else {
swprintf_s(response, MESSAGE_SIZE, L"OK�");
printf("nt�33[32mStaticAnalyzer allows�33[0mn");
}
}
DWORD bytesWritten = 0;
// Write to the named pipe
WriteFile(
hServerPipe, // Handle to the named pipe
response, // Buffer to write from
MESSAGE_SIZE, // Size of the buffer
&bytesWritten, // Numbers of bytes written
NULL // Whether or not the pipe supports overlapped operations
);
}
// Disconnect
DisconnectNamedPipe(
hServerPipe // Handle to the named pipe
);
printf("nn");
}
return0;
}
这段代码相当简单,它创建了一个 named pipe,由驱动程序通过 kernel callback 获取并发送有关正在启动的进程的信息。对于接收到的每个程序,static analyzer 会进行以下几项检查:
-
二进制文件中是否存在危险函数(这只是一个 IAT lookup); -
是否在其中找到 SeDebugPrivilege 字符串; -
二进制文件是否已签名。
但这里有一个陷阱,如果二进制文件已签名,其他所有要求都变成可选的,请看 if 语句:
wchar_t response[MESSAGE_SIZE] = { 0 };
if (isSigned == TRUE) {
swprintf_s(response, MESSAGE_SIZE, L"OK�");
printf("t�33[32mStaticAnalyzer allows�33[0mn");
}
else {
// If the following conditions are met, the binary is blocked
if (isDangerousFunctionsFound || isSeDebugPrivilegeStringPresent) {
swprintf_s(response, MESSAGE_SIZE, L"KO�");
printf("nt�33[31mStaticAnalyzer denies�33[0mn");
}
else {
swprintf_s(response, MESSAGE_SIZE, L"OK�");
printf("nt�33[32mStaticAnalyzer allows�33[0mn");
}
}
现在让我们来看看签名检查函数是如何工作的:
BOOL isSigned;
switch (lStatus) {
// The file is signed and the signature was verified
case ERROR_SUCCESS:
isSigned = TRUE;
break;
// File is signed but the signature is not verified or is not trusted
case TRUST_E_SUBJECT_FORM_UNKNOWN || TRUST_E_PROVIDER_UNKNOWN || TRUST_E_EXPLICIT_DISTRUST || CRYPT_E_SECURITY_SETTINGS || TRUST_E_SUBJECT_NOT_TRUSTED:
isSigned = TRUE;
break;
// The file is not signed
case TRUST_E_NOSIGNATURE:
isSigned = FALSE;
break;
// Shouldn't happen but hey may be!
default:
isSigned = FALSE;
break;
}
只要你的二进制文件有签名,无论签名是否有效,都能通过静态分析器的检查。这是一个我亲身经历过的真实案例,我利用这一点运行受信任的工具并成功入侵了一些公司。这一切都是因为过度信任。但是别担心,还是有办法保护自己的!
4 如何保护自己
虽然我在这篇博文中武器化了 PsExeSVC.exe,但重点不是要防范 PsExec.exe。重点是不要基于证书、二进制文件名、目录或发布者使用全局信任白名单。现在你可能会问"那些实际使用 PsExec.exe 进行远程管理的系统管理员该怎么办?"
他们真的需要它吗?他们难道不能通过 GPO、远程注册表、远程任务调度甚至 PsRemoting/RDP 来管理计算机吗?如果他们确实需要,那么可以只在他们的计算机上将这些二进制文件列入白名单,这意味着只有特定的 IP 才能使用这些工具。但是没有理由在整个企业网络中将 PsExec.exe 列入白名单。毕竟,市场营销人员不会使用 PsExec.exe,对吧?
那么,我们如何阻止 PsExec.exe 呢?这里有一些想法。
首先,当第一次运行 PsExec 时,系统会要求用户接受 EULA。如果用户同意,该值将存储在以下注册表项中:
HKCUSoftwareSysinternalsPsExec
监控这个注册表项的创建可以让你检测到 PsExec 的使用。
其次,PsExec.exe 总是按照相同的方式工作:
-
将二进制文件上传到 ADMIN$共享; -
远程创建服务; -
启动服务; -
确保服务正在运行。
这一系列特定的远程 RPC 调用是一个危险信号,你可以根据相关的事件 ID(例如,服务创建 4697)来检测和阻止。
第三,禁用 ADMIN共享。如果找不到共享,它就无法上传服务二进制文件。但既然我们在讨论 ADMIN$共享,请问问自己:你的 SMB 服务器和工作站是否真的需要暴露任何共享?某些系统确实需要(例如文件服务器和暴露 SYSVOL 的域控制器),但大多数系统并不需要,所以尽可能禁用更多共享以减少攻击面,并监控最后必需的共享。
第四,关联命名管道设置。我们之前看到 PsExec.exe 会向 psexecsvc 命名管道发送一组信息,这些信息将用于创建 stdin、stdout 和 stderr 命名管道。在我们的示例中,命名管道是:
PSEXESVC-COMMANDO-5800-std*
我没有提到的是,PSEXESVC 这部分并不是硬编码的,而是通过 PsExeSVC.exe 读取自身二进制文件名获得的。大多数情况下,攻击者会重命名上传的 PsExeSVC.exe 来试图隐藏它。如果他们这样做,比如说将 psexesvc.exe 重命名为 update.exe,那么生成的三个命名管道将遵循以下模式:
UPDATE-COMPUTERNAME-PID-std*
如果由于某种原因,你发现一个投放的二进制文件设置了三个包含其二进制名称的命名管道...这就是危险信号!!
第五点也是最后一点,因为我认为这可能是这篇博文中最重要的一点,那就是不要信任任何东西。Lolbins、GTFObins、可被利用的签名驱动程序、EDR 和防病毒软件...这些都是合法的工具和组件,但如果你过度信任它们,它们随时可能被用来对付你自己。如果你想确保网络安全,就不要信任任何人或任何事物,因为正如法国诗人皮埃尔·科耐尔所说:"过度的信任往往会导致危险"。对此我完全同意!
黑客快乐!
原文始发于微信公众号(securitainment):正确使用 PsExec 以及为什么零信任是必需的
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论