windows 内核开发 (1)

windows 内核开发基础知识

介绍

主要通过学习 《Windows Kernel Programming》,了解 windows 相关内核机制,进而实现对应 EDR,DLP能力,实现windows 企业安全监控。

可做为中文学习资料。

该书使用 C和C++作为代码示例: https://github.com/zodiacon/windowskernelprogrammingbook2e

第一章 Windows Internals Overview

该章是windows的关键工作概念,需要理解这一章内容,为后续学习做基础。

Processes 进程

是一个容器(containment)和 管理对象( management object),程序的运行实例。

线程 是实际执行代码并实现技术意义上运行的实体。 (比较绕口hhh

在Windows 内核架构中,线程(thread)作为调度的基本单位,核心是执行代码片段来实现 “运行” 的实质。

流程如下:

  • 可执行程序 —— 包含初始代码和数据(用于进程代码执行)某些特殊进程(内核直接创建的进程)可能不存在可执行的映像文件。
  • 私有虚拟地址空间 —— 供进程内部代码按需分配内存的隔离地址域。
  • access token (primary token)令牌 —— 存储进程安全上下文的对象,并且提供给线程用于执行(除非线程通过模仿使用假设的不同令牌)
  • 私有句柄表(private handle table) —— 用于管理进程执行对象的引用(事件、信号量、文件等)
  • 一个或多个执行线程 —— 常规用户模式进程在创建时默认包含一个线程(执行传统的main/WinMain函数)。不含线程的用户模式进程通常是无用的,在正常情况下会被内核销毁。

书中提到的相关结构关系图:

image-20250403224717688

PID:

process ID 为 进程的唯一标识,只要内核对象存在就是独一无二的。如果内核对象毁坏,那么这个ID可能会被分配个新的进程(process id 复用)。进程文件不能认为是进程的唯一标识。

Virtual Memory 虚拟内容

每个进程都有它自己的虚拟私有线性地址空间。

虚拟地址空间初试为空(或者接近空),可执行映像和NtDll.Dll会首先被映射,随后加载更多子系统DLL。

当主线程开始执行,会分配更多空间,加载更多的 DLLs。

这个虚拟机空间是私有的。

地址空间范围从零开始(严格来说,首尾各64KB空间无法提交),其上限取决于进程"位宽"(32位或64位)与操作系统位宽的配合关系

基础介绍:

  • 32位Windows 系统上的 32位进程,默认地址空间位 2GB。

  • 32位Windows 系统上的 32位进程,设置 increase user virtual address space 最大分配 3GB。且必须满足进程的可执行程序PE头设置 LARGEADDRESSAWARE链接器标志。

  • 64位程序(64位win上),这个地址为 8TB(win8 或 更早版本)或者 128TB(win 8.1 或之后版本呢)

  • 32位程序(64位win上),可执行程序PE头设置 LARGEADDRESSAWARE链接器标志,地址空间为 4GB,否者还是2GB。

为什么是 2G:

​ 因为在32位中,2GB地址空间仅需31位寻址,最高有效位(MSB/bit 31)原可供应用程序自由使用。

  • 2GB空间(0x00000000-0x7FFFFFFF):bit 31始终为0
  • 扩展空间(0x80000000-0xFFFFFFFF):bit 31=1

虚拟地址为相对的,和物理内存(RAM)间接映射。且虚拟是从执行角度看,若内存已经映射到RAM,那么CPU会在访问数据之前完成虚拟到物理地址的转换;若内存未驻留,由转换表条目中的标志位指示),CPU将触发缺页异常,使内存管理器的缺页处理程序:

  1. 从对应文件获取数据(当缺页有效时)
  2. 复制到RAM
  3. 更新映射该缓冲区的页表项
  4. 通知CPU重试操作

虚拟、物理地址映射如图:

image-20250403231103991

内存的基本管理单位为 页 page

内存相关的属性,均以页为单位进行管理,包含 保护标志或状态。

页大小与CPU架构有关。

Windows中标准页的大小为 4KB。

win还支持大页:

  • x86/x64/ARM64架构:2MB
  • ARM32架构:4MB

其实现原理是通过页目录项(PDE)直接映射大页,绕过页表层级。这种设计带来:

  1. 更快的地址转换(减少一次页表查询)
  2. 更高的TLB命中率(单个TLB条目可覆盖更大内存范围)

大页有什么缺点呢?

  1. 大页面需要在RAM中有连续的内存,如果内存紧张或非常碎片化,这可能会失败。
  2. 大页面总是不可分页的。
  3. 只能使用读/写保护。

在 win10 和 win server 2016 中,有巨页的机制,支持 1GB。如果分配的大小至少为1 GB,则会自动对大页面使用这些方法,并且该大小可以定位为RAM中的连续大小。

Page State

在虚拟内存中的每个也都有下面三种状态中的一种:

1. 空闲状态(Free)

  • 定义: 页面完全未分配,无任何有效内容。访问此类页面将触发访问违规异常ACCESS_VIOLATION)。
  • 典型场景: 新创建进程的绝大多数页面初始状态为 Free。
  • 内核数据结构: 通过 MMFREE_POOL_ENTRY 结构维护空闲页链表。

2. 已提交状态(Committed)

  • 定义: 页面已分配且可正常访问(前提是保护属性匹配,例如写入只读页仍会触发异常)。 提交页可能映射到:
    • 物理内存(RAM)
    • 文件映射(如页面文件、内存映射文件)

3. 保留状态(Reserved)

  • 定义: 页面未提交,但虚拟地址范围被预留,禁止其他分配器占用。 CPU视角:与 Free 状态行为一致(访问触发异常),但地址空间被锁定。
  • 核心用途
    • 维护虚拟地址连续性(如线程栈的渐进式提交)
    • 减少物理内存占用(延迟提交)

System Memory

  • 用户模式分区 (User-mode space)

  • 内核模式分区(User-mode space)

操作系统也必须驻留在某个地方——这个地方是系统支持的最高地址范围,如下所示:

  • 在没有设置 increase user virtual address space 的32位win系统上,虚拟空间上部分 2GB 0x80000000 to 0xFFFFFFFF

  • 如果设置 increase user virtual address space 的32位win系统, 3GB,对应的地址为 0xC0000000 to 0xFFFFFFFF

  • 64位的 Windows 8, Server 2012 and earlier,为 8TB

  • 64位的 Windows 8.1, Server 2012 R2 and later, 为128TB

user process 和 System space 如图结构

image-20250403235123204

系统空间和进程是没有关系的,它是为系统总的所有进程服务的相同驱动程序。

系统空间地址不是相对的,对于进程上下文中看起来都是相同的,当然,从用户模式到系统空间的实际访问会导致访问冲突异常。

系统空间是内核本身、硬件抽象层(HAL)以及已加载的内核驱动程序所在的区域。

用户模式进程在其生命周期结束后不会留下任何资源泄漏——内核会负责关闭和释放已终止进程的所有私有资源(包括关闭所有句柄和释放所有私有内存)。

Threads 线程

这是代码运行的实际实体,线程包含在进程中,使用进程公开的资源来完成工作(例如虚拟内存和内核对象的句柄)

  • 有对应运行模式,用户模式和内核模式
  • 执行上下文,包含寄存器和运行状态。
  • 一个或两个stack,用于本地变量分配和调用管理。
  • Thread Local Storage (TLS),提供了一种使用统一访问语义存储线程私有数据的方法
  • Base priority and a current (dynamic) priority.
  • 处理器亲和性,指示线程允许在哪个处理器上运行。

线程常见状态:

  • Running

  • Ready

  • Waiting

线程状态示意图:

image-20250405195947162

1. 堆栈的核心功能

  • 存储内容
    • 函数调用时的返回地址
    • 局部变量
    • 部分架构中传递的函数参数(如x86)
  • 执行依赖:线程执行期间必须依赖堆栈完成函数调用链和局部数据管理。

2. 双堆栈设计(用户态与内核态)

  • 内核堆栈(Kernel Stack)
    • 位置:始终位于内核空间。
    • 驻留性:线程处于运行/就绪状态时,强制驻留RAM(不可换出)。
    • 默认大小
      • 32位系统:12 KB
      • 64位系统:24 KB
    • 特性:固定大小,无动态扩展机制。
  • 用户堆栈(User Stack)
    • 位置:进程用户空间内。
    • 驻留性:可被页文件换出(与普通用户内存行为一致)。
    • 默认上限:通常可扩展至1 MB。
    • 动态扩展机制
      1. 初始提交:仅提交少量内存(如1个页面)。
      2. 保护页:相邻下一页标记为PAGE_GUARD,触发访问异常。
      3. 按需扩展
        • 线程访问保护页时触发缺页异常→内存管理器提交新页→更新保护页位置。
      4. 保留空间:未使用的地址空间仅保留(不占用物理内存)。

3. 多线程示例

  • 进程A:包含线程1、线程2(各自独立的用户堆栈)。

  • 进程B:包含线程3(堆栈与其他进程隔离)。

    image-20250420140235262

4. 设计优势

  • 内存效率:避免提前提交全部堆栈空间(如1 MB仅保留虚拟地址,按需分配物理页)。
  • 安全性:保护页机制防止堆栈溢出破坏相邻内存区域。

Windows大多数情况下 使用 三个保护页

image-20250420192049166

  1. 默认值由PE头决定 线程用户栈的默认保留大小初始提交大小由可执行文件的PE头定义。进程的主线程必须使用这些默认值,而其他线程若不指定参数,也会继承PE头的默认设置。
  2. 线程创建时可自定义堆栈大小 通过CreateThread等函数创建线程时,可单独指定初始提交大小或保留大小(需配合标志位选择),若传0则回退到PE头默认值。主线程的堆栈大小不可自定义,始终采用PE头设定。

函数CreateThread和CreateRemoteThread(Ex)只允许指定堆栈大小的单个值,可以是提交的或保留的大小,但不能两者都指定。本机(未记录)函数NtCreateThreadEx允许指定这两个值。

关键点总结表

特性 内核堆栈 用户堆栈
空间归属 内核地址空间 用户进程地址空间
物理内存驻留 始终驻留RAM 可被换出
大小 固定(12/24 KB) 动态扩展(默认上限1 MB)
扩展机制 不支持扩展 保护页触发的按需提交
多线程共享 每个线程独立 同一进程的线程间隔离

System Services 系统服务

应用程序需要执行各种不纯计算性的操作,例如分配内存、打开文件、创建线程等。这些操作最终只能由在内核模式下运行的代码执行。那么用户模式代码如何能够执行这些操作呢?

例子:使用 notepad 使用File / Open菜单请求打开一个文件。

1. 用户态API调用链

  • **应用程序(如Notepad)**调用 CreateFile(文档化的Windows API),该函数位于 kernel32.dll(Windows子系统DLL)。
    • 关键点CreateFile 仍在用户模式下运行,无法直接操作硬件或文件系统。
  • CreateFile 在参数校验后,调用 NtCreateFile(实际执行文件操作的底层函数)。
    • NtCreateFile 位于 NTDLL.dll(Windows Native API 的实现层),是用户模式下最接近内核的接口。
    • 用途NTDLL.dll 提供的Native API是Windows内核的“门面”,专为系统调用(syscall)设计。

2. 用户态到内核态的切换机制

  • 系统调用编号(Service Number)
    • NtCreateFile 在触发内核切换前,会将一个系统服务编号(唯一标识请求的操作)存入CPU寄存器(x86/x64架构下为 EAX)。
  • 特殊CPU指令
    • x86架构:执行 sysenter 指令。
    • x64架构:执行 syscall 指令。
    • 作用:通过硬件指令强制切换到内核模式,并跳转到内核预定义的系统服务分发器(System Service Dispatcher)。
  • 内核响应
    • 内核根据EAX中的编号,定位到对应的内核函数(如文件管理、设备驱动等),完成实际操作(如打开文件)。

image-20250420201127353

在进度内核层时,使用系统服务分发器(System Service Dispatcher)。

  • 当CPU通过syscall/sysenter进入内核模式后,控制权交给系统服务分发器
    • 获取 EAX 寄存处保存的 系统服务编号。
    • 查询SSDT获取对应的系统调用函数指针。
    • 跳转到对应 地址执行。
  • 调用完成返回用户模式。
    • CPU通过sysexit(x86)或sysret(x64)指令返回用户模式
    • 线程继续执行syscall/sysenter之后的指令(即NTDLL.dll中的后续代码)。
    • 返回值通过寄存器(如EAX)或栈传递回用户态,最终由CreateFile返回给应用程序(如Notepad)。

General System Architecture 系统架构

系统架构图:

image-20250420203107898

对应解释

  • 用户进程(User Processes)

​ 基于可执行文件运行进程。

​ 运行在用户模式,无法直接访问硬件或内核资源。

​ 通过调用子系统DLL 或 Native API 与 操作系统交互

  • 子系统DLL(Subsystem DLLs)

    实现win子系统 API 的动态链接库,提供内核功能的封装。

    提供官方文档化的Windows API(如CreateFileMessageBox)。

    kernel32.dll,user32.dll 等。

    从Windows 8.1起,仅保留Windows子系统(不再支持POSIX/OS2等旧子系统)。

  • NTDLL.DLL(Windows Native API层)

    用户模式下最底层的系统组件,核心功能有两个。

    1. 系统调用桥接
      • 实现NtCreateFileNtOpenProcessNative API,通过syscall/sysenter切换到内核模式。
      • 是用户态与内核态交互的唯一入口
    2. 关键用户态功能
      • 堆管理器(Heap Manager):管理进程内存分配(如malloc/free的底层实现)。
      • 映像加载器(Image Loader):加载DLL/EXE文件(解析PE结构、处理导入表)。
      • 用户态线程池:部分线程池逻辑在此实现。
  • 服务进程(Service Processes)

​ 与**服务控制管理器(SCM,services.exe)**交互的特殊进程。

​ 由SCM管理生命周期(启动、停止、暂停、恢复)接收SCM发送的控制命令。

​ 通常以以下内置账户运行:

Local System:最高权限,等同系统内核。

Network Service:网络相关操作权限。

Local Service:本地低权限服务。

  • 执行体(Executive)

    位于Ntoskrnl.exe(Windows内核镜像)的上层,是内核模式的主要代码载体。

    主要包含 对象管理器(Object Manager)内存管理器(Memory Manager)I/O管理器(I/O Manager)即插即用管理器(Plug & Play Manager)电源管理器(Power Manager)配置管理器(Configuration Manager)

    不涉及最底层硬件操作。提供结构化接口供驱动程序调用(如ExAllocatePool内存分配API)。

  • 内核层(Kernel)

Ntoskrnl.exe的下层,处理与硬件紧密交互的核心功能。

核心职责

线程调度:时间片分配、上下文切换(通过KiSwapThread等函数)。

中断/异常处理:CPU中断请求(IRQ)和异常的首次响应(如缺页异常)。

同步原语实现:自旋锁(Spinlock)、互斥体(Mutex)、信号量(Semaphore)的原子操作。

​ 部分代码直接以汇编实现。直接操作CPU寄存器

  • 设备驱动程序(Device Drivers)

​ 动态加载的内核模块(.sys文件),扩展内核功能。

​ 代码运行在内核模式,拥有与Executive同级的权限(可访问所有内存)。

需要注意:处理IRP、内存安全(避免蓝屏)、与Executive的交互。

  • Win32k.sys(Windows子系统内核组件)

​ 将用户态GUI请求转换为内核操作。

主要负责:窗口管理、图形渲染。

​ 其他内核组件(如内存管理器)对UI无感知,通过Win32k.sys抽象交互。

​ 现代Windows中,部分功能已迁移至DirectX/DWM(桌面窗口管理器)。

  • 硬件抽象层(HAL, Hardware Abstraction Layer)

​ 屏蔽硬件差异,为驱动提供统一硬件接口。

​ 抽象关键硬件组件:中断控制器(APIC/IOAPIC)、DMA控制器、时钟管理(HPET/ACPI PM Timer)

  • 系统进程(System Processes)

    维持OS核心功能的原生进程,特点包括:

    • 仅依赖NTAPI(不调用kernel32.dll等子系统API)
    • 多数以SYSTEM权限运行
    • 终止会导致系统崩溃(蓝屏)
  • 子系统进程(Subsystem Process)

​ Windows子系统(唯一现存子系统)的用户态管理进程,协助内核管理子系统相关功能。

​ 负责进程/线程的子系统级初始化控制台支持(如cmd.exe)和异常处理转发

​ Csrss.exe是Windows子系统的“用户态管家”,每会话独立运行,崩溃会导致系统蓝屏,是操作系统稳定性的关键组件之一。

  • Hyper-V 管理程序与基于虚拟化的安全性(Hyper-V Hypervisor)

​ Hyper-V 管理程序在支持虚拟化安全(VBS)的 Windows 系统中运行,将常规操作系统作为虚拟机管理,并通过两个虚拟信任级别(VTL)增强安全性——VTL 0 运行普通系统和内核,而 VTL 1 运行安全内核和隔离用户模式,为关键安全功能提供硬件级防护。这种架构能有效防御内核攻击,但需要特定硬件支持并可能略微影响性能。

Handles and Objects

​ Windows内核通过对象管理器为用户态进程、内核自身及驱动提供多种内核对象,这些对象本质是系统地址空间中的数据结构,使用Object manager 创建,采用引用计数机制管理生命周期——仅当最后一个引用释放时对象才会被销毁并释放内存。

(注:内核对象包括文件、线程、事件等类型,由用户态API或内核代码发起创建请求后,由执行体中的对象管理器统一构造和管理。)

​ 由于这些对象实例驻留在系统空间(内核内存),用户模式代码无法直接访问它们,必须通过一种间接访问机制——**句柄(handle)**来实现。句柄本质上是 进程私有句柄表(存储于内核空间)的索引项,该表每个进程独立维护,通过句柄表中的条目最终指向系统空间中的内核对象。

​ 例如:用户模式函数CreateMutex允许创建或打开互斥体对象(根据该命名对象是否存在而定)。若操作成功,函数将返回该对象的有效句柄;返回值为零则表示无效句柄(即函数调用失败)。而OpenMutex函数则尝试打开已命名的互斥体句柄——若指定名称的互斥体不存在,函数将返回空值(0)表示操作失败。

在内核中,可以直接使用 Handle 也可以 直接通过指针访问 Object,具体选择通常取决于 调用的 API 类型。某些情况下,当驱动程序需要将用户模式传递来的句柄转换为有效指针时,必须调用ObReferenceObjectByHandle函数来完成转换。

句柄值是4的倍数,其中第一个有效句柄为4;且0为无效句柄。

需要注意两点:

  1. 防悬垂指针 即使用户态持有者关闭了句柄,内核代码持有的指针仍能安全访问对象——因为引用计数保证了对象存活。
  2. 显式释放责任 内核代码必须通过ObDereferenceObject递减引用计数,否则会导致资源泄漏(该泄漏仅能通过系统重启回收)

对于内核对象的生命周期管理,主要Object Manager 维护了两个关键技术:句柄计数 和 总引用计数。

当对象不再需要时:

  1. 用户态程序应调用CloseHandle关闭句柄
  2. 内核态代码需调用ObDereferenceObject递减引用

此后,必须视该句柄/指针为永久失效状态。当对象的引用计数归零时,对象管理器将自动销毁该对象并回收其内存资源。

Object Names

对象分为:无名对象 和 命名对象

并非所有对象都支持命名,例如:

  • 进程/线程对象仅通过数字ID标识(故 OpenProcess/OpenThread 需传入进程/线程ID)
  • 文件对象名称 ≠ 内核对象名称(二者属于不同维度的标识体系)

命名对象具备跨进程共享能力,例如:

  • 命名互斥体(Mutex)可用于进程间同步
  • 命名事件(Event)允许不同进程监听同一信号

在用户模式代码中,调用带有名称参数的 Create 系列函数(如 CreateMutex)时,系统会遵循以下行为规则:

  1. 对象不存在时 函数会创建并返回一个全新命名对象的句柄
  2. 对象已存在时 函数不会创建新对象,而是:
  • 返回指向现有对象的另一个有效句柄
  • 通过 GetLastError() 返回 ERROR_ALREADY_EXISTS(错误码 183)
  • 引用计数会增加,但不会重复创建同名对象

使用 WinObj 可以查看对应的 Object 对象

WinObj - Sysinternals | Microsoft Learn

使用管理员打开

image-20250422222305950

普通会话进程

名称会被添加 \Sessions\<会话ID>\BaseNamedObjects\ 前缀

会话0(服务会话)的对象直接使用 \BaseNamedObjects\ 前缀

AppContainer 进程

\Sessions\<会话ID>\AppContainerNamedObjects\{AppContainerSID}\</会话ID>

image-20250422222707225

进程句柄也可以使用 process explorer 进行查看。

image-20250422223255629

Accessing Existing Objects

  • 若要通过句柄终止进程,调用OpenProcess时必须包含至少PROCESS_TERMINATE权限位
  • 成功获取此类句柄后,后续调用TerminateProcess必然成功
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
bool KillProcess(DWORD pid) {
    //
    // open a powerful-enough handle to the process
    //
    HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
    if (!hProcess)
    return false;
    //
    // now kill it with some arbitrary exit code
    //
    BOOL success = TerminateProcess(hProcess, 1);
    //
    // close the handle
    //
    CloseHandle(hProcess);
    return success != FALSE;
}
Licensed under CC BY-NC-SA 4.0
comments powered by Disqus