介绍
主要通过学习 《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函数)。不含线程的用户模式进程通常是无用的,在正常情况下会被内核销毁。
书中提到的相关结构关系图:
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将触发缺页异常,使内存管理器的缺页处理程序:
- 从对应文件获取数据(当缺页有效时)
- 复制到RAM
- 更新映射该缓冲区的页表项
- 通知CPU重试操作
虚拟、物理地址映射如图:
内存的基本管理单位为 页 page 。
内存相关的属性,均以页为单位进行管理,包含 保护标志或状态。
页大小与CPU架构有关。
Windows中标准页的大小为 4KB。
win还支持大页:
- x86/x64/ARM64架构:2MB
- ARM32架构:4MB
其实现原理是通过页目录项(PDE)直接映射大页,绕过页表层级。这种设计带来:
- 更快的地址转换(减少一次页表查询)
- 更高的TLB命中率(单个TLB条目可覆盖更大内存范围)
大页有什么缺点呢?
- 大页面需要在RAM中有连续的内存,如果内存紧张或非常碎片化,这可能会失败。
- 大页面总是不可分页的。
- 只能使用读/写保护。
在 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 如图结构
系统空间和进程是没有关系的,它是为系统总的所有进程服务的相同驱动程序。
系统空间地址不是相对的,对于进程上下文中看起来都是相同的,当然,从用户模式到系统空间的实际访问会导致访问冲突异常。
系统空间是内核本身、硬件抽象层(HAL)以及已加载的内核驱动程序所在的区域。
用户模式进程在其生命周期结束后不会留下任何资源泄漏——内核会负责关闭和释放已终止进程的所有私有资源(包括关闭所有句柄和释放所有私有内存)。
Threads 线程
这是代码运行的实际实体,线程包含在进程中,使用进程公开的资源来完成工作(例如虚拟内存和内核对象的句柄)
- 有对应运行模式,用户模式和内核模式
- 执行上下文,包含寄存器和运行状态。
- 一个或两个stack,用于本地变量分配和调用管理。
- Thread Local Storage (TLS),提供了一种使用统一访问语义存储线程私有数据的方法
- Base priority and a current (dynamic) priority.
- 处理器亲和性,指示线程允许在哪个处理器上运行。
线程常见状态:
-
Running
-
Ready
-
Waiting
线程状态示意图:
1. 堆栈的核心功能
- 存储内容:
- 函数调用时的返回地址
- 局部变量
- 部分架构中传递的函数参数(如x86)
- 执行依赖:线程执行期间必须依赖堆栈完成函数调用链和局部数据管理。
2. 双堆栈设计(用户态与内核态)
- 内核堆栈(Kernel Stack):
- 位置:始终位于内核空间。
- 驻留性:线程处于运行/就绪状态时,强制驻留RAM(不可换出)。
- 默认大小:
- 32位系统:12 KB
- 64位系统:24 KB
- 特性:固定大小,无动态扩展机制。
- 用户堆栈(User Stack):
- 位置:进程用户空间内。
- 驻留性:可被页文件换出(与普通用户内存行为一致)。
- 默认上限:通常可扩展至1 MB。
- 动态扩展机制:
- 初始提交:仅提交少量内存(如1个页面)。
- 保护页:相邻下一页标记为
PAGE_GUARD
,触发访问异常。 - 按需扩展:
- 线程访问保护页时触发缺页异常→内存管理器提交新页→更新保护页位置。
- 保留空间:未使用的地址空间仅保留(不占用物理内存)。
3. 多线程示例
-
进程A:包含线程1、线程2(各自独立的用户堆栈)。
-
进程B:包含线程3(堆栈与其他进程隔离)。
4. 设计优势
- 内存效率:避免提前提交全部堆栈空间(如1 MB仅保留虚拟地址,按需分配物理页)。
- 安全性:保护页机制防止堆栈溢出破坏相邻内存区域。
Windows大多数情况下 使用 三个保护页
- 默认值由PE头决定 线程用户栈的默认保留大小和初始提交大小由可执行文件的PE头定义。进程的主线程必须使用这些默认值,而其他线程若不指定参数,也会继承PE头的默认设置。
- 线程创建时可自定义堆栈大小
通过
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)。
- x86架构:执行
- 内核响应:
- 内核根据EAX中的编号,定位到对应的内核函数(如文件管理、设备驱动等),完成实际操作(如打开文件)。
在进度内核层时,使用系统服务分发器(System Service Dispatcher)。
- 当CPU通过
syscall
/sysenter
进入内核模式后,控制权交给系统服务分发器。- 获取 EAX 寄存处保存的 系统服务编号。
- 查询SSDT获取对应的系统调用函数指针。
- 跳转到对应 地址执行。
- 调用完成返回用户模式。
- CPU通过
sysexit
(x86)或sysret
(x64)指令返回用户模式。 - 线程继续执行
syscall
/sysenter
之后的指令(即NTDLL.dll
中的后续代码)。 - 返回值通过寄存器(如EAX)或栈传递回用户态,最终由
CreateFile
返回给应用程序(如Notepad)。
- CPU通过
General System Architecture 系统架构
系统架构图:
对应解释
-
用户进程(User Processes)
基于可执行文件运行进程。
运行在用户模式,无法直接访问硬件或内核资源。
通过调用子系统DLL 或 Native API 与 操作系统交互
-
子系统DLL(Subsystem DLLs)
实现win子系统 API 的动态链接库,提供内核功能的封装。
提供官方文档化的Windows API(如
CreateFile
、MessageBox
)。kernel32.dll,user32.dll 等。
从Windows 8.1起,仅保留Windows子系统(不再支持POSIX/OS2等旧子系统)。
-
NTDLL.DLL(Windows Native API层)
用户模式下最底层的系统组件,核心功能有两个。
- 系统调用桥接:
- 实现
NtCreateFile
、NtOpenProcess
等Native API,通过syscall
/sysenter
切换到内核模式。 - 是用户态与内核态交互的唯一入口。
- 实现
- 关键用户态功能:
- 堆管理器(Heap Manager):管理进程内存分配(如
malloc
/free
的底层实现)。 - 映像加载器(Image Loader):加载DLL/EXE文件(解析PE结构、处理导入表)。
- 用户态线程池:部分线程池逻辑在此实现。
- 堆管理器(Heap Manager):管理进程内存分配(如
- 系统调用桥接:
-
服务进程(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
权限运行 - 终止会导致系统崩溃(蓝屏)
- 仅依赖NTAPI(不调用
-
子系统进程(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为无效句柄。
需要注意两点:
- 防悬垂指针 即使用户态持有者关闭了句柄,内核代码持有的指针仍能安全访问对象——因为引用计数保证了对象存活。
- 显式释放责任
内核代码必须通过
ObDereferenceObject
递减引用计数,否则会导致资源泄漏(该泄漏仅能通过系统重启回收)
对于内核对象的生命周期管理,主要Object Manager 维护了两个关键技术:句柄计数 和 总引用计数。
当对象不再需要时:
- 用户态程序应调用
CloseHandle
关闭句柄 - 内核态代码需调用
ObDereferenceObject
递减引用
此后,必须视该句柄/指针为永久失效状态。当对象的引用计数归零时,对象管理器将自动销毁该对象并回收其内存资源。
Object Names
对象分为:无名对象 和 命名对象
并非所有对象都支持命名,例如:
- 进程/线程对象仅通过数字ID标识(故 OpenProcess/OpenThread 需传入进程/线程ID)
- 文件对象名称 ≠ 内核对象名称(二者属于不同维度的标识体系)
命名对象具备跨进程共享能力,例如:
- 命名互斥体(Mutex)可用于进程间同步
- 命名事件(Event)允许不同进程监听同一信号
在用户模式代码中,调用带有名称参数的 Create 系列函数(如 CreateMutex)时,系统会遵循以下行为规则:
- 对象不存在时 函数会创建并返回一个全新命名对象的句柄
- 对象已存在时 函数不会创建新对象,而是:
- 返回指向现有对象的另一个有效句柄
- 通过 GetLastError() 返回 ERROR_ALREADY_EXISTS(错误码 183)
- 引用计数会增加,但不会重复创建同名对象
使用 WinObj 可以查看对应的 Object 对象
WinObj - Sysinternals | Microsoft Learn
使用管理员打开
普通会话进程
名称会被添加 \Sessions\<会话ID>\BaseNamedObjects\
前缀
会话0(服务会话)的对象直接使用 \BaseNamedObjects\
前缀
AppContainer 进程
\Sessions\<会话ID>\AppContainerNamedObjects\{AppContainerSID}\</会话ID>
进程句柄也可以使用 process explorer 进行查看。
Accessing Existing Objects
- 若要通过句柄终止进程,调用
OpenProcess
时必须包含至少PROCESS_TERMINATE
权限位 - 成功获取此类句柄后,后续调用
TerminateProcess
必然成功
|
|