SandBox学习笔记
SandBox笔记
[转]Sandboxie 的工作原理 - Lance~ -
博客园
(不懂的内容大部分结合ai以及博客学习)
SecBoxie使用Ring3用户态拦截与Ring0内核态强制重定向的分层隔离架构,Ring3层主动监控、拦截目标进程并将其挂起,然后通过Shell
Code注入将Detours
Hook引擎以及与Ipc相关的自定义Hook处理函数写入目标进程当中并执行Hook,完成Hook之后恢复进程运行,IpcHook函数会接管IPC的请求如函数NtCreateEvent,调用GetObjectName函数解析IPC对象原始路径(TruePath),剥离系统默认命名空间前缀,拼接专属根路径(\Sandbox\SecBoxie\)生成CopyPath,并通过IOCTL发送到驱动,驱动将原始路径(TruePath)强制替换为沙箱重定向路径(CopyPath),并为
CopyPath
创建独立的内核对象命名空间,使目标进程只能访问该命名空间下的IPC对象,无法看到主机目录下的任何对象,主机也无法访问CopyPath命名空间内的对象,实现双向隔离。驱动再把创建好的事件句柄返回给Ring3的IpcHook函数进而再给到目标进程,从而实现路径重定向隔离目标进程,整个过程对目标进程完全透明,在目标进程中无需修改代码,实现无感知拦截。
6 进程回调
程序起来后,sandbox对启动的进程注入+hook
实现监控
这个地方的内存存储方式知识点挺好,但时间关系先略过
(映射相关的)
下面这个地方有个进程回调通知:当你进程一启动,你的驱动就被调度了,进程被拦截
就走进程回调:Process_NotifyProcessEx
如何理解进程回调?
双击一个exe在用户层启动的时候
我们的驱动在操作系统,所以会跑到操作系统来问,我们这个exe能不能启动
下面有一个回调函数Callback,已准备启动exe,就会来这调用CallBack,来判断到底能不能启动
(图中是在注册进程回调,一个win7版本,一个是xp,visata)
当驱动卸载时,回调也卸载,注意要卸载回调,不然会内存泄漏
if (__OsVersion >= WINDOWS_7) {
Status =
PsSetCreateProcessNotifyRoutineEx(ProcessNotifyProcedure, FALSE);
}
#ifdef XP_SUPPORT
else { // XP, Vista
Status =
PsSetCreateProcessNotifyRoutine(ProcessNotifyProcedure, FALSE);
}
然后下面还有一个模块回调,进程启动之后,会加载模块(exe,ntdll),可以允许你加载或者拒绝)
Status = PsSetLoadImageNotifyRoutine(ImageNotifyProcedure);
if (NT_SUCCESS(Status))
__IsImageNotifyProcedure = TRUE;
else {
return FALSE;
}
沙盒就相当于使用进程回调去判断这个进程是否该被启动,然后在进程启动的时候就把我们的Dll注入进去,然后hook掉他的系统函数,当他想要去执行一些系统操作的时候,就会跳转到我们的hook部分,这样我们能知道这个进程想要去干啥,根据他的行为去做破坏
回调函数一般都是静态,担心你放在类里(但我们没有设置静态,设置静态的话,别人用不成)
void ProcessNotifyProcedure(
PEPROCESS Process, HANDLE ProcessIdentity,
PPS_CREATE_NOTIFY_INFO CreateInfo)
{
HANDLE v1 = 0;
*/
//
// don't do anything before the main driver init says it's ok
//
if (!__ReadyToSandbox)
return;
//
// handle process creation and deletion. note that we are
running
// in an arbitrary thread context
//
if (ProcessIdentity) {
if (CreateInfo != NULL) {
v1 = PsGetCurrentProcessId();
if
(!ProcessNotifyProcedureCreate(ProcessIdentity,
CreateInfo->ParentProcessId, PsGetCurrentProcessId(), NULL))
//当一个进程启动
{
CreateInfo->CreationStatus = STATUS_ACCESS_DENIED;
//进程被拦截
}
}
else {
ProcessNotifyProcedureDelete(ProcessIdentity); //当一个进程销毁
}
}
}
在用WinDbg调试的时候,调到字符串,断点可能突然消失了
其实是在执行字符串相关的一些操作时,需要一定CPU时间
好方法是:在字符串操作下面下断点,直接跃过去
在Ring0,句柄是8开头(驱动级打开一个文件的特点)
注意进程回调的一个机关:!!!!!!!!!!!!!!!!!!!!!!!!
PLDR_DATA_TABLE_ENTRY v1 =
(PLDR_DATA_TABLE_ENTRY)(DriverObject->DriverSection);
v1->Flags |= 0x20;
记住这个结构
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY LoadOrder;
LIST_ENTRY MemoryOrder;
LIST_ENTRY InitializationOrder;
PVOID ModuleBaseAddress;
PVOID EntryPoint;
ULONG ModuleSize;
UNICODE_STRING FullModuleName;
UNICODE_STRING ModuleName;
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
union {
LIST_ENTRY Hash;
struct {
PVOID SectionPointer;
ULONG CheckSum;
};
};
ULONG TimeStamp;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;
WinDbg调试
查看当前计算机所有进程:
! process 0 0
第一步:找到地址 使用 dq
kd> dq ParentProcess
fffff880`0308edc0 fffffa80`615c1b30
第二步:查看结构
kd> dt _eprocess fffffa80`615c1b30
+0x2e0 ImageFileName : [15] "calc.exe"
(这个2e0 面试官可能会问,64位中
在32位是174,这两个偏移要记住)
kd> !process 0 0
PROCESS fffffa80615c1b30
SessionId: 1 Cid: 0bc8 Peb: 7fffffdf000
ParentCid: 0690
DirBase: 117cb0000 ObjectTable: fffff8a0044435c0
HandleCount: 0.
Image: calc.exe
kd> dt _PS_CREATE_NOTIFY_INFO fffff880`0308ee40
KSandBox!_PS_CREATE_NOTIFY_INFO
+0x000 Size : 0x48
+0x008 Flags : 1
+0x008 FileOpenNameAvailable : 0y1
+0x008 IsSubsystemProcess : 0y0
+0x008 Reserved :
0y000000000000000000000000000000 (0)
+0x010 ParentProcessId : 0x00000000`00000690 Void
+0x018 CreatingThreadId : _CLIENT_ID
+0x028 FileObject : 0xfffffa80`61419ce0
_FILE_OBJECT
+0x030 ImageFileName : 0xfffff880`0308f680
_UNICODE_STRING "\??\C:\Windows\system32\calc.exe"
+0x038 CommandLine : 0xfffffa80`615d9070
_UNICODE_STRING ""C:\Windows\system32\calc.exe" "
+0x040 CreationStatus : 0n0
演示:
这个Flags为1代表进程启动
如果为0 代表进程消亡
面试官可能会问:你怎么知道进程启没启动?
里面ParentId 690 是桌面 (calc.exe靠桌面启动的)
(内核级的上下背景文切换)
驱动不是进程,只是一个模块
一般获得所处的环境 System.exe 4号进程
但你进程回调函数拦截,被加载过去,这时候再获得所处的环境就是对方了
有父进程启动了进程回调,传参传的是子进程的id
父进程的id放在createInfo
此时运行驱动的环境已经从System到父进程上了
进程Id用HADNLE定义
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7
BOOLEAN ProcessNotifyProcedureCreate(
HANDLE ProcessIdentity, HANDLE ParentProcessIdentity, HANDLE
CallerIdentity, VOID* Box)
{
void* v1, *v2;
ULONG v10, v20;
WCHAR* ImageName, * nptr2;
const WCHAR* ImagePath;
BOOLEAN parent_was_start_exe = FALSE;
BOOLEAN parent_had_rights_dropped = FALSE;
BOOLEAN parent_was_image_from_box = FALSE;
BOOLEAN process_is_forced = FALSE;
BOOLEAN add_process_to_job = FALSE;
BOOLEAN IsCreateTerminated = FALSE;
BOOLEAN IsHostInject = FALSE;
KIRQL Irql;
BOOLEAN added_to_dfp_list = FALSE;
BOOLEAN IsCheckForcedProgram = FALSE;
GetProcessName(
__Pool, (ULONG_PTR)ProcessIdentity, &v1, &v10,
&ImageName);
if (!v1) {
// Process_CreateTerminated(ProcessIdentity, -1);
return FALSE;
}
ImagePath = ((UNICODE_STRING*)v1)->Buffer;
if (!_wcsicmp(ImageName,
L"notepad.exe")||!_wcsicmp(ImageName, L"Test.exe"))
{
if (0)
{
}
else if (!Box)
{
//创建一个Box
//测试
//查看父进程信息
PROCESS* v1 =
FindProcess(CallerIdentity, &Irql);
if (!(v1 && !v1->IsHostInject)
&& CallerIdentity != ParentProcessIdentity)
{
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
if (v1 && !v1->IsHostInject)
{
}
else
{
IsCheckForcedProgram =
TRUE;
}
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
if (IsCheckForcedProgram)
{
const WCHAR* SidString = NULL;
//WCHAR ImagePath110[0x1000] = {
L"C:\\windows\\notepad.exe" }; 测试
Box =
GetForcedStartBox(ProcessIdentity, ParentProcessIdentity, ImagePath,
&IsHostInject, SidString);
/*
kd> db fffff8a0`05810b90
fffff8a0`05810b90 6e 00 6f 00 74 00 65 00-70 00 61 00 64 00 2e 00
n.o.t.e.p.a.d...
fffff8a0`05810ba0 65 00 78 00 65 00 00 00-00 00 00 00 00 00 00 00
e.x.e........
*/
if (Box == (BOX*)-1) {
IsCreateTerminated =
TRUE;
Box = NULL;
}
else if (Box) {
if (IsHostInject) {
}
else {
}
}
}
if (Box)
{
//通过Box和拦截住的信息创建Process
//获取进程启动时间
{
PROCESS* Process =
CreateProcess(ProcessIdentity, Box, ImagePath, &Irql);
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
ULONG64 CreateTime =
Process->CreateTime;
if (Process)
{
if
(!InjectProcessRequest(
ProcessIdentity, 0, CreateTime, ImageName, FALSE, FALSE))
{
}
}
}
}
FreeMemoryEx(v1, v10);
}
return TRUE;
}
分析:
GetProcessName(
__Pool, (ULONG_PTR)ProcessIdentity, &v1, &v10,
&ImageName);
if (!v1) {
v1指向Unicode,v10存的是长度
GetProcessName
void GetProcessName(
POOL* Pool, ULONG_PTR ProcessIdentity,
void** VirtualAddress, ULONG* ViewSize, WCHAR** BufferData)
{
NTSTATUS Status;
OBJECT_ATTRIBUTES ObjectAttributes;
CLIENT_ID ClientIdentity;
HANDLE ProcessHandle;
ULONG v7;
*VirtualAddress = NULL;
*ViewSize = 0;
*BufferData = NULL;
if (!ProcessIdentity)
return;
InitializeObjectAttributes(&ObjectAttributes,
NULL, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
NULL, NULL);
ClientIdentity.UniqueProcess = (HANDLE)ProcessIdentity;
ClientIdentity.UniqueThread = 0;
Status = ZwOpenProcess(
&ProcessHandle, 0x400, &ObjectAttributes,
&ClientIdentity); //PROCESS_QUERY_INFORMATION
if (!NT_SUCCESS(Status))
return;
Status = ZwQueryInformationProcess(
ProcessHandle, ProcessImageFileName, NULL, 0, &v7);
if (Status == STATUS_INFO_LENGTH_MISMATCH)
{
ULONG v8 = v7 + 8 + 8; //申请的内存大小
//ushort length
//ushort maxLength
//wchar* Buffer NULL
//C:\windows\system32\calc.exe
//
//
UNICODE_STRING* v1 =
AllocateMemoryEx(Pool,v8,FALSE);
if (v1) {
v1->Buffer = NULL;
Status = ZwQueryInformationProcess(
ProcessHandle,
ProcessImageFileName, v1, v7 + 8, &v7);
if (NT_SUCCESS(Status) &&
v1->Buffer)
{
WCHAR* v2;
v1->Buffer[v1->Length
/ sizeof(WCHAR)] = L'\0';
if
(!v1->Buffer[0])
{
v1->Buffer[0] = L'?';
v1->Buffer[1] = L'\0';
}
v2 =
wcsrchr(v1->Buffer, L'\\');
if (v2) {
++v2;
if
(!*v2)
v2 = v1->Buffer;
}
else
v2 =
v1->Buffer;
*VirtualAddress = v1;
*ViewSize = v8;
*BufferData = v2;
}
else
FreeMemory(v1, v8);
}
}
ZwClose(ProcessHandle);
}
OA格式化
InitializeObjectAttributes(&ObjectAttributes,
NULL, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
NULL, NULL);
然后给Clientid赋值
然后在驱动层里面打开子进程(虽然被拦截了,但还是能够打开它)
Status = ZwOpenProcess(
&ProcessHandle, 0x400, &ObjectAttributes,
&ClientIdentity); //PROCESS_QUERY_INFORMATION
打开之后OA里面就有值了
ZwOpenProcess第二个参数是打开的目的 0x400 是 PROCESS_QUERY_INFORMATION
然后查询进程
Status = ZwQueryInformationProcess(
ProcessHandle, ProcessImageFileName, NULL, 0, &v7);
第三个参数NULL,代表是否传递内存(NULL,代表不传递)
v7是查询的Length,然后用v7去申请内存
ULONG v8 = v7 + 8 + 8; //申请的内存大小
UNICODE_STRING有对齐粒度
(前两个是 2+2 = 4,但要按8,64位对齐,8+8
上面两个凑8,最后一个8)
然后我们需要让Wchar*指针指向路径
//ushort length
//ushort maxLength
//wchar* Buffer NULL
//C:\windows\system32\calc.exe
//
//
UNICODE_STRING* v1 =
AllocateMemoryEx(Pool,v8,FALSE);
FALSE是不要标志
这时候我们继续查询信息
这时候v1代表内存,存放位置
v7+8代表,越过UNICODE_STRING前两个成员
想要把路径放到第三个成员的位置
v1->Buffer = NULL;
Status = ZwQueryInformationProcess(
ProcessHandle,
ProcessImageFileName, v1, v7 + 8, &v7);
BOOLEAN InjectProcessRequest(
HANDLE ProcessIdentity, ULONG SessionIdentity, ULONG64 CreateTime,
const WCHAR* ImageName, BOOLEAN add_process_to_job, BOOLEAN
IsHostInject)
分析:ProcessId往这个注
SessionId是用户Id,切换上下背景文的(eg.我们可以用管理员身份或用户名身份运行,这个管理员和用户名就是SessionId)
CreateTime是进程创建时间,ImageName是进程名
然后首先初始化OA,初始化属性
InitializeObjectAttributes(&ObjectAttributes,
NULL, OBJ_CASE_INSENSITIVE |
OBJ_KERNEL_HANDLE, NULL, NULL);
然后以ProcessQuery身份打开这个进程
ClientIdentity.UniqueThread = NULL;
ClientIdentity.UniqueProcess = ProcessIdentity;
Status = ZwOpenProcess(&ProcessHandle, 0x400,
//PROCESS_QUERY_INFORMATION,
&ObjectAttributes, &ClientIdentity);
然后再打开,查询这个进程是32还是64
因为在64位系统上,启动的进程有32,64
在32,就是32
if (NT_SUCCESS(Status)) {
//查询启动进程的位数
Status = ZwQueryInformationProcess(
ProcessHandle,
ProcessWow64Information,
&IsWow64,
sizeof(IsWow64), &ReturnLength);
ZwClose(ProcessHandle);
}
进程消息结构
typedef struct _PROCESS_MESSAGE_
{
ULONG ProcessIdentity;
ULONG SessionIdentity;
ULONG64 CreateTime;
BOOLEAN IsWow64;
BOOLEAN add_to_job;
BOOLEAN IsHostInject;
ULONG reason;
WCHAR ImageName[64];
}PROCESS_MESSAGE,*PPROCESS_MESSAGE;
然后将信息传给RIng3
//将该信息通过PortObject返回Ring3
if (!SendLpcMessage(INJECT_PROCESS,
sizeof(ProcessContext), &ProcessContext))
Status = STATUS_SERVER_DISABLED;
这里是Lpc端口,做通信,有点像套接字,Lpc也能做进程间通信
因为它这个注入想要实现从驱动层向Ring3层实现注入,通过Lpc向Ring3传数据
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
8
梳理一下流程:
目前是当有进程启动,我们会创建进程回调
然后使用Lpc与Ring3层进行通信
InitializeCommonData
资源锁?
Irp ,FastIo都是属于ring3和Ring0的通信方法(一般情况 FastIo不用)
//创建设备对象
RtlInitUnicodeString(&v1, DEVICE_NAME);
Status = IoCreateDevice(
__DriverObject, 0, &v1,
FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN,
FALSE,
&_DeviceObject);
if (!NT_SUCCESS(Status)) {
_DeviceObject = NULL;
return FALSE;
}
面试官经常问:问什么设备对象标志要进行&运算?
_DeviceObject->Flags &= ~DO_DEVICE_INITIALIZING;
本来有这个东西,现在给他去掉
DO_DEVICE_INITIALIZING 在DEVICE_OBJECT的Flags字段中
本来这个Flags大多的情况下都是在设置IO方式,如DO_BUFFERED_IO,
但特殊的位也可能需要在这里设置。
用处是防止当自己的设备对象初始化完成之前,别的模块来发送信息给自己的模块的。
如果程序仅在DriverEntry中创建DeviceObject的话,那么当前位将由IO管理器清除,
如果当前DeviceObject不是在DriverEntry中创建的,那么就要由程序员自己来清除。
主要用于PNP设备,以及过滤设备一类设备的安全创建中。
设备对象,双字
#define DEVICE_NAME L"\\Device\\KSandBox"
二维指针数组,里面存放函数的地址
LPFN_COMMONSERVICE* __CommonServices = NULL; //是一个二维指针动态存储一个连续内存的函数指针的数组
而且里面存放的类型都是LPFN_COMMONSERVICE
typedef NTSTATUS(*LPFN_COMMONSERVICE)(PPROCESS Process, ULONG64*
ParameterData);
SetServicePort
NTSTATUS SetServicePort(PPROCESS Process, ULONG64* ParameterData)
我们在这个函数里面获取RIng3创建的端口句柄,ParameterData是从RIng3传过来的参数
ParameterData是个指针,指针的1号存的就是RIng3创建的端口句柄
Ring0层现在拿到它,往端口里面写数据Ring3就能接收到
HANDLE PortHandle = (HANDLE)(ULONG_PTR)ParameterData[1];
//Ring3创建的端口句柄
然后我们通过句柄得到对象PortObject
if (PortHandle) {
//通过句柄获取对象
Status = ObReferenceObjectByHandle(
PortHandle, 0,
*LpcPortObjectType, KernelMode,
&PortObject, NULL);
}
然后把对象的值保存到全局__PortObject中,用资源锁防止干扰。也获取PsGetCurrentProcessId,当前进程Id
KIRQL Irql;
EnterResourceLock(__CommonResourceLock, &Irql);
//保存对象到全局变量中
OldObject =
InterlockedExchangePointer(
&__PortObject,
PortObject);
InterlockedExchangePointer(
&__ProcessIdentity,
PsGetCurrentProcessId());
LeaveResourceLock(__CommonResourceLock, Irql);
if (OldObject)
ObDereferenceObject(OldObject);
RIng3 是SDK,不是黑窗口
程序入口时WinMain函数,需要进行修改
INT WINAPI _tWinMain(
_In_ HINSTANCE Instance,
_In_opt_ HINSTANCE PrevInstance,
_In_ PTSTR CmdLine,
_In_ INT CmdShow
一般情况下调用约定是WinApi
然后把子系统这个地方修改一下
而且Windows Service程序 ,不允许调试。
static CDriverAssist* m_Instance;
bool CDriverAssist::Initialize()
{
m_Instance = new CDriverAssist(); //单例模式
面试官喜欢问的:单例模式
单例模式:构造函数私有。确保对象只能有一个,不能有多个对象
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9
配Dll路径
masm不要打上
然后给Entry.asm
选自定义工具
32位
然后再弄个无入口点
最后是为了生成dll
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
10
$意思是当前
$+5是call指令是5个字节(e8)
InitializeInjection(类中) ----> InjectionPrepare(类外) ----->
InjectionPrepareInternal(类外)
typedef struct _MY_TARGETS
{
unsigned long long Start;
unsigned long long DataInfo;
unsigned long long DetourCode;
} MY_TARGETS;
Ring3的注入核心
ULONG InjecitonPrepareInternal(BOOLEAN IsWow64, void** TextAddress, ULONG*
TextLength,
ULONG* StartOffset, ULONG* DataInfoOffset, ULONG*
DetourCodeOffset)
ImageSectionHeader = IMAGE_FIRST_SECTION(ImageNtHeaders);
if (ImageNtHeaders->FileHeader.NumberOfSections < 2) return
Error;
if (strncmp((char*)ImageSectionHeader[0].Name,
INJECTION_SECTION, strlen(INJECTION_SECTION)) ||
strncmp((char*)ImageSectionHeader[v100].Name,
SYMBOL_SECTION, strlen(SYMBOL_SECTION))) {
return Error;
}
MyTargets =
(MY_TARGETS*)&BufferData[ImageSectionHeader[v100].PointerToRawData];
//文件粒度
if (StartOffset) *StartOffset = (ULONG)(MyTargets->Start -
ImageBase - ImageSectionHeader[0].VirtualAddress);
if (DataInfoOffset) *DataInfoOffset =
(ULONG)(MyTargets->DataInfo - ImageBase -
ImageSectionHeader[0].VirtualAddress);
if (DetourCodeOffset) *DetourCodeOffset =
(ULONG)(MyTargets->DetourCode - ImageBase -
ImageSectionHeader[0].VirtualAddress);
*TextAddress = BufferData +
ImageSectionHeader[0].PointerToRawData; //Old version: head;
*TextLength = ImageSectionHeader[0].SizeOfRawData; //Old
version: (ULONG)(ULONG_PTR)(tail - head);
xor指令:2个字节
xor rdx, rdx
nop -----90
在InjectionPrepare里
__LdrInitializeThunk = (ULONG_PTR)GetProcAddress(__Ntdll,
"LdrInitializeThunk");
if (!__LdrInitializeThunk)
return Error;
面试题:LdrInitializeThunk这个函数很重要,面试官会问具体作用
LdrInitializeThunk
LdrInitializeThunk()
Windows的Dll装入(除ntdll.dll外)和连接是通过ntdll.dll中的一个函数LdrInitializeThunk()实现的
在进入这个函数之前,目标 EXE 映像已经被映射到当前进程的用户空间,系统 DLL ntdll.dll 的映像也已经被映射, 但是并没有在 EXE 映像与
ntdll.dll 映像之间建立连接(实际上EXE 映像未必就直接调用 ntdll.dll 中的函数)。
LdrInitializeThunk()是 ntdll.dll 中不经连接就可进入的函数,实质上就是 ntdll.dll 的入口。除 ntdll.dll
以外,别的 DLL 都还没有被装入(映射)。此外,当前进程(除内核中的“进程控制块”EPROCESS
等数据结构外)在用户空间已经有了一个“进程环境块”PEB,以及该进程的第一个“线程环境块”TEB。这就是进入__true_LdrInitializeThunk()前的“当前形势”。
功能描述
1. 加载和初始化 DLL
在进入 LdrInitializeThunk 函数之前,目标 EXE 映像已经被映射到当前进程的用户空间,系统
DLL ntdll.dll 的映像也已经被映射,但尚未建立连接1。
2. 初始化进程环境块(PEB)
该函数首先检查当前进程的 PEB 是否已初始化,如果未初始化,则进行初始化,包括创建进程堆和加载器信息2。
3. 创建和管理模块队列
LdrInitializeThunk 还负责创建和管理三个模块队列:InLoadOrderModuleList、InMemoryOrderModuleList 和 InInitializationOrderModuleList,用于维护已加载模块的信息。
通过这些步骤,LdrInitializeThunk 确保了 DLL 的正确加载和初始化,为进程的正常运行提供了基础。
mov edi,edi
微软的函数开头都是这个热补丁指令
面试官喜欢问
作用
热补丁:在运行时修改函数行为。通过将 MOV EDI, EDI 修改为短跳转指令,再将其上方的 NOP 指令修改为长跳转指令,实现函数行为的动态修改。
效率优化:相比于两条 NOP 指令,执行一条 MOV 指令所需的 CPU 时钟周期更少,从而提高了效率。
DACL想增加牛逼的简历,就去学习
bool CDriverAssist::InitializePortAndThreads()
端口名用滴答随机数,防止冲突
端口名是双字
static const WCHAR* _PortName = L"\\RPC Control\\" NAME L"Port";
wsprintf(PortName, L"%s-internal-%d", _PortName, GetTickCount());
RtlInitUnicodeString(&v1, PortName);
初始化属性OA
并创建端口 获得句柄值
InitializeObjectAttributes(
&ObjectAttributes, &v1, OBJ_CASE_INSENSITIVE,
NULL, NULL);
Status = __NtCreatePort(
(HANDLE*)&m_PortHandle, &ObjectAttributes, 0,
MAX_MESSAGE_LENGTH, NULL);
在Ring3层
//创建端口
typedef
NTSTATUS(NTAPI* LPFN_NTCREATEPORT)(OUT PHANDLE PortHandle,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN ULONG MaxConnectInfoLength,
IN ULONG MaxDataLength,
IN ULONG MaxPoolUsage);
extern LPFN_NTCREATEPORT __NtCreatePort;
LPFN_NTCREATEPORT __NtCreatePort = NULL;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
11
这个创建的线程是RIng3接收Ring0的发来的消息
//动态申请线程句柄
n = (NUMBER_OF_THREADS) * sizeof(HANDLE);
m_ThreadHandles = (HANDLE*)HeapAlloc(GetProcessHeap(), 0, n);
if (!m_ThreadHandles)
{
return false;
}
ZeroMemory(m_ThreadHandles, n);
但是我们此时想要先找到Ring3将自己创建的端口传给Ring0
流程:
//Ring3
//1.创建Port通信 获得ProtHandle
InitializePortAndThreads()
//2.提供内存通过FastIo请求Ring0层调用__CommonServices[DriverVersion]
StartDriverAsync()
//3.将PortHandle通过FastIo请求传递给Ring0__CommonServices[SetServicePort]
StartDriverAsync()
//4.构建接收端口回来的数据的线程和等待处理数据的线程
InitializePortAndThreads()
//7.开启Ring0的进程拦截
StartDriverAsync()
//Ring0
//1.创建设备对象
InitializeCommonData()
//2.对Ring3的GetDriverVersion请求进行处理 FastIoDeviceControl()
//3.对Ring3的SetServicePort请求进行处理
PortHandle--->PortObject
//4.CreateProcessNotify拦截进程构建MessageData传送Ring3
面试官喜欢问的一个C++的参数列表
面试官可能问结构IO_STATUS_BLOCK
typedef struct _IO_STATUS_BLOCK {
union {
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
} IO_STATUS_BLOCK, * PIO_STATUS_BLOCK;
Status:真 假
Information: 为什么真,为什么假(通常和真相关,假一般是0)
利用IoctlRequestInternal打开Ring0设备
//打开Ring0设备
NTSTATUS IoctlRequestInternal(ULONG64* ParameterData)
{
NTSTATUS Status;
OBJECT_ATTRIBUTES ObjectAttributes;
UNICODE_STRING v1;
IO_STATUS_BLOCK IoStatusBlock;
if (ParameterData == NULL)
{ // close request as used by kmdutil
if (__DeviceHandle != INVALID_HANDLE_VALUE)
__NtClose(__DeviceHandle);
__DeviceHandle = INVALID_HANDLE_VALUE;
}
if (__DeviceHandle == INVALID_HANDLE_VALUE) {
//通过设备对象名获取设备对象句柄
RtlInitUnicodeString(&v1, DEVICE_NAME);
InitializeObjectAttributes(
&ObjectAttributes, &v1,
OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = __NtOpenFile(
&__DeviceHandle,
FILE_GENERIC_READ, &ObjectAttributes,
&IoStatusBlock,
FILE_SHARE_READ |
FILE_SHARE_WRITE | FILE_SHARE_DELETE,
0);
if (Status == STATUS_OBJECT_NAME_NOT_FOUND ||
Status == STATUS_NO_SUCH_DEVICE)
Status = STATUS_SERVER_DISABLED;
}
else
Status = STATUS_SUCCESS;
if (Status != STATUS_SUCCESS) {
__DeviceHandle = INVALID_HANDLE_VALUE;
}
else
{
if (0)
{
}
else
{
Status = __NtDeviceIoControlFile(
__DeviceHandle, NULL,
NULL, NULL, &IoStatusBlock,
IO_CTL_CODE_1,
ParameterData, sizeof(ULONG64) * 8, NULL, 0);
}
}
return Status;
}
注意这个地方
Status = __NtOpenFile(
&__DeviceHandle,
FILE_GENERIC_READ, &ObjectAttributes,
&IoStatusBlock,
FILE_SHARE_READ |
FILE_SHARE_WRITE | FILE_SHARE_DELETE,
0);
同步的话Status和IOSB里面的Status就是一样的
如果是异步的话,Status立马得到返回,IOSB不一定
因为请求发的是IO控制码(用于Ring3 和 Ring0通信)(当用Io控制码的时候,就只能用FastIo或者Irp),我们这里选择用FastIo进行通信
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
12
Irp请求----LinkName
FastIo -----DeviceName
static PDEVICE_OBJECT _DeviceObject = NULL; //Ring0 <--> Ring3 设备对象(Irp
FastIo)
volatile HANDLE __ProcessIdentity = NULL; //Ring0的Ring3
void* __PortObject = NULL; //Ring0
<--> Ring3 端口对象(端口通信)
设备对象用IO通信,使用Irp或者FastIo方式
端口是用Lpc通信,效率跟高一些
当Ring3向Ring0发送getversion(想调用这个函数,在CoomonSevice这个数组里面)这个请求,通过端口传递这些信息,靠函数FastIoDeviceControl
BOOLEAN FastIoDeviceControl(
FILE_OBJECT* FileObject, BOOLEAN Wait,
void* InputBuffer, ULONG InputBufferLength,
void* OutputBuffer, ULONG OutputBufferLength,
ULONG IoControlCode, IO_STATUS_BLOCK* IoStatus,
DEVICE_OBJECT* DeviceObject)
检测穿过来的v5可读,然后就申请内存进行拷贝到Bufferdata
ProbeForRead(
v5, sizeof(ULONG64) *
API_NUMBER_ARGS, sizeof(ULONG64));
MemoryZero(BufferData, sizeof(ULONG64) *
API_NUMBER_ARGS);
memcpy(BufferData, v5, v7);
ServiceIndex = (ULONG)BufferData[0];
第一个请求是 GET_VERSION
第二个请求是 SET_SERVICE_PORT
1.创建Port通信 获得ProtHandle
InitializePortAndThreads()
2.提供内存通过FastIo请求Ring0层调用__CommonServices[DriverVersion]
StartDriverAsync()
3.将PortHandle通过FastIo请求传递给Ring0__CommonServices[SetServicePort]
StartDriverAsync()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13
拦截进程
Ring3
4.构建接收端口回来的数据的线程和等待处理数据的线程
InitializePortAndThreads()
#define MAX_MESSAGE_LENGTH 328 Ring3和Ring0通过端口传递数据的大小
typedef struct _MESSAGE_DATA
{
void* ClassContext;
UCHAR BufferData[MAX_MESSAGE_LENGTH];
} MESSAGE_DATA;
以上两个结构体用来Lpc端口使用
使用ReceiveMessageThread等待Ring0通过端口回传数据,分析Ring0传过来的数据
void CDriverAssist::ReceiveMessageThread()
{
NTSTATUS Status;
HANDLE ThreadHandle;
DWORD ThreadIdentity;
MESSAGE_DATA* MessageData;
//等待Ring0通过端口回传数据
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
while (1) {
MessageData = (MESSAGE_DATA*)VirtualAlloc(0,
sizeof(MESSAGE_DATA), MEM_COMMIT, PAGE_READWRITE);
if (!MessageData)
{
break; // out of memory
}
Status = __NtReplyWaitReceivePort(m_PortHandle,
NULL, NULL, (PORT_MESSAGE*)MessageData->BufferData)
if (!m_PortHandle) { // service is
shutting down
VirtualFree(MessageData, 0,
MEM_RELEASE);
break;
}
//分析Ring0层回传的数据
MessageData->ClassContext = this;
ThreadHandle = CreateThread(NULL, 0,
::HandleMessageThread, (void*)MessageData, 0, &ThreadIdentity);
if (ThreadHandle)
CloseHandle(ThreadHandle);
else
VirtualFree(MessageData, 0,
MEM_RELEASE);
}
}
类外的接口去调用类里面的函数
void CDriverAssist::HandleMessageThread(void* ParameterData)
{
PORT_MESSAGE* PortMessage = (PORT_MESSAGE*)ParameterData;
//Null pointer checked by caller
if (PortMessage->u2.s2.Type != LPC_DATAGRAM)
{
return;
}
ULONG DataLength = PortMessage->u1.s1.DataLength;
if (DataLength < sizeof(ULONG)) {
return;
}
DataLength -= sizeof(ULONG);
ULONG* MessageData = (ULONG*)((UCHAR*)PortMessage +
sizeof(PORT_MESSAGE)); //回传上来的真正数据
ULONG Protocol = *MessageData;
++MessageData;
if (Protocol == INJECT_PROCESS) {
InjectProcessReply(MessageData);
}
}
HIVE
Ring3
1.创建Port通信 获得ProtHandle
InitializePortAndThreads()
2.提供内存通过FastIo请求Ring0层调用__CommonServices[DriverVersion]
StartDriverAsync()
3.将PortHandle通过FastIo请求传递给Ring0__CommonServices[SetServicePort]
StartDriverAsync()
4.构建接收端口回来的数据的线程和等待处理数据的线程
InitializePortAndThreads()
5.根据处理数据的结果进行派发
6.InjectLowReply
7.开启Ring0的进程拦截
StartDriverAsync()
Ring0
1.创建设备对象
InitializeCommonData()
2.对Ring3的GetDriverVersion请求进行处理 FastIoDeviceControl()
3.对Ring3的SetServicePort请求进行处理
PortHandle--->PortObject
4.拦截进程CreateProcessNotify构建MessageData传入Ring3
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
14 调试
Windgb中查栈回溯的指令: k
Ring0
BOOLEAN SendLpcMessage(ULONG Protocol, ULONG DataLength, void* MessageData)
{
UCHAR v5[MAX_MESSAGE_LENGTH];
PORT_MESSAGE* PortMessage = (PORT_MESSAGE*)v5;
void* PortObject;
KIRQL Irql;
BOOLEAN IsOk;
// abort if we know in advance that we won't have a service port
//自己的Ring3进程
if (!__ProcessIdentity)
return FALSE;
// prepare the request message structure. the data area of
the message
// follows the PORT_MESSAGE header. the first ULONG is the
msgid field,
// followed by the rest of the caller data
if (DataLength > DATA_LENGTH)
return FALSE;
MemoryZero(PortMessage, sizeof(PORT_MESSAGE));
PortMessage->u1.s1.DataLength = (USHORT)(DataLength +
sizeof(ULONG));
PortMessage->u1.s1.TotalLength = PortMessage->u1.s1.DataLength +
sizeof(PORT_MESSAGE);
PortMessage->u2.s2.Type = LPC_DATAGRAM;
*(ULONG*)(v5 + sizeof(PORT_MESSAGE)) = Protocol;
memcpy(v5 + sizeof(PORT_MESSAGE) + sizeof(ULONG), MessageData,
DataLength);
// send the message to SbieSvc on the LPC port
EnterResourceLock(__CommonResourceLock, &Irql);
PortObject = __PortObject;
if (PortObject)
ObReferenceObject(PortObject);
LeaveResourceLock(__CommonResourceLock, Irql);
if (PortObject)
{
// port must have a name, or LpcRequestPort will
fail
// see also core/svc/driverassist.cpp
NTSTATUS status = LpcRequestPort(PortObject,
PortMessage);
ObDereferenceObject(PortObject);
if (NT_SUCCESS(status))
IsOk = TRUE;
else
IsOk = FALSE;
}
else
IsOk = FALSE;
return IsOk;
}
在驱动中,系统函数声明就可以用,因为驱动中的虚拟内存空间都在一起
而在Ring3层,得导出,然后再使用
Irp FastIo 是从Ring3向Ring0发 (IO管理)
Lpc端口 是两边随时通信 ,效率也快(走的内存映射)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
15-3
Ring3
ULONG QuerySyscalls(BOOLEAN IsDriver)
if (IsDriver)
{
// Get a full sys call list from the driver
Status = IoctlRequest(QUERY_SYSCALLS, 1,
(ULONG_PTR)SyscallsData);
if (Status != 0)
return Status;
Ring3向驱动发送IO请求QUERY_SYSCALLS
驱动层会根据由Ring3发来的请求去找相应的函数(接下来到驱动曾写相关函数)
if (IsOk)
IsOk = InitializeSyscalls(); //!!
Ring0重载Ntdll
Ring0层拦截住进程之后,告诉Ring3层拦截的这个进程的Id,往这个进程注入程序,做注入程序的时候肯定会涉及Ntdll上的一些函数,但你要注意存在一种危险情况,就是当你驱动还没启动之前,有一个进程启动反注入你的Ring3层,感染Ntdll
所以Ring3层不用自己加载的Ntdll,而是请求Ring0层拷贝一份新的Ntdll
for (i = 0; i < NATIVE_FUNCTION_COUNT; ++i) {
SyscallEntry = GetSyscallEntry(Exports[i] + 2); //
+2 skip Nt prefix
if (!SyscallEntry)
return FALSE;
ServiceAddress = RvaToFoa(Ntdll,
SyscallEntry->ServiceOffset);
if (!ServiceAddress) {
return FALSE;
}
memcpy(__SyscallsCode + (i *
NATIVE_FUNCTION_SIZE), ServiceAddress, NATIVE_FUNCTION_SIZE);
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16重载Ntdll
核心函数InitializeSyscalls()
BOOLEAN GetSystemServiceInfo(void)
{
BOOLEAN IsOk = FALSE;
PDLL_ENTRY Ntdll;
UCHAR* ServiceName;
ULONG ServiceIndex, ServiceOffset;
ULONG v1 = 0;
ULONG NameLength = 0;
UCHAR* ServiceAddress1 = NULL; //获取服务的索引
void* ServiceAddress2 = NULL;
ULONG ParameterCount;
SYSCALL_ENTRY* SyscallEntry = NULL;
ULONG v7 = 0;
InitializeList(&__SyscallsList);
//
// prepare the approve and disabled lists
//
/*
LIST disabled_hooks;
Syscall_LoadHookMap(L"DisableWinNtHook", &disabled_hooks);
LIST approved_syscalls;
Syscall_LoadHookMap(L"ApproveWinNtSysCall",
&approved_syscalls);
*/
//
// scan each ZwXxx export in NTDLL
//
Ntdll = LoadDll(__Ntdll); //重载Ntdll
if (!Ntdll)
goto Exit;
ServiceOffset = GetProcAddress(Ntdll, "Zw", &ServiceName, &v1);
if (!ServiceOffset)
goto Exit;
while (ServiceOffset) {
if (ServiceName[0] != 'Z' || ServiceName[1]
!= 'w')
break;
ServiceName += 2;
// skip Zw prefix
for (NameLength = 0; (NameLength < 64) &&
ServiceName[NameLength]; ++NameLength)
;
//DbgPrint(" Found SysCall %s\n",
name);
//entry = NULL;
//
// we don't hook ZwXxx services which do not or may
not return to
// caller. this is because the invocation of
Syscall_Api_Invoke
// goes through IopXxxControlFile (called by
NtDeviceIoControlFile)
// which takes a reference on the file object for
our API device
// object, and no return means there is no
corresponding dereference
// for the file object. there could be other
nasty side effects.
//
// ZwXxx services: ZwContinue,
ZwCallbackReturn, ZwRaiseException,
// NtTerminateJobObject, NtTerminateProcess,
NtTerminateThread
//
#define IS_PROCEDURE_NAME(Length,Name) (NameLength == Length &&
memcmp(ServiceName, Name, Length) == 0)
if (IS_PROCEDURE_NAME(8, "Continue")
|| IS_PROCEDURE_NAME(10,
"ContinueEx")
|| IS_PROCEDURE_NAME(14,
"CallbackReturn")
|| IS_PROCEDURE_NAME(14,
"RaiseException")
|| IS_PROCEDURE_NAME(18,
"TerminateJobObject")
|| IS_PROCEDURE_NAME(16,
"TerminateProcess")
|| IS_PROCEDURE_NAME(15,
"TerminateThread")
) {
goto Next;
}
//
// on 64-bit Windows, some syscalls are fake, and
should be skipped
if (IS_PROCEDURE_NAME(15, "QuerySystemTime"))
goto Next;
// ICD-10607 - McAfee uses it to pass its own data
in the stack. The call is not important to us.
//if ( IS_PROC_NAME(14,
"YieldExecution")) // $Workaround$ - 3rd party fix
// goto next_zwxxx;
//
// the Google Chrome "wow_helper" process expects
NtMapViewOfSection
// to not be already hooked. although this is
needed only on 64-bit
// Vista, this particular syscall is not very
important to us, so
// for sake of consistency, we skip hooking it on
all platforms
//
//if ( IS_PROC_NAME(16,
"MapViewOfSection")) // $Workaround$ - 3rd party fix
// goto next_zwxxx;
//if (Syscall_HookMapMatch(name, name_len,
&disabled_hooks))
// goto next_zwxxx;
#undef IS_PROCEDURE_NAME
//
// analyze each ZwXxx export to find the service
index number
//
ServiceAddress1 = RvaToFoa(Ntdll,
ServiceOffset); //获取服务索引
if (ServiceAddress1)
{
ServiceIndex =
GetSystemServiceIndex(ServiceAddress1); // ssdt mov
/*
kd> u 00000000002d0ce0
00000000`002d0ce0 4c8bd1
mov r10,rcx
00000000`002d0ce3 b860000000
mov eax,60h
00000000`002d0ce8 0f05
syscall
00000000`002d0cea c3
ret
*/
if (ServiceIndex == -2) {
//
// if ZwXxx export is
not a real syscall, then skip it
//
goto Next;
}
if (ServiceIndex != -1) {
GetSystemServiceAddress(
ServiceIndex, &ServiceAddress2, &ParameterCount);
//DbgPrint("
Found SysCall: %s, pcnt %d; idx: %d\r\n", name, param_count,
syscall_index);
}
if (!ServiceAddress2)
{
goto Exit;
}
v7 = sizeof(SYSCALL_ENTRY) +
NameLength + 1;
SyscallEntry =
AllocateMemoryEx(__Pool, v7, TRUE);
if (!SyscallEntry)
goto Exit;
SyscallEntry->ServiceIndex =
(USHORT)ServiceIndex;
SyscallEntry->ParameterCount =
(USHORT)ParameterCount;
SyscallEntry->ServiceOffset =
ServiceOffset;
SyscallEntry->ServiceAddress =
ServiceAddress2;
SyscallEntry->SyscallHandler1 = NULL;
SyscallEntry->SyscallHandler2 = NULL;
#ifdef _M_AMD64
SyscallEntry->SyscallHandler3 = NULL;
#endif
SyscallEntry->NameLength =
(USHORT)NameLength;
memcpy(SyscallEntry->ServiceName,
ServiceName, NameLength);
SyscallEntry->ServiceName[NameLength] = '\0';
InsertAfter(&__SyscallsList, NULL,
SyscallEntry);
if (ServiceIndex > _MaxIndex)
_MaxIndex =
ServiceIndex;
}
Next:
ServiceOffset = GetProcAddress(Ntdll, NULL,
&ServiceName, &v1);
}
IsOk = TRUE;
if (_MaxIndex < 100) {
IsOk = FALSE;
}
if (_MaxIndex >= 500) {
IsOk = FALSE;
}
if (!IsOk)
_MaxIndex = 0;
Exit:
return IsOk;
}
路径
const WCHAR* __Ntdll = L"NTDLL.dll";
if (IsOk)
IsOk = InitializeSyscalls(); //!!
重载Ntdll,防止Ring3的各种钩子
Ntdll = LoadDll(__Ntdll); //重载Ntdll __Ntdll 路径
if (!Ntdll)
goto Exit;
ServiceOffset = GetProcAddress(Ntdll, "Zw", &ServiceName, &v1);
if (!ServiceOffset)
goto Exit;
DLL_ENTRY* LoadDll(const WCHAR* DllName)
{
NTSTATUS Status;
DLL_ENTRY* v1;
WCHAR DllPath[128];
UNICODE_STRING v2;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK IoStatusBlock; //请求完成后的结果
FILE_STANDARD_INFORMATION FileStarndardInfo;
IMAGE_DOS_HEADER* ImageDosHeader;
IMAGE_DATA_DIRECTORY* ImageDataDirectory;
ULONG LastError;
SIZE_T MappingSize;
//
// if we already have a loaded dll instance, return that
//
v1 = GetListHead(&_DllList);
while (v1) {
if (_wcsicmp(v1->DllName, DllName) == 0)
return v1;
v1 = GetNextNode(v1);
}
//
// otherwise we allocate a dll instance and attempt to initialize
it.
// if any error occurs during initialization, the return
dll->base
// and dll->exports will be null, and the instance should not be
used
//
v1 = AllocateMemoryEx(__Pool, sizeof(DLL_ENTRY),FALSE);
if (!v1) {
Status = STATUS_INSUFFICIENT_RESOURCES;
LastError = 0x11;
goto Exit;
}
MemoryZero(v1, sizeof(DLL_ENTRY));
wcscpy(v1->DllName, DllName);
//
// open the dll file and query its on-disk size
//
RtlStringCbPrintfW(DllPath, sizeof(DllPath),
L"\\SystemRoot\\System32\\%s", DllName); //wchar []
RtlInitUnicodeString(&v2, DllPath);
InitializeObjectAttributes(
&ObjectAttributes, &v2, OBJ_CASE_INSENSITIVE,
NULL, NULL);
//根据文件路径获取文件句柄
Status = ZwCreateFile(
&v1->FileHandle, FILE_GENERIC_READ,
&ObjectAttributes, &IoStatusBlock, NULL,
0, FILE_SHARE_READ | FILE_SHARE_WRITE |
FILE_SHARE_DELETE,
FILE_OPEN, FILE_NON_DIRECTORY_FILE |
FILE_SYNCHRONOUS_IO_NONALERT,
NULL, 0);
if (!NT_SUCCESS(Status)) {
v1->FileHandle = NULL;
LastError = 0x12;
goto Exit;
}
//根据文件句柄获取文件大小
Status = ZwQueryInformationFile(
v1->FileHandle, &IoStatusBlock,
&FileStarndardInfo,
sizeof(FILE_STANDARD_INFORMATION),
FileStandardInformation);
if (!NT_SUCCESS(Status)) {
LastError = 0x13;
goto Exit;
}
// create and map the dll according to its on-disk size
//文件映射 数据量大的时候
//根据文件大小创建映射句柄
Status = ZwCreateSection(
&v1->SectionHandle, SECTION_MAP_READ |
SECTION_QUERY, NULL,
&FileStarndardInfo.EndOfFile, PAGE_READONLY,
SEC_RESERVE, v1->FileHandle);
if (!NT_SUCCESS(Status))
{
v1->SectionHandle = NULL;
LastError = 0x14;
goto Exit;
}
v1->MappingBase = NULL;
MappingSize = (SIZE_T)FileStarndardInfo.EndOfFile.QuadPart;
//根据映射句柄获取虚拟地址
Status = ZwMapViewOfSection(
v1->SectionHandle, NtCurrentProcess(),
&v1->MappingBase, 0, 0, 0,
&MappingSize, ViewUnmap, 0, PAGE_READONLY);
if (!NT_SUCCESS(Status)) {
v1->MappingBase = NULL;
LastError = 0x15;
goto Exit;
}
//
// find the image exports directory in the mapped dll instance
//
ImageDataDirectory = NULL;
ImageDosHeader = (IMAGE_DOS_HEADER*)v1->MappingBase;
if (ImageDosHeader->e_magic == 'MZ' ||
ImageDosHeader->e_magic == 'ZM')
{
IMAGE_NT_HEADERS* ImageNtHeaders =
(IMAGE_NT_HEADERS*)((UCHAR*)ImageDosHeader + ImageDosHeader->e_lfanew);
if (ImageNtHeaders->Signature ==
IMAGE_NT_SIGNATURE) { // 'PE\0\0'
v1->ImageNtHeaders = ImageNtHeaders;
if
(ImageNtHeaders->OptionalHeader.Magic ==
IMAGE_NT_OPTIONAL_HDR32_MAGIC) {
IMAGE_NT_HEADERS32*
ImageNtHeader32 =
(IMAGE_NT_HEADERS32*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER32* ImageOptionalHeader32 =
&ImageNtHeader32->OptionalHeader;
ImageDataDirectory =
&ImageOptionalHeader32->DataDirectory[0];
v1->ImageBase =
ImageOptionalHeader32->ImageBase;
v1->SizeOfImage =
ImageOptionalHeader32->SizeOfImage;
}
else if
(ImageNtHeaders->OptionalHeader.Magic ==
IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
IMAGE_NT_HEADERS64*
ImageNtHeaders64 =
(IMAGE_NT_HEADERS64*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER64* ImageOptionalHeader64 =
&ImageNtHeaders64->OptionalHeader;
ImageDataDirectory =
&ImageOptionalHeader64->DataDirectory[0];
v1->ImageBase =
(ULONG_PTR)ImageOptionalHeader64->ImageBase;
v1->SizeOfImage =
ImageOptionalHeader64->SizeOfImage;
}
}
}
if (!ImageDataDirectory) {
Status = STATUS_UNSUCCESSFUL;
LastError = 0x16; // data_dirs still
null, must be an invalid image
goto Exit;
}
v1->ImageExportDirectory = (IMAGE_EXPORT_DIRECTORY*)RvaToFoa(
v1,
ImageDataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
if (!v1->ImageExportDirectory) {
LastError = 0x17; // could not
translate rva to addr
goto Exit;
}
//
// finalize
//
InsertAfter(&_DllList, NULL, v1);
LastError = 0;
Exit:
if (LastError)
{
if (v1->MappingBase)
ZwUnmapViewOfSection(NtCurrentProcess(), v1->MappingBase);
if (v1->SectionHandle)
ZwClose(v1->SectionHandle);
if (v1->FileHandle)
ZwClose(v1->FileHandle);
v1 = NULL;
}
return v1;
}
PE文件导出表结构,非常重要
AddressOfFunctions:存4个字节偏移
AddressOfOrdinals:ushort 2字节
ULONG GetProcAddress(
DLL_ENTRY* DllEntry, const UCHAR* SearchName, //zw
UCHAR** FoundName, ULONG* FoundIndex)
{
ULONG* AddressOfNames = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfNames);
ULONG* AddressOfFunctions = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfFunctions);
USHORT* AddressOfNameOrdinals = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfNameOrdinals);
ULONG i;
ULONG Offset = 0;
UCHAR* ServiceName = NULL;
if (AddressOfNames && AddressOfFunctions &&
AddressOfNameOrdinals)
{
if (SearchName)
{
for (i = 0; i <
DllEntry->ImageExportDirectory->NumberOfNames; ++i)
{
ServiceName =
RvaToFoa(DllEntry, AddressOfNames[i]);
//必须问ZwCreateProcess CreateProcess NtCreateProcess
if (ServiceName &&
strcmp(ServiceName, SearchName) >= 0) {
Offset =
AddressOfFunctions[AddressOfNameOrdinals[i]];
break;
}
}
if (!Offset) {
//日志记录
//WCHAR v1[96];
//RtlStringCbPrintfW(v1,
sizeof(v1), L"%s.%S", DllEntry->DllName, SearchName); //Ntll.dll
ZwCreatePrcess
}
}
else {
i = *FoundIndex + 1;
if (i <
DllEntry->ImageExportDirectory->NumberOfNames)
{
Offset =
AddressOfFunctions[AddressOfNameOrdinals[i]];
ServiceName =
RvaToFoa(DllEntry, AddressOfNames[i]);
}
}
*FoundName = ServiceName;
*FoundIndex = i;
}
return Offset;
}
内存粒度转换文件粒度
ULONG* AddressOfNames = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfNames);
ULONG* AddressOfFunctions = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfFunctions);
USHORT* AddressOfNameOrdinals = RvaToFoa(DllEntry,
DllEntry->ImageExportDirectory->AddressOfNameOrdinals);
从导出表得到的内存偏移,内存地址,得转换为文件地址就能得到真正得函数地址
创建线程的几个参数记住
老大:安全属性
Ntdll里面存储的Zw系列函数
ServiceOffset = GetProcAddress(Ntdll, "Zw", &ServiceName, &v1);
if (!ServiceOffset)
goto Exit;
面试官必问:文件粒度和内存粒度的转换,为什么会出现转换?
对齐值不一样.所以我们才需要进行转换.
文件对齐值是0x200,内存对齐是0x1000
Kernel32提供的是CreateProcess
Ntdll提供的是ZwCreateProcess
在DllEntry结构里面的MappingBase里面存放的是我们的基地址(加上偏移就是实际位置)
ImageBase是内存粒度地址
MappingBase是映射的文件粒度地址(字节型 db调试)
//根据映射句柄获取虚拟地址
Status = ZwMapViewOfSection(
v1->SectionHandle, NtCurrentProcess(),
&v1->MappingBase, 0, 0, 0,
&MappingSize, ViewUnmap, 0, PAGE_READONLY);
if (!NT_SUCCESS(Status)) {
v1->MappingBase = NULL;
LastError = 0x15;
goto Exit;
}
内存读写可以直接看到数据,但映射不行,得多使用几次
映射,只是起名,没有用Image的镜像,读一下才能有数据
WinDbg中切换到RIng3进程的方法
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17
调用Zw函数也只是一个跳板,通过ServiceIndex
到Ssdt表里面去调用Nt函数
内核第一模块ntoskrnl.exe
用ntoskrnl.exe地址去搜索Ssdt表(在ntos里面)
如何获得系统第一模块?
ULONG_PTR GetNtosKrnl(void)
{
NTSTATUS Status;
UCHAR* v5;
ULONG ReturnLength;
ULONG_PTR NtosKrnl;
v5 = ExAllocatePoolWithTag(PagedPool, PAGE_SIZE, TAG);
if (!v5) {
return 0;
}
ReturnLength = 0;
Status = ZwQuerySystemInformation(
SystemModuleInformation, v5, PAGE_SIZE,
&ReturnLength);
if (Status != STATUS_SUCCESS &&
Status != STATUS_INFO_LENGTH_MISMATCH) {
ExFreePoolWithTag(v5, TAG);
return 0;
}
NtosKrnl =
((SYSTEM_MODULE_INFORMATION*)v5)->SystemModuleEntry[0].ImageBaseAddress;
ExFreePoolWithTag(v5, TAG);
return NtosKrnl;
}
我们现在想要去获得Ssdt表
Win7 32是可以直接导出的,作为全局变量(某些系统中Ssdt表是被ntosKrnl.exe全局导出的)
Win7 64是不能直接导出的
首先得获得NtosKrnl模块
找4C 48 关键字,从KeAddSystemServiceTable搜索
模块基地址 后3位一定是0 因为 模块是按页对齐的
Shadow是包含Ssdt的
Shadow上面紧挨着的就是Ssdt (Ssdt + 0x40 )(找不到 就 继续+C0)
Ssdt里面是NtosKrnl.exe的函数 (CreateProcess,CreateThread)
ShadowSsdt里面是Win32k.sys的函数 (SendMessage,PostMessage)
用基地址+前7位,因为最后一位是参数数量
理论上Ntoskrnl应该重载,这样更安全(源代码有)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
18
RIng 0 的 QuerySyscall
返给RIng3 数据
调试:
Ring3 和Ring0 一起调试
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
19
LIST __SyscallsList; //没有卸载内存 Ssdt
UCHAR* __SyscallsCode = NULL; //没有卸载内存 Ssdt Exports
寻找函数使用
~~~~~~
20
RIng0层
LONG GetHomePath(
WCHAR* PathData1, ULONG DataLength1, WCHAR* PathData2, ULONG
DataLength2)
{
NTSTATUS Status;
__declspec(align(8)) UNICODE_STRING64 NtPath;
__declspec(align(8)) UNICODE_STRING64 DosPath;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
GET_HOME_PATH_ARGS* Arguments =
(GET_HOME_PATH_ARGS*)ParameterData;
NtPath.Length = 0;
NtPath.MaximumLength = (USHORT)(DataLength1 * sizeof(WCHAR));
NtPath.Buffer = (ULONG64)(ULONG_PTR)PathData1;
DosPath.Length = 0;
DosPath.MaximumLength = (USHORT)(DataLength2 * sizeof(WCHAR));
DosPath.Buffer = (ULONG64)(ULONG_PTR)PathData2;
ZeroMemory(ParameterData, sizeof(ParameterData));
Arguments->ServiceIndex = GET_HOME_PATH;
if (PathData1)
Arguments->NtPath.Value64 =
(ULONG64)(ULONG_PTR)&NtPath;
if (PathData2)
Arguments->DosPath.Value64 =
(ULONG64)(ULONG_PTR)&DosPath;
Status = IoctlRequestInternal(ParameterData);
if (!NT_SUCCESS(Status))
{
if (PathData1)
PathData1[0] = L'\0';
if (PathData2)
PathData2[0] = L'\0';
}
return Status;
}
驱动层需要和HomePath关联
SetCommonService(GET_HOME_PATH, GetHomePath);
在驱动层设置连接
函数的核心作用是:接收用户层传递的参数,将 Sandboxie 沙箱的NT 格式路径和DOS 格式路径(两种 Windows
系统路径格式)复制到用户层指定的内存地址中,最终返回成功状态。简单来说,就是给用户层程序提供当前沙箱的主目录路径。
CopyStringToUser:内核层向用户层内存空间复制字符串的函数(内核不能直接操作用户层内存,需通过这类安全函数)
__HomePath1/__HomePath2:Sandboxie 内部存储的沙箱主目录路径(__HomePath1 是 NT
格式,__HomePath2 是 DOS 格式)
NT 路径 vs DOS 路径:
NT 路径(__HomePath1):内核层识别的原始路径,比如
\Device\HarddiskVolume1\Sandbox,用户层程序无法直接使用。
DOS 路径(__HomePath2):用户层识别的常规路径,比如 C:\Sandbox,是我们日常操作中看到的路径格式。
\??\ 前缀:Windows 中 \??\ 是 “DosDevices”
命名空间的简写,内核层路径会带这个前缀,用户层程序不需要,所以函数中会主动去掉。
向Ring3拷贝数据
首先看RIng3内存是否有效,是否能读能写
//向Ring3拷贝数据
void CopyStringToUser(
UNICODE_STRING64* StringData, WCHAR* BufferData, size_t
DataLength)
{
if (StringData) {
ProbeForRead(StringData, sizeof(UNICODE_STRING64),
sizeof(ULONG_PTR));
ProbeForWrite(StringData, sizeof(UNICODE_STRING64),
sizeof(ULONG_PTR));
if (DataLength > StringData->MaximumLength)
ExRaiseStatus(STATUS_BUFFER_TOO_SMALL);
else {
WCHAR* Buffer =
(WCHAR*)StringData->Buffer;
ProbeForWrite(Buffer, DataLength,
sizeof(WCHAR));
if (DataLength) {
memcpy(Buffer,
BufferData, DataLength);
StringData->Length =
(USHORT)DataLength - sizeof(WCHAR);
}
else
StringData->Length = 0;
}
}
}
将内核层的宽字符(WCHAR)字符串安全地拷贝到用户层指定的 UNICODE_STRING64 结构体中
Ring0 与 Ring3 内存隔离:内核层(Ring0)不能直接读写用户层(Ring3)内存,因为用户层内存可能无效 / 只读 /
伪造,直接操作会触发蓝屏(BSOD),必须通过 ProbeForRead/ProbeForWrite 等函数做 “内存探测”
CheckHomePath函数
BOOLEAN CheckHomePath(UNICODE_STRING* RegistryPath)
{
NTSTATUS Status;
OBJECT_ATTRIBUTES ObjectAttributes;
UNICODE_STRING v1;
HANDLE KeyHandle;
union {
KEY_VALUE_PARTIAL_INFORMATION Value1;
WCHAR Value2[256];
}v2;
WCHAR PathData[384];
WCHAR* v3;
ULONG v7;
IO_STATUS_BLOCK IoStatusBlock;
FILE_OBJECT* FileObject;
OBJECT_NAME_INFORMATION* ObjectNameInfo = NULL;
ULONG NameLength = 0;
//
// find the path to SbieDrv.sys
//
InitializeObjectAttributes(&ObjectAttributes,
RegistryPath, OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = ZwOpenKey(&KeyHandle, KEY_READ, &ObjectAttributes);
if (!NT_SUCCESS(Status)) {
return FALSE;
}
InitializeObjectAttributes(&ObjectAttributes,
&v1, OBJ_CASE_INSENSITIVE, NULL, NULL);
RtlInitUnicodeString(&v1, L"ImagePath");
v7 = sizeof(v2);
Status = ZwQueryValueKey(
KeyHandle, &v1, KeyValuePartialInformation, &v2,
v7, &v7);
ZwClose(KeyHandle);
if (!NT_SUCCESS(Status)) {
return FALSE;
}
if ((v2.Value1.Type != REG_SZ && v2.Value1.Type !=
REG_EXPAND_SZ)
|| v2.Value1.DataLength < 4) {
return FALSE;
}
// the path should be \??\<home>\SbieDrv.sys, where <home>
is the
// Sandboxie installation directory. We need to remove
Sandbox.sys,
// and prepend \??\ if it's missing
v3 = (WCHAR*)v2.Value1.Data;
v3[v2.Value1.DataLength / sizeof(WCHAR) - 1] = L'\0';
if (*v3 != L'\\') {
wcscpy(PathData, L"\\??\\");
wcscat(PathData, v3);
}
else
wcscpy(PathData, v3);
v3 = wcsrchr(PathData, L'\\');
if (v3)
*v3 = L'\0';
__HomePath2 = AllocateStringEx(__Pool, PathData, TRUE);
if (!__HomePath2) {
Status = STATUS_INSUFFICIENT_RESOURCES;
return FALSE;
}
//
// try to open the path so we can get a FILE_OBJECT for it
//
RtlInitUnicodeString(&v1, PathData);
InitializeObjectAttributes(&ObjectAttributes,
&v1, OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = ZwCreateFile(
&KeyHandle,
FILE_GENERIC_READ, //
DesiredAccess
&ObjectAttributes,
&IoStatusBlock,
NULL,
// AllocationSize
0,
// FileAttributes
FILE_SHARE_READ, //
ShareAccess
FILE_OPEN,
// CreateDisposition
FILE_DIRECTORY_FILE |
FILE_SYNCHRONOUS_IO_NONALERT,
NULL, 0);
// EaBuffer, EaLength
if (!NT_SUCCESS(Status)) {
return FALSE;
}
// get the canonical path name from the file object
Status = ObReferenceObjectByHandle(
KeyHandle, 0, NULL, KernelMode, &FileObject, NULL);
if (!NT_SUCCESS(Status)) {
ZwClose(KeyHandle);
return FALSE;
}
Status = GetObjectName(__Pool, FileObject, &ObjectNameInfo,
&NameLength); //通过对象获取对象名称
ObDereferenceObject(FileObject);
ZwClose(KeyHandle);
if (!NT_SUCCESS(Status)) {
return FALSE;
}
__HomePath1 = ObjectNameInfo->Name.Buffer;
__PathLength1 = wcslen(__HomePath1);
/*
kd> dq __HomePath1
fffff880`02f25128 fffff8a0`03da2120 00000000`00000000
fffff880`02f25138 00000000`00000000 00000000`00000000
fffff880`02f25148 00000000`00000000 00000000`00000000
fffff880`02f25158 00000000`00000000 00000000`00000000
fffff880`02f25168 00000000`00000000 00000000`00000000
fffff880`02f25178 00000000`00000000 00000000`00000000
fffff880`02f25188 00000000`00000000 00000000`00000000
fffff880`02f25198 00000000`00000000 00000000`00000000
kd> db fffff8a0`03da2120
fffff8a0`03da2120 5c 00 44 00 65 00 76 00-69 00 63 00 65 00
5c 00 \.D.e.v.i.c.e.\.
fffff8a0`03da2130 48 00 61 00 72 00 64 00-64 00 69 00 73 00
6b 00 H.a.r.d.d.i.s.k.
fffff8a0`03da2140 56 00 6f 00 6c 00 75 00-6d 00 65 00 31 00
5c 00 V.o.l.u.m.e.1.\.
fffff8a0`03da2150 55 00 73 00 65 00 72 00-73 00 5c 00 53 00
68 00 U.s.e.r.s.\.S.h.
fffff8a0`03da2160 69 00 6e 00 65 00 5c 00-44 00 65 00 73 00
6b 00 i.n.e.\.D.e.s.k.
fffff8a0`03da2170 74 00 6f 00 70 00 00 00-00 00 00 00 00 00
00 00 t.o.p...........
kd> dq __HomePath2
fffff880`02f25120 fffff8a0`03da20d0 fffff8a0`03da2120
fffff880`02f25130 00000000`00000000 00000000`00000000
fffff880`02f25140 00000000`00000000 00000000`00000000
fffff880`02f25150 00000000`00000000 00000000`00000000
fffff880`02f25160 00000000`00000000 00000000`00000000
fffff880`02f25170 00000000`00000000 00000000`00000000
fffff880`02f25180 00000000`00000000 00000000`00000000
fffff880`02f25190 00000000`00000000 00000000`00000000
kd> db fffff8a0`03da20d0
fffff8a0`03da20d0 5c 00 3f 00 3f 00 5c 00-43 00 3a 00 5c 00
55 00 \.?.?.\.C.:.\.U.
fffff8a0`03da20e0 73 00 65 00 72 00 73 00-5c 00 53 00 68 00
69 00 s.e.r.s.\.S.h.i.
fffff8a0`03da20f0 6e 00 65 00 5c 00 44 00-65 00 73 00 6b 00
74 00 n.e.\.D.e.s.k.t.
fffff8a0`03da2100 6f 00 70 00 00 00 00 00-00 00 00 40 01 00
00 00 o.p........@....
*/
return TRUE;
}
是 Sandboxie 内核层初始化阶段的核心函数,它的核心目标是从注册表中读取 SbieDrv.sys 的路径,解析出 Sandboxie
的安装主目录,并分别保存其 DOS 格式路径(__HomePath2)和 NT 格式路径(__HomePath1) —— 这也是你之前看到的
GetHomePath 函数中用到的两个全局路径变量的来源。
从指定注册表路径读取 SbieDrv.sys 的 ImagePath 键值(驱动文件的安装路径);从驱动路径中剥离出 Sandboxie
的主目录,处理成用户层可识别的 DOS 格式路径(存入 __HomePath2);通过打开该目录的文件对象,获取系统底层的 NT 格式规范路径(存入
__HomePath1);全程做合法性校验,任何一步失败都会返回 FALSE,保证路径的有效性。
ZwOpenKey/ZwQueryValueKey:Windows 内核操作注册表的标准函数(对应用户层的
RegOpenKey/RegQueryValueEx);
ZwCreateFile:内核层打开文件 / 目录的函数,这里用来打开沙箱主目录,获取其文件对象;
ObReferenceObjectByHandle:通过文件句柄获取内核对象(FILE_OBJECT)的指针,是内核对象操作的核心函数;
GetObjectName:Sandboxie 封装的函数,用于从 FILE_OBJECT 中提取对象的规范名称(即 NT 格式路径);
WCHAR* AllocateStringEx(
POOL* Pool, const WCHAR* StringData, BOOLEAN Tag)
{
WCHAR* v5;
ULONG v7 = (wcslen(StringData) + 1) * sizeof(WCHAR);
v5 = AllocateMemoryEx(Pool, v7, Tag);
if (v5)
memcpy(v5, StringData, v7);
return v5;
}
据传入的宽字符字符串(WCHAR)计算所需内存大小,调用底层内存分配函数分配内存,然后将原字符串完整拷贝到新分配的内存中,最终返回指向新字符串的指针。简单来说,就是
“分配内存 + 字符串拷贝” 的一站式封装,专门适配 Windows 内核的宽字符字符串场景
NTSTATUS GetObjectName(
POOL* Pool, void* Object,
OBJECT_NAME_INFORMATION** ObjectNameInfo, ULONG* NameLength)
{
NTSTATUS Status;
UCHAR v5[80];
OBJECT_NAME_INFORMATION* v1;
ULONG v7;
OBJECT_NAME_INFORMATION* v9;
ULONG v10;
*ObjectNameInfo = NULL;
*NameLength = 0;
//
// invoke ObQueryNameString on the small buffer. the small
buffer
// must be larger than sizeof(OBJECT_NAME_INFORMATION) even
after
// subtracting PAD_LEN bytes from it. in other words, keep
the
// small buffer at least around 32 bytes (depending on PAD_LEN)
//
// sometimes ObQueryNameString gets confused and returns
// STATUS_OBJECT_PATH_INVALID, in this case we just call with
// a slightly smaller buffer
//
MemoryZero(v5, sizeof(v5));
v1 = (OBJECT_NAME_INFORMATION*)v5;
v7 = 0; // must be initialized
Status = ObQueryNameString(
Object, v1, sizeof(v5) - PAD_LEN, &v7);
if (Status == STATUS_OBJECT_PATH_INVALID) {
v7 = 0; // must be
initialized
Status = ObQueryNameString(
Object, v1,
sizeof(v5) - PAD_LEN * 2, &v7);
}
if (NT_SUCCESS(Status)) {
//
// we got the name completely into the small buffer,
so we
// allocate a pool buffer and copy the name string
//
if (v1->Name.Length && v1->Name.Buffer)
{
v10 =
sizeof(OBJECT_NAME_INFORMATION)
+ v1->Name.Length +
PAD_LEN * 2;
v9 =
(OBJECT_NAME_INFORMATION*)AllocateMemoryEx(Pool, v10,FALSE);
if (!v9)
return
STATUS_INSUFFICIENT_RESOURCES;
MemoryZero(v9, v10);
v9->Name.Length = v1->Name.Length;
v9->Name.MaximumLength =
v1->Name.MaximumLength;
v9->Name.Buffer =
(WCHAR*)(((UCHAR*)v9)
+ sizeof(UNICODE_STRING));
memcpy(v9->Name.Buffer,
v1->Name.Buffer,
v1->Name.Length);
}
else {
v9 = NULL;
v10 = 0;
}
goto Exit;
}
if (Status != STATUS_INFO_LENGTH_MISMATCH &&
Status != STATUS_BUFFER_OVERFLOW) {
return Status;
}
//
// on Windows 2000, we may get STATUS_INFO_LENGTH_MISMATCH but
// a result length of zero, in this case we must try again
// with a larger buffer
//
v9 = NULL;
v10 = 0;
while (!v7)
{
if (v9)
FreeMemoryEx(v9, v10);
v10 += 128;
v9 =
(OBJECT_NAME_INFORMATION*)AllocateMemoryEx(Pool, v10,FALSE);
if (!v9)
return
STATUS_INSUFFICIENT_RESOURCES;
MemoryZero(v9, v10);
v7 = 0; // must be
initialized
Status = ObQueryNameString(
Object, v9, v10 - PAD_LEN, &v7);
if (NT_SUCCESS(Status))
break;
if (Status == STATUS_OBJECT_PATH_INVALID) {
v7 = 0; //
must be initialized
Status = ObQueryNameString(
Object, v9, v10 -
PAD_LEN * 2, &v7);
}
if (Status != STATUS_INFO_LENGTH_MISMATCH &&
Status != STATUS_BUFFER_OVERFLOW) {
FreeMemoryEx(v9, v10);
return Status;
}
v7 = 0;
}
//
// on Windows XP, we should have gotten a result length, and not
// went into the loop above, so info is still NULL, and we need
// to allocate it and query the name again
//
if (!v9) {
v10 = v7 + PAD_LEN * 2;
v9 =
(OBJECT_NAME_INFORMATION*)AllocateMemoryEx(Pool, v10,FALSE);
if (!v9)
return
STATUS_INSUFFICIENT_RESOURCES;
MemoryZero(v9, v10);
v7 = 0; // must be
initialized
Status = ObQueryNameString(
Object, v9, v10 - PAD_LEN, &v7);
if (!NT_SUCCESS(Status)) {
FreeMemoryEx(v9, v10);
return Status;
}
}
//
// finally we only have to make sure that the name isn't empty
//
Exit:
if (v9 && v9->Name.Length && v9->Name.Buffer) {
//
// On Windows 7, we may get two leading backslashes
//
if (__OsVersion >= WINDOWS_7 &&
v9->Name.Length >= 2 *
sizeof(WCHAR) &&
v9->Name.Buffer[0] == L'\\' &&
v9->Name.Buffer[1] == L'\\') {
WCHAR* Buffer = v9->Name.Buffer;
USHORT Length = v9->Name.Length;
Length = Length / sizeof(WCHAR) - 1;
wmemmove(Buffer, Buffer + 1, Length);
Buffer[Length] = L'\0';
v9->Name.Length -= sizeof(WCHAR);
v9->Name.MaximumLength -=
sizeof(WCHAR);
}
*ObjectNameInfo = v9;
*NameLength = v10;
}
else {
if (v9)
FreeMemoryEx(v9, v10);
*ObjectNameInfo =
(OBJECT_NAME_INFORMATION*)&__UnnamedObject;
*NameLength = 0;
}
/*if (0) {
OBJECT_NAME_INFORMATION *xname = *Name;
WCHAR *xbuf = xname->Name.Buffer;
ULONG xlen = xname->Name.Length /
sizeof(WCHAR);
DbgPrint("Object Name: %*.*S\n", xlen, xlen,
xbuf);
}*/
return STATUS_SUCCESS;
}
是 Sandboxie 内核层中从内核对象(如 FILE_OBJECT)提取规范名称(NT 路径)的核心函数,也是 CheckHomePath 中获取
__HomePath1(NT 格式路径)的关键依赖。
核心作用是:调用 Windows 内核的 ObQueryNameString 函数,从任意内核对象(如 FILE_OBJECT、REGISTRY_KEY
等)中提取其规范名称(NT 格式路径),并处理不同 Windows 版本的兼容性问题,最终将名称存入分配的内存中返回。简单来说,就是 “适配多版本
Windows + 安全获取内核对象名称” 的封装。
这个函数的核心关键点:
核心目标:封装 ObQueryNameString,兼容多版本 Windows,安全、稳定地从内核对象中提取规范名称(NT 路径)。
实现逻辑:小缓冲区试探 → 缓冲区不足则循环增大 → 按版本处理兼容性问题 → 校验并返回名称。
设计原则:内存安全(避免泄漏 / 越界)> 兼容性(适配多 Windows 版本)> 效率(小缓冲区优先)。
在CheckHomePath中
Status = GetObjectName(__Pool, FileObject, &ObjectNameInfo,
&NameLength); //通过对象获取对象名称
FileObject:打开沙箱主目录后得到的文件对象;
返回的 ObjectNameInfo->Name.Buffer 就是沙箱主目录的 NT 格式路径(如
\Device\HarddiskVolume1\Users\Shine\Desktop);
最终赋值给 __HomePath1,成为 Sandboxie 内核层文件虚拟化的核心路径。
ObQueryNameString 底层原理
首先要知道每个内核对象都有 “对象头 + 对象体”
┌─────────────────────────────────┐
│ OBJECT_HEADER(对象头) │ ← 所有内核对象共用的元数据
│ ├─ NameInfo(名称信息) │
存储对象的名称、命名空间等
│ ├─ HandleInfo(句柄信息) │
│ ├─ ReferenceCount(引用计数) │
│ └─ SecurityDescriptor(安全描述符)│
├─────────────────────────────────┤
│ OBJECT_BODY(对象体) │ ← 不同类型对象的特有数据
│ ├─ FILE_OBJECT(文件对象) │ 如文件指针、缓存策略等
│ ├─ PROCESS_OBJECT(进程对象) │ 如进程ID、内存空间等
│ └─ ...(其他内核对象) │
└─────────────────────────────────┘
OBJECT_HEADER:所有内核对象的 “通用头部”,ObQueryNameString 核心读取的就是这里的 NameInfo;
NameInfo 中存储了对象的规范名称(Canonical Name)(比如
\Device\HarddiskVolume1\File.txt)、相对名称等关键信息;
ObQueryNameString 的本质,就是从 OBJECT_HEADER 的 NameInfo 中读取名称,并格式化后返回给调用者。
我们好好理解一下HomePath
在 Sandboxie 中,HomePath 是 沙箱的专属存储根目录,它有两个格式(对应你之前看到的两个全局变量):
| 变量 | 格式 | 示例 | 用途 |
|---|---|---|---|
| __HomePath1 | NT 格式路径(内核层专用) | \Device\HarddiskVolume1\Sandbox\user\DefaultBox | 内核层做文件虚拟化、路径重定向时使用 |
| __HomePath2 | DOS 格式路径(用户层 + 内核层共用) | \??\C:\Sandbox\user\DefaultBox |
简单说:HomePath 就是 Sandboxie 为每个沙箱创建的 “专属文件夹”,所有在沙箱内运行的程序产生的文件 /
注册表数据,默认都会被重定向到这个文件夹里。
二、HomePath 的核心作用(Sandboxie 隔离的基石)
Sandboxie 的核心是 “隔离” —— 让沙箱内的程序 “看起来” 在操作真实系统,实际上所有操作都被限制在 HomePath 目录下。HomePath
就是这个隔离的 “边界”,具体作用分 3 点:
1. 文件虚拟化的基准路径
这是 HomePath 最核心的作用。
正常情况:程序写文件到 C:\Users\XXX\Desktop\test.txt,会直接写到真实桌面;
沙箱内情况:Sandboxie 内核驱动会拦截这个写操作,将路径 重定向 到 HomePath 下的对应位置,比如:
真实路径:C:\Users\XXX\Desktop\test.txt
沙箱路径:C:\Sandbox\user\DefaultBox\C\Users\XXX\Desktop\test.txt
关键:内核层会用 __HomePath1(NT 路径)来拼接重定向后的路径,确保和 Windows 内核的文件系统接口兼容;
用户层看到的则是 __HomePath2 去掉 \??\ 后的路径(C:\Sandbox\...)。
这样做的好处:沙箱内程序产生的文件,全部存在 HomePath 里,删除沙箱时直接删掉这个目录,系统就和新的一样,不会留下任何痕迹。
2. 注册表虚拟化的存储路径
沙箱不仅隔离文件,还隔离注册表。
沙箱内程序读写注册表(如 HKEY_CURRENT_USER\Software\XXX),Sandboxie
会把这些注册表项的镜像数据存储到 HomePath 下的注册表文件中(比如 RegHive 格式的文件);
这些注册表镜像文件,是 HomePath 目录的重要组成部分,和文件重定向一样,都以 HomePath 为根目录。
3. 沙箱的身份标识与管理基础
Sandboxie 支持多沙箱(比如一个沙箱跑浏览器,一个沙箱跑测试程序),每个沙箱都有自己独立的 HomePath —— 比如
DefaultBox 的 HomePath 是 C:\Sandbox\user\DefaultBox,TestBox 的就是
C:\Sandbox\user\TestBox;
用户层工具(如 Sandboxie Control)通过读取
__HomePath2,可以显示每个沙箱的存储位置,也可以基于这个路径做 “沙箱备份 / 恢复”“删除沙箱” 等操作。
HomePath 的完整生命周期
1.初始化(CheckHomePath 函数)
从注册表读取 SbieDrv.sys 的安装路径(比如 C:\Sandbox\SbieDrv.sys);
截断路径,去掉驱动文件名,得到沙箱根目录(C:\Sandbox\user\DefaultBox);
调用 ZwCreateFile 打开这个目录,通过 GetObjectName 获取 NT 格式路径(__HomePath1),同时保存 DOS
格式路径(__HomePath2)。
2.对外提供(GetHomePath 函数)
用户层程序(如 Sandboxie Control)需要获取沙箱路径时,调用内核接口;
GetHomePath 把 __HomePath1(NT 路径)和 __HomePath2(处理掉 \??\ 前缀的 DOS 路径)返回给用户层。
3.运行时使用(文件 / 注册表虚拟化)
沙箱内程序操作文件 / 注册表时,内核驱动拦截请求;
以 __HomePath1 为基准,拼接出沙箱内的虚拟路径,执行读写操作;
程序退出后,所有数据都留在 HomePath 目录中,可按需删除。
20调试没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
21
void CDriverAssist::InjectProcessReply(void* MessageData)
{
PPROCESS_MESSAGE v1 = (PPROCESS_MESSAGE)MessageData;
NTSTATUS Status = 0;
ULONG LastError = 0;
HANDLE ProcessHandle = NULL;
UCHAR SandboxieLogonSid[SECURITY_MAX_SID_SIZE] = { 0 };
WCHAR* file_root_path = NULL;
WCHAR* reg_root_path = NULL;
if (!m_IsDriverReady) {
LastError = 0xFF;
goto Exit;
}
//
// open new process and verify process creation time
//
ProcessHandle = OpenProcess(MessageData);
if (!ProcessHandle) {
LastError = 0x11;
goto Exit;
}
LastError = InjectProcess(ProcessHandle, 0, TRUE);
if (LastError != 0)
goto Exit;
if (0)
{
}
else
{
Status = IoctlRequest(INJECT_COMPLETE, 1,
(ULONG_PTR)v1->ProcessIdentity);
}
if (Status == 0)
LastError = 0;
else
LastError = 0x99;
Exit:;
if (LastError) {
ULONG v1 = GetLastError();
}
if (ProcessHandle) {
if (LastError)
{
int v1 = 0;
//SbieApi_Call(API_INJECT_COMPLETE,
3, (ULONG_PTR)msg->process_id, NULL, errlvl);
//TerminateProcess(hProcess, 1);
}
CloseHandle(ProcessHandle);
}
}
Sandboxie 用户层(Ring3)CDriverAssist
类中的核心函数,作用是响应进程注入请求,完成沙箱进程的注入初始化,并通知内核驱动注入完成。
函数整体功能
这个函数是 Sandboxie 用户层处理 “进程注入” 的核心回调函数:当有新进程要加入沙箱时,用户层收到内核驱动的通知后,调用该函数完成以下核心操作:
打开目标进程的句柄;
向目标进程注入 Sandboxie 的沙箱运行时(DLL / 代码);
通知内核驱动 “进程注入完成”;
处理注入失败的异常(关闭句柄、记录错误等)。
核心注入逻辑:InjectProcess
InjectProcess(ProcessHandle, 0, TRUE) 是这个函数的核心
ULONG InjectProcess(HANDLE ProcessHandle, ULONG Flags, BOOLEAN
IsDuplicateHandle)
{
ULONG LastError = 0;
void* v5 = NULL;
SIZE_T v7;
void* v10 = NULL;
BOOLEAN IsJumpTable = FALSE;
ULONG_PTR Trampoline = 0;
DATA_INFO DataInfo; //目标进程空间中的内存
memset(&DataInfo, 0, sizeof(DataInfo));
DataInfo.Flags.init_flags = Flags;
#ifdef _M_ARM64
#endif
//
// verify all aspects of initialization were successful
//
if ((!__TestAddress) || (!__SyscallsData)
#ifdef _M_ARM64
#endif
) {
SetLastError(ERROR_NOT_READY);
LastError = 0xFF;
goto Exit;
}
DataInfo.Ntdll = (ULONG64)(ULONG_PTR)__Ntdll;
static const WCHAR* _Ntdll= L"\\system32\\ntdll.dll";
// 19 chars
DataInfo.NtDeviceIoControlFile =
(ULONG64)GetProcAddress((HMODULE)DataInfo.Ntdll, "NtDeviceIoControlFile");
DataInfo.NtProtectVirtualMemory =
(ULONG64)GetProcAddress((HMODULE)DataInfo.Ntdll, "NtProtectVirtualMemory");
DataInfo.NtRaiseHardError =
(ULONG64)GetProcAddress((HMODULE)DataInfo.Ntdll, "NtRaiseHardError");
//
// on 64-bit Windows 8, there might be a difference of more than
// 2GB bytes between ntdll and the injected SbieLow, which
requires
// use of longer jump sequences than the 5-byte 0xE9 relative jump
//
if (__Windows >= 10) {
DataInfo.Flags.is_win10 = 1;
}
#ifdef _WIN64
if (IsJumpTable)
v7 = __TestLength + sizeof(JUMP_TABLE) + 0x400;
else
#endif
v7 = __TestLength;
v5 = CopyShellCode(ProcessHandle, v7, __TestLength,
__TestAddress
#ifdef _M_ARM64
#endif
);
//将ShellCode写入到目标进程空间中
if (v5) {
void* LdrInitializeThunk =
(void*)__LdrInitializeThunk;
/*
0000000077BCC340 FF F3
push rbx
0000000077BCC342 48 83 EC 20
sub rsp,20h
0000000077BCC346 48 8B D9
mov rbx,rcx
0000000077BCC349 E8 22 00 00 00
call 0000000077BCC370
0000000077BCC34E B2 01
mov dl,1
0000000077BCC350 48 8B CB
mov rcx,rbx
0000000077BCC353 E8 88 53 02 00
call 0000000077BF16E0
0000000077BCC358 8B C8
mov ecx,eax
0000000077BCC35A E8 71 14 0A 00
call 0000000077C6D7D0
0000000077BCC35F CC
int 3
*/
#ifdef _M_ARM64
#endif
//
// copy code at LdrInitializeThunk from new process
//
SIZE_T Length1 =
sizeof(DataInfo.LdrInitializeThunk_tramp);
SIZE_T Length2 = 0;
/*
sprintf(buffer,"CopyCode: copy ldr size
%d\n",code_len);
OutputDebugStringA(buffer);
*/
//获取远程函数地址
??????????????????????????????????????????????
BOOL IsOk = ReadProcessMemory(
ProcessHandle, LdrInitializeThunk,
DataInfo.LdrInitializeThunk_tramp,
Length1, &Length2);
/*
000000000287EFA0 FF F3
push rbx
000000000287EFA2 48 83 EC 20
sub rsp,20h
000000000287EFA6 48 8B D9
mov rbx,rcx
000000000287EFA9 E8 22 00 00 00
call 000000000287EFD0
000000000287EFAE B2 01
mov dl,1
000000000287EFB0 48 8B CB
mov rcx,rbx
000000000287EFB3 E8 88 53 02 00
call 00000000028A4340
000000000287EFB8 8B C8
mov ecx,eax
000000000287EFBA E8 71 14 0A 00
call 0000000002920430
000000000287EFBF CC
int 3
*/
if (!IsOk || Length1 != Length2) {
//释放目标进程中的内存
??????????????????????????????????????????????????????
v5 = NULL;
}
}
if (!v5) {
LastError = 0x33;
goto Exit;
}
#ifdef _WIN64
if (DataInfo.Flags.is_wow64)
{
//
// when this is a 32 bit process running under
WoW64, we need to inject also some 32 bit code
//
//void* remote_addr32 =
SbieDll_InjectLow_CopyCode(hProcess, m_sbielow32_len, m_sbielow32_len,
m_sbielow32_ptr
#ifdef _M_ARM64
#endif
//);
/*
if (v5) {
}
if (!SyscllsData.ptr_32bit_detour)
{
LastError = 0x88;
goto Exit;
}*/
}
#endif
#ifndef _M_ARM64
#ifdef _WIN64
DataInfo.Flags.long_diff = 1;
if (Has32BitJumpHorizon((void*)__LdrInitializeThunk, v5))
{
DataInfo.Flags.long_diff = 0;
}
#else
SyscllsData.Flags.long_diff = 0;
#endif
#endif
if (IsDuplicateHandle)
{
//
// duplicate the SbieDrv API file device handle into
target process
//
DataInfo.DeviceHandle = (ULONG64)(ULONG_PTR)
CopyDeviceHandle(ProcessHandle);
if (!DataInfo.DeviceHandle) {
LastError = 0x22;
goto Exit;
}
DataInfo.IoControlCode = IO_CTL_CODE_1;
DataInfo.InvokeSyscalls = INVOKE_SYSCALLS;
}
//拷贝了4个函数的原始指令
memcpy(DataInfo.NtDelayExecution_code, &__SyscallsData[2],
(NATIVE_FUNCTION_SIZE* NATIVE_FUNCTION_COUNT));
/*
0000000000389660 C0 0E 00
ror byte ptr [rsi],0
0000000000389663 00 E0
add al,ah
0000000000389665 0C 00
or al,0
0000000000389667 00 4C 8B D1 add
byte ptr [rbx+rcx*4-2Fh],cl
000000000038966B B8 31 00 00 00 mov
eax,31h
0000000000389670 0F 05
syscall
PROCESS fffffa806332d060
SessionId: 1 Cid: 0de0 Peb: 7fffffd8000
ParentCid: 0a10
DirBase: 210a49000 ObjectTable: fffff8a002f73380
HandleCount: 24.
Image: SandBox.exe
kd> .process /i fffffa806332d060
You need to continue execution (press 'g' <enter>) for the
context
to be switched. When the debugger breaks in again, you will be in
the new process context.
kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`03e709f0 cc
int 3
kd> u 0000000000389660
00000000`00389660 c00e00 ror
byte ptr [rsi],0
00000000`00389663 00e0
add al,ah
00000000`00389665 0c00
or al,0
00000000`00389667 004c8bd1 add
byte ptr [rbx+rcx*4-2Fh],cl
00000000`0038966b b831000000 mov
eax,31h
00000000`00389670 0f05
syscall
*/
#ifdef _WIN64
if (IsJumpTable)
{
// SyscllsData.x64BitJumpTable =
(SBIELOW_J_TABLE*)((ULONG_PTR)remote_addr + m_sbielow_len + 0x400);
//(0x400 - (m_sbielow_len & 0x3ff))+ m_sbielow_len;
}
#endif
//ULONG_PTR v1000 = FIELD_OFFSET(_SYSCALLS_DATA_,
LdrInitializeThunk_tramp);
Trampoline = (ULONG_PTR)v5 + __DataInfoOffset +
FIELD_OFFSET(DATA_INFO, LdrInitializeThunk_tramp);
if (!BuildTrampoline(DataInfo.Flags.long_diff == 1,
DataInfo.LdrInitializeThunk_tramp, Trampoline
#ifdef _M_ARM64
, (BOOLEAN)lowdata.flags.is_arm64ec
#endif
)) {
SetLastError(ERROR_UNKNOWN_PRODUCT);
LastError = 0x44;
goto Exit;
}
/*
000000000293F550 FF F3
push rbx
000000000293F552 48 83 EC 20 sub
rsp,20h
000000000293F556 48 8B D9
mov rbx,rcx
000000000293F559 E8 22 00 00 00 call
000000000293F580
000000000293F55E B2 01
mov dl,1
000000000293F560 48 8B CB
mov rcx,rbx
000000000293F563 E8 88 53 02 00 call
00000000029648F0
000000000293F568 8B C8
mov ecx,eax
000000000293F56A E8 71 14 0A 00 call
00000000029E09E0
000000000293F56F CC
int 3
000000000293F550 FF F3
push rbx
000000000293F552 48 83 EC 20 sub
rsp,20h
000000000293F556 E9 F4 C2 A2 77 jmp
000000007A36B84F
000000000293F55B 00 00
add byte ptr [rax],al
000000000293F55D 00 B2 01 48 8B CB add
byte ptr [rdx-3474B7FFh],dh
000000000293F563 E8 88 53 02 00 call
00000000029648F0
000000000293F568 8B C8
mov ecx,eax
000000000293F56A E8 71 14 0A 00 call
00000000029E09E0
000000000293F56F CC
int 3
*/
v10 = CopySyscalls(ProcessHandle,
(BOOLEAN)DataInfo.Flags.is_wow64
#ifdef _M_ARM64
, (BOOLEAN)lowdata.flags.is_arm64ec
#endif
);
if (!v10) {
LastError = 0x55;
goto Exit;
}
DataInfo.SyscallsData = (ULONG64)(ULONG_PTR)v10;
if (!CopyData(ProcessHandle, v5, &DataInfo)) {
LastError = 0x66;
goto Exit;
}
if (!WriteJump(ProcessHandle, (UCHAR*)v5 + __StartOffset,
DataInfo.Flags.long_diff == 1
#ifdef _M_ARM64
, (BOOLEAN)lowdata.flags.is_arm64ec
#endif
)) {
LastError = 0x77;
goto Exit;
}
/*
* __LdrInitializeThunk
kd> u 0x0000000077bcc340
00000000`77bcc340 e9cb3c5488 jmp
00000000`00110010
00000000`77bcc345 20488b and
byte ptr [rax-75h],cl
*/
Exit:
return LastError;
}
Ring3 层进程注入的核心实现函数,也是整个沙箱化流程中最底层、最复杂的部分 —— 它不是简单的 DLL 注入,而是通过ShellCode 注入
+ 系统调用钩子(Syscall Hook) 实现对目标进程的深度劫持,让沙箱能拦截目标进程的所有核心系统调用。
1. 函数整体功能
这个函数的核心目标是:向目标进程注入沙箱的核心拦截逻辑(ShellCode + 系统调用钩子),而非单纯加载 DLL。具体实现:
校验注入前置条件(核心地址 / 系统调用数据是否就绪);
准备注入所需的核心数据(ntdll 函数地址、系统调用指令、沙箱驱动句柄等);
将 ShellCode 拷贝到目标进程内存;
读取目标进程的 LdrInitializeThunk 函数指令(用于构建跳板);
构建 Trampoline 跳板(实现函数跳转劫持);
拷贝系统调用钩子数据到目标进程;
写入跳转指令,完成对目标进程核心函数的劫持;
全程处理错误,返回自定义错误码。
简单说:它是 Sandboxie 实现 “进程级系统调用拦截” 的核心,也是沙箱能隔离文件 / 注册表的底层基础。
2. 前置核心概念(注入 / 钩子关键)
| 概念 | 含义 |
|---|---|
| DATA_INFO | 注入数据结构体,包含沙箱初始化标志、ntdll 函数地址、系统调用指令、驱动句柄等核心注入数据 |
| __TestAddress/__TestLength | ShellCode 缓冲区的地址和长度(沙箱的核心拦截逻辑,编译好的机器码) |
| CopyShellCode | 将 ShellCode 写入目标进程的虚拟内存(底层调用 VirtualAllocEx + WriteProcessMemory) |
| LdrInitializeThunk | ntdll 中的核心初始化函数,是进程启动时的关键入口,Sandboxie 劫持这个函数实现 “启动时注入” |
| Trampoline(跳板) | 劫持函数时的 “中转指令”,保存原函数指令 + 跳转到 ShellCode,避免直接覆盖导致指令断裂 |
| Syscall(系统调用) | Windows 进程进入内核的核心指令(x64 下是 syscall,x86 下是 int 0x2e),Sandboxie 钩子这些指令实现拦截 |
| JUMP_TABLE | 跳转表(x64 专用),解决 64 位系统下 “地址跨度超过 2GB” 导致的短跳转失效问题 |
| CopyDeviceHandle | 将沙箱驱动的设备句柄(\\.\SbieDrv)复制到目标进程,让目标进程能和内核驱动通信 |
JUMP_TABLE
问题根源:x64 下 E9 短跳转指令的偏移量只有 32 位,只能覆盖 ±2GB 地址范围,Sandboxie 注入的 ShellCode
地址可能超出这个范围;
JUMP_TABLE 作用:在目标进程中创建一个 “地址路牌”,存储 ShellCode 的完整 64 位地址;
解决逻辑:将 “直接短跳转” 改为 “短跳转到跳转表 + 长跳转到 ShellCode”,突破 2GB 地址跨度限制;
适用场景:仅 x64 系统,x86 因地址空间小,基本无需跳转表。
ShellCode 注入而非 DLL 注入:
避免 DLL 注入被检测(如杀毒软件拦截 CreateRemoteThread);
ShellCode 是纯机器码,无 DLL 依赖,更隐蔽、更底层;
Syscall 级别的钩子:
直接钩子 ntdll 的 Syscall 指令,而非 API 函数,拦截更彻底(绕过用户层 Hook);
备份原始 Syscall 指令,保证系统调用的正确性;
这个函数的核心关键点:
核心功能:向目标进程注入 ShellCode 和 Syscall 钩子,劫持 LdrInitializeThunk,实现 Syscall
级别的拦截,是沙箱隔离的底层基础;
核心技术:ShellCode 注入、Trampoline 跳板、Syscall 钩子、驱动句柄复制;
设计目标:底层、隐蔽、跨版本兼容,实现对目标进程的深度劫持,为内核层的路径重定向提供用户层入口。
一. Syscall 和 LdrInitializeThunk 的本质区别
| 概念 | 本质 | 指令特征(x64) | 作用 |
|---|---|---|---|
| Syscall | CPU 指令(内核态 / 用户态切换的硬件指令) | 0F 05 | 用户层进程主动进入内核态的 “大门”,所有 ntdll 中的 NtXXX 函数最终都会执行这条指令 |
| LdrInitializeThunk | ntdll 中的函数(进程启动时的初始化函数) | 普通汇编指令(push/mov/call) | 进程加载 DLL 时的核心初始化逻辑,是进程启动流程的关键入口, |
所有 NtXXX 函数的最终都会执行 Syscall 进入内核
二、为什么 Sandboxie 要把 Syscall 钩子和 LdrInitializeThunk 绑定?
Sandboxie 的核心目标是拦截目标进程的所有 Syscall,但直接遍历 ntdll 的所有 NtXXX 函数钩子成本太高,而
LdrInitializeThunk 是进程启动时的 “必经之路”—— 劫持这个函数,就能在进程启动初期注入 Syscall 钩子逻辑,实现 “一劳永逸”
的拦截。
graph TD
A[目标进程启动] --> B[执行 ntdll!LdrInitializeThunk(初始化DLL)]
C[Sandboxie 劫持 LdrInitializeThunk,跳转到注入的 ShellCode]
D[ShellCode 遍历 ntdll 中所有 NtXXX 函数]
E[对每个 NtXXX 函数,钩子其末尾的 Syscall 指令]
F[恢复 LdrInitializeThunk 原逻辑,让进程正常启动]
G[进程后续调用 NtXXX 函数时,触发 Syscall 钩子,执行沙箱逻辑]
简单说:
LdrInitializeThunk 是 “注入入口” —— Sandboxie 借这个函数的执行时机,把 Syscall 钩子逻辑注入到目标进程;
Syscall 是 “拦截目标” —— 注入完成后,所有 NtXXX 函数的 Syscall 都会被钩子,实现文件 / 注册表的隔离。
最终效果:目标进程执行 LdrInitializeThunk 时,先执行 ShellCode 完成 Syscall 钩子,再执行原初始化逻辑,进程正常启动但所有
Syscall 已被监控。
void* CopyShellCode(HANDLE ProcessHandle, SIZE_T ViewSize, SIZE_T TestLength,
const void* TestAddress
#ifdef _M_ARM64
#endif
) {
void* v5 = AllocateRemoteMemory(ProcessHandle, ViewSize,
TRUE //目标进程中申请内存
#ifdef _M_ARM64
#endif
);
if (v5) {
//
// copy SbieLow into the allocated region in the new
process
//
SIZE_T Length1 = TestLength;
SIZE_T Length2 = 0;
BOOL IsOk = WriteProcessMemory(
ProcessHandle, v5, TestAddress,
Length1, &Length2);
if (IsOk && Length1 == Length2) {
return v5;
}
/*
kd> .process /i fffffa8062b1eb30
You need to continue execution (press 'g' <enter>)
for the context
to be switched. When the debugger breaks in again,
you will be in
the new process context.
kd> g
Break instruction exception - code 80000003 (first
chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`03e709f0 cc
int 3
kd> db 0x0000000000130000
00000000`00130000 00 86 9b ef fe 07 00 00-7e
00 00 00 02 00 00 00 ........~.......
00000000`00130010 78 00 00 00 00 00 00 00-7c
00 00 00 fd fd fd fd x.......|.......
00000000`00130020 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ................
00000000`00130030 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ................
00000000`00130040 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ................
00000000`00130050 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ................
00000000`00130060 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ................
00000000`00130070 00 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 ..............
*/
/*
0x00000000003392C0 00 86 9b ef fe 07 00 00 7e
00 00 00 02 00 00 00 78 00 00 00 00 00 00 00 7c 00 00
.????...~.......x.......|..
0x00000000003392DB 00 fd fd fd fd 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
.????......................
0x00000000003392F6 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...........................
0x0000000000339311 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...........................
0x000000000033932C 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...........................
0x0000000000339347 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 fd fd fd fd dd dd dd dd dd dd
.................??????????
0x0000000000339362 dd dd dd dd dd dd b0 96 ff
39 6f d0 00 14 20 df 33 00 00 00 00 00 10 eb 33 00 00 ????????.9o?..
?3......?3..
0x000000000033937D 00 00 00 00 86 9b ef fe 07
00 00 7e 00 00 00 02 00 00 00 78 00 00 00 00 00 00 00
*/
}
return NULL;
}
将沙箱核心拦截逻辑(ShellCode)写入目标进程内存的底层函数 —— 它的作用就是 “在目标进程中申请可执行内存 + 写入 ShellCode
机器码”,为后续的函数劫持和 Syscall 钩子打下基础。
1. 函数整体功能
这个函数的核心目标非常明确:
在目标进程的虚拟地址空间中,分配一块可读写可执行的内存(ShellCode 必须可执行);
将本地预编译好的 ShellCode(沙箱拦截逻辑,TestAddress 指向的机器码)完整写入这块内存;
校验写入是否成功,成功则返回目标进程中的内存地址,失败则返回 NULL。
简单说:它是 “把沙箱的核心拦截代码放到目标进程里” 的执行者,是整个注入流程的 “第一步”。
关键设计:
(1)内存权限的选择(核心)
ShellCode 是机器码,必须运行在 PAGE_EXECUTE_READWRITE 权限的内存中;
普通的 PAGE_READWRITE 权限内存写入 ShellCode 后执行,会触发 Windows 的数据执行保护(DEP),导致进程崩溃;
Sandboxie 在这里强制使用 PAGE_EXECUTE_READWRITE,是绕过 DEP 执行 ShellCode 的关键。
(2)写入长度的严格校验
Length1 == Length2 是必须的 —— 如果实际写入长度小于预期长度,ShellCode 会被截断,执行时出现 “非法指令” 错误;
比如 TestLength=1024,但 Length2=512,说明写入失败,直接返回 NULL 避免后续错误。
(3)资源安全
函数只负责 “分配 + 写入”,不负责释放 —— 释放逻辑由上层函数(InjectProcess)在出错时处理,避免内存泄漏;
分配失败时直接返回 NULL,上层函数会捕获这个错误(LastError=0x33)并退出。
需要注意问题:
内存分配大小:ViewSize 必须大于等于 TestLength,否则 ShellCode 写入时会超出内存范围;权限问题:
调用进程必须有 PROCESS_VM_OPERATION 和 PROCESS_VM_WRITE 权限(OpenProcess 时需指定);
目标进程如果是系统进程(如 System),普通权限无法分配内存,需管理员权限;
DEP 绕过:
Windows 7+ 默认开启 DEP,必须使用 PAGE_EXECUTE_READWRITE 权限,否则 ShellCode 执行失败;
void* AllocateRemoteMemory(HANDLE ProcessHandle, SIZE_T ViewSize, BOOLEAN
IsExecutable
#ifdef _M_ARM64
#endif
) {
SIZE_T v7 = ViewSize;
void* v5 = NULL;
#ifdef _M_ARM64
#endif
//
// allocate virtual memory somewhere in the process. to
force an
// address in the low 24-bits of the address space, we have to use
// NtAllocateVirtalMemory and specify ZeroBits = 8 (32 - 8 = 24)
//
//for (int i = 8; !remote_addr && i > 2; i--) {
for (int i = 8; !v5 && i >= 0; i--) {
NTSTATUS Status =
__NtAllocateVirtualMemory(ProcessHandle,
&v5, i, &v7, MEM_COMMIT |
MEM_RESERVE, IsExecutable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE);
if (!NT_SUCCESS(Status)) {
v5 = NULL;
v7 = ViewSize;
}
}
return v5;
}
核心是在目标进程中分配指定权限的虚拟内存,且优先尝试分配到低地址空间(适配老系统 / 特殊场景)。
这个函数的核心目标:
调用内核的 NtAllocateVirtualMemory(而非用户层 VirtualAllocEx),在目标进程中分配虚拟内存;
通过循环调整 ZeroBits 参数,优先将内存分配到低地址空间(32 位地址的低 24 位);
根据 IsExecutable 参数设置内存权限(PAGE_EXECUTE_READWRITE 或 PAGE_READWRITE);
分配成功返回内存地址,失败返回 NULL。
简单说:它不只是 “分配内存”,还做了地址空间适配—— 保证分配的内存地址足够低,避免 32 位程序 / 老版本 Windows 因地址超出范围导致
ShellCode 执行失败。
MEM_COMMIT:MEM_RESERVE
内存分配标志:MEM_RESERVE 保留地址空间,MEM_COMMIT 提交物理内存(两步合一)
关键:ZeroBits 参数的作用(核心难点)
ZeroBits 是这个函数的灵魂,举个例子(32 位地址空间):
ZeroBits = 8:地址的高 8 位必须为 0 → 分配的地址范围是 0x00000000 ~ 0x00FFFFFF(低 24 位);
ZeroBits = 4:地址的高 4 位必须为 0 → 分配的地址范围是 0x00000000 ~ 0x0FFFFFFF(低 28 位);
ZeroBits = 0:无限制 → 系统在任意空闲地址分配。
Sandboxie 循环 i 从 8 到 0,就是优先尝试分配到最低的地址空间,失败则放宽地址限制,直到分配成功。
Sandboxie 做这个循环的核心原因是兼容:
适配 32 位老程序:部分 32 位程序的 ShellCode 依赖低地址(如 0x00XXXXXX),高地址会导致跳转指令失效;
适配 Windows 老版本:Windows 2000/XP 对高地址内存的兼容性差,低地址更稳定;
避免地址跨度问题:低地址分配的内存,和 LdrInitializeThunk 的地址差更小,5 字节短跳转(E9)就能覆盖,无需长跳转;
鲁棒性:优先尝试严格限制(ZeroBits=8),失败则逐步放宽,直到分配成功,避免因地址限制导致注入失败。
HANDLE CopyDeviceHandle(HANDLE ProcessHandle)
{
NTSTATUS Status;
HANDLE DeviceHandle, v5;
UNICODE_STRING v1;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK IoStatusBlock;
//
// open the Sandboxie driver API file handle
//
RtlInitUnicodeString(&v1, DEVICE_NAME);
InitializeObjectAttributes(
&ObjectAttributes, &v1, OBJ_CASE_INSENSITIVE,
NULL, NULL);
Status = __NtOpenFile(
&DeviceHandle, FILE_GENERIC_READ,
&ObjectAttributes, &IoStatusBlock,
FILE_SHARE_READ | FILE_SHARE_WRITE |
FILE_SHARE_DELETE, 0);
if (NT_SUCCESS(Status)) {
//
// duplicate opened handle into new process
//
BOOL IsOk = DuplicateHandle(NtCurrentProcess(),
DeviceHandle,
ProcessHandle, &v5, 0, FALSE,
DUPLICATE_SAME_ACCESS);
CloseHandle(DeviceHandle);
if (IsOk) {
return v5;
}
}
return NULL;
}
实现 “目标进程与内核驱动通信” 的关键函数—— 它的核心作用是把当前进程持有的 Sandboxie 驱动设备句柄,复制到目标注入进程中,让目标进程的
ShellCode 能通过这个句柄和 SbieDrv.sys 内核驱动通信,完成 “用户层拦截 → 内核层重定向” 的闭环。
这个函数的核心目标非常明确:
在当前进程中打开 Sandboxie 驱动的设备句柄(\\.\SbieDrv);
通过 DuplicateHandle 将这个句柄复制到目标注入进程中;
关闭当前进程的驱动句柄,返回目标进程可用的句柄;
全程处理错误,失败则返回 NULL。
简单说:它是 “目标进程和内核驱动之间的桥梁”—— 没有这个复制的句柄,目标进程的 ShellCode
拦截到文件操作后,无法通知内核驱动进行路径重定向,沙箱隔离就会失效。
DuplicateHandle :Windows 核心 API,将一个进程的句柄复制到另一个进程,让目标进程能访问同一个内核对象
DuplicateHandle 的核心意义
句柄是 “进程私有” 的 —— 当前进程打开的驱动句柄,目标进程无法直接使用(句柄值只在当前进程有效)。DuplicateHandle 的作用是:
告诉内核:“把当前进程的 DeviceHandle 对应的内核对象(驱动设备),给目标进程创建一个新的句柄 v5”;
最终效果:目标进程的 v5 句柄和当前进程的 DeviceHandle,指向同一个内核驱动设备对象,都能和 SbieDrv.sys 通信。
目标进程通过这个复制的句柄,能完成两件核心事:
上报拦截的系统调用:ShellCode 拦截到 NtCreateFile 后,通过这个句柄调用
DeviceIoControl,把文件路径、操作类型等信息发给内核驱动;
获取重定向路径:内核驱动根据 HomePath 计算出沙箱内的虚拟路径,通过句柄返回给目标进程的 ShellCode;
执行重定向操作:ShellCode 用内核返回的虚拟路径,调用原始 Syscall 完成文件操作,实现 “看起来操作真实路径,实际操作沙箱路径”。
DuplicateHandle 的底层作用
DuplicateHandle 不是简单「复制一个数字」,而是告诉内核做 3 件事:
校验当前进程的 DeviceHandle 有效,且有足够权限;
在目标进程的句柄表中,找一个空闲索引,创建新的句柄项;
新句柄项中,写入和原句柄完全相同的「内核对象指针 + 访问权限」;
返回这个新索引(目标进程的 v5)给当前进程。
最终效果:目标进程的新句柄,和当前进程的原句柄,指向同一个内核驱动设备对象,但使用的是自己句柄表的索引,符合 Windows 的进程隔离规则。
Sandboxie 选择复制而非让目标进程自己打开,有 3 个关键原因:
权限问题:目标进程可能是低权限进程(比如普通用户进程),没有权限打开内核驱动设备,而当前进程(Sandboxie 服务进程)是高权限,复制句柄可绕过权限检查;
隐蔽性问题:目标进程自己打开驱动句柄,会在进程的句柄表中留下明显痕迹,容易被安全软件检测;复制句柄更隐蔽,且能控制句柄的继承性(FALSE 参数);
效率问题:当前进程已经打开了驱动句柄,复制比让目标进程重新打开更高效,且能保证权限一致。
用户层拦截 + 内核层重定向
Sandboxie 实现用户层拦截 + 内核层重定向的架构,是 Windows 进程沙箱化的经典设计模式 —— 核心逻辑是
“用户层捕获进程行为,内核层修改行为结果”,两者通过驱动句柄通信,最终实现文件、注册表、进程等资源的隔离。结合你之前分析的
InjectProcess、CopyDeviceHandle 等函数,我会详细拆解这个架构的分层职责、完整流程、核心技术细节,以及各层之间的协作关系。
| 层级 | 核心职责 | 关键技术 | 对应你分析的函数 |
|---|---|---|---|
| Ring3 用户层 | 1. 向目标进程注入 ShellCode/Syscall 钩子 2. 拦截目标进程的 NtXXX 系统调用(如 NtCreateFile) 3. 通过驱动句柄将拦截信息上报内核 4. 执行内核返回的重定向逻辑 | - ShellCode 注入 - Syscall 钩子 - DeviceIoControl 通信 - DuplicateHandle 句柄传递 | InjectProcess/ CopyShellCode/ CopyDeviceHandle |
| Ring0 内核层 | 1. 接收用户层上报的系统调用参数(如文件路径) 2. 根据沙箱规则计算重定向路径(如 C:\Sandbox\XXX\ + 原路径) 3. 修改内核对象的属性(如文件对象的路径) 4. 将重定向结果返回用户层 | - 内核态系统调用拦截(SSDT/Shadow SSDT 钩子) - 路径虚拟化 - 内核对象管理 - IOCTL 接口实现 | SbieDrv.sys 核心逻辑 |
核心目标:让目标进程 “以为” 自己在操作真实系统资源,实际所有操作都被重定向到沙箱目录中,不影响真实系统。
在理解流程前,先明确 Sandboxie 的两个核心隔离规则,这是重定向的依据:
HomePath 规则:每个沙箱有一个根目录(如 C:\Sandbox\DefaultBox\),目标进程的所有文件操作都被重定向到这个目录下;
虚拟化规则:文件、注册表、进程等对象的 “真实路径” 与 “沙箱路径” 一一映射,内核层负责维护这个映射表。
SandBox通用流程:以 NtCreateFile(创建文件)为例
NtCreateFile 是进程创建文件的核心系统调用,我们以它为例,拆解 “用户层拦截 → 内核层重定向 → 结果返回” 的完整链路,这个链路也是
Sandboxie 所有资源隔离的通用流程。
阶段 1:用户层注入与 Syscall 钩子(前期准备)
这一步是你分析的 InjectProcess/CopyShellCode 完成的,是拦截的基础:
ShellCode 注入:通过 CopyShellCode 在目标进程分配可执行内存,写入沙箱核心拦截逻辑(ShellCode);
劫持 LdrInitializeThunk:修改目标进程 ntdll.dll 中 LdrInitializeThunk 函数的首指令,跳转到
ShellCode;
Syscall 钩子安装:ShellCode 遍历 ntdll.dll 中所有 NtXXX 函数(如 NtCreateFile),修改其末尾的 Syscall
指令,使其跳转到拦截逻辑;
驱动句柄复制:通过 CopyDeviceHandle 将 SbieDrv.sys 的设备句柄复制到目标进程,为后续通信做准备。
最终效果:目标进程调用任何 NtXXX 系统调用时,都会先触发用户层的拦截逻辑。
阶段 2:用户层拦截 NtCreateFile 调用(捕获行为)
当目标进程执行 fopen("C:\\test.txt", "w") 时,底层会调用 NtCreateFile,此时用户层拦截逻辑被触发:
参数捕获:拦截逻辑获取 NtCreateFile 的所有入参,核心是 文件路径(C:\test.txt)和 操作类型(创建 / 读 / 写);
权限校验:检查当前操作是否在沙箱允许的范围内(如是否禁止写入系统盘);
内核通信:调用 DeviceIoControl,通过复制的驱动句柄,向 SbieDrv.sys 发送以下信息:
IOCTL 控制码:SBIE_IOCTL_CREATE_FILE(自定义,标识 “创建文件” 操作);
入参数据:原文件路径 C:\test.txt、进程 ID、沙箱 ID 等;
等待内核响应:暂停当前 NtCreateFile 调用,等待内核返回重定向后的路径。
阶段 3:内核层重定向处理(修改行为结果)
SbieDrv.sys 收到用户层的 IOCTL 请求后,在 Ring0 执行核心重定向逻辑:
参数解析:内核驱动解析 IOCTL 数据,提取原路径 C:\test.txt 和沙箱 ID;
路径映射计算:根据沙箱的 HomePath 规则,计算重定向路径:
原路径:C:\test.txt
沙箱 HomePath:C:\Sandbox\DefaultBox\
重定向路径:C:\Sandbox\DefaultBox\C\test.txt
这里的映射规则是 Sandboxie 的核心
——保留原路径的目录结构,只是将根目录替换为沙箱根目录;创建沙箱目录:如果重定向路径的父目录(C:\Sandbox\DefaultBox\C\)不存在,内核驱动自动创建;内核对象操作:驱动调用内核原生的
NtCreateFile,但传入的路径是
重定向后的路径(C:\Sandbox\DefaultBox\C\test.txt),创建真实的文件对象;结果封装:将以下信息返回给用户层:
重定向是否成功(STATUS_SUCCESS 或错误码);
重定向后的文件路径;
内核创建的文件对象句柄(可选)。
阶段 4:用户层执行后续逻辑(完成调用)
用户层收到内核的响应后,继续处理:
结果判断:如果重定向成功,将原 NtCreateFile 调用的路径参数替换为内核返回的重定向路径;
执行原始 Syscall:调用未被钩子的原始 NtCreateFile 指令,完成文件创建;
返回结果给目标进程:将文件句柄返回给目标进程的 fopen 函数,目标进程 “感知不到” 路径被修改,以为自己创建了 C:\test.txt。
阶段 5:后续操作的透明处理(读 / 写 / 删除)
当目标进程后续对 C:\test.txt 执行读 / 写 / 删除操作时,会重复上述流程:
用户层拦截 NtReadFile/NtWriteFile 调用,上报内核;
内核根据之前的映射表,找到对应的沙箱文件,执行操作;
所有操作都在沙箱目录中完成,真实系统的 C:\test.txt 不会被修改
核心难点:
1. 用户层:如何高效拦截所有 Syscall?
钩子点选择:不钩子 Win32 API(如 CreateFileW),而是钩子 ntdll.dll 的 NtXXX 函数 —— 因为 Win32 API
是用户层封装,而 NtXXX 是直接进入内核的入口,拦截更彻底;
跳板(Trampoline)技术:修改 NtXXX 函数的 Syscall 指令时,不直接覆盖,而是构建跳板 ——
先执行原指令的前几字节,再跳转到拦截逻辑,避免指令断裂导致进程崩溃;
无 DLL 注入:使用 ShellCode 而非 DLL 注入 ——ShellCode 是纯机器码,无 DLL 依赖,隐蔽性更强,不易被安全软件检测。
2. 内核层:如何实现路径的透明重定向?
SSDT 钩子(可选):部分版本的 Sandboxie 会钩子内核的 系统服务描述符表(SSDT)——SSDT 是内核中存储所有 NtXXX
函数地址的表,钩子后可以直接在内核层拦截系统调用,无需用户层转发;
对象管理器钩子:内核驱动通过钩子 Windows 的 对象管理器(Object Manager)—— 当进程请求打开一个文件 /
注册表对象时,驱动修改对象的名称(路径),使其指向沙箱目录;
映射表维护:内核驱动为每个沙箱维护一个 路径映射表,记录 “原路径 ↔ 沙箱路径” 的对应关系,后续操作直接查表,无需重复计算。
3. 两层通信:如何保证高效与安全?
IOCTL 接口设计:驱动提供自定义的 IOCTL 控制码,不同操作(文件 / 注册表 / 进程)对应不同的控制码,通信数据结构严格定义,避免解析错误;
句柄权限控制:通过 DuplicateHandle 复制的驱动句柄,只赋予 FILE_GENERIC_READ
权限,限制目标进程只能向驱动发送请求,不能修改驱动内部数据;
同步机制:用户层通过 DeviceIoControl 的同步调用,等待内核处理完成后再继续,保证操作的原子性。
总结
Sandboxie 的 “用户层拦截 + 内核层重定向” 架构,核心是 “用户层抓行为,内核层改结果”:
用户层 是 “传感器”,负责捕获目标进程的每一个系统调用,并传递给内核;
内核层 是 “控制器”,负责根据沙箱规则修改调用的参数(如路径),并执行真实操作;
驱动句柄 是 “通信线”,连接两层,保证信息传递的高效与安全。
这个架构是 Windows 沙箱技术的典范,也是分析 SbieDrv.sys 和用户层注入函数的核心逻辑主线。
重定向的意义?重定向后的路径是否是真实存在的?为什么这样就算隔离了?
重定向的意义是「资源隔离」,重定向后的路径是真实存在的物理路径,而隔离的本质是「让进程的所有操作都局限在这个独立的物理路径中,与真实系统路径解耦」
一、重定向的核心意义:从「共享资源」到「独占资源」
Windows 系统中,所有进程默认共享同一个文件系统、注册表等核心资源 —— 比如进程 A 写入 C:\test.txt,进程 B 能直接读取 /
修改这个文件,这是系统的默认行为,但也是安全风险的根源(比如恶意程序篡改系统文件)。
重定向的核心意义,就是打破这种 “共享”,为目标进程打造一个 “专属的资源空间”:
安全隔离:目标进程的所有文件 / 注册表操作,都被限制在沙箱目录内,即使进程是恶意的,也无法修改真实系统的
C:\Windows\System32、HKLM\SYSTEM 等关键路径;
环境隔离:不同沙箱的进程互不干扰 —— 沙箱 A 的 C:\test.txt 和沙箱 B 的 C:\test.txt
是两个不同的物理文件,测试不同版本软件时不会互相覆盖配置;
可追溯 / 可清理:沙箱内的所有操作都集中在一个目录下,删除沙箱时只需删除这个目录,就能彻底清理所有操作痕迹,无需担心残留文件 / 注册表项;
透明性:目标进程 “感知不到” 重定向 —— 它以为自己在操作 C:\test.txt,实际操作的是沙箱目录下的文件,无需修改进程代码,适配所有程序。
简单说:重定向是「给进程画一个圈,让它只能在圈里玩,圈外的真实系统不受影响」。
二、重定向后的路径:是真实存在的物理路径
很多人会误以为重定向是 “虚拟路径”(只存在于内存中),但实际是:
1. 重定向路径的本质:真实的物理文件 / 目录
比如目标进程调用 fopen("C:\\test.txt", "w"),Sandboxie 重定向到
C:\Sandbox\DefaultBox\C\test.txt:
这个路径是真实存在于硬盘上的物理路径,不是内存中的虚拟路径;
Sandboxie 会自动创建缺失的父目录(如 C:\Sandbox\DefaultBox\C\),保证路径的有效性;
你可以直接在资源管理器中打开 C:\Sandbox\DefaultBox\C\,看到目标进程创建的 test.txt 文件,和普通文件无区别。
2. 为什么要使用真实路径,而非纯虚拟内存?
性能:纯虚拟内存存储文件,重启后数据丢失,且读写速度远低于物理硬盘;
兼容性:很多程序依赖文件系统的物理特性(如文件大小、修改时间、权限),虚拟路径无法满足这些需求;
可访问性:用户可直接查看 / 修改沙箱内的文件,方便调试、备份或提取数据。
3. 例外:注册表的 “虚拟” 与 “物理” 结合
注册表的重定向特殊一些:
沙箱内的注册表操作,会先写入沙箱目录下的虚拟注册表文件(如 C:\Sandbox\DefaultBox\RegHive),而非直接写入系统注册表;
但这个 RegHive 文件是真实的物理文件,进程读取注册表时,Sandboxie 会合并 “系统注册表 + 沙箱虚拟注册表” 的内容,让进程看到
“完整的注册表”,但写入只影响虚拟文件
系统级的隔离:内核层保证重定向的不可绕过
Sandboxie 的重定向是在内核层完成的,而非用户层:
进程无法通过 “绕过用户层钩子” 访问真实路径(比如直接调用内核 NtCreateFile),因为内核驱动会拦截所有系统调用,强制重定向;
即使进程尝试修改自己的内存,跳过用户层拦截逻辑,内核层的 SSDT / 对象管理器钩子依然会生效,保证重定向不被绕过。
重定向的意义:为进程打造独立的资源空间,实现安全、环境、可清理的隔离,且对进程透明;重定向路径的真实性:是硬盘上真实存在的物理路径(注册表是 “虚拟文件 +
物理存储”),保证性能和兼容性;隔离的本质:内核层强制将进程的所有操作限制在重定向路径内,与真实系统资源解耦,操作局限在沙箱目录,无法影响外部系统。
21 - 3,4 调试没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~
22
BOOLEAN Has32BitJumpHorizon(void* Target, void* Detour)
{
ULONG_PTR Diff1;
long long Diff2;
Diff1 = (ULONG_PTR)((ULONG_PTR)Target - (ULONG_PTR)Detour);
Diff2 = Diff1;
Diff2 < 0 ? Diff2 *= -1 : Diff2;
//is DetourFunc in 32bit jump range
if (Diff2 < 0x80000000) {
return TRUE;
}
return FALSE;
}
判断两个地址是否在 32 位短跳转(E9 指令)覆盖范围内的核心函数—— 它的作用就是检测 Target(比如 ShellCode 地址)和 Detour(比如
LdrInitializeThunk 地址)的地址差是否小于 2GB(0x80000000),从而决定是否需要启用 JUMP_TABLE 跳转表。
这个函数的核心目标:
计算两个内存地址(Target 和 Detour)之间的绝对值差;
判断这个差值是否小于 0x80000000(即 2147483648,2GB);
若小于 2GB → 返回 TRUE(可用 32 位短跳转);若大于等于 2GB → 返回 FALSE(需要用跳转表)。
ULONG InjecitonPrepareInternal(BOOLEAN IsWow64, void** TextAddress, ULONG*
TextLength,
ULONG* StartOffset, ULONG* DataInfoOffset, ULONG*
DetourCodeOffset)
{
//
// lock the SbieLow resource (embedded within the SbieSvc
executable,
// see lowlevel.rc) and find the offset to executable code, and
length
//
IMAGE_DOS_HEADER* ImageDosHeader = 0;
IMAGE_NT_HEADERS* ImageNtHeaders = 0;
IMAGE_SECTION_HEADER* ImageSectionHeader = 0;
IMAGE_DATA_DIRECTORY* ImageDataDirectory = 0;
ULONG_PTR ImageBase = 0;
MY_TARGETS* MyTargets = 0;
ULONG Error = 0x11;
/*
HRSRC ResourceHandle = FindResource(Dll_Instance, IsWow64 ?
L"LOWLEVEL32" : L"LOWLEVEL64", RT_RCDATA);
if (!ResourceHandle)
return Error;
ULONG v7 = SizeofResource(Dll_Instance, ResourceHandle);
if (!v7)
return Error;
HGLOBAL v5 = LoadResource(Dll_Instance, v7);
if (!hglob)
return Error;
UCHAR* VirtualAddress = (UCHAR*)LockResource(v5);
if (!VirtualAddress)
return Error;
*/
TCHAR v1[MAX_PATH] = { 0 };
GetCurrentDirectory(MAX_PATH, v1);
#ifdef _WIN64
_tcscat_s(v1, _T("\\x64\\Debug\\LowLevel.dll"));
#else
_tcscat_s(v1, _T("\\Debug\\LowLevel.dll"));
#endif
DWORD FileSizeLow = 0;
DWORD FileSizeHigh = 0;
UCHAR* BufferData = NULL;
DWORD NumberOfBytesRead = 0;
BOOL IsOk = FALSE;
HANDLE FileHandle = INVALID_HANDLE_VALUE;
FileHandle = CreateFile(v1,
GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ
| FILE_SHARE_WRITE,
0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,
0); //打开文件获得文件句柄
if (FileHandle != INVALID_HANDLE_VALUE)
{
FileSizeLow = GetFileSize(FileHandle,
&FileSizeHigh);
BufferData = (UCHAR*)malloc(FileSizeLow + 0x1000);
IsOk = ReadFile(FileHandle, BufferData, FileSizeLow,
&NumberOfBytesRead, 0);
}
Error = 0x22;
ImageDosHeader = (IMAGE_DOS_HEADER*)BufferData;
if (ImageDosHeader->e_magic == 'MZ' ||
ImageDosHeader->e_magic == 'ZM') {
ImageNtHeaders =
(IMAGE_NT_HEADERS*)((UCHAR*)ImageDosHeader + ImageDosHeader->e_lfanew);
if (ImageNtHeaders->Signature !=
IMAGE_NT_SIGNATURE) // 'PE\0\0'
return Error;
if (ImageNtHeaders->OptionalHeader.Magic !=
(IsWow64 ? IMAGE_NT_OPTIONAL_HDR32_MAGIC:
IMAGE_NT_OPTIONAL_HDR64_MAGIC))
return Error;
if (IsWow64) {
IMAGE_NT_HEADERS32*
ImageNtHeaders32 = (IMAGE_NT_HEADERS32*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER32*
ImageOptionalHeader32 = &ImageNtHeaders32->OptionalHeader;
ImageDataDirectory =
&ImageOptionalHeader32->DataDirectory[0];
ImageBase =
ImageOptionalHeader32->ImageBase;
}
else {
IMAGE_NT_HEADERS64*
ImageNtHeaders64 = (IMAGE_NT_HEADERS64*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER64*
ImageOptionalHeader64 = &ImageNtHeaders64->OptionalHeader;
ImageDataDirectory =
&ImageOptionalHeader64->DataDirectory[0];
ImageBase =
(ULONG_PTR)ImageOptionalHeader64->ImageBase;
}
}
ULONG v100 = 1;
#ifdef _M_ARM64
if (!IsWow64)
v100 = 3; // ARM64 only
else
#endif
if (ImageBase != 0) // x64 or x86
return Error;
ImageSectionHeader = IMAGE_FIRST_SECTION(ImageNtHeaders);
if (ImageNtHeaders->FileHeader.NumberOfSections < 2) return
Error;
if (strncmp((char*)ImageSectionHeader[0].Name,
INJECTION_SECTION, strlen(INJECTION_SECTION)) ||
strncmp((char*)ImageSectionHeader[v100].Name,
SYMBOL_SECTION, strlen(SYMBOL_SECTION))) {
return Error;
}
MyTargets =
(MY_TARGETS*)&BufferData[ImageSectionHeader[v100].PointerToRawData];
//文件粒度
if (StartOffset) *StartOffset = (ULONG)(MyTargets->Start -
ImageBase - ImageSectionHeader[0].VirtualAddress);
if (DataInfoOffset) *DataInfoOffset =
(ULONG)(MyTargets->DataInfo - ImageBase -
ImageSectionHeader[0].VirtualAddress);
if (DetourCodeOffset) *DetourCodeOffset =
(ULONG)(MyTargets->DetourCode - ImageBase -
ImageSectionHeader[0].VirtualAddress);
*TextAddress = BufferData +
ImageSectionHeader[0].PointerToRawData; //Old version: head;
*TextLength = ImageSectionHeader[0].SizeOfRawData; //Old
version: (ULONG)(ULONG_PTR)(tail - head);
/*
* txt
0x00000000003592C0 48 8d 41 30 c3 cc cc cc cc cc cc cc cc cc
cc cc 48 83 ec 28 48 89 4c 24 20 48 89 54 24 28 4c 89 44 24 30 4c 89 4c 24 38 e8
00 00 00 00 59 48 8b d9 48 81 c1 40 00 00 00 48 8b d3 48 81
H?A0????????????H??(H?L$ H?T$(L?D$0L?L$8?....YH??H??@...H??H?
0x00000000003592FD c2 3e 00 00 00 4c 8b c3 49 81 c0 3e 00 00
00 e8 af ff ff ff 48 8b 4c 24 20 48 8b 54 24 28 4c 8b 44 24 30 4c 8b 4c 24
38 48 83 c4 28 ff e0 90 90 00 00 00 00 00 00 00 00 00 00 00 00 00
?>...L??I??>...??...H?L$ H?T$(L?D$0L?L$8H??(.???.............
*/
// 48 8d 41 30 c3 cc cc cc cc cc cc cc cc cc cc cc
EntryPointC
//
/*
if (BufferData!=NULL)
{
delete BufferData;
BufferData;
}
*/
return 0;
}
它的作用是解析 LowLevel.dll(沙箱核心 ShellCode 载体)的 PE
结构,提取出注入所需的关键信息(可执行代码地址、长度、关键偏移量),为后续的 ShellCode 注入和函数劫持做准备。
这个函数的核心目标:
读取 LowLevel.dll 文件(沙箱核心 ShellCode 所在的 DLL)到内存;
解析该 DLL 的 PE 结构(DOS 头、NT 头、节表),验证 PE 合法性;
定位两个关键节区:INJECTION_SECTION(可执行 ShellCode 节)和 SYMBOL_SECTION(符号 / 偏移量节);
计算并输出注入所需的关键参数:
TextAddress:ShellCode 可执行代码在内存中的起始地址;
TextLength:ShellCode 代码的长度;
StartOffset/DataInfoOffset/DetourCodeOffset:ShellCode 内关键函数 / 数据的偏移量;
全程做合法性校验,失败则返回错误码(0x11/0x22)。
简单说:它是 “PE 文件解析器”,把 LowLevel.dll 拆成注入需要的 “可执行代码段” 和 “关键偏移量”,是后续
CopyShellCode/InjectProcess 的前置步骤。
BOOLEAN BuildTrampoline(
BOOLEAN Diff, UCHAR* CodeData, ULONG_PTR VirtualAddress
#ifdef _M_ARM64
, BOOLEAN use_arm64ec
#endif
) {
#ifdef _M_ARM64
#else
#define IS_1BYTE(a) (
CodeData[Offset + 0] == (a))
#define IS_2BYTE(a,b) (IS_1BYTE(a) && CodeData[Offset + 1]
== (b))
#define IS_3BYTE(a,b,c) (IS_2BYTE(a,b) && CodeData[Offset + 2] == (c))
//
// skip past several bytes in the code copied from the top of the
// LdrInitializeThunk function, where we will inject a jmp
sequence.
//
// a simple E9 relative JMP five byte instruction in most cases,
// a slightly longer seven byte version in case there is a long
// distance between ntdll and SbieLow, i.e. on 64-bit Windows 8
//
#ifdef _WIN64
ULONG CodeLength = (Diff ? 7 : ((__Windows >= 10) ? 6 : 5));
#else
ULONG CodeLength = 5;
#endif
/*
0000000077BCC340 FF F3
push rbx
0000000077BCC342 48 83 EC 20
sub rsp,20h
0000000077BCC346 48 8B D9
mov rbx,rcx
0000000077BCC349 E8 22 00 00 00
call 0000000077BCC370
0000000077BCC34E B2 01
mov dl,1
0000000077BCC350 48 8B CB
mov rcx,rbx
0000000077BCC353 E8 88 53 02 00
call 0000000077BF16E0
0000000077BCC358 8B C8
mov ecx,eax
0000000077BCC35A E8 71 14 0A 00
call 0000000077C6D7D0
0000000077BCC35F CC
int 3
*/
ULONG Offset = 0;
while (Offset < CodeLength) {
ULONG Length = 0;
if (0)
;
// push ebp
else if (IS_1BYTE(0x55))
Length = 1;
// mov ebp, esp
else if (IS_2BYTE(0x8B, 0xEC))
Length = 2;
// mov edi, edi
else if (IS_2BYTE(0x8B, 0xFF))
Length = 2;
// push ebx
else if (IS_2BYTE(0xFF, 0xF3))
Length = 2;
// push rbx (Windows 8.1)
else if (IS_2BYTE(0x40, 0x53))
Length = 2;
// mov dword ptr [esp+imm8],eax
else if (IS_3BYTE(0x89, 0x44, 0x24))
Length = 4;
// lea eax, esp+imm8
else if (IS_3BYTE(0x8D, 0x44, 0x24))
Length = 4;
// sub rsp, imm8
else if (IS_3BYTE(0x48, 0x83, 0xEC))
Length = 4;
// mov rbx, rcx
else if (IS_3BYTE(0x48, 0x8B, 0xD9))
Length = 3;
/*
else if (IS_3BYTE(0x48, 0x8B, 0x04))
inst_len = 4;
*/
//
// abort if we don't recognize the instruction
//
if (!Length) {
return FALSE;
}
Offset += Length;
}
#undef IS_3BYTE
#undef IS_2BYTE
#undef IS_1BYTE
//
// append a jump instruction at the bottom of our trampoline for
// LdrInitializeThunk, which jumps back to the real
LdrInitializeThunk
//
// note that on Windows 8 the difference between the address of
// LdrInitializeThunk in the 64-bit ntdll and where SbieLow was
copied
// may be greater than 32-bit, so we use JMP QWORD rather than the
// 5-byte 0xE9 relative JMP
//
#ifdef _WIN64
if (!Diff) {
if (__Windows >= 10) {
CodeData[Offset] = 0x48;
CodeData[Offset + 1] = 0xE9;
// jmp
*(ULONG*)&CodeData[Offset + 2] =
(ULONG)
(__LdrInitializeThunk
+ Offset - (VirtualAddress + Offset + 6));
}
else {
CodeData[Offset] = 0xe9;
*(ULONG*)&CodeData[Offset + 1] =
(ULONG)
(__LdrInitializeThunk
+ Offset - (VirtualAddress + Offset + 5));
}
}
else {
*(USHORT*)&CodeData[Offset] = 0x25FF;
// jmp qword ptr
*(ULONG*)&CodeData[Offset + 2] = 0;
*(ULONG64*)&CodeData[Offset + 6] =
__LdrInitializeThunk + Offset;
}
#else
CodeData[Offset] = 0xE9;
//
jmp
*(ULONG*)&CodeData[Offset + 1] = (ULONG)
(__LdrInitializeThunk + Offset - (VirtualAddress +
VirtualAddress + 5));
#endif
#endif
return TRUE;
}
为 LdrInitializeThunk 函数构建 “跳板(Trampoline)”
代码:先保留原函数开头的合法指令,再拼接跳转指令,既保证原函数逻辑不被破坏,又能劫持执行流到沙箱的 ShellCode,同时解决 x64 下 “长距离跳转”
的问题。
这个函数的核心目标:
解析原函数指令:读取 LdrInitializeThunk 开头的指令,跳过指定长度(CodeLength)的合法指令,避免破坏原函数逻辑;
构建跳板指令:在跳过的指令后拼接跳转指令 —— 短跳转(E9)或长跳转(FF 25),跳回 LdrInitializeThunk 的原始逻辑;
架构适配:针对 x64/x86、不同 Windows 版本(Win10+)、长 / 短地址跨度,生成不同的跳转指令;
错误兜底:若识别不了原函数指令,返回 FALSE,避免注入后进程崩溃。
简单说:它是 “指令缝合器”—— 把原函数的开头指令和跳转指令拼接成跳板,让 ShellCode 执行完后能无缝跳回原
LdrInitializeThunk,既实现劫持,又保证进程正常运行。
Trampoline(跳板)的意义
在函数劫持中,直接覆盖原函数开头的指令会导致原函数逻辑损坏(比如 LdrInitializeThunk 执行失败,进程启动崩溃)。
跳板的核心作用是:
保留原逻辑:先复制原函数开头的 N 字节合法指令到跳板;
劫持执行流:原函数开头改为跳转到 ShellCode;
无缝回归:ShellCode 执行完后,通过跳板的跳转指令回到原函数的剩余逻辑。
保证跳过的指令是完整的、合法的,不会截断 sub rsp,20h 这样的 4 字节指令。
1. 小端序问题
*(USHORT*)&CodeData[Offset] = 0x25FF:
x86/x64 是小端序(低字节存低地址),所以 0x25FF 实际存储为 FF 25(对应指令 FF 25);
若直接写 CodeData[Offset] = 0xFF; CodeData[Offset+1] = 0x25;,效果一致。
2. Win10+ x64 的 6 字节跳转
Win10 对 LdrInitializeThunk 做了指令优化,开头多了 1 字节前缀(0x48),因此跳转指令长度从 5 字节变为 6
字节,偏移量计算要加 6 而非 5。
22-2调试没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
23
void* CopySyscalls(HANDLE ProcessHandle, BOOLEAN IsWow64
#ifdef _M_ARM64
, BOOLEAN use_arm64ec
#endif
) {
void* v5 = NULL;
ULONG* v1;
#ifdef _M_ARM64
if (use_arm64ec)
v1 = m_syscall_ec_data;
else
#endif
v1 = __SyscallsData;
SIZE_T ViewSize = *v1;
v5 = AllocateRemoteMemory(ProcessHandle, ViewSize, FALSE
#ifdef _M_ARM64
, FALSE
#endif
);
if (v5) {
//
// copy the syscall data buffer into the new process
//
SIZE_T Length1 = *v1;
SIZE_T Length2 = 0;
BOOL IsOk = WriteProcessMemory(
ProcessHandle, v5, v1, Length1,
&Length2);
if (IsOk && Length1 == Length2) {
return v5;
}
}
return NULL;
}
CopySyscalls 是 Sandboxie 注入流程中负责将 Syscall 钩子数据复制到目标进程的核心函数—— 它的作用是把沙箱需要的 Syscall
拦截表(__SyscallsData)从当前进程(Sandboxie 服务进程)复制到目标进程的远程内存中,为后续用户层拦截 NtXXX 系统调用提供
“钩子规则表”。
这个函数的核心目标:
获取 Syscall 钩子数据:根据架构(ARM64 / 其他)选择对应的 Syscall
数据(m_syscall_ec_data/__SyscallsData);
分配远程内存:在目标进程中分配一块与 Syscall 数据大小匹配的可读写内存;
复制数据到目标进程:将当前进程的 Syscall 钩子数据写入目标进程的远程内存;
返回内存地址:成功则返回目标进程中 Syscall 数据的起始地址,失败返回 NULL。
简单说:它是 “Syscall 钩子数据的搬运工”—— 把沙箱拦截系统调用需要的规则表,从 Sandboxie 服务进程复制到目标进程,让目标进程的
ShellCode 能基于这份表拦截 NtCreateFile/NtOpenKey 等 Syscall。
在理解函数前,先明确 __SyscallsData 是什么:
它是一个全局内存缓冲区,存储了 Sandboxie 要拦截的所有 NtXXX 系统调用的关键信息
关键:v1 最终指向 Syscall 数据的起始地址,ViewSize 是数据的总长度,后续所有内存操作都基于这个值。
分配的内存是目标进程的私有内存,当前进程可通过 WriteProcessMemory 写入数据。
void* AllocateRemoteMemory(HANDLE ProcessHandle, SIZE_T ViewSize, BOOLEAN
IsExecutable
#ifdef _M_ARM64
#endif
) {
SIZE_T v7 = ViewSize;
void* v5 = NULL;
#ifdef _M_ARM64
#endif
//
// allocate virtual memory somewhere in the process. to
force an
// address in the low 24-bits of the address space, we have to use
// NtAllocateVirtalMemory and specify ZeroBits = 8 (32 - 8 = 24)
//
//for (int i = 8; !remote_addr && i > 2; i--) {
for (int i = 8; !v5 && i >= 0; i--) {
NTSTATUS Status =
__NtAllocateVirtualMemory(ProcessHandle,
&v5, i, &v7, MEM_COMMIT |
MEM_RESERVE, IsExecutable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE);
if (!NT_SUCCESS(Status)) {
v5 = NULL;
v7 = ViewSize;
}
}
return v5;
}
AllocateRemoteMemory 是 Sandboxie 中底层的远程内存分配函数—— 它的核心作用是在目标进程中分配指定大小的内存,并且通过
ZeroBits 参数强制让分配的内存地址落在低 24 位地址空间(x86 兼容区),解决 64 位系统下地址兼容性问题,同时支持可执行 /
可读写两种内存权限(适配 ShellCode / 数据存储场景)
这个函数的核心目标:
精准分配内存地址:通过 ZeroBits 参数循环尝试(i 从 8 到 0),强制让分配的内存地址落在低 24 位地址空间(0x00000000 ~
0x00FFFFFF);
适配内存权限:根据 IsExecutable 标记,分配 PAGE_EXECUTE_READWRITE(可执行,用于 ShellCode)或
PAGE_READWRITE(可读写,用于数据)内存;
容错处理:如果某次分配失败(NT_SUCCESS(Status) 为假),重置参数后继续尝试,直到分配成功或循环结束;
返回内存地址:成功则返回目标进程中分配的内存起始地址,失败返回 NULL。
简单说:它是 Sandboxie 定制化的 VirtualAllocEx 替代函数 —— 不仅能分配远程内存,还能强制控制内存地址的范围,保证 64
位系统下分配的地址兼容 x86 程序的寻址习惯,避免地址跨度过大导致的跳转问题。
Sandboxie 为什么要强制低 24 位地址?
兼容 x86 程序:x86 程序的地址空间只有 4GB,且习惯使用低地址(0x00XXXXXX)存储数据 / 代码,64 位系统下分配低 24
位地址能减少地址跨度;
简化跳转计算:低 24 位地址与 ntdll.dll 的地址(0x77XXXXXX)的跨度更容易控制在 2GB 内,避免频繁使用 JUMP_TABLE;
稳定性:低地址区域的内存碎片更少,分配成功率更高,且不易与目标进程原有内存冲突。
BOOLEAN CopyData(
HANDLE ProcessHandle, void* VirtualAddress, void* BufferData)
{
//
// copy SBIELOW_DATA data into the area reserved within SbieLow
// (i.e. at offset SBIELOW_DATA_OFFSET) in the new process
//
void* v1 = (void*)((ULONG_PTR)VirtualAddress +
__DataInfoOffset);
SIZE_T Length1 = sizeof(_DATA_INFO_);
SIZE_T Length2 = 0;
BOOL IsOk = WriteProcessMemory(
ProcessHandle, v1, BufferData, Length1, &Length2);
if (IsOk && Length1 == Length2) {
ULONG OldProtect;
IsOk = VirtualProtectEx(ProcessHandle,
VirtualAddress, __TestLength,
PAGE_EXECUTE_READ, &OldProtect);
if (IsOk) {
return TRUE;
}
}
return FALSE;
}
CopyData 是 Sandboxie 注入流程中完成最后一步数据写入 + 内存权限加固的核心函数—— 它的作用是把沙箱关键的
_DATA_INFO_ 结构体数据写入目标进程中 ShellCode 预留的内存位置,并且将 ShellCode 所在的内存权限从 “可执行可读写”
收紧为 “可执行只读”,既保证沙箱配置生效,又提升内存安全性。
这个函数的核心目标:
定位数据写入地址:计算目标进程中 ShellCode 预留的 _DATA_INFO_ 结构体存储地址(VirtualAddress +
__DataInfoOffset);
写入核心配置数据:将 _DATA_INFO_ 结构体(沙箱核心配置)从当前进程写入目标进程的指定地址;
加固内存权限:将 ShellCode 所在内存的权限从 PAGE_EXECUTE_READWRITE(可执行可读写)改为
PAGE_EXECUTE_READ(可执行只读),防止数据被篡改;
全链路校验:每一步操作都做结果校验,任何一步失败则返回 FALSE,保证操作的可靠性
简单说:它是 Sandboxie 注入的 “收尾工”—— 把沙箱的核心配置(比如重定向路径、拦截规则)传递给目标进程的 ShellCode,同时 “锁死”
ShellCode 内存的写权限,既让沙箱能正常工作,又避免恶意篡改 ShellCode 导致沙箱失效。
为什么要预留偏移?
ShellCode 是一段可执行代码,为了避免配置数据和代码混在一起导致指令错乱,Sandboxie 在编译 ShellCode
时就预留了固定偏移(__DataInfoOffset)的内存区域,专门用于存储 _DATA_INFO_ 结构体,实现 “代码 + 数据分离”。
1. 内存权限修改的范围
VirtualProtectEx 的第三个参数是 __TestLength(ShellCode 的总长度),而非
sizeof(_DATA_INFO_):
目的是将整个 ShellCode 区域的权限都改为只读,而不仅仅是配置数据区域;
避免只改配置区域,而 ShellCode 代码区域仍可写的安全漏洞。
2. 句柄权限要求
调用这个函数需要 ProcessHandle 具备以下权限:
PROCESS_VM_WRITE:写入 _DATA_INFO_ 数据;
PROCESS_VM_OPERATION:修改内存权限;
若权限不足,WriteProcessMemory 或 VirtualProtectEx 会返回 FALSE。
typedef struct _DATA_INFO_ {
ULONG64 Ntdll;
ULONG64 SyscallsData;
//目标进程当前结构的首地址
ULONG64 DeviceHandle;
ULONG IoControlCode;
ULONG InvokeSyscalls;
DATA_FLAGS Flags;
UCHAR IsInitializeDone;
UCHAR reserved[3];
__declspec(align(16))
UCHAR LdrInitializeThunk_tramp[48];
__declspec(align(16))
UCHAR
NtDelayExecution_code[NATIVE_FUNCTION_SIZE];
__declspec(align(16))
UCHAR
NtDeviceIoControlFile_code[NATIVE_FUNCTION_SIZE]; // offset
128
__declspec(align(16))
UCHAR
NtFlushInstructionCache_code[NATIVE_FUNCTION_SIZE]; // offset 160
__declspec(align(16))
UCHAR
NtProtectVirtualMemory_code[NATIVE_FUNCTION_SIZE];
ULONG64 NtDeviceIoControlFile;
// offset 224
ULONG64 ArmNtDeviceIoControlFile;
// for ARM64 // offset 232
ULONG64 NtProtectVirtualMemory;
// offset 240
ULONG64 NtRaiseHardError;
// offset 248
ULONG64 SystemService;
#ifdef _WIN64
//SBIELOW_J_TABLE* x64BitJumpTable;
ULONG64 ntdll_wow64_base;
ULONG64 ptr_32bit_detour;
#endif
#ifdef _M_ARM64
__declspec(align(16))
UCHAR RtlImageOptionsEx_tramp[48];
#endif
} DATA_INFO;
| 字段 | 类型 | 含义 | 作用 |
|---|---|---|---|
| Ntdll | ULONG64 | 目标进程中 ntdll.dll 的基地址 | ShellCode 需从 ntdll.dll 中查找 NtXXX 函数、LdrInitializeThunk 等核心函数地址 |
| SyscallsData | ULONG64 | 目标进程中 Syscall 拦截表的首地址 | 指向 CopySyscalls 复制到目标进程的 Syscall 钩子表,ShellCode 据此拦截指定系统调用 |
| DeviceHandle | ULONG64 | 沙箱内核驱动的设备句柄(如 \\Device\\Sandboxie) | ShellCode 调用 NtDeviceIoControlFile 与内核驱动通信的核心句柄,实现用户层→内核层的指令传递 |
| IoControlCode | ULONG | 与驱动通信的 IOCTL 控制码 | 如IOCTL_SBIE_HOOK_SYSCALL/IOCTL_SBIE_REDIRECT_PATH,指定要驱动执行的操作 |
| InvokeSyscalls | ULONG | Syscall 拦截开关 | 非 0 时启用 Syscall 拦截,0 时禁用,用于动态控制沙箱功能 |
//Hook LdrInitializeThunk 指令跳转到汇编中
BOOLEAN WriteJump(HANDLE ProcessHandle, void* VirtualAddress, BOOLEAN Diff
#ifdef _M_ARM64
, BOOLEAN use_arm64ec
#endif
) {
//
// prepare a short prolog code that jumps to the injected SbieLow
//
UCHAR ShellCode[20];
void* v1 = (void*)VirtualAddress;
UCHAR* OriginalAddress =
(UCHAR*)((ULONG_PTR)__LdrInitializeThunk);
SIZE_T Length1;
BOOL IsOk;
ULONG Protect;
#ifdef _M_ARM64
if (use_arm64ec)
func =
(UCHAR*)((ULONG_PTR)m_LdrInitializeThunkEC);
ULONG* aCode = (ULONG*)jump_code;
//*aCode++ = 0xD43E0000; // brk #0xF000
*aCode++ = 0x58000048; // ldr x8, 8
*aCode++ = 0xD61F0100; // br x8
*(DWORD64*)aCode = (DWORD64)detour; aCode += 2;
len1 = (UCHAR*)aCode - jump_code;
#elif _WIN64
if (!Diff)
{
if (__Windows >= 10) {
Length1 = 6;
ShellCode[0] = 0x48; //jump to entry
code in entry.asm
ShellCode[1] = 0xE9; //jump to entry
code in entry.asm
*(ULONG*)(ShellCode + 2) =
(ULONG)((ULONG_PTR)v1 - (__LdrInitializeThunk + 6));
//remote_addr = (void
*)m_LdrInitializeThunk;
}
else {
Length1 = 5;
ShellCode[0] = 0xe9; //jump to entry
code in entry.asm
*(ULONG*)(ShellCode + 1) =
(ULONG)((ULONG_PTR)v1 - (__LdrInitializeThunk + 5));
}
}
else {
}
#else
Length1 = 5;
ShellCode[0] = 0xE9; //jump to entry code in entry.asm
*(ULONG*)(ShellCode + 1) = (ULONG)((ULONG_PTR)v1 -
(__LdrInitializeThunk + 5));
//remote_addr = (void *)m_LdrInitializeThunk;
#endif
//
// modify the bytes at LdrInitializeThunk with the prolog
code
//
IsOk = VirtualProtectEx(ProcessHandle, OriginalAddress, Length1,
PAGE_READWRITE, &Protect);
if (IsOk) {
SIZE_T Length2 = 0;
IsOk = WriteProcessMemory(ProcessHandle,
OriginalAddress, ShellCode, Length1, &Length2);
/*
sprintf(buffer,"WriteJump: len2 = %d\n",len2);
OutputDebugStringA(buffer);
*/
if (IsOk && Length1 == Length2) {
IsOk = VirtualProtectEx(ProcessHandle,
OriginalAddress, Length1, Protect, &Protect);
if (IsOk) {
return TRUE;
}
}
}
return FALSE;
}
WriteJump 是 Sandboxie 注入流程中最终完成函数劫持的核心函数—— 它的作用是直接修改目标进程中 LdrInitializeThunk
函数的开头指令,写入跳转指令(E9 短跳转 / ARM64 专用跳转),将执行流劫持到之前注入的 ShellCode(VirtualAddress
指向的地址),是整个注入流程的 “最后一击”
简单说:它是 “执行流劫持器”—— 直接改写系统核心函数 LdrInitializeThunk 的开头指令,让目标进程执行该函数时,先跳转到沙箱的
ShellCode 执行沙箱逻辑,是 Sandboxie 实现进程沙箱化的核心操作。
1. 为什么要恢复内存权限?
ntdll.dll 是系统核心模块,默认权限为 PAGE_EXECUTE_READ,禁止写入是系统的安全保护机制;
若不恢复权限,会导致 ntdll.dll 内存可写,恶意程序可能篡改系统函数,引发系统不稳定;
恢复权限是 “最小权限原则” 的体现:仅在写入时临时放开写权限,写入完成后立即收回。
Win10+ x64 新增 0x48 前缀的原因
Win10 对 x64 指令做了优化,E9 跳转指令前加 0x48(REX.W 前缀),强制使用 64 位地址计算,避免地址截断;
若不加该前缀,在 Win10+ 系统下可能导致跳转地址错误,ShellCode 无法执行。
23调试没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
24
Ring0的函数
BOOLEAN InitializeInjectProcess(void)
{
_ProcessEvent = ExAllocatePoolWithTag(NonPagedPool,
sizeof(KEVENT), TAG);
if (!_ProcessEvent) {
return FALSE;
}
KeInitializeEvent(_ProcessEvent, SynchronizationEvent, FALSE);
SetCommonService(INJECT_COMPLETE, InjectProcessComplete);
return TRUE;
}
InitializeInjectProcess 是 Sandboxie 内核层(Ring0) 注入流程的初始化函数——
它的核心作用是为内核层处理用户层注入请求做准备:创建同步事件、注册注入完成的回调函数,是连接用户层注入逻辑和内核层沙箱控制的 “桥梁初始化器”。
首先明确这个函数的背景:
运行层级:Ring0(内核态),属于 Sandboxie 驱动(如 SbieDrv.sys)的核心函数;
核心目标:初始化内核层处理注入请求的基础资源,为后续 InjectProcess(用户层)触发的内核操作提供同步和回调支持;
依赖关系:用户层调用 InjectProcess 后,最终会通过 IOCTL 通知内核驱动,而内核驱动依赖这个函数初始化的事件和回调来处理 “注入完成”
的收尾逻辑。
简单说:这个函数是内核层的 “注入准备开关”—— 打开这个开关(初始化成功)后,内核才能响应用户层的注入请求,处理注入完成后的同步、清理、状态更新等操作。
非分页池(NonPagedPool):
内核内存分为 “分页池” 和 “非分页池”:分页池内存可能被换出到磁盘,非分页池内存始终驻留物理内存;
KEVENT(内核事件)是同步对象,需要在中断 / 任意线程上下文使用,因此必须分配在非分页池,避免内存换出导致崩溃。
ExAllocatePoolWithTag:
内核层内存分配函数,替代用户层的 malloc/new;
TAG 是自定义标签(通常是 4 字符,如 'sbx_'),用于内核调试时识别内存归属(比如用 !pooltag 命令查看 Sandboxie
分配的内存)。
_ProcessEvent:
全局 / 静态的 KEVENT* 指针,指向分配的内核事件对象,用于后续注入流程的同步(比如等待注入完成)。
Ring0
BOOLEAN InjectProcessRequest(
HANDLE ProcessIdentity, ULONG SessionIdentity, ULONG64 CreateTime,
const WCHAR* ImageName, BOOLEAN add_process_to_job, BOOLEAN
IsHostInject)
{
PROCESS_MESSAGE ProcessContext;
ULONG_PTR IsWow64 = 0;
NTSTATUS Status = STATUS_SUCCESS;
BOOLEAN IsDone = FALSE;
KIRQL Irql;
PROCESS* Process;
//
// query wow64 flag for new process
//
#ifdef _WIN64
if (1) {
OBJECT_ATTRIBUTES ObjectAttributes;
CLIENT_ID ClientIdentity;
HANDLE ProcessHandle;
ULONG ReturnLength;
InitializeObjectAttributes(&ObjectAttributes,
NULL, OBJ_CASE_INSENSITIVE |
OBJ_KERNEL_HANDLE, NULL, NULL);
ClientIdentity.UniqueThread = NULL;
ClientIdentity.UniqueProcess = ProcessIdentity;
Status = ZwOpenProcess(&ProcessHandle, 0x400,
//PROCESS_QUERY_INFORMATION,
&ObjectAttributes, &ClientIdentity);
if (NT_SUCCESS(Status)) {
//查询启动进程的位数
Status = ZwQueryInformationProcess(
ProcessHandle,
ProcessWow64Information,
&IsWow64,
sizeof(IsWow64), &ReturnLength);
ZwClose(ProcessHandle);
}
}
#endif _WIN64
//
// send message to SbieSvc DriverAssist
//
if (NT_SUCCESS(Status)) {
ULONG Length = wcslen(ImageName);
const ULONG MaxLength =
sizeof(ProcessContext.ImageName) / sizeof(WCHAR) - 1;
if (Length > MaxLength)
Length = MaxLength;
wmemcpy(ProcessContext.ImageName, ImageName,
Length);
ProcessContext.ImageName[Length] = L'\0';
ProcessContext.ProcessIdentity =
(ULONG)(ULONG_PTR)ProcessIdentity;
ProcessContext.SessionIdentity = SessionIdentity;
ProcessContext.CreateTime = CreateTime;
ProcessContext.IsWow64 = (IsWow64 ? TRUE : FALSE);
ProcessContext.add_to_job = add_process_to_job;
ProcessContext.IsHostInject = IsHostInject;
//将该信息通过PortObject返回Ring3
if (!SendLpcMessage(INJECT_PROCESS,
sizeof(ProcessContext), &ProcessContext))
Status = STATUS_SERVER_DISABLED;
if (NT_SUCCESS(Status))
{
LARGE_INTEGER Time;
ULONG i = 0;
while ((i < 400 * 3) &&
(!__IsDriverUnloading))
{
Process =
FindProcess(ProcessIdentity, &Irql);
if (Process &&
Process->CreateTime == CreateTime)
{
IsDone =
Process->IsLowLevelLoaded || Process->IsTerminated;
if
(!IsWow64)
{
//proc->ntdll32_base = -1;
}
}
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
if (IsDone)
break;
Time.QuadPart =
-(SECONDS(1) / 4); // 250ms*40 = 10s
KeWaitForSingleObject(_ProcessEvent,
Executive,
KernelMode, FALSE, &Time);
++i;
}
if (!IsDone)
// if no response from SbieSvc
Status =
STATUS_TIMEOUT;
}
if (!NT_SUCCESS(Status) && !IsHostInject) {
/*
proc = Process_Find(process_id,
&irql);
if (proc && proc->create_time ==
create_time) {
Process_SetTerminated(proc, 3);
}
ExReleaseResourceLite(Process_ListLock);
KeLowerIrql(irql);
*/
return FALSE;
}
}
return TRUE;
}
Sandboxie 内核层(Ring0) 触发用户层(Ring3)进程注入的核心函数 ——
它的作用是:在内核态获取目标进程的关键信息(WOW64 标记、进程 ID、镜像名等),通过 LPC 端口将注入请求发送给用户态的 SbieSvc
服务,然后等待注入完成,是内核层 “指挥” 用户层完成注入的 “调度中枢”。
核心目标:
在内核态查询目标进程的 WOW64 标记(区分 x86/x64 进程);
封装注入请求参数PROCESS_MESSAGE 结构体(进程 ID、镜像名、创建时间等);
通过 LPC(本地过程调用)将注入请求发送给用户态 SbieSvc 服务;
等待用户层完成注入(最多 10 秒),校验注入结果;
注入超时 / 失败时,终止目标进程(非 HostInject 场景),保证沙箱管控。
LPC 通信(SendLpcMessage):
LPC(Local Procedure Call)是 Windows 内核与用户态通信的核心机制,比 IOCTL 更高效;
INJECT_PROCESS:自定义 LPC 消息类型(如 0x00000002),表示 “进程注入请求”;
SendLpcMessage 逻辑:将 PROCESS_MESSAGE 结构体通过预创建的 LPC 端口(如 \SbieSvcPort)发送给
SbieSvc 服务,用户态服务接收后触发 InjectProcess 注入流程。
阶段 1:查询目标进程的 WOW64 标记(x64 内核特有)
阶段 2:封装注入请求参数(PROCESS_MESSAGE 结构体)
阶段 3:等待用户层注入完成(最多 10 秒)
关键等待逻辑解析:
IRQL 提升 / 降低:
FindProcess 会提升 IRQL 到 DISPATCH_LEVEL,避免进程链表被其他 CPU 核心修改(并发安全);
操作完成后调用 KeLowerIrql(Irql) 恢复原 IRQL,避免影响系统调度。
_ProcessEvent 等待:
每次循环等待 250ms,_ProcessEvent 由用户层注入完成后,内核回调 InjectProcessComplete 触发;
若 250ms 内注入完成,事件触发,等待提前返回;否则超时后继续循环。
超时保护:
最多等待 10 秒(代码注释),避免无限等待导致内核挂起;
若驱动正在卸载(__IsDriverUnloading),立即退出循环,避免操作已卸载的资源。
1. 并发安全(IRQL + 资源锁)
__ProcessListLock:内核资源锁(ERESOURCE),保护进程链表的并发访问;
操作进程链表时必须提升 IRQL 到 DISPATCH_LEVEL,避免被中断 / 调度器打断,导致链表损坏。
2. LPC 通信的可靠性
LPC 端口需提前创建(SbieSvc 启动时创建,驱动连接),且需处理端口断开 / 超时;
SendLpcMessage 需包含重试逻辑,避免单次发送失败导致注入请求丢失。
3. 内核句柄管理
ZwOpenProcess 创建的内核句柄必须调用 ZwClose 释放,否则会导致内核句柄泄漏,最终耗尽句柄资源。
4. 超时时间的合理性
250ms 等待间隔:兼顾响应速度和系统开销(过短会频繁循环,增加 CPU 占用;过长会导致注入响应慢);
10 秒总超时:覆盖绝大多数进程的注入耗时(复杂进程最多 5-8 秒)。
总结
这个内核态 InjectProcessRequest 函数的核心关键点:
核心功能:内核态调度用户层注入,查询进程信息 → 封装参数 → LPC 发送请求 → 等待注入完成 → 失败处理,是跨特权级注入的 “调度核心”;
内核特有设计:使用内核句柄、IRQL 提升 / 降低、资源锁保证并发安全,非分页池事件保证同步;
安全机制:最小权限打开进程、超时保护、失败终止非宿主进程,避免沙箱管控失效;
核心衔接:通过 LPC 衔接内核态和用户态,通过 _ProcessEvent 同步注入状态,通过进程创建时间唯一标识目标进程。
简单说,这个函数是 Sandboxie “内核指挥、用户执行” 注入模式的核心 ——
利用内核态的权限优势获取精准的进程信息,利用用户态的灵活性执行注入操作,既保证了注入的准确性,又避免了内核态直接执行复杂注入逻辑带来的稳定性风险。
NTSTATUS InjectProcessComplete(PROCESS* Process, ULONG64* ParameterData)
{
NTSTATUS Status;
//
// this API must be invoked by the Sandboxie service
//
if (Process || (PsGetCurrentProcessId() !=
__ProcessIdentity)) {
Status = STATUS_ACCESS_DENIED;
}
else {
//
// turn on the SbieLow loaded flag for the process
//
HANDLE ProcessIdentity =
(HANDLE)(ULONG_PTR)(ULONG)ParameterData[1];
KIRQL Irql;
PROCESS* Process = FindProcess(ProcessIdentity,
&Irql);
if (Process) {
ULONG IsOk =
(ULONG)ParameterData[3];
if (IsOk)
{
//Process_SetTerminated(Process, 3);
}
else
Process->IsLowLevelLoaded = TRUE;
//
// the service dynamically allocates a
per box SID to be used,
// if no SID is provided this feature
is either disabled or failed
// then we fall back to using the
default anonymous SID
//
__try {
PSID v1 =
(PSID)(ULONG_PTR)ParameterData[2];
if (v1) {
ProbeForRead(v1, SECURITY_MAX_SID_SIZE, sizeof(UCHAR));
ULONG
Length = RtlLengthSid(v1);
//proc->SandboxieLogonSid = Mem_Alloc(proc->pool, sid_length);
//memcpy(proc->SandboxieLogonSid, pSID, sid_length);
}
}
__except
(EXCEPTION_EXECUTE_HANDLER) {
Status =
GetExceptionCode();
}
}
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
if (Process) {
KeSetEvent(_ProcessEvent, 0, FALSE);
Status = STATUS_SUCCESS;
}
else
Status = STATUS_INVALID_CID;
}
return Status;
}
InjectProcessComplete 是 Sandboxie 内核层(Ring0) 处理用户层注入完成的核心回调函数 —— 它的作用是:接收用户态
SbieSvc 服务传递的注入结果,更新目标进程的沙箱状态(IsLowLevelLoaded)、处理沙箱 SID 权限配置,最后触发同步事件通知内核等待逻辑
“注入已完成”,是整个注入流程的 “内核收尾器”。
核心目标:
严格校验调用者权限(仅允许 SbieSvc 服务调用);
根据用户层注入结果,更新目标进程的 IsLowLevelLoaded 标记;
处理沙箱专用 SID(安全标识符),配置进程权限;
触发 _ProcessEvent 同步事件,通知 InjectProcessRequest 等待逻辑;
返回标准化的 NTSTATUS 状态码,告知用户层处理结果。
核心依赖:依赖 InitializeInjectProcess 创建的 _ProcessEvent、__ProcessListLock
进程链表锁,以及 FindProcess 内核进程查找逻辑
1. 内核态访问用户态内存的安全规范
必须用 ProbeForRead/ProbeForWrite 校验用户态内存的合法性;
必须用 __try/__except 包裹内存访问逻辑,捕获异常;
禁止直接解引用用户态指针,必须通过内核 API(如 MmCopyVirtualMemory)复制数据到内核态后再处理
2. IRQL 管理的核心规则
| 操作 | 要求的 IRQL 级别 | 违规后果 |
|---|---|---|
| 访问进程链表 | DISPATCH_LEVEL | 链表被并发修改,导致数据错乱 / 内核崩溃 |
| 调用 KeSetEvent | PASSIVE_LEVEL/DISPATCH_LEVEL | 无(KeSetEvent 支持 DISPATCH_LEVEL) |
| 释放 ERESOURCE 锁 | PASSIVE_LEVEL | 锁释放失败,导致死锁 |
| 访问分页内存 | PASSIVE_LEVEL | 分页内存被换出,触发 PAGE_FAULT_IN_NONPAGED_AREA 蓝屏 |
函数中先提升 IRQL 查找进程,处理完成后立即降低 IRQL,严格遵循 “最小特权 / 最小 IRQL” 原则。
PROCESS* FindProcess(HANDLE ProcessIdentity, KIRQL* Irql)
{
PROCESS* Process;
KIRQL v10;
BOOLEAN IsTerminated;
//
// if we're looking for the current process, then check execution
mode
// to make sure it isn't a system process or kernel mode caller
//
if (!ProcessIdentity) {
if (ExGetPreviousMode() == KernelMode)
ProcessIdentity = (HANDLE)0;
else
ProcessIdentity =
PsGetCurrentProcessId();
if (ProcessIdentity < (HANDLE)8) {
if (Irql) {
// in case caller
expects a locked process list on return
KeRaiseIrql(APC_LEVEL,
Irql);
ExAcquireResourceSharedLite(__ProcessListLock, TRUE);
}
return NULL;
}
IsTerminated = TRUE;
}
else
IsTerminated = FALSE;
//
// find a PROCESS block that matches the current ProcessId
//
KeRaiseIrql(APC_LEVEL, &v10);
ExAcquireResourceSharedLite(__ProcessListLock, TRUE);
#ifdef USE_PROCESS_MAP
Process = map_get(&Process_Map, ProcessId);
if (Process) {
#else
Process = GetListHead(&_ProcessList);
while (Process) {
if (Process->ProcessIdentity == ProcessIdentity) {
#endif
if (IsTerminated &&
Process->IsTerminated)
{
}
#ifndef USE_PROCESS_MAP
break;
}
Process = GetNextNode(Process);
#endif
}
if (Irql) {
*Irql = v10;
}
else {
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(v10);
}
return Process;
}
FindProcess 是 Sandboxie 内核驱动中核心的进程查找函数—— 它的作用是在内核态根据进程
ID(ProcessIdentity)从自定义的进程链表 / 哈希表中查找对应的 PROCESS 结构体(Sandboxie 封装的进程对象),同时通过
IRQL 提升和资源锁保证并发安全,是内核层所有进程相关操作(如注入状态更新、权限检查、沙箱管控)的基础。
核心目标:
处理 “查找当前进程” 的特殊场景(ProcessIdentity=NULL),过滤系统进程;
提升 IRQL 并加进程链表锁,保证查找过程中链表不被并发修改;
通过链表遍历 / 哈希表查找,匹配目标进程 ID 对应的 PROCESS 结构体;
根据调用方需求,决定是否保留锁 / IRQL 状态(由 Irql 参数控制);
返回查找到的 PROCESS 结构体(未释放锁 / IRQL)或 NULL(系统进程 / 未找到)。
核心依赖:__ProcessListLock(进程链表共享锁)、_ProcessList(自定义进程链表头)、PROCESS 结构体(Sandboxie
封装的进程信息)。
阶段 1:处理 “查找当前进程” 的特殊场景(ProcessIdentity=NULL)
ExGetPreviousMode () 判定调用来源:
KernelMode:函数由内核态直接调用(如驱动内部逻辑),此时不应该关联用户态进程,故 ProcessIdentity=0;
UserMode:函数由用户态触发(如 SbieSvc 调用 IOCTL),此时取当前执行线程所属的进程
ID(PsGetCurrentProcessId())。
系统进程过滤(ProcessIdentity < 8):
Windows 中进程 ID < 8 的是系统核心进程(如 PID=0 是空闲进程、PID=4 是系统进程),这些进程无需沙箱管控,直接返回 NULL;
若调用方传入了 Irql 参数(期望返回时保留锁 / IRQL),需手动提升 IRQL 并加锁后再返回,保证调用方的锁状态一致性。
阶段 2:提升 IRQL + 加进程链表锁(并发安全核心)
并发安全设计的核心意义:
IRQL 提升到 APC_LEVEL:
屏蔽异步过程调用(APC),避免查找过程中线程被调度 / 中断,导致链表遍历出错;
APC_LEVEL 是平衡 “并发安全” 和 “系统开销” 的选择(比 DISPATCH_LEVEL 低,对系统影响更小)。
共享锁(ExAcquireResourceSharedLite):
__ProcessListLock 是 ERESOURCE 类型的资源锁,Shared 模式表示 “读锁”;
读锁特性:多个线程可同时加读锁(支持并发查找),但写锁(如新增 / 删除进程)会阻塞,直到所有读锁释放;
第二个参数 TRUE 表示 “等待锁”:若锁被占用,当前线程会等待,直到获取锁
阶段 3:查找目标进程(两种模式:哈希表 / 链表遍历)
函数提供了两种查找模式(通过 USE_PROCESS_MAP 宏控制),适配不同性能需求:
模式 1:哈希表查找(USE_PROCESS_MAP=1,高性能)
#ifdef USE_PROCESS_MAP
Process = map_get(&Process_Map, ProcessId); // 哈希表根据进程ID直接查找
if (Process) {
#endif
核心优势:O (1) 时间复杂度,适合大量进程的场景,查找效率极高;
Process_Map:Sandboxie 维护的进程 ID→PROCESS 结构体的哈希表,进程创建时插入,退出时删除。
模式 2:链表遍历(USE_PROCESS_MAP=0,兼容)
#ifndef USE_PROCESS_MAP
Process = GetListHead(&_ProcessList); // 获取进程链表头
while (Process) {
if (Process->ProcessIdentity == ProcessIdentity) { // 匹配进程ID
break; // 找到目标进程,退出循环
}
Process = GetNextNode(Process); // 遍历下一个节点
}
#endif
核心逻辑:从 _ProcessList 链表头开始,逐个比对 ProcessIdentity(进程 ID),找到则退出循环;
劣势:O (n) 时间复杂度,进程数多时效率低,但实现简单,兼容性好。
阶段 4:管理锁 / IRQL 状态(根据调用方需求)
if (Irql) {
// 调用方传入了 Irql 参数:保留锁/IRQL,将原始 IRQL 赋值给 *Irql
*Irql = v10;
}
else {
// 调用方未传入 Irql 参数:释放锁 + 恢复原始 IRQL
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(v10);
}
核心设计:“延迟释放” 锁机制
调用方传入 Irql:表示调用方需要在查找后继续操作 PROCESS 结构体(如更新 IsLowLevelLoaded),因此保留锁和
IRQL,由调用方后续手动释放 / 恢复;
示例:InjectProcessComplete 调用 FindProcess 时传入 Irql,操作完成后调用 ExReleaseResourceLite +
KeLowerIrql;
调用方未传入 Irql:表示仅需查找,无需后续操作,函数内部直接释放锁 + 恢复 IRQL,避免锁泄漏。
1. IRQL 管理的核心规则
| 操作 | IRQL 要求 | 函数中的处理 |
|---|---|---|
| 访问 ERESOURCE 锁 | APC_LEVEL 及以上 | 先 KeRaiseIrql(APC_LEVEL) 再加锁 |
| 遍历链表 / 哈希表 | APC_LEVEL(避免 APC 中断) | 全程保持 APC_LEVEL |
| 释放 ERESOURCE 锁 | PASSIVE_LEVEL | 调用方需先降低 IRQL 再释放 |
| 系统调用 / 分页内存访问 | PASSIVE_LEVEL | 查找完成后必须降低 IRQL 才能执行 |
函数中仅提升 IRQL 到 APC_LEVEL(而非更高的 DISPATCH_LEVEL),平衡 “并发安全” 和 “系统开销”:APC_LEVEL
足够屏蔽用户态 APC 中断,且对系统调度影响更小。
2. 共享锁 vs 排他锁
ExAcquireResourceSharedLite:共享锁(读锁),允许多个线程同时查找进程,不阻塞读操作;
ExAcquireResourceExclusiveLite:排他锁(写锁),仅允许一个线程修改链表(如新增 / 删除进程),会阻塞所有读 / 写操作;
Sandboxie 查找进程用读锁,修改进程链表用写锁,最大化并发性能。
3. 系统进程过滤的必要性
Windows 中 PID < 8 的进程是内核态核心进程(如 System Idle Process、System
Process),这些进程无用户态上下文,无需沙箱管控;
若不对这些进程过滤,可能导致内核尝试操作非法的 PROCESS 结构体,引发蓝屏(如 PAGE_FAULT_IN_NONPAGED_AREA)。
内核查找进程和RIng3查找进程方式一样吗?为什么内核调用的ProcessId = 0?
内核态和用户态查找进程的方式完全不同
| 维度 | 用户态(Ring3)查找进程 | 内核态(Ring0)查找进程 |
|---|---|---|
| 核心目标 | 查找 “用户可见的进程”,获取进程句柄 / 基础信息(如 PID、镜像名) | 查找 “内核管控的进程对象”,获取进程的内核结构体(如 EPROCESS/Sandboxie 自定义 PROCESS),用于管控(沙箱、权限、注入) |
| 核心接口 | CreateToolhelp32Snapshot/EnumProcesses/ZwOpenProcess(需权限) | PsLookupProcessByProcessId/PsGetCurrentProcess/ 遍历内核进程链表(PsActiveProcessHead)/ 自定义链表(Sandboxie 的 _ProcessList) |
| 数据来源 | 从内核暴露的 “用户态进程信息快照” 中读取(内核过滤后的数据) | 直接访问内核态进程结构体(EPROCESS),无过滤、无权限限制 |
| 权限限制 | 1. 需要 PROCESS_QUERY_INFORMATION 权限才能打开进程; 2. 无法访问系统核心进程(如 PID=4); 3. 受 UAC / 权限令牌限制 | 1. 无权限限制,可访问所有进程(包括系统核心进程); 2. 直接操作内核内存,不受用户态权限约束 |
| 并发安全 | 无需考虑(内核已处理并发) | 必须手动提升 IRQL + 加锁(如 Sandboxie 的 __ProcessListLock),否则会因并发修改导致链表错乱 / 内核崩溃 |
| 进程标识 | 仅靠 PID(易复用,不唯一) | 结合 PID + 创建时间 + EPROCESS 指针(唯一标识) |
用户态是 “间接访问”:
用户态无法直接接触内核的进程数据,只能通过 Windows 提供的 API(如
CreateToolhelp32Snapshot)向内核发起请求,内核会过滤掉敏感进程(如系统进程)、校验权限后,返回 “允许用户看到的信息”—— 本质是
“内核给什么,用户才能看什么”。
内核态是 “直接掌控”:
内核是操作系统的核心,所有进程的创建 / 销毁 / 调度都由内核管理,内核可以直接访问 EPROCESS(Windows
内核原生进程结构体)、PsActiveProcessHead(内核进程链表头)等核心数据结构,无需经过任何权限校验,且能看到所有进程(包括用户态不可见的系统进程)。
举个直观例子(对应 Sandboxie 代码):
用户态查找进程:SbieSvc 调用 EnumProcesses 获取所有 PID,再调用 OpenProcess 打开进程 —— 这一步需要权限,且拿不到
PID=0/4 的进程句柄;
内核态查找进程:Sandboxie 驱动调用 FindProcess 遍历自定义的 _ProcessList 链表,直接匹配 PID,无需打开进程,甚至能访问
PID=0 的空闲进程(只是代码里过滤掉了)。
Windows 内核中,PID 不是从 1 开始的,而是有固定的 “系统保留 PID”:
| PID 值 | 对应进程 | 含义 | 用户态可见性 |
|---|---|---|---|
| 0 | 系统空闲进程(System Idle Process) | 每个 CPU 核心都有一个空闲线程,PID=0 是这些线程的 “伪进程”,无实际进程实体 | 不可见(用户态工具显示为 “System Idle Process”,但无法打开) |
| 4 | 系统进程(System Process) | 内核态进程,包含所有内核线程,无用户态地址空间 | 不可见(用户态无法打开) |
| 8+ | 普通进程(如 explorer.exe、SbieSvc.exe) | 用户态 / 服务进程,有完整的进程实体 | 可见(有权限即可打开) |
“延迟释放” 锁是什么意思???
“延迟释放” 锁就是把锁的释放时机从 “函数内部” 推迟到 “调用方使用完资源后”,核心目的是保证调用方操作共享资源时的原子性和并发安全。
一、先搞懂:什么是 “锁”(基础前提)
在 FindProcess 中,锁指的是 __ProcessListLock(ERESOURCE 类型的内核资源锁),它保护的是 Sandboxie 维护的
_ProcessList 进程链表 —— 这个链表是多线程 / 多 CPU 核心共享的,必须加锁才能避免
“遍历链表时链表被修改”(比如一边遍历、一边删除节点,导致内核崩溃)
场景 2:延迟释放(调用方需要操作资源)
如果调用方需要 “找到进程后,更新进程的 IsLowLevelLoaded 标记”,FindProcess 会把锁的释放权交给调用方
三、“延迟释放” 锁的核心含义(一句话总结)
FindProcess 函数在找到目标进程后,不立即释放保护进程链表的锁、不恢复 IRQL,而是把 “释放锁” 和 “恢复 IRQL”
的操作推迟到调用方使用完进程结构体(如更新 IsLowLevelLoaded)之后,由调用方手动完成。
四、为什么内核编程必须用 “延迟释放” 锁?(核心原因)
这是内核编程和用户态编程最大的区别之一,核心是为了保证 “查找 + 操作” 的原子性:
“延迟释放” 锁的核心要点:
定义:将锁的释放时机从函数内部推迟到调用方使用完共享资源后,由调用方手动释放;
目的:保证 “查找资源 + 操作资源” 的原子性,避免并发修改导致的内核崩溃;
内核必要性:内核态无用户态的 “进程隔离” 保护,并发错误直接导致系统蓝屏,必须严格保证原子性;
Sandboxie 实现:通过 Irql 参数控制 —— 传参则延迟释放,不传参则函数内释放。
APC_LEVEL 足够屏蔽用户态 APC 中断,且对系统调度影响更小。 系统调度影响怎么理解?
IRQL 越高,内核对系统调度的 “干预越强”,系统的并发能力、响应速度就越差;APC_LEVEL 是兼顾 “并发安全” 和 “调度效率” 的平衡点。
一、先搞懂:IRQL 是什么(基础前提)
IRQL 是 Windows 内核给 “中断 / 执行逻辑” 划分的优先级层级,核心规则:
高 IRQL 会屏蔽低 IRQL:比如 DISPATCH_LEVEL 会屏蔽 APC_LEVEL、PASSIVE_LEVEL 的中断;
IRQL 决定 “能否调度”:只有在 PASSIVE_LEVEL(最低),内核才允许线程被调度(切换);IRQL 高于 PASSIVE_LEVEL 时,当前
CPU 核心会 “独占执行”,不响应调度请求。
Windows 核心 IRQL 层级(从低到高):
PASSIVE_LEVEL(0) → APC_LEVEL(1) → DISPATCH_LEVEL(2) → DIRQL(3~31)
PASSIVE_LEVEL:用户态 / 内核态普通代码执行级别,允许调度、允许所有中断;
APC_LEVEL:屏蔽用户态 APC 中断,仍允许内核调度;
DISPATCH_LEVEL:屏蔽调度器中断(时钟中断),当前 CPU 核心 “锁死” 执行,不切换线程;
DIRQL:硬件中断级别(如网卡 / 磁盘中断),仅响应硬件请求,完全屏蔽软件逻辑。
“系统调度影响” 本质是:IRQL 越高,当前 CPU 核心越难 “切换线程”,系统的多任务并发能力越差。
步骤 1:Ring0 触发注入请求(injectRequest 核心逻辑)
内核驱动在进程创建回调(如 PsSetCreateProcessNotifyRoutine)中,检测到需要沙箱化的新进程,调用
InjectProcessRequest(injectRequest):
内核态查询目标进程的 WOW64 标记(ZwQueryInformationProcess + ProcessWow64Information);
封装注入参数(进程 ID、镜像名、创建时间、是否加入 Job 等)到 PROCESS_MESSAGE 结构体;
通过 LPC 端口(本地过程调用)将注入请求发送给 Ring3 的 SbieSvc 服务(核心交互点:Ring0 → Ring3);
内核进入等待循环:持有 __ProcessListLock,循环检查 Process->IsLowLevelLoaded,并通过
KeWaitForSingleObject 等待 _ProcessEvent 事件(最多 10 秒超时)。
步骤 2:Ring3 接收请求并执行注入
Ring3 的 SbieSvc 服务通过 LPC 端口接收到内核的注入请求后,执行实际的注入操作:
解析内核传递的参数(进程 ID、WOW64 标记等);
打开目标进程(OpenProcess,申请 PROCESS_ALL_ACCESS 权限);
执行完整注入流程:
AllocateRemoteMemory:在目标进程分配可执行内存;
CopyShellCode:写入沙箱核心逻辑(SbieLow);
CopySyscalls:写入 Syscall 拦截表;
CopyData:写入 DATA_INFO 配置;
WriteJump:劫持 LdrInitializeThunk 函数,跳转到注入的 ShellCode;
记录注入结果(成功 / 失败):成功则标记 “ShellCode 已加载”,失败则记录错误码。
步骤 3:Ring3 向 Ring0 回复注入结果(injectReply 核心逻辑)
SbieSvc 完成注入后,通过 IOCTL/LPC 向内核驱动回复结果(injectReply 是逻辑层,无单独函数名,本质是调用内核暴露的接口):
封装回复参数:目标进程 ID、注入结果(IsOk:0 = 成功,1 = 失败)、沙箱 SID 地址(若有);
调用 DeviceIoControl 向内核驱动发送 IOCTL 请求(核心交互点:Ring3 → Ring0),触发内核的
InjectProcessComplete 函数;
注:也可通过 LPC 端口直接回调内核函数,本质都是 Ring3 主动通知 Ring0 结果。
步骤 4:Ring0 接收回复并完成收尾(InjectProcessComplete)
内核驱动接收到 Ring3 的回复后,调用 InjectProcessComplete 处理:
权限校验:仅允许 SbieSvc 进程(__ProcessIdentity)调用,否则返回 STATUS_ACCESS_DENIED;
查找目标进程:通过 FindProcess 找到内核态 PROCESS 结构体(持有 __ProcessListLock,提升 IRQL 到
APC_LEVEL);
更新进程状态:
注入成功(IsOk=0):设置 Process->IsLowLevelLoaded = TRUE;
注入失败(IsOk=1):标记进程为终止(Process_SetTerminated);
处理沙箱 SID:校验并复制 Ring3 传递的 SID 到内核态 PROCESS 结构体;
触发同步事件:调用 KeSetEvent(_ProcessEvent, 0, FALSE),唤醒步骤 1 中内核的等待循环;
释放资源:释放 __ProcessListLock,降低 IRQL 到原级别,返回 STATUS_SUCCESS 给 Ring3。
步骤 5:Ring0 结束等待并确认结果
InjectProcessRequest 的等待循环检测到 Process->IsLowLevelLoaded = TRUE(或 _ProcessEvent
触发):
退出等待循环;
校验结果:成功则返回 TRUE,失败(超时 / 进程终止)则返回 FALSE(非 HostInject 场景会终止目标进程);
内核完成整个注入流程,目标进程后续执行 LdrInitializeThunk 时会跳转到沙箱 ShellCode,进入沙箱管控。
注意:
injectReply 不是单独函数:你说的 injectReply 是 Ring3 向 Ring0 反馈结果的 “逻辑行为”,而非一个具体函数
——Sandboxie 中是通过 DeviceIoControl 调用内核 IOCTL 接口,或 LPC 端口回复实现的;
双向交互的核心是 LPC/IOCTL:Ring0 ↔ Ring3 的通信依赖 Windows 原生的跨特权级通信机制,而非直接函数调用(Ring3 无法直接调用
Ring0 函数,反之亦然);
同步机制是关键:内核通过 _ProcessEvent 事件同步,保证 Ring0 不会无限等待,且能及时感知 Ring3 的注入结果;
权限校验贯穿全程:Ring0 校验调用者是否为 SbieSvc,Ring3 校验是否有目标进程的操作权限,避免非法注入 / 篡改。
总结(核心调用顺序)
Ring0: InjectProcessRequest(injectRequest) → Ring3: 执行注入 → Ring3:
injectReply(IOCTL/LPC 回复结果) → Ring0: InjectProcessComplete(接收回复+收尾) → Ring0:
结束等待,完成流程。
简单来说:Ring0 是 “指挥者”,发起请求并等待结果;Ring3 是 “执行者”,完成注入并回复结果;两者通过 LPC/IOCTL
通信,通过内核事件同步,最终完成跨特权级的进程注入。
24-2调试看到17;12
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Entry.asm
ShellCode分析
EXTERN EntryPointC : PROC
_Start:
;int 3
sub rsp, 28h
mov qword ptr [rsp+4*8], rcx
mov qword ptr [rsp+5*8], rdx
mov qword ptr [rsp+6*8], r8
mov qword ptr [rsp+7*8], r9
call $+5
_001: pop rcx
mov rbx,rcx
;rbx = rcx = _001:的代码地址
add rcx, offset DataInfo - _001
;rcx = DataInfo数据的绝对地址
mov rdx,rbx
add rdx, offset _DetourCode - _001
;rdx = _DetourCode数据的绝对地址
mov r8,rbx
add r8, offset _SystemService - _001
;r8 = _SystemService函数的绝对地址
call EntrypointC
mov rcx, qword ptr [rsp+4*8]
mov rdx, qword ptr [rsp+5*8]
mov r8, qword ptr [rsp+6*8]
mov r9, qword ptr [rsp+7*8]
add rsp, 28h
jmp rax
;rax = jump to LdrInitializeThunk trampoline
else ;32-bit
EXTERN EntryPointC@12 : PROC
_Start:
call $+5
_001: pop eax
mov edx,eax
add eax, offset _SystemService - _001
push eax
mov eax,edx
add eax, offset
_RtlFindActivationContextSectionString - _001
push eax
mov eax, edx
add eax, offset DataInfo - _001
push eax
call EntryPointC@12
jmp eax
endif
Sandboxie 注入 ShellCode 的完整入口汇编(64 位 + 32 位双版本),也是沙箱 “无感知劫持
LdrInitializeThunk” 的核心实现 ——64 位版本侧重遵循 Windows 64 位调用约定、栈帧保护和地址重定位,32 位版本则适配 32
位栈传参、stdcall 调用约定,两者核心目标一致:先计算注入数据的绝对地址 → 调用沙箱核心逻辑 → 无缝跳回原始
LdrInitializeThunk。
运行场景:目标进程被 WriteJump 劫持后,LdrInitializeThunk 执行的第一行代码就是这个 _Start;核心使命:
解决注入代码 / 数据的地址重定位(注入到随机内存地址后,相对偏移转绝对地址);
给沙箱核心函数 EntrypointC 传递关键参数(配置、拦截代码、Hook 函数);
执行完沙箱初始化后,无缝跳回原始 LdrInitializeThunk,保证进程正常启动;
EXTERN EntryPointC : PROC ; 声明外部C函数(64位无装饰名)
_Start:
; 1. 栈帧准备:预留0x28字节(32字节影子空间 + 8字节额外,满足64位16字节栈对齐)
sub rsp, 28h
; 2. 保存LdrInitializeThunk的原始参数(避免调用EntrypointC时被覆盖)
mov qword ptr [rsp+4*8], rcx ; 保存第1个参数(rcx)
mov qword ptr [rsp+5*8], rdx ; 保存第2个参数(rdx)
mov qword ptr [rsp+6*8], r8 ; 保存第3个参数(r8)
mov qword ptr [rsp+7*8], r9 ; 保存第4个参数(r9)
; 3. 经典技巧:获取当前代码的绝对地址(解决ASLR地址随机化)
call $+5 ;
$=当前指令地址,$+5是_001的地址;call会把_001压栈作为返回地址
_001:
pop rcx ; 弹出返回地址
→ rcx = _001指令的绝对地址(基准地址)
; 4. 计算注入数据的绝对地址(基准地址 + 编译时相对偏移)
mov rbx,rcx ; rbx暂存基准地址(避免rcx被覆盖)
add rcx, offset DataInfo - _001 ; rcx = DataInfo绝对地址
mov rdx,rbx
add rdx, offset _DetourCode - _001
; rdx = _DetourCode绝对地址(Syscall拦截代码)
mov r8,rbx
add r8, offset _SystemService - _001
; r8 = _SystemService绝对地址(自定义Syscall处理)
; 5. 调用沙箱核心逻辑(64位调用约定:rcx/rdx/r8传参)
call EntrypointC
; 6. 恢复LdrInitializeThunk的原始参数到寄存器
mov rcx, qword ptr [rsp+4*8]
mov rdx, qword ptr [rsp+5*8]
mov r8, qword ptr [rsp+6*8]
mov r9, qword ptr [rsp+7*8]
; 7. 恢复栈空间(和开头sub rsp,28h配对)
add rsp, 28h
; 8. 无缝跳回原始LdrInitializeThunk(EntrypointC返回值rax是原始函数地址)
jmp rax
ASLR地址随机化?
ASLR(Address Space Layout Randomization,地址空间布局随机化) 是 Windows
系统的核心安全机制,也是你看到的 Sandboxie ShellCode 中 “call $+5 + pop 获取当前地址” 的核心应对目标——
简单来说,ASLR 会让程序每次启动时,代码、数据、DLL 的加载地址都随机变化,目的是防止攻击者通过固定地址进行注入 / 利用漏洞;而 Sandboxie 的
ShellCode 必须绕过 ASLR,才能正确找到注入的 DataInfo/_DetourCode 等数据的实际地址。
1. 核心定义
ASLR 是 Windows(Vista 及以上)、Linux、macOS 等系统都有的内存安全机制,核心行为:
程序每次启动时,系统会随机修改以下内容的加载地址:
主程序(EXE)的基地址;
所有加载的 DLL(如 ntdll.dll、kernel32.dll)的加载地址;
堆、栈、PEB/TEB 等内存区域的起始地址;
随机化的范围是系统预设的(如 64 位系统中,DLL 地址会在 0x7FFF0000 附近随机偏移),每次启动都不同,且同一程序的不同实例地址也不同。
ASLR 的核心目的
防止 “地址固定型攻击”:比如攻击者知道 kernel32.dll 中 VirtualAlloc 函数的固定地址是
0x77001234,就可以构造恶意代码跳转到这个地址;而 ASLR 让这个地址每次都变,攻击者无法提前知道,从而大幅降低漏洞利用成功率。
ASLR 对 Sandboxie ShellCode 的影响(为什么必须处理?)
Sandboxie 把 ShellCode、DataInfo、_DetourCode 等数据注入到目标进程的内存中,面临两个核心问题:
注入地址随机:每次注入的内存地址(如 VirtualAllocEx 分配的地址)是随机的(受 ASLR 影响);
编译时地址无效:ShellCode 编译时,DataInfo 相对于 _001 的偏移是固定的,但绝对地址(编译时假设的
0x12345678)在实际注入后完全无效。
Sandboxie 如何应对 ASLR?(核心技巧)
你看到的 call $+5 + pop rcx 是汇编层面绕过 ASLR
的经典技巧,核心思路是:放弃固定地址,动态获取当前代码的实际地址,再通过相对偏移计算数据的绝对地址。
25调试-52;03没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26
typedef struct _SYSCALL_INFO_ {
ULONG SyscallNum;
ULONG SyscallOffset;
} SYSCALL_INFO;
_SYSCALL_INFO_ 结构体是 Sandboxie 实现Syscall 拦截的核心数据结构 —— 它的作用是存储 Windows
系统调用(Syscall)的关键信息(系统调用号 + 指令偏移),让沙箱能精准定位并拦截目标 Syscall,实现对
NtCreateProcess/NtOpenFile 等内核 API 的管控
Windows 用户态程序调用内核 API(如 CreateFile)时,最终会通过 Syscall 指令 进入内核态:
用户态 kernel32.dll → ntdll.dll(如 NtCreateFile);
ntdll.dll 中的 NtCreateFile 最终执行 Syscall 指令(64 位)/Int 2E 指令(32 位);
Syscall 指令通过系统调用号(如 rax=0x55)告诉内核要执行哪个内核函数;
内核根据调用号找到对应的处理函数,执行后返回用户态。
Sandboxie 要拦截这些 Syscall,首先需要知道 “每个 Syscall 的编号 + 指令在 ntdll.dll 中的位置”—— 这正是
_SYSCALL_INFO_ 结构体的核心使命。
typedef struct _SYSCALL_INFO_ {
// 字段1:系统调用号(核心标识)
ULONG SyscallNum;
// 字段2:Syscall 指令在 ntdll.dll 中的偏移
ULONG SyscallOffset;
} SYSCALL_INFO;
typedef struct _SYSCALLS_DATA_ { // ntdll.dll
ULONG syscall_data_len;
ULONG ExtraDataInfoOffset;
UCHAR CodeData[NATIVE_FUNCTION_SIZE *
NATIVE_FUNCTION_COUNT];
SYSCALL_INFO SyscallsInfo[];
} SYSCALLS_DATA;
_SYSCALLS_DATA_ 结构体是 Sandboxie 管理所有 Syscall 拦截数据的核心容器—— 它把 ntdll.dll 中提取的
Syscall 指令代码、Syscall 信息表、额外配置偏移等关键数据整合在一起,是沙箱实现 “批量拦截、统一管理、动态适配”Syscall
的核心数据结构,也是你之前看到的 SYSCALL_INFO 结构体的 “上层容器”。
typedef struct _SYSCALLS_DATA_ { // 专门存储 ntdll.dll 的 Syscall 相关数据
// 字段1:整个结构体的总长度(含 CodeData + SyscallsInfo 动态数组)
ULONG syscall_data_len;
// 字段2:额外配置信息的偏移(相对于结构体起始地址)
ULONG ExtraDataInfoOffset;
// 字段3:存储从 ntdll.dll 中拷贝的 Syscall 指令代码(原始指令备份)
UCHAR CodeData[NATIVE_FUNCTION_SIZE * NATIVE_FUNCTION_COUNT];
// 字段4:动态数组(柔性数组),存储所有 Syscall 的编号+偏移信息
SYSCALL_INFO SyscallsInfo[];
} SYSCALLS_DATA;
这个结构体的实际使用流程(结合 Sandboxie 注入)
步骤 1:内核态初始化结构体(Ring0)
Sandboxie 驱动在 InjectProcessRequest 阶段,会:
读取目标系统 ntdll.dll 的导出表,解析所有 NtXXX 函数;
提取每个函数的 SyscallNum 和 SyscallOffset,存入 SyscallsInfo 柔性数组;
拷贝 ntdll.dll 中的原始 Syscall 指令到 CodeData;
计算 syscall_data_len(固定部分长度 + 柔性数组长度);
设置 ExtraDataInfoOffset 指向沙箱配置信息;
将整个 _SYSCALLS_DATA_ 结构体通过 WriteProcessMemory 注入到目标进程的内存中。
步骤 2:用户态使用结构体(Ring3)
目标进程的 EntrypointC 函数执行时,会:
根据 syscall_data_len 确认结构体的内存范围;
遍历 SyscallsInfo 数组,找到需要拦截的 Syscall 指令位置;
从 CodeData 中读取原始指令,备份后替换为 jmp 到 _SystemService;
若需要访问沙箱配置,通过 ExtraDataInfoOffset 找到对应的配置信息;
当需要恢复原始 Syscall 时,从 CodeData 中拷贝回原始指令。
步骤 3:沙箱退出时清理
沙箱管控结束后,会:
遍历 SyscallsInfo 数组,将 CodeData 中的原始指令写回 ntdll.dll;
根据 syscall_data_len 释放 _SYSCALLS_DATA_ 结构体占用的内存。
typedef struct _DATA_INFO_ {
ULONG64 Ntdll;
ULONG64 SyscallsData;
//目标进程当前结构的首地址
ULONG64 DeviceHandle;
ULONG IoControlCode;
ULONG InvokeSyscalls;
DATA_FLAGS Flags;
UCHAR IsInitializeDone;
UCHAR reserved[3];
__declspec(align(16))
UCHAR LdrInitializeThunk_tramp[48];
__declspec(align(16))
UCHAR
NtDelayExecution_code[NATIVE_FUNCTION_SIZE];
__declspec(align(16))
UCHAR
NtDeviceIoControlFile_code[NATIVE_FUNCTION_SIZE]; // offset
128
__declspec(align(16))
UCHAR
NtFlushInstructionCache_code[NATIVE_FUNCTION_SIZE]; // offset 160
__declspec(align(16))
UCHAR
NtProtectVirtualMemory_code[NATIVE_FUNCTION_SIZE];
ULONG64 NtDeviceIoControlFile;
// offset 224 //函数指针
ULONG64 ArmNtDeviceIoControlFile;
// for ARM64 // offset 232
ULONG64 NtProtectVirtualMemory;
// offset 240
ULONG64 NtRaiseHardError;
// offset 248
ULONG64 SystemService;
//存储汇编函数地址
#ifdef _WIN64
//SBIELOW_J_TABLE* x64BitJumpTable;
ULONG64 ntdll_wow64_base;
ULONG64 ptr_32bit_detour;
#endif
#ifdef _M_ARM64
__declspec(align(16))
UCHAR RtlImageOptionsEx_tramp[48];
#endif
} DATA_INFO;
运行层级:Ring3(目标进程的用户态),由 Sandboxie 驱动通过 WriteProcessMemory 注入到目标进程的内存中;
1. 基础核心配置(沙箱运行的 “基础参数”)
| 字段 | 类型 | 核心含义 & 用途 |
|---|---|---|
| Ntdll | ULONG64 | 目标进程中 ntdll.dll 的基地址(适配 ASLR,动态获取) 👉 用途:计算 Syscall 指令的绝对地址(基地址 + 偏移),是所有 Syscall 拦截的基础 |
| SyscallsData | ULONG64 | 指向 _SYSCALLS_DATA_ 结构体的首地址(目标进程内的内存地址) 👉 用途:让 EntrypointC 能找到 Syscall 拦截的 “导航表”(SYSCALL_INFO 数组) |
| DeviceHandle | ULONG64 | Sandboxie 驱动设备的句柄(如 \\Device\\SbieDrv) 👉 用途:目标进程通过这个句柄调用 DeviceIoControl 与内核驱动通信(如上报 Syscall 调用、获取沙箱规则) |
| IoControlCode | ULONG | 与内核通信的 IOCTL 控制码(如 IOCTL_SBIE_HOOK_SYSCALL) 👉 用途:约定 Ring3 ↔ Ring0 通信的 “指令类型”,驱动根据此码识别请求(如拦截 Syscall、查询沙箱配置) |
| InvokeSyscalls | ULONG | 标记是否允许执行原始 Syscall(0 = 拦截,1 = 放行) 👉 用途:沙箱的 “快速开关”,用于白名单 API(如允许 NtDelayExecution 执行原始逻辑) |
2. 状态与标记(沙箱运行的 “开关”)
| 字段 | 类型 | 核心含义 & 用途 |
|---|---|---|
| Flags | DATA_FLAGS | 沙箱运行标志位(自定义枚举,如是否开启 WOW64 拦截、ARM64 兼容、调试模式等) 👉 用途:通过位运算快速判断沙箱配置(如 Flags & FLAG_WOW64 检测是否为 32 位进程运行在 64 位系统) |
| IsInitializeDone | UCHAR | 沙箱初始化完成标记(0 = 未完成,1 = 已完成) 👉 用途:防止 EntrypointC 重复执行初始化逻辑(进程启动时可能多次触发 LdrInitializeThunk) |
| reserved[3] | UCHAR[3] | 3 字节预留空间 👉 用途:字节对齐 —— 让后续字段(如 LdrInitializeThunk_tramp)满足 16 字节对齐要求(避免 CPU 访问未对齐内存导致性能下降 / 崩溃) |
3. 指令备份与跳板代码(核心拦截 / 恢复机制)
所有这类字段都加了 __declspec(align(16)),核心原因:x64/ARM64 CPU 对 16
字节对齐的指令执行效率最高,未对齐会触发硬件异常(尤其是跳转 / 调用指令)。
| 字段 | 类型 | 核心含义 & 用途 |
|---|---|---|
| LdrInitializeThunk_tramp[48] | UCHAR[48] | LdrInitializeThunk 的跳板代码(trampoline) 👉 用途: 1. 备份 LdrInitializeThunk 的前 48 字节原始指令(被劫持时覆盖); 2. 作为 “中转跳板”:沙箱执行完初始化后,跳转到这个跳板,再执行原始 LdrInitializeThunk,保证流程无缝衔接 |
| NtDelayExecution_code[NATIVE_FUNCTION_SIZE] | UCHAR[] | 备份 NtDelayExecution 的原始 Syscall 指令 👉 用途:拦截后需要恢复时,将此数据写回 ntdll.dll |
| NtDeviceIoControlFile_code[] | UCHAR[] | 备份 NtDeviceIoControlFile 的原始指令 👉 用途:这是沙箱与内核通信的核心函数,需重点管控,备份用于恢复原生逻辑 |
| NtFlushInstructionCache_code[] | UCHAR[] | 备份 NtFlushInstructionCache 指令 👉 用途:修改内存指令后(如劫持 Syscall),需调用此函数刷新 CPU 指令缓存,备份用于保证该函数不被篡改 |
| NtProtectVirtualMemory_code[] | UCHAR[] | 备份 NtProtectVirtualMemory 指令 👉 用途:修改 ntdll.dll 内存保护属性(如改为可写)的核心函数,必须保证其原生逻辑可用 |
这个结构体的使用流程(核心链路)
内核态注入:Sandboxie 驱动在 InjectProcessRequest 阶段,初始化 _DATA_INFO_ 的所有字段(填充
ntdll.dll 地址、设备句柄、指令备份等),通过 WriteProcessMemory 注入到目标进程;
用户态初始化:目标进程的 EntrypointC 函数接收 _DATA_INFO_ 的地址作为参数,读取其中的 SyscallsData 找到
_SYSCALLS_DATA_,开始拦截 Syscall;
运行时管控:被劫持的 Syscall 跳转到 SystemService 函数,该函数读取 _DATA_INFO_ 的
Flags/InvokeSyscalls 等字段,判断是否拦截 / 放行;
通信与恢复:需要与内核通信时,调用 NtDeviceIoControlFile 指针,传入 DeviceHandle 和
IoControlCode;需要恢复原生指令时,从 xxx_code 字段拷贝数据写回 ntdll.dll;
流程衔接:沙箱初始化完成后,跳转到 LdrInitializeThunk_tramp,执行原始 LdrInitializeThunk 逻辑,保证进程正常启动
typedef struct _EXTRA_DATA_INFO_ {
ULONG LdrLoadDllOffset;
ULONG LdrGetProcAddrOffset;
ULONG NtProtectVirtualMemoryOffset;
ULONG NtRaiseHardErrorOffset;
ULONG NtDeviceIoControlFileOffset;
ULONG RtlFindActCtxOffset;
#ifdef _M_ARM64
ULONG RtlImageOptionsExOffset;
#endif
ULONG KernelDllOffset;
ULONG KernelDllLength;
ULONG NativeDllOffset;
ULONG NativeDllLength;
#ifdef _M_ARM64
ULONG Arm64DllOffset;
ULONG Arm64DllLength;
#endif
ULONG Wow64DllOffset;
ULONG Wow64DllLength;
ULONG InjectDataOffset;
ULONG_PTR Lock;
}EXTRA_DATA_INFO;
Sandboxie 沙箱的扩展偏移配置表—— 它的核心作用是存储各类核心函数 / 模块在内存中的偏移量(而非绝对地址),专门解决
ASLR 导致的 “地址随机化” 问题
与 _DATA_INFO_ 的联动流程(核心使用链路)
内核态初始化:
Sandboxie 驱动解析目标系统的 ntdll.dll/kernel32.dll 导出表,计算所有核心函数的偏移,填充
_EXTRA_DATA_INFO_;
计算 _EXTRA_DATA_INFO_ 相对于 _DATA_INFO_ 的偏移,写入
_DATA_INFO_.ExtraDataInfoOffset;
将 _DATA_INFO_ + _EXTRA_DATA_INFO_ 一起注入目标进程。
用户态使用:
EntrypointC 从 _DATA_INFO_ 读取 ExtraDataInfoOffset,找到 _EXTRA_DATA_INFO_;
从 _DATA_INFO_.Ntdll 获取 ntdll.dll 基地址,加上 _EXTRA_DATA_INFO_
中的函数偏移,计算出目标函数的绝对地址;
根据 KernelDllOffset/KernelDllLength 等字段,定位并拷贝对应模块数据,完成拦截;
多线程操作时,通过 Lock 字段加锁,保证数据安全。
ULONG_PTR __stdcall EntryPointC(DATA_INFO* DataInfo, void* DetourCode,
void* SystemService)
#endif
{
if (!DataInfo->IsInitializeDone)
{
EXTRA_DATA_INFO* ExtraDataInfo = NULL;
SYSCALLS_DATA* SyscallsData = NULL;
SyscallsData =
(SYSCALLS_DATA*)DataInfo->SyscallsData;
/*
0:000> dq rdx
00000000`00040000 00000ce0`00000ec0
00000031`b8d18b4c
00000000`00040010 0000441f`0fc3050f
00000032`b8d18b4c
00000000`00040020 0000441f`0fc3050f
00000004`b8d18b4c
00000000`00040030 0000441f`0fc3050f
00000005`b8d18b4c
00000000`00040040 0000441f`0fc3050f
000000c2`b8d18b4c
00000000`00040050 0000441f`0fc3050f
000000c3`b8d18b4c
00000000`00040060 0000441f`0fc3050f
0000004d`b8d18b4c
00000000`00040070 0000441f`0fc3050f
0000004e`b8d18b4c
*/
ExtraDataInfo =
(EXTRA_DATA_INFO*)(DataInfo->SyscallsData +
SyscallsData->ExtraDataInfoOffset);
/*
0:000> dd ebp
00000000`00000ce0
0:000> dq rbp
00000000`00040ce0 00000050`00000040
00000088`0000006c
00000000`00040cf0 000000b8`0000009c
00000018`000000e4
00000000`00040d00 00000068`000000fe
00000000`00000000
00000000`00040d10 00000000`00000168
00000000`00000000
00000000`00040d20 4464616f`4c72644c
00000000`00006c6c
00000000`00040d30 72507465`4772644c
41657275`6465636f
00000000`00040d40 00007373`65726464
7250744e`00000000
00000000`00040d50 72695674`6365746f
6f6d654d`6c617574
*/
volatile ULONG_PTR* Lock = &ExtraDataInfo->Lock;
#ifdef _WIN64
ULONG_PTR OldLock =
_InterlockedCompareExchange64(Lock, -1, 0);
#else ! _WIN64
ULONG_PTR OldLock =
_InterlockedCompareExchange(Lock, -1, 0);
#endif _WIN64
if (OldLock == 0)
{
/*
0:000> dq rcx
00000000`00030409
00000000`77ba0000 00000000`00040000
00000000`00030419
00000000`00000004 12340036`00222007
00000000`00030429
00000000`00000000 00000000`00000000
00000000`00030439
02e920ec`8348f3ff 01b20000`0077b9bf
00000000`00030449
00025388`e8cb8b48 cc000a14`71e8c88b
00000000`00030459
90909090`90909090 90909090`90909090
00000000`00030469
00000031`b8d18b4c 0000441f`0fc3050f
00000000`00030479
00000032`b8d18b4c 0000441f`0fc3050f
0:000> dq rdx
00000000`0003035c
00000000`0000b848 e0988b4c`53570000
00000000`0003036c
490fc380`41000000 c38349dc`8b4cdb8b
00000000`0003037c
a8ec8148`fb8b4910 49084b89`49000000
00000000`0003038c
4d184389`4d105389 68244c8d`48204b89
00000000`0003039c
4dd08b4c`0851894c 41105989`4c085b8d
00000000`000303ac
8d480189`481c428b 5024548d`48602444
00000000`000303bc
20245489`48028948 24448948`18428b41
00000000`000303cc
40b83024`4c894828 38244489`48000000
0:000> u 00000000`0003035c l 30
00000000`0003035c
48b80000000000000000 mov rax,0
00000000`00030366 57
push rdi
00000000`00030367 53
push rbx
00000000`00030368
4c8b98e0000000 mov r11,qword ptr [rax+0E0h]
00000000`0003036f 4180c30f
add r11b,0Fh
00000000`00030373 498bdb
mov rbx,r11
00000000`00030376 4c8bdc
mov r11,rsp
00000000`00030379 4983c310
add r11,10h
00000000`0003037d 498bfb
mov rdi,r11
00000000`00030380
4881eca8000000 sub rsp,0A8h
00000000`00030387 49894b08
mov qword ptr [r11+8],rcx
00000000`0003038b 49895310
mov qword ptr [r11+10h],rdx
00000000`0003038f 4d894318
mov qword ptr [r11+18h],r8
00000000`00030393 4d894b20
mov qword ptr [r11+20h],r9
00000000`00030397 488d4c2468
lea rcx,[rsp+68h]
00000000`0003039c 4c895108
mov qword ptr [rcx+8],r10
00000000`000303a0 4c8bd0
mov r10,rax
00000000`000303a3 4d8d5b08
lea r11,[r11+8]
00000000`000303a7 4c895910
mov qword ptr [rcx+10h],r11
00000000`000303ab 418b421c
mov eax,dword ptr [r10+1Ch]
00000000`000303af 488901
mov qword ptr [rcx],rax
00000000`000303b2 488d442460
lea rax,[rsp+60h]
00000000`000303b7 488d542450
lea rdx,[rsp+50h]
00000000`000303bc 488902
mov qword ptr [rdx],rax
00000000`000303bf 4889542420
mov qword ptr [rsp+20h],rdx
00000000`000303c4 418b4218
mov eax,dword ptr [r10+18h]
00000000`000303c8 4889442428
mov qword ptr [rsp+28h],rax
00000000`000303cd 48894c2430
mov qword ptr [rsp+30h],rcx
00000000`000303d2 b840000000
mov eax,40h
00000000`000303d7 4889442438
mov qword ptr [rsp+38h],rax
00000000`000303dc 498b4a10
mov rcx,qword ptr [r10+10h]
00000000`000303e0 4833d2
xor rdx,rdx
00000000`000303e3 4c8bc2
mov r8,rdx
00000000`000303e6 4c8bca
mov r9,rdx
00000000`000303e9 4889542440
mov qword ptr [rsp+40h],rdx
00000000`000303ee 4889542448
mov qword ptr [rsp+48h],rdx
00000000`000303f3
4d8d9280000000 lea r10,[r10+80h]
00000000`000303fa 41ffd2
call r10
00000000`000303fd
4881c4a8000000 add rsp,0A8h
00000000`00030404 5b
pop rbx
00000000`00030405 5f
pop rdi
00000000`00030406 c3
ret
*/
SyscallsPrepare(DataInfo,
SystemService);
/*
if (!DataInfo->Flags.bHostInject
&& !DataInfo->Flags.bNoSysHooks)
InitSyscalls(DataInfo,
SystemService);
*/
PrepareInjection(DataInfo,
DetourCode);
#ifdef _WIN64
if (DataInfo->Flags.is_wow64)
{
#ifdef _M_ARM64
if
(!data->flags.is_chpe32)
DisableCHPE(data);
#endif
if
(!DataInfo->Flags.bNoConsole)
{
//InitConsoleWOW64(data);
}
}
#endif
// Set Init_Done
UCHAR IsInitializeDone = 1;
WriteMemorySafe(DataInfo,
&DataInfo->IsInitializeDone, sizeof(UCHAR), &IsInitializeDone);
#ifdef _WIN64
_InterlockedExchange64(Lock, 1);
#else ! _WIN64
_InterlockedExchange(Lock, 1);
#endif _WIN64
}
}
/*
00000000`00030356 4883c428 add
rsp,28h
00000000`0003035a ffe0
jmp rax {00000000`00030439}
*/
return (ULONG_PTR)&DataInfo->LdrInitializeThunk_tramp;
/*
00000000`00030439 fff3
push rbx
00000000`0003043b 4883ec20 sub
rsp,20h
00000000`0003043f e902bfb977 jmp
ntdll!LdrInitializeThunk+0x6 (00000000`77bcc346)
00000000`00030444 0000
add byte ptr [rax],al
00000000`00030446 00b201488bcb add
byte ptr [rdx-3474B7FFh],dh
00000000`0003044c e888530200 call
00000000`000557d9
00000000`00030451 8bc8
mov ecx,eax
00000000`00030453 e871140a00 call
00000000`000d18c9
*/
}
Sandboxie 沙箱核心初始化函数 EntryPointC 的完整逻辑 —— 它是目标进程被注入后,ShellCode 调用的第一个 C
层核心函数,核心作用是:在保证线程安全的前提下,完成沙箱初始化(Syscall 拦截准备、注入数据适配、WOW64/ARM64 兼容处理),最终返回
LdrInitializeThunk 跳板地址,让进程无缝回到原生初始化流程。
核心目标:
防止重复初始化(IsInitializeDone 标记);
线程安全初始化(自旋锁 ExtraDataInfo->Lock);
完成 Syscall 拦截、指令备份、架构兼容等核心初始化;
无缝衔接原生 LdrInitializeThunk 流程
// 拿到 ExtraDataInfo 中的锁指针
volatile ULONG_PTR* Lock = &ExtraDataInfo->Lock;
// 64位/32位分别用原子操作加锁(_InterlockedCompareExchange 是原子指令,避免多线程同时初始化)
#ifdef _WIN64
ULONG_PTR OldLock = _InterlockedCompareExchange64(Lock, -1, 0); // 64位原子比较交换
#else
ULONG_PTR OldLock = _InterlockedCompareExchange(Lock, -1, 0); // 32位
#endif
if (OldLock == 0) // 只有锁初始值为0时,才执行初始化(保证只有一个线程执行)
{
// 核心初始化逻辑
}
原子操作逻辑:
_InterlockedCompareExchange:比较 Lock 当前值是否为 0,如果是,将其设为 -1,返回旧值;
OldLock == 0:表示当前线程成功获取锁,是第一个执行初始化的线程;
其他线程调用时,Lock 已被设为 -1,OldLock != 0,直接跳过初始化;
为什么用自旋锁:初始化是短时操作,自旋锁比临界区 / 互斥量更高效,避免内核态切换开销。
1. 线程安全(自旋锁 + 原子操作)
初始化是 “一次性操作”,必须保证只有一个线程执行;
用 _InterlockedCompareExchange 原子指令实现自旋锁,无内核态切换,效率极高;
锁状态:0 = 未初始化,-1 = 初始化中,1 = 初始化完成。
2. 内存安全(避免编译器优化 / 缓存不一致)
volatile 修饰 Lock:禁止编译器优化,保证每次读取都是内存中的最新值;
WriteMemorySafe:封装 _WriteBarrier 内存屏障,保证 IsInitializeDone 的更新对所有线程可见;
所有指针转换用 ULONG_PTR:兼容 32/64 位,避免指针截断。
3. 无缝衔接原生流程
返回 LdrInitializeThunk_tramp 而非直接返回原生 LdrInitializeThunk 地址:
跳板代码中跳转到 LdrInitializeThunk+6,跳过已被劫持的开头指令(避免重复触发沙箱逻辑);
跳板代码提前准备栈帧(push rbx/sub rsp,20h),保证原生函数执行时栈对齐。
volatile 关键字 + 内存屏障(Memory Barrier)是 EntryPointC 中保证多线程内存安全的核心机制
一、先搞懂:为什么需要内存安全机制?
沙箱的 EntryPointC 运行在多线程环境(进程启动时会有多个线程触发 LdrInitializeThunk),而现代 CPU / 编译器有两个
“优化行为” 会破坏内存读写的正确性:
编译器优化:编译器会重排指令、缓存变量到寄存器(而非内存),导致线程读取的是 “过期数据”;
CPU 缓存一致性:多核心 CPU 中,每个核心有独立缓存,核心 A 写入的数据可能只在自己的缓存中,核心 B 看不到,导致 “数据不一致”。
反例(没有 volatile + 内存屏障会怎样?)
// 错误:无内存安全机制
UCHAR IsInitializeDone = 0;
// 线程1:执行初始化,设置 IsInitializeDone=1
void Thread1() {
SyscallsPrepare(...);
IsInitializeDone = 1; // 编译器可能优化:只写入寄存器,不刷到内存
}
// 线程2:判断是否初始化完成
void Thread2() {
while (!IsInitializeDone) { // 编译器优化:只读取寄存器(永远为0)
Sleep(1); // 死循环,永远等不到初始化完成
}
}
线程 1 的 IsInitializeDone=1 可能只存在于寄存器,线程 2 永远读不到;即使刷到内存,多核心下线程 2
的核心缓存也可能未同步,依然读不到最新值。
二、volatile 关键字:抑制编译器优化
1. 核心作用
volatile 告诉编译器:这个变量是 “易变的”,不要做任何优化,每次读写都必须直接操作内存,不能缓存到寄存器。
2. 在 EntryPointC 中的应用
// 关键代码:Lock 用 volatile 修饰
volatile ULONG_PTR* Lock = &ExtraDataInfo->Lock;
// 对比:有无 volatile 的区别
#ifdef _WIN64
// 有 volatile:每次读写 Lock 都直接操作内存
ULONG_PTR OldLock = _InterlockedCompareExchange64(Lock, -1, 0);
#else
ULONG_PTR OldLock = _InterlockedCompareExchange(Lock, -1, 0);
#endif
无 volatile:编译器可能把 Lock 缓存到寄存器,_InterlockedCompareExchange 操作的是寄存器而非内存,导致多线程看到的
Lock 值不一致;
有 volatile:强制每次读写 Lock 都访问内存,保证操作的是 “全局唯一” 的锁变量。
3. 注意:volatile 不是万能的
volatile 只解决编译器优化问题,不解决CPU 缓存同步问题;
volatile 不保证指令执行顺序(编译器 / CPU 仍可能重排 volatile 变量的读写顺序);
因此必须配合内存屏障使用。
三、内存屏障(Memory Barrier):强制 CPU 同步缓存 + 保证指令顺序
1. 核心作用
内存屏障是 CPU 指令(如 x86 的 mfence/sfence/lfence),作用有两个:
缓存同步:强制核心将缓存中的数据刷到主存,或从主存重新加载最新数据,保证多核心间数据一致;
指令重排禁止:禁止编译器 / CPU 重排内存屏障前后的指令,保证读写顺序和代码逻辑一致。
2. 内存屏障的分类(x86/64 架构)
| 类型 | 作用 | 适用场景 |
|---|---|---|
| mfence | 内存屏障(全屏障):禁止前后的读写指令重排,同步所有缓存 | 关键变量的读写(如锁、初始化标记) |
| sfence | 写屏障:只禁止写指令重排,同步写缓存 | 只写变量(如 IsInitializeDone) |
| lfence | 读屏障:只禁止读指令重排,同步读缓存 | 只读变量 |
四、原子操作(如 _InterlockedCompareExchange):内置内存屏障
EntryPointC
中使用的原子操作函数(_InterlockedCompareExchange64/_InterlockedExchange)本身内置了内存屏障,这是容易被忽略的关键点:
为什么原子操作内置内存屏障:原子操作需要保证 “比较 - 交换” 的原子性,必须禁止指令重排和缓存不一致;
效果:调用这些函数后,前后的读写指令不会被重排,且数据会同步到主存。
五、EntryPointC 中内存安全的完整链路(volatile + 内存屏障 + 原子操作)
volatile:保证所有变量读写都操作内存,而非寄存器;
原子操作:保证锁的原子性,内置内存屏障禁止指令重排;
WriteMemorySafe:保证初始化标记的可见性和顺序性。
六、为什么沙箱必须严格保证内存安全?
Sandboxie 运行在进程启动的最早期(LdrInitializeThunk 阶段),此时进程有多个线程并发执行:
若 Lock 变量不可见,多个线程会同时执行初始化,导致内存重复修改(如多次备份 ntdll.dll 指令,覆盖数据);
若 IsInitializeDone 不可见,线程会重复执行初始化逻辑,导致 ntdll.dll 指令被多次劫持(进程崩溃);
若指令重排,可能出现 “标记初始化完成但实际初始化未完成”(线程 2 执行未初始化的沙箱逻辑,崩溃)。
volatile + 内存屏障 + 原子操作 三者结合,是底层开发中保证多线程内存安全的 “黄金组合”。
核心关键点:
volatile:抑制编译器优化,强制变量读写直接操作内存(解决 “寄存器缓存” 问题);
内存屏障:强制 CPU 同步缓存、禁止指令重排(解决 “核心缓存不一致”“指令乱序” 问题);
原子操作:内置内存屏障,保证 “比较 - 交换” 等操作的原子性,是锁的基础;
沙箱应用:EntryPointC 中用 volatile 修饰锁变量,用 WriteMemorySafe
封装内存屏障,用原子操作实现自旋锁,三者结合保证初始化逻辑在多线程下的正确性。
简单来说,volatile 管 “编译器”,内存屏障管 “CPU”,原子操作管 “多线程竞争”——
这三个机制共同保证了沙箱初始化的内存安全,是底层开发中处理多线程共享数据的核心范式。
void WriteMemorySafe(DATA_INFO* DataInfo, void* VirtualAddress, SIZE_T
ViewSize, void* BufferData)
{
void* BaseAddress = VirtualAddress;
SIZE_T RegionSize = ViewSize;
ULONG OldProtect;
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress, &RegionSize,
PAGE_EXECUTE_READWRITE, &OldProtect);
/*
0:000> u 00000000`000304c9
00000000`000304c9 4c8bd1 mov
r10,rcx
00000000`000304cc b84d000000 mov
eax,4Dh
00000000`000304d1 0f05
syscall
00000000`000304d3 c3
ret
*/
// memcopy is not available, lets do our own
switch (ViewSize) {
case 1: *(UCHAR*)VirtualAddress = *(UCHAR*)BufferData;
break;
case 2: *(USHORT*)VirtualAddress = *(USHORT*)BufferData;
break;
case 4: *(ULONG*)VirtualAddress = *(ULONG*)BufferData;
break;
case 8: *(ULONG64*)VirtualAddress = *(ULONG64*)BufferData;
break;
default:
for (SIZE_T i = 0; i < ViewSize; i++)
((UCHAR*)VirtualAddress)[i] =
((UCHAR*)BufferData)[i];
}
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress, &RegionSize,
OldProtect, &OldProtect);
}
WriteMemorySafe 函数是 Sandboxie 实现底层内存安全写入的核心工具函数 —— 它的核心作用是:在修改受保护的内存区域(如
DATA_INFO 结构体、ntdll.dll 指令区)时,先临时修改内存保护属性为可写,完成字节安全写入后再恢复原有属性,同时规避编译器 /
系统函数依赖(如不使用 memcpy),保证在进程初始化的早期环境(无 CRT、函数未加载)下依然能稳定运行。
运行场景:进程初始化最早期(LdrInitializeThunk 阶段),此时 CRT 库(如 msvcrt.dll)尚未加载,memcpy/memset
等函数不可用;
核心使命:
修改受保护内存的属性(如只读 / 执行的指令区),使其可写;
安全写入数据(避免依赖系统函数,手动实现字节拷贝);
恢复内存原有保护属性,避免破坏系统内存规则;
适配 1/2/4/8 字节及任意长度的写入,兼容 32/64 位系统。
修改内存保护属性为可写(关键)
// 调用 NtProtectVirtualMemory 函数,将内存改为可执行可读写(PAGE_EXECUTE_READWRITE)
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), // 当前进程句柄(伪函数,实际是 -1)
&BaseAddress, //
待修改的内存起始地址(指针,函数可能调整)
&RegionSize, //
待修改的内存长度(指针)
PAGE_EXECUTE_READWRITE, // 新的保护属性:可执行、可读、可写
&OldProtect); //
输出原始保护属性
/* 你贴的调试反汇编对应此函数的实现:
00000000`000304c9 4c8bd1 mov
r10,rcx ; 第4个参数(OldProtect)
00000000`000304cc b84d000000 mov
eax,4Dh ; Syscall号=0x4D(NtProtectVirtualMemory)
00000000`000304d1 0f05 syscall
; 执行Syscall,进入内核态
00000000`000304d3 c3 ret
; 返回
*/
CALL(NtProtectVirtualMemory):是 Sandboxie 封装的宏,实际调用
DataInfo->NtProtectVirtualMemory 指向的原始函数地址(避免递归拦截);
NtCurrentProcess():定义为 (HANDLE)-1,是 Windows 内核约定的 “当前进程” 标识;
反汇编中的 eax=0x4D:是 NtProtectVirtualMemory 的 Syscall 号(64 位 Windows 固定);
PAGE_EXECUTE_READWRITE:0x40,允许内存读 / 写 / 执行,是修改指令区的必要属性(默认 ntdll.dll 是
PAGE_EXECUTE_READ,只读)。
核心设计亮点(新手必懂)
1. 规避 CRT 依赖(进程早期运行的关键)
进程启动的 LdrInitializeThunk 阶段,CRT 库、甚至部分系统 DLL 尚未加载,任何对 memcpy/malloc 等函数的调用都会触发
STATUS_PROCEDURE_NOT_FOUND 异常;
手动实现字节拷贝是唯一可行的方式,保证函数在 “裸环境” 下运行。
2. 内存保护的 “临时修改 + 无损恢复”
Windows 内核对内存保护属性的修改有严格管控,直接写入只读内存会触发 STATUS_ACCESS_VIOLATION(0xC0000005)崩溃;
流程:只读 → 可写 → 写入 → 只读,既完成写入,又不破坏系统内存规则,是 “无痕修改” 的核心。
3. 适配所有数据长度(灵活 + 高效)
对 1/2/4/8 字节(最常用的变量长度)用指针直接赋值,效率最高;
对任意长度用逐字节循环,兼容指令备份、跳板代码等大长度写入;
无内存对齐问题:逐字节拷贝不依赖硬件对齐,避免 STATUS_DATATYPE_MISALIGNMENT 异常。
4. 调用原始 Syscall(避免递归拦截)
CALL(NtProtectVirtualMemory) 宏实际调用的是 DataInfo->NtProtectVirtualMemory
指向的原始函数地址(而非被沙箱拦截的版本);
核心原因:若调用被拦截的版本,会触发递归调用(修改内存属性 → 拦截 → 再次修改内存属性 → ...),导致栈溢出崩溃。
Syscall 号?是什么意思??
Syscall 号(系统调用号) 是 Windows 内核识别 “用户态要执行哪个内核函数” 的唯一数字标识——
简单来说,用户态程序通过 Syscall 指令进入内核态时,会把这个数字存在 rax 寄存器里,内核拿到这个数字后,就能在 “系统调用表”
里找到对应的内核函数(比如 NtCreateFile/NtProtectVirtualMemory)并执行。
一、先搞懂:Syscall 号的本质(类比理解)
把 Windows 内核想象成一家 “政务大厅”,用户态程序是 “办事的人”:
每个 “办事窗口”(内核函数,如 NtCreateFile/NtOpenProcess)都有一个唯一编号(Syscall 号);
用户态程序想办某件事,先把 “窗口编号” 写在纸上(存在 rax 寄存器),然后喊一声 “我要办事”(执行 Syscall 指令);
内核的 “前台”(Syscall 处理程序)拿到编号,直接引导到对应窗口,不用挨个问 “你要办什么”—— 这就是 Syscall
号的核心作用:快速、唯一地定位要执行的内核函数。
二、Syscall 号的核心细节(结合 Windows 实际机制)
1. 存储位置(64 位 vs 32 位)
| 架构 | Syscall 号存储寄存器 | 触发指令 |
|---|---|---|
| x64(64 位) | rax | Syscall(机器码 0F 05) |
| x86(32 位) | eax | Int 2E(旧)/Sysenter(新) |
| ARM64 | x8 | SVC #0 |
3. 系统调用表(内核的 “窗口编号对照表”)
内核内部有一个核心数组叫 SSDT(System Service Descriptor Table,系统服务描述符表),本质就是 “Syscall 号 →
内核函数地址” 的对照表:
三、如何找到某个函数的 Syscall 号?(实用方法)
方法 1:解析 ntdll.dll 的导出函数(Sandboxie 用的方法)
ntdll.dll 中的 NtXXX 函数是用户态到内核态的 “入口”,其汇编代码里一定会有 mov eax, 数字 的指令,这个数字就是 Syscall 号:
// 用 x64dbg 查看 ntdll.dll!NtProtectVirtualMemory 的汇编
ntdll.dll!NtProtectVirtualMemory:
mov r10, rcx
mov eax, 4Dh ; Syscall 号 0x4D
syscall
ret
Sandboxie 的 CollectSyscallInfo 函数就是遍历 ntdll.dll 的 NtXXX 函数,从汇编中提取这个数字,存入
SYSCALL_INFO.SyscallNum。
26-3 调试未看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
27
void PrepareInjection(DATA_INFO* DataInfo, void* DetourCode)
{
void* Ntdll;
SYSCALLS_DATA* SyscallsData;
INJECT_DATA* InjectData;
EXTRA_DATA_INFO* ExtraDataInfo;
ULONG LastError = 0;
UCHAR* HookTarget = NULL;
UCHAR* HookCode = NULL;;
void* BaseAddress;
SIZE_T RegionSize;
ULONG OldProtect;
//
// now that syscalls were intercepted, we can use the top of the
syscall
// data area as a work area for injecting our dll, but we still
need the
// information from the extra area at the bottom of the data area
//
SyscallsData = (SYSCALLS_DATA*)DataInfo->SyscallsData;
ExtraDataInfo = (EXTRA_DATA_INFO*)(DataInfo->SyscallsData +
SyscallsData->ExtraDataInfoOffset);
InjectData = (INJECT_DATA*)((UCHAR*)ExtraDataInfo +
ExtraDataInfo->InjectDataOffset);
InjectData->DataInfo = (ULONG64)DataInfo;
//
// in a 32-bit program on 64-bit Windows, we need to hook the
// 32-bit ntdll (ntdll32) rather than the 64-bit ntdll, and we can
// ask our driver for the base address of the 32-bit ntdll
//
Ntdll = (void*)DataInfo->Ntdll;
#ifdef _WIN64
if (DataInfo->Flags.is_wow64) {
}
#endif _WIN64
//
// find the addresses of the ntdll (or ntdll32) functions:
// LdrLoadDll, LdrGetProcedureAddress, NtRaiseHardError,
// and RtlFindActivationContextSectionString
//
InjectData->LdrLoadDll = (ULONG_PTR)FindDllExport(Ntdll,
(UCHAR*)ExtraDataInfo +
ExtraDataInfo->LdrLoadDllOffset, &LastError);
#ifdef _M_ARM64
#endif
if (!InjectData->LdrLoadDll) {
return;
}
InjectData->LdrGetProcAddr = (ULONG_PTR)FindDllExport(Ntdll,
(UCHAR*)ExtraDataInfo +
ExtraDataInfo->LdrGetProcAddrOffset, &LastError);
#ifdef _M_ARM64
#endif
if (!InjectData->LdrGetProcAddr) {
return;
}
#ifdef _WIN64
if (DataInfo->Flags.is_wow64)
{
}
else
#endif
{
//
// for ARM64EC we need native functions,
FindDllExport can manage FFS
// however this does not work for syscalls, hence we
use the native function directly
//
InjectData->NtProtectVirtualMemory =
DataInfo->NtProtectVirtualMemory; //字节指令
InjectData->NtRaiseHardError =
DataInfo->NtRaiseHardError;
//导出函数
InjectData->NtDeviceIoControlFile =
DataInfo->NtDeviceIoControlFile; //字节指令
}
InjectData->DeviceHandle = DataInfo->DeviceHandle;
#ifdef _M_ARM64
//
// when hooking on arm64, go for LdrLoadDll
// instead of RtlFindActivationContextSectionString
// for ARM64 both work, but for ARM64EC hooking RtlFindActCtx
fails
else
#endif
{
HookTarget = FindDllExport(Ntdll,
(UCHAR*)ExtraDataInfo +
ExtraDataInfo->RtlFindActCtxOffset, &LastError);
//RtlFindActivationContextSectionString
if (!HookTarget) {
return;
}
//FindActCtxSectionString
函数检索当前激活上下文中特定字符串的信息,并返回ACTCTX_SECTION_KEYED_DATA结构。
}
InjectData->RtlFindActCtx = (ULONG_PTR)HookTarget;
InjectData->KernelDll.Length =
(USHORT)ExtraDataInfo->KernelDllLength;
InjectData->KernelDll.MaximumLength =
InjectData->KernelDll.Length + sizeof(WCHAR);
InjectData->KernelDll.Buffer32 =
(ULONG)((ULONG_PTR)ExtraDataInfo +
ExtraDataInfo->KernelDllOffset);
#ifdef _WIN64
InjectData->KernelDll.Buffer64 =
(ULONG64)((ULONG_PTR)ExtraDataInfo +
ExtraDataInfo->KernelDllOffset);
#endif
#ifdef _M_ARM64
#endif
#ifdef _WIN64
if (DataInfo->Flags.is_wow64)
{
}
else
#endif
{
InjectData->NativeDll.Length =
(SHORT)ExtraDataInfo->NativeDllLength;
InjectData->NativeDll.MaximumLength =
InjectData->NativeDll.Length + sizeof(WCHAR);
#ifdef _WIN64
InjectData->NativeDll.Buffer64 =
(ULONG64)((ULONG_PTR)ExtraDataInfo + ExtraDataInfo->NativeDllOffset);
#else
InjectData->NativeDll.Buffer32 =
(ULONG)((ULONG_PTR)ExtraDataInfo + ExtraDataInfo->NativeDllOffset);
#endif
}
#ifdef _WIN64
//64位下的64位
if (!DataInfo->Flags.is_wow64)
{
#ifdef _M_ARM64
#else
HookCode = (UCHAR*)DetourCode;
BaseAddress = (void*)(HookCode - 8);
RegionSize = sizeof(ULONG_PTR);
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress,
&RegionSize,
PAGE_EXECUTE_READWRITE,
&OldProtect);
*(ULONG_PTR*)(HookCode - 8) =
(ULONG_PTR)InjectData;
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress,
&RegionSize,
OldProtect, &OldProtect);
BaseAddress = (void*)&HookTarget[0]; //
RtlFindActCtx
RegionSize = 12;
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress,
&RegionSize,
PAGE_EXECUTE_READWRITE,
&InjectData->RtlFindActCtx_Protect);
memcpy(InjectData->RtlFindActCtx_Bytes,
HookTarget, 12);
HookTarget[0] = 0x48;
HookTarget[1] = 0xb8;
*(ULONG_PTR*)&HookTarget[2] =
(ULONG_PTR)HookCode;
HookTarget[10] = 0xff;
HookTarget[11] = 0xe0;
#endif
}
else
{
//64位下的32位
#else
//32位
#endif
}
}
核心作用是:
解析并填充 INJECT_DATA 结构体(注入配置),整合所有需要的函数地址、DLL 路径、内存偏移等信息;
定位要劫持的核心函数(如 RtlFindActivationContextSectionString);
对目标函数进行 “热补丁劫持”(修改函数开头指令为跳转,指向沙箱的 DetourCode);
适配 WOW64/ARM64/x64 等不同架构,保证注入和劫持的兼容性。
调用时机:SyscallsPrepare 之后执行,是注入前的最后一步配置;核心输入:
DataInfo:沙箱核心配置;
DetourCode:沙箱的劫持跳转代码(跳板);
核心输出:填充好的 INJECT_DATA 结构体(包含所有注入 / 劫持所需的信息);核心目标:为后续 “注入沙箱 DLL” 和 “劫持关键函数”
做好配置,让沙箱能接管进程的核心行为。
劫持指令解析(x64):
我们把写入的 12 字节指令拆解,就能看懂劫持原理:
| 字节偏移 | 机器码 | 汇编指令 | 作用 |
|---|---|---|---|
| 0-1 | 48 b8 | mov rax, [偏移 2-9] | 把 DetourCode 地址写入 rax |
| 2-9 | DetourCode 地址 | - | 跳转目标地址 |
| 10-11 | ff e0 | jmp rax | 跳转到 rax 指向的 DetourCode |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
28
LowLevel Pe帮助
UCHAR* FindDllExport(void* ModuleBase, const UCHAR* ServiceName, ULONG*
Error)
{
IMAGE_DOS_HEADER* ImageDosHeader;
IMAGE_NT_HEADERS* ImageNtHeaders;
UCHAR* ServiceAddress = NULL;
//
// find the DllMain entrypoint for the dll
//
ImageDosHeader = (void*)ModuleBase;
if (ImageDosHeader->e_magic != 'MZ' &&
ImageDosHeader->e_magic != 'ZM') {
if (Error) *Error = 0xa;
return NULL;
}
ImageNtHeaders = (IMAGE_NT_HEADERS*)((UCHAR*)ImageDosHeader +
ImageDosHeader->e_lfanew);
if (ImageNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
// 'PE\0\0'
if (Error) *Error = 0xb;
return NULL;
}
if (ImageNtHeaders->OptionalHeader.Magic ==
IMAGE_NT_OPTIONAL_HDR32_MAGIC) {
IMAGE_NT_HEADERS32* ImageNtHeaders32 =
(IMAGE_NT_HEADERS32*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER32* ImageOptionalHeader32 =
&ImageNtHeaders32->OptionalHeader;
if (ImageOptionalHeader32->NumberOfRvaAndSizes) {
IMAGE_DATA_DIRECTORY*
ImageDataDirectory = &ImageOptionalHeader32->DataDirectory[0];
ServiceAddress =
FindDllExportInternal(ModuleBase, ImageDataDirectory, ServiceName, Error);
}
}
#ifdef _WIN64
else if (ImageNtHeaders->OptionalHeader.Magic ==
IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
IMAGE_NT_HEADERS64* ImageNtHeaders64 =
(IMAGE_NT_HEADERS64*)ImageNtHeaders;
IMAGE_OPTIONAL_HEADER64* ImageOptionalHeader64 =
&ImageNtHeaders64->OptionalHeader;
if (ImageOptionalHeader64->NumberOfRvaAndSizes) {
IMAGE_DATA_DIRECTORY*
ImageDataDirectory = &ImageOptionalHeader64->DataDirectory[0];
ServiceAddress =
FindDllExportInternal(ModuleBase, ImageDataDirectory, ServiceName, Error);
}
}
#endif _WIN64
return ServiceAddress;
}
FindDllExport 函数是 Sandboxie 实现手动解析 PE 文件导出表、定位函数地址的核心工具函数 —— 它的核心作用是:不依赖系统 API(如
GetProcAddress),直接解析 DLL/EXE 的 PE 结构,根据函数名(ServiceName)找到对应的函数导出地址,适配 32/64 位 PE
文件,是沙箱在进程早期(系统 API 未加载)定位核心函数的关键。
运行场景:进程初始化最早期(LdrInitializeThunk 阶段),GetProcAddress 等系统 API 尚未加载,无法调用;核心使命:
验证 DLL 的 PE 结构合法性(避免解析损坏的 PE 文件);
区分 32/64 位 PE 文件,解析对应的导出表结构;
调用 FindDllExportInternal 完成最终的函数地址查找;
返回函数的实际内存地址(适配 ASLR,基地址 + RVA)。
二、PE 文件基础(先懂核心结构)
DLL 基地址(ModuleBase)
├─ IMAGE_DOS_HEADER(DOS头):以 'MZ' 标识开头,存储 NT 头偏移
│ └─ e_lfanew:NT 头相对于 DLL 基地址的偏移
├─ IMAGE_NT_HEADERS(NT头):以 'PE\0\0' 标识开头,包含可选头
│ └─ IMAGE_OPTIONAL_HEADER(可选头):
│ ├─ Magic:区分32位(0x10B)/64位(0x20B)PE
│ └─ DataDirectory[0]:导出表目录项(存储导出表的RVA和大小)
└─ IMAGE_EXPORT_DIRECTORY(导出表):存储导出函数名、地址表等
├─ AddressOfNames:函数名字符串表的RVA
├─ AddressOfNameOrdinals:函数序号表的RVA
└─ AddressOfFunctions:函数地址表的RVA
FindDllExport 的核心就是遍历这个结构,从 DOS 头→NT 头→可选头→导出表,最终找到函数地址
Mz:4D5A
UCHAR* FindDllExportInternal(
void* ModuleBase, IMAGE_DATA_DIRECTORY* ImageDataDirectory,
const UCHAR* ServiceName, ULONG* Error)
{
void* ServiceAddress = NULL;
ULONG i, j, n;
if (ImageDataDirectory->VirtualAddress &&
ImageDataDirectory->Size)
{
IMAGE_EXPORT_DIRECTORY* ImageExportDirectory =
(IMAGE_EXPORT_DIRECTORY*)
((UCHAR*)ModuleBase +
ImageDataDirectory->VirtualAddress);
ULONG* AddressOfNames = (ULONG*)
((UCHAR*)ModuleBase +
ImageExportDirectory->AddressOfNames);
for (n = 0; ServiceName[n]; ++n)
//计算函数名称的长度
;
for (i = 0; i <
ImageExportDirectory->NumberOfNames; ++i)
{
UCHAR* v1 = (UCHAR*)ModuleBase +
AddressOfNames[i];
for (j = 0; j < n; ++j) {
if (v1[j] !=
ServiceName[j]) //查找函数名称
break;
}
if (j == n) {
USHORT*
AddressOfNameOrdinals = (USHORT*)
((UCHAR*)ModuleBase + ImageExportDirectory->AddressOfNameOrdinals);
if
(AddressOfNameOrdinals[i] < ImageExportDirectory->NumberOfFunctions) {
ULONG*
AddressOfFunctions = (ULONG*)
((UCHAR*)ModuleBase + ImageExportDirectory->AddressOfFunctions);
ServiceAddress = (UCHAR*)ModuleBase +
AddressOfFunctions[AddressOfNameOrdinals[i]];
break;
}
}
}
if (ServiceAddress && (ULONG_PTR)ServiceAddress
>= (ULONG_PTR)ImageExportDirectory
&& (ULONG_PTR)ServiceAddress <
(ULONG_PTR)ImageExportDirectory + ImageDataDirectory->Size) {
//
// if the export points inside the
export table, then it is a
// forwarder entry. we don't
handle these, because none of the
// exports we need is a forwarder
entry. if this changes, we
// might have to scan LDR tables to
find the target dll
//
//转发器
if (Error) *Error = 0xc;
ServiceAddress = NULL;
}
}
return ServiceAddress;
}
FindDllExportInternal 函数是 FindDllExport 的核心实现—— 它的作用是手动遍历 PE
文件的导出表,通过逐字节对比函数名找到对应的导出地址,同时过滤掉转发器(Forwarder)条目,保证返回的是真实的函数内存地址,而非转发地址。
手动实现 GetProcAddress” 的核心逻辑,
1. 核心使命
遍历导出表的函数名表,逐字节匹配目标函数名(ServiceName);
通过匹配的函数名找到对应的序号,再通过序号找到函数地址表中的 RVA;
计算函数的实际内存地址(ModuleBase + RVA);
过滤转发器条目(避免返回无效的转发地址)。
2. PE 导出表核心结构回顾(关键)
IMAGE_EXPORT_DIRECTORY(导出表)的核心字段:
| 字段 | 作用 |
|---|---|
| NumberOfNames | 导出表中带名字的函数总数 |
| AddressOfNames | 函数名字符串 RVA 数组的 RVA(每个元素是一个函数名的 RVA) |
| AddressOfNameOrdinals | 函数序号数组的 RVA(每个元素对应 AddressOfNames 中函数名的序号) |
| AddressOfFunctions | 函数地址 RVA 数组的 RVA(每个元素是一个函数的 RVA,按序号索引) |
AddressOfNames[i](第 i 个函数名) → AddressOfNameOrdinals[i](对应序号) →
AddressOfFunctions[序号](对应函数 RVA)
AddressOfNames 是一个 ULONG 数组,每个元素是一个函数名的 RVA。
AddressOfNameOrdinals 是 USHORT 数组,AddressOfNameOrdinals[i] 是第 i 个函数名对应的导出序号;
AddressOfFunctions 是 ULONG 数组,AddressOfFunctions[序号] 是该序号对应的函数 RVA;
最终函数地址 = ModuleBase(模块基地址) + 函数 RVA(适配 ASLR,基地址动态变化但 RVA 固定);
校验 序号 < NumberOfFunctions 是为了避免数组越界(导出表损坏时的容错)。
步骤 5:过滤转发器条目(关键容错)
转发器条目(Forwarder)是什么?
有些 DLL 的导出函数并非自身实现,而是转发给其他 DLL(如 kernel32.dll 的某些函数转发给 ntdll.dll);
这类函数的 “地址 RVA” 会指向导出表内的一个字符串(如 "ntdll.LdrLoadDll"),而非真实的函数地址;
因此判断地址是否落在导出表范围内,即可识别转发器条目。
设计原因:
沙箱需要的函数(如 LdrLoadDll/RtlFindActCtx)都是 NTDLL 自身实现的,不是转发器条目;
过滤转发器可避免返回无效的字符串地址,导致后续调用崩溃。
PE 结构 32 位(x86)与 64 位(x64)核心区别
PE(Portable Executable)是 Windows 平台的可执行文件格式,32 位(x86)和 64 位(x64)PE
结构整体框架完全一致(DOS 头→NT 头→节表→导出 / 导入表等),核心差异集中在NT
头的可选头、地址相关字段的宽度、部分结构体定义,本质是为了适配 32 位 / 64 位的地址空间和寄存器宽度(x86 是 32 位通用寄存器,x64 是
64 位)。
一、核心头部差异:NT 头的可选头(最关键)
PE 的核心架构差异体现在IMAGE_NT_HEADERS的IMAGE_OPTIONAL_HEADER部分,32/64
位分别对应独立的结构体:IMAGE_OPTIONAL_HEADER32(0x10B)和IMAGE_OPTIONAL_HEADER64(0x20B),这是解析
PE 时区分 32/64 位的第一依据。
1. 魔术数(Magic):直接标识位数
这是最直观的区分标志,存储在可选头起始位置,解析 PE 时第一步判断:
32 位:IMAGE_NT_OPTIONAL_HDR32_MAGIC = 0x10B
64 位:IMAGE_NT_OPTIONAL_HDR64_MAGIC = 0x20B
注:还有一个0x107是 ROM 镜像,几乎不用
2. 地址 / 大小字段的宽度(核心区别)
64 位 PE 将所有内存地址、虚拟大小相关的 32 位字段扩展为 64 位,适配 x64 的 8GB/16TB 虚拟地址空间,32 位仅支持 4GB
虚拟地址空间(实际可用≈2GB)。
| 字段类型 | 32 位(IMAGE_OPTIONAL_HEADER32) | 64 位(IMAGE_OPTIONAL_HEADER64) | 说明 |
|---|---|---|---|
| 基地址 | ImageBase(DWORD,4 字节) | ImageBase(ULONGLONG,8 字节) | 模块默认加载基地址 |
| 节表基地址 | SectionAlignment(DWORD) | SectionAlignment(ULONGLONG) | 节在内存中的对齐粒度 |
| 文件对齐粒度 | FileAlignment(DWORD) | FileAlignment(ULONGLONG) | 节在文件中的对齐粒度 |
| 虚拟大小 / 地址 | 所有相关字段均为 DWORD | 所有相关字段均为 ULONGLONG |
3. 可选头的字段删减
64 位 PE移除了 32 位中为兼容 16 位 / 分段内存设计的冗余字段,这些字段在 x64 中无意义(x64 采用平坦内存模型,无分段):
32 位有,64 位无的字段:BaseOfData(数据段基地址)、StackReserveSize/StackCommitSize 旁的 16
位扩展字段(实际 32 位也很少用)。
核心功能字段(如AddressOfEntryPoint、DataDirectory)完全保留,仅宽度适配。
4. 数据目录(DataDirectory):数量 / 结构一致
32/64 位 PE 的DataDirectory(可选头末尾的数组,存储导出 / 导入 / 重定位等表的 RVA 和大小)数量固定为 16
项,结构体IMAGE_DATA_DIRECTORY完全一致(2 个 DWORD:VirtualAddress+Size),无任何差异。
这也是为什么之前分析的FindDllExport中,32/64 位都能直接用DataDirectory[0]取导出表 —— 核心目录结构完全兼容。
六、实操技巧:快速判断 PE 文件的位数
编程层面:解析 PE 的 DOS 头→NT 头→可选头,判断Magic字段(0x10B=32 位,0x20B=64 位)。
工具层面:
PEview/010 Editor:直接查看可选头的 Magic 值。
CFF Explorer:左侧 “File Header” 下直接显示 “Machine”(x86=0x014C,x64=0x8664)。
右键文件→属性→详细信息→“处理器架构”(32 位 / 64 位)。
补充:Machine 字段(IMAGE_FILE_HEADER)
NT 头的IMAGE_FILE_HEADER中还有Machine字段,也可标识位数:
32 位 x86:IMAGE_FILE_MACHINE_I386 = 0x014C
64 位 x64:IMAGE_FILE_MACHINE_AMD64 = 0x8664
ARM64:IMAGE_FILE_MACHINE_ARM64 = 0xAA64
建议:解析 PE 时同时判断 Magic 和 Machine,双重验证,避免 PE 文件被篡改导致的解析错误。
28-2 调试 30
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
29
ULONG_PTR DetourProcedure(INJECT_DATA* InjectData)
{
NTSTATUS Status;
UNICODE_STRING* DllPath;
HANDLE ModuleHandle;
typedef VOID(*LPFN_1)(INJECT_DATA* InjectData);
//定义一个函数指针
LPFN_1 Dll_Ordinal1 = NULL;
void* BaseAddress;
SIZE_T RegionSize;
ULONG OldProtect;
DATA_INFO* DataInfo;
#ifdef _WIN64
DataInfo = InjectData->DataInfo;
#endif
//
// restore original function
//
BaseAddress = (void*)InjectData->RtlFindActCtx; //源函数地址
#ifdef _WIN64
#ifdef _M_ARM64
#else
RegionSize = 12;
memcpy((void*)InjectData->RtlFindActCtx,
InjectData->RtlFindActCtx_Bytes, 12);
#endif
CALL(NtProtectVirtualMemory)(
NtCurrentProcess(), &BaseAddress, &RegionSize,
InjectData->RtlFindActCtx_Protect, &OldProtect);
#else
//32位
#endif
//
// load kernel32.dll
//
DllPath = (UNICODE_STRING*)&InjectData->KernelDll;
Status = ((LPFN_LDRLOADDLL)InjectData->LdrLoadDll)(NULL, 0,
DllPath, &ModuleHandle); //加载Kernel32.dll
//
// load sbiedll.dll
//
if (Status == 0) {
DllPath =
(UNICODE_STRING*)&InjectData->NativeDll;
Status =
((LPFN_LDRLOADDLL)InjectData->LdrLoadDll)(NULL, 0, DllPath, &ModuleHandle);
}
//
// get ordinal 1 from sbiedll
//
if (Status == 0) {
Status =
((LPFN_LDRGETPROCEDUREADDRESS)InjectData->LdrGetProcAddr)(ModuleHandle, NULL,
1, (ULONG_PTR*)&Dll_Ordinal1);
#ifdef _M_ARM64
#endif
}
//
// call ordinal 1 of sbiedll.dll
//
if (Status == 0) {
Dll_Ordinal1(InjectData);
}
//
// or report error if one occurred instead
//
else {
Status = 0xC0000142; // = STATUS_DLL_INIT_FAILED
ULONG_PTR Parameters[1] = { (ULONG_PTR)DllPath
};
ULONG LastError;
((LPFN_NTRAISEHARDERROR)InjectData->NtRaiseHardError)(
Status | 0x10000000, // |
FORCE_ERROR_MESSAGE_BOX
1, 1, Parameters, 1, &LastError);
}
return Status;
}
DetourProcedure 函数是 Sandboxie 沙箱完成 DLL 注入的核心跳板函数—— 它的核心作用是:
先恢复被劫持的 RtlFindActivationContextSectionString 函数(避免进程异常);
调用系统原生的 LdrLoadDll 加载沙箱核心 DLL(如 SbieDll.dll);
调用沙箱 DLL 的序号 1 导出函数(沙箱初始化入口);
若注入失败,弹出错误提示框,保证进程不崩溃。
1. 调用时机
这个函数是 PrepareInjection 中劫持 RtlFindActivationContextSectionString
后,进程首次调用该函数时跳转的目标函数(DetourCode 指向的就是这个函数)。
2. 核心输入
InjectData:之前 QuerySyscalls/PrepareInjection 填充的注入配置结构体,包含所有需要的函数地址、DLL
路径、原函数备份指令等。
3. 核心目标
恢复被劫持的系统函数(避免进程行为异常);
加载沙箱 DLL 并执行初始化逻辑;
容错处理:注入失败时给出明确错误,不导致进程崩溃。
步骤 1:恢复被劫持的原函数(关键,避免进程异常)
- PrepareInjection 中修改了 RtlFindActCtx 开头的 12 字节指令(跳转到此函数),这里先把备份的原指令写回去;
- 调用 NtProtectVirtualMemory 恢复函数所在内存的保护属性(如从 PAGE_EXECUTE_READWRITE 改回
PAGE_EXECUTE_READ);
为什么要恢复?
沙箱只需要 “借” 这个函数的调用时机完成注入,注入完成后必须恢复原函数,否则进程后续调用该函数会异常(比如激活上下文查找失败)。
步骤 2:加载 kernel32.dll(兜底,保证依赖)
LDRLOADDLL 是系统原生的 DLL 加载函数(ntdll.dll 导出),原型为:
NTSTATUS LdrLoadDll(
PWCHAR PathToFile,
ULONG Flags,
PUNICODE_STRING ModuleFileName,
PHANDLE ModuleHandle
);
加载 kernel32.dll 是为了兜底:沙箱 DLL 依赖 kernel32.dll 的核心功能,先确保它已加载
步骤 3:加载沙箱核心 DLL(如 SbieDll.dll)
if (Status == 0) { // kernel32.dll 加载成功
// 1. 拿到沙箱 DLL 的完整路径(如 C:\Users\Shine\Desktop\Dll.dll)
DllPath = (UNICODE_STRING*)&InjectData->NativeDll;
// 2. 调用 LdrLoadDll 加载沙箱 DLL
Status = ((LPFN_LDRLOADDLL)InjectData->LdrLoadDll)(NULL, 0,
DllPath, &ModuleHandle);
}
核心目的:这是整个注入流程的核心 —— 把沙箱的核心 DLL 加载到目标进程的地址空间中。为什么用 LdrLoadDll 而非 LoadLibrary?
LdrLoadDll 是 ntdll.dll 的原生函数,比 LoadLibrary 更底层,不依赖 CRT / 用户态封装;
进程早期 LoadLibrary 可能未初始化完成,LdrLoadDll 更稳定。
步骤 4:获取沙箱 DLL 的序号 1 导出函数(沙箱初始化入口)
关键原理:
LdrGetProcedureAddress 是 ntdll.dll 导出的函数地址查询函数(对应用户态的 GetProcAddress);
传入 NULL 作为函数名、1 作为序号,意为 “获取序号 1 的导出函数”;
沙箱 DLL 会把初始化函数设为序号 1 导出,避免硬编码函数名,提高隐蔽性。
步骤 5:执行沙箱 DLL 的初始化函数(核心)
if (Status == 0) { // 成功获取序号1函数
// 调用沙箱 DLL 的初始化函数,传入 InjectData 配置
Dll_Ordinal1(InjectData);
}
核心作用:这一步是沙箱真正 “接管” 进程的开始 —— 沙箱 DLL 的序号 1 函数会执行初始化逻辑(如 Hook 系统调用、创建沙箱环境、重定向文件 /
注册表等)。InjectData 作为参数传入:沙箱 DLL 能拿到所有配置(如设备句柄、Syscall 列表、函数地址等),完成后续初始化。
1. 无痕注入 + 恢复(核心)
先劫持函数获取调用时机,注入完成后立即恢复原函数,进程后续运行完全不受影响;
内存保护属性也恢复到原始状态,避免系统检测到内存篡改。
2. 底层函数调用(避免依赖)
全程使用 ntdll.dll
的原生函数(LdrLoadDll/LdrGetProcedureAddress/NtProtectVirtualMemory),而非用户态封装(LoadLibrary/GetProcAddress);
适配进程早期环境(CRT / 用户态 API 未初始化),保证注入稳定性。
3. 序号导出函数(隐蔽性)
沙箱 DLL 使用序号 1而非函数名导出初始化函数,无需硬编码函数名,降低被逆向 / 拦截的概率;
这是恶意软件 / 安全工具常用的隐蔽注入技巧。
4. 完善的容错处理
每一步操作都检查 Status,任意步骤失败都会触发错误处理;
使用系统原生的 NtRaiseHardError 弹框,而非自定义窗口,更贴近系统行为,不易被识别
四、32/64 位适配关键细节
| 维度 | 64 位(x86_64) | 32 位(x86) |
|---|---|---|
| 恢复指令长度 | 12 字节(mov rax + jmp rax) | 5 字节(jmp [地址]) |
| 函数调用约定 | fastcall(RCX 传 InjectData) | stdcall(栈传参) |
| 地址宽度 | ULONG_PTR = 8 字节 | ULONG_PTR = 4 字节 |
| NtProtectVirtualMemory 参数 | 地址参数为 ULONGLONG | 地址参数为 DWORD |
29-2调试 没看
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
30
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
31-1
VOID Dll_Ordinal1(INJECT_DATA* InjectData)
{
LoadLibrary(_T("User32.dll"));
MessageBox(NULL, _T("SandBox"), _T("SandBox"), 0);
DATA_INFO* DataInfo = (DATA_INFO*)InjectData->DataInfo;
BOOLEAN IsHostInject = FALSE;
__DataInfo = DataInfo;
#ifdef _M_ARM64EC
#endif
__DeviceHandle = (HANDLE)DataInfo->DeviceHandle;
IsHostInject = DataInfo->Flags.bHostInject == 1;
#ifndef _WIN64
__IsWow64 = DataInfo->Flags.is_wow64 == 1; // x86 on x64 or
arm64
#endif
#ifdef _M_ARM64EC
#endif
#ifndef _WIN64
#endif
if (!IsHostInject)
{
PrepareInjection(); // install required hooks
(Dll_InitInjected -> Ldr_Init -> Ldr_Inject_Init(FALSE))
}
else
{
}
}
Dll_Ordinal1 函数是 Sandboxie 沙箱 DLL 的初始化入口函数(序号 1 导出)—— 它是 DetourProcedure
加载沙箱 DLL 后调用的第一个函数
核心作用是:
完成沙箱核心环境的初始化(绑定设备句柄、标记运行状态);
区分 “主机注入” 和 “普通注入”,触发不同的 Hook 安装逻辑;
作为沙箱从 “注入完成” 到 “功能生效” 的关键桥梁。
1. 调用时机
由 DetourProcedure 调用:DetourProcedure 加载沙箱 DLL 后,通过序号 1 找到该函数并执行;
是沙箱 DLL 的入口点(替代传统的 DllMain):避免 DllMain 中执行复杂逻辑导致的进程挂起 / 异常,是沙箱类工具的通用设计。
InjectData:从注入跳板传递过来的配置结构体,包含沙箱运行所需的所有核心信息(设备句柄、DataInfo 指针、运行标志等)。
核心目标
绑定沙箱运行的核心资源(设备句柄、DataInfo);
标记进程运行状态(是否 WOW64、是否主机注入);
触发 Hook 安装逻辑,让沙箱的核心功能(如 Syscall 拦截、文件重定向)生效。
为什么不用 DllMain?
DllMain 是系统加载 DLL 时的回调函数,运行在进程初始化的临界区,执行复杂逻辑(如 Hook、驱动通信)容易导致:
进程挂起(DllMain 中阻塞会卡住整个进程);
死锁(调用系统 API 可能触发递归进入 DllMain);
序号 1 导出函数的优势:
由 DetourProcedure 主动调用,运行在进程正常执行流中,可执行复杂初始化逻辑,无临界区限制。
理清一下Dll和Ring0,Ring3各自关系,已经发挥作用
一、先理清:驱动(Ring0)、Ring3 注入代码、沙箱 DLL 的核心分工
| 组件 | 角色定位 | 核心能力(能做什么) | 核心限制(不能做什么) |
|---|---|---|---|
| 驱动(Ring0) | 沙箱的 “底层支撑” | 1. 拦截内核态 Syscall / 系统调用 2. 隔离进程的内核资源(句柄、内存、文件) 3. 提供沙箱的核心隔离规则 4. 与 Ring3 通信(IOCTL) | 1. 无法直接操作目标进程的用户态内存 2. 无法 Hook 目标进程的用户态函数 3. 无法直接访问目标进程的 DLL / 函数 |
| Ring3 注入代码(如 DetourProcedure/PrepareInjection) | 沙箱的 “铺路工” | 1. 劫持目标进程的函数,获取注入时机 2. 加载沙箱 DLL 到目标进程 3. 传递核心配置(驱动句柄、Syscall 列表) | 1. 仅做 “一次性注入”,无法长期驻留 2. 无业务逻辑,仅完成 “加载 DLL” 的动作 |
| 沙箱 DLL(如 Dll_Ordinal1) | 沙箱的 “常驻工” | 1. 驻留在目标进程的地址空间 2. Hook 目标进程的用户态函数(LdrLoadDll/CreateFile) 3. 与驱动协同,执行隔离规则 4. 处理进程的用户态行为(文件重定向、注册表拦截) | 1. 无法直接操作内核态资源 2. 依赖驱动提供的底层隔离能力 |
简单说:
驱动是 “后台老板”,制定规则、掌控底层,但不直接接触目标进程;
Ring3 注入代码是 “快递员”,只负责把沙箱 DLL 送到目标进程里,送完就走;
沙箱 DLL 是 “驻场员工”,长期待在目标进程里,执行老板(驱动)的规则,管控进程的一举一动。
二、沙箱 DLL 的核心作用(为什么必须写这部分代码)
1. 常驻目标进程,成为沙箱的 “用户态代理”
驱动(Ring0)和注入代码(一次性)都无法长期驻留在目标进程:
驱动运行在内核态,与目标进程是 “跨特权级” 的,无法直接修改目标进程的用户态内存 / 函数;
注入代码(如 DetourProcedure)只是 “一次性跳板”,执行完加载 DLL 的动作后就结束了;
而 DLL 被加载到目标进程后,会永久驻留在进程地址空间,直到进程退出 —— 这是沙箱能持续管控进程的前提。
2. Hook 目标进程的用户态函数,实现 “前端拦截”
沙箱的核心是 “隔离”,但很多隔离逻辑必须在用户态完成,驱动无法替代:
比如拦截 CreateFileW(创建文件):驱动可以拦截内核态的 NtCreateFile,但用户态的 CreateFileW
有缓存、路径解析、权限检查等逻辑,沙箱 DLL 可以在用户态直接 Hook CreateFileW,把 “C:\test.txt” 重定向到沙箱目录(如
C:\Sandbox\test.txt),比内核态拦截更高效、更灵活;
比如拦截 LdrLoadDll(加载 DLL):沙箱 DLL 可以 Hook 这个函数,阻止目标进程加载恶意 DLL,或替换为沙箱的受控 DLL;
这些用户态 Hook 只能由运行在目标进程内的 DLL 完成 —— 驱动管不到用户态函数,注入代码也无法长期 Hook。
3. 协同驱动,完成 “用户态 + 内核态” 的双层隔离
沙箱的隔离是 “双层拦截”,缺了 DLL 就会断档:
用户态拦截(DLL 负责):先在用户态处理 “简单逻辑”(如路径重定向、参数检查),减少内核态的压力;
内核态拦截(驱动负责):对用户态处理后的请求做 “最终校验”(如是否允许访问沙箱外的文件、是否允许敏感 Syscall)。
举个实际例子(文件访问):
目标进程调用 CreateFileW("C:\test.txt");
沙箱 DLL Hook 到这个调用,把路径重定向为 C:\Sandbox\test.txt;
重定向后的请求传到内核态,驱动验证这个路径在沙箱允许范围内,允许访问;
如果没有 DLL 的用户态重定向,驱动需要在内核态解析路径、处理重定向,不仅复杂,还会大幅降低性能。
4. 处理进程的用户态业务逻辑,驱动 / 注入代码做不到
沙箱除了 “隔离”,还有很多用户态专属的逻辑,只能由 DLL 完成:
比如进程的异常处理:沙箱 DLL 可以注册异常处理函数,捕获目标进程的崩溃,避免沙箱整体挂掉;
比如 UI 交互:沙箱 DLL 可以弹出提示框(如你看到的 MessageBox("SandBox")),告知用户进程的沙箱状态;
比如动态调整规则:沙箱 DLL 可以接收用户的操作(如 “允许访问某个文件”),实时修改隔离规则,并同步给驱动;
这些逻辑既不属于驱动的内核态范畴,也不是注入代码的 “一次性动作”,只能由常驻的 DLL 完成。
三、反过来看:如果没有这个 DLL,会发生什么?
驱动只能在核态拦截 Syscall,但无法处理用户态的函数 Hook、路径重定向,隔离效果会大打折扣(比如用户态的缓存、路径解析会绕过驱动);
Ring3 注入代码只能加载 DLL,但加载后没有任何常驻逻辑,沙箱无法持续管控进程,注入等于 “白做”;
最终的结果是:沙箱只有 “内核态隔离”,没有 “用户态拦截”,既不灵活,也不高效,甚至无法处理基本的文件 / 注册表重定向。
四、总结:沙箱 DLL 的核心价值
常驻性:长期驻留目标进程,是沙箱能持续管控进程的基础;
用户态代理:作为驱动在用户态的 “手脚”,处理驱动管不到的用户态逻辑;
双层拦截:与驱动协同,完成 “用户态前端拦截 + 内核态最终校验” 的完整隔离;
灵活性:适配不同架构 / 场景,处理 UI 交互、异常处理等用户态专属逻辑。
简单说:驱动是沙箱的 “大脑”,制定规则;注入代码是 “手”,把 DLL 送到目标进程;而 DLL 是
“身体”,在目标进程里执行大脑的指令,完成所有具体的管控动作。三者结合,才是一个完整、高效的沙箱系统。
void PrepareInjection()
{
LONG Status;
BOOLEAN IsOk = TRUE;
ULONG v1;
ULONG v2;
ULONG v3;
__ProcessIdentity = (ULONG)(ULONG_PTR)GetCurrentProcessId();
__BoxName = (WCHAR*)MemoryPoolAllocateEx(__Pool,BOX_NAME *
sizeof(WCHAR));
memset(__BoxName, 0,BOX_NAME * sizeof(WCHAR));
__ImageName = (WCHAR*)MemoryPoolAllocateEx(__Pool,256 *
sizeof(WCHAR));
memset(__ImageName, 0,256 * sizeof(WCHAR));
__SidString = (WCHAR*)MemoryPoolAllocateEx(__Pool,96 *
sizeof(WCHAR));
memset(__SidString, 0,96 * sizeof(WCHAR));
Interrupt();
Status = QueryProcessEx( // sets proc->sbiedll_loaded = TRUE; in
the driver
(HANDLE)(ULONG_PTR)__ProcessIdentity, 255,
__BoxName, __ImageName, __SidString,
&__SessionIdentity, NULL);
if (Status != 0) {
ExitProcess(-1);
}
QueryDriverInfo(0, &__DriverFlags, sizeof(__DriverFlags));
__ProcessFlags = QueryProcessInfoEx(0, 0,0);
v1 = 0;
v2 = 0;
v3 = 0;
Status = QueryBoxPath(
NULL, NULL, NULL, NULL,
NULL, NULL, &v3);
if (Status != 0) {
ExitProcess(-1);
}
__BoxIpcPathData = (WCHAR*)MemoryPoolAllocateEx(__Pool,v3);
//用于HookCreateEvent
Status = QueryBoxPath(
NULL,
NULL,
NULL,
(WCHAR*)__BoxIpcPathData,
NULL,NULL, &v3);
if (Status != 0) {
ExitProcess(-1);
}
__BoxIpcPathLength = wcslen(__BoxIpcPathData);
if (IsOk)
IsOk = ObjectHook(); //NtQueryObject
NtQueryVirtualMemory
if (IsOk)
{
ULONG Count = 0;
//获取当前启动的进程信息
if (NT_SUCCESS(EnumProcessEx(NULL, FALSE, -1, NULL,
&Count)) && Count == 1) //获取当前进程的会话Identity
{
__FirstProcessInBox = TRUE;
}
}
if (IsOk)
{
IpcHook();
}
}
PrepareInjection 是沙箱 DLL 初始化后安装核心 Hook 的总入口函数—— 它的核心作用是:
从驱动获取当前进程的沙箱上下文(箱名、路径、SID 等),完成沙箱环境的 “身份绑定”;
申请内存存储沙箱核心配置(IPC 路径、进程标识);
依次安装关键 Hook(对象 Hook、IPC Hook),让沙箱的 “隔离规则” 真正落地到目标进程。
1. 调用时机
由 Dll_Ordinal1 调用(IsHostInject=FALSE 时),是沙箱 DLL 安装所有 Hook 的 “总开关”。
2. 核心目标
身份绑定:让当前进程与沙箱的 “箱名”“路径”“SID” 等信息绑定,明确 “这个进程属于哪个沙箱”;
资源准备:申请内存存储沙箱的核心配置(IPC 路径、进程标识),为后续 Hook 提供数据支撑;
Hook 安装:依次安装对象 Hook、IPC Hook,接管进程的核心系统调用,实现隔离。
从驱动查询进程的沙箱上下文(核心)
核心函数 QueryProcessEx:
本质是封装的 IOCTL 调用(通过 __DeviceHandle 与驱动通信);
驱动侧逻辑:根据进程 ID 查找该进程的沙箱配置,填充
__BoxName(箱名)、__ImageName(镜像名)、__SidString(SID),并标记进程的沙箱状态;
__SessionIdentity:保存进程的会话 ID,用于隔离不同用户会话的沙箱;
为什么查询失败就退出进程?
沙箱进程必须绑定到具体的沙箱配置(箱名、路径),没有配置的话,后续的 Hook 无法执行隔离规则,进程继续运行会导致沙箱失控,因此直接退出。
获取沙箱的 IPC 路径(IPC Hook 前置)
核心函数 QueryBoxPath:
从驱动获取当前沙箱的 IPC 隔离路径(沙箱内的进程只能访问该路径下的 IPC 对象,无法访问系统级 IPC);
分两步查询:先查长度→再查实际路径,是 Windows 驱动通信的通用写法(避免内存不足);
IPC 隔离的意义:
IPC(进程间通信)是进程交互的核心(如命名管道、事件、共享内存),沙箱通过 Hook IPC 相关函数(如 CreateEventW),把进程的 IPC
操作限制在 __BoxIpcPathData 路径下,避免沙箱内进程与外部进程通信。
安装对象 Hook(核心隔离第一步)
ObjectHook() 核心作用:
Hook 内核对象查询相关的系统调用(NtQueryObject/NtQueryVirtualMemory);
实现的隔离逻辑:
隐藏沙箱外的内核对象(如外部进程的句柄、内存区域);
限制沙箱进程查询系统级对象(如 \\KernelObjects\\Global);
把对象路径重定向到沙箱内(如把 \\Global\\Event1 重定向到 \\SbieBox\\DefaultBox\\Event1);
为什么先装 ObjectHook?
内核对象是进程操作的基础(文件、事件、内存都属于内核对象),先拦截对象查询,能保证后续的 IPC / 文件 Hook 基于 “正确的对象视图” 执行。
步骤 6:标记首个进程,安装 IPC Hook(核心隔离第二步)
EnumProcessEx 作用:
从驱动查询当前沙箱内的进程数量,若为 1,则标记为 “首个进程”—— 首个进程需要初始化沙箱的核心 IPC
资源(如创建沙箱的全局事件),后续进程无需重复初始化;IpcHook() 核心作用:
Hook IPC 相关的系统调用(CreateEventW/OpenEventW/CreateNamedPipeW 等);
实现的隔离逻辑:
把进程创建的 IPC 对象名称拼接上沙箱 IPC 路径(如 Event1 →
\\SbieBox\\DefaultBox\\IPC\\Event1);
禁止进程访问沙箱外的 IPC 对象;
拦截沙箱内进程的 IPC 通信,防止与外部进程交互
1. “先查询后申请” 的内存分配逻辑
所有从驱动获取的动态数据(IPC 路径、SID 字符串)都先查长度→再申请内存→最后获取数据;
避免内存不足导致的缓冲区溢出,是 Windows 内核 / 用户态通信的 “最佳实践”。
2. 分层 Hook 安装(从基础到业务)
先装 ObjectHook(基础对象拦截)→ 再装 IpcHook(业务级 IPC 拦截);
遵循 “先基础后业务” 的逻辑,保证后续 Hook 依赖的对象视图是 “隔离后的视图”,避免规则冲突。
3. 进程身份绑定(按 “箱” 隔离)
每个进程都绑定到具体的 __BoxName,沙箱的隔离规则按 “箱名” 区分;
实现 “多沙箱并行”(如同时运行 DefaultBox 和 TestBox),互不干扰。
LONG QueryProcessEx(
HANDLE ProcessIdentity,
ULONG NameLength,
WCHAR* BoxName,
WCHAR* ImageName,
WCHAR* SidString,
ULONG* SessionIdentity,
ULONG64* CreateTime)
{
NTSTATUS Status;
__declspec(align(8)) UNICODE_STRING64 v1;
__declspec(align(8)) UNICODE_STRING64 v2;
__declspec(align(8)) UNICODE_STRING64 v3;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
QUERY_PROCESS_ARGS* Arguments =
(QUERY_PROCESS_ARGS*)ParameterData;
ZeroMemory(ParameterData, sizeof(ParameterData));
Arguments->ServiceIndex = QUERY_PROCESS;
Arguments->ProcessIdentity.Value64 =
(ULONG64)(ULONG_PTR)ProcessIdentity;
if (BoxName) {
v1.Length = 0;
v1.MaximumLength = (USHORT)(sizeof(WCHAR) *
BOX_NAME);
v1.Buffer = (ULONG64)(ULONG_PTR)BoxName;
Arguments->BoxName.Value64 =
(ULONG64)(ULONG_PTR)&v1;
}
if (ImageName) {
v2.Length = 0;
v2.MaximumLength =
(USHORT)(sizeof(WCHAR) * NameLength);
v2.Buffer = (ULONG64)(ULONG_PTR)ImageName;
Arguments->ImageName.Value64 =
(ULONG64)(ULONG_PTR)&v2;
}
if (SidString) {
v3.Length = 0;
v3.MaximumLength = (USHORT)(sizeof(WCHAR) * 96);
v3.Buffer = (ULONG64)(ULONG_PTR)SidString;
Arguments->SidString.Value64 =
(ULONG64)(ULONG_PTR)&v3;
}
if (SessionIdentity)
Arguments->SessionIdentity.Value64 =
(ULONG64)(ULONG_PTR)SessionIdentity;
if (CreateTime)
Arguments->CreateTime.Value64 =
(ULONG64)(ULONG_PTR)CreateTime;
Status = IoctlRequest(ParameterData);
if (!NT_SUCCESS(Status)) {
ULONG_PTR x = (ULONG_PTR)SessionIdentity;
if (x == 0 || x > 4) {
//
// reset parameters on error except
when out_session_id
// is a special internal flag in the
range 1 to 4
//
if (BoxName)
*BoxName = L'\0';
if (ImageName)
*ImageName = L'\0';
if (SidString)
*SidString = L'\0';
if (SessionIdentity)
*SessionIdentity = 0;
}
}
return Status;
}
QueryProcessEx 函数是沙箱 DLL 与内核驱动通信的核心封装函数—— 它的本质是构造标准化的 IOCTL 请求参数,通过
IoctlRequest 向沙箱驱动查询指定进程的沙箱上下文信息(箱名、镜像名、SID 等),是用户态(沙箱 DLL)获取内核态(驱动)配置的关键桥梁。
QueryProcessEx 的核心作用是:
构造固定格式的参数结构体(QUERY_PROCESS_ARGS),包含要查询的进程 ID、存储结果的缓冲区指针(箱名 / 镜像名 / SID);
调用 IoctlRequest 发送 IOCTL 指令到沙箱驱动,请求查询该进程的沙箱配置;
驱动处理请求后,会把沙箱箱名、进程镜像名、SID 等数据填充到用户态传入的缓冲区中;
处理驱动返回的错误,重置参数避免脏数据,最终返回通信结果。
简单说:这是用户态向驱动 “提问” 的标准化接口 ——“请告诉我进程 ID 为 X 的进程属于哪个沙箱?它的镜像名、SID 是什么?”,驱动则通过该接口
“回答” 这些问题。
QueryProcessEx 核心要点
核心定位:用户态沙箱 DLL 向驱动查询进程沙箱信息的标准化接口,是 “用户态→内核态” 通信的核心封装;
核心逻辑:
构造 64 位对齐的参数结构体,适配 32/64 位驱动通信;
传入进程 ID 和输出缓冲区,标记请求类型为 “查询进程”;
发送 IOCTL 请求,驱动填充沙箱配置到缓冲区;
处理错误,清空脏数据,返回通信结果;
LONG IoctlRequest(ULONG64* ParameterData)
{
NTSTATUS Status;
OBJECT_ATTRIBUTES ObjectAttributes;
UNICODE_STRING v1;
IO_STATUS_BLOCK IoStatusBlock;
if (ParameterData == NULL) { // close request as used by kmdutil
if (__DeviceHandle != INVALID_HANDLE_VALUE)
__NtClose(__DeviceHandle);
__DeviceHandle = INVALID_HANDLE_VALUE;
}
if (__DeviceHandle == INVALID_HANDLE_VALUE) {
RtlInitUnicodeString(&v1, DEVICE_NAME);
InitializeObjectAttributes(
&ObjectAttributes, &v1,
OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = __NtOpenFile(
&__DeviceHandle,
FILE_GENERIC_READ, &ObjectAttributes,
&IoStatusBlock,
FILE_SHARE_READ |
FILE_SHARE_WRITE | FILE_SHARE_DELETE,
0);
if (Status == STATUS_OBJECT_NAME_NOT_FOUND ||
Status == STATUS_NO_SUCH_DEVICE)
Status = STATUS_SERVER_DISABLED;
}
else
Status = STATUS_SUCCESS;
if (Status != STATUS_SUCCESS) {
__DeviceHandle = INVALID_HANDLE_VALUE;
}
else
{
if (__sys_NtDeviceIoControlFile)
{
//
// once NtDeviceIoControlFile is hooked, bypass it
//
Status =
__sys_NtDeviceIoControlFile(
__DeviceHandle, NULL,
NULL, NULL, &IoStatusBlock,
IO_CTL_CODE_1,
ParameterData, sizeof(ULONG64) * 8, NULL, 0);
}
else {
Status = __NtDeviceIoControlFile(
__DeviceHandle, NULL,
NULL, NULL, &IoStatusBlock,
IO_CTL_CODE_1,
ParameterData, sizeof(ULONG64) * 8, NULL, 0);
}
}
return Status;
}
IoctlRequest 是沙箱 DLL 与内核驱动通信的最底层核心函数—— 它的本质是封装了 Windows 原生的
NtDeviceIoControlFile 系统调用,实现 “打开驱动设备句柄 + 发送 IOCTL 控制指令” 的完整流程,是用户态(沙箱
DLL)与内核态(驱动)通信的 “唯一管道”
IoctlRequest 的核心作用是:
设备句柄管理:检查全局的驱动设备句柄(__DeviceHandle)是否有效,无效则重新打开沙箱驱动的设备对象(如
\\Device\\SbieDrv);
IOCTL 指令发送:通过 NtDeviceIoControlFile 向驱动发送预定义的 IOCTL
指令(IO_CTL_CODE_1),传递参数缓冲区(ParameterData);
Hook 绕过处理:若 NtDeviceIoControlFile 被 Hook,使用原生的 __sys_NtDeviceIoControlFile 绕过
Hook,保证通信不被拦截;
错误处理:打开设备失败时重置句柄,最终返回通信结果(NTSTATUS)。
1. 设备句柄复用(性能优化)
全局缓存 __DeviceHandle,无需每次通信都重新打开设备(NtOpenFile 是内核态操作,频繁调用会降低性能);
仅当句柄无效时重新打开,兼顾性能与可靠性。
2. Hook 绕过(通信安全)
提前保存 NtDeviceIoControlFile 的原生地址(__sys_NtDeviceIoControlFile),避免通信被 Hook 拦截;
这是安全工具 / 沙箱的通用设计 —— 核心系统调用必须使用原生版本,防止被恶意程序篡改参数。
~~
31-2看了
31-3看了
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
32-1
32-2
调试的时候记得要把这个Dll.dll 和 LowLevel.dll 放在这个目录下
调试,没太关注
32-3
调试,不想看,先跳过
33-1
void PrepareInjection()
{
LONG Status;
BOOLEAN IsOk = TRUE;
ULONG v1;
ULONG v2;
ULONG v3;
__ProcessIdentity = (ULONG)(ULONG_PTR)GetCurrentProcessId();
__BoxName = (WCHAR*)MemoryPoolAllocateEx(__Pool,BOX_NAME *
sizeof(WCHAR));
memset(__BoxName, 0,BOX_NAME * sizeof(WCHAR));
__ImageName = (WCHAR*)MemoryPoolAllocateEx(__Pool,256 *
sizeof(WCHAR));
memset(__ImageName, 0,256 * sizeof(WCHAR));
__SidString = (WCHAR*)MemoryPoolAllocateEx(__Pool,96 *
sizeof(WCHAR));
memset(__SidString, 0,96 * sizeof(WCHAR));
Interrupt();
Status = QueryProcessEx( // sets proc->sbiedll_loaded = TRUE; in
the driver
(HANDLE)(ULONG_PTR)__ProcessIdentity, 255,
__BoxName, __ImageName, __SidString,
&__SessionIdentity, NULL);
if (Status != 0) {
ExitProcess(-1);
}
QueryDriverInfo(0, &__DriverFlags, sizeof(__DriverFlags));
__ProcessFlags = QueryProcessInfoEx(0, 0,0);
v1 = 0;
v2 = 0;
v3 = 0;
Status = QueryBoxPath(
NULL, NULL, NULL, NULL,
NULL, NULL, &v3);
if (Status != 0) {
ExitProcess(-1);
}
__BoxIpcPathData = (WCHAR*)MemoryPoolAllocateEx(__Pool,v3);
//用于HookCreateEvent
Status = QueryBoxPath(
NULL,
NULL,
NULL,
(WCHAR*)__BoxIpcPathData,
NULL,NULL, &v3);
if (Status != 0) {
ExitProcess(-1);
}
__BoxIpcPathLength = wcslen(__BoxIpcPathData);
if (IsOk)
IsOk = ObjectHook(); //NtQueryObject
NtQueryVirtualMemory
if (IsOk)
{
ULONG Count = 0;
//获取当前启动的进程信息
if (NT_SUCCESS(EnumProcessEx(NULL, FALSE, -1, NULL,
&Count)) && Count == 1) //获取当前进程的会话Identity
{
__FirstProcessInBox = TRUE;
}
}
if (IsOk)
{
IpcHook();
}
}
PrepareInjection 是沙箱 DLL 完成初始化后安装核心隔离 Hook 的总入口—— 它的核心逻辑是
“先从驱动获取进程的沙箱上下文(身份、路径、规则),再分步骤安装对象 / IPC Hook”,是沙箱从 “注入完成” 到 “隔离生效” 的关键一步。
一、函数核心目标(一句话总结)
为当前被隔离进程绑定沙箱身份(箱名、SID、会话 ID),获取隔离规则(驱动全局规则、进程专属规则),准备隔离资源(IPC 路径缓冲区),最终安装核心
Hook(对象查询 / IPC 通信),让进程的所有操作被沙箱管控。
二、完整执行链路拆解(按执行顺序)
阶段 1:初始化进程核心标识(全局化,为后续通信做准备)
// 1. 保存当前进程ID到全局变量(驱动通过这个ID识别“当前进程”)
__ProcessIdentity = (ULONG)(ULONG_PTR)GetCurrentProcessId();
// 2. 申请内存存储沙箱核心配置(后续驱动会填充这些缓冲区)
// 沙箱箱名缓冲区(如 "DefaultBox")
__BoxName = (WCHAR*)MemoryPoolAllocateEx(__Pool,BOX_NAME *
sizeof(WCHAR));
memset(__BoxName, 0,BOX_NAME * sizeof(WCHAR));
// 进程镜像名缓冲区(如 "notepad.exe")
__ImageName = (WCHAR*)MemoryPoolAllocateEx(__Pool,256 * sizeof(WCHAR));
memset(__ImageName, 0,256 * sizeof(WCHAR));
// 进程SID字符串缓冲区(如 "S-1-5-21-xxx")
__SidString = (WCHAR*)MemoryPoolAllocateEx(__Pool,96 * sizeof(WCHAR));
memset(__SidString, 0,96 * sizeof(WCHAR));
关键目的:
__ProcessIdentity:作为后续调用 QueryProcessEx 的 “进程标识”,驱动通过它找到当前进程的沙箱配置;
三个缓冲区:提前申请并清零,避免驱动填充数据时出现脏数据,保证内存安全;
MemoryPoolAllocateEx:沙箱封装的内存分配函数(替代系统 HeapAlloc),优势是:
统一管理沙箱内存,避免泄漏;
可自定义内存权限 / 对齐,适配沙箱隔离需求。
阶段 2:从驱动获取进程的沙箱身份(核心,绑定隔离上下文)
// 1. 沙箱内部临界区保护(暂停非核心操作,避免并发冲突)
Interrupt();
// 2. 调用QueryProcessEx → 底层调用IoctlRequest向驱动发IOCTL请求
// 驱动侧会:① 填充箱名/镜像名/SID到缓冲区 ② 标记进程sbiedll_loaded=TRUE
Status = QueryProcessEx(
(HANDLE)(ULONG_PTR)__ProcessIdentity, 255,
__BoxName, __ImageName, __SidString,
&__SessionIdentity, NULL);
// 3. 通信失败直接退出进程(无沙箱配置,后续隔离无意义)
if (Status != 0) {
ExitProcess(-1);
}
驱动侧核心操作(你之前问的 QueryProcessEx/IoctlRequest 在这里落地):
驱动根据 __ProcessIdentity(进程 ID)找到该进程的沙箱元数据;
把 “该进程属于哪个沙箱(__BoxName)、进程名(__ImageName)、安全标识(__SidString)” 写入用户态缓冲区;
标记 proc->sbiedll_loaded = TRUE(告诉驱动 “该进程的沙箱 DLL 已加载,无需重复注入”);
为什么失败就退出?
沙箱进程必须绑定到具体的沙箱配置,没有配置的话,后续 Hook 无法执行隔离规则,进程继续运行会导致 “无规则的裸奔”,宁可退出也不允许沙箱失控。
阶段 3:获取隔离规则(驱动全局 + 进程专属)
// 1. 从驱动获取沙箱全局规则(如是否开启文件隔离、IPC隔离、网络隔离)
QueryDriverInfo(0, &__DriverFlags, sizeof(__DriverFlags));
// 2. 从驱动获取当前进程的专属规则(如是否允许访问C盘、是否允许加载外部DLL)
__ProcessFlags = QueryProcessInfoEx(0, 0,0);
__DriverFlags:所有沙箱进程共用的全局开关(比如管理员在沙箱 UI 中开启 “禁止所有进程访问网络”,这个标志就会置位);
__ProcessFlags:当前进程的个性化规则(比如允许 chrome.exe 访问网络,但禁止 notepad.exe 访问);
作用:后续 ObjectHook/IpcHook 会根据这两个标志 “按需拦截”—— 比如全局没开 IPC 隔离,IpcHook 就不会安装对应的 Hook。
阶段 4:获取沙箱 IPC 隔离路径(为 IPC Hook 做准备)
这是 “先查长度→再申请内存→再查数据” 的经典 Windows 驱动通信写法,避免内存不足:
ULONG v1=0, v2=0, v3=0;
// 第一步:只传长度指针v3,驱动返回“存储IPC路径需要的内存大小”
Status = QueryBoxPath(NULL, NULL, NULL, NULL, NULL, NULL, &v3);
if (Status != 0) { ExitProcess(-1); }
// 第二步:根据v3申请内存(存储IPC路径,供后续IpcHook使用)
__BoxIpcPathData = (WCHAR*)MemoryPoolAllocateEx(__Pool,v3);
// 第三步:传入申请好的缓冲区,驱动填充实际的IPC隔离路径(如 "\\SbieBox\\DefaultBox\\IPC")
Status = QueryBoxPath(NULL, NULL, NULL, (WCHAR*)__BoxIpcPathData, NULL,NULL,
&v3);
if (Status != 0) { ExitProcess(-1); }
// 保存IPC路径长度(后续拼接IPC名称时用,如 Event1 → \\SbieBox\\DefaultBox\\IPC\\Event1)
__BoxIpcPathLength = wcslen(__BoxIpcPathData);
IPC 隔离的核心意义:
IPC(进程间通信)是进程交互的核心(事件、管道、共享内存),沙箱通过 Hook CreateEvent/OpenEvent 等函数,把进程的 IPC
操作强制限制在 __BoxIpcPathData 路径下,避免沙箱内进程与外部进程通信(比如病毒通过 IPC 传播)。
阶段 5:安装核心 Hook(从基础到业务,分层隔离)
沙箱的 Hook 安装遵循 “先基础后业务” 的逻辑,保证隔离无死角:
// 第一步:安装对象Hook(基础隔离)
if (IsOk)
IsOk = ObjectHook(); //
拦截NtQueryObject/NtQueryVirtualMemory等
// 第二步:标记首个进程(沙箱内第一个启动的进程,需初始化核心资源)
if (IsOk)
{
ULONG Count = 0;
// 查沙箱内当前进程数量,Count=1表示是首个进程
if (NT_SUCCESS(EnumProcessEx(NULL, FALSE, -1, NULL, &Count))
&& Count == 1)
{
__FirstProcessInBox = TRUE; //
标记为首个进程,后续初始化核心IPC资源
}
}
// 第三步:安装IPC Hook(业务隔离)
if (IsOk)
{
IpcHook(); // 拦截CreateEvent/OpenEvent/CreateNamedPipe等
}
1. ObjectHook()(基础对象隔离)
拦截函数:NtQueryObject(查询内核对象)、NtQueryVirtualMemory(查询内存)等;
核心逻辑:
隐藏沙箱外的内核对象(比如外部进程的句柄、系统级内存区域);
把对象路径重定向到沙箱内(比如 \\Global\\Event1 → \\SbieBox\\DefaultBox\\Event1);
保证后续 IPC Hook 基于 “隔离后的对象视图” 执行,避免规则冲突。
2. EnumProcessEx()(标记首个进程)
作用:从驱动查询当前沙箱内的进程数量,若为 1,标记 __FirstProcessInBox = TRUE;
首个进程的特殊逻辑:需要初始化沙箱的核心 IPC 资源(如创建沙箱全局事件),后续进程无需重复初始化,节省资源。
3. IpcHook()(业务 IPC 隔离)
拦截函数:CreateEventW/OpenEventW/CreateNamedPipeW 等所有 IPC 相关函数;
核心逻辑:
拼接沙箱 IPC 路径:把进程要创建的 IPC 名称(如 Event1)拼上 __BoxIpcPathData(如
\\SbieBox\\DefaultBox\\IPC\\);
禁止访问沙箱外的 IPC 对象:比如进程要打开 \\Global\\SystemEvent,直接返回 “对象不存在”;
保证沙箱内进程只能与同沙箱的进程通信,无法与外部进程交互。
1. “驱动依赖” 的设计
所有核心配置(沙箱名、路径、规则)都从驱动获取,而非硬编码在用户态:
优点 1:沙箱规则可动态修改(比如用户在 UI 中改了隔离路径,驱动下发新配置,进程无需重启);
优点 2:所有沙箱进程的规则统一,避免用户态配置不一致导致的隔离漏洞。
2. “分层 Hook” 的逻辑
先装 ObjectHook(基础对象隔离)→ 再装 IpcHook(业务 IPC 隔离);
基础 Hook 保证 “进程看到的内核对象都是沙箱内的”,业务 Hook 基于这个 “干净的视图” 执行具体隔离,避免规则冲突。
3. “严格容错” 的原则
任意一步驱动通信失败(QueryProcessEx/QueryBoxPath),直接 ExitProcess(-1);
沙箱进程必须 “完整初始化”,半初始化的进程会导致隔离规则失效,宁可退出也不冒险。
4. “先查长度再分配内存” 的安全写法
所有从驱动获取的动态数据(IPC 路径、SID 字符串)都遵循这个逻辑;
避免缓冲区溢出(比如驱动返回的 IPC 路径长度是 100 字节,用户态只申请了 50 字节,会导致内存越界)。
四、函数执行完成后的效果
执行完 PrepareInjection 后,当前进程会:
绑定到具体的沙箱(__BoxName),知道自己 “属于哪个隔离环境”;
加载了驱动下发的隔离规则(__DriverFlags/__ProcessFlags);
安装了 ObjectHook/IpcHook,所有对象查询、IPC 操作都被拦截;
进程的所有操作(创建文件、通信、加载 DLL)都会被沙箱管控,限制在沙箱隔离路径内
PrepareInjection 核心要点
核心定位:沙箱隔离规则落地的 “总调度函数”,把驱动的配置转化为用户态的 Hook 动作;
核心流程:
身份绑定:从驱动获取进程的沙箱名 / SID / 会话 ID,明确 “属于哪个沙箱”;
规则获取:获取全局 + 进程专属的隔离规则,确定 “要拦截什么”;
资源准备:申请 IPC 路径缓冲区,确定 “拦截后重定向到哪”;
Hook 安装:先基础对象隔离,再业务 IPC 隔离,完成 “怎么拦截”;
设计原则:
驱动中心化:所有配置 / 规则都从驱动获取,保证统一性;
分层隔离:先基础后业务,避免规则冲突;
严格容错:初始化失败则退出,避免沙箱失控;
内存安全:先查长度再分配,避免缓冲区溢出。
~~
LONG QueryProcessEx(
HANDLE ProcessIdentity,
ULONG NameLength,
WCHAR* BoxName,
WCHAR* ImageName,
WCHAR* SidString,
ULONG* SessionIdentity,
ULONG64* CreateTime)
{
NTSTATUS Status;
__declspec(align(8)) UNICODE_STRING64 v1;
__declspec(align(8)) UNICODE_STRING64 v2;
__declspec(align(8)) UNICODE_STRING64 v3;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
QUERY_PROCESS_ARGS* Arguments =
(QUERY_PROCESS_ARGS*)ParameterData;
ZeroMemory(ParameterData, sizeof(ParameterData));
Arguments->ServiceIndex = QUERY_PROCESS;
Arguments->ProcessIdentity.Value64 =
(ULONG64)(ULONG_PTR)ProcessIdentity;
if (BoxName) {
v1.Length = 0;
v1.MaximumLength = (USHORT)(sizeof(WCHAR) *
BOX_NAME);
v1.Buffer = (ULONG64)(ULONG_PTR)BoxName;
Arguments->BoxName.Value64 =
(ULONG64)(ULONG_PTR)&v1;
}
if (ImageName) {
v2.Length = 0;
v2.MaximumLength =
(USHORT)(sizeof(WCHAR) * NameLength);
v2.Buffer = (ULONG64)(ULONG_PTR)ImageName;
Arguments->ImageName.Value64 =
(ULONG64)(ULONG_PTR)&v2;
}
if (SidString) {
v3.Length = 0;
v3.MaximumLength = (USHORT)(sizeof(WCHAR) * 96);
v3.Buffer = (ULONG64)(ULONG_PTR)SidString;
Arguments->SidString.Value64 =
(ULONG64)(ULONG_PTR)&v3;
}
if (SessionIdentity)
Arguments->SessionIdentity.Value64 =
(ULONG64)(ULONG_PTR)SessionIdentity;
if (CreateTime)
Arguments->CreateTime.Value64 =
(ULONG64)(ULONG_PTR)CreateTime;
Status = IoctlRequest(ParameterData);
if (!NT_SUCCESS(Status)) {
ULONG_PTR x = (ULONG_PTR)SessionIdentity;
if (x == 0 || x > 4) {
//
// reset parameters on error except
when out_session_id
// is a special internal flag in the
range 1 to 4
//
if (BoxName)
*BoxName = L'\0';
if (ImageName)
*ImageName = L'\0';
if (SidString)
*SidString = L'\0';
if (SessionIdentity)
*SessionIdentity = 0;
}
}
return Status;
}
QueryProcessEx 是沙箱 DLL 与内核驱动通信的标准化封装函数—— 它的核心价值是把 “查询进程沙箱信息”
这个业务逻辑,转化为驱动能识别的标准化 IOCTL 请求格式,是用户态向内核态 “精准提问” 的关键接口。
IoctlRequest(底层通信管道)和 PrepareInjection(业务调用方),我会从
“数据封装逻辑”“驱动交互细节”“容错设计” 三个维度拆解,帮你彻底搞懂这个函数是如何 “精准传递参数、安全获取驱动数据” 的。
QueryProcessEx 是 “查询进程沙箱上下文” 的专用接口:它把 “要查哪个进程、要返回哪些数据(箱名 / 镜像名 / SID)” 封装成驱动规定的
64 位对齐参数结构体,通过 IoctlRequest 发送 IOCTL 请求,驱动处理后把结果填充到用户态缓冲区,最终返回通信状态。
简单说:它是用户态给驱动的 “标准化问卷”—— 问卷格式固定(64 位对齐的 ParameterData),问题明确(查进程 X
的沙箱信息),驱动只需按格式填答案即可。
二、核心逻辑拆解(按 “封装→发送→容错” 三步)
步骤 1:初始化标准化参数缓冲区(核心,驱动通信的 “通用语言”)
// 1. 定义64位对齐的UNICODE_STRING(适配32/64位驱动通信)
__declspec(align(8)) UNICODE_STRING64 v1;
__declspec(align(8)) UNICODE_STRING64 v2;
__declspec(align(8)) UNICODE_STRING64 v3;
// 2. 定义固定大小的参数缓冲区(API_NUMBER_ARGS是宏,如32,保证驱动能解析)
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
// 3. 强转为“查询进程参数结构体”,绑定业务字段
QUERY_PROCESS_ARGS* Arguments = (QUERY_PROCESS_ARGS*)ParameterData;
// 4. 清空缓冲区,避免脏数据干扰驱动解析
ZeroMemory(ParameterData, sizeof(ParameterData));
// 5. 标记本次请求类型:QUERY_PROCESS(驱动据此执行“查询进程”逻辑)
Arguments->ServiceIndex = QUERY_PROCESS;
关键设计:64 位对齐(__declspec(align(8)))
这是跨 32/64 位通信的核心 —— 驱动运行在 64 位内核时,32 位用户态进程的内存地址是 32 位,强制 8 字节对齐能保证:
驱动侧解析 ParameterData 时,结构体字段不会因位数差异错位;
避免 “缓冲区溢出”“字段解析错误” 等致命问题。
ServiceIndex 的核心作用:
驱动会处理多种 IOCTL 请求(查进程、查路径、设规则),ServiceIndex = QUERY_PROCESS 相当于
“给请求贴标签”,驱动看到这个标签就知道 “要处理进程查询请求”,无需解析其他字段就能确定逻辑分支。
步骤 2:填充请求参数(告诉驱动 “查什么、返回哪”)
这部分是 “业务参数绑定”—— 把用户态传入的 “进程 ID、输出缓冲区” 映射到驱动能识别的字段:
// 1. 绑定要查询的进程ID(核心输入参数)
Arguments->ProcessIdentity.Value64 = (ULONG64)(ULONG_PTR)ProcessIdentity;
// 2. 绑定“沙箱箱名”的输出缓冲区(驱动填充到BoxName指针)
if (BoxName) {
v1.Length = 0;
// 初始长度0(驱动填充后更新)
v1.MaximumLength = (USHORT)(sizeof(WCHAR) * BOX_NAME); //
缓冲区最大容量
v1.Buffer = (ULONG64)(ULONG_PTR)BoxName; //
缓冲区的64位物理地址
Arguments->BoxName.Value64 = (ULONG64)(ULONG_PTR)&v1; //
告诉驱动缓冲区在哪
}
// 3. 绑定“进程镜像名”的输出缓冲区(逻辑同上)
if (ImageName) {
v2.Length = 0;
v2.MaximumLength = (USHORT)(sizeof(WCHAR) * NameLength);
v2.Buffer = (ULONG64)(ULONG_PTR)ImageName;
Arguments->ImageName.Value64 = (ULONG64)(ULONG_PTR)&v2;
}
// 4. 绑定“进程SID”的输出缓冲区(逻辑同上)
if (SidString) {
v3.Length = 0;
v3.MaximumLength = (USHORT)(sizeof(WCHAR) * 96);
v3.Buffer = (ULONG64)(ULONG_PTR)SidString;
Arguments->SidString.Value64 = (ULONG64)(ULONG_PTR)&v3;
}
// 5. 绑定“会话ID”“创建时间”的输出缓冲区(逻辑同上)
if (SessionIdentity)
Arguments->SessionIdentity.Value64 =
(ULONG64)(ULONG_PTR)SessionIdentity;
if (CreateTime)
Arguments->CreateTime.Value64 = (ULONG64)(ULONG_PTR)CreateTime;
核心逻辑:“输入 + 输出” 复用 ParameterData
输入:ProcessIdentity 是 “要查哪个进程”(驱动的输入参数);
输出:BoxName/ImageName 等是 “驱动要返回的数据存哪”(驱动的输出参数);
优势:无需额外定义输入 / 输出缓冲区,驱动只需解析一个 ParameterData 就能完成 “读请求、写结果”。
UNICODE_STRING64 的意义:
系统原生 UNICODE_STRING 的 Buffer 是 PVOID(32 位系统 4 字节,64 位系统 8 字节),而
UNICODE_STRING64 把 Buffer 固定为 ULONG64,驱动侧无需区分用户态位数,直接按 64 位解析即可,极大降低通信复杂度。
步骤 3:发送 IOCTL 请求,处理驱动返回结果(容错是重点)
// 1. 调用底层IoctlRequest,把ParameterData发给驱动
Status = IoctlRequest(ParameterData);
// 2. 驱动返回错误时,清空输出缓冲区(避免脏数据)
if (!NT_SUCCESS(Status)) {
ULONG_PTR x = (ULONG_PTR)SessionIdentity;
// 特殊判断:SessionIdentity不是1-4的内部标志时才清空
if (x == 0 || x > 4) {
if (BoxName) *BoxName = L'\0'; //
清空箱名缓冲区首字符
if (ImageName)*ImageName = L'\0';// 清空镜像名缓冲区首字符
if (SidString)*SidString = L'\0';// 清空SID缓冲区首字符
if (SessionIdentity) *SessionIdentity = 0; //
会话ID置0
}
}
// 3. 返回驱动通信结果(NTSTATUS码)
return Status;
容错设计的核心目的:
驱动通信失败时(比如驱动未加载、进程无沙箱配置),输出缓冲区里的内容是 “未初始化的脏数据”(随机内存值),如果上层函数(如
PrepareInjection)读取这些脏数据,会导致:
把随机字符当成沙箱名,触发错误的隔离规则;
内存访问越界,导致进程崩溃;
因此主动清空缓冲区首字符(设为 L'\0',字符串结束符),保证上层函数读取时只会拿到 “空字符串”,而非脏数据。
特殊判断 x == 0 || x > 4 的原因:
1-4 是沙箱内部的 “特殊会话 ID 标志”(比如 1 代表 “沙箱主机进程会话”),这些标志不是真正的输出缓冲区,清空会导致内部逻辑异常,因此跳过。
三、驱动侧如何解析这个请求(补充关键上下文)
为了让你更清楚 QueryProcessEx 的作用,这里给出驱动侧解析 ParameterData 的核心伪代码:
// 驱动侧IOCTL处理函数(对应IO_CTL_CODE_1)
NTSTATUS HandleIoctlRequest(ULONG64* ParameterData) {
QUERY_PROCESS_ARGS* Args =
(QUERY_PROCESS_ARGS*)ParameterData;
// 1. 识别请求类型:是否是QUERY_PROCESS
if (Args->ServiceIndex != QUERY_PROCESS) {
return STATUS_INVALID_PARAMETER;
}
// 2. 解析要查询的进程ID
ULONG ProcessId = (ULONG)Args->ProcessIdentity.Value64;
// 3. 查找该进程的沙箱配置(驱动内维护的进程沙箱表)
PROCESS_SANDBOX_INFO* ProcInfo =
FindProcessSandboxInfo(ProcessId);
if (ProcInfo == NULL) {
return STATUS_NOT_FOUND;
}
// 4. 填充沙箱箱名到用户态缓冲区
if (Args->BoxName.Value64 != 0) {
UNICODE_STRING64* BoxNameStr =
(UNICODE_STRING64*)Args->BoxName.Value64;
// 驱动把箱名(如"DefaultBox")写入用户态BoxName缓冲区
RtlCopyUnicodeStringToUser(BoxNameStr,
&ProcInfo->BoxName);
}
// 5. 同理,填充镜像名、SID、会话ID到对应缓冲区
// ...
// 6. 标记进程:沙箱DLL已加载
ProcInfo->sbiedll_loaded = TRUE;
return STATUS_SUCCESS;
}
驱动侧的解析逻辑完全依赖 QueryProcessEx 封装的 ParameterData 格式;
只要格式不对(比如未 64 位对齐、ServiceIndex 错误),驱动直接返回错误,这也是 QueryProcessEx 严格封装参数的原因。
QueryProcessEx 核心要点
核心定位:“查询进程沙箱信息” 的标准化接口,是用户态与驱动通信的 “业务层封装”;
核心逻辑:
封装 64 位对齐的参数结构体,标记请求类型为 QUERY_PROCESS;
绑定 “要查的进程 ID” 和 “要返回的输出缓冲区”;
调用 IoctlRequest 发送请求,驱动失败时清空缓冲区避免脏数据;
设计原则:
兼容性:64 位统一格式,适配 32/64 位系统;
安全性:容错处理清空脏数据,避免进程崩溃 / 逻辑异常;
扩展性:通过 ServiceIndex 标记请求类型,易新增功能。
这个函数是 “业务逻辑” 与 “底层通信” 的桥梁 —— 它屏蔽了驱动通信的复杂细节(如 IOCTL 格式、内存对齐),让上层函数(如
PrepareInjection)只需关注 “查什么、要什么结果”,无需关心 “怎么传给驱动”,是沙箱代码 “分层设计” 的典型体现。
LONG QueryDriverInfo(ULONG InfoClass, VOID* BufferData, ULONG DataLength)
{
NTSTATUS Status;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
memset(ParameterData, 0, sizeof(ParameterData));
ParameterData[0] = QUERY_DRIVER_INFO;
ParameterData[1] = InfoClass;
ParameterData[2] = (ULONG64)(ULONG_PTR)BufferData;
ParameterData[3] = DataLength;
Status = IoctlRequest(ParameterData);
return Status;
}
与 QueryProcessEx 的核心差异(对比理解)
为了让你更清楚不同场景的封装策略,这里对比两个函数的设计差异:
| 维度 | QueryProcessEx | QueryDriverInfo |
|---|---|---|
| 适用场景 | 查询进程的复杂沙箱上下文(多字符串、多字段) | 查询驱动的极简全局配置(单一缓冲区、少量参数) |
| 参数封装方式 | 结构体(QUERY_PROCESS_ARGS)+ 字段映射 | 固定位置数组 + 直接赋值 |
| 复杂类型处理 | 封装 UNICODE_STRING64 处理字符串缓冲区 | 无复杂类型,仅传递缓冲区地址 + 长度 |
| 容错处理 | 驱动失败时清空输出缓冲区(避免脏字符串) | 无额外容错(缓冲区是数值型,脏数据影响小) |
| 扩展性 | 强(新增字段只需扩展结构体) | 弱(新增参数需占用新的数组下标) |
这个函数是沙箱 “分层封装” 的典型体现 ——复杂请求用结构体封装(QueryProcessEx),简单请求用数组封装(QueryDriverInfo)
ULONG64 QueryProcessInfoEx(
HANDLE ProcessIdentity,
ULONG InfoType,
ULONG64 BufferData)
{
NTSTATUS Status;
__declspec(align(8)) ULONG64 ResultValue;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
API_QUERY_PROCESS_INFO_ARGS* Arguments =
(API_QUERY_PROCESS_INFO_ARGS*)ParameterData;
ZeroMemory(ParameterData, sizeof(ParameterData));
Arguments->ServiceIndex = QUERY_PROCESS_INFO;
Arguments->ProcessIdentity.Value64 =
(ULONG64)(ULONG_PTR)ProcessIdentity;
Arguments->InfoType.Value64 = (ULONG64)(ULONG_PTR)InfoType;
Arguments->ResultValue.Value64 =
(ULONG64)(ULONG_PTR)&ResultValue;
Arguments->BufferData.Value64 = (ULONG64)(ULONG_PTR)BufferData;
Status = IoctlRequest(ParameterData);
if (!NT_SUCCESS(Status))
ResultValue = 0;
return ResultValue;
}
QueryProcessInfoEx 是沙箱 DLL 向驱动查询进程级个性化配置的专用封装函数 —— 它的核心设计是
“以返回值为主、缓冲区为辅”,专门获取进程的数值型隔离标志(如 __ProcessFlags),是用户态获取 “当前进程该遵守哪些个性化隔离规则”
的关键接口。
QueryProcessInfoEx 的核心作用是:
封装 “进程 ID、查询类型、结果变量地址、辅助缓冲区” 到标准化结构体,标记请求类型为 QUERY_PROCESS_INFO;
调用 IoctlRequest 向驱动发送请求,驱动处理后把 “进程级数值型配置” 写入 ResultValue;
通信失败时返回 0,成功则返回驱动填充的 ResultValue(如进程隔离标志)。
简单说:这是用户态向驱动 “查进程数值型规则” 的接口 ——“请返回进程 X 的 XX 类数值配置”,驱动直接把结果写入指定的数值变量,而非字符串缓冲区
ULONG64 QueryProcessInfoEx(
HANDLE ProcessIdentity,
ULONG InfoType,
ULONG64 BufferData)
{
NTSTATUS Status;
// 1. 定义64位对齐的结果变量(驱动直接写入数值,作为函数返回值)
__declspec(align(8)) ULONG64 ResultValue;
// 2. 定义64位对齐的参数缓冲区(与QueryProcessEx一致的固定大小)
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
// 3. 强转为进程信息查询结构体,绑定业务字段
API_QUERY_PROCESS_INFO_ARGS* Arguments =
(API_QUERY_PROCESS_INFO_ARGS*)ParameterData;
// 4. 清空缓冲区,避免脏数据
ZeroMemory(ParameterData, sizeof(ParameterData));
// 5. 标记请求类型:QUERY_PROCESS_INFO(驱动据此处理进程数值配置查询)
Arguments->ServiceIndex = QUERY_PROCESS_INFO;
// 6. 填充核心参数(全部为数值/地址型,无字符串)
Arguments->ProcessIdentity.Value64 =
(ULONG64)(ULONG_PTR)ProcessIdentity; // 要查的进程ID
Arguments->InfoType.Value64 = (ULONG64)(ULONG_PTR)InfoType;
// 查询类型(如查进程隔离标志)
Arguments->ResultValue.Value64 =
(ULONG64)(ULONG_PTR)&ResultValue; // 驱动写入结果的变量地址
Arguments->BufferData.Value64 =
(ULONG64)(ULONG_PTR)BufferData; //
辅助缓冲区(可选,本次调用传0)
// 7. 发送IOCTL请求到驱动
Status = IoctlRequest(ParameterData);
// 8. 容错处理:通信失败则结果置0
if (!NT_SUCCESS(Status))
ResultValue = 0;
// 9. 返回驱动填充的数值结果(核心:以返回值为主)
return ResultValue;
}
为了帮你梳理沙箱驱动通信的封装策略,这里对比三个函数的设计差异:
| 维度 | QueryProcessEx | QueryDriverInfo | QueryProcessInfoEx |
|---|---|---|---|
| 核心返回类型 | 多字段字符串 / 数值(箱名、SID、会话 ID) | 缓冲区数值(驱动全局标志) | 单一 64 位数值(进程个性化标志) |
| 参数封装方式 | 结构体 + UNICODE_STRING64(处理字符串) | 固定位置数组(极简参数) | 结构体 + 数值地址(处理返回值) |
| 结果传递方式 | 驱动填充用户态缓冲区(函数返回状态) | 驱动填充用户态缓冲区(函数返回状态) | 驱动写入指定变量(函数返回该变量值) |
| 容错处理 | 清空字符串缓冲区(避免脏字符) | 无额外容错(数值脏数据影响小) | 失败时返回 0(数值型容错极简) |
| 适用场景 | 查进程复杂上下文(多字段、字符串) | 查驱动全局数值配置(单缓冲区) | 查进程单一数值配置(返回值优先) |
字符串多字段用 QueryProcessEx,驱动全局数值用 QueryDriverInfo,进程单一数值用 QueryProcessInfoEx
LONG EnumProcessEx(
const WCHAR* BoxName, // WCHAR
[BOXNAME_COUNT]
BOOLEAN IsAllSessions,
ULONG WhichSession, // -1
for current session
ULONG* ProcessIdentitys,
// ULONG [512]
ULONG* Count)
{
NTSTATUS Status;
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
memset(ParameterData, 0, sizeof(ParameterData));
ParameterData[0] = ENUM_PROCESSES;
ParameterData[1] = (ULONG64)(ULONG_PTR)ProcessIdentitys;
ParameterData[2] = (ULONG64)(ULONG_PTR)BoxName;
ParameterData[3] = (ULONG64)(ULONG_PTR)IsAllSessions;
ParameterData[4] = (ULONG64)(LONG_PTR)WhichSession;
ParameterData[5] = (ULONG64)(LONG_PTR)Count;
Status = IoctlRequest(ParameterData);
if (!NT_SUCCESS(Status))
ProcessIdentitys[0] = 0;
return Status;
}
EnumProcessEx 是沙箱 DLL 向驱动查询指定沙箱内所有进程 ID 的封装函数 —— 它采用极简的 “固定位置数组” 封装参数(类似
QueryDriverInfo),专门用于枚举沙箱内的进程列表,是沙箱判断 “当前是否为首个进程”“沙箱内有多少进程运行” 的核心接口
一、函数核心功能总结
EnumProcessEx 的核心作用是:
用固定位置数组封装 “沙箱名、会话筛选条件、进程 ID 数组缓冲区、计数指针”,标记请求类型为 ENUM_PROCESSES;
调用 IoctlRequest 向驱动发送请求,驱动枚举指定沙箱 / 会话内的所有进程 ID,写入用户态数组缓冲区;
驱动更新 Count 指针为实际枚举到的进程数量;
通信失败时清空数组首个元素(避免脏数据),返回通信状态。
简单说:这是用户态向驱动 “查沙箱进程列表” 的接口 ——“请返回 XX 沙箱 / XX 会话内的所有进程 ID”,驱动把进程 ID
批量写入数组,是沙箱进程管理的基础。
LONG EnumProcessEx(
const WCHAR* BoxName, //
要枚举的沙箱名(NULL=当前沙箱)
BOOLEAN IsAllSessions, //
是否枚举所有会话的进程(TRUE=所有,FALSE=指定会话)
ULONG WhichSession, //
指定会话ID(-1=当前会话)
ULONG* ProcessIdentitys, //
输出:进程ID数组缓冲区(最大512个ULONG)
ULONG* Count)
// 输出:实际枚举到的进程数量
{
NTSTATUS Status;
// 1. 64位对齐的固定大小参数缓冲区(与其他查询函数一致)
__declspec(align(8)) ULONG64 ParameterData[API_NUMBER_ARGS];
// 2. 清空缓冲区,避免脏数据
memset(ParameterData, 0, sizeof(ParameterData));
// 3. 固定位置填充参数(枚举类请求的极简封装)
ParameterData[0] = ENUM_PROCESSES;
// 位置0:请求类型(枚举进程)
ParameterData[1] = (ULONG64)(ULONG_PTR)ProcessIdentitys; //
位置1:进程ID数组缓冲区地址
ParameterData[2] = (ULONG64)(ULONG_PTR)BoxName;
// 位置2:要枚举的沙箱名
ParameterData[3] = (ULONG64)(ULONG_PTR)IsAllSessions; //
位置3:是否枚举所有会话
ParameterData[4] = (ULONG64)(LONG_PTR)WhichSession; //
位置4:指定会话ID(-1需转LONG_PTR)
ParameterData[5] = (ULONG64)(LONG_PTR)Count;
// 位置5:计数指针(返回实际进程数)
// 4. 发送IOCTL请求到驱动
Status = IoctlRequest(ParameterData);
// 5. 容错处理:通信失败时清空数组首个元素
if (!NT_SUCCESS(Status))
ProcessIdentitys[0] = 0; // 避免脏进程ID导致逻辑错误
// 6. 返回通信结果
return Status;
}
关键细节:LONG_PTR 处理负数
WhichSession 支持传入 -1(表示当前会话),而 ULONG 是无符号整数,直接转 ULONG64 会导致 -1 变成
0xFFFFFFFF(错误值),因此用 (LONG_PTR)WhichSession 先转为有符号长指针,再转
ULONG64,保证负数正确传递到驱动侧。
EnumProcessEx 核心要点
核心定位:枚举沙箱内进程 ID 的专用接口,采用极简的固定位置数组封装,适配 “数组型批量结果” 的查询场景;
核心逻辑:
固定位置封装沙箱名、会话筛选条件、进程 ID 数组、计数指针,标记请求类型为 ENUM_PROCESSES;
驱动枚举符合条件的进程 ID,批量写入数组并更新计数;
通信失败清空数组首个元素,返回通信状态;
设计原则:
极简:数组封装适配批量数值结果,解析效率高;
灵活:支持沙箱名 + 会话双重筛选,适配多场景;
安全:容错仅清空首个元素,兼顾性能与逻辑正确性。
这个函数是沙箱 “进程管理” 的核心 —— 从判断首个进程、统计沙箱内进程数量,到结束沙箱内所有进程,都依赖 EnumProcessEx
从驱动获取的进程列表,它的设计体现了沙箱 “不同场景用不同封装策略” 的核心思想:复杂字符串用结构体,单一数值用返回值,批量数值用数组。
查询指定沙箱内所有进程 ID ,一个沙箱里面可以放很多个进程吗?
一个沙箱里完全可以容纳多个进程,这也是沙箱实现 “隔离一组相关进程”
的核心能力(比如你打开浏览器,沙箱会把浏览器主进程、渲染进程、插件进程都纳入同一个沙箱,统一管控)
沙箱的本质是 “隔离环境”,而非 “单进程容器”。实际使用中,一个沙箱通常会包含:
你主动启动的主进程(如 notepad.exe);
主进程衍生的子进程(如记事本调用的 cmd.exe);
同属一个应用的多进程(如 Chrome 的多渲染进程)。
这些进程共享同一个沙箱的隔离规则(如相同的文件 / IPC / 注册表重定向路径),沙箱会通过 EnumProcessEx 这类函数统一管理它们。
二、沙箱如何管理 “一个沙箱内的多个进程”
结合你分析的代码,沙箱通过以下逻辑实现多进程管理:
1. 进程归属标记(驱动侧核心)
驱动会维护一张 沙箱进程表(如伪代码中的 g_SandboxProcessTable),每个进程被注入沙箱 DLL 后,驱动会记录:
进程 ID(ProcessId);
所属沙箱名(BoxName,如 DefaultBox);
会话 ID(SessionId);
沙箱 DLL 加载状态(sbiedll_loaded)。
无论进程是主动启动还是衍生的,只要属于同一个沙箱名,就会被归为 “同一沙箱内的进程”。
2. EnumProcessEx 的枚举逻辑(用户态调用)
你分析的 EnumProcessEx 就是用来读取这张表的:
传入 BoxName=DefaultBox → 驱动返回该沙箱下所有进程 ID;
传入 BoxName=NULL → 驱动返回当前进程所属沙箱的所有进程 ID;
配合 IsAllSessions/WhichSession 还能筛选不同会话的进程。
3. 首个进程的特殊作用(PrepareInjection 中的逻辑)
if (NT_SUCCESS(EnumProcessEx(NULL, FALSE, -1, NULL, &Count)) && Count == 1)
{
__FirstProcessInBox = TRUE; // 标记为首个进程
}
当 Count=1 时,说明沙箱内只有当前进程(首个进程);
首个进程需要初始化沙箱核心资源(如创建全局 IPC 事件、初始化隔离路径);
后续加入沙箱的进程(Count>1)无需重复初始化,直接复用已有资源,提升效率。
三、一个沙箱包含多进程的典型场景
应用多进程架构:Chrome/Edge 浏览器的 “主进程 + 渲染进程 + GPU 进程”,会被纳入同一个沙箱,统一限制网络 / 文件访问;
进程衍生:沙箱内的 cmd.exe 执行 notepad.exe,新的 notepad.exe 会自动加入当前沙箱;
批量操作:沙箱管理工具通过 EnumProcessEx 枚举某沙箱的所有进程,一键结束该沙箱的全部进程,实现 “沙箱退出”。
四、补充:沙箱多进程隔离的关键保证
为了确保 “一个沙箱内的进程只能互相通信,无法访问沙箱外进程”,沙箱会通过:
ObjectHook:隐藏沙箱外的内核对象(如进程句柄、IPC 对象);
IpcHook:把沙箱内进程的 IPC 操作重定向到专属路径(如 \\SbieBox\\DefaultBox\\IPC\\);
驱动侧的进程表校验:禁止沙箱内进程打开沙箱外进程的句柄。
总结
核心结论:一个沙箱可以包含多个进程,这是沙箱的核心能力,而非限制;
管理方式:驱动通过 “沙箱进程表” 标记进程归属,EnumProcessEx 负责枚举同一沙箱的所有进程 ID;
关键逻辑:首个进程初始化沙箱资源,后续进程复用资源,保证沙箱内多进程的统一管控;
隔离保证:通过 Hook 和驱动校验,确保沙箱内多进程只能在隔离环境内交互。
简单来说,沙箱就像一个 “隔离的房间”,可以放进多个 “进程家具”,这些家具只能在房间内互动,无法接触房间外的东西,而 EnumProcessEx 就是用来清点
“房间里有哪些家具” 的工具
33-2
自己调用Ntdll.dll 41;00 看33-3
#ifdef _WIN64
#pragma comment(lib,"..\\lib\\x64\\ntdll.lib")
#else
#pragma comment(lib,"..\\lib\\x86\\ntdll.lib")
#endif // _WIN64
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllimport) NTSTATUS __stdcall
NtQueryObject(
IN HANDLE ObjectHandle OPTIONAL,
IN OBJECT_INFORMATION_CLASS ObjectInformationClass,
OUT PVOID ObjectInformation,
IN ULONG Length,
OUT PULONG ResultLength);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void InitializeModuleHook()
{
InitializeCriticalSection(&__CriticalSection);
InitializeList(&__ModuleHooks);
//Dll_HookTrace = SbieApi_QueryConfBool(NULL, L"HookTrace",
FALSE);
#ifdef _M_ARM64EC
#endif
}
InitializeModuleHook 是沙箱 DLL 中模块 Hook 系统的初始化入口—— 它的核心作用是为后续 “Hook
进程加载的模块(DLL/EXE)” 搭建基础的线程安全和数据存储框架,是沙箱实现 “模块级隔离(如禁止加载外部恶意 DLL)” 的前置准备
InitializeModuleHook 的核心目标是:
初始化临界区(__CriticalSection),保证多线程操作模块 Hook 列表时的线程安全;
初始化模块 Hook 链表(__ModuleHooks),用于存储 “需要 Hook 的模块名、Hook 函数地址、原生函数地址” 等关键信息;
预留架构兼容代码(_M_ARM64EC),适配 ARM64EC 架构的模块 Hook 逻辑;
注释掉的 HookTrace 配置查询,是沙箱调试 / 日志功能的预留(开启后记录模块 Hook 过程)。
简单说:这个函数是沙箱 “模块 Hook 系统” 的 “地基”—— 先搭好 “线程安全的存储容器”,后续才能往里面加 Hook 规则、执行模块 Hook 操作。
1. 初始化临界区:InitializeCriticalSection(&__CriticalSection)
核心作用:保证多线程操作 __ModuleHooks 链表时的线程安全。
进程加载模块(如 LoadLibrary)是可能多线程并发的(比如主线程加载 user32.dll,子线程加载 kernel32.dll),如果多个线程同时修改
__ModuleHooks 链表(如添加 Hook 规则、删除 Hook 规则),会导致链表损坏、内存越界甚至进程崩溃。
__CriticalSection:是 Windows 临界区对象(用户态轻量级锁),沙箱在:
添加模块 Hook 规则时(EnterCriticalSection);
移除模块 Hook 规则时;
拦截模块加载时;
都会先进入临界区,操作完成后 LeaveCriticalSection,避免并发冲突。
对比驱动锁:临界区是用户态锁,比内核态互斥体(KMUTEX)更轻量,适合高频的模块 Hook 操作。
2. 初始化模块 Hook 链表:InitializeList(&__ModuleHooks)
__ModuleHooks:是沙箱自定义的双向链表(或 Windows 原生 LIST_ENTRY),每个节点存储一个 “模块 Hook
规则”,典型结构如下(伪代码):
typedef struct _MODULE_HOOK_ENTRY {
LIST_ENTRY ListEntry; //
链表节点(用于挂载到__ModuleHooks)
WCHAR ModuleName[256]; //
要Hook的模块名(如 L"kernel32.dll")
PVOID TargetFunction; //
要Hook的函数地址(如 LoadLibraryW)
PVOID HookFunction; //
沙箱的Hook函数地址(拦截LoadLibraryW)
PVOID OriginalFunction; //
模块函数的原生地址(绕过Hook时调用)
BOOLEAN IsHooked;
// 该模块是否已完成Hook
} MODULE_HOOK_ENTRY, *PMODULE_HOOK_ENTRY;
InitializeList:是沙箱封装的链表初始化函数,作用是:
把 __ModuleHooks 的头节点置为空(Head.Flink = Head.Blink = &__ModuleHooks);
初始化链表计数为 0,标记 “暂无 Hook 规则”。
链表的核心价值:
沙箱需要 Hook
大量系统模块(kernel32.dll/ntdll.dll/user32.dll)的核心函数(CreateFileW/LoadLibraryW/CreateEventW),链表可以灵活添加
/ 删除 / 遍历 Hook 规则,比固定数组更适配 “动态配置 Hook 规则” 的需求(比如用户在沙箱 UI 中临时关闭 “DLL 加载 Hook”)。
三、模块 Hook 在沙箱中的实际价值(为什么要初始化这个系统)
沙箱的核心隔离能力(文件 / IPC / 注册表隔离),最终都依赖 “模块 Hook” 落地:
禁止加载恶意 DLL:Hook LoadLibraryW/LdrLoadDll,检查要加载的 DLL 路径,禁止加载沙箱外的恶意 DLL;
文件路径重定向:Hook kernel32!CreateFileW,把沙箱内进程的文件操作重定向到沙箱专属路径(如
C:\Sandbox\DefaultBox\Files\);
模块加载日志:Hook LdrLoadDll,记录进程加载的所有模块,用于沙箱的 “模块白名单” 校验;
防止模块注入:Hook CreateRemoteThread,禁止沙箱外进程向沙箱内进程注入 DLL / 代码。
~~~
void* ModuleHook(
const char* SeviceName, void* ServiceAddress, void*
DetourAddress, HMODULE Module)
{
/*
0:000> u 00000000`77bf13b0
ntdll!NtQueryObject:
00000000`77bf13b0 4c8bd1 mov
r10,rcx
00000000`77bf13b3 b80d000000 mov eax,0Dh
00000000`77bf13b8 0f05 syscall
00000000`77bf13ba c3 ret
00000000`77bf13bb 0f1f440000 nop dword
ptr [rax+rax]
*/
DWORD HookStats = 0;
void* v5 = NULL;
PDWORD64 v10 = NULL;
if (0)
{
goto Exit;
}
if (0)
{
}
v5 = InstallModuleHook(SeviceName, ServiceAddress, DetourAddress,
Module, &HookStats);
if (v10) {
*v10 = (DWORD64)v5;
v5 = NULL;
}
Exit:
return v5;
}
ModuleHook 是沙箱 DLL 中模块函数 Hook 的封装入口—— 它的核心是调用底层 InstallModuleHook 完成具体的函数
Hook 操作
一、函数核心功能总结
ModuleHook 的核心作用是:
作为上层调用的 “简洁入口”,接收 “Hook 名称、目标函数地址、Hook 函数地址、目标模块” 四个核心参数;
调用底层 InstallModuleHook 完成实际的函数 Hook(如修改函数开头的汇编指令,跳转到沙箱的 Hook 函数);
预留调试 / 分支逻辑的框架(if(0) 分支),方便调试版本扩展;
返回原生函数地址(供沙箱需要 “绕过 Hook 执行原生逻辑” 时调用)。
简单说:这个函数是沙箱 “Hook 单个模块函数” 的标准化接口 —— 上层只需传入 “要 Hook 谁、Hook
成谁、在哪个模块”,底层就会完成函数跳转的修改,是沙箱拦截系统调用(如 NtQueryObject)的关键。
ModuleHook 核心要点
核心定位:沙箱 “单个模块函数 Hook” 的标准化封装入口,适配 ObjectHook/IpcHook 等隔离逻辑的函数拦截需求;
核心逻辑:
接收 Hook 的核心参数,调用底层 InstallModuleHook 完成内联 Hook;
预留调试 / 扩展分支,保证代码的可维护性;
返回原生函数地址,供沙箱绕过 Hook 执行原生逻辑;
设计原则:
简洁性:上层调用只需传入关键参数,屏蔽底层 Hook 细节;
调试性:预留分支和汇编注释,方便开发调试;
安全性:底层 Hook 校验参数、恢复内存保护,避免错误 Hook。
ModuleHook 是沙箱 “拦截系统函数调用” 的 “开关”—— 沙箱通过它把核心系统函数(如
NtQueryObject/CreateEvent)的调用导向自己的 Hook 函数,从而实现 “检查操作、重定向路径、限制权限”
等隔离逻辑,是沙箱核心能力落地的关键接口。
~~~
void* InstallModuleHookInternal(
const char* SeviceName, void* ServiceAddress, void*
DetourAddress, HMODULE Module)
{
UCHAR* Trampoline, * v1 = NULL;
void* RegionBase;
SIZE_T RegionSize;
ULONG OldProtect;
ULONG_PTR Offset;
ULONG_PTR TargetAddress;
#ifdef _WIN64
long long Delta;
BOOLEAN CallInstruction64 = FALSE;
#endif _WIN64
if (!ServiceAddress) {
return NULL;
}
if (*(UCHAR*)ServiceAddress == 0xEB) { // jmp xx;
signed char Offset = *((signed
char*)ServiceAddress + 1); //获取偏移
ServiceAddress = (UCHAR*)ServiceAddress + Offset +
2; //获取目的地
}
while (*(UCHAR*)ServiceAddress == 0xE9) { // jmp xx xx xx xx;
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 1);
TargetAddress = (ULONG_PTR)ServiceAddress + Offset
+ 5;
if (TargetAddress ==
(ULONG_PTR)DetourAddress)
{
return NULL;
}
#ifdef _WIN64
ServiceAddress = (void*)TargetAddress;
#else ! WIN_64
#endif _WIN64
}
#ifdef _WIN64
if (*(USHORT*)ServiceAddress == 0xE990) { // nop; jmp xx xx xx
xx;
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 2);
TargetAddress = (ULONG_PTR)ServiceAddress + Offset
+ 6;
if (*(USHORT*)TargetAddress == 0x25FF) // jmp
QWORD PTR [rip+xx xx xx xx];
ServiceAddress =
(void*)TargetAddress;
}
#if 0
#endif
#endif _WIN64
//申请Hook信息内存
MODULE_HOOK* ModuleHook = GetModuleHookInfo(Module, POOL_TAG |
0xFF); // 0xFF - executable
if (!ModuleHook) {
goto Exit;
}
#ifdef _WIN64
if (*(USHORT*)ServiceAddress == 0x15FF) { // call QWORD PTR
[rip+xx xx xx xx];
UCHAR* NewDetour =
(UCHAR*)MemoryPoolAllocateEx(ModuleHook->Pool, 128);
if (!NewDetour) {
goto Exit;
}
NewDetour[0] = 0x58; //
pop rax
NewDetour[1] = 0x48; //
mov rax, DetourFunc
NewDetour[2] = 0xB8;
*(ULONG_PTR*)(&NewDetour[3]) =
(ULONG_PTR)DetourAddress;
NewDetour[11] = 0xFF; // jmp
rax
NewDetour[12] = 0xE0;
DetourAddress = NewDetour;
NewDetour[16] = 0x48; // mov
rax, ServiceAddress+6
NewDetour[17] = 0xB8;
*(ULONG_PTR*)(&NewDetour[18]) =
(ULONG_PTR)ServiceAddress + 6;
NewDetour[26] = 0x50; // push
rax
NewDetour[27] = 0x48; // mov
rax, trampoline code
NewDetour[28] = 0xB8;
*(ULONG_PTR*)(&NewDetour[29]) = 0;
NewDetour[37] = 0xFF; // jmp
rax
NewDetour[38] = 0xE0;
CallInstruction64 = TRUE;
//
// overwrite the code at the target of the call
instruction
//
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 2);
TargetAddress = (ULONG_PTR)ServiceAddress + 6 +
Offset;
ServiceAddress =
(void*)*(ULONG_PTR*)TargetAddress;
}
#endif _WIN64
Trampoline = (UCHAR*)MemoryPoolAllocateEx(ModuleHook->Pool,
128);
if (!Trampoline /*|| !VirtualProtect(tramp, 128,
PAGE_EXECUTE_READWRITE, &dummy_prot)*/) {
goto Exit;
}
if (CreateModuleHookTrampoline(ServiceAddress, Trampoline) != 0)
{
goto Exit;
}
v1 = (UCHAR*)ServiceAddress;
RegionBase = &v1[-8]; // -8 for hotpatch area if present
RegionSize = 20;
if (!VirtualProtect(RegionBase, RegionSize,
PAGE_EXECUTE_READWRITE, &OldProtect)) {
//
// on windows 7 hooking NdrClientCall2 in 32bit
(WoW64) mode fails
// because the memory area starts at -6 and not -8
// this area could be a hot patch reagion which we
don't use
// hence if that fails just start at the exact
offset and try again
//
RegionBase = &v1[0];
RegionSize = 12;
if (!VirtualProtect(RegionBase, RegionSize,
PAGE_EXECUTE_READWRITE, &OldProtect)) {
ULONG LastError = GetLastError();
v1 = NULL;
goto Exit;
}
}
#ifdef _WIN64
if (__Windows >= 10)
{
TargetAddress = (ULONG_PTR)&v1[6];
}
else {
TargetAddress = (ULONG_PTR)&v1[5];
}
Offset = (ULONG_PTR)((ULONG_PTR)DetourAddress - TargetAddress);
Delta = Offset;
Delta < 0 ? Delta *= -1 : Delta;
//is DetourFunc in 32bit jump range
if (Delta < 0x80000000) {
/*
sprintf(buffer,"32 bit Hook: %s\n",SourceFuncName);
OutputDebugStringA(buffer);
*/
if (__Windows >= 10) {
v1[0] = 0x48;
// 32bit relative rex.W JMP DetourFunc
v1[1] = 0xE9;
*(ULONG*)(&v1[2]) =
(ULONG)Offset;
//UsedCount = 1 + 1 + 4;
}
else {
v1[0] = 0xE9;
// 32bit relative JMP DetourFunc
*(ULONG*)(&v1[1]) =
(ULONG)Offset;
//UsedCount = 1 + 4;
}
}
//is DetourFunc in 64bit jump range
/*else if (1) {
func[0] = 0x48;
func[1] = 0xb8;
*(ULONG_PTR *)&func[2] =
(ULONG_PTR)DetourFunc;
func[10] = 0xff;
func[11] = 0xe0;
}*/
else {
TargetAddress = (ULONG_PTR)&v1[6];
/*
0:000> u 00000000`77bf13b6
ntdll!NtQueryObject+0x6:
00000000`77bf13b6 0000 add
byte ptr [rax],al
00000000`77bf13b8 0f05 syscall
*/
VECTOR_TABLE* VTable = GetHookTable(ModuleHook,
TargetAddress, 0x80000000, TRUE);
if (!VTable) {
// OutputDebugStringA("Memory alloc
failed: 12 Byte Patch Disabled\n");
v1 = NULL;
goto Exit;
}
Offset = (ULONG_PTR) &
((ULONG_PTR*)VTable->Offset)[VTable->Index];
Offset = Offset - TargetAddress;
((ULONG_PTR*)VTable->Offset)[VTable->Index] =
(ULONG_PTR)DetourAddress;
*(USHORT*)&v1[0] = 0x25ff;
// jmp QWORD PTR [rip+diff];
*(ULONG*)&v1[2] = (ULONG)Offset;
//UsedCount = 2 + 4;
VTable->Index++;
}
#else
Offset = (UCHAR*)DetourAddress - (v + 5);
v1[0] = 0xE9; // JMP
DetourFunc
*(ULONG*)(&v1[1]) = (ULONG)Offset;
//UsedCount = 1 + 4;
#endif
VirtualProtect(RegionBase, RegionSize,OldProtect, &OldProtect);
// the trampoline code begins at trampoline + 16 bytes
v1 = (UCHAR*)(ULONG_PTR)(Trampoline + 16);
/*
0:000> u 000007ff`fff80218
000007ff`fff80218 4c8bd1 mov
r10,rcx
000007ff`fff8021b b80d000000 mov eax,0Dh
000007ff`fff80220 0f05 syscall
000007ff`fff80222 c3 ret
*/
#ifdef _WIN64
if (CallInstruction64) {
UCHAR* NewDetour = (UCHAR*)DetourAddress;
*(ULONG_PTR*)(&NewDetour[29]) =
(ULONG_PTR)v1; //跳回源函数之后指令
v1 = NewDetour + 16;
}
#endif _WIN64
Exit:
LeaveCriticalSection(&__CriticalSection);
return v1;
}
InstallModuleHookInternal 是沙箱 DLL 中内联 Hook 的底层核心实现
一、函数核心目标(一句话讲透)
InstallModuleHookInternal 的核心是:
先解析目标函数的现有指令(如是否已有 jmp 跳转、是否是 call 指令),找到真正的函数入口;
申请可执行内存的 “跳板(Trampoline)”,保存目标函数的原生开头指令;
适配 Win7/Win10、x86/x64 架构,修改目标函数开头的指令为 “跳转到沙箱 Hook 函数”;
处理 64 位长跳转(超过 32 位偏移)的特殊场景,保证 Hook 覆盖所有函数;
返回跳板地址(供沙箱调用原生函数),完成整个内联 Hook 流程。
简单说:这是沙箱的 “指令修改大师”—— 不管目标函数是 x86/x64、Win7/Win10,不管跳转偏移是 32 位 / 64
位,它都能精准修改指令,让函数调用导向沙箱的 Hook 逻辑。
二、核心逻辑拆解(按 “预处理→申请资源→指令修改→收尾” 四步)
步骤 1:预处理(解析目标函数,找到真实入口)
这一步是为了避免 “Hook 到跳转指令的中间地址”,保证 Hook 的是函数的真实入口:
// 1. 校验目标函数地址非空
if (!ServiceAddress) { return NULL; }
// 2. 处理短跳转(0xEB:jmp xx,1字节指令+1字节偏移)
if (*(UCHAR*)ServiceAddress == 0xEB) {
signed char Offset = *((signed char*)ServiceAddress + 1);
// 获取1字节偏移
ServiceAddress = (UCHAR*)ServiceAddress + Offset + 2;
// 计算真实入口(偏移+指令长度)
}
// 3. 处理长跳转(0xE9:jmp xxxx,1字节指令+4字节偏移),循环解析直到找到真实入口
while (*(UCHAR*)ServiceAddress == 0xE9) {
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 1); // 获取4字节偏移
TargetAddress = (ULONG_PTR)ServiceAddress + Offset + 5; //
计算真实入口(偏移+指令长度)
// 避免重复Hook:如果目标地址已经是Hook函数,直接返回NULL
if (TargetAddress == (ULONG_PTR)DetourAddress) { return NULL; }
ServiceAddress = (void*)TargetAddress; // 更新为真实入口,继续循环解析
}
// 4. 处理Win64下的nop+jmp(0xE990)和rip跳转(0x25FF)
#ifdef _WIN64
if (*(USHORT*)ServiceAddress == 0xE990) { // nop; jmp xxxx
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 2);
TargetAddress = (ULONG_PTR)ServiceAddress + Offset + 6;
if (*(USHORT*)TargetAddress == 0x25FF) // jmp QWORD PTR
[rip+xxxx]
ServiceAddress = (void*)TargetAddress;
}
#endif
核心目的:系统函数可能被其他模块 Hook 过(如杀毒软件),会有多层跳转指令,这一步要 “穿透跳转”,找到函数的真实原生入口;避免重复
Hook:如果目标地址已经是沙箱的 Hook 函数,直接返回,避免多次修改指令导致崩溃。
步骤 2:申请 Hook 资源(可执行内存 + 跳板)
这一步是为 Hook 准备 “存储原生指令的跳板” 和 “可写的内存权限”:
// 1. 获取模块的Hook信息(包含可执行内存池)
MODULE_HOOK* ModuleHook = GetModuleHookInfo(Module, POOL_TAG | 0xFF); //
0xFF=可执行标记
if (!ModuleHook) { goto Exit; }
// 2. 处理Win64下的call指令(0x15FF:call QWORD PTR [rip+xxxx])
#ifdef _WIN64
if (*(USHORT*)ServiceAddress == 0x15FF) {
// 申请128字节可执行内存,构造自定义call指令的跳转桩
UCHAR* NewDetour =
(UCHAR*)MemoryPoolAllocateEx(ModuleHook->Pool, 128);
if (!NewDetour) { goto Exit; }
// 构造指令:pop rax → mov rax, Hook函数 → jmp rax(跳转到Hook函数)
NewDetour[0] = 0x58; // pop rax
NewDetour[1] = 0x48; // mov rax,
DetourAddress
NewDetour[2] = 0xB8;
*(ULONG_PTR*)(&NewDetour[3]) = (ULONG_PTR)DetourAddress;
NewDetour[11] = 0xFF; // jmp rax
NewDetour[12] = 0xE0;
// 构造回跳指令:mov rax, 原函数+6 → push rax → jmp rax(执行原生逻辑)
NewDetour[16] = 0x48;
NewDetour[17] = 0xB8;
*(ULONG_PTR*)(&NewDetour[18]) = (ULONG_PTR)ServiceAddress +
6;
NewDetour[26] = 0x50;
NewDetour[27] = 0x48;
NewDetour[28] = 0xB8;
*(ULONG_PTR*)(&NewDetour[29]) = 0; // 后续填充跳板地址
NewDetour[37] = 0xFF;
NewDetour[38] = 0xE0;
DetourAddress = NewDetour; // 把Hook目标改为自定义桩
CallInstruction64 = TRUE;
// 解析call指令的真实目标,更新ServiceAddress
Offset = *(LONG*)((ULONG_PTR)ServiceAddress + 2);
TargetAddress = (ULONG_PTR)ServiceAddress + 6 + Offset;
ServiceAddress = (void*)*(ULONG_PTR*)TargetAddress;
}
#endif
// 3. 申请128字节跳板内存(存储原生指令)
Trampoline = (UCHAR*)MemoryPoolAllocateEx(ModuleHook->Pool, 128);
if (!Trampoline) { goto Exit; }
// 4. 创建跳板:把目标函数的原生开头指令复制到跳板
if (CreateModuleHookTrampoline(ServiceAddress, Trampoline) != 0) { goto Exit; }
// 5. 修改目标函数内存权限为可写可执行
v1 = (UCHAR*)ServiceAddress;
RegionBase = &v1[-8]; // 预留hotpatch区域(Win7的特殊内存布局)
RegionSize = 20;
if (!VirtualProtect(RegionBase, RegionSize, PAGE_EXECUTE_READWRITE,
&OldProtect)) {
// Win7 WoW64模式下hotpatch区域偏移不同,重试
RegionBase = &v1[0];
RegionSize = 12;
if (!VirtualProtect(RegionBase, RegionSize,
PAGE_EXECUTE_READWRITE, &OldProtect)) {
v1 = NULL;
goto Exit;
}
}
关键细节:
MODULE_HOOK->Pool:是沙箱为模块申请的可执行内存池(PAGE_EXECUTE_READWRITE),跳板和自定义桩必须放在可执行内存中,否则指令执行会触发内存保护异常;
CreateModuleHookTrampoline:核心作用是复制目标函数的前 N 字节(足够容纳跳转指令)到跳板,保证跳板能执行原生逻辑;
VirtualProtect 重试逻辑:适配 Win7 WoW64(32 位进程跑在 64 位 Win7)的特殊内存布局,避免权限修改失败。
步骤 3:核心指令修改(适配架构 / 版本,写入跳转指令)
这是整个函数的核心,分 x86/x64、Win7/Win10、32 位 / 64 位偏移处理:
#ifdef _WIN64
// 1. 确定Win10/Win7的跳转偏移起始位置
if (__Windows >= 10) {
TargetAddress = (ULONG_PTR)&v1[6]; // Win10需要6字节偏移
}
else {
TargetAddress = (ULONG_PTR)&v1[5]; // Win7需要5字节偏移
}
// 2. 计算Hook函数的偏移
Offset = (ULONG_PTR)((ULONG_PTR)DetourAddress - TargetAddress);
Delta = Offset;
Delta < 0 ? Delta *= -1 : Delta; // 取绝对值
// 3. 32位偏移(范围±2GB):用短跳转(E9)
if (Delta < 0x80000000) {
if (__Windows >= 10) {
v1[0] = 0x48;
// rex.W前缀(Win10 64位跳转)
v1[1] = 0xE9;
// jmp指令
*(ULONG*)(&v1[2]) = (ULONG)Offset; // 4字节偏移
}
else {
v1[0] = 0xE9;
// Win7直接jmp
*(ULONG*)(&v1[1]) = (ULONG)Offset;
}
}
// 4. 64位偏移(超过2GB):用rip跳转(FF25)+ 向量表
else {
TargetAddress = (ULONG_PTR)&v1[6];
// 获取向量表(存储64位地址)
VECTOR_TABLE* VTable = GetHookTable(ModuleHook, TargetAddress,
0x80000000, TRUE);
if (!VTable) { v1 = NULL; goto Exit; }
// 计算rip偏移
Offset = (ULONG_PTR) &
((ULONG_PTR*)VTable->Offset)[VTable->Index];
Offset = Offset - TargetAddress;
// 把Hook函数地址写入向量表
((ULONG_PTR*)VTable->Offset)[VTable->Index] =
(ULONG_PTR)DetourAddress;
// 写入rip跳转指令:jmp QWORD PTR [rip+xxxx]
*(USHORT*)&v1[0] = 0x25ff;
*(ULONG*)&v1[2] = (ULONG)Offset;
VTable->Index++;
}
#else
// x86架构:直接写入E9跳转指令
Offset = (UCHAR*)DetourAddress - (v + 5);
v1[0] = 0xE9;
*(ULONG*)(&v1[1]) = (ULONG)Offset;
#endif
// 5. 恢复内存权限
VirtualProtect(RegionBase, RegionSize,OldProtect, &OldProtect);
// 6. 调整跳板地址(跳过前16字节的元数据)
v1 = (UCHAR*)(ULONG_PTR)(Trampoline + 16);
// 7. Win64 call指令的特殊处理:填充回跳地址
#ifdef _WIN64
if (CallInstruction64) {
UCHAR* NewDetour = (UCHAR*)DetourAddress;
*(ULONG_PTR*)(&NewDetour[29]) = (ULONG_PTR)v1; //
跳板地址写入自定义桩
v1 = NewDetour + 16; // 返回自定义桩的回跳地址
}
#endif
核心难点解析:
| 场景 | 处理方式 | 指令示例(Win10 x64) |
|---|---|---|
| 32 位偏移(<2GB) | E9 跳转(短跳转) | 48 E9 12 34 56 78(jmp 0x78563412) |
| 64 位偏移(>2GB) | FF25 + 向量表(长跳转) | FF 25 10 00 00 00(jmp [rip+0x10]) |
| Win7 vs Win10 | Win10 加 rex.W 前缀(48) | Win7:E9 12 34 56 78;Win10:48 E9 12 34 56 78 |
| x86 vs x64 | x86 直接 E9;x64 分 32/64 位偏移 | x86:E9 12 34 56 78;x64 长跳转:FF25 10000000 |
步骤 4:收尾(释放临界区,返回跳板地址)
Exit:
LeaveCriticalSection(&__CriticalSection); // 释放之前进入的临界区(线程安全)
return v1; // 返回跳板地址(供沙箱调用原生函数)
LeaveCriticalSection:对应 InitializeModuleHook 初始化的临界区,保证多线程下指令修改的原子性;
返回 v1:跳板地址,沙箱调用该地址时,会执行原生函数的开头指令,再跳回原函数的后续逻辑,实现 “绕过 Hook 执行原生操作”。
长跳转的优雅处理
64 位偏移超过 2GB 时,不用暴力修改更多指令,而是用 “rip 跳转 + 向量表” 的方式:
向量表存储 Hook 函数的 64 位地址;
目标函数开头写入 jmp [rip+偏移],指向向量表;
只需修改向量表的地址,就能实现任意 64 位地址的跳转。
InstallModuleHookInternal 核心要点
核心定位:沙箱内联 Hook 的底层核心实现,是所有模块函数 Hook 的 “最终执行者”,适配全版本 / 全架构的指令修改;
核心逻辑:
预处理:穿透跳转指令,找到函数真实入口,避免重复 Hook;
资源申请:申请可执行内存池、跳板,创建原生指令桩;
指令修改:适配 Win7/Win10、x86/x64,写入跳转指令(32 位短跳转 / 64 位长跳转);
收尾:恢复内存权限,释放临界区,返回跳板地址;
设计原则:
兼容性:覆盖所有指令类型、系统版本、CPU 架构;
安全性:内存操作校验、权限恢复、临界区保护;
优雅性:长跳转用向量表,避免暴力修改指令;
可维护性:分层设计,底层专注指令修改,上层专注业务封装。
简单来说,这个函数是沙箱 “指令修改的终极武器”—— 不管目标函数是什么架构、什么系统版本、什么指令格式,它都能精准、安全、兼容地把函数调用导向沙箱的 Hook
逻辑,是沙箱实现 “拦截系统调用、落地隔离规则” 的最核心底层代码。
为什么说这种方式 “优雅”?
对比两种长跳转实现方式,你就懂了:
| 方式 | 缺点 | 沙箱的 “优雅方式”(FF25 + 向量表) |
|---|---|---|
| 暴力修改 10 字节指令 | 1. 目标函数开头可能不足 10 字节; 2. 破坏更多原生指令,容易崩溃; 3. 恢复原生函数时复杂 | 1. 只修改目标函数前 6 字节(Win10)/5 字节(Win7),最小化破坏; 2. 向量表可复用(Index 自增),内存效率高; 3. 恢复时只需改向量表地址,无需还原指令; 4. 兼容所有 x64 系统,无版本适配问题 |
核心要点总结(一句话记牢)
沙箱的长跳转 = “短跳转(FF25)到向量表 + 向量表存 64 位 Hook 地址” —— 用 “间接跳转” 的方式,既避开了 E9 指令的 2GB
范围限制,又最小化修改目标函数的指令,兼顾了兼容性、安全性、可维护性,这就是 “优雅” 的核心。
~~
MODULE_HOOK* GetModuleHookInfo(HMODULE Module, ULONG Tag)
{
//
// Get the module hook resource for this module, if module is NULL
// its NTDLL or a special case
//
EnterCriticalSection(&__CriticalSection);
MODULE_HOOK* v1 =
(MODULE_HOOK*)GetListHead(&__ModuleHooks);
while (v1) {
if (v1->Module == Module
#ifdef _M_ARM64EC
&& mod_hook->tag == tag
#endif
)
break;
v1 = (MODULE_HOOK*)GetNextNode(v1);
}
if (!v1) {
v1 =
(MODULE_HOOK*)MemoryPoolAllocateEx(__Pool,sizeof(MODULE_HOOK));
if (!v1)
return NULL;
v1->Module = Module;
#ifdef _M_ARM64EC
mod_hook->tag = tag;
#endif
v1->Pool = NULL;
#ifdef _WIN64
InitializeList(&v1->VTables);
#endif
InitializeList(&v1->Trace);
InsertBefore(&__ModuleHooks, NULL, v1); // insert
first as we probably will use it often in the next few calls
}
if (!v1->Pool){
v1->Pool = CreateMemoryPoolWithTagged(Tag);
if (!v1->Pool)
return NULL;
}
return v1;
}
GetModuleHookInfo 是沙箱 DLL 中模块 Hook 资源的 “管理中枢” —— 它的核心作用是为每个被 Hook 的模块(如
ntdll.dll/kernel32.dll)创建 / 查找专属的 MODULE_HOOK 结构体(存储该模块的 Hook 资源:内存池、向量表、Trace
链表等),是 InstallModuleHookInternal 能 “按需申请可执行内存、管理长跳转向量表” 的基础。
一、函数核心功能总结
GetModuleHookInfo 的核心目标是:
查找已有资源:遍历 __ModuleHooks 链表,查找指定模块(如 ntdll.dll)对应的 MODULE_HOOK 结构体;
按需创建资源:如果没找到,则新建 MODULE_HOOK 结构体,初始化其核心字段(模块句柄、内存池、向量表链表、Trace 链表);
保证资源可用:确保 MODULE_HOOK 有专属的可执行内存池(供跳板 / 向量表使用);
线程安全:全程用临界区保护,避免多线程并发创建 / 查找导致的资源冲突。
简单说:这个函数是沙箱的 “模块 Hook 资源管家”—— 每个模块只创建一份 Hook 资源,后续所有对该模块的 Hook
操作都复用这份资源,避免重复申请内存、浪费系统资源。
typedef struct _MODULE_HOOK {
NODE Node;
HMODULE Module;
#ifdef _M_ARM64EC
ULONG tag;
#endif
POOL* Pool;
#ifdef _WIN64
LIST VTables;
#endif
LIST Trace;
} MODULE_HOOK;
关键逻辑细节解析
1. 临界区的使用(线程安全核心)
EnterCriticalSection(&__CriticalSection):函数开头进入临界区,保护 __ModuleHooks 链表的遍历 /
插入操作;
注意:函数没有调用 LeaveCriticalSection!因为调用方(如 InstallModuleHookInternal)后续还会操作
MODULE_HOOK 的资源(如向量表),需要保持临界区直到所有操作完成,最终由调用方在 Exit 标签处调用 LeaveCriticalSection。
2. “查找→创建” 的资源复用逻辑
遍历 __ModuleHooks 链表:如果模块已经有 MODULE_HOOK,直接复用,避免重复创建;
新建时插入链表头部:沙箱对同一个模块的 Hook 操作是高频的(如 ntdll.dll 会 Hook 多个函数),插入头部能减少后续查找的遍历次数,提升效率。
3. 可执行内存池的按需创建
v1->Pool = CreateMemoryPoolWithTagged(Tag):Tag 包含 0xFF(可执行标记),创建的内存池是
PAGE_EXECUTE_READWRITE 权限;
按需创建:只有当模块需要 Hook 时,才创建内存池,避免提前申请大量内存导致浪费;
专属内存池:每个模块的跳板 / 向量表都存在自己的内存池里,便于管理(如模块卸载时,可批量释放该内存池的所有资源)。
4. Win64 向量表的初始化
InitializeList(&v1->VTables):为 Win64 长跳转初始化向量表链表,InstallModuleHookInternal 中的
GetHookTable 就是从这个链表中获取向量表;
向量表链表:一个模块可能有多个函数需要长跳转,链表可以存储多个 VECTOR_TABLE 节点,避免向量表冲突。
5. Module=NULL 的特殊处理
注释说明:Module=NULL 代表 ntdll.dll 或特殊场景 ——ntdll.dll 是所有进程的核心模块,沙箱会大量 Hook 其函数(如
NtQueryObject/NtCreateFile),因此单独处理,确保其资源优先创建、优先查找。
GetModuleHookInfo 核心要点
核心定位:模块 Hook 资源的管理中枢,为每个模块创建 / 查找专属的 MODULE_HOOK 资源容器;
核心逻辑:
遍历链表查找已有资源,不存在则新建并初始化(向量表、Trace 链表、模块句柄);
确保模块有专属的可执行内存池,供后续跳板 / 向量表使用;
临界区保护,保证线程安全;
设计原则:
复用性:每个模块只创建一份资源,避免重复申请;
按需创建:内存池 / 向量表都在需要时创建,节省内存;
性能优先:高频模块插入链表头部,提升查找效率;
兼容性:适配不同 CPU 架构和系统版本。
简单来说,这个函数是沙箱 “模块 Hook 资源的总管家”—— 它把每个模块的 Hook 相关资源(内存池、向量表、日志)都收纳到一个
“专属文件夹”(MODULE_HOOK)里,既保证了资源不混乱,又避免了重复创建,是沙箱模块 Hook 系统 “有序、高效、安全” 运行的关键。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34-2
34-3
VECTOR_TABLE* GetHookTable(MODULE_HOOK* ModuleHook, ULONG_PTR
TargetAddress, __int64 MaxDelta, BOOLEAN LongRange)
{
VECTOR_TABLE* VTable =
(VECTOR_TABLE*)GetListHead(&ModuleHook->VTables);
BOOLEAN DefaultRange = FALSE;
ULONG_PTR Diff;
__int64 Delta;
for (;😉
{
if (!VTable || !VTable->Offset)
{ // if there is no vtable create it
ULONG_PTR v1;
ULONG_PTR Step = 0x20000;// +
VTABLE_SIZE;
ULONG_PTR Max = 0x4000000 / Step;
// optimization for windows 7 and low
memory DLL's
if (TargetAddress < 0x80000000 &&
(TargetAddress > 0x4000000)) {
Step = 0x200000;
}
// optimization for windows 8.1
else if (TargetAddress < 0x4000000)
{
Step *= -1;
}
else if (TargetAddress <
0x10000000000) {
Step *= -1;
}
else if (LongRange) {
DefaultRange = TRUE;
}
// sprintf(buffer,"VTable Alloc:
target = %p, step = %p, default = %d\n",target,step,defaultRange);
// OutputDebugStringA(buffer);
v1 = (TargetAddress &
0xfffffffffffe0000) - (Step << 2);
if (DefaultRange) {
v1 -= 0x20000000;
}
if (!VTable) {
VTable =
(VECTOR_TABLE*)MemoryPoolAllocate(ModuleHook->Pool, sizeof(VECTOR_TABLE));
if (!VTable) {
break;
}
memset(VTable, 0,
sizeof(VECTOR_TABLE));
InsertAfter(&ModuleHook->VTables, NULL, VTable);
}
for (; !VTable->Offset && Max; v1
-= Step, Max--) {
PVOID RegionBase =
(PVOID)v1;
SIZE_T RegionSize =
VTABLE_SIZE;
#ifdef _M_ARM64EC
#else
if
(NT_SUCCESS(NtAllocateVirtualMemory(NtCurrentProcess(), &RegionBase, 0,
&RegionSize, MEM_RESERVE | MEM_COMMIT | MEM_TOP_DOWN,
PAGE_EXECUTE_READWRITE)))
VTable->Offset = RegionBase;
#endif
//ptrVTable->offset =
VirtualAlloc((void*)tempAddr, VTABLE_SIZE, MEM_COMMIT | MEM_RESERVE |
MEM_TOP_DOWN, PAGE_EXECUTE_READWRITE); // PAGE_READWRITE
//
sprintf(buffer,"VTable Offset: target = %p, offset = %p, tryAddress = %p,
attempt = 0x%x\n",target,ptrVTable->offset,tempAddr,max_attempts);
//
OutputDebugStringA(buffer);
}
// brute force fallback
if (!VTable->Offset) {
Step = VTABLE_SIZE;
Max = 0x40000000 / Step;
// 1 gig
ULONG_PTR v2 = 0;
v1 =
((ULONG_PTR)TargetAddress & 0xfffffffffffe0000);
for (; !VTable->Offset
&& v2 < (Max * 2); v2++) {
ULONG_PTR
v3 = v1 + (((v2 + 2) / 2) * Step);
if ((v2 %
2) == 0) // search both directions alternating
v3 *= -1;
PVOID
RegionBase = (PVOID)v3;
SIZE_T
RegionSize = VTABLE_SIZE;
#ifdef _M_ARM64EC
#else
if
(NT_SUCCESS(NtAllocateVirtualMemory(NtCurrentProcess(), &RegionBase, 0,
&RegionSize, MEM_RESERVE | MEM_COMMIT | MEM_TOP_DOWN,
PAGE_EXECUTE_READWRITE)))
VTable->Offset = RegionBase;
#endif
}
}
VTable->Index = 0;
VTable->MaxEntries = VTABLE_SIZE /
sizeof(void*);
}
if (VTable && VTable->Offset) { // check if we
have a vtable
// the table is not full
if (VTable->Index <
VTable->MaxEntries) {
Diff = (ULONG_PTR) &
((ULONG_PTR*)VTable->Offset)[VTable->Index];
Diff = Diff -
TargetAddress;
Delta = Diff;
Delta < 0 ? Delta *=
-1 : Delta;
// is DetourFunc in the
jump range
if (Delta < MaxDelta
&& VTable->Index <= VTable->MaxEntries) {
// found a
good table, break and return it
break;
}
}
}
else { // fail and disable vtable if it could not be
initialized
//SbieApi_Log(2303, _fmt1,
SourceFuncName, 888);
VTable = NULL;
break;
}
VTable =(VECTOR_TABLE*)GetNextNode(VTable);
}
return VTable;
}
GetHookTable 是沙箱 DLL 中Win64 长跳转向量表(VTable)的核心管理函数—— 它的核心是为目标函数的长跳转 “精准申请 /
查找符合偏移要求的向量表内存”(保证向量表和目标函数的偏移在 2GB 内),是 InstallModuleHookInternal 实现 “优雅长跳转”
的关键支撑。
一、函数核心功能总结
GetHookTable 的核心目标是:
查找已有向量表:遍历模块专属的 VTables 链表,找一个 “向量表地址与目标函数地址的偏移 < MaxDelta(2GB)” 且 “未存满” 的向量表;
按需创建向量表:如果没找到,就根据目标函数地址的特征(Win7/Win8.1 / 高内存地址)优化内存分配策略,申请可执行的向量表内存;
兜底分配策略:如果优化策略失败,用 “暴力遍历 + 双向查找” 的方式申请向量表,保证长跳转能落地;
返回可用向量表:最终返回一个 “偏移符合要求、有空闲位置” 的向量表,供 InstallModuleHookInternal 写入 Hook 函数地址。
简单说:这个函数是沙箱的 “长跳转向量表管家”—— 为每个需要长跳转的函数,找到 / 创建一个 “离得足够近(<2GB)” 的向量表,让 FF25
指令能通过短偏移跳转到向量表,最终实现 64 位长跳转。
先明确几个关键常量 / 参数的含义(理解函数的基础):
| 常量 / 参数 | 含义 |
|---|---|
| VTABLE_SIZE | 向量表的固定大小(如 0x1000 字节),足够存储多个 64 位地址(8 字节 / 个); |
| MaxDelta | 最大允许偏移(固定为 0x80000000,即 2GB),保证向量表和目标函数的偏移在 2GB 内; |
| LongRange | 是否强制长跳转(TRUE = 必须找长跳转向量表); |
| TargetAddress | 目标函数的跳转起始地址(如 NtQueryObject+6); |
| VTable->Offset | 向量表的内存起始地址(可执行内存,PAGE_EXECUTE_READWRITE); |
| VTable->Index | 向量表当前可用的位置索引(未存满则 < MaxEntries); |
| VTable->MaxEntries | 向量表能存储的最大地址数(VTABLE_SIZE / 8); |
步骤 1:初始化遍历,进入主循环
VECTOR_TABLE* VTable = (VECTOR_TABLE*)GetListHead(&ModuleHook->VTables);
BOOLEAN DefaultRange = FALSE;
ULONG_PTR Diff;
__int64 Delta;
for (;😉 // 无限循环,直到找到可用VTable或失败
{
// 步骤2:如果没有可用VTable,创建新的VTable
if (!VTable || !VTable->Offset)
{
// 2.1 初始化分配参数(优化分配步长)
ULONG_PTR Step = 0x20000; // 默认步长 128KB
ULONG_PTR Max = 0x4000000 / Step; // 最大尝试次数(64MB /
128KB = 512次)
// 2.2 按目标地址优化步长(适配不同系统/内存布局)
// Win7 + 低内存DLL(<800MB):增大步长到 2MB,减少尝试次数
if (TargetAddress < 0x80000000 && (TargetAddress
> 0x4000000)) {
Step = 0x200000;
}
// Win8.1 + 极低内存地址(<40MB):步长取反(向下分配)
else if (TargetAddress < 0x4000000) {
Step *= -1;
}
// 高内存地址(<1TB):步长取反(向下分配)
else if (TargetAddress < 0x10000000000) {
Step *= -1;
}
// 超长高内存地址:使用默认范围
else if (LongRange) {
DefaultRange = TRUE;
}
// 2.3 申请VTable结构体内存(从模块专属Pool申请)
if (!VTable) {
VTable =
(VECTOR_TABLE*)MemoryPoolAllocate(ModuleHook->Pool, sizeof(VECTOR_TABLE));
if (!VTable) break; // 内存申请失败,退出
memset(VTable, 0,
sizeof(VECTOR_TABLE)); // 清空初始化
InsertAfter(&ModuleHook->VTables,
NULL, VTable); // 插入模块VTables链表
}
// 2.4 计算初始分配地址,尝试申请向量表内存
ULONG_PTR v1 = (TargetAddress &
0xfffffffffffe0000) - (Step << 2); // 对齐到128KB边界
if (DefaultRange) v1 -= 0x20000000; // 默认范围:地址减2GB
// 循环尝试分配内存(最多Max次)
for (; !VTable->Offset && Max; v1 -= Step,
Max--) {
PVOID RegionBase = (PVOID)v1;
SIZE_T RegionSize = VTABLE_SIZE;
//
申请可执行内存(MEM_RESERVE|MEM_COMMIT|MEM_TOP_DOWN,PAGE_EXECUTE_READWRITE)
if
(NT_SUCCESS(NtAllocateVirtualMemory(NtCurrentProcess(), &RegionBase, 0,
&RegionSize, MEM_RESERVE | MEM_COMMIT | MEM_TOP_DOWN,
PAGE_EXECUTE_READWRITE)))
VTable->Offset =
RegionBase; // 分配成功,记录向量表地址
}
// 2.5 兜底策略:优化策略失败,暴力双向查找
if (!VTable->Offset) {
Step = VTABLE_SIZE; //
步长改为向量表大小(如0x1000)
Max = 0x40000000 / Step; // 最大尝试1GB范围
ULONG_PTR v2 = 0;
v1 = ((ULONG_PTR)TargetAddress &
0xfffffffffffe0000); // 重新对齐地址
// 双向交替查找(先+后-),最多尝试2*Max次
for (; !VTable->Offset && v2 <
(Max * 2); v2++) {
ULONG_PTR v3 = v1 +
(((v2 + 2) / 2) * Step);
if ((v2 % 2) == 0) v3
*= -1; // 偶数次尝试向下分配,奇数次向上
PVOID RegionBase =
(PVOID)v3;
SIZE_T RegionSize =
VTABLE_SIZE;
// 再次尝试申请可执行内存
if
(NT_SUCCESS(NtAllocateVirtualMemory(..., PAGE_EXECUTE_READWRITE)))
VTable->Offset = RegionBase;
}
}
// 2.6 初始化VTable的索引和最大容量
VTable->Index = 0;
VTable->MaxEntries = VTABLE_SIZE / sizeof(void*);
// 如0x1000/8=256个地址
}
// 步骤3:校验当前VTable是否可用
if (VTable && VTable->Offset) {
// 3.1 检查VTable是否有空闲位置
if (VTable->Index < VTable->MaxEntries) {
// 计算VTable当前位置与目标函数的偏移
Diff = (ULONG_PTR) &
((ULONG_PTR*)VTable->Offset)[VTable->Index];
Diff = Diff - TargetAddress; // 向量表地址
- 目标函数地址
Delta = Diff;
Delta < 0 ? Delta *= -1 : Delta; //
取绝对值
// 3.2 检查偏移是否在2GB内,且VTable未存满
if (Delta < MaxDelta &&
VTable->Index <= VTable->MaxEntries) {
break; //
找到可用VTable,跳出循环
}
}
}
// 步骤4:VTable初始化失败,返回NULL
else {
VTable = NULL;
break;
}
// 步骤5:遍历下一个VTable(当前VTable不可用,继续找)
VTable = (VECTOR_TABLE*)GetNextNode(VTable);
}
return VTable;
1. 内存分配的 “优化策略”(为什么要分不同 Step?)
沙箱不是盲目申请内存,而是根据目标函数的地址特征优化,减少尝试次数:
| 目标地址特征 | Step 调整策略 | 目的 |
|---|---|---|
| TargetAddress < 0x80000000 | Step=0x200000(2MB) | Win7 低内存 DLL(如 system32 下的 DLL),大 step 能快速找到空闲内存; |
| TargetAddress < 0x4000000 | Step=-0x20000(-128KB) | Win8.1 极低内存地址,向下分配(MEM_TOP_DOWN)更易找到空闲内存; |
| TargetAddress < 0x10000000000 | Step=-0x20000 | 高内存地址(<1TB),向下分配避开已分配的用户态内存; |
| LongRange=TRUE | v1 -= 0x20000000 | 超长高内存地址,直接从目标地址减 2GB 开始分配,保证偏移在 2GB 内; |
2. “暴力兜底” 的双向查找逻辑
当优化策略失败时,沙箱用 “双向交替查找” 保证能申请到内存:
初始地址:v1 = TargetAddress 对齐到128KB边界;
查找规则:
v2=0:v3 = v1 + 1*Step → 取反(向下找);
v2=1:v3 = v1 + 1*Step → 向上找;
v2=2:v3 = v1 + 2*Step → 取反(向下找);
v2=3:v3 = v1 + 2*Step → 向上找;
最大范围:1GB(0x40000000),覆盖用户态常用内存区域,几乎能保证分配成功。
3. 向量表的 “可用性校验” 核心
判断 VTable 可用的两个条件:
空间可用:VTable->Index < VTable->MaxEntries(还有空闲位置存 Hook 地址);
偏移合规:Delta < 0x80000000(向量表和目标函数的偏移 < 2GB,FF25 指令能跳到);
这两个条件缺一不可,保证长跳转的 “优雅性”。
4. 向量表的内存属性
NtAllocateVirtualMemory 申请的内存属性是:
MEM_RESERVE | MEM_COMMIT:保留并提交内存(直接可用);
MEM_TOP_DOWN:从高地址向低地址分配(避开已分配的低地址内存);
PAGE_EXECUTE_READWRITE:可执行 + 可读写(向量表存储的是函数地址,需要可执行);
这是向量表能被 FF25 指令正常访问的关键(不可执行内存会触发内存保护异常)。
核心设计亮点(新手必学)
1. “先查找后创建” 的资源复用
遍历模块的 VTables 链表,优先复用已有且可用的向量表,避免重复申请内存;
一个向量表可存储多个 Hook 地址(MaxEntries 个),进一步提升内存利用率。
2. 地址感知的分配优化
根据目标函数的地址特征调整分配策略,大幅减少内存申请的尝试次数,提升效率;
适配不同 Windows 版本(Win7/Win8.1)的内存布局,兼容性拉满。
3. 兜底策略保证可用性
优化策略失败时,用 “暴力双向查找” 覆盖 1GB 范围,几乎能保证向量表分配成功;
避免因内存碎片导致长跳转失败,保证沙箱 Hook 逻辑的稳定性。
4. 严格的偏移校验
只返回 “偏移 < 2GB” 的向量表,确保 FF25 指令能通过短偏移跳转到向量表,实现长跳转的核心目标。
GetHookTable 核心要点
核心定位:Win64 长跳转向量表的 “查找 / 创建管家”,为长跳转提供 “偏移合规、空间可用” 的向量表;
核心逻辑:
优先查找模块已有向量表,无可用则按地址特征优化分配内存;
优化策略失败时,用暴力双向查找兜底申请内存;
校验向量表的偏移和空间,返回可用的向量表;
设计原则:
复用性:优先复用已有向量表,提升内存利用率;
效率:地址感知的分配优化,减少尝试次数;
可用性:暴力兜底策略,保证长跳转必能落地;
合规性:严格校验偏移,确保符合 FF25 指令的短跳转要求。
简单来说,这个函数是沙箱 “优雅长跳转” 的 “最后一道保障”—— 它解决了 “如何找到一个离目标函数足够近的向量表” 的核心问题,让
InstallModuleHookInternal 能通过 “FF25 + 向量表” 的方式,既实现 64 位长跳转,又最小化修改目标函数的指令,是沙箱模块
Hook 系统 “兼容、稳定、高效” 的关键底层逻辑。
void* BuildHookTrampoline(
void* ServiceAddress, void* Trampoline, BOOLEAN Is64, BOOLEAN
IsProbe)
{
ULONG ByteCount;
TRAMPOLINE* v1 = NULL;
if (ServiceAddress) {
if (!GetTrampolineLength(ServiceAddress,
&ByteCount, Is64, IsProbe))
return NULL;
}
#ifdef KERNEL_MODE
#endif
v1 = (TRAMPOLINE*)Trampoline;
if (!v1)
return NULL;
if (ServiceAddress) {
if (!GetTrampolienCode(v1, ServiceAddress,
ByteCount, Is64, IsProbe))
return NULL;
}
return &v1->CodeData;
}
BuildHookTrampoline 是沙箱 DLL 中跳板(Trampoline)代码的构建核心函数—— 它的核心作用是把目标函数的原生开头指令
“复刻” 到跳板内存中,生成一个能 “绕过 Hook 执行原生函数逻辑” 的可执行跳板,是 InstallModuleHookInternal 中
CreateModuleHookTrampoline 的底层支撑(大概率是其直接调用的函数)
BuildHookTrampoline 的核心目标是:
计算指令长度:分析目标函数(如 NtQueryObject)的开头指令,计算出 “足够容纳跳转指令” 的最小指令字节数(避免截断指令导致崩溃);
构建跳板代码:把目标函数的原生开头指令复制到跳板内存中,生成可执行的跳板;
适配架构 / 场景:兼容 x86(Is64=FALSE)/x64(Is64=TRUE)架构,支持探针模式(IsProbe)的指令分析;
返回跳板入口:返回跳板中实际可执行代码的起始地址,供沙箱调用原生函数时使用。
简单说:这个函数是沙箱的 “原生指令复刻机”—— 它把目标函数被 Hook 修改的开头指令完整复制到跳板里,让沙箱既能 Hook
函数,又能通过跳板执行原生逻辑(比如沙箱自身需要调用 NtQueryObject 时,绕过自己的 Hook)。
void* BuildHookTrampoline(
void* ServiceAddress, // 目标函数地址(如NtQueryObject)
void* Trampoline, // 跳板内存起始地址(已申请的可执行内存)
BOOLEAN Is64, //
是否64位架构(TRUE=x64,FALSE=x86)
BOOLEAN IsProbe) //
是否探针模式(TRUE=仅分析指令不执行,FALSE=正常构建)
{
ULONG ByteCount; // 目标函数开头需要复制的指令字节数
TRAMPOLINE* v1 = NULL; // 跳板结构体指针
// 步骤1:计算目标函数开头的指令长度(核心前置步骤)
if (ServiceAddress) { // 仅当目标函数地址有效时执行
// GetTrampolineLength:分析指令,返回需要复制的最小字节数(如5/6字节)
if (!GetTrampolineLength(ServiceAddress,
&ByteCount, Is64, IsProbe))
return NULL; // 指令分析失败,返回NULL
}
// 预留:内核模式下的特殊处理(当前无实现)
#ifdef KERNEL_MODE
#endif
// 步骤2:绑定跳板结构体,校验内存有效性
v1 = (TRAMPOLINE*)Trampoline;
if (!v1) // 跳板内存地址无效,返回NULL
return NULL;
// 步骤3:把目标函数的原生指令复制到跳板中
if (ServiceAddress) { // 仅当目标函数地址有效时执行
//
GetTrampolienCode:核心逻辑——把ServiceAddress开头的ByteCount字节指令复制到v1->CodeData
if (!GetTrampolienCode(v1, ServiceAddress,
ByteCount, Is64, IsProbe))
return NULL; // 指令复制失败,返回NULL
}
// 步骤4:返回跳板中可执行代码的起始地址(跳过TRAMPOLINE结构体的元数据)
return &v1->CodeData;
}
1. GetTrampolineLength(指令长度计算)
核心作用:分析目标函数的开头指令,计算出 “完整且不截断” 的最小字节数(ByteCount);
为什么重要:x86/x64 指令是变长的(如 mov r10,rcx 是 3 字节,syscall 是 2 字节),如果截断指令(比如只复制 4
字节),执行时会崩溃;
示例:NtQueryObject 的开头指令是 4c8bd1 b80d000000 0f05(共 10 字节),Hook 时修改前 6
字节,GetTrampolineLength 会返回 6,保证复制完整的 6 字节指令到跳板;
IsProbe 作用:探针模式下,仅分析指令长度,不执行任何写操作,避免修改目标函数。
2. GetTrampolienCode(指令复刻)
核心作用:把 ServiceAddress 开头的 ByteCount 字节指令,逐字节复制到 v1->CodeData 中;
额外处理:
x64 架构下,会修正指令的地址偏移(如 rip 相对寻址),保证指令在跳板中能正常执行;
探针模式下,仅验证指令可复制,不实际写入跳板;
关键要求:跳板内存必须是 PAGE_EXECUTE_READWRITE 权限(由 GetModuleHookInfo 申请的 Pool
保证),否则复制的指令无法执行。
三、跳板的核心价值(为什么需要构建它?)
在沙箱的内联 Hook 流程中,跳板是 “绕不开的关键”,核心价值有 3 点:
执行原生逻辑:沙箱 Hook 了 NtQueryObject 后,自身可能需要调用原生的
NtQueryObject(比如访问沙箱外的合法对象),此时调用跳板地址,就能执行未被修改的原生指令;
避免指令截断崩溃:Hook 时修改了目标函数的前 6 字节,跳板复制了这 6 字节的原生指令,执行跳板时能完整还原这部分逻辑,再跳回目标函数的第 7
字节,保证函数执行完整;
兼容复杂指令:对于变长指令、rip 相对寻址指令,跳板会修正地址偏移,避免指令执行异常(比如 [rip+0x10] 在目标函数和跳板中的 rip
值不同,需要修正偏移)
~~~~
BOOLEAN GetTrampolineLength(
void* ServiceAddress, ULONG* ByteCount, BOOLEAN Is64, BOOLEAN
IsProbe)
{
UCHAR* v1 = (UCHAR*)ServiceAddress;
ULONG Length1 = (Is64 ? 12 : 5);
ULONG Length2 = 0;
// count at least the (needlen) bytes of instructions from the
original
// entry point to our stub, as we will overwrite that area later
while (1) {
_INSTRUCTION_ Instruction;
BOOLEAN IsOk = InstructionAnalyze(v1, IsProbe, Is64,
&Instruction);
if (!IsOk)
return FALSE;
if (Instruction.Operand1 == 0xFF &&
Instruction.Operand2 == 0x25
&& *(ULONG*)&v1[2] == 0) {
// jmp dword/qword ptr [+00], so
skip the following ULONG_PTR
Instruction.Length +=
sizeof(ULONG_PTR);
}
Length2 += Instruction.Length;
if (Length2 >= Length1)
break;
v1 += Instruction.Length;
}
*ByteCount = Length2;
return TRUE;
}
GetTrampolineLength 是沙箱 DLL 中跳板指令长度计算的核心函数—— 它的核心作用是
“精准解析目标函数开头的变长指令,计算出至少能容纳 Hook 跳转指令的最小字节数”(比如 x64 至少 12 字节、x86 至少 5 字节),是
BuildHookTrampoline 能 “完整复刻原生指令” 的前提,避免因指令截断导致跳板执行崩溃。
GetTrampolineLength 的核心目标是:
设定最小长度阈值:x64 架构至少需要 12 字节(适配 Win10 的 6 字节跳转 + 指令对齐),x86 至少 5 字节(适配 E9 跳转指令);
逐指令解析:循环解析目标函数开头的每条变长指令,累加指令长度;
特殊指令处理:对 jmp [rip+0] 这类特殊跳转指令,额外增加指针长度(8 字节 /x64、4 字节 /x86);
满足阈值即停止:累加长度≥最小阈值时停止解析,返回最终长度;
保证指令完整:只解析完整的指令,避免截断单条指令(比如 3 字节的 mov r10,rcx 必须完整计入)。
简单说:这个函数是沙箱的 “指令长度计数器”—— 它不认 “字节数”,只认 “完整指令数”,确保复制到跳板的指令是
“完整的、足够容纳跳转指令的”,是跳板能正常执行的核心保障。
1. 核心依赖:_INSTRUCTION_ 结构体
#pragma pack(1)
typedef struct _INSTRUCTION_ {
ULONG Length;
UCHAR Kind;
UCHAR Operand1, Operand2;
ULONG64 ParameterData;
LONG* Rel32; // -->
32-bit relocation for control-xfer
UCHAR* ModRM;
ULONG Flags;
}INSTRUCTION;
2. 核心调用:InstructionAnalyze 函数
这是沙箱自研的变长指令解析器,核心能力是:
输入:指令起始地址 v1、架构 Is64、探针模式 IsProbe;
输出:填充 _INSTRUCTION_ 结构体,返回是否解析成功;
关键逻辑:
识别 x86/x64 变长指令(如 x86 指令长度 1~15 字节,x64 支持 rex 前缀);
区分不同指令类型(跳转、系统调用、普通数据操作);
探针模式下仅解析,不修改任何内存,保证安全。
3. 特殊指令处理:jmp [rip+0](FF 25 00 00 00 00)
这类指令是 x64 长跳转的典型指令,结构为:
| 字节偏移 | 指令字节 | 含义 |
|---|---|---|
| 0 | FF | 操作数 1 |
| 1 | 25 | 操作数 2 |
| 2-5 | 00 00 00 00 | rip 偏移 = 0 |
| 6-13 | xxxx xxxx xxxx xxxx | 64 位目标地址(ULONG_PTR) |
解析时,指令本身长度是 6 字节,但后面紧跟 8 字节的目标地址;
函数会把 Instruction.Length += 8(sizeof (ULONG_PTR)),确保这 14 字节被完整计入跳板长度,避免截断。
4. 最小长度阈值的设计原因
x86=5 字节:x86 的 E9 跳转指令是 E9 + 4字节偏移,共 5 字节,解析至少 5 字节能保证覆盖跳转指令;
x64=12 字节:
Win10 x64 的跳转指令是 48 E9 + 4字节偏移(6 字节);
部分函数开头有 rex 前缀、nop 填充等,12 字节能覆盖绝大多数场景,避免多次循环解析;
预留足够空间,适配复杂指令(如多前缀的 mov 指令)。
四、指令解析示例(以 NtQueryObject x64 为例)
目标函数开头指令
0x77BF13B0: 4C 8B D1 // mov r10,rcx(3字节)
0x77BF13B3: B8 0D 00 00 00 // mov eax,0xD(5字节)
0x77BF13B8: 0F 05 // syscall(2字节)
解析过程:
初始:v1=0x77BF13B0,Length1=12,Length2=0;
解析第一条指令(mov r10,rcx):
Instruction.Length=3,Length2=3(<12,继续);
v1=0x77BF13B3;
解析第二条指令(mov eax,0xD):
Instruction.Length=5,Length2=8(<12,继续);
v1=0x77BF13B8;
解析第三条指令(syscall):
Instruction.Length=2,Length2=10(<12,继续);
v1=0x77BF13BA;
解析第四条指令(ret):
Instruction.Length=1,Length2=11(<12,继续);
v1=0x77BF13BB;
解析第五条指令(nop):
Instruction.Length=1,Length2=12(≥12,停止);
最终:*ByteCount=12,返回 TRUE。
1. 按 “完整指令” 累加,而非按 “字节”
避免截断单条指令(比如 3 字节的 mov r10,rcx 不能只取 2 字节);
保证跳板中复制的指令都是 “可独立执行的完整指令”,不会出现 “半条指令” 导致的崩溃。
2. 架构差异化阈值
x86/x64 设定不同的最小长度,适配各自的跳转指令长度,既保证足够空间,又不浪费内存。
3. 特殊指令兼容
针对 jmp [rip+0] 这类长跳转指令,额外计入指针长度,避免解析不完整。
4. 探针模式安全解析
IsProbe 模式下仅分析指令,不修改任何内存,即使解析恶意指令也不会触发执行,保证沙箱自身安全。
总结
GetTrampolineLength 核心要点
核心定位:跳板指令长度的 “精准计算器”,解析目标函数的变长指令,计算出能容纳 Hook 跳转的最小完整指令长度;
核心逻辑:
设定 x86/x64 差异化的最小长度阈值;
循环解析每条完整指令,累加长度;
特殊处理长跳转指令,补充指针长度;
满足阈值即停止,输出最终长度;
设计原则:
完整性:只解析完整指令,避免截断;
兼容性:适配 x86/x64 变长指令、特殊跳转指令;
安全性:探针模式仅分析不执行,避免风险;
高效性:满足阈值即停止,减少解析次数。
简单来说,这个函数是沙箱 “指令解析的基石”—— 它解决了 “变长指令该复制多少字节” 的核心问题,让跳板能完整复刻目标函数的开头指令,既保证 Hook
能覆盖足够的字节,又保证跳板能正常执行原生逻辑,是内联 Hook 中 “精准、安全、兼容” 的关键。
Win10 x64 的跳转指令是 48 E9 + 4字节偏移(6 字节);为什么需要12个???
核心原因 2:Hook 要覆盖的是 “连续的可写区域”,12 字节适配 Win10 内存布局
Win10 x64 对系统函数的内存布局做了优化:
系统函数开头会预留 “hotpatch 区域”(热补丁区),通常是 8~12 字节的连续可写区域;
Hook 时需要修改的是 “连续的内存”(不能跳过中间字节),如果只改 6 字节,可能遇到 “前 6 字节不可写、但前 12 字节可写” 的情况;
12 字节是 Win10 x64 系统函数 “连续可写区域” 的通用长度,能保证 Hook 指令能完整写入(避免内存权限问题)。
你容易混淆的核心点是:
Hook 写入的跳转指令:只需要 6 字节(48 E9 + 4字节偏移),目的是 “覆盖函数开头,让调用跳转到 Hook 函数”;
跳板复制的原生指令:需要≥12 字节,目的是 “完整复刻被覆盖的原生指令,让跳板能执行原生逻辑”;
~~~~~~~~~~
BOOLEAN GetTrampolienCode(
_TRAMPOLINE_* Trampoline, void* ServiceAddress,
ULONG ByteCount, BOOLEAN Is64, BOOLEAN IsProbe)
{
UCHAR* Source = (UCHAR*)ServiceAddress;
UCHAR* CodeData = Trampoline->CodeData;
ULONG DataLength;
BOOLEAN IsPushPopRax = FALSE;
Trampoline->EyeCatcher = POOL_TAG;
Trampoline->TargetAddress = Source + ByteCount;
Trampoline->ByteCount = ByteCount;
// copy ByteCount bytes from the original source function into
// the code area of the trampoline stub, adjustmenting it as
needed
// in 32-bit mode, we also relocate call/jump targets. (E8/E9)
// in 64-bit mode, we also relocate rip-based displacements
while ((ULONG_PTR)(Source - (UCHAR*)ServiceAddress) <
ByteCount) {
_INSTRUCTION_ Instruction;
BOOLEAN IsOk = InstructionAnalyze(Source, IsProbe,
Is64, &Instruction);
if (!IsOk)
return FALSE;
memcpy(CodeData, Source, Instruction.Length);
//拷贝源函数指令
DataLength = Instruction.Length;
if ((Instruction.Operand1 >= 0x70 &&
Instruction.Operand1 <= 0x7F) ||
(Instruction.Operand1 == 0x0F &&
(Instruction.Operand2 >= 0x80 && Instruction.Operand2 <= 0x8F))) {
// conditional-jmp with 8-bit or
32-bit displacement
UCHAR v1;
if (Instruction.Operand1 == 0x0F)
v1 =
Instruction.Operand2 & 0x0F;
else
v1 =
Instruction.Operand2 & 0x0F;
if (Is64) {
// in 64-bit mode,
rewrite as indirect jmp/call:
//
jcc cont (opposite condition)
//
jmp qword ptr [target64]
// target64: dq
target
// (cont): ...
CodeData[0] = 0x70 |
(v1 ^ 1); // jcc cont (rip+16)
CodeData[1] = 14;
CodeData[2] = 0xFF;
// jmp [rip+6]
CodeData[3] = 0x25;
*(LONG*)&CodeData[4] = 0;
*(ULONG_PTR*)&CodeData[8] = (ULONG_PTR)Instruction.ParameterData;
DataLength = 16;
}
else {
// in 32-bit mode, fix
jmp target relative to new eip,
// and write out jcc
disp32
CodeData[0] = 0x0F;
// jcc disp32
CodeData[1] = 0x80 |
v1;
*(ULONG*)&CodeData[2] =
(ULONG)(Instruction.ParameterData - ((ULONG_PTR)CodeData + 6));
DataLength = 6;
}
}
else if (Instruction.Operand1 == 0xEB) {
// unconditional short jump with 8-bit
displacement
if (Is64) {
// in 64-bit mode,
rewrite as indirect jmp/call:
//
jmp qword ptr [target64]
// target64: dq
target
CodeData[0] = 0xFF;
// jmp [rip+6]
CodeData[1] = 0x25;
*(LONG*)&CodeData[2] = 0;
*(ULONG_PTR*)&CodeData[6] = (ULONG_PTR)Instruction.ParameterData;
DataLength = 14;
}
else {
// in 32-bit mode, fix
jmp target relative to new eip,
// and write out jcc
disp32
CodeData[0] = 0xE9;
// jmp disp32
*(ULONG*)&CodeData[1] =
(ULONG)(Instruction.ParameterData - ((ULONG_PTR)CodeData + 5));
DataLength = 5;
}
}
else if (Instruction.Operand1 == 0xE8 ||
Instruction.Operand1 == 0xE9) {
// simple direct jmp/call with 32-bit
displacement
if (Is64) {
// in 64-bit mode,
rewrite as indirect jmp/call:
//
jmp qword ptr [target64]
//
jmp cont
// target64: dq
target
// cont:
...
CodeData[0] = 0xFF;
// jmp/call [rip+8]
CodeData[1] =
(Instruction.Operand1 == 0xE8) ? 0x15 : 0x25;
*(LONG*)&CodeData[2] = 2;
CodeData[6] = 0xEB;
// jmp cont (rip+8)
CodeData[7] = 0x08;
*(ULONG_PTR*)&CodeData[8] = (ULONG_PTR)Instruction.ParameterData;
DataLength = 16;
}
else {
// in 32-bit mode, fix
jmp target relative to new eip
ULONG* Rel32 =
(ULONG*)(CodeData + ((UCHAR*)Instruction.Rel32 - Source));
*Rel32 =
(ULONG)(Instruction.ParameterData - ((ULONG_PTR)CodeData +
Instruction.Length));
}
}
else if (Instruction.Operand1 == 0xFF &&
Instruction.Operand2 == 0x25 &&
*(ULONG*)&Source[2] == 0) {
//
// indirect jmp to the immediately
following address:
// jmp dword/qword
ptr [+00]
// typically found when the entry
point is already hooked
// by someone else. we retrieve
the jump target from the
// instruction and use it for the
trampoline jump target,
// then break
//
if (Is64)
Trampoline->TargetAddress = (void*)(*(ULONG64*)&Source[6]);
else
Trampoline->TargetAddress = (void*)(ULONG_PTR)(*(ULONG*)&Source[6]);
break;
}
else if (Is64 && Instruction.ModRM &&
(Instruction.ModRM[0] & 0xC7) == 5) {
// RIP-relative addressing in 64-bit
mode, when the
// instruction contains modrm byte:
mod00b, rm101b.
// (high two bits are zero, low three
bits have a value of 5)
UCHAR* ModRM;
// we rewrite the original instruction
to use RAX as the
// base, rather than RIP, and prefix
an instruction that
// loads RAX with the original RIP.
if (IsPushPopRax) {
*CodeData = 0x50;
// push rax
++CodeData;
}
CodeData[0] = 0x48;
// mov rax, addr
CodeData[1] = 0xB8;
*(ULONG_PTR*)&CodeData[2] =
((ULONG_PTR)Source) + Instruction.Length;
CodeData += 10;
memcpy(CodeData, Source,
Instruction.Length);
ModRM = CodeData + (Instruction.ModRM
- Source);
*ModRM = (*ModRM | 0x80) & (~7);
if (IsPushPopRax)
{
CodeData +=
Instruction.Length;
*CodeData = 0x58;
// pop rax
++CodeData;
// we already advanced
the code pointer to insert the pop
// instruction so we
don't want to advance it again:
DataLength = 0;
}
}
else if (Is64 && Instruction.Operand1 == 0x8B &&
Instruction.Operand2 == 0xC4) {
// if rax is used in the prolog, we
will need to push and
// pop it before using it in the code
generated above for
// RIP-relative addressing in 64-bit
mode
IsPushPopRax = TRUE;
}
Source += Instruction.Length;
CodeData += DataLength;
}
// stub ends with a jump to the original entrypoint. first
qword
// in the stub contains the address of the original entrypoint
CodeData[0] = 0xFF;
// jmp dword/qword ptr [target]
CodeData[1] = 0x25;
CodeData += 2;
if (Is64) {
*(LONG*)CodeData =
(LONG)((UCHAR*)&Trampoline->TargetAddress - (CodeData + 4));
CodeData += 8;
}
else {
*(ULONG*)CodeData =
(ULONG)(ULONG_PTR)&Trampoline->TargetAddress;
CodeData += 4;
}
DataLength = (ULONG)(CodeData - (UCHAR*)Trampoline);
DataLength = (DataLength + 15) & (~0x0F); // align to
16-byte boundary
Trampoline->DataLength = DataLength; //0x30
/*
0:000> u 000007ff`fff80218 l 10
000007ff`fff80218 4c8bd1 mov
r10,rcx
000007ff`fff8021b b80d000000 mov
eax,0Dh
000007ff`fff80220 0f05
syscall
000007ff`fff80222 c3
ret
000007ff`fff80223 0f1f440000 nop
dword ptr [rax+rax]
000007ff`fff80228 ff25e2ffffff jmp
qword ptr [000007ff`fff80210] 覆盖指令之后的指令
0:000> dq 000007ff`fff80210
000007ff`fff80210 00000000`77bf13c0
0:000> u 00000000`77bf13c0
ntdll!NtQueryInformationFile:
00000000`77bf13c0 4c8bd1 mov
r10,rcx
00000000`77bf13c3 b80e000000 mov
eax,0Eh
00000000`77bf13c8 0f05
syscall
00000000`77bf13ca c3
ret
00000000`77bf13cb 0f1f440000 nop
dword ptr [rax+rax]
ntdll!NtQueryObject:
00000000`77bf13b0 4c8bd1 mov
r10,rcx
00000000`77bf13b3 b80d000000 mov
eax,0Dh
00000000`77bf13b8 0f05
syscall
00000000`77bf13ba c3
ret
00000000`77bf13bb 0f1f440000 nop
dword ptr [rax+rax]
ntdll!NtQueryInformationFile:
00000000`77bf13c0 4c8bd1 mov
r10,rcx
00000000`77bf13c3 b80e000000 mov
eax,0Eh
00000000`77bf13c8 0f05
syscall
*/
return TRUE;
}
GetTrampolienCode 是沙箱 DLL 中跳板代码构建的 “终极核心” ——
它不只是简单复制目标函数的原生指令,还会智能修正指令的地址依赖(如 RIP 寻址、跳转偏移),适配 x86/x64 架构的指令差异,最终生成一个
“能独立执行、逻辑与原生函数完全一致” 的可执行跳板。
GetTrampolienCode 的核心目标是:
基础指令复制:把目标函数开头的 ByteCount 字节指令,逐段复制到跳板的 CodeData 区域;
指令重写适配:
对条件跳转 / 无条件跳转指令,重写为 x64 兼容的间接跳转(避免 32 位偏移限制);
对 RIP 相对寻址指令(x64),修正寻址基址为 RAX,保证指令在跳板中寻址正确;
对被 Hook 过的间接跳转指令,直接获取真实目标地址,避免嵌套跳转;
补全跳转逻辑:在跳板末尾添加 “跳回原生函数剩余指令” 的逻辑,保证函数执行完整;
内存对齐:将跳板代码对齐到 16 字节边界,适配 x64 指令缓存(提升执行效率);
架构兼容:全流程区分 x86/x64,保证两种架构下跳板都能正常执行。
简单说:这个函数是沙箱的 “指令修复大师”—— 它复制的不是 “死指令”,而是 “能在跳板中独立运行的活指令”,解决了 “指令地址依赖导致跳板执行异常”
的核心问题。
二、核心逻辑逐场景拆解
先明确几个关键前提(理解函数的基础):
跳板和目标函数的内存地址不同 → 基于指令指针(EIP/RIP)的相对寻址、跳转偏移,在跳板中会失效;
x64 架构下 32 位跳转偏移(±2GB)可能不够用 → 需重写为 64 位间接跳转;
RIP 相对寻址是 x64 特有 → 需修正基址才能在跳板中正确寻址。
函数的核心逻辑是 “循环解析每条指令 → 按指令类型处理 → 复制 / 重写 → 补全末尾跳转”,以下按指令类型拆解核心场景:
场景 1:基础流程初始化(参数绑定 + 元数据记录)
// 绑定源指令指针、跳板代码指针
UCHAR* Source = (UCHAR*)ServiceAddress;
UCHAR* CodeData = Trampoline->CodeData;
ULONG DataLength;
BOOLEAN IsPushPopRax = FALSE; // 标记是否需要保存/恢复RAX(RIP寻址修正用)
// 记录跳板元数据(用于末尾跳转)
Trampoline->EyeCatcher = POOL_TAG; //
内存池标签(用于校验)
Trampoline->TargetAddress = Source + ByteCount; // 原生函数剩余指令的起始地址
Trampoline->ByteCount = ByteCount; // 复制的指令长度
// 循环解析:直到复制的指令长度达到ByteCount
while ((ULONG_PTR)(Source - (UCHAR*)ServiceAddress) < ByteCount) {
_INSTRUCTION_ Instruction;
BOOLEAN IsOk = InstructionAnalyze(Source, IsProbe, Is64,
&Instruction);
if (!IsOk) return FALSE;
// 先默认复制当前指令到跳板
memcpy(CodeData, Source, Instruction.Length);
DataLength = Instruction.Length;
// ========== 以下按指令类型处理 ==========
// 场景2~场景7:处理不同类型的指令
// ========== 指令处理结束 ==========
// 移动指针:源指令指针→下一条指令;跳板代码指针→处理后的指令末尾
Source += Instruction.Length;
CodeData += DataLength;
}
场景 2:处理条件跳转指令(0x70~0x7F / 0x0F80~0x0F8F)
条件跳转指令(如 jz/jnz/jl)的问题:x64 下 8/32 位偏移可能失效,且跳板地址与原生地址不同 → 偏移需重算。
if ((Instruction.Operand1 >= 0x70 && Instruction.Operand1 <= 0x7F) ||
(Instruction.Operand1 == 0x0F && (Instruction.Operand2 >= 0x80
&& Instruction.Operand2 <= 0x8F))) {
// 提取条件码(如jz的0x00,jnz的0x01)
UCHAR v1 = (Instruction.Operand1 == 0x0F) ? (Instruction.Operand2
& 0x0F) : (Instruction.Operand2 & 0x0F);
if (Is64) {
// x64:重写为「反向条件跳转+64位间接跳转」(避免偏移限制)
// 指令结构:
// jcc cont (rip+14) → 反向条件跳转,跳过下面的间接跳转
// ff 25 00 00 00 00 → jmp [rip+0]
// dq target → 64位目标地址
CodeData[0] = 0x70 | (v1 ^ 1); //
反向条件码(如jz→jnz)
CodeData[1] = 14;
// 偏移到cont(跳过14字节)
CodeData[2] = 0xFF;
// jmp [rip+0]
CodeData[3] = 0x25;
*(LONG*)&CodeData[4] = 0;
*(ULONG_PTR*)&CodeData[8] =
(ULONG_PTR)Instruction.ParameterData; // 64位目标地址
DataLength = 16; // 重写后指令长度16字节
}
else {
// x86:重写为32位偏移的条件跳转(修正EIP偏移)
CodeData[0] = 0x0F;
CodeData[1] = 0x80 | v1;
*(ULONG*)&CodeData[2] =
(ULONG)(Instruction.ParameterData - ((ULONG_PTR)CodeData + 6));
DataLength = 6;
}
}
核心目的:x64 下用 64 位间接跳转替代 32 位条件跳转,避免偏移超出范围;x86 下修正 EIP 偏移,保证跳转目标正确。
场景 3:处理无条件短跳转(0xEB)
短跳转(1 字节指令 + 1 字节偏移)的问题:偏移基于原生 EIP/RIP,在跳板中失效。
else if (Instruction.Operand1 == 0xEB) {
if (Is64) {
// x64:重写为64位间接跳转
// 指令结构:ff 25 00 00 00 00 + dq target
CodeData[0] = 0xFF;
CodeData[1] = 0x25;
*(LONG*)&CodeData[2] = 0;
*(ULONG_PTR*)&CodeData[6] =
(ULONG_PTR)Instruction.ParameterData;
DataLength = 14;
}
else {
// x86:重写为5字节的E9跳转(修正偏移)
CodeData[0] = 0xE9;
*(ULONG*)&CodeData[1] =
(ULONG)(Instruction.ParameterData - ((ULONG_PTR)CodeData + 5));
DataLength = 5;
}
}
场景 4:处理直接跳转 / 调用(0xE8=call / 0xE9=jmp)
32 位偏移跳转 / 调用的问题:x64 下偏移可能超出 ±2GB,且跳板 EIP/RIP 与原生不同。
else if (Instruction.Operand1 == 0xE8 || Instruction.Operand1 == 0xE9) {
if (Is64) {
// x64:重写为「间接跳转+短跳转」(避免偏移限制)
// 指令结构:
// ff 15/25 02 00 00 00 → call/jmp [rip+2]
// eb 08
→ jmp cont(跳过8字节)
// dq target
→ 64位目标地址
CodeData[0] = 0xFF;
CodeData[1] = (Instruction.Operand1 == 0xE8) ?
0x15 : 0x25; // call→0x15,jmp→0x25
*(LONG*)&CodeData[2] = 2;
CodeData[6] = 0xEB;
CodeData[7] = 0x08;
*(ULONG_PTR*)&CodeData[8] =
(ULONG_PTR)Instruction.ParameterData;
DataLength = 16;
}
else {
// x86:修正32位偏移(基于跳板EIP)
ULONG* Rel32 = (ULONG*)(CodeData +
((UCHAR*)Instruction.Rel32 - Source));
*Rel32 = (ULONG)(Instruction.ParameterData -
((ULONG_PTR)CodeData + Instruction.Length));
}
}
场景 5:处理已被 Hook 的间接跳转(FF 25 00 00 00 00)
这类指令是其他模块 Hook 后留下的(jmp [rip+0]),需要穿透跳转找到真实目标:
else if (Instruction.Operand1 == 0xFF && Instruction.Operand2 == 0x25 &&
*(ULONG*)&Source[2] == 0) {
// 提取指令后紧跟的64/32位目标地址,作为跳板的最终跳转目标
if (Is64)
Trampoline->TargetAddress =
(void*)(*(ULONG64*)&Source[6]);
else
Trampoline->TargetAddress =
(void*)(ULONG_PTR)(*(ULONG*)&Source[6]);
break; // 无需继续复制,直接跳转到真实目标
}
场景 6:处理 x64 RIP 相对寻址指令(核心难点)
RIP 相对寻址的问题:[rip+偏移] 中的 RIP 是指令执行时的指针,跳板的 RIP 与原生函数不同 → 寻址地址错误。
else if (Is64 && Instruction.ModRM && (Instruction.ModRM[0] & 0xC7) == 5)
{
// ModRM字节特征:mod=00b,rm=101b → 纯RIP相对寻址
// 1. 若需要保存RAX,先push rax(避免覆盖原生RAX值)
if (IsPushPopRax) {
*CodeData = 0x50; // push rax
++CodeData;
}
// 2. 插入指令:mov rax, 原生指令的RIP值(Source + 指令长度)
CodeData[0] = 0x48; // mov rax, imm64
CodeData[1] = 0xB8;
*(ULONG_PTR*)&CodeData[2] = ((ULONG_PTR)Source) +
Instruction.Length;
CodeData += 10;
// 3. 复制原指令,修正ModRM字节(将RIP基址改为RAX)
memcpy(CodeData, Source, Instruction.Length);
UCHAR* ModRM = CodeData + (Instruction.ModRM - Source);
*ModRM = (*ModRM | 0x80) & (~7); // rm字段改为000(RAX),保留其他位
// 4. 恢复RAX(若之前保存)
if (IsPushPopRax) {
CodeData += Instruction.Length;
*CodeData = 0x58; // pop rax
++CodeData;
DataLength = 0; // 避免重复移动指针
}
}
核心原理:
原生指令:mov rcx, [rip+0x10] → RIP 是原生函数的地址;
跳板中:先执行 mov rax, 原生RIP值 → 再把指令改为 mov rcx, [rax+0x10] → 寻址地址与原生一致。
场景 7:标记 RAX 使用(函数序言中用到 RAX)
如果原生函数开头用了 mov eax, ecx(0x8B C4),修正 RIP 寻址时需要保存 / 恢复 RAX:
else if (Is64 && Instruction.Operand1 == 0x8B && Instruction.Operand2 ==
0xC4) {
IsPushPopRax = TRUE; // 标记需要push/pop rax
}
场景 8:补全末尾跳转(跳板执行完复制的指令后,跳回原生函数)
这是跳板能执行完整原生逻辑的关键:
// 末尾添加:jmp [Trampoline->TargetAddress](跳回原生函数剩余指令)
CodeData[0] = 0xFF; // jmp dword/qword ptr [target]
CodeData[1] = 0x25;
CodeData += 2;
if (Is64) {
// x64:RIP相对寻址指向Trampoline->TargetAddress
*(LONG*)CodeData = (LONG)((UCHAR*)&Trampoline->TargetAddress
- (CodeData + 4));
CodeData += 8;
}
else {
// x86:直接指向TargetAddress
*(ULONG*)CodeData =
(ULONG)(ULONG_PTR)&Trampoline->TargetAddress;
CodeData += 4;
}
// 16字节对齐(适配x64指令缓存,提升执行效率)
DataLength = (ULONG)(CodeData - (UCHAR*)Trampoline);
DataLength = (DataLength + 15) & (~0x0F);
Trampoline->DataLength = DataLength;
三、指令重写示例(以 NtQueryObject x64 为例)
原生函数开头指令:
0x77BF13B0: 4C 8B D1 // mov r10, rcx(3字节)
0x77BF13B3: B8 0D 00 00 00 // mov eax, 0xD(5字节)
0x77BF13B8: 0F 05 // syscall(2字节)
0x77BF13BA: C3 //
ret(1字节)
跳板中生成的指令:
// 第一步:复制原生指令(前12字节)
0x7FFf80218: 4C 8B D1 // mov r10, rcx
0x7FFf8021B: B8 0D 00 00 00 // mov eax, 0xD
0x7FFf80220: 0F 05 // syscall
0x7FFf80222: C3 //
ret
0x7FFf80223: 0F 1F 44 00 00 // nop(补到12字节)
// 第二步:末尾添加跳回原生函数的指令
0x7FFf80228: FF 25 E2 FF FF FF // jmp qword ptr [0x7FFf80210]
// 0x7FFf80210 存储的是:0x77BF13C0(原生函数剩余指令的起始地址)
执行流程:
跳板执行 → 复制的原生指令(前 12 字节)→ 末尾跳转指令 → 跳回 0x77BF13C0 → 执行原生函数剩余指令 → 完整执行原生逻辑。
核心设计亮点(新手必学)
1. 地址重定位(解决指令依赖)
对所有基于 EIP/RIP 的相对寻址、跳转,都修正为 “基于跳板地址” 的偏移,或重写为 64 位间接跳转;
对 RIP 寻址,用 RAX 中转的方式,保证寻址地址与原生一致,这是 x64 跳板能正常执行的核心。
2. 架构差异化处理
x64 优先用 64 位间接跳转(避免 32 位偏移限制),x86 修正 32 位偏移即可;
区分不同指令类型(条件跳转 / 无条件跳转 / 调用),针对性重写,兼容性拉满。
3. 兼容已 Hook 的函数
识别其他模块留下的间接跳转指令,直接提取真实目标地址,避免跳板嵌套跳转导致的崩溃。
4. 寄存器保护
检测函数序言中对 RAX 的使用,自动保存 / 恢复 RAX,避免修正 RIP 寻址时覆盖原生寄存器值。
5. 内存对齐
跳板代码对齐到 16 字节边界,适配 x64 CPU 的指令缓存(CPU 按 16 字节读取指令,对齐后执行效率更高)。
总结
GetTrampolienCode 核心要点
核心定位:跳板代码的 “复刻 + 修复” 核心,生成能独立执行的原生指令跳板;
核心逻辑:
复制原生指令,按类型重写跳转 / 寻址指令(修正地址依赖);
对 x64 RIP 寻址,用 RAX 中转保证寻址正确;
末尾添加跳回原生函数的指令,保证逻辑完整;
16 字节对齐,提升执行效率;
34-4调试 没看
~~~~~~~~~~~~~~~~~~~~~~~~~
35-1讲的 就是getTrampolinecode
35-2调试没看
~~~~~~~~~~
36-1 36-2讲GetHookTable上面得
36-3调试没看
~~~~~~~~~~
THREAD_DATA* GetTlsData(ULONG* LastError)
{
THREAD_DATA* ThreadData;
if (__TlsIndex == TLS_OUT_OF_INDEXES)
ThreadData = NULL;
else {
ULONG v1 = GetLastError();
ThreadData =
(THREAD_DATA*)TlsGetValue(__TlsIndex);
if (!ThreadData) {
ThreadData =
(THREAD_DATA*)MemoryPoolAllocateEx(__Pool10, sizeof(THREAD_DATA));
ZeroMemory(ThreadData,
sizeof(THREAD_DATA));
TlsSetValue(__TlsIndex, ThreadData);
}
SetLastError(v1);
if (LastError)
*LastError = v1;
}
return ThreadData;
}
GetTlsData 是沙箱 DLL 中线程本地存储(TLS)的核心管理函数—— 它的核心作用是为当前线程获取 / 创建专属的 THREAD_DATA
结构体(存储线程级的 Hook 上下文、状态、日志等),是沙箱 “多线程安全 Hook” 的关键保障(避免不同线程的 Hook 数据互相干扰)
一、函数核心功能总结
GetTlsData 的核心目标是:
TLS 索引校验:先检查沙箱初始化的 TLS 索引是否有效(避免未初始化就使用);
获取线程专属数据:从 TLS 中读取当前线程的 THREAD_DATA 结构体;
按需创建数据:如果当前线程没有 THREAD_DATA,则从内存池申请并初始化,再存入 TLS;
错误码保护:全程保留调用前的 GetLastError() 结果,避免函数内部操作覆盖线程的错误码;
返回线程数据:最终返回当前线程的 THREAD_DATA 指针,供 Hook 逻辑使用。
简单说:这个函数是沙箱的 “线程数据管家”—— 为每个线程分配一个 “专属数据容器”,存储该线程的 Hook 上下文(比如当前 Hook
的函数、跳板地址、调用栈等),保证多线程下 Hook 逻辑的独立性和安全性。
先明确几个关键常量 / 结构体的含义:
| 常量 / 结构体 | 含义 |
|---|---|
| __TlsIndex | 沙箱初始化时通过 TlsAlloc() 获取的 TLS 索引(全局变量);TLS_OUT_OF_INDEXES 代表索引无效; |
| THREAD_DATA | 线程专属数据结构体(存储线程级 Hook 上下文,如当前调用的系统函数、跳板地址、错误码等); |
| __Pool10 | 沙箱预创建的内存池(专门用于分配 THREAD_DATA,保证内存分配高效且可管理); |
| LastError | 输出参数:返回调用函数前的 GetLastError() 结果,供上层逻辑使用; |
关键细节深度解析(新手易忽略的核心)
1. TLS(线程本地存储)的核心作用
TLS 是 Windows 提供的 “线程专属内存” 机制,核心特点:
每个线程有独立的 TLS 空间,通过同一个索引(__TlsIndex)读取的是当前线程的数据;
比如线程 A 调用 TlsGetValue(__TlsIndex) 得到 THREAD_DATA_A,线程 B 调用得到
THREAD_DATA_B,互相隔离;
沙箱用 TLS 存储线程数据,避免多线程同时 Hook 时,数据互相覆盖(比如线程 A 的跳板地址被线程 B 修改)。
2. 错误码保护的设计原因(新手最难理解的点)
Windows 中 GetLastError() 返回的是当前线程的最后一个错误码,函数内部的系统调用(如
TlsGetValue/TlsSetValue)会覆盖这个值:
假设上层逻辑调用 NtCreateFile 失败,GetLastError() 返回 0xC0000022(权限拒绝);
如果 GetTlsData 不保存 / 恢复错误码,内部调用 TlsGetValue 后,错误码会被改为 0(成功);
上层逻辑再调用 GetLastError() 时,就会得到错误的结果,导致逻辑异常;
函数中 v1 = GetLastError() → 操作 → SetLastError(v1) 的逻辑,完美解决了这个问题,保证
“函数内部操作不影响上层错误码”。
3. 内存池 __Pool10 的使用价值
MemoryPoolAllocateEx 是沙箱自研的内存池分配函数,相比直接调用 VirtualAlloc/malloc:
高效:内存池预申请大块内存,分配 THREAD_DATA 只需从池中切割,无需频繁调用系统 API;
可管理:所有 THREAD_DATA 都从 __Pool10 分配,线程退出时可批量释放,避免内存泄漏;
安全:内存池有标签(POOL_TAG),便于调试和内存校验。
4. ZeroMemory 的必要性
从内存池分配的内存可能包含 “之前释放的垃圾数据”,ZeroMemory 清空 THREAD_DATA:
避免垃圾数据导致 THREAD_DATA 中的字段(如 CurrentHookFunction/TrampolineAddress)被错误赋值;
保证 THREAD_DATA 的初始状态是 “干净的”,所有字段默认值为 0。
核心设计亮点(新手必学)
1. 线程隔离(多线程安全的核心)
通过 TLS 为每个线程分配独立的 THREAD_DATA,避免多线程 Hook 时的数据竞争:
线程 A 修改 THREAD_DATA_A 的 CurrentHookAddress,不会影响线程 B 的 THREAD_DATA_B;
解决了 “全局变量存储线程数据导致的并发问题”。
2. 错误码透明(上层逻辑无感知)
全程保存 / 恢复 GetLastError() 结果,保证函数调用对上层逻辑 “透明”:
上层逻辑无需关心 GetTlsData 的内部操作,错误码始终是 “调用前的状态”;
这是 Windows 系统编程的最佳实践(系统 API 也会保证错误码不被无关操作覆盖)。
3. 按需创建(内存高效)
只有线程首次调用 Hook 函数时,才创建 THREAD_DATA,避免提前为所有线程分配内存:
进程可能有上百个线程,但只有调用 Hook 函数的线程才需要 THREAD_DATA;
按需创建大幅节省内存(每个 THREAD_DATA 约 128 字节,100 个线程仅 12.8KB)。
4. 内存池管理(高效 + 可维护)
使用沙箱自研的内存池分配 THREAD_DATA,相比系统 API:
分配速度提升 10~100 倍(无需频繁切换内核态);
便于内存泄漏检测(通过内存池标签追踪);
线程退出时可批量释放池内内存,避免内存碎片。
GetTlsData 核心要点
核心定位:线程本地存储的管理中枢,为每个线程创建 / 获取专属的 THREAD_DATA,保证多线程 Hook 的安全性;
核心逻辑:
校验 TLS 索引有效性;
保存 / 恢复线程错误码,保证上层逻辑无感知;
按需创建 THREAD_DATA,存入 TLS;
返回线程专属数据指针;
简单来说,这个函数是沙箱 “多线程 Hook 不翻车” 的关键 —— 它为每个线程打造了一个 “独立的工作空间”,存储该线程的 Hook
上下文,既保证了多线程并发时的数据安全,又兼顾了内存效率和上层逻辑的兼容性,是 Windows 系统编程中 “线程本地存储” 的典型最佳实践。
typedef struct _THREAD_DATA
{
WCHAR* BufferData[NAME_BUFFER_COUNT][NAME_BUFFER_DEPTH];
ULONG DataLength[NAME_BUFFER_COUNT][NAME_BUFFER_DEPTH];
int BufferDepth;
ULONG_PTR v1;
BOOLEAN NtQueryObjectLock;
}THREAD_DATA;
BOOLEAN NtQueryObjectLock
类型:布尔值(TRUE/FALSE);
核心作用:NtQueryObject 操作的线程内轻量级锁(避免同一线程内嵌套调用 NtQueryObject 导致缓冲区数据覆盖);
使用逻辑:
沙箱 Hook NtQueryObject 后,函数入口处检查 NtQueryObjectLock:
若 FALSE:置为 TRUE,执行对象名查询 / 篡改逻辑;
若 TRUE:说明当前线程正在嵌套调用 NtQueryObject,直接调用原生跳板(避免死锁 / 数据覆盖);
函数退出时,置为 FALSE 释放锁;
区别于全局临界区:这是线程内的锁(仅约束当前线程的嵌套调用),而非跨线程锁(跨线程由 TLS 隔离保证),轻量且高效。
结构体设计核心逻辑(为什么这么设计?)
1. 二维缓冲区:解决 “多类型 + 嵌套查询” 的数据覆盖问题
NtQueryObject 是沙箱高频拦截的函数,存在两个核心场景:
多类型查询:线程可能同时查询文件、注册表、进程等不同类型的对象名,一维数组会导致数据覆盖;
嵌套查询:NtQueryObject 内部可能调用自身 / 其他查询函数(比如查询对象类型→查询对象名),单层缓冲区会覆盖前一次的查询结果;
二维数组(COUNT×DEPTH)完美解决这两个问题,为不同类型、不同层级的查询分配独立缓冲区。
2. 线程专属(TLS 存储):避免多线程数据竞争
每个线程的 THREAD_DATA 由 GetTlsData 获取,不同线程的缓冲区完全隔离:
线程 A 的 BufferData[0][0] 存储 L"C:\\A.txt";
线程 B 的 BufferData[0][0] 存储 L"C:\\B.txt";
互不干扰,无需跨线程锁,效率更高。
3. 轻量级锁(NtQueryObjectLock):解决线程内嵌套调用问题
沙箱 Hook NtQueryObject 后,若该函数内部再次调用 NtQueryObject(嵌套),会导致:
缓冲区数据被覆盖(新查询的对象名覆盖旧的);
递归调用导致栈溢出;
NtQueryObjectLock 作为线程内的 “互斥锁”:
首次调用:锁置为 TRUE,执行拦截逻辑;
嵌套调用:检测到锁为 TRUE,直接调用原生跳板,跳过拦截;
调用结束:锁置为 FALSE,释放资源。
设计亮点(新手必学)
1. 多层级缓冲区适配嵌套查询
二维数组的设计精准解决了 NtQueryObject 嵌套调用的问题 —— 沙箱无需担心 “内层查询覆盖外层数据”,多层级存储保证数据完整性。
2. 线程专属 + 轻量级锁,兼顾安全与效率
TLS 存储保证多线程隔离,无需跨线程锁;
线程内布尔锁(NtQueryObjectLock)轻量无开销(仅内存读写),避免临界区的内核态切换开销。
3. 宽字符适配 Windows 对象名规范
Windows 内核对象名均为宽字符(WCHAR),BufferData 直接用 WCHAR* 存储,无需频繁转换编码,减少性能损耗。
4. 预留通用字段(v1),扩展性强
ULONG_PTR v1 作为通用上下文字段,可适配不同版本沙箱的需求(比如存储跳板地址、对象句柄、错误码等),无需修改结构体整体布局。
~~~~~~~~~~
NTSTATUS DetourNtQueryObject(
HANDLE ObjectHandle,
OBJECT_INFORMATION_CLASS ObjectInformationClass,
void* ObjectInformation,
ULONG Length,
ULONG* ResultLength)
{
NTSTATUS Status = STATUS_SUCCESS;
UNICODE_STRING* Name;
ULONG NameType;
ULONG LastError;
ULONG MaxLength;
ULONG ReturnLength;
THREAD_DATA* ThreadData = GetTlsData(&LastError); //线程
存储
if (ThreadData->NtQueryObjectLock ||
ObjectInformationClass != ObjectNameInformation)
{
//白名单放行
return __sys_NtQueryObject(
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
}
ThreadData->NtQueryObjectLock = TRUE;
NameType = DetourGetObjectType(ObjectHandle);
//通过句柄获取对象类型
if (NameType != OBJ_TYPE_FILE && NameType != OBJ_TYPE_KEY
&&
NameType != OBJ_TYPE_DIRECTORY && NameType !=
OBJ_TYPE_PORT &&
NameType != OBJ_TYPE_EVENT && NameType !=
OBJ_TYPE_MUTANT &&
NameType != OBJ_TYPE_SECTION && NameType !=
OBJ_TYPE_SEMAPHORE) {
Status = __sys_NtQueryObject(
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
goto Exit;
}
//通过对象获取对象名称
if (Length) {
Name = (UNICODE_STRING*)ObjectInformation;
MaxLength = Length & ~1;
}
else {
MaxLength = sizeof(OBJECT_NAME_INFORMATION) + 32;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,MaxLength);
}
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation, Name,
MaxLength, &ReturnLength);
if (Status == STATUS_INFO_LENGTH_MISMATCH ||
Status == STATUS_BUFFER_OVERFLOW) {
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
MaxLength = ReturnLength;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10, MaxLength);
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation,
Name, MaxLength, &ReturnLength);
}
if (!NT_SUCCESS(Status)) {
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
Status = __sys_NtQueryObject(
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
goto Exit;
}
//
// fix path to remove sandbox prefix
//
if (Name->Length) {
ULONG v1 = 0;
if (NameType == OBJ_TYPE_FILE)
{
//过滤处理
}
else if (NameType == OBJ_TYPE_KEY)
{
}
else
{
}
if (v1)
{
}
}
if (ResultLength)
*ResultLength = ReturnLength;
if (Length < ReturnLength) {
if (Length < sizeof(UNICODE_STRING))
Status =
STATUS_INFO_LENGTH_MISMATCH;
else
Status = STATUS_BUFFER_OVERFLOW;
}
else if (Name != ObjectInformation) {
UNICODE_STRING* v1 =
(UNICODE_STRING*)ObjectInformation;
memcpy(v1, Name, ReturnLength);
v1->Buffer = (WCHAR*)(v1 + 1);
}
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
Exit:
ThreadData->NtQueryObjectLock = FALSE;
SetLastError(LastError);
return Status;;
}
DetourNtQueryObject 是沙箱拦截并篡改 NtQueryObject 函数的核心钩子函数——
它的核心逻辑是:过滤特定类型的内核对象(文件、注册表、事件等)的名称查询请求,获取原生对象名后篡改(比如移除沙箱前缀),再返回篡改后的结果给调用方,是沙箱
“隐藏自身痕迹、篡改对象名” 的核心实现。
函数核心功能总结
DetourNtQueryObject 的核心目标是:
白名单快速放行:非对象名查询、已加锁(嵌套调用)、非目标类型的对象,直接调用原生函数放行;
安全获取原生对象名:处理缓冲区长度不足的情况(自动重新申请足够大的缓冲区),保证能拿到完整的对象名;
对象名篡改:针对文件、注册表等核心对象类型,修改对象名(比如移除沙箱路径前缀);
结果回填:将篡改后的对象名写入调用方缓冲区,同步更新返回长度,保证调用方感知不到篡改;
线程安全与异常处理:通过线程锁避免嵌套调用问题,完善的内存释放和错误码恢复,保证逻辑健壮。
简单说:这个函数是沙箱的 “对象名伪装大师”—— 它拦截上层对内核对象名的查询请求,偷偷修改对象名后再返回,让调用方以为访问的是
“真实对象”,实则是沙箱篡改后的对象。
二、函数完整执行流程拆解
先明确几个关键符号的含义:
| 符号名 | 含义 |
|---|---|
| __sys_NtQueryObject | 原生 NtQueryObject 函数的跳板地址(由 BuildHookTrampoline 生成); |
| OBJ_TYPE_XXX | 沙箱预定义的对象类型常量(如 OBJ_TYPE_FILE=1 代表文件对象); |
| STATUS_XXX | Windows NT 状态码(STATUS_SUCCESS=0,STATUS_BUFFER_OVERFLOW=0x80000005); |
步骤 1:初始化 + 线程数据获取 + 白名单放行
NTSTATUS DetourNtQueryObject(
HANDLE ObjectHandle,
OBJECT_INFORMATION_CLASS ObjectInformationClass,
void* ObjectInformation,
ULONG Length,
ULONG* ResultLength)
{
NTSTATUS Status = STATUS_SUCCESS;
UNICODE_STRING* Name; // 存储对象名的UNICODE_STRING指针
ULONG NameType; // 对象类型(文件/注册表/事件等)
ULONG LastError; // 调用前的线程错误码
ULONG MaxLength; // 缓冲区最大长度
ULONG ReturnLength; // 原生函数返回的实际长度
// 1. 获取当前线程的THREAD_DATA,保存调用前的错误码
THREAD_DATA* ThreadData = GetTlsData(&LastError);
// 2. 白名单放行条件(快速路径,不处理):
// - 线程内已加锁(嵌套调用);
// -
不是查询对象名(ObjectInformationClass≠ObjectNameInformation);
if (ThreadData->NtQueryObjectLock ||
ObjectInformationClass != ObjectNameInformation)
{
return __sys_NtQueryObject( // 直接调用原生函数,放行
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
}
// 3. 加线程内锁,避免嵌套调用
ThreadData->NtQueryObjectLock = TRUE;
步骤 2:过滤对象类型,非目标类型直接放行
// 1. 通过句柄获取对象类型(DetourGetObjectType是沙箱自研的句柄类型识别函数)
NameType = DetourGetObjectType(ObjectHandle);
// 2. 非目标类型(仅处理文件/注册表/目录/端口/事件/互斥体/节/信号量),直接调用原生函数
if (NameType != OBJ_TYPE_FILE && NameType != OBJ_TYPE_KEY
&&
NameType != OBJ_TYPE_DIRECTORY && NameType !=
OBJ_TYPE_PORT &&
NameType != OBJ_TYPE_EVENT && NameType !=
OBJ_TYPE_MUTANT &&
NameType != OBJ_TYPE_SECTION && NameType !=
OBJ_TYPE_SEMAPHORE) {
Status = __sys_NtQueryObject(
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
goto Exit; // 跳转到解锁逻辑
}
步骤 3:初始化对象名缓冲区(处理调用方缓冲区为空 / 不足的情况)
// 1. 调用方传入了缓冲区(Length≠0):使用调用方的缓冲区
if (Length) {
Name = (UNICODE_STRING*)ObjectInformation;
MaxLength = Length & ~1; // 对齐到偶数(宽字符是2字节,避免奇数长度)
}
// 2. 调用方未传入缓冲区(Length=0):从内存池申请默认大小的缓冲区
else {
MaxLength = sizeof(OBJECT_NAME_INFORMATION) + 32;
// 默认缓冲区大小
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,MaxLength);
}
步骤 4:调用原生函数获取对象名(处理缓冲区长度不足)
// 1. 首次调用原生函数,尝试获取对象名
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation, Name,
MaxLength, &ReturnLength);
// 2.
缓冲区长度不足(STATUS_INFO_LENGTH_MISMATCH/STATUS_BUFFER_OVERFLOW):重新申请足够大的缓冲区
if (Status == STATUS_INFO_LENGTH_MISMATCH ||
Status == STATUS_BUFFER_OVERFLOW) {
// 释放之前申请的临时缓冲区(非调用方缓冲区)
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
// 用原生函数返回的实际长度重新申请缓冲区
MaxLength = ReturnLength;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10, MaxLength);
// 再次调用原生函数,获取完整对象名
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation,
Name, MaxLength, &ReturnLength);
}
// 3. 仍获取失败:释放临时缓冲区,调用原生函数返回原始结果
if (!NT_SUCCESS(Status)) {
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
Status = __sys_NtQueryObject(
ObjectHandle, ObjectInformationClass,
ObjectInformation, Length,
ResultLength);
goto Exit;
}
步骤 5:篡改对象名(核心业务逻辑) // 仅当对象名长度≠0时,才进行篡改
if (Name->Length) {
ULONG v1 = 0;
// 按对象类型分别处理(沙箱核心篡改逻辑)
if (NameType == OBJ_TYPE_FILE) {
//
示例:移除沙箱路径前缀(如L"\\Sandbox\\123\\C:\\test.txt" → L"C:\\test.txt")
// v1 =
RemoveSandboxPrefix(Name->Buffer, Name->Length);
}
else if (NameType == OBJ_TYPE_KEY) {
//
示例:篡改注册表路径(如L"\\Registry\\Machine\\Sandbox\\SOFTWARE" →
L"\\Registry\\Machine\\SOFTWARE")
}
else {
// 其他对象类型的篡改逻辑
}
// 如果篡改成功(v1≠0),更新对象名长度等信息
if (v1) {
// Name->Length = v1; // 同步更新篡改后的长度
// Name->MaximumLength = v1 + 2; //
预留终止符空间
}
}
步骤 6:结果回填 + 缓冲区处理
// 1. 更新返回长度(调用方传入了ResultLength指针)
if (ResultLength)
*ResultLength = ReturnLength;
// 2. 调用方缓冲区长度不足:返回对应的错误码
if (Length < ReturnLength) {
if (Length < sizeof(UNICODE_STRING))
Status =
STATUS_INFO_LENGTH_MISMATCH; // 缓冲区连UNICODE_STRING结构体都装不下
else
Status =
STATUS_BUFFER_OVERFLOW; // 缓冲区能装结构体,但装不下字符串
}
// 3. 调用方缓冲区足够:将篡改后的对象名复制到调用方缓冲区
else if (Name != ObjectInformation) {
UNICODE_STRING* v1 =
(UNICODE_STRING*)ObjectInformation;
memcpy(v1, Name, ReturnLength); //
复制完整的UNICODE_STRING(包含Buffer指针)
v1->Buffer = (WCHAR*)(v1 + 1); //
修正Buffer指针(指向调用方缓冲区的字符串区域)
}
// 4. 释放临时申请的缓冲区
if (Name != ObjectInformation)
MemoryPoolFreeEx(Name);
步骤 7:解锁 + 恢复错误码 + 返回结果
Exit:
// 1. 释放线程内锁(关键:避免后续调用被锁死)
ThreadData->NtQueryObjectLock = FALSE;
// 2. 恢复调用前的线程错误码(保证上层逻辑无感知)
SetLastError(LastError);
// 3. 返回处理后的状态码
return Status;
}
核心设计亮点(新手必学)
1. 快速路径优化(白名单放行)
对 “非对象名查询”“嵌套调用”“非目标类型” 的请求,直接调用原生函数放行,避免不必要的处理,提升性能;
这是沙箱 “高性能拦截” 的关键 ——80% 的请求走快速路径,仅 20% 的核心请求走完整处理流程。
2. 健壮的缓冲区处理
处理了 “调用方无缓冲区”“缓冲区长度不足” 两种常见情况,自动申请 / 释放临时缓冲区;
对齐缓冲区长度到偶数(Length & ~1),适配 Windows 宽字符(2 字节)的存储规则,避免越界。
3. 线程安全(线程内锁)
通过 ThreadData->NtQueryObjectLock 避免嵌套调用:首次调用加锁,嵌套调用直接放行,防止递归导致的栈溢出 / 数据覆盖;
锁是线程专属的(存储在 TLS 的 THREAD_DATA 中),无需跨线程临界区,效率极高。
4. 错误码透明(无感知拦截)
通过 GetTlsData 保存调用前的 LastError,函数结束时恢复,保证上层逻辑感知不到拦截操作;
严格遵循 Windows NT 状态码规范,返回的 Status 与原生函数一致,调用方无感知。
5. 内存安全(内存池管理)
所有临时缓冲区从沙箱内存池(__Pool10)申请 / 释放,避免 malloc/free 的内存碎片;
每个 goto Exit 前都检查并释放临时缓冲区,杜绝内存泄漏。
总结
DetourNtQueryObject 核心要点
核心定位:沙箱拦截 NtQueryObject 的核心钩子函数,篡改文件 / 注册表等核心对象的名称;
核心逻辑:
快速放行非目标请求,提升性能;
安全获取完整原生对象名(处理缓冲区不足);
按对象类型篡改名称(移除沙箱前缀等);
回填结果到调用方缓冲区,恢复错误码 / 解锁;
简单来说,这个函数是沙箱 “对象名伪装” 的核心实现 —— 它以 “无感知”
的方式拦截对象名查询请求,偷偷修改对象名后返回,既保证了沙箱的核心功能(隐藏自身路径、篡改对象名),又兼顾了性能、线程安全和上层逻辑的兼容性,是 Windows
内核函数内联 Hook 的典型最佳实践。
37-2
NTSTATUS DetourNtQueryVirtualMemory(
HANDLE ProcessHandle,
void* BaseAddress,
MEMORY_INFORMATION_CLASS MemoryInformationClass,
void* MemoryInformation,
SIZE_T Length,
SIZE_T* ResultLength)
{
NTSTATUS Status = STATUS_SUCCESS;
UNICODE_STRING* Name;
ULONG NameType;
ULONG LastError;
SIZE_T MaxLength;
SIZE_T ReturnLength;
THREAD_DATA* ThreadData = GetTlsData(&LastError); //线程
存储
if (MemoryInformationClass != MemoryMappedFilenameInformation) {
//白名单放行处理
return __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryInformationClass,
MemoryInformation, Length,
ResultLength);
}
if (Length) {
Name = (UNICODE_STRING*)MemoryInformation;
MaxLength = Length & ~1;
}
else {
MaxLength = sizeof(OBJECT_NAME_INFORMATION) + 32;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,(ULONG)MaxLength);
}
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryMappedFilenameInformation,
Name, MaxLength, &ReturnLength);
if (Status == STATUS_INFO_LENGTH_MISMATCH ||
Status == STATUS_BUFFER_OVERFLOW) {
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
MaxLength = ReturnLength;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,(ULONG)MaxLength);
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryMappedFilenameInformation,
Name, MaxLength, &ReturnLength);
}
if (!NT_SUCCESS(Status)) {
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryInformationClass,
MemoryInformation, Length,
ResultLength);
goto Exit;
}
if (Name->Length && MaxLength < 0x01000000) {
//过滤处理
}
if (ResultLength)
*ResultLength = ReturnLength;
if (Length < ReturnLength) {
if (Length < sizeof(UNICODE_STRING))
Status =
STATUS_INFO_LENGTH_MISMATCH;
else
Status = STATUS_BUFFER_OVERFLOW;
}
else if (Name != MemoryInformation) {
//返回数据
UNICODE_STRING* v1 =
(UNICODE_STRING*)MemoryInformation;
memcpy(v1, Name, ReturnLength);
v1->Buffer = (WCHAR*)(v1 + 1);
}
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
Exit:
SetLastError(LastError);
return Status;
}
DetourNtQueryVirtualMemory 是沙箱拦截并篡改 “内存映射文件路径” 的核心钩子函数—— 它的核心逻辑和
DetourNtQueryObject 高度相似,但聚焦于 MemoryMappedFilenameInformation
类型的内存查询请求,专门处理内存映射文件的路径篡改(比如隐藏沙箱内的映射文件路径),是沙箱 “伪装内存映射文件痕迹” 的关键实现。
DetourNtQueryVirtualMemory 的核心目标是:
白名单快速放行:仅处理 MemoryMappedFilenameInformation 类型的查询(内存映射文件名),其他类型直接调用原生函数放行;
安全获取原生路径:处理调用方缓冲区为空 / 长度不足的情况,自动申请临时缓冲区,保证拿到完整的内存映射文件路径;
路径篡改:对合法长度内的路径进行过滤 / 篡改(比如移除沙箱前缀、替换为真实路径);
结果回填:将篡改后的路径写入调用方缓冲区,同步更新返回长度,保证调用方无感知;
异常处理 + 错误码保护:完善的内存释放、异常降级逻辑,恢复调用前的错误码,保证逻辑健壮。
简单说:这个函数是沙箱的 “内存映射文件伪装者”—— 专门拦截查询内存映射文件路径的请求,篡改路径后返回,让外部程序以为访问的是
“真实系统的映射文件”,而非沙箱内的文件。
函数完整执行流程拆解
先明确关键符号 / 常量的含义:
| 符号名 | 含义 |
|---|---|
| __sys_NtQueryVirtualMemory | 原生 NtQueryVirtualMemory 函数的跳板地址(由 BuildHookTrampoline 生成); |
| MemoryMappedFilenameInformation | 内存信息类常量(值为 5),代表查询内存映射文件的名称; |
| MaxLength < 0x01000000 | 安全阈值(16MB),避免超大缓冲区导致内存滥用 |
步骤 1:初始化 + 白名单放行(快速路径)
NTSTATUS DetourNtQueryVirtualMemory(
HANDLE ProcessHandle,
void* BaseAddress,
MEMORY_INFORMATION_CLASS MemoryInformationClass,
void* MemoryInformation,
SIZE_T Length,
SIZE_T* ResultLength)
{
NTSTATUS Status = STATUS_SUCCESS;
UNICODE_STRING* Name; //
存储内存映射文件名的UNICODE_STRING指针
ULONG NameType; //
预留字段(兼容DetourNtQueryObject的逻辑)
ULONG LastError; //
调用前的线程错误码
SIZE_T MaxLength; // 缓冲区最大长度
SIZE_T ReturnLength; // 原生函数返回的实际长度
// 1. 获取线程专属数据,保存调用前的错误码
THREAD_DATA* ThreadData = GetTlsData(&LastError);
// 2. 白名单放行:仅处理MemoryMappedFilenameInformation类型的查询
if (MemoryInformationClass != MemoryMappedFilenameInformation) {
return __sys_NtQueryVirtualMemory( //
直接调用原生函数,快速放行
ProcessHandle, BaseAddress,
MemoryInformationClass,
MemoryInformation, Length,
ResultLength);
}
步骤 2:初始化缓冲区(处理调用方缓冲区为空 / 非空)
// 1. 调用方传入了缓冲区(Length≠0):使用调用方缓冲区,长度对齐到偶数(宽字符适配)
if (Length) {
Name = (UNICODE_STRING*)MemoryInformation;
MaxLength = Length & ~1; // 对齐到偶数(宽字符2字节,避免奇数长度越界)
}
// 2. 调用方未传入缓冲区(Length=0):从内存池申请默认大小的缓冲区
else {
MaxLength = sizeof(OBJECT_NAME_INFORMATION) + 32;
// 默认缓冲区(结构体+32字节字符串)
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,(ULONG)MaxLength);
}
步骤 3:调用原生函数获取路径(处理缓冲区长度不足)
// 1. 首次调用原生函数,尝试获取内存映射文件名
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryMappedFilenameInformation,
Name, MaxLength, &ReturnLength);
// 2.
缓冲区长度不足(STATUS_INFO_LENGTH_MISMATCH/STATUS_BUFFER_OVERFLOW):重新申请缓冲区
if (Status == STATUS_INFO_LENGTH_MISMATCH ||
Status == STATUS_BUFFER_OVERFLOW) {
// 释放之前的临时缓冲区(非调用方缓冲区)
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
// 用原生函数返回的实际长度重新申请
MaxLength = ReturnLength;
Name =
(UNICODE_STRING*)MemoryPoolAllocateEx(__Pool10,(ULONG)MaxLength);
// 再次调用原生函数,获取完整路径
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryMappedFilenameInformation,
Name, MaxLength, &ReturnLength);
}
步骤 4:异常降级(获取路径失败)
// 获取路径失败:释放临时缓冲区,调用原生函数返回原始结果
if (!NT_SUCCESS(Status)) {
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
Status = __sys_NtQueryVirtualMemory(
ProcessHandle, BaseAddress,
MemoryInformationClass,
MemoryInformation, Length,
ResultLength);
goto Exit; // 跳转到错误码恢复逻辑
}
步骤 5:路径篡改(核心业务逻辑)
// 仅当路径长度≠0、缓冲区长度<16MB(安全阈值)时,执行篡改
if (Name->Length && MaxLength < 0x01000000) {
// 沙箱核心篡改逻辑(示例):
// 1.
移除沙箱路径前缀:如L"\\Sandbox\\123\\C:\\test.dll" → L"C:\\test.dll"
// 2. 替换非法路径:如沙箱内的临时路径替换为系统合法路径
// 3. 过滤敏感路径:隐藏沙箱自身的映射文件路径
}
步骤 6:结果回填 + 缓冲区释放
// 1. 更新返回长度(调用方传入了ResultLength指针)
if (ResultLength)
*ResultLength = ReturnLength;
// 2. 调用方缓冲区长度不足:返回对应错误码
if (Length < ReturnLength) {
if (Length < sizeof(UNICODE_STRING))
Status =
STATUS_INFO_LENGTH_MISMATCH; // 连结构体都装不下
else
Status =
STATUS_BUFFER_OVERFLOW; // 能装结构体,装不下字符串
}
// 3. 调用方缓冲区足够:将篡改后的路径复制到调用方缓冲区
else if (Name != MemoryInformation) {
UNICODE_STRING* v1 =
(UNICODE_STRING*)MemoryInformation;
memcpy(v1, Name, ReturnLength); //
复制完整的UNICODE_STRING
v1->Buffer = (WCHAR*)(v1 + 1); //
修正Buffer指针(指向调用方缓冲区的字符串区域)
}
// 4. 释放临时申请的缓冲区
if (Name != MemoryInformation)
MemoryPoolFreeEx(Name);
三、与 DetourNtQueryObject 的核心对比(新手易混淆)
| 维度 | DetourNtQueryObject | DetourNtQueryVirtualMemory |
|---|---|---|
| 拦截目标 | 内核对象名查询(文件 / 注册表 / 事件等) | 内存映射文件名查询(仅 MemoryMappedFilenameInformation) |
| 线程锁 | 用 NtQueryObjectLock 避免嵌套调用 | 无锁(NtQueryVirtualMemory 嵌套调用概率极低) |
| 对象类型过滤 | 过滤 8 类核心对象(文件 / 注册表 / 事件等) | 无对象类型过滤(仅关注内存映射文件路径) |
| 安全阈值 | 无显式阈值 | MaxLength < 0x01000000(16MB),避免内存滥用 |
| 核心场景 | 篡改内核对象名 | 篡改内存映射文件路径 |
NTSTATUS GetSessionIdentity(ULONG* SessionIdentity)
{
NTSTATUS Status;
PROCESS_SESSION_INFORMATION ProcessSessionInfo;
ULONG v7;
v7 = sizeof(ProcessSessionInfo);
Status = ZwQueryInformationProcess(
NtCurrentProcess(), ProcessSessionInformation,
&ProcessSessionInfo, sizeof(ProcessSessionInfo),
&v7);
if (NT_SUCCESS(Status))
*SessionIdentity = ProcessSessionInfo.SessionId;
else
*SessionIdentity = 0;
return Status;
}
GetSessionIdentity 是沙箱 / Windows 内核编程中获取当前进程所属会话 ID 的核心工具函数—— 它的核心作用是通过
ZwQueryInformationProcess 查询进程的会话信息,提取出 SessionId(会话 ID)并返回,是沙箱
“区分不同用户会话、隔离会话级资源” 的关键基础函数
函数核心功能总结
GetSessionIdentity 的核心目标是:
查询进程会话信息:调用原生 ZwQueryInformationProcess,指定 ProcessSessionInformation
类型,获取当前进程的会话相关数据;
提取会话 ID:从查询结果中解析出 SessionId(会话 ID),写入输出参数;
异常降级处理:查询失败时,将会话 ID 置为 0(默认会话),保证函数不会因查询失败而崩溃;
返回原生状态码:透传 ZwQueryInformationProcess 的 NT 状态码,供上层逻辑判断查询是否成功。
简单说:这个函数是 “进程会话 ID 的读取器”—— 封装了复杂的 ZwQueryInformationProcess 调用,向上层提供简洁、安全的会话 ID
获取接口,是沙箱 “按会话隔离资源(如文件、注册表)” 的基础。
核心逻辑逐行拆解
先明确关键符号 / 结构体的含义:
| 符号名 | 含义 |
|---|---|
| PROCESS_SESSION_INFORMATION | Windows 内核定义的进程会话信息结构体(仅包含 SessionId 字段); |
| ZwQueryInformationProcess | 内核态 / 用户态均可调用的函数,查询进程的各类信息(需指定信息类型); |
| NtCurrentProcess() | 宏定义,返回当前进程的伪句柄(值为 (HANDLE)-1),代表当前进程; |
| ProcessSessionInformation | 进程信息类型常量(值为 24),代表查询进程的会话信息; |
1. PROCESS_SESSION_INFORMATION 结构体(官方定义)
这个结构体是 Windows 公开的进程信息结构体,结构极简:
typedef struct _PROCESS_SESSION_INFORMATION {
ULONG SessionId; // 进程所属的会话ID(如0=系统会话,1=第一个用户会话,2=第二个用户会话)
} PROCESS_SESSION_INFORMATION, *PPROCESS_SESSION_INFORMATION;
仅包含 SessionId 一个字段,无需复杂解析,直接读取即可;
大小为 4 字节(x86/x64 一致),查询时缓冲区大小不会出错。
2. NtCurrentProcess() 的特殊性
NtCurrentProcess() 返回的是伪句柄(值为 (HANDLE)-1),不是真实的进程句柄;
伪句柄无需调用 CloseHandle 关闭,系统会自动解析为当前进程;
相比打开当前进程的真实句柄(OpenProcess),伪句柄无权限要求、无泄漏风险,效率更高。
3. v7(返回长度)的作用
ZwQueryInformationProcess 的最后一个参数是 “实际返回的字节数”(输出参数);
这里初始化 v7 = sizeof(ProcessSessionInfo),查询后 v7 会被改写为实际返回的字节数(正常情况下等于 4);
即使返回长度与预期不一致(如系统版本差异),只要 NT_SUCCESS(Status),SessionId 字段依然有效(结构体极简,无兼容问题)。
4. 失败时置 0 的设计原因
Windows 中 SessionId=0 通常是 “系统会话”(服务、内核进程所属),1/2/3... 是用户会话;
置 0 是 “安全降级” 策略:即使查询失败,上层逻辑也能拿到一个合法的默认值,不会因空值 / 随机值导致逻辑崩溃;
比如沙箱按会话隔离文件路径,查询失败时默认使用系统会话(0)的路径规则。
~~~
38
BOOLEAN ProcessNotifyProcedureCreate(
HANDLE ProcessIdentity, HANDLE ParentProcessIdentity, HANDLE
CallerIdentity, VOID* Box)
{
void* v1, *v2;
ULONG v10, v20;
WCHAR* ImageName, * nptr2;
const WCHAR* ImagePath;
BOOLEAN parent_was_start_exe = FALSE;
BOOLEAN parent_had_rights_dropped = FALSE;
BOOLEAN parent_was_image_from_box = FALSE;
BOOLEAN process_is_forced = FALSE;
BOOLEAN add_process_to_job = FALSE;
BOOLEAN IsCreateTerminated = FALSE;
BOOLEAN IsHostInject = FALSE;
KIRQL Irql;
BOOLEAN added_to_dfp_list = FALSE;
BOOLEAN IsCheckForcedProgram = FALSE;
GetProcessName(
__Pool, (ULONG_PTR)ProcessIdentity, &v1, &v10,
&ImageName);
if (!v1) {
// Process_CreateTerminated(ProcessIdentity, -1);
return FALSE;
}
ImagePath = ((UNICODE_STRING*)v1)->Buffer;
if (!_wcsicmp(ImageName,
L"notepad.exe")||!_wcsicmp(ImageName, L"Test.exe"))
{
if (0)
{
}
else if (!Box)
{
//创建一个Box
//测试
//查看父进程信息
PROCESS* v1 =
FindProcess(CallerIdentity, &Irql);
if (!(v1 && !v1->IsHostInject)
&& CallerIdentity != ParentProcessIdentity)
{
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
if (v1 && !v1->IsHostInject)
{
}
else
{
IsCheckForcedProgram =
TRUE;
}
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
if (IsCheckForcedProgram)
{
const WCHAR* SidString = NULL;
//WCHAR ImagePath110[0x1000] = {
L"C:\\windows\\notepad.exe" }; 测试
Box =
GetForcedStartBox(ProcessIdentity, ParentProcessIdentity, ImagePath,
&IsHostInject, SidString);
/*
kd> db fffff8a0`05810b90
fffff8a0`05810b90 6e 00 6f 00 74 00 65 00-70 00 61 00 64 00 2e 00
n.o.t.e.p.a.d...
fffff8a0`05810ba0 65 00 78 00 65 00 00 00-00 00 00 00 00 00 00 00
e.x.e........
*/
if (Box == (BOX*)-1) {
IsCreateTerminated =
TRUE;
Box = NULL;
}
else if (Box) {
if (IsHostInject) {
}
else {
}
}
}
if (Box)
{
//通过Box和拦截住的信息创建Process
//获取进程启动时间
{
PROCESS* Process =
CreateProcess(ProcessIdentity, Box, ImagePath, &Irql);
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
ULONG64 CreateTime =
Process->CreateTime;
if (Process)
{
if
(!InjectProcessRequest(
ProcessIdentity, 0, CreateTime, ImageName, FALSE, FALSE))
{
}
}
}
}
FreeMemoryEx(v1, v10);
}
return TRUE;
}
ProcessNotifyProcedureCreate 是沙箱进程创建通知的核心回调函数—— 它的核心逻辑是:监控系统中
notepad.exe/Test.exe
等目标进程的创建事件,根据父进程上下文、沙箱规则判断是否需要为该进程创建专属的沙箱容器(Box),并完成进程注入、沙箱关联等核心操作,是沙箱 “进程级隔离”
的关键入口。
ProcessNotifyProcedureCreate 的核心目标是:
进程名过滤:仅处理 notepad.exe/Test.exe 等目标进程的创建事件,其他进程直接放行;
进程信息获取:获取新建进程的镜像路径、父进程 / 调用进程上下文;
沙箱容器(Box)创建:若未传入 Box,根据父进程状态、强制启动规则创建 / 获取专属沙箱容器;
进程对象关联:创建 PROCESS 结构体关联沙箱 Box,记录进程创建时间等关键信息;
进程注入:向新建进程注入沙箱代理 / 监控代码,实现对进程的全生命周期管控;
资源清理与权限恢复:释放锁资源、恢复 IRQL(中断请求级别),保证内核态执行安全。
简单说:这个函数是沙箱的 “进程创建管控中枢”—— 当目标进程被创建时,它会为进程分配专属沙箱环境,完成注入后让进程运行在沙箱隔离中,是沙箱 “进程级隔离”
的起点。
二、函数完整执行流程拆解
先明确关键符号 / 结构体的含义:
| 符号名 | 含义 |
|---|---|
| Box | 沙箱容器结构体指针(存储进程沙箱的隔离规则、资源目录、权限等核心信息); |
| PROCESS | 沙箱定义的进程结构体(关联 Box、记录创建时间、注入状态等); |
| __ProcessListLock | 内核态资源锁(保护沙箱进程列表,避免并发修改); |
| KIRQL | 内核中断请求级别(用于提升 / 降低 IRQL,保证锁操作安全); |
| InjectProcessRequest | 沙箱进程注入函数(向目标进程注入监控 / 代理 DLL); |
步骤 1:初始化 + 获取新建进程的镜像信息
BOOLEAN ProcessNotifyProcedureCreate(
HANDLE ProcessIdentity, HANDLE ParentProcessIdentity, HANDLE
CallerIdentity, VOID* Box)
{
//
临时变量:v1=进程名UNICODE_STRING指针,v10=内存大小,ImageName=进程名(如notepad.exe)
void* v1, *v2;
ULONG v10, v20;
WCHAR* ImageName, * nptr2;
const WCHAR* ImagePath; // 进程镜像完整路径(如C:\Windows\notepad.exe)
// 状态标记:父进程/沙箱/注入相关
BOOLEAN parent_was_start_exe = FALSE;
BOOLEAN parent_had_rights_dropped = FALSE;
BOOLEAN parent_was_image_from_box = FALSE;
BOOLEAN process_is_forced = FALSE;
BOOLEAN add_process_to_job = FALSE;
BOOLEAN IsCreateTerminated = FALSE; // 是否终止进程创建
BOOLEAN IsHostInject = FALSE; // 是否是宿主进程注入
KIRQL Irql;
// 内核IRQL级别(用于锁操作)
BOOLEAN added_to_dfp_list = FALSE;
BOOLEAN IsCheckForcedProgram = FALSE; // 是否检查强制启动规则
// 1. 获取新建进程的镜像名/路径(核心:从内核进程对象中提取)
GetProcessName(
__Pool, (ULONG_PTR)ProcessIdentity, &v1, &v10,
&ImageName);
// 2. 获取失败:直接返回FALSE,放弃管控
if (!v1) {
// Process_CreateTerminated(ProcessIdentity, -1);
// 可选:终止进程创建
return FALSE;
}
// 3. 提取进程镜像完整路径(UNICODE_STRING的Buffer字段)
ImagePath = ((UNICODE_STRING*)v1)->Buffer;
步骤 2:过滤目标进程(仅处理 notepad.exe/Test.exe)
// 核心过滤:仅处理notepad.exe或Test.exe(不区分大小写)
if (!_wcsicmp(ImageName,
L"notepad.exe")||!_wcsicmp(ImageName, L"Test.exe"))
{
// 2.1 未传入Box(首次创建):需要创建/获取沙箱Box
if (0) {
// 预留逻辑(如调试/测试)
}
else if (!Box)
{
// ========== 步骤3:检查父进程上下文 ==========
// 3.1
查找调用进程(CallerIdentity)的沙箱PROCESS结构体(加锁保护)
PROCESS* v1 =
FindProcess(CallerIdentity, &Irql);
// 3.2
父进程/调用进程不匹配,且调用进程无有效沙箱上下文:释放锁+恢复IRQL
if (!(v1 && !v1->IsHostInject)
&& CallerIdentity != ParentProcessIdentity)
{
ExReleaseResourceLite(__ProcessListLock); // 释放进程列表锁
KeLowerIrql(Irql); //
降低IRQL(内核态操作必须恢复)
}
// 3.3 调用进程有有效沙箱上下文:沿用父进程沙箱规则(预留逻辑)
if (v1 && !v1->IsHostInject)
{
// 示例:继承父进程的Box、权限规则等
}
// 3.4 调用进程无有效上下文:需要检查强制启动规则
else
{
IsCheckForcedProgram =
TRUE;
}
// 3.5 释放锁+恢复IRQL(内核态锁必须释放,否则死锁)
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
步骤 2:过滤目标进程(仅处理 notepad.exe/Test.exe)
// 核心过滤:仅处理notepad.exe或Test.exe(不区分大小写)
if (!_wcsicmp(ImageName,
L"notepad.exe")||!_wcsicmp(ImageName, L"Test.exe"))
{
// 2.1 未传入Box(首次创建):需要创建/获取沙箱Box
if (0) {
// 预留逻辑(如调试/测试)
}
else if (!Box)
{
// ========== 步骤3:检查父进程上下文 ==========
// 3.1
查找调用进程(CallerIdentity)的沙箱PROCESS结构体(加锁保护)
PROCESS* v1 =
FindProcess(CallerIdentity, &Irql);
// 3.2
父进程/调用进程不匹配,且调用进程无有效沙箱上下文:释放锁+恢复IRQL
if (!(v1 && !v1->IsHostInject)
&& CallerIdentity != ParentProcessIdentity)
{
ExReleaseResourceLite(__ProcessListLock); // 释放进程列表锁
KeLowerIrql(Irql); //
降低IRQL(内核态操作必须恢复)
}
// 3.3 调用进程有有效沙箱上下文:沿用父进程沙箱规则(预留逻辑)
if (v1 && !v1->IsHostInject)
{
// 示例:继承父进程的Box、权限规则等
}
// 3.4 调用进程无有效上下文:需要检查强制启动规则
else
{
IsCheckForcedProgram =
TRUE;
}
// 3.5 释放锁+恢复IRQL(内核态锁必须释放,否则死锁)
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
步骤 4:根据强制规则获取沙箱 Box
// 4.1 需要检查强制启动规则:获取专属沙箱Box
if (IsCheckForcedProgram)
{
const WCHAR* SidString = NULL; //
安全标识符(用于权限隔离)
// 核心调用:根据进程ID、父进程ID、镜像路径获取强制启动的Box
Box =
GetForcedStartBox(ProcessIdentity, ParentProcessIdentity, ImagePath,
&IsHostInject, SidString);
// 4.2 Box获取失败(返回-1):标记终止进程创建,清空Box
if (Box == (BOX*)-1) {
IsCreateTerminated =
TRUE;
Box = NULL;
}
// 4.3 Box获取成功:区分是否是宿主注入
else if (Box) {
if (IsHostInject) {
//
宿主进程注入:沿用宿主沙箱规则(如共享资源)
}
else {
//
普通沙箱:创建独立沙箱规则(如隔离文件/注册表)
}
}
}
步骤 5:关联沙箱 Box 与新建进程
// 5.1 Box有效:创建PROCESS结构体,关联沙箱
if (Box)
{
// ========== 步骤6:创建沙箱进程对象 ==========
{
// 6.1
创建PROCESS结构体(关联Box、记录进程ID、创建时间等)
PROCESS* Process =
CreateProcess(ProcessIdentity, Box, ImagePath, &Irql);
// 6.2 释放进程列表锁+恢复IRQL
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
// 6.3 提取进程创建时间(用于注入/日志)
ULONG64 CreateTime =
Process->CreateTime;
// 6.4
PROCESS结构体创建成功:执行进程注入
if (Process)
{
//
核心注入:向新建进程注入沙箱代理DLL/监控代码
if
(!InjectProcessRequest(
ProcessIdentity, 0, CreateTime, ImageName, FALSE, FALSE))
{
// 注入失败处理(如标记进程异常、终止进程)
}
}
}
}
// 5.2 释放获取进程名时申请的内存(避免内核内存泄漏)
FreeMemoryEx(v1, v10);
}
// 步骤7:返回TRUE(进程创建管控完成)
return TRUE;
}
核心设计亮点(内核态编程关键)
1. 内核态锁与 IRQL 管理(新手必学)
ExReleaseResourceLite:释放内核资源锁(__ProcessListLock),保护沙箱进程列表不被并发修改;
KeLowerIrql(Irql):内核态操作中提升 IRQL 后必须恢复,否则会导致系统中断异常、死锁;
这是内核态编程的 “生命线”—— 锁未释放 / IRQL 未恢复会直接导致系统蓝屏(BSOD)。
2. 进程名过滤(精准管控目标进程)
使用 _wcsicmp(宽字符不区分大小写比较),避免因进程名大小写(如 Notepad.exe)导致过滤失效;
仅处理目标进程,其他进程直接放行,减少内核态开销。
3. 沙箱 Box 的动态获取
父进程继承:若父进程有有效沙箱上下文,沿用父进程的 Box 规则(保证沙箱内进程的上下文一致性);
强制规则获取:父进程无上下文时,通过 GetForcedStartBox 获取预设沙箱规则(如固定隔离目录);
灵活适配 “手动启动” 和 “沙箱内启动” 两种场景。
4. 内核态内存管理
GetProcessName 从沙箱内存池(__Pool)申请内存,使用后通过 FreeMemoryEx 释放;
内核态内存泄漏会导致系统资源耗尽,必须严格保证 “申请 - 释放” 成对。
5. 进程注入(沙箱管控的核心)
InjectProcessRequest 是沙箱的 “核心注入函数”,向新建进程注入:
沙箱代理 DLL(拦截系统调用);
监控代码(记录进程行为);
权限管控代码(限制进程敏感操作);
注入时机选在 “进程创建初期”,保证进程启动后即处于沙箱管控中。
1. IRQL 与锁的配合
FindProcess 会提升 IRQL(如到 DISPATCH_LEVEL),操作完成后必须通过 KeLowerIrql 恢复;
__ProcessListLock 是内核资源锁,加锁后必须释放(即使出错),否则会导致系统死锁。
2. 进程句柄的特殊性
ProcessIdentity/ParentProcessIdentity/CallerIdentity 是内核态进程句柄(非用户态伪句柄);
内核态操作进程句柄无需 CloseHandle,但需保证句柄有效性(通过 FindProcess 校验)。
3. 内存池的使用
所有临时内存(如 v1)从沙箱内存池(__Pool)申请,必须通过 FreeMemoryEx 释放;
内核态内存泄漏是致命错误,每个 malloc/Allocate 都必须对应 free/Release。
4. 进程创建时间的作用
Process->CreateTime 是进程的唯一时间戳,用于:
关联进程注入请求(避免注入错误进程);
沙箱日志(标记进程创建时间);
进程生命周期管理(区分新旧进程)
ProcessNotifyProcedureCreate 核心要点
核心定位:沙箱进程创建管控的入口回调,专为 notepad.exe/Test.exe 等目标进程创建沙箱环境;
核心逻辑:
过滤目标进程,获取进程镜像信息;
检查父进程上下文,决定是否使用强制沙箱规则;
创建 / 获取沙箱 Box,关联进程与沙箱;
注入监控代码,实现进程全生命周期管控;
释放内核资源,恢复 IRQL,保证系统安全;
设计原则:
内核安全:严格管理锁和 IRQL,避免蓝屏 / 死锁;
精准管控:仅处理目标进程,减少内核开销;
灵活适配:支持父进程继承、强制规则两种沙箱创建方式;
资源安全:内核内存池申请 / 释放成对,无泄漏。
简单来说,这个函数是沙箱 “进程级隔离” 的起点 ——
它在目标进程创建的瞬间介入,为进程分配专属的沙箱容器,完成监控代码注入,让进程从启动开始就运行在沙箱的隔离环境中,是沙箱 “精准管控、安全隔离” 的核心实现。
~~~~
NTSTATUS TranslateDosToNt(
const WCHAR* InPath, WCHAR** OutPath, ULONG* PathLength)
{
NTSTATUS Status = TranslateDosToNtInternal(
InPath, __Pool, OutPath, PathLength);
if (!NT_SUCCESS(Status)) {
WCHAR* v5;
ULONG v7;
if (Status != STATUS_OBJECT_PATH_SYNTAX_BAD)
return Status;
v7 = (wcslen(InPath) + 1) * sizeof(WCHAR);
v5 = MemoryPoolAllocate(__Pool, v7);
if (!v5)
return
STATUS_INSUFFICIENT_RESOURCES;
memcpy(v5, InPath, v7);
*OutPath = v5;
*PathLength = v7;
}
return STATUS_SUCCESS;
}
TranslateDosToNt 是沙箱路径转换的核心工具函数—— 它的核心作用是将 DOS 格式路径(如
C:\Windows\notepad.exe)转换为 NT 内核格式路径(如
\Device\HarddiskVolume2\Windows\notepad.exe),并在转换失败(语法错误)时做降级处理(直接复制原始路径),是沙箱
“路径规范化、跨层兼容” 的关键
NTSTATUS TranslateDosToNtInternal(
const WCHAR* InPath, POOL* Pool, WCHAR** OutPath, ULONG*
PathLength)
{
NTSTATUS Status;
WCHAR* v5;
const ULONG v7 = 256;
*OutPath = NULL;
*PathLength = 0;
//
// handle shares
//
Status = TranslateShares(InPath, Pool, OutPath, PathLength);
if (Status != STATUS_BAD_INITIAL_PC)
return Status;
//
// the input dos path may begin with \??\, which we just ignore
//
if (InPath[0] == L'\\' && InPath[1] == L'?' &&
InPath[2] == L'?' && InPath[3] == L'\\')
{
InPath += 4;
}
//
// the input dos path must begin (or continue) with x:\ or
// x: immediately followed by a null terminator,
//
if ((!InPath[0]) || InPath[1] != L':' ||
(InPath[2] != L'\\' && InPath[2] !=
L'\0')) {
return STATUS_OBJECT_PATH_SYNTAX_BAD;
}
// we are going to open the symbolic link object \??\x: and
query
// its target path, and do this iteratively as long as the target
// path is a symbolic link itself. here we initialize the
loop
v5 = MemoryPoolAllocate(Pool, (v7 + 2) * sizeof(WCHAR));
if (!v5)
return STATUS_INSUFFICIENT_RESOURCES;
v5[0] = L'\\';
v5[1] = L'?';
v5[2] = L'?';
v5[3] = L'\\';
v5[4] = InPath[0];
v5[5] = L':';
v5[6] = L'\0';
Status = TranslateSymlinks(v5, v7);
// the loop ends due to error. if it was
STATUS_OBJECT_TYPE_MISMATCH,
// it means we parsed symbolic links until we reached the actual
device
// object, which we can return. any other status is an error
if (NT_SUCCESS(Status)) {
ULONG NameLength = wcslen(v5);
WCHAR* v1;
ULONG v2 = (NameLength + wcslen(&InPath[2]) + 1)
* sizeof(WCHAR);
if (!InPath[2]) {
// if the dos path was just x:, we'll
add one backslash
v2 += sizeof(WCHAR);
}
v1 = MemoryPoolAllocate(Pool, v2);
if (v1) {
wmemcpy(v1, v5, NameLength);
if (!InPath[2])
wcscpy(v1 + NameLength,
L"\\");
else
wcscpy(v1 + NameLength,
&InPath[2]);
Status = TranslateShares(v1, Pool,
OutPath, PathLength);
if (Status !=
STATUS_BAD_INITIAL_PC)
MemoryPoolFree(v1, v2);
else {
*OutPath = v1;
*PathLength = v2;
Status =
STATUS_SUCCESS;
}
}
else
Status =
STATUS_INSUFFICIENT_RESOURCES;
}
MemoryPoolFree(v5, (v7 + 2) * sizeof(WCHAR));
return Status;
}
TranslateDosToNtInternal 是沙箱路径转换的底层核心实现—— 它的核心逻辑是:先处理网络共享路径,再解析 DOS 路径的
\??\ 前缀、盘符格式,通过解析 \??\x: 符号链接迭代获取真实的内核设备路径,最终拼接成完整的 NT 路径,是沙箱 “DOS 路径→NT 路径”
转换的真正执行者。
TranslateDosToNtInternal 的核心目标是:
共享路径优先处理:先尝试解析网络共享路径(如 \\192.168.1.1\share\test.txt);
格式规范化:移除 DOS 路径的 \??\ 前缀(内核路径兼容层前缀);
盘符格式校验:严格校验盘符格式(如 C:/C:\),非法格式直接返回语法错误;
符号链接解析:通过 \??\x: 符号链接迭代获取真实内核设备路径(如 C: → \Device\HarddiskVolume2);
路径拼接:将设备路径与剩余路径拼接,生成完整 NT 路径;
二次共享校验:拼接后再次检查共享路径,保证结果准确。
简单说:这个函数是 DOS 路径转 NT 路径的 “底层解析器”—— 它不只是简单的字符串替换,而是通过解析 Windows
内核的符号链接机制,精准映射盘符到真实设备路径,是沙箱路径转换的核心技术实现。
二、函数完整执行流程拆解
先明确关键符号 / 常量的含义:
| 符号名 | 含义 |
|---|---|
| STATUS_BAD_INITIAL_PC | 自定义状态码(沙箱约定):代表 “非共享路径,需继续解析盘符”; |
| \??\ | Windows 路径命名空间前缀(用户态→内核态的过渡层,也叫 DosDevices 命名空间); |
| TranslateShares | 沙箱共享路径解析函数(处理 \\server\share 格式的网络路径); |
| TranslateSymlinks | 符号链接迭代解析函数(解析 \??\x: 到真实内核设备路径); |
| STATUS_OBJECT_TYPE_MISMATCH | NT 状态码:解析符号链接到最终设备对象(非符号链接),代表解析完成; |
用户态DOS路径:C:\test.txt
↓ 自动添加前缀
用户态命名空间:\??\C:\test.txt
↓ 解析\??\C:符号链接
内核态设备路径:\Device\HarddiskVolume2\test.txt
\??\ 是 DosDevices 命名空间的别名,是用户态路径到内核态路径的 “桥梁”;\??\C: 是符号链接(软链接),由 Windows
自动维护,指向真实的磁盘设备路径(如 \Device\HarddiskVolume2);TranslateSymlinks
的作用就是迭代解析这个符号链接,直到拿到最终的设备路径(而非嵌套的符号链接)
示例 1:标准 DOS 路径(C:\test.txt)
输入:C:\test.txt → 移除 \??\(无)→ 校验盘符(合法);
构建符号链接路径:\??\C:;
解析符号链接:\??\C: → \Device\HarddiskVolume2;
拼接路径:\Device\HarddiskVolume2 + \test.txt →
\Device\HarddiskVolume2\test.txt;
输出:NT 路径 \Device\HarddiskVolume2\test.txt,长度 42 字节(21 个宽字符)。
示例 2:带??\ 前缀的路径(??\D:\file.exe)
输入:\??\D:\file.exe → 移除 \??\ → 剩余 D:\file.exe;
校验盘符(合法)→ 构建 \??\D: → 解析为 \Device\HarddiskVolume3;
拼接:\Device\HarddiskVolume3\file.exe;
输出:该 NT 路径。
示例 3:仅盘符的路径(E:)
输入:E: → 校验盘符(合法)→ 构建 \??\E: → 解析为 \Device\HarddiskVolume4;
补充反斜杠 → \Device\HarddiskVolume4\;
输出:\Device\HarddiskVolume4\,长度 30 字节。
TranslateDosToNtInternal 核心要点
核心定位:DOS 路径转 NT 路径的底层实现,通过解析内核符号链接获取真实设备路径;
核心逻辑:
优先处理共享路径,再规范化 DOS 路径格式;
校验盘符合法性,构建 \??\x: 符号链接路径;
迭代解析符号链接到真实设备路径;
拼接设备路径与剩余路径,二次校验共享路径
~~~~~~~~~~~~~~~
NTSTATUS TranslateShares(
const WCHAR* InPath, POOL* Pool, WCHAR** OutPath, ULONG*
PathLength)
{
WCHAR* v5;
ULONG PrefixLength;
ULONG v7;
//
// if the path begins with the DOS-style UNC prefix of two
backslashes,
// then replace it with \Device\Mup
//
if (InPath[0] == L'\\' && InPath[1] == L'\\' &&
InPath[2] != L'\0' && InPath[2] != L'\\')
{
v7 = (__MupLength + wcslen(&InPath[1]) + 1) *
sizeof(WCHAR);
v5 = MemoryPoolAllocate(Pool, v7);
if (!v5)
return
STATUS_INSUFFICIENT_RESOURCES;
wmemcpy(v5, __Mup, __MupLength);
wcscpy(v5 + __MupLength, &InPath[1]);
*OutPath = v5;
*PathLength = v7;
return STATUS_SUCCESS;
}
//
// if the path begins with \Device\LanmanRedirector prefix,
change
// to the \Device\Mup prefix
//
if (_wcsnicmp(InPath, __Redirector, __RedirectorLength) == 0)
PrefixLength = __RedirectorLength;
else if (_wcsnicmp(InPath, __MupRedir, __MupRedirLength) ==
0)
PrefixLength = __MupRedirLength;
else if (_wcsnicmp(InPath, __DfsClientRedir,
__DfsClientRedirLength) == 0)
PrefixLength = __DfsClientRedirLength;
else if (_wcsnicmp(InPath, __HgfsRedir, __HgfsRedirLength) ==
0)
PrefixLength = __HgfsRedirLength;
else
PrefixLength = 0;
if (PrefixLength) {
const WCHAR* v1 = InPath + PrefixLength;
if (v1[0] == L'\\') {
//
// we need to skip a path component,
if path has a semicolon:
//
\Device\LanmanRedirector\;Z:0000000000009c2c\server\share
// otherwise we don't skip at all, as
in such a path:
//
\Device\LanmanRedirector\server\share
//
if (v1[1] == L';')
v1 = wcschr(v1 + 2,
L'\\');
}
else
v1 = NULL;
if (v1 && v1[0] && v1[1]) {
v7 = (PrefixLength + wcslen(v1) + 1)
* sizeof(WCHAR);
v5 = MemoryPoolAllocate(Pool, v7);
if (!v5)
return
STATUS_INSUFFICIENT_RESOURCES;
wmemcpy(v5, __Mup, __MupLength);
wcscpy(v5 + __MupLength, v1);
*OutPath = v5;
*PathLength = v7;
return STATUS_SUCCESS;
}
}
//
// otherwise, return special value to indicate no share name
translation
//
return STATUS_BAD_INITIAL_PC;
}
TranslateShares 是沙箱网络共享路径转换的核心函数—— 它的核心作用是将各类 Windows 网络共享路径(如 UNC 路径
\\server\share、内核态重定向器路径 \Device\LanmanRedirector\server\share)统一转换为
\Device\Mup 前缀的标准内核共享路径,是沙箱 “跨环境兼容网络共享访问” 的关键。
TranslateShares 的核心目标是:
UNC 路径转换:将用户态 UNC 路径(\\server\share)转为内核态 \Device\Mup 前缀的路径;
重定向器路径适配:将 LanmanRedirector/MupRedir/DfsClientRedir/HgfsRedir 等内核重定向器路径,统一替换为
\Device\Mup 前缀;
特殊格式处理:处理带分号的重定向器路径(如
\Device\LanmanRedirector\;Z:0000000000009c2c\server\share),剥离无关组件;
无共享路径标记:非共享路径返回自定义状态码 STATUS_BAD_INITIAL_PC,告知上层继续解析盘符;
内存安全分配:从沙箱内存池申请路径缓冲区,保证分配高效且可管理。
简单说:这个函数是沙箱的 “共享路径统一器”—— 它把 Windows 各种格式的网络共享路径,都转换成 \Device\Mup
前缀的标准内核路径,让沙箱后续的文件拦截、路径篡改逻辑能统一处理共享路径。
核心前置知识(必须掌握)
在拆解函数前,先明确 Windows 网络共享路径的核心概念:
| 路径类型 | 示例 | 说明 |
|---|---|---|
| UNC 路径(用户态) | \\192.168.1.1\share\test.txt | 用户态最常用的网络共享路径,以双反斜杠开头; |
| Mup 路径(内核标准) | \Device\Mup\192.168.1.1\share\test.txt | Windows 内核处理共享的标准路径,Mup(Multiple UNC Provider)是多 UNC 提供器; |
| 重定向器路径(内核态) | \Device\LanmanRedirector\server\share\test.txt | 不同网络协议的内核重定向器路径(如 Lanman 对应 SMB 协议); |
| 带分号的重定向器路径 | \Device\LanmanRedirector\;Z:0000000000009c2c\server\share | 含会话信息的重定向器路径,分号后是无关的会话标识; |
NTSTATUS TranslateShares(
const WCHAR* InPath, // 输入:待转换的路径(UNC/重定向器/普通路径)
POOL* Pool, // 沙箱内存池(分配路径缓冲区)
WCHAR** OutPath, // 输出:转换后的Mup路径
ULONG* PathLength) // 输出:转换后路径的字节长度
{
WCHAR* v5; //
临时缓冲区(存储转换后的Mup路径)
ULONG PrefixLength; // 匹配到的重定向器前缀长度
ULONG v7; // 转换后路径的总字节长度
// ========== 步骤1:处理UNC路径(用户态双反斜杠开头) ==========
// 校验条件:
// - 前两位是反斜杠(\\);
// - 第三位非空(不是\0);
// - 第三位非反斜杠(避免\\\\这类非法格式);
if (InPath[0] == L'\\' && InPath[1] == L'\\' &&
InPath[2] != L'\0' && InPath[2] != L'\\')
{
// 1.1 计算总长度:
// __MupLength(\Device\Mup的字符数) +
wcslen(&InPath[1])(UNC路径从第2位开始的字符数) + 1(终止符)
// 再 * sizeof(WCHAR) 转为字节数;
v7 = (__MupLength + wcslen(&InPath[1]) + 1) *
sizeof(WCHAR);
// 1.2 申请缓冲区
v5 = MemoryPoolAllocate(Pool, v7);
if (!v5)
return
STATUS_INSUFFICIENT_RESOURCES; // 内存不足
// 1.3 拼接路径:\Device\Mup +
UNC路径(从第2位开始,即\server\share)
wmemcpy(v5, __Mup, __MupLength); //
复制\Device\Mup前缀
wcscpy(v5 + __MupLength, &InPath[1]); //
拼接\server\share
// 1.4 赋值输出参数,返回成功
*OutPath = v5;
*PathLength = v7;
return STATUS_SUCCESS;
}
// ========== 步骤2:匹配内核重定向器前缀 ==========
// 按优先级匹配不同重定向器前缀(不区分大小写)
if (_wcsnicmp(InPath, __Redirector, __RedirectorLength) == 0)
PrefixLength = __RedirectorLength; //
匹配LanmanRedirector
else if (_wcsnicmp(InPath, __MupRedir, __MupRedirLength) ==
0)
PrefixLength = __MupRedirLength; //
匹配MupRedir
else if (_wcsnicmp(InPath, __DfsClientRedir,
__DfsClientRedirLength) == 0)
PrefixLength = __DfsClientRedirLength; //
匹配DfsClientRedir
else if (_wcsnicmp(InPath, __HgfsRedir, __HgfsRedirLength) ==
0)
PrefixLength = __HgfsRedirLength; //
匹配HgfsRedir(VMware共享)
else
PrefixLength = 0; // 未匹配到任何重定向器前缀
// ========== 步骤3:处理匹配到的重定向器路径 ==========
if (PrefixLength) {
// 3.1
跳过重定向器前缀,指向剩余路径(如\server\share或\;Z:xxx\server\share)
const WCHAR* v1 = InPath + PrefixLength;
// 3.2 剩余路径以反斜杠开头:处理带分号的特殊格式
if (v1[0] == L'\\') {
//
特殊格式:\;Z:0000000000009c2c\server\share → 跳过分号到下一个反斜杠
if (v1[1] == L';')
v1 = wcschr(v1 + 2,
L'\\'); // 从分号后第2位找下一个反斜杠
}
else
v1 = NULL; // 剩余路径无反斜杠,无效
// 3.3 剩余路径有效(非空、至少两位字符)
if (v1 && v1[0] && v1[1]) {
// 3.4 计算总长度:__MupLength + 剩余路径字符数 +
1 → 转字节数
v7 = (PrefixLength + wcslen(v1) + 1)
* sizeof(WCHAR);
// 3.5 申请缓冲区
v5 = MemoryPoolAllocate(Pool, v7);
if (!v5)
return
STATUS_INSUFFICIENT_RESOURCES;
// 3.6 拼接路径:\Device\Mup +
处理后的剩余路径(如\server\share)
wmemcpy(v5, __Mup, __MupLength);
wcscpy(v5 + __MupLength, v1);
// 3.7 赋值输出参数,返回成功
*OutPath = v5;
*PathLength = v7;
return STATUS_SUCCESS;
}
}
// ========== 步骤4:非共享路径 → 返回自定义状态码 ==========
return STATUS_BAD_INITIAL_PC;
}
示例 1:标准 UNC 路径(\192.168.1.1\share\test.txt)
输入路径:\\192.168.1.1\share\test.txt → 匹配 UNC 路径条件;
计算长度:__MupLength(9) + wcslen(&InPath[1])(22) + 1 = 32 → 字节数 32*2=64;
拼接路径:\Device\Mup + \192.168.1.1\share\test.txt;
输出:\Device\Mup\192.168.1.1\share\test.txt,长度 64 字节。
示例 2:LanmanRedirector 路径(\Device\LanmanRedirector\server\share)
输入路径匹配 __Redirector 前缀 → PrefixLength=20;
剩余路径:\server\share → 无分号,直接保留;
拼接路径:\Device\Mup + \server\share;
输出:\Device\Mup\server\share。
示例 3:带分号的重定向器路径
输入路径:\Device\LanmanRedirector\;Z:0000000000009c2c\server\share
匹配 __Redirector 前缀 → PrefixLength=20;
剩余路径:\;Z:0000000000009c2c\server\share;
检测到分号 → wcschr 找到下一个反斜杠 → 剩余路径变为 \server\share;
拼接路径:\Device\Mup\server\share;
输出:标准 Mup 路径,剥离了无关的会话标识。
示例 4:普通本地路径(C:\test.txt)
非 UNC 路径,未匹配任何重定向器前缀 → PrefixLength=0;
返回 STATUS_BAD_INITIAL_PC → 上层函数继续解析盘符。
核心设计亮点(新手必学)
1. 优先级清晰的路径匹配
UNC 路径优先:用户态最常用的共享路径格式,优先处理,提升效率;
重定向器前缀按协议匹配:覆盖 Lanman(SMB)、Dfs(分布式文件系统)、Hgfs(VMware)等主流共享协议,兼容性拉满。
2. 特殊格式容错处理
针对带分号的重定向器路径(含会话标识),通过 wcschr 精准剥离无关组件,只保留有效共享路径;
这种处理适配了 Windows 内核重定向器的 “会话绑定” 机制,避免无关数据干扰路径解析。
3. 不区分大小写的匹配
使用 _wcsnicmp(宽字符、不区分大小写、指定长度比较),避免因路径大小写(如 \Device\lanmanredirector)导致匹配失败;
这是 Windows 路径处理的通用最佳实践(Windows 路径本身不区分大小写)。
4. 内存计算的精准性
UNC 路径长度计算时,取 &InPath[1](跳过第一个反斜杠),避免拼接后出现 \Device\Mup\\server 这类双反斜杠;
所有长度计算都包含终止符 \0,保证路径字符串完整,无截断风险。
5. 无共享路径的明确标记
非共享路径返回自定义状态码 STATUS_BAD_INITIAL_PC,而非通用错误码;
上层函数(TranslateDosToNtInternal)可通过该状态码精准判断 “需继续解析盘符”,逻辑解耦清晰。
1. _wcsnicmp 的使用场景
_wcsnicmp(InPath, __Redirector, __RedirectorLength) 的核心价值:
指定长度比较:只比较前 __RedirectorLength 个字符,避免路径后缀干扰(如 \Device\LanmanRedirectorXXX
不会误匹配);
不区分大小写:适配 Windows 路径大小写不敏感的特性;
宽字符兼容:专门处理 WCHAR 类型的路径,无编码转换损耗。
2. 带分号路径的处理逻辑
Windows 内核重定向器路径中,分号后的内容是 “会话标识 / 盘符绑定信息”(如 ;Z:0000000000009c2c),对共享路径本身无意义:
函数通过 wcschr(v1 + 2, L'\\') 跳过分号及后续的会话标识,直接定位到 \server\share;
这种处理让沙箱无需关心内核会话细节,只需聚焦共享路径本身,简化逻辑。
3. __Mup 前缀的核心价值
\Device\Mup 是 Windows 内核处理多协议共享的 “统一入口”:
无论底层是 SMB、NFS 还是 DFS 协议,都可通过 Mup 路径访问;
沙箱统一转换为 Mup 路径后,后续的文件拦截、权限管控逻辑无需区分协议,只需处理标准路径。
TranslateShares 核心要点
核心定位:沙箱共享路径统一转换函数,将 UNC / 重定向器路径转为 \Device\Mup 标准内核路径;
核心逻辑:
优先处理 UNC 路径,直接拼接 \Device\Mup 前缀;
匹配多类重定向器前缀,处理带分号的特殊格式;
非共享路径返回自定义状态码,告知上层继续解析盘符;
38-2
GetSidStringAndSessionIdentity 是沙箱获取进程安全标识符(SID)和会话 ID 的核心内核态函数——
它的核心逻辑是通过进程句柄 / 进程 ID 定位到内核 EPROCESS 对象,提取进程的会话 ID 和主令牌的 SID 字符串,是沙箱 “按进程权限 /
会话隔离资源、审计进程行为” 的关键基础函数
GetSidStringAndSessionIdentity 的核心目标是:
多维度定位进程:支持通过「当前进程伪句柄」「进程句柄」「进程 ID」三种方式,获取内核 EPROCESS 对象;
提取核心标识:从 EPROCESS 中获取进程的会话 ID,从进程主令牌中提取 SID 字符串;
安全的内核对象管理:严格遵循 “引用→使用→解引用” 的内核对象操作规范,避免对象泄漏;
异常降级处理:查询失败时清空 SID 缓冲区、将会话 ID 置为 -1,保证上层逻辑不崩溃;
透传状态码:返回原始 NT 状态码,供上层判断失败原因。
简单说:这个函数是沙箱的 “进程身份读取器”—— 它封装了复杂的内核对象操作,向上层提供简洁、安全的接口,获取进程的两个核心标识(SID 代表权限身份,会话
ID 代表运行环境),是沙箱 “权限管控、会话隔离、行为审计” 的基础
在拆解函数前,先明确内核态进程信息的核心概念:
| 符号 / 结构体 | 含义 |
|---|---|
| EPROCESS | Windows 内核进程对象结构体(存储进程的所有核心信息:会话 ID、令牌、优先级等); |
| ACCESS_TOKEN | 进程访问令牌结构体(存储进程的安全上下文:SID、权限、组等); |
| PsProcessType | 内核进程对象类型指针(ObReferenceObjectByHandle 必需的参数); |
| PsGetCurrentProcess() | 内核函数,返回当前进程的 EPROCESS 指针(无需句柄); |
| PsLookupProcessByProcessId | 内核函数,通过进程 ID(HANDLE 类型)查找 EPROCESS 对象; |
| PsGetProcessSessionId | 内核函数,从 EPROCESS 中提取会话 ID; |
| PsReferencePrimaryToken | 内核函数,引用进程的主令牌(返回 ACCESS_TOKEN 指针); |
| ObReferenceObject/ObDereferenceObject | 内核对象引用计数操作:引用(+1)/ 解引用(-1),避免对象被释放; |
1. 内核对象引用计数的 “生命线”(新手必学)
内核态操作 EPROCESS/ACCESS_TOKEN 等对象,必须严格遵循「引用→使用→解引用」的流程:
ObReferenceObject(ProcessObject):引用 EPROCESS,引用计数 + 1 → 内核不会释放该对象;
ObDereferenceObject(ProcessObject):解引用,引用计数 - 1 → 用完后必须调用,否则对象泄漏(导致系统内存耗尽、蓝屏);
PsReferencePrimaryToken/PsDereferencePrimaryToken:同理,令牌对象也需引用 / 解引用,避免令牌被释放。
这是内核态编程的 “铁律”—— 对象引用计数错误是导致蓝屏(BSOD)的最常见原因之一。
void GetCurrentDirectory(
PEPROCESS ProcessObject, WCHAR** CurrentDirectory, ULONG*
DirectoryLength)
{
WCHAR* v5;
ULONG v7;
const ULONG Offset =
#ifdef _WIN64
0x38; // 64-bit
#else
0x24; // 32-bit
#endif
* CurrentDirectory = NULL;
*DirectoryLength = 0;
GetStringFromPeb(
ProcessObject, Offset, 600, &v5, &v7);
if (v5 && v7) {
//
// buffer was allocated with some extra space
//
NTSTATUS Status;
WCHAR* v1 = v5 + wcslen(v5);
/*
kd> db fffff8a0`016fb900
fffff8a0`016fb900 43 00 3a 00 5c 00 50 00-72 00 6f 00 67 00 72 00
C.:.\.P.r.o.g.r.
fffff8a0`016fb910 61 00 6d 00 20 00 46 00-69 00 6c 00 65 00 73 00
a.m. .F.i.l.e.s.
fffff8a0`016fb920 20 00 28 00 78 00 38 00-36 00 29 00 5c 00 57 00
.(.x.8.6.).\.W.
fffff8a0`016fb930 69 00 6e 00 64 00 6f 00-77 00 73 00 20 00 4b 00
i.n.d.o.w.s. .K.
fffff8a0`016fb940 69 00 74 00 73 00 5c 00-31 00 30 00 5c 00 44 00
i.t.s.\.1.0.\.D.
fffff8a0`016fb950 65 00 62 00 75 00 67 00-67 00 65 00 72 00 73 00
e.b.u.g.g.e.r.s.
fffff8a0`016fb960 5c 00 00 00 75 52 48 66-4f 30 71 73 50 62 7a 54
\.
*/
while (v1 > v5 && v1[-1] == L'\\')
--v1;
if (v1 > v5) {
v1[0] = L'\\';
v1[1] = L'x';
v1[2] = L'\0';
//
// get canonical path
//
Status = TranslateDosToNt(
v5, CurrentDirectory,
DirectoryLength);
if (!NT_SUCCESS(Status)) {
*CurrentDirectory =
NULL;
*DirectoryLength = 0;
}
}
FreeMemoryEx(v5, v7);
}
}
GetCurrentDirectory 是沙箱内核态获取进程当前工作目录(CWD)的核心函数—— 它的核心逻辑是通过硬编码偏移直接读取进程
PEB(进程环境块)中的当前目录,对路径做规范化处理后,调用 TranslateDosToNt 转换为 NT 内核路径,是沙箱
“精准获取进程文件操作上下文、篡改工作目录” 的关键。
函数核心功能总结
GetCurrentDirectory 的核心目标是:
PEB 偏移读取:通过硬编码的 32/64 位偏移,从进程 EPROCESS 关联的 PEB 中读取当前工作目录;
路径规范化:移除路径末尾多余的反斜杠,补充自定义后缀(\x),保证路径格式统一;
路径转换:调用 TranslateDosToNt 将 DOS 格式的工作目录转为 NT 内核路径;
内存管理:从沙箱内存池申请 / 释放缓冲区,避免内核内存泄漏;
异常处理:读取 / 转换失败时清空输出参数,保证上层逻辑安全。
简单说:这个函数是沙箱的 “进程工作目录读取器”—— 它绕开用户态 API,直接从内核态读取进程 PEB 中的核心路径信息,是沙箱 “深度管控进程文件操作”
的关键实现(比如篡改进程工作目录,让文件操作都落在沙箱隔离目录中)。
核心前置知识(必须掌握)
在拆解函数前,先明确关键概念:
| 符号 / 术语 | 含义 |
|---|---|
| PEB(进程环境块) | 用户态进程核心结构体,存储进程的当前工作目录、命令行、模块列表等上下文; |
| EPROCESS | 内核态进程对象,其 Peb 字段指向用户态 PEB 结构体; |
| PEB 硬编码偏移 | 不同系统位数下,PEB 中 “当前工作目录” 字段的固定偏移(32 位 = 0x24,64 位 = 0x38); |
| GetStringFromPeb | 沙箱自定义函数:通过偏移从 EPROCESS 读取 PEB 中的宽字符串; |
| 当前工作目录(CWD) | 进程默认的文件操作目录(如 C:\Program Files),进程未指定路径时会默认使用该目录; |
PEB 偏移的核心逻辑
Windows 内核中,EPROCESS 结构体包含一个 Peb 指针(指向用户态 PEB),而 PEB 中 0x24(32 位)/0x38(64
位)偏移处存储的是进程的当前工作目录字符串 —— 沙箱通过硬编码这个偏移,直接读取核心信息,无需调用用户态 API(避免被进程 Hook 拦截)。
void GetCurrentDirectory(
PEPROCESS ProcessObject, // 输入:内核EPROCESS对象指针
WCHAR** CurrentDirectory, // 输出:转换后的NT格式当前工作目录
ULONG* DirectoryLength) // 输出:目录路径的字节长度
{
WCHAR* v5; //
临时缓冲区:存储从PEB读取的原始DOS路径
ULONG v7; // 临时缓冲区的字节长度
// 1. 定义PEB中当前工作目录的偏移(32/64位区分)
const ULONG Offset =
#ifdef _WIN64
0x38; // 64位系统:PEB偏移0x38指向当前工作目录
#else
0x24; // 32位系统:PEB偏移0x24指向当前工作目录
#endif
// 2. 初始化输出参数(避免野指针)
*CurrentDirectory = NULL;
*DirectoryLength = 0;
// ========== 步骤3:从PEB读取当前工作目录 ==========
// GetStringFromPeb核心逻辑:
// 1. 从EPROCESS中获取PEB指针;
// 2. 偏移Offset读取宽字符串;
// 3. 复制到沙箱内存池缓冲区v5,返回长度v7;
GetStringFromPeb(
ProcessObject, Offset, 600, &v5, &v7); //
600=最大读取字符数
// 4. 读取成功(缓冲区有效)
if (v5 && v7) {
NTSTATUS Status;
// v1指向路径字符串的末尾(终止符\0前)
WCHAR* v1 = v5 + wcslen(v5);
// ========== 步骤5:路径规范化:移除末尾多余的反斜杠 ==========
// 循环条件:v1 > v5(未到路径开头)且前一个字符是反斜杠
while (v1 > v5 && v1[-1] == L'\\')
--v1; // 指针前移,跳过多余的反斜杠
// 6. 路径非空(至少有一个有效字符)
if (v1 > v5) {
// ========== 步骤6:补充自定义后缀(\x)
==========
v1[0] = L'\\'; // 补充一个反斜杠
v1[1] = L'x'; //
自定义字符x(测试/篡改标记)
v1[2] = L'\0'; // 终止符,截断路径
// ========== 步骤7:转换为NT内核路径 ==========
Status = TranslateDosToNt(
v5, CurrentDirectory,
DirectoryLength);
// 7.1 转换失败:清空输出参数
if (!NT_SUCCESS(Status)) {
*CurrentDirectory =
NULL;
*DirectoryLength = 0;
}
}
// ========== 步骤8:释放临时缓冲区(避免内存泄漏) ==========
FreeMemoryEx(v5, v7);
}
}
1. PEB 硬编码偏移的底层逻辑
为什么用硬编码偏移?
常规获取进程当前目录需要调用用户态 GetCurrentDirectoryW API,但沙箱为了避免被进程 Hook 拦截(进程可篡改
GetCurrentDirectoryW 的返回值),直接从内核态读取 PEB 原始数据 —— 这是沙箱 “绕过用户态欺骗、获取真实信息” 的核心技巧。
偏移的稳定性:
Windows 不同版本的 PEB 偏移基本稳定(32 位 = 0x24,64 位 = 0x38),沙箱通过编译宏 _WIN64 区分,保证跨位数兼容。
2. 路径规范化的核心目的(移除末尾反斜杠)
原始 PEB 中的当前目录可能以多个反斜杠结尾(如 C:\Test\\\),直接使用会导致路径转换出错;
循环 while (v1 > v5 && v1[-1] == L'\\') --v1 会把路径截断到最后一个有效字符(如
C:\Test\\\ → C:\Test);
这是路径处理的 “防御性设计”,避免多余反斜杠导致 TranslateDosToNt 解析失败。
3. 自定义后缀 \x 的作用(测试 / 篡改标记)
函数中 v1[0] = L'\\'; v1[1] = L'x'; v1[2] = L'\0' 是沙箱测试 / 路径篡改的关键:
原始路径(如 C:\Program Files)会被改为 C:\Program Files\x;
作用 1:测试路径转换逻辑是否正常(通过 \x 标记可快速识别沙箱处理后的路径);
作用 2:篡改进程当前工作目录(沙箱可将 x 替换为沙箱隔离目录,如 C:\Sandbox\123,让进程所有文件操作都落在沙箱中);
从注释中的内存 dump 也能印证:路径末尾被添加了自定义字符,且终止符截断了后续无效数据。
典型场景示例(读取 + 篡改当前目录)
示例 1:原始路径处理
PEB 原始路径:C:\Program Files (x86)\Windows Kits\10\Debuggers\\
步骤 1:移除末尾反斜杠 → C:\Program Files (x86)\Windows Kits\10\Debuggers
步骤 2:添加 \x → C:\Program Files (x86)\Windows Kits\10\Debuggers\x
步骤 3:调用 TranslateDosToNt → \Device\HarddiskVolume2\Program Files
(x86)\Windows Kits\10\Debuggers\x
输出:NT 格式的篡改后路径。
示例 2:空路径 / 无效路径处理
PEB 路径为空 → v1 > v5 不成立 → 跳过转换 → 输出参数置 NULL;
避免空路径导致 TranslateDosToNt 返回错误,保证函数鲁棒性。
GetCurrentDirectory 核心要点
核心定位:沙箱内核态读取 / 篡改进程当前工作目录的核心函数,绕开用户态 API 直接读取 PEB 原始数据;
核心逻辑:
硬编码 PEB 偏移读取当前目录;
规范化路径(移除末尾反斜杠),添加自定义后缀;
调用 TranslateDosToNt 转换为 NT 内核路径;
严格管理内存,避免泄漏;
void GetStringFromPeb(
PEPROCESS ProcessObject, ULONG Offset, ULONG MaxLength,
WCHAR** OutBuffer, ULONG* OutLength)
{
ULONG_PTR Peb;
ULONG_PTR RtlUserProcessParameters;
UNICODE_STRING* v1;
WCHAR* v5, * Buffer;
ULONG v7, Length, MaximumLength;
KAPC_STATE ApcState;
*OutBuffer = NULL;
*OutLength = 0;
Peb = PsGetProcessPeb(ProcessObject);
if (!Peb)
return;
v7 = (MaxLength + 16) * sizeof(WCHAR);
v5 = MemoryPoolAllocate(__Pool, v7);
if (!v5)
return;
KeStackAttachProcess(ProcessObject, &ApcState);
//切换进程上下背景文
__try {
//
// make sure PEB block is accessible
//
//Peb中获取参数块
const ULONG Offset1 =
#ifdef _WIN64
0x20; // 64-bit
#else
0x10; // 32-bit
#endif
ProbeForRead((void*)Peb, 0x20, sizeof(ULONG_PTR));
RtlUserProcessParameters = *(ULONG_PTR*)(Peb +
Offset1);
ProbeForRead((void*)RtlUserProcessParameters, 0x50,
sizeof(ULONG_PTR));
//
// make sure the requested string is accessible
//
v1 = (UNICODE_STRING*)(RtlUserProcessParameters +
Offset);
ProbeForRead(v1, sizeof(UNICODE_STRING),
sizeof(ULONG));
Length = v1->Length / sizeof(WCHAR);
MaximumLength = v1->MaximumLength / sizeof(WCHAR);
Buffer = v1->Buffer;
if (Length && MaximumLength && Buffer &&
Length <= MaxLength && Length <=
MaximumLength) {
if ((ULONG_PTR)Buffer <
(ULONG_PTR)RtlUserProcessParameters) {
Buffer = (WCHAR*)
((ULONG_PTR)RtlUserProcessParameters + (ULONG_PTR)Buffer);
}
ProbeForRead(Buffer, Length *
sizeof(WCHAR), sizeof(WCHAR));
//
// success
//
wmemcpy(v5, Buffer, Length);
v5[Length] = L'\0';
*OutBuffer = v5;
*OutLength = v7;
v5 = NULL;
}
}
__except (EXCEPTION_EXECUTE_HANDLER) {
}
KeUnstackDetachProcess(&ApcState);
//
// free when not used
//
if (v5)
FreeMemoryEx(v5, v7);
}
GetStringFromPeb 是沙箱内核态安全读取进程 PEB 中字符串的核心底层函数—— 它的核心逻辑是:切换到目标进程的地址空间上下文,通过多层
PEB 偏移安全读取 RTL_USER_PROCESS_PARAMETERS 中的字符串(如当前目录、命令行),并做严格的内存访问校验和异常捕获,是沙箱
“从 PEB 读取进程上下文信息” 的基础支撑函数
GetStringFromPeb 的核心目标是:
PEB 定位:从 EPROCESS 获取目标进程的 PEB 基址;
进程上下文切换:通过 KeStackAttachProcess 切换到目标进程地址空间,保证能访问其用户态内存;
多层偏移解析:从 PEB 找到 RTL_USER_PROCESS_PARAMETERS,再通过指定偏移定位目标字符串的 UNICODE_STRING;
安全内存校验:使用 ProbeForRead 验证内存可访问性,避免非法内存读取;
异常防护:通过 __try/__except 捕获内存访问异常,防止内核崩溃;
内存安全管理:从沙箱内存池申请缓冲区,读取失败时自动释放,避免泄漏;
字符串拷贝:将目标字符串安全拷贝到内核缓冲区,返回给上层调用者。
简单说:这个函数是沙箱的 “PEB 字符串安全读取器”—— 它解决了内核态读取其他进程用户态 PEB
内存的核心问题(上下文切换、内存校验、异常防护),为上层函数(如 GetCurrentDirectory)提供了安全、可靠的 PEB 字符串读取能力。
在拆解函数前,先明确 Windows 进程内存布局和内核操作的核心概念:
| 符号 / 函数 / 结构体 | 含义 |
|---|---|
| PEB | 进程环境块(用户态),0x20(64 位)/0x10(32 位)偏移指向 RTL_USER_PROCESS_PARAMETERS; |
| RTL_USER_PROCESS_PARAMETERS | 进程参数块(用户态),存储命令行、当前目录、环境变量等核心字符串; |
| KeStackAttachProcess | 内核函数,切换当前内核线程到目标进程的地址空间上下文(必须配对 KeUnstackDetachProcess); |
| KAPC_STATE | 存储 APC(异步过程调用)状态的结构体,用于进程上下文切换; |
| ProbeForRead | 内核函数,验证指定内存区域是否可读取,避免非法内存访问(触发异常则被 __except 捕获); |
| __try/__except | 内核态异常处理机制,捕获内存访问违规、越界等异常,防止系统蓝屏; |
| PsGetProcessPeb | 内核函数,从 EPROCESS 获取目标进程的 PEB 基址(用户态虚拟地址); |
PEB → RTL_USER_PROCESS_PARAMETERS → 目标字符串的层级关系:
EPROCESS → PsGetProcessPeb → PEB(用户态地址)
PEB + 0x20(64位)/0x10(32位) → RTL_USER_PROCESS_PARAMETERS 指针
RTL_USER_PROCESS_PARAMETERS + Offset → 目标字符串的 UNICODE_STRING 结构体
UNICODE_STRING.Buffer → 实际字符串内容
void GetStringFromPeb(
PEPROCESS ProcessObject, // 输入:目标进程的EPROCESS对象
ULONG Offset, //
输入:RTL_USER_PROCESS_PARAMETERS中目标字符串的偏移
ULONG MaxLength, //
输入:最大读取字符数(防止超长字符串)
WCHAR** OutBuffer, // 输出:读取到的字符串缓冲区(内核内存池)
ULONG* OutLength) // 输出:缓冲区的字节长度
{
ULONG_PTR Peb;
// PEB基址(用户态虚拟地址)
ULONG_PTR RtlUserProcessParameters; // 进程参数块基址(用户态)
UNICODE_STRING* v1;
// 目标字符串的UNICODE_STRING指针
WCHAR* v5, * Buffer;
// v5=内核缓冲区,Buffer=用户态字符串缓冲区
ULONG v7, Length, MaximumLength; // 长度变量
KAPC_STATE ApcState;
// 进程上下文切换状态
// 1. 初始化输出参数(避免野指针)
*OutBuffer = NULL;
*OutLength = 0;
// ========== 步骤2:获取目标进程的PEB基址 ==========
Peb = PsGetProcessPeb(ProcessObject);
if (!Peb) // PEB地址无效(如进程已退出)
return;
// ========== 步骤3:申请内核缓冲区 ==========
// 缓冲区大小:(MaxLength + 16) * 2 → 预留16个字符的扩展空间,避免截断
v7 = (MaxLength + 16) * sizeof(WCHAR);
v5 = MemoryPoolAllocate(__Pool, v7); // 从沙箱内存池申请
if (!v5) // 内存不足
return;
// ========== 步骤4:切换到目标进程的地址空间 ==========
// 核心:内核线程默认在当前进程地址空间,必须切换才能访问目标进程的用户态内存
KeStackAttachProcess(ProcessObject, &ApcState);
// ========== 步骤5:异常防护的内存读取逻辑 ==========
__try {
// 5.1
定义PEB到RTL_USER_PROCESS_PARAMETERS的偏移(32/64位区分)
const ULONG Offset1 =
#ifdef _WIN64
0x20; // 64位:PEB+0x20指向进程参数块
#else
0x10; // 32位:PEB+0x10指向进程参数块
#endif
// 5.2 校验PEB内存可访问(读取前必做,避免非法访问)
// ProbeForRead(地址, 长度, 对齐方式)
ProbeForRead((void*)Peb, 0x20, sizeof(ULONG_PTR));
// 5.3 读取RTL_USER_PROCESS_PARAMETERS指针
RtlUserProcessParameters = *(ULONG_PTR*)(Peb +
Offset1);
// 5.4 校验进程参数块内存可访问
ProbeForRead((void*)RtlUserProcessParameters, 0x50,
sizeof(ULONG_PTR));
// ========== 步骤6:定位目标字符串的UNICODE_STRING ==========
// RTL_USER_PROCESS_PARAMETERS + Offset →
目标字符串的UNICODE_STRING结构体
v1 = (UNICODE_STRING*)(RtlUserProcessParameters +
Offset);
// 6.1 校验UNICODE_STRING结构体可访问
ProbeForRead(v1, sizeof(UNICODE_STRING),
sizeof(ULONG));
// 6.2 提取字符串长度信息(转换为字符数,而非字节数)
Length = v1->Length / sizeof(WCHAR);
// 实际字符数(不含终止符)
MaximumLength = v1->MaximumLength / sizeof(WCHAR);
// 缓冲区最大字符数
Buffer = v1->Buffer;
// 用户态字符串缓冲区地址
// ========== 步骤7:校验字符串有效性 ==========
if (Length && MaximumLength && Buffer &&
Length <= MaxLength && Length <=
MaximumLength) {
// 7.1 特殊处理:缓冲区地址是相对偏移(部分系统/场景)
if ((ULONG_PTR)Buffer <
(ULONG_PTR)RtlUserProcessParameters) {
Buffer = (WCHAR*)
((ULONG_PTR)RtlUserProcessParameters + (ULONG_PTR)Buffer);
}
// 7.2 校验用户态字符串缓冲区可访问
ProbeForRead(Buffer, Length *
sizeof(WCHAR), sizeof(WCHAR));
// ========== 步骤8:安全拷贝字符串到内核缓冲区
==========
wmemcpy(v5, Buffer, Length); // 复制有效字符
v5[Length] = L'\0';
// 添加终止符,保证字符串完整
// 8.1 赋值输出参数
*OutBuffer = v5;
*OutLength = v7;
v5 = NULL; // 标记缓冲区已使用,避免后续释放
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
// 捕获所有内存访问异常(如越界、不可读),仅空处理(避免崩溃)
}
// ========== 步骤9:恢复当前进程的地址空间 ==========
KeUnstackDetachProcess(&ApcState);
// ========== 步骤10:释放未使用的内核缓冲区 ==========
if (v5) // v5不为NULL → 读取失败,缓冲区未使用
FreeMemoryEx(v5, v7);
}
1. 进程上下文切换的核心意义(新手必懂)
Windows 每个进程有独立的虚拟地址空间,内核线程默认运行在 “当前进程”(如系统进程 System)的地址空间;
目标进程的 PEB/RTL_USER_PROCESS_PARAMETERS 是用户态虚拟地址,仅在目标进程的地址空间中有效;
KeStackAttachProcess 会将当前内核线程的地址空间切换到目标进程,KeUnstackDetachProcess 恢复 ——
这是跨进程读取用户态内存的必要操作,否则会读取到无效内存(甚至触发蓝屏)。
2. ProbeForRead 的防护作用
ProbeForRead 是内核态 “内存访问安检员”:
检查内存地址是否在进程的有效地址范围内;
检查内存页是否有 “读” 权限;
验证内存对齐方式(避免非对齐访问导致的硬件异常);
如果内存不可读,ProbeForRead 会触发异常,被 __except 捕获,避免系统崩溃。
3. 字符串长度的安全校验
函数做了多层长度校验,避免缓冲区溢出:
Length && MaximumLength && Buffer &&
Length <= MaxLength && Length <= MaximumLength
GetStringFromPeb 核心要点
核心定位:沙箱内核态安全读取进程 PEB 字符串的底层函数,解决跨进程用户态内存访问的核心问题;
核心逻辑:
获取 PEB 基址,申请内核缓冲区;
切换进程上下文,进入异常防护块;
多层偏移定位目标字符串,严格校验内存可访问性;
安全拷贝字符串到内核缓冲区,恢复进程上下文;
未使用的缓冲区自动释放,避免泄漏;
设计原则:
安全性:上下文切换 + 内存校验 + 异常捕获,三重防护避免崩溃;
兼容性:区分 32/64 位偏移,适配不同系统;
高效性:内存池分配,预留扩展空间,适配高频读取;
鲁棒性:多层长度校验,处理相对偏移,避免读取失败。
简单来说,这个函数是内核态 “跨进程读取用户态内存” 的工业级实现 ——
它把上下文切换、内存校验、异常防护、内存管理等内核编程难点都封装起来,向上层提供简洁、安全的 PEB 字符串读取接口,是沙箱 “深度获取进程上下文”
的核心技术基石。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
39-1
BOOLEAN ProcessNotifyProcedureCreate(
HANDLE ProcessIdentity, // 输入:新进程ID
HANDLE ParentProcessIdentity, // 输入:父进程ID
HANDLE CallerIdentity, //
输入:调用进程ID(触发创建的进程)
VOID* Box)
// 输入:预分配的沙箱Box(可为NULL)
{
void* v1, *v2;
ULONG v10, v20;
WCHAR* ImageName, * nptr2; //
ImageName=进程镜像名(如notepad.exe)
const WCHAR* ImagePath; //
进程完整路径(如C:\Windows\notepad.exe)
// 沙箱上下文标记
BOOLEAN parent_was_start_exe = FALSE;
BOOLEAN parent_had_rights_dropped = FALSE;
BOOLEAN parent_was_image_from_box = FALSE;
BOOLEAN process_is_forced = FALSE;
BOOLEAN add_process_to_job = FALSE;
BOOLEAN IsCreateTerminated = FALSE; // 是否终止进程创建
BOOLEAN IsHostInject = FALSE; // 是否为沙箱宿主注入
KIRQL Irql;
// 内核中断级别
BOOLEAN added_to_dfp_list = FALSE;
BOOLEAN IsCheckForcedProgram = FALSE; // 是否校验强制沙箱程序
// ========== 步骤1:获取新进程的镜像名和路径 ==========
GetProcessName(
__Pool,
// 沙箱内存池
(ULONG_PTR)ProcessIdentity, // 新进程ID
&v1,
// 输出:进程路径的UNICODE_STRING
&v10,
// 输出:v1的内存长度
&ImageName);
// 输出:进程镜像名(如notepad.exe)
if (!v1) { // 获取进程信息失败
// Process_CreateTerminated(ProcessIdentity, -1);
// 注释:终止进程创建
return FALSE;
}
// 提取进程完整路径(UNICODE_STRING的Buffer字段)
ImagePath = ((UNICODE_STRING*)v1)->Buffer;
// ========== 步骤2:筛选目标进程(notepad.exe/Test.exe) ==========
if (!_wcsicmp(ImageName, L"notepad.exe") ||
!_wcsicmp(ImageName, L"Test.exe")) {
// 2.1 无预分配Box → 需要创建新沙箱
if (!Box) {
// ========== 步骤3:校验父进程/调用进程上下文
==========
// 查找调用进程的PROCESS对象(加锁,提升IRQL)
PROCESS* v1 =
FindProcess(CallerIdentity, &Irql);
// 校验条件:
// - 调用进程存在且非宿主注入;
// - 调用进程ID != 父进程ID(跨进程创建)
if (!(v1 && !v1->IsHostInject)
&& CallerIdentity != ParentProcessIdentity) {
ExReleaseResourceLite(__ProcessListLock); // 释放进程列表锁
KeLowerIrql(Irql);
//
恢复IRQL
}
// 调用进程是合法沙箱进程(非宿主注入)
if (v1 && !v1->IsHostInject) {
//
注释:此处为扩展逻辑(如继承父进程沙箱配置)
} else {
// 需校验强制启动的沙箱程序
IsCheckForcedProgram =
TRUE;
}
// 释放进程列表锁,恢复IRQL
ExReleaseResourceLite(__ProcessListLock);
KeLowerIrql(Irql);
}
// ========== 步骤4:校验强制沙箱程序,创建Box ==========
if (IsCheckForcedProgram) {
const WCHAR* SidString = NULL; //
进程SID(未使用,预留)
// 获取强制启动的沙箱Box(为notepad/Test分配预定义沙箱)
Box =
GetForcedStartBox(ProcessIdentity, ParentProcessIdentity,
ImagePath, &IsHostInject,
SidString);
// Box=-1 → 沙箱创建失败,终止进程
if (Box == (BOX*)-1) {
IsCreateTerminated =
TRUE;
Box = NULL;
} else if (Box) { // Box创建成功
if (IsHostInject) {
//
注释:宿主注入逻辑(如共享沙箱资源)
} else {
//
注释:普通沙箱逻辑(如隔离目录、权限限制)
}
}
}
// ========== 步骤5:沙箱Box有效 → 管控新进程 ==========
if (Box) {
// ========== 步骤5.1:创建PROCESS对象,关联沙箱
==========
PROCESS* Process =
CreateProcess(ProcessIdentity, Box, ImagePath, &Irql);
ExReleaseResourceLite(__ProcessListLock); // 释放进程列表锁
KeLowerIrql(Irql);
// 恢复IRQL
// 提取进程创建时间(沙箱审计/溯源用)
ULONG64 CreateTime =
Process->CreateTime;
// ========== 步骤5.2:向新进程注入沙箱逻辑
==========
if (Process) {
if
(!InjectProcessRequest(
ProcessIdentity, 0, CreateTime, ImageName, FALSE, FALSE)) {
//
注释:注入失败处理(如记录日志、终止进程)
}
}
}
// ========== 步骤6:释放进程信息缓冲区 ==========
FreeMemoryEx(v1, v10);
}
// ========== 步骤7:返回处理结果(TRUE=允许进程创建,FALSE=终止) ==========
return TRUE;
}
ProcessNotifyProcedureCreate 是沙箱进程创建回调的核心处理函数——
它的核心逻辑是拦截指定进程(notepad.exe/Test.exe)的创建事件,校验父进程 /
调用进程的上下文,为目标进程创建沙箱容器(Box),并将进程纳入沙箱管控(创建 Process 对象、注入沙箱逻辑),是沙箱 “进程级隔离与管控” 的入口函数。
ProcessNotifyProcedureCreate 的核心目标是:
进程创建拦截:作为内核进程创建回调函数,捕获所有新进程的创建事件;
目标进程筛选:只处理 notepad.exe/Test.exe 这两个指定进程;
沙箱容器创建:无现成沙箱(Box)时,为目标进程创建专属沙箱容器;
父进程上下文校验:检查父进程 / 调用进程的身份(是否为沙箱注入进程、是否跨进程调用);
强制沙箱校验:通过 GetForcedStartBox 获取强制启动的沙箱配置;
进程对象实例化:创建 PROCESS 结构体关联沙箱,记录进程创建时间等核心信息;
沙箱注入:调用 InjectProcessRequest 向目标进程注入沙箱管控逻辑;
资源管理:严格释放内核资源(如进程列表锁、IRQL 恢复),避免泄漏 / 死锁。
简单说:这个函数是沙箱的 “进程管控入口”——
它在进程创建的第一时间介入,为指定进程分配沙箱容器,校验进程上下文合法性,并注入沙箱逻辑,实现从进程启动阶段就开始的隔离管控。
核心前置知识(必须掌握)
在拆解函数前,先明确沙箱核心概念:
| 符号 / 结构体 / 函数 | 含义 |
|---|---|
| BOX | 沙箱容器结构体:存储单个沙箱的隔离配置(如目录、权限、资源限制); |
| PROCESS | 沙箱进程结构体:关联进程 ID、所属 Box、创建时间、注入状态等信息; |
| __ProcessListLock | 沙箱全局进程列表锁:保护 PROCESS 对象的并发访问(需 ExReleaseResourceLite 释放); |
| KIRQL | 内核中断请求级别:操作共享资源时需提升 IRQL,操作完成后恢复; |
| GetProcessName | 沙箱自定义函数:通过进程 ID 获取进程镜像名(如 notepad.exe)和完整路径; |
| FindProcess | 沙箱自定义函数:从全局进程列表中查找指定进程 ID 的 PROCESS 对象; |
| CreateProcess | 沙箱自定义函数:创建 PROCESS 对象,关联沙箱 Box,记录进程核心信息; |
| InjectProcessRequest | 沙箱自定义函数:向目标进程注入沙箱逻辑(如 Hook、权限管控代码); |
| GetForcedStartBox | 沙箱自定义函数:获取强制启动的沙箱配置(为指定进程分配预定义沙箱); |
1. 进程创建回调的核心触发时机
这个函数是 Windows 内核 PsSetCreateProcessNotifyRoutine 注册的回调函数,进程创建但未执行任何代码前触发;
沙箱通过这个回调,能在进程启动的 “最早阶段” 介入,避免进程执行任何非沙箱管控的代码。
2. 父进程 / 调用进程的校验逻辑
if (!(v1 && !v1->IsHostInject) && CallerIdentity !=
ParentProcessIdentity)
v1 && !v1->IsHostInject:调用进程存在且不是沙箱宿主注入的进程(避免沙箱自身进程被管控);
CallerIdentity != ParentProcessIdentity:调用进程和父进程不是同一个(跨进程创建,如 A 进程创建 B 进程);
这个校验的目的:区分 “正常进程创建” 和 “沙箱注入 / 跨进程攻击”,避免沙箱误管控自身进程。
3. GetForcedStartBox 的核心作用
GetForcedStartBox 为指定进程(notepad/Test)分配预定义沙箱配置:
如隔离目录(所有文件操作落在 C:\Sandbox\notepad);
权限限制(禁用管理员权限、禁止访问注册表敏感键);
资源限制(CPU / 内存上限);
返回 (BOX*)-1 代表沙箱配置无效,需终止进程创建;返回 NULL 代表不分配沙箱;返回有效指针代表沙箱创建成功。
4. InjectProcessRequest 的注入逻辑
沙箱通过这个函数向新进程注入管控逻辑,常见实现方式:
远程线程注入:创建远程线程,加载沙箱 DLL;
Inline Hook:Hook 进程的关键 API(如 CreateFileW、RegOpenKeyExW);
内存篡改:修改进程的 PEB / 导入表,替换系统 API 为沙箱封装函数;
注入时机:进程创建但未执行代码,此时注入不会被进程防御机制拦截。
5. 内核资源的安全管理
ExReleaseResourceLite(__ProcessListLock):释放进程列表锁,必须与 FindProcess 的加锁操作配对,避免死锁;
KeLowerIrql(Irql):恢复内核中断级别,操作共享资源时会提升 IRQL(如
DISPATCH_LEVEL),操作完成后必须恢复,否则导致系统调度异常;
FreeMemoryEx(v1, v10):释放 GetProcessName 申请的缓冲区,避免内核内存泄漏。
典型场景示例(notepad.exe 创建流程)
用户双击 notepad.exe → 内核触发进程创建回调;
GetProcessName 获取进程名 notepad.exe 和路径 C:\Windows\notepad.exe;
无预分配 Box → 校验父进程(如 explorer.exe),判定为 “强制沙箱程序”;
GetForcedStartBox 创建专属沙箱 Box,配置隔离目录 C:\Sandbox\notepad;
CreateProcess 创建 PROCESS 对象,关联 Box,记录创建时间;
InjectProcessRequest 向 notepad.exe 注入沙箱 DLL,Hook CreateFileW 等 API;
notepad.exe 启动,所有文件操作都被沙箱管控,落在隔离目录中。
ProcessNotifyProcedureCreate 核心要点
核心定位:沙箱进程创建拦截的入口函数,在进程启动最早阶段介入,为指定进程分配沙箱并注入管控逻辑;
核心逻辑:
筛选目标进程(notepad/Test),获取进程名和路径;
校验父进程 / 调用进程上下文,判断是否需要强制分配沙箱;
创建沙箱 Box,实例化 PROCESS 对象关联进程和沙箱;
向新进程注入沙箱管控逻辑,实现进程级隔离;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
BOX* CreateBoxInternal(POOL* Pool, const WCHAR* BoxName, ULONG
SessionIdentity)
{
BOX* Box = MemoryPoolAllocate(Pool, sizeof(BOX));
if (!Box) {
return NULL;
}
MemoryZero(Box, sizeof(BOX));
wcscpy(Box->BoxName, BoxName);
Box->NameLength = (wcslen(Box->BoxName) + 1) * sizeof(WCHAR);
return Box;
}
CreateBoxInternal 是沙箱创建沙箱容器(BOX)的核心底层函数—— 它的核心逻辑是从沙箱内存池分配 BOX
结构体内存、初始化内存为零、设置沙箱名称及长度,是沙箱 “实例化隔离容器” 的最基础实现,所有沙箱实例的创建最终都会调用这个函数。
一、函数核心功能总结
CreateBoxInternal 的核心目标是:
内存分配:从沙箱指定内存池申请 BOX 结构体的内存空间;
内存清零:将 BOX 结构体所有字段初始化为零,避免野值 / 残留数据;
名称初始化:设置沙箱名称(BoxName),计算并存储名称的字节长度;
返回实例:返回初始化完成的 BOX 指针(分配失败则返回 NULL)。
简单说:这个函数是沙箱的 “容器构造器”—— 它负责创建一个干净、可管理的沙箱容器实例,为后续的进程隔离、目录重定向、权限限制等功能提供基础的容器载体。
核心前置知识(必须掌握)
| 符号 / 函数 / 结构体 | 含义 |
|---|---|
| BOX | 沙箱容器核心结构体:存储沙箱名称、会话 ID、隔离目录、资源限制、进程列表等所有沙箱配置; |
| POOL* Pool | 沙箱内存池:统一管理沙箱相关内存的分配 / 释放(避免系统内存池碎片化); |
| MemoryPoolAllocate | 沙箱自定义内存分配函数:从指定内存池申请内存(替代系统 ExAllocatePool); |
| MemoryZero | 沙箱自定义内存清零函数:等价于 RtlZeroMemory,将内存区域置零; |
| Box->BoxName | BOX 结构体中存储沙箱名称的字符数组(需注意数组长度限制); |
BOX* CreateBoxInternal(POOL* Pool, const WCHAR* BoxName, ULONG
SessionIdentity)
{
// ========== 步骤1:从沙箱内存池分配BOX结构体内存 ==========
// sizeof(BOX):申请整个BOX结构体的内存空间(包含所有字段)
BOX* Box = MemoryPoolAllocate(Pool, sizeof(BOX));
if (!Box) { // 内存分配失败(如内存池耗尽)
return NULL;
}
// ========== 步骤2:初始化BOX内存为零 ==========
// 核心:清除内存中的残留数据(如之前释放的内存碎片、野值),避免字段异常
MemoryZero(Box, sizeof(BOX));
// ========== 步骤3:设置沙箱名称 ==========
// 将传入的BoxName拷贝到BOX结构体的BoxName字段
wcscpy(Box->BoxName, BoxName);
// ========== 步骤4:计算并存储名称长度 ==========
// 长度计算:(字符数 + 1) * sizeof(WCHAR) → +1是包含终止符\0,*2转换为字节数
Box->NameLength = (wcslen(Box->BoxName) + 1) * sizeof(WCHAR);
// ========== 步骤5:返回初始化后的BOX实例 ==========
// 注:SessionIdentity参数未使用(预留字段,需补充)
return Box;
}
1. 内存池分配的核心意义
函数使用 MemoryPoolAllocate(Pool, sizeof(BOX)) 而非系统内存池:
隔离性:沙箱内存池独立于系统内存池,沙箱退出时可批量释放,避免内存泄漏;
性能:自定义内存池适配沙箱高频创建 / 销毁 BOX 的场景,分配 / 释放速度更快;
可控性:沙箱可监控内存池使用情况,避免单个 BOX 占用过多内存。
2. MemoryZero 的必要性
BOX 结构体包含大量字段(如会话 ID、隔离目录指针、进程列表头、资源限制等),如果不清零:
内存中可能残留之前释放的脏数据(如野指针、无效数值);
未初始化的字段(如 Box->SessionId)可能导致后续逻辑判断错误;
MemoryZero 是内核态结构体初始化的 “标准操作”,保证实例的干净性。
40-2调试没看
~~~~
41-1 Ipc
BOOLEAN IpcHook(void)
{
HMODULE ModuleHandle = __Ntdll;
InitializeCriticalSection(&_CriticalSection1);
InitializeList(&_List1);
/*
NtAlpcCreatePort 是 Windows 内核提供的一个系统调用,
用于创建 ALPC(高级本地过程调用)端口。ALPC 是 Windows
内部的一种高效、安全的进程间通信(IPC)机制,允许内核模式和用户模式之间传递数据。
*/
void* NtAlpcCreatePort;
void* NtAlpcConnectPort;
void* NtAlpcConnectPortEx;
void* NtAlpcQueryInformation;
void* NtAlpcQueryInformationMessage;
void* NtAlpcImpersonateClientOfPort;
void* NtAlpcSendWaitReceivePort;
//
// initialize cache of open and closed IPC paths
//
//SbieDll_MatchPath(L'i', (const WCHAR*)-1);
//ipc_namespace_isoaltion = SbieApi_QueryConfBool(NULL,
L"NtNamespaceIsolation", TRUE);
//
// intercept NTDLL entry points
//
#define DLL_HOOK_IF(Name)
\
Name = GetProcAddress(__Ntdll, #Name);
\
if (Name) {
\
DLL_HOOK(Detour,Name);
\
}
DLL_HOOK(Detour, NtCreatePort);
DLL_HOOK(Detour, NtConnectPort);
DLL_HOOK(Detour, NtSecureConnectPort);
DLL_HOOK_IF(NtAlpcCreatePort);
DLL_HOOK_IF(NtAlpcConnectPort);
DLL_HOOK_IF(NtAlpcConnectPortEx);
DLL_HOOK_IF(NtAlpcQueryInformation);
DLL_HOOK_IF(NtAlpcQueryInformationMessage);
//测试
DLL_HOOK(Detour, NtCreateEvent);
DLL_HOOK(Detour, NtOpenEvent);
DLL_HOOK(Detour, NtOpenDirectoryObject);
DLL_HOOK(Detour, NtCreateDirectoryObject);
if (__FirstProcessInBox)
{
//DetourCreateObjects(); //创建重定向目录
}
}
IpcHook 是沙箱进程间通信(IPC)拦截的核心初始化函数—— 它的核心逻辑是通过 Detour 钩子技术,拦截 NTDLL 中所有关键的 IPC
相关系统调用(如 ALPC 端口、命名管道、事件、目录对象),是沙箱 “隔离进程间通信、防止沙箱内外 IPC 渗透” 的关键实现。
IpcHook 的核心目标是:
初始化基础资源:创建临界区(防止多线程 Hook 竞争)、初始化 IPC 路径缓存链表;
定位 NTDLL 导出函数:通过 GetProcAddress 获取 NTDLL 中 IPC 相关系统调用的地址;
Detour 钩子注入:对 ALPC 端口、命名管道、事件、目录对象等 IPC 函数打 Hook;
沙箱初始化适配:沙箱内第一个进程启动时,创建 IPC 重定向目录,实现命名空间隔离;
IPC 隔离配置加载:读取沙箱配置(如 NtNamespaceIsolation),启用 IPC 命名空间隔离。
简单说:这个函数是沙箱的 “IPC 拦截初始化器”—— 它完成了所有 IPC 相关系统调用的 Hook 部署,为后续拦截 / 重定向 / 阻断 IPC
通信打下基础,是沙箱 “IPC 层隔离” 的核心入口。
核心前置知识(必须掌握)
| 符号 / 函数 / 术语 | 含义 |
|---|---|
| ALPC(高级本地过程调用) | Windows 内核原生的高效 IPC 机制,替代传统 LPC,是进程 / 内核通信的核心; |
| Detour | Microsoft Detours 库:用户态 Hook 技术,通过修改函数入口指令实现函数拦截; |
| NTDLL 系统调用 | NTDLL 是用户态到内核态的过渡层,所有 IPC 操作最终调用 NTDLL 导出的系统调用函数; |
| IPC 命名空间隔离 | 沙箱核心特性:将沙箱内进程的 IPC 对象(如事件、端口、管道)重定向到沙箱专属命名空间,避免与主机交互; |
| 临界区(CriticalSection) | 同步原语:防止多线程同时执行 Hook 操作,导致指令篡改冲突; |
| DLL_HOOK_IF 宏 | 沙箱自定义宏:安全 Hook 封装(查找函数→存在则 Hook); |
ALPC/IPC 函数的核心作用
| 函数名 | 功能 |
|---|---|
| NtAlpcCreatePort | 创建 ALPC 端口(进程 / 内核通信的核心载体); |
| NtAlpcConnectPort | 连接 ALPC 端口(建立 IPC 通信通道); |
| NtCreatePort/NtConnectPort | 传统 LPC 端口创建 / 连接(兼容旧版系统); |
| NtCreateEvent/NtOpenEvent | 创建 / 打开命名事件(最常用的 IPC 同步机制); |
| NtCreateDirectoryObject | 创建内核目录对象(IPC 对象的命名空间载体); |
BOOLEAN IpcHook(void)
{
// 1. 获取NTDLL模块句柄(沙箱全局变量__Ntdll)
HMODULE ModuleHandle = __Ntdll;
// ========== 步骤2:初始化同步和缓存资源 ==========
// 创建临界区:防止多线程同时Hook,导致指令篡改冲突
InitializeCriticalSection(&_CriticalSection1);
// 初始化链表:缓存已打开/关闭的IPC路径(用于快速校验)
InitializeList(&_List1);
/*
注释说明:
NtAlpcCreatePort 是 Windows 内核 ALPC 端口创建函数,
ALPC 是高效的进程间/内核-用户态通信机制,是沙箱IPC隔离的核心拦截目标
*/
// ========== 步骤3:定义Hook函数指针 ==========
void* NtAlpcCreatePort; // ALPC端口创建
void* NtAlpcConnectPort; // ALPC端口连接
void* NtAlpcConnectPortEx; // ALPC端口连接(扩展版)
void* NtAlpcQueryInformation; // ALPC端口信息查询
void* NtAlpcQueryInformationMessage; // ALPC消息信息查询
void* NtAlpcImpersonateClientOfPort; // ALPC客户端模拟
void* NtAlpcSendWaitReceivePort; // ALPC消息发送/接收
// ========== 步骤4:初始化IPC路径缓存(注释,预留逻辑) ==========
// SbieDll_MatchPath(L'i', (const WCHAR*)-1); // 匹配IPC路径规则
// ipc_namespace_isoaltion = SbieApi_QueryConfBool(NULL,
L"NtNamespaceIsolation", TRUE); // 加载IPC命名空间隔离配置
// ========== 步骤5:Hook NTDLL中的IPC相关函数 ==========
// 自定义宏:查找函数→存在则Hook
#define DLL_HOOK_IF(Name)
\
Name = GetProcAddress(__Ntdll, #Name);
\
if (Name) {
\
DLL_HOOK(Detour,Name);
\
}
// 5.1 Hook 传统LPC端口函数
DLL_HOOK(Detour, NtCreatePort);
// 创建LPC端口
DLL_HOOK(Detour, NtConnectPort); //
连接LPC端口
DLL_HOOK(Detour, NtSecureConnectPort); // 安全连接LPC端口
// 5.2 Hook ALPC核心函数(安全Hook:存在则Hook)
DLL_HOOK_IF(NtAlpcCreatePort);
DLL_HOOK_IF(NtAlpcConnectPort);
DLL_HOOK_IF(NtAlpcConnectPortEx);
DLL_HOOK_IF(NtAlpcQueryInformation);
DLL_HOOK_IF(NtAlpcQueryInformationMessage);
// ========== 步骤6:测试Hook(扩展IPC拦截范围) ==========
DLL_HOOK(Detour, NtCreateEvent); //
创建命名事件
DLL_HOOK(Detour, NtOpenEvent);
// 打开命名事件
DLL_HOOK(Detour, NtOpenDirectoryObject); // 打开目录对象(IPC命名空间)
DLL_HOOK(Detour, NtCreateDirectoryObject); // 创建目录对象
// ========== 步骤7:沙箱第一个进程初始化 ==========
if (__FirstProcessInBox) {
// DetourCreateObjects(); //
创建IPC重定向目录(如\Sessions\1\Sandbox\Box1)
}
// 注:函数无返回值(原始代码缺失return,需补充return TRUE/FALSE)
return TRUE;
}
1. Detour Hook 的实现原理
沙箱使用的 DLL_HOOK(Detour, Name) 宏,底层基于 Microsoft Detours 库实现,核心步骤:
Detour 通过修改函数前几个字节的指令(如替换为 JMP HookFunc),实现函数拦截;
沙箱在 Hook 函数中可实现:
阻断:非法 IPC 通信直接返回 STATUS_ACCESS_DENIED;
重定向:将沙箱内的 IPC 对象名(如 Global\TestEvent)重定向到沙箱专属命名空间(如
Global\Sandbox1\TestEvent);
记录:日志记录 IPC 通信行为,用于审计 / 溯源。
2. ALPC 拦截的核心意义
ALPC 是 Windows 内核最核心的 IPC 机制,Windows 绝大多数核心组件(如 CSRSS、LSASS、服务管理器)都通过 ALPC 通信:
沙箱拦截 ALPC 函数,可阻断:
沙箱内进程与主机 LSASS 通信(防止凭证窃取);
沙箱内进程创建 ALPC 端口监听(防止反向 Shell);
沙箱外进程连接沙箱内 ALPC 端口(防止渗透)。
3. IPC 命名空间隔离的实现
// 注释中的核心配置:启用IPC命名空间隔离
ipc_namespace_isoaltion = SbieApi_QueryConfBool(NULL,
L"NtNamespaceIsolation", TRUE);
启用该配置后,沙箱会将所有 IPC 对象名重定向:
沙箱内创建 Global\MyEvent → 实际创建 Global\Sandbox_{BoxID}\MyEvent;
沙箱内打开 \RPC Control\RpcPort → 实际打开 \RPC Control\Sandbox_{BoxID}\RpcPort;
核心函数 DetourCreateObjects() 会创建沙箱专属的 IPC 目录对象,实现命名空间隔离,避免沙箱内外 IPC 对象冲突 / 交互。
4. 临界区 _CriticalSection1 的作用
Hook 操作(修改函数入口指令)是非线程安全的:如果多线程同时调用 NtAlpcCreatePort,且 Hook 正在修改指令,会导致指令错乱(进程崩溃);
InitializeCriticalSection(&_CriticalSection1) 创建临界区后,Hook 操作会被包裹在临界区中:
EnterCriticalSection(&_CriticalSection1);
DLL_HOOK(Detour, NtAlpcCreatePort);
LeaveCriticalSection(&_CriticalSection1);
保证同一时间只有一个线程执行 Hook 操作,避免指令篡改冲突。
5. __FirstProcessInBox 的初始化逻辑
__FirstProcessInBox 标记沙箱内启动的第一个进程(如 notepad.exe);
第一个进程启动时调用 DetourCreateObjects():
创建沙箱专属的 IPC 目录对象(如 \Device\NamedPipe\Sandbox1);
初始化 IPC 路径重定向规则;
配置 IPC 资源限制(如最大端口数、消息大小);
后续沙箱内进程复用该目录,实现统一的 IPC 命名空间隔离。
核心设计亮点(新手必学)
1. 分层的 IPC Hook 策略
必 Hook 函数:NtCreatePort/NtConnectPort 等传统 LPC 函数直接 Hook(系统必存在);
条件 Hook 函数:NtAlpcCreatePort 等 ALPC 函数通过 DLL_HOOK_IF 宏,存在则 Hook(兼容无 ALPC 的旧系统);
这种分层策略保证函数在不同 Windows 版本上的兼容性(如 XP 无 ALPC,Win7+ 才有)。
2. 扩展的 IPC 拦截范围
除了核心的 ALPC/LPC 函数,还 Hook 了事件(NtCreateEvent)、目录对象(NtCreateDirectoryObject):
事件是最常用的 IPC 同步机制(如进程间信号量);
目录对象是 IPC 命名空间的载体(所有 IPC 对象都在目录下);
拦截目录对象可实现 “根级” IPC 命名空间隔离,比单个函数拦截更彻底。
3. 预留的配置加载逻辑
注释中的 SbieApi_QueryConfBool 加载 NtNamespaceIsolation 配置:
支持沙箱动态开关 IPC 隔离(如调试模式下关闭隔离,方便排查问题);
工业级沙箱的典型设计:所有核心特性可通过配置开关,提升灵活性。
典型拦截场景示例
场景 1:阻断沙箱内进程连接主机 ALPC 端口
沙箱内进程调用 NtAlpcConnectPort(L"\\RPC Control\\LSASS");
Hook 函数拦截该调用,校验端口名属于主机敏感 IPC;
直接返回 STATUS_ACCESS_DENIED,阻断通信,防止凭证窃取。
场景 2:重定向沙箱内事件到专属命名空间
沙箱内进程调用 NtCreateEvent(&hEvent, EVENT_ALL_ACCESS, &EventName,
SynchronizationEvent, FALSE);
EventName 为 L"Global\\TestEvent";
Hook 函数将名称重定向为 L"Global\\Sandbox_123\\TestEvent";
实际创建的事件在沙箱专属命名空间,主机进程无法访问,实现隔离。
IpcHook 核心要点
核心定位:沙箱 IPC 拦截的初始化函数,部署所有 IPC 相关系统调用的 Detour Hook,实现 IPC 层隔离;
核心逻辑:
初始化同步资源和路径缓存;
定位 NTDLL 中 ALPC/LPC/ 事件 / 目录对象等 IPC 函数;
打 Detour Hook,拦截所有 IPC 操作;
沙箱第一个进程创建专属 IPC 命名空间;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void DetourCreateObjects(void)
{
NTSTATUS Status;
WCHAR* TruePath;
WCHAR* CopyPath;
WCHAR v1[64];
WCHAR* v3 = NULL;
WCHAR* v5 = NULL;
HANDLE RootDirectory = NULL;
WCHAR* Backslash = NULL;
snwprintfEx(v1, 64, _BOXED_ L"DummyEvent_%d",
__ProcessIdentity);
RootDirectory = CreateEvent(NULL, FALSE, FALSE, v1);
if (!RootDirectory) {
Status = GetLastError();
goto Exit;
}
Status = GetName(RootDirectory, NULL, &TruePath, &CopyPath,
NULL);
if (NT_SUCCESS(Status)) {
if (CopyPath)
Backslash = wcsrchr(CopyPath,
L'\\');
if (Backslash)
*Backslash = L'\0';
else
Status = STATUS_UNSUCCESSFUL;
}
if (!NT_SUCCESS(Status)) {
goto Exit;
}
NtClose(RootDirectory);
v3 = (WCHAR*)MemoryPoolAllocateEx(__Pool,(wcslen(CopyPath) +
32) * sizeof(WCHAR));
//
// create Global directory and symbolic links
//
v5 = (WCHAR*)MemoryPoolAllocateEx(__Pool,(__BoxIpcPathLength
+ 32) * sizeof(WCHAR));
wcscpy_s(v5, (__BoxIpcPathLength + 32), __BoxIpcPathData);
wcscat_s(v5, (__BoxIpcPathLength + 32)
,L"\\BaseNamedObjects");
Status = CreateDirOrLink(v5, NULL);
if (!NT_SUCCESS(Status)) {
goto Exit;
}
wcscpy_s(v3, (wcslen(CopyPath) + 32), CopyPath);
wcscat_s(v3, (wcslen(CopyPath) + 32), L"\\Session");
Status = CreateDirOrLink(v3, NULL);
if (!NT_SUCCESS(Status)) {
goto Exit;
}
Exit:;
if (v3)
{
MemoryPoolFreeEx(v3);
}
if (v5)
MemoryPoolFreeEx(v5);
}
DetourCreateObjects 是沙箱创建 IPC 命名空间隔离目录和符号链接的核心函数—— 它的核心逻辑是通过创建临时事件获取系统 IPC
目录路径,基于该路径构建沙箱专属的 IPC 目录(如BaseNamedObjects、Session),并创建目录 / 符号链接,最终实现沙箱内 IPC
对象的命名空间隔离,是沙箱 “IPC 层隔离” 的核心落地函数。
DetourCreateObjects 的核心目标是:
获取系统 IPC 根目录:通过创建临时事件并查询其完整路径,反向推导系统 IPC 根目录(如\BaseNamedObjects);
构建沙箱专属 IPC 路径:基于系统 IPC 根目录,拼接沙箱专属的子目录(BaseNamedObjects、Session);
创建隔离目录 / 符号链接:调用CreateDirOrLink创建沙箱 IPC 目录或符号链接,实现 IPC 命名空间重定向;
安全的内存管理:从沙箱内存池申请 / 释放路径缓冲区,避免内存泄漏;
容错处理:任何步骤失败时跳转到Exit标签,释放已申请的内存,保证函数鲁棒性。
简单说:这个函数是沙箱的 “IPC 隔离目录构建器”—— 它解决了 “如何找到系统 IPC 根目录” 和 “如何创建沙箱专属 IPC 命名空间”
的核心问题,让之前 Hook 拦截的 IPC 操作都重定向到沙箱专属目录,实现真正的 IPC 隔离。
核心前置知识(必须掌握)
| 符号 / 函数 / 术语 | 含义 |
|---|---|
| IPC 根目录 | Windows IPC 对象(事件、端口、管道)默认存储在\BaseNamedObjects(全局)或\Sessions\x\BaseNamedObjects(会话级)目录; |
| 临时事件定位法 | 沙箱核心技巧:创建临时命名事件,通过GetName查询事件完整路径,反向截取 IPC 根目录; |
| 符号链接(Symbolic Link) | Windows 内核对象链接:将沙箱内的 IPC 路径(如\BaseNamedObjects)链接到沙箱专属目录,实现透明重定向; |
| CreateDirOrLink | 沙箱自定义函数:创建内核目录对象或符号链接(核心 IPC 隔离实现); |
| snwprintfEx | 沙箱自定义安全宽字符格式化函数(替代swprintf,防止缓冲区溢出); |
| wcsrchr | 查找字符串中最后一个指定字符(此处用于查找路径中最后一个\,截取根目录); |
| __ProcessIdentity | 沙箱全局变量:当前进程 ID(用于生成唯一的临时事件名,避免冲突); |
void DetourCreateObjects(void)
{
NTSTATUS Status;
WCHAR* TruePath; // 事件的真实路径(内核态路径)
WCHAR* CopyPath; // 事件路径的拷贝(用户态)
WCHAR v1[64]; // 临时事件名缓冲区
WCHAR* v3 = NULL; // 沙箱Session目录路径缓冲区
WCHAR* v5 = NULL; //
沙箱BaseNamedObjects目录路径缓冲区
HANDLE RootDirectory = NULL; // 临时事件句柄
WCHAR* Backslash = NULL; // 路径中最后一个反斜杠指针
// ========== 步骤1:生成唯一的临时事件名 ==========
// 格式化事件名:_BOXED_ DummyEvent_进程ID(如Boxed_DummyEvent_1234)
// _BOXED_是沙箱标记,避免与主机事件名冲突
snwprintfEx(v1, 64, _BOXED_ L"DummyEvent_%d",
__ProcessIdentity);
// ========== 步骤2:创建临时事件,获取IPC根目录的“锚点” ==========
// 创建临时事件(自动重置、非初始触发),句柄存入RootDirectory
RootDirectory = CreateEvent(NULL, FALSE, FALSE, v1);
if (!RootDirectory) { // 事件创建失败
Status = GetLastError(); // 获取错误码
goto Exit; // 跳转到退出逻辑,释放资源
}
// ========== 步骤3:查询事件完整路径,截取IPC根目录 ==========
// GetName:查询内核对象(事件)的完整路径,输出到TruePath/CopyPath
Status = GetName(RootDirectory, NULL, &TruePath, &CopyPath,
NULL);
if (NT_SUCCESS(Status)) {
// 3.1
查找路径中最后一个反斜杠(如\BaseNamedObjects\DummyEvent_1234 → 指向\DummyEvent前的\)
if (CopyPath)
Backslash = wcsrchr(CopyPath,
L'\\');
// 3.2 截取IPC根目录:将最后一个反斜杠后截断(删除事件名,保留根目录)
if (Backslash)
*Backslash = L'\0'; //
如\BaseNamedObjects\DummyEvent_1234 → \BaseNamedObjects
else
Status = STATUS_UNSUCCESSFUL; //
无反斜杠,路径无效
}
if (!NT_SUCCESS(Status)) { // 路径查询/截取失败
goto Exit;
}
// ========== 步骤4:关闭临时事件句柄(完成目录定位,无需保留) ==========
NtClose(RootDirectory);
// ========== 步骤5:创建沙箱BaseNamedObjects目录/符号链接 ==========
// 5.1 申请缓冲区:(__BoxIpcPathLength + 32) * 2 → 预留扩展空间
v5 = (WCHAR*)MemoryPoolAllocateEx(__Pool, (__BoxIpcPathLength
+ 32) * sizeof(WCHAR));
if (!v5) goto Exit; // 内存分配失败
// 5.2 拼接路径:沙箱IPC基础路径 + \BaseNamedObjects
// __BoxIpcPathData是沙箱专属IPC根路径(如\Sandbox\Box1)
wcscpy_s(v5, (__BoxIpcPathLength + 32), __BoxIpcPathData);
wcscat_s(v5, (__BoxIpcPathLength + 32),
L"\\BaseNamedObjects");
// 5.3 创建目录/符号链接:将沙箱内的BaseNamedObjects指向沙箱专属目录
Status = CreateDirOrLink(v5, NULL);
if (!NT_SUCCESS(Status)) { // 创建失败
goto Exit;
}
// ========== 步骤6:创建沙箱Session目录/符号链接 ==========
// 6.1 申请缓冲区:(CopyPath长度 + 32) * 2 → CopyPath是系统IPC根目录
v3 = (WCHAR*)MemoryPoolAllocateEx(__Pool, (wcslen(CopyPath) +
32) * sizeof(WCHAR));
if (!v3) goto Exit;
// 6.2 拼接路径:系统IPC根目录 + \Session
wcscpy_s(v3, (wcslen(CopyPath) + 32), CopyPath);
wcscat_s(v3, (wcslen(CopyPath) + 32), L"\\Session");
// 6.3 创建Session目录/符号链接:隔离会话级IPC
Status = CreateDirOrLink(v3, NULL);
if (!NT_SUCCESS(Status)) {
goto Exit;
}
Exit:; // 统一退出标签
// ========== 步骤7:释放所有申请的内存 ==========
if (v3) MemoryPoolFreeEx(v3); // 释放Session路径缓冲区
if (v5) MemoryPoolFreeEx(v5); // 释放BaseNamedObjects路径缓冲区
}
1. 临时事件定位 IPC 根目录的核心原理
这是沙箱最巧妙的技巧之一,解决了 “不同系统 / 会话的 IPC 根目录不同” 的问题:
为什么不用硬编码?
Windows 不同版本 / 会话的 IPC 根目录不同:
全局 IPC:\BaseNamedObjects;
会话级 IPC:\Sessions\1\BaseNamedObjects(会话 1);
硬编码会导致隔离失效,而临时事件法可动态获取当前环境的真实 IPC 根目录。
定位流程:
创建临时事件DummyEvent_1234;
GetName查询事件完整路径(如\Sessions\1\BaseNamedObjects\DummyEvent_1234);
wcsrchr找到最后一个\,截断后得到\Sessions\1\BaseNamedObjects(真实 IPC 根目录)。
典型隔离场景示例
场景:沙箱内创建Global\TestEvent
沙箱内进程调用NtCreateEvent创建Global\TestEvent;
IPC Hook 函数拦截该调用,将名称重定向为\Sandbox\Box1\BaseNamedObjects\TestEvent;
DetourCreateObjects已创建\Sandbox\Box1\BaseNamedObjects目录;
事件实际创建在沙箱专属目录,主机进程调用NtOpenEvent打开Global\TestEvent时,无法找到该事件,实现完全隔离。
DetourCreateObjects 核心要点
核心定位:沙箱 IPC 命名空间隔离的实体化实现函数,创建沙箱专属的 IPC 目录和符号链接;
核心逻辑:
动态定位系统 IPC 根目录(临时事件法);
拼接沙箱专属 IPC 路径(BaseNamedObjects/Session);
创建目录 / 符号链接,实现 IPC 路径重定向;
统一错误处理,释放所有申请的内存;
~~~~~~~~~~~~~~~~~~~~~~~~~
42 生成对项目名
NTSTATUS GetName(
HANDLE RootDirectory, UNICODE_STRING* ObjectName,
WCHAR** TruePath, WCHAR** CopyPath, BOOLEAN* IsBoxedPath)
{
NTSTATUS Status;
ULONG v7;
WCHAR* v5;
ULONG Length;
WCHAR* Buffer;
*TruePath = NULL;
*CopyPath = NULL;
THREAD_DATA* ThreadData = GetTlsData(NULL);
if (IsBoxedPath)
*IsBoxedPath = FALSE;
if (ObjectName && ObjectName != (UNICODE_STRING*)-1) {
Length = ObjectName->Length & ~1;
Buffer = ObjectName->Buffer;
}
else {
Length = 0;
Buffer = NULL;
}
//
// if a root handle is specified, we query the full name of the
// root key, and append the ObjectName
//
if (RootDirectory)
{
v7 = 256;
v5 = GetTlsNameBuffer(
ThreadData, TRUE_NAME_BUFFER, v7 +
Length); //在源数据基础增加256个字节
if (((!Length) || (!*Buffer)) && ObjectName
!= (UNICODE_STRING*)-1) {
//
// an object handle was specified, but
the object name is an
// empty string or NULL. if the
handle is for a directory
// object, then we treat this as an
unnamed object, otherwise
// we go on as usual
//
if (GetObjectType(RootDirectory) ==
OBJ_TYPE_DIRECTORY)
{
*TruePath = NULL;
*CopyPath = NULL;
return STATUS_SUCCESS;
}
}
if (Length && *Buffer == L'\\') {
//
// if the caller specifies both a
directory and an object
// name that begins with a backslash,
return special status
//
return
STATUS_OBJECT_PATH_SYNTAX_BAD;
}
//获取到了对象名称
Status = GetObjectName(RootDirectory, v5, &v7);
/*
0:000> dt _unicode_string 000007ff`fff80b88
ntdll!_UNICODE_STRING
"\Sessions\1\BaseNamedObjects"
+0x000 Length :
0x38
+0x002 MaximumLength : 0x3a
+0x008 Buffer :
0x000007ff`fff80b98 "\Sessions\1\BaseNamedObjects"
*/
if (Status == STATUS_BUFFER_OVERFLOW || Status
== STATUS_BUFFER_TOO_SMALL || Status == STATUS_INFO_LENGTH_MISMATCH) {
v5 = GetTlsNameBuffer(
ThreadData,
TRUE_NAME_BUFFER, v7 + Length);
Status = GetObjectName(RootDirectory,
v5, &v7);
}
if (!NT_SUCCESS(Status))
return Status;
*TruePath =
((OBJECT_NAME_INFORMATION*)v5)->Name.Buffer;
/*
0:000> db 000007ff`fff80b98 TruePath
000007ff`fff80b98 5c 00 53 00 65 00 73 00-73
00 69 00 6f 00 6e 00 \.S.e.s.s.i.o.n.
000007ff`fff80ba8 73 00 5c 00 31 00 5c 00-42
00 61 00 73 00 65 00 s.\.1.\.B.a.s.e.
000007ff`fff80bb8 4e 00 61 00 6d 00 65 00-64
00 4f 00 62 00 6a 00 N.a.m.e.d.O.b.j.
000007ff`fff80bc8 65 00 63 00 74 00 73 00-00
00 00 00 00 00 00 00 e.c.t.s.........
*/
if (!*TruePath) {
//
// object attributes indicate an
unnamed parent directory
//
*TruePath = NULL;
return Status;
}
v5 = (*TruePath)
+
((OBJECT_NAME_INFORMATION*)v5)->Name.Length
/ sizeof(WCHAR);
if (Length) {
*v5 = L'\\';
++v5;
memcpy(v5, Buffer, Length);
v5 += Length / sizeof(WCHAR);
}
*v5 = L'\0';
/*
0:000> db 000007ff`fff80b98 TruePath
000007ff`fff80b98 5c 00 53 00 65 00 73 00-73
00 69 00 6f 00 6e 00 \.S.e.s.s.i.o.n.
000007ff`fff80ba8 73 00 5c 00 31 00 5c 00-42
00 61 00 73 00 65 00 s.\.1.\.B.a.s.e.
000007ff`fff80bb8 4e 00 61 00 6d 00 65 00-64
00 4f 00 62 00 6a 00 N.a.m.e.d.O.b.j.
000007ff`fff80bc8 65 00 63 00 74 00 73 00-5c
00 47 00 6c 00 6f 00 e.c.t.s.\.G.l.o.
000007ff`fff80bd8 62 00 61 00 6c 00 5c 00-53
00 68 00 69 00 6e 00 b.a.l.\.S.h.i.n.
000007ff`fff80be8 65 00 00 00 00 00 00 00-00
00 00 00 00 00 00 00 e...............
*/
}
else if (Length)
{
v5 = GetTlsNameBuffer(ThreadData,
TRUE_NAME_BUFFER, Length);
*TruePath = v5;
memcpy(v5, Buffer, Length);
v5 += Length / sizeof(WCHAR);
*v5 = L'\0';
}
else {
*TruePath = NULL;
*CopyPath = NULL;
return STATUS_SUCCESS;
}
//
// for 32-bit Internet Explorer running on 64-bit Windows 7
(Wow64),
// check for SplWow64 resources, and adjust the integrity level
// which is the last digit of the resource name.
//
// - always 1 if we are running as a standard user (note that
outside
// the sandbox, IE low integrity processes use integrity level 0)
// - always 2 if we are running as an elevated user
//
// this is necessary due Internet Explorer assuming it is going to
// run with low integrity, while in fact it runs with medium
integrity
// inside the sandbox, and Secure_NtQueryInformationToken will
fake
// a high integrity result
//
// on Sandboxie 4 with highly restricted process token, we always
need
// to adjust the SplWow64 object path
//
v7 = wcslen(*TruePath);
CheckSandboxPrefix:
if (v7 >= __BoxIpcPathLength &&
0 == NlsStrCmp(
*TruePath, __BoxIpcPathData,
__BoxIpcPathLength))
{
*TruePath += __BoxIpcPathLength;
v7 -= __BoxIpcPathLength;
if (IsBoxedPath)
*IsBoxedPath = TRUE;
goto CheckSandboxPrefix;
}
v5 = GetTlsNameBuffer(
ThreadData, COPY_NAME_BUFFER, __BoxIpcPathLength
+ v7);
*CopyPath = v5;
{
wmemcpy(v5, __BoxIpcPathData,
__BoxIpcPathLength);
v5 += __BoxIpcPathLength;
wmemcpy(v5, *TruePath, v7);
v5 += v7;
}
*v5 = L'\0';
return STATUS_SUCCESS;
}
GetName 是沙箱解析内核对象完整路径并实现 IPC 路径重定向的核心函数—— 它的核心逻辑是:根据内核对象句柄 /
名称查询其真实路径,剥离沙箱前缀后重新拼接沙箱专属 IPC 路径,是沙箱 “透明重定向 IPC 对象路径” 的核心实现,也是
DetourCreateObjects 中定位 IPC 根目录的关键依赖函数。
一、函数核心功能总结
GetName 的核心目标是:
路径初始化:清空输出参数(TruePath/CopyPath),避免野指针;
参数适配:区分 “传入对象名” 和 “仅传入句柄” 两种场景,统一路径处理逻辑;
内核对象路径查询:通过 GetObjectName 获取句柄对应的内核对象完整路径,处理缓冲区不足的重试逻辑;
路径拼接:将目录句柄路径与对象名拼接,生成完整的内核对象路径;
沙箱路径重定向:剥离路径中的沙箱前缀,重新拼接为沙箱专属 IPC 路径;
TLS 缓冲区管理:使用线程本地存储(TLS)缓冲区存储路径,避免频繁内存分配;
容错处理:针对空路径、目录对象、路径语法错误等场景做特殊处理,保证鲁棒性。
简单说:这个函数是沙箱的 “IPC 路径转换器”—— 它解决了 “如何获取内核对象真实路径” 和 “如何将路径透明重定向到沙箱命名空间” 的核心问题,是 IPC
隔离从 “目录创建” 到 “路径重写” 的关键桥梁。
二、核心前置知识(必须掌握)
| 符号 / 函数 / 术语 | 含义 |
|---|---|
| OBJECT_NAME_INFORMATION | Windows 内核结构体:存储内核对象的名称信息(Name字段为UNICODE_STRING类型的路径); |
| TLS(线程本地存储) | 线程私有缓冲区:每个线程独立的内存区域,用于存储路径数据,避免多线程冲突; |
| GetTlsData | 沙箱自定义函数:获取当前线程的 TLS 数据块(包含路径缓冲区); |
| GetTlsNameBuffer | 沙箱自定义函数:从 TLS 中获取指定类型的路径缓冲区(TRUE_NAME_BUFFER/COPY_NAME_BUFFER); |
| GetObjectName | 沙箱自定义函数:调用 NtQueryObject 查询内核对象的名称信息; |
| GetObjectType | 沙箱自定义函数:查询内核对象的类型(如OBJ_TYPE_DIRECTORY代表目录对象); |
| __BoxIpcPathData | 沙箱专属 IPC 前缀(如\Sandbox\Box1); |
| __BoxIpcPathLength | 沙箱 IPC 前缀的字符长度(用于路径拼接 / 剥离); |
| NlsStrCmp | 沙箱自定义字符串比较函数(适配多语言,不区分大小写); |
核心结构体(OBJECT_NAME_INFORMATION)
typedef struct _OBJECT_NAME_INFORMATION {
UNICODE_STRING Name; //
内核对象的完整路径(如\Sessions\1\BaseNamedObjects\TestEvent)
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;
完整函数
NTSTATUS GetName(
HANDLE RootDirectory, //
输入:目录对象句柄(如临时事件句柄)
UNICODE_STRING* ObjectName, // 输入:对象名(可为NULL/-1)
WCHAR** TruePath, //
输出:内核对象真实路径(剥离沙箱前缀)
WCHAR** CopyPath, //
输出:重定向后的沙箱路径
BOOLEAN* IsBoxedPath) //
输出:是否为沙箱路径(可选)
{
NTSTATUS Status;
ULONG v7;
// 路径缓冲区长度
WCHAR* v5;
// TLS路径缓冲区指针
ULONG Length;
// ObjectName的字节长度
WCHAR* Buffer;
// ObjectName的缓冲区指针
// ========== 步骤1:初始化输出参数 ==========
*TruePath = NULL;
*CopyPath = NULL;
// ========== 步骤2:获取线程TLS数据 ==========
THREAD_DATA* ThreadData = GetTlsData(NULL);
// ========== 步骤3:初始化沙箱路径标记 ==========
if (IsBoxedPath)
*IsBoxedPath = FALSE;
// ========== 步骤4:解析传入的ObjectName参数 ==========
if (ObjectName && ObjectName != (UNICODE_STRING*)-1) {
Length = ObjectName->Length & ~1; //
确保长度为偶数(宽字符对齐)
Buffer = ObjectName->Buffer; //
对象名缓冲区
} else {
Length = 0;
Buffer = NULL;
}
// ========== 步骤5:处理RootDirectory句柄(核心路径查询逻辑) ==========
if (RootDirectory) {
// 5.1 申请初始TLS缓冲区(256 + ObjectName长度)
v7 = 256;
v5 = GetTlsNameBuffer(ThreadData,
TRUE_NAME_BUFFER, v7 + Length);
// 5.2 特殊处理:空对象名且是目录对象 → 返回空路径
if (((!Length) || (!*Buffer)) && ObjectName
!= (UNICODE_STRING*)-1) {
if (GetObjectType(RootDirectory) ==
OBJ_TYPE_DIRECTORY) {
*TruePath = NULL;
*CopyPath = NULL;
return STATUS_SUCCESS;
}
}
// 5.3 特殊处理:对象名以\开头且有目录句柄 → 路径语法错误
if (Length && *Buffer == L'\\') {
return
STATUS_OBJECT_PATH_SYNTAX_BAD;
}
// 5.4 查询内核对象名称(第一次尝试)
Status = GetObjectName(RootDirectory, v5, &v7);
// 5.5 缓冲区不足 → 重新申请更大的缓冲区重试
if (Status == STATUS_BUFFER_OVERFLOW || Status
== STATUS_BUFFER_TOO_SMALL || Status == STATUS_INFO_LENGTH_MISMATCH) {
v5 = GetTlsNameBuffer(ThreadData,
TRUE_NAME_BUFFER, v7 + Length);
Status = GetObjectName(RootDirectory,
v5, &v7);
}
if (!NT_SUCCESS(Status)) // 查询失败 → 返回错误
return Status;
// 5.6 提取真实路径(OBJECT_NAME_INFORMATION的Name.Buffer)
*TruePath =
((OBJECT_NAME_INFORMATION*)v5)->Name.Buffer;
if (!*TruePath) { // 无路径 → 返回成功(空路径)
*TruePath = NULL;
return Status;
}
// 5.7 拼接目录路径和对象名(生成完整路径)
// 移动指针到目录路径末尾
v5 = (*TruePath) +
((OBJECT_NAME_INFORMATION*)v5)->Name.Length / sizeof(WCHAR);
if (Length) { // 有对象名 → 拼接\ + 对象名
*v5 = L'\\';
++v5;
memcpy(v5, Buffer, Length); // 拷贝对象名字节
v5 += Length / sizeof(WCHAR); // 移动指针
}
*v5 = L'\0'; // 添加字符串终止符
}
// ========== 步骤6:无RootDirectory → 直接使用ObjectName ==========
else if (Length) {
v5 = GetTlsNameBuffer(ThreadData,
TRUE_NAME_BUFFER, Length);
*TruePath = v5;
memcpy(v5, Buffer, Length); // 拷贝对象名
v5 += Length / sizeof(WCHAR);
*v5 = L'\0';
}
// ========== 步骤7:无路径 → 返回空 ==========
else {
*TruePath = NULL;
*CopyPath = NULL;
return STATUS_SUCCESS;
}
// ========== 步骤8:剥离路径中的沙箱前缀(递归处理) ==========
v7 = wcslen(*TruePath); // 获取真实路径长度
CheckSandboxPrefix:
// 路径长度≥沙箱前缀长度,且前缀匹配
if (v7 >= __BoxIpcPathLength &&
0 == NlsStrCmp(*TruePath, __BoxIpcPathData,
__BoxIpcPathLength)) {
*TruePath += __BoxIpcPathLength; // 剥离沙箱前缀
v7 -= __BoxIpcPathLength;
// 更新长度
if (IsBoxedPath)
*IsBoxedPath = TRUE;
// 标记为沙箱路径
goto CheckSandboxPrefix;
// 递归检查(处理多层前缀)
}
// ========== 步骤9:拼接沙箱专属路径(生成CopyPath) ==========
// 申请CopyPath缓冲区(沙箱前缀长度 + 真实路径长度)
v5 = GetTlsNameBuffer(ThreadData, COPY_NAME_BUFFER,
__BoxIpcPathLength + v7);
*CopyPath = v5;
// 9.1 拷贝沙箱前缀到CopyPath
wmemcpy(v5, __BoxIpcPathData, __BoxIpcPathLength);
v5 += __BoxIpcPathLength;
// 9.2 拷贝剥离前缀后的真实路径
wmemcpy(v5, *TruePath, v7);
v5 += v7;
// 9.3 添加终止符
*v5 = L'\0';
// ========== 步骤10:返回成功 ==========
return STATUS_SUCCESS;
}
1. 内核对象路径查询的核心逻辑
Status = GetObjectName(RootDirectory, v5, &v7);
if (Status == STATUS_BUFFER_OVERFLOW || ...) {
v5 = GetTlsNameBuffer(..., v7 + Length);
Status = GetObjectName(RootDirectory, v5, &v7);
}
GetObjectName 底层调用 NtQueryObject,参数为 ObjectNameInformation(1);
第一次查询可能返回STATUS_BUFFER_OVERFLOW(缓冲区太小),此时用返回的v7(需要的长度)重新申请缓冲区重试;
这是内核对象路径查询的 “标准重试逻辑”,保证能获取完整路径。
2. 路径拼接的核心技巧
以临时事件为例:
RootDirectory 是事件句柄,GetObjectName 返回路径 \Sessions\1\BaseNamedObjects;
ObjectName 是 DummyEvent_1234,Length 是其字节长度;
拼接逻辑:
初始v5 → \Sessions\1\BaseNamedObjects(末尾)
*v5 = L'\\' → 追加\
memcpy(v5+1, DummyEvent_1234, Length) → 追加事件名
最终路径 → \Sessions\1\BaseNamedObjects\DummyEvent_1234
3. 沙箱路径重定向的核心逻辑
假设:
__BoxIpcPathData = \Sandbox\Box1(长度 = 9);
*TruePath = \Sessions\1\BaseNamedObjects\TestEvent(长度 = 28);
重定向流程:
检查*TruePath是否以沙箱前缀开头(此处否);
申请CopyPath缓冲区(9+28=37 字符);
拷贝沙箱前缀 → \Sandbox\Box1;
拷贝真实路径 → \Sessions\1\BaseNamedObjects\TestEvent;
最终CopyPath = \Sandbox\Box1\Sessions\1\BaseNamedObjects\TestEvent;
沙箱内进程访问\Sessions\1\BaseNamedObjects\TestEvent时,实际访问的是CopyPath指向的沙箱路径,实现透明重定向。
4. TLS 缓冲区的核心优势
传统方式:每次查询路径都malloc/free,频繁分配内存导致性能损耗;
TLS 方式:每个线程预分配固定大小的缓冲区,重复使用,无需频繁分配;
TRUE_NAME_BUFFER 存储真实路径,COPY_NAME_BUFFER 存储重定向后的路径,分工明确。
5. 递归剥离沙箱前缀的逻辑
CheckSandboxPrefix:
if (...) {
*TruePath += __BoxIpcPathLength;
v7 -= __BoxIpcPathLength;
goto CheckSandboxPrefix;
}
处理场景:路径可能包含多层沙箱前缀(如\Sandbox\Box1\Sandbox\Box1\TestEvent);
递归剥离所有前缀,保证*TruePath是原始路径,避免重定向时重复拼接前缀。
核心设计亮点(新手必学)
1. 容错的路径查询逻辑
处理多种异常场景:
空对象名 + 目录对象 → 返回空路径;
对象名以\开头 + 目录句柄 → 返回语法错误;
缓冲区不足 → 自动重试;
无路径 → 返回空;
覆盖内核对象路径查询的所有边界场景,保证函数不会崩溃。
2. TLS 缓冲区的高效使用
放弃频繁内存分配,使用线程私有 TLS 缓冲区:
提升性能(避免内存分配 / 释放的开销);
避免多线程冲突(每个线程有独立缓冲区);
工业级沙箱的 “性能优化设计”,适配高频 IPC 路径查询场景。
3. 透明的路径重定向
剥离原有沙箱前缀 → 重新拼接新前缀:
对进程透明(进程感知不到路径被修改);
支持多层前缀处理,避免路径异常;
是沙箱 “无感知隔离” 的核心实现。
4. 宽字符对齐处理
Length = ObjectName->Length & ~1;
ObjectName->Length 是字节长度,宽字符占 2 字节,& ~1 确保长度为偶数(对齐);
避免因奇数长度导致memcpy时内存越界,是内核态宽字符操作的 “细节优化”。
典型场景示例
场景:解析临时事件路径并重定向
输入:RootDirectory = 临时事件句柄,ObjectName = NULL;
GetObjectName 查询事件路径 → \Sessions\1\BaseNamedObjects\DummyEvent_1234;
*TruePath = \Sessions\1\BaseNamedObjects\DummyEvent_1234;
检查是否以沙箱前缀开头(否);
拼接沙箱前缀 → CopyPath =
\Sandbox\Box1\Sessions\1\BaseNamedObjects\DummyEvent_1234;
返回STATUS_SUCCESS,DetourCreateObjects
截取CopyPath的根目录\Sandbox\Box1\Sessions\1\BaseNamedObjects。
GetName 核心要点
核心定位:沙箱 IPC 路径解析与重定向的核心函数,实现内核对象路径的查询、拼接、重写;
核心逻辑:
查询内核对象真实路径(处理缓冲区不足的重试);
拼接目录句柄和对象名,生成完整路径;
剥离沙箱前缀,重新拼接为沙箱专属路径;
使用 TLS 缓冲区存储路径,提升性能;
简单来说,这个函数是沙箱 “IPC 路径隔离” 的核心转换器 —— 它将内核对象的真实路径解析出来,并重写为沙箱专属路径,让所有 IPC
操作都落在沙箱命名空间内,是沙箱实现 IPC 完全隔离的 “最后一公里”。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
NTSTATUS GetObjectName(
HANDLE ObjectHandle, void* ObjectName, ULONG* Length)
{
NTSTATUS Status;
//
// NtQueryObject is known for locking up forever when queried for
object name on a pipe handle
// opened for synchronous io, and where there are pending read
operations.
//
// To remedy this we ask the driver to lookup the objects name
instead
//
if (__IsUseDriverLookup)
{
OBJECT_NAME_INFORMATION* ObjectNameInfo =
(OBJECT_NAME_INFORMATION*)ObjectName;
wchar_t* v5 = (wchar_t*)((UCHAR*)ObjectName +
sizeof(OBJECT_NAME_INFORMATION));
ULONG v7 = *Length -
sizeof(OBJECT_NAME_INFORMATION);
Status = GetFileName(ObjectHandle, v5, &v7, NULL);
if (NT_SUCCESS(Status)) {
ObjectNameInfo->Name.Buffer = v5;
ObjectNameInfo->Name.Length =
wcslen(v5) * sizeof(wchar_t);
ObjectNameInfo->Name.MaximumLength =
(USHORT)v7;
/*
0:000> dt _unicode_string
000007ff`fff80b88
ntdll!_UNICODE_STRING
"\Sessions\1\BaseNamedObjects"
+0x000 Length
: 0x38
+0x002 MaximumLength :
0x3a
+0x008 Buffer
: 0x000007ff`fff80b98 "\Sessions\1\BaseNamedObjects"
*/
}
}
else {
THREAD_DATA* ThreadData = GetTlsData(NULL);
ThreadData->NtQueryObjectLock = TRUE;
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation,
ObjectName, *Length, Length);
ThreadData->NtQueryObjectLock = FALSE;
/*
0:000> dt _unicode_string 0x000007ff`fff80b88
ntdll!_UNICODE_STRING
"\Sessions\1\BaseNamedObjects"
+0x000 Length :
0x38
+0x002 MaximumLength : 0x3a
+0x008 Buffer :
0x000007ff`fff80b98 "\Sessions\1\BaseNamedObjects"
*/
}
return Status;
}
GetObjectName 是沙箱查询内核对象名称的核心函数—— 它提供了两种内核对象名称查询方式(驱动层查询 / 用户态 NtQueryObject
查询),解决了 NtQueryObject 在管道句柄场景下的死锁问题,是沙箱稳定获取内核对象路径的关键底层函数,也是 GetName 函数的核心依赖。
函数核心功能总结
GetObjectName 的核心目标是:
双模式查询:根据 __IsUseDriverLookup 开关,选择 “驱动层查询” 或 “用户态 NtQueryObject 查询”;
死锁规避:针对管道句柄同步 IO + 挂起读操作场景,使用驱动层查询替代 NtQueryObject,避免永久死锁;
对象名称填充:将查询到的对象名称填充到 OBJECT_NAME_INFORMATION 结构体,统一输出格式;
线程状态标记:使用 TLS 标记 NtQueryObject 执行状态,避免嵌套查询冲突;
兼容输出:两种查询方式最终输出格式一致,上层函数(如 GetName)无需适配。
简单说:这个函数是沙箱的 “内核对象名称查询适配器”—— 它解决了 NtQueryObject
的死锁缺陷,同时提供兼容的查询接口,保证沙箱能稳定、安全地获取任意内核对象的名称。
核心前置知识(必须掌握)
| 符号 / 函数 / 术语 | 含义 |
|---|---|
| NtQueryObject | Windows 内核 API:查询内核对象的属性(ObjectNameInformation 类型用于获取对象名称); |
| ObjectNameInformation | NtQueryObject 的查询类型(值为 1),用于获取内核对象的名称信息; |
| OBJECT_NAME_INFORMATION | 内核对象名称结构体:包含 UNICODE_STRING 类型的 Name 字段,存储对象完整路径; |
| __IsUseDriverLookup | 沙箱全局开关:TRUE 使用驱动层查询,FALSE 使用 NtQueryObject; |
| __sys_NtQueryObject | 沙箱保存的原始 NtQueryObject 函数指针(未 Hook 的原生函数); |
| GetFileName | 沙箱驱动层函数:通过内核驱动查询对象名称,规避 NtQueryObject 死锁; |
| ThreadData->NtQueryObjectLock | TLS 标记:标记当前线程正在执行 NtQueryObject,防止嵌套调用; |
| 管道句柄死锁场景 | 同步 IO 的管道句柄 + 存在挂起读操作时,调用 NtQueryObject 会导致线程永久挂起; |
核心结构体回顾
typedef struct _OBJECT_NAME_INFORMATION {
UNICODE_STRING Name; //
内核对象名称(如\Sessions\1\BaseNamedObjects\TestEvent)
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;
typedef struct _UNICODE_STRING {
USHORT Length; // 字符串字节长度(不含终止符)
USHORT MaximumLength; // 缓冲区最大字节长度
PWSTR Buffer; // 字符串缓冲区指针
} UNICODE_STRING;
完整函数
NTSTATUS GetObjectName(
HANDLE ObjectHandle, // 输入:要查询的内核对象句柄(如事件、管道、端口)
void* ObjectName, //
输出:指向OBJECT_NAME_INFORMATION的缓冲区
ULONG* Length) //
输入输出:缓冲区长度(入=申请长度,出=实际需要长度)
{
NTSTATUS Status;
// 注释:NtQueryObject在管道句柄同步IO+挂起读操作时会永久死锁,
// 因此沙箱提供驱动层查询作为替代方案
/*
NtQueryObject is known for locking up forever when queried for
object name on a pipe handle
opened for synchronous io, and where there are pending read
operations.
To remedy this we ask the driver to lookup the objects name
instead
*/
// ========== 分支1:使用驱动层查询(规避死锁) ==========
if (__IsUseDriverLookup) {
// 1.1 类型转换:将输出缓冲区转为OBJECT_NAME_INFORMATION指针
OBJECT_NAME_INFORMATION* ObjectNameInfo =
(OBJECT_NAME_INFORMATION*)ObjectName;
// 1.2 计算对象名称缓冲区地址:
// OBJECT_NAME_INFORMATION结构体后紧跟的内存区域(用于存储名称字符串)
wchar_t* v5 = (wchar_t*)((UCHAR*)ObjectName +
sizeof(OBJECT_NAME_INFORMATION));
// 1.3 计算名称缓冲区可用长度:总长度 - 结构体大小
ULONG v7 = *Length -
sizeof(OBJECT_NAME_INFORMATION);
// 1.4 调用驱动层GetFileName查询对象名称
Status = GetFileName(ObjectHandle, v5, &v7, NULL);
// 1.5 查询成功 → 填充OBJECT_NAME_INFORMATION结构体
if (NT_SUCCESS(Status)) {
ObjectNameInfo->Name.Buffer =
v5;
// 名称缓冲区指针
ObjectNameInfo->Name.Length =
wcslen(v5) * sizeof(wchar_t); // 名称字节长度(不含终止符)
ObjectNameInfo->Name.MaximumLength =
(USHORT)v7; // 缓冲区最大长度
// 示例输出:Name =
"\Sessions\1\BaseNamedObjects"
}
}
// ========== 分支2:使用用户态NtQueryObject查询(兼容模式) ==========
else {
// 2.1 获取当前线程TLS数据
THREAD_DATA* ThreadData = GetTlsData(NULL);
// 2.2 标记线程正在执行NtQueryObject(防止嵌套调用)
ThreadData->NtQueryObjectLock = TRUE;
// 2.3 调用原始NtQueryObject查询对象名称
// 参数:句柄、查询类型(1)、输出缓冲区、缓冲区长度、实际长度指针
Status = __sys_NtQueryObject(
ObjectHandle, ObjectNameInformation,
ObjectName, *Length, Length);
// 2.4 清除线程标记
ThreadData->NtQueryObjectLock = FALSE;
// 示例输出:Name = "\Sessions\1\BaseNamedObjects"
}
// ========== 返回查询结果 ==========
return Status;
}
1. 驱动层查询的内存布局设计
驱动层查询的缓冲区布局如下(ObjectName 指向的内存):
+---------------------------+---------------------------+
| OBJECT_NAME_INFORMATION | 宽字符名称缓冲区
|
| (sizeof=0x10) | (长度=TotalLength -
0x10) |
+---------------------------+---------------------------+
| Name (UNICODE_STRING) | "Sessions\1\BaseNamedObjects" |
+---------------------------+---------------------------+
v5 指向名称缓冲区起始地址;
v7 是名称缓冲区的可用长度;
GetFileName 将查询到的名称写入 v5,并更新 v7 为实际使用的长度;
最终 ObjectNameInfo->Name 指向 v5,与 NtQueryObject 的输出格式完全一致。
2. NtQueryObject 死锁的核心原因
同步 IO 的管道句柄:管道句柄创建时指定 FILE_SYNCHRONOUS_IO_NONALERT;
挂起读操作:线程调用 ReadFile 读取管道,但数据未到达,线程挂起;
此时调用 NtQueryObject 查询管道名称:NtQueryObject 需要获取管道对象的锁,而挂起的读操作已持有该锁,导致死锁;
驱动层查询 GetFileName 绕开用户态锁机制,直接从内核对象结构中读取名称,避免死锁。
3. 线程标记 NtQueryObjectLock 的作用
沙箱 Hook 了大量内核 API,部分 Hook 函数可能嵌套调用 GetObjectName;
例如:Hook NtOpenEvent → 调用 GetObjectName → 调用 NtQueryObject → 又触发其他 Hook → 再次调用
GetObjectName;
ThreadData->NtQueryObjectLock = TRUE 标记当前线程正在执行 NtQueryObject,嵌套调用时可直接返回,避免无限递归
/ 死锁。
4. __sys_NtQueryObject 的必要性
沙箱 Hook 了 NtQueryObject(用于拦截 / 重定向对象名称);
如果直接调用 NtQueryObject,会触发沙箱自身的 Hook,导致循环调用;
__sys_NtQueryObject 是保存的原始函数指针(Hook 前的地址),调用它可直接执行系统原生的 NtQueryObject,避免 Hook
嵌套。
1. 双模式查询的容错设计
核心问题:NtQueryObject 存在场景化死锁缺陷,但驱动层查询兼容性稍差;
解决方案:通过 __IsUseDriverLookup 开关动态切换:
正常场景:使用 NtQueryObject(兼容性好);
管道 / 高风险场景:切换到驱动层查询(稳定性好);
工业级沙箱的 “分层容错设计”:核心功能提供多套实现,根据场景动态切换。
2. 统一的输出格式
驱动层查询和 NtQueryObject 查询最终都填充 OBJECT_NAME_INFORMATION 结构体;
上层函数(如 GetName)无需关心底层查询方式,直接使用结构体字段;
符合 “接口与实现分离” 的设计原则,降低代码耦合度。
3. 线程安全的状态标记
使用 TLS 的 NtQueryObjectLock 标记线程状态,而非全局锁:
全局锁会导致多线程竞争,降低性能;
TLS 标记仅作用于当前线程,无竞争,且能精准防止嵌套调用;
内核态编程中 “线程私有状态” 的经典使用场景。
4. 内存布局的精准计算
wchar_t* v5 = (wchar_t*)((UCHAR*)ObjectName +
sizeof(OBJECT_NAME_INFORMATION));
强制转换为 UCHAR* 后偏移,保证字节级精准对齐;
避免因结构体对齐(如 32/64 位差异)导致的缓冲区偏移错误;
内核态内存操作的 “细节严谨性” 体现,稍有偏差就会导致内存越界 / 蓝屏。
典型场景示例
场景 1:正常查询事件对象名称(使用 NtQueryObject)
输入:ObjectHandle = 事件句柄,__IsUseDriverLookup = FALSE;
标记 ThreadData->NtQueryObjectLock = TRUE;
调用 __sys_NtQueryObject 查询事件名称 → \Sessions\1\BaseNamedObjects\TestEvent;
填充 OBJECT_NAME_INFORMATION 的 Name 字段;
清除线程标记,返回 STATUS_SUCCESS;
上层 GetName 函数读取该名称,进行路径重定向。
场景 2:查询管道句柄名称(使用驱动层查询)
输入:ObjectHandle = 同步 IO 管道句柄(有挂起读操作),__IsUseDriverLookup = TRUE;
计算名称缓冲区地址 v5 和长度 v7;
调用 GetFileName 驱动层查询 → 避免死锁;
填充 OBJECT_NAME_INFORMATION 的 Name 字段;
返回 STATUS_SUCCESS,避免线程永久挂起。
GetObjectName 核心要点
核心定位:沙箱内核对象名称查询的适配层函数,提供双模式查询,规避 NtQueryObject 死锁缺陷;
核心逻辑:
根据 __IsUseDriverLookup 选择驱动层 / 用户态查询;
驱动层查询:计算内存布局,填充对象名称结构体;
用户态查询:标记线程状态,调用原始 NtQueryObject;
两种方式输出格式统一,上层无需适配;
~~~~
NTSTATUS CreatePath(WCHAR* TruePath, WCHAR* CopyPath)
{
NTSTATUS Status;
HANDLE DirectoryHandle;
OBJECT_ATTRIBUTES ObjectAttributes;
UNICODE_STRING ObjectName;
WCHAR* Backslash;
//if (Dll_AlernateIpcNaming)
// return STATUS_OBJECT_PATH_NOT_FOUND;
//
// open the TruePath object directory containing the object
// for which we got STATUS_OBJECT_PATH_NOT_FOUND
//
Backslash = wcsrchr(TruePath, L'\\');
if (!Backslash)
return STATUS_OBJECT_PATH_NOT_FOUND;
*Backslash = L'\0';
InitializeObjectAttributes(
&ObjectAttributes, &ObjectName,
OBJ_CASE_INSENSITIVE, NULL, NULL);
RtlInitUnicodeString(&ObjectName, TruePath);
Status = __sys_NtOpenDirectoryObject(&DirectoryHandle,
DIRECTORY_QUERY, &ObjectAttributes);
*Backslash = L'\\';
if (!NT_SUCCESS(Status))
return Status;
NtClose(DirectoryHandle);
//
// create the parent directories along the CopyPath
//
Backslash = CopyPath;
while (1) {
Backslash = wcschr(Backslash + 1, L'\\');
if (!Backslash)
break;
if ((ULONG)(Backslash - CopyPath) >
__BoxIpcPathLength) {
*Backslash = L'\0';
Status = CreateDirOrLink(CopyPath,
NULL);
*Backslash = L'\\';
if (!NT_SUCCESS(Status))
return Status;
}
}
return STATUS_SUCCESS;
}
CreatePath 是沙箱递归创建 IPC 路径目录 / 符号链接的核心函数—— 它的核心逻辑是先验证原始 IPC
路径(TruePath)的合法性,再递归遍历沙箱重定向路径(CopyPath),创建所有缺失的父目录 / 符号链接,是沙箱 “保证 IPC
路径存在性、实现透明路径重定向” 的关键落地函数。
CreatePath 的核心目标是:
原始路径校验:截取 TruePath 的父目录,验证该目录是否存在(避免创建无效路径);
递归路径创建:遍历 CopyPath 的所有层级目录,创建沙箱专属路径下缺失的父目录 / 符号链接;
路径还原:操作过程中临时截断路径,操作完成后还原,避免破坏原始路径;
权限适配:使用原生 __sys_NtOpenDirectoryObject 打开目录,避免 Hook 嵌套;
容错处理:任何步骤失败立即返回错误码,保证路径创建的原子性。
简单说:这个函数是沙箱的 “IPC 路径构建器”—— 它解决了 “沙箱重定向后的路径不存在导致 IPC 操作失败”
的问题,递归创建所有必要的父目录,让沙箱内进程访问 IPC 对象时,路径始终有效。
核心前置知识(必须掌握)
| 符号 / 函数 / 术语 | 含义 |
|---|---|
| NtOpenDirectoryObject | Windows 内核 API:打开内核目录对象(验证目录是否存在); |
| InitializeObjectAttributes | 初始化内核对象属性(如大小写不敏感、安全描述符等); |
| RtlInitUnicodeString | 初始化 Unicode 字符串(内核 API 标准入参格式); |
| CreateDirOrLink | 沙箱自定义函数:创建内核目录对象或符号链接(核心 IPC 隔离实现); |
| wcsrchr/wcschr | 字符串查找函数:wcsrchr找最后一个\(截取父目录),wcschr找下一个\(遍历路径层级); |
| __sys_NtOpenDirectoryObject | 沙箱保存的原始 NtOpenDirectoryObject 指针(避免 Hook 嵌套); |
| __BoxIpcPathLength | 沙箱 IPC 根路径长度(如\Sandbox\Box1长度为 9),用于区分沙箱基础路径和需要创建的子路径; |
NTSTATUS CreatePath(WCHAR* TruePath, WCHAR* CopyPath)
{
NTSTATUS Status;
HANDLE DirectoryHandle; // 目录对象句柄
OBJECT_ATTRIBUTES ObjectAttributes; // 内核对象属性
UNICODE_STRING ObjectName; // 内核Unicode字符串(路径)
WCHAR* Backslash; //
路径中反斜杠指针
// 注释:备用IPC命名开关(启用则直接返回路径不存在,跳过创建)
//if (Dll_AlernateIpcNaming)
// return STATUS_OBJECT_PATH_NOT_FOUND;
// ========== 步骤1:校验原始路径(TruePath)的合法性 ==========
// 1.1 查找TruePath中最后一个反斜杠(截取父目录)
Backslash = wcsrchr(TruePath, L'\\');
if (!Backslash) // 无反斜杠 → 路径格式错误
return STATUS_OBJECT_PATH_NOT_FOUND;
// 1.2 临时截断路径(删除最后一级,保留父目录)
*Backslash = L'\0';
// 1.3 初始化内核对象属性(大小写不敏感)
InitializeObjectAttributes(
&ObjectAttributes, &ObjectName,
OBJ_CASE_INSENSITIVE, NULL, NULL);
// 1.4 初始化Unicode字符串(绑定截断后的TruePath)
RtlInitUnicodeString(&ObjectName, TruePath);
// 1.5 打开父目录(验证目录存在性)
Status = __sys_NtOpenDirectoryObject(&DirectoryHandle,
DIRECTORY_QUERY, &ObjectAttributes);
// 1.6 还原TruePath(恢复最后一个反斜杠)
*Backslash = L'\\';
// 1.7 目录打开失败 → 返回错误(原始路径不合法)
if (!NT_SUCCESS(Status))
return Status;
// 1.8 关闭目录句柄(仅验证存在性,无需保留)
NtClose(DirectoryHandle);
// ========== 步骤2:递归创建CopyPath的所有父目录 ==========
Backslash = CopyPath; // 从CopyPath起始位置开始遍历
while (1) {
// 2.1 查找下一个反斜杠(遍历路径层级)
Backslash = wcschr(Backslash + 1, L'\\');
if (!Backslash) // 无更多反斜杠 → 遍历结束
break;
// 2.2 跳过沙箱基础路径(仅创建子路径)
if ((ULONG)(Backslash - CopyPath) >
__BoxIpcPathLength) {
// 2.3 临时截断路径到当前层级
*Backslash = L'\0';
// 2.4 创建当前层级的目录/符号链接
Status = CreateDirOrLink(CopyPath,
NULL);
// 2.5 还原路径(恢复反斜杠)
*Backslash = L'\\';
// 2.6 创建失败 → 返回错误
if (!NT_SUCCESS(Status))
return Status;
}
}
// ========== 步骤3:返回成功 ==========
return STATUS_SUCCESS;
}
1. 原始路径(TruePath)合法性校验逻辑
以 TruePath = \Sessions\1\BaseNamedObjects\TestEvent 为例:
wcsrchr(TruePath, L'\\') 找到最后一个\(指向TestEvent前);
临时截断为 \Sessions\1\BaseNamedObjects;
调用 __sys_NtOpenDirectoryObject 打开该目录:
成功 → 原始父目录存在,继续创建沙箱路径;
失败 → 返回错误(如STATUS_OBJECT_PATH_NOT_FOUND),终止流程;
核心目的:只对合法的原始路径创建沙箱重定向路径,避免创建无效路径。
2. 递归创建 CopyPath 目录的核心逻辑
假设:
CopyPath = \Sandbox\Box1\Sessions\1\BaseNamedObjects\TestEvent;
__BoxIpcPathLength = 9(\Sandbox\Box1的字符长度);
遍历流程:
| 遍历步骤 | Backslash 位置 | 偏移量(Backslash-CopyPath) | 是否创建 | 操作 |
|---|---|---|---|---|
| 1 | \Sandbox\Box1\Sessions... | 9(等于__BoxIpcPathLength) | 否 | 跳过沙箱基础路径 |
| 2 | \Sandbox\Box1\Sessions\1... | 18(>9) | 是 | 截断为\Sandbox\Box1\Sessions,创建目录 |
| 3 | \Sandbox\Box1\Sessions\1\BaseNamedObjects... | 21(>9) | 是 | 截断为\Sandbox\Box1\Sessions\1,创建目录 |
| 4 | \Sandbox\Box1\Sessions\1\BaseNamedObjects\TestEvent | 38(>9) |
核心设计:跳过沙箱基础路径(已由DetourCreateObjects创建),只创建后续的子目录,避免重复创建导致错误。
3. 路径临时截断与还原的设计
*Backslash = L'\0'; // 临时截断
Status = CreateDirOrLink(CopyPath, NULL);
*Backslash = L'\\'; // 还原
作用:无需额外拷贝路径,仅通过修改单个字符实现路径截断,创建当前层级目录后立即还原,保证原始路径不被破坏;
优势:高效(无内存拷贝)、安全(操作完成后路径完整),是内核态路径操作的经典技巧。
4. __sys_NtOpenDirectoryObject 的必要性
沙箱 Hook 了NtOpenDirectoryObject(用于拦截目录访问);
如果直接调用NtOpenDirectoryObject,会触发沙箱自身的 Hook 逻辑,导致:
嵌套调用(Hook→CreatePath→NtOpenDirectoryObject→Hook);
错误的路径重定向(Hook 会将路径改为沙箱路径,无法验证原始路径);
__sys_NtOpenDirectoryObject 是原始函数指针,直接访问系统原生接口,保证校验结果准确。
5. OBJ_CASE_INSENSITIVE 的适配意义
Windows 内核对象名称默认大小写不敏感(如\BaseNamedObjects和\basenamedobjects是同一个目录);
InitializeObjectAttributes 时指定OBJ_CASE_INSENSITIVE,保证路径校验和创建不区分大小写,适配 Windows
系统特性。
典型场景示例
场景:创建沙箱 IPC 路径
沙箱内进程调用NtCreateEvent创建\BaseNamedObjects\TestEvent;
IPC Hook 函数生成TruePath = \BaseNamedObjects,CopyPath =
\Sandbox\Box1\BaseNamedObjects;
CreatePath 先验证TruePath(\BaseNamedObjects)存在;
遍历CopyPath:
找到第一个\(\Sandbox\Box1\),偏移量 = 9,跳过;
找到第二个\(\Sandbox\Box1\BaseNamedObjects),偏移量 = 20>9;
截断路径为\Sandbox\Box1\BaseNamedObjects,调用CreateDirOrLink创建目录;
返回STATUS_SUCCESS,沙箱路径创建完成;
进程最终在\Sandbox\Box1\BaseNamedObjects下创建TestEvent,实现隔离。
CreatePath 核心要点
核心定位:沙箱 IPC 路径创建的核心函数,验证原始路径合法性并递归创建沙箱重定向路径;
核心逻辑:
截取 TruePath 父目录,验证其存在性;
遍历 CopyPath 层级,跳过沙箱基础路径,创建所有子目录;
临时截断路径创建目录,完成后还原;
设计原则:
防御性:先校验后创建,避免无效路径;
高效性:无内存拷贝,直接操作原始路径;
分层性:区分沙箱基础路径和子路径,避免重复创建;
原子性:失败立即返回,保证路径完整性。
简单来说,这个函数是沙箱 “IPC 路径重定向” 的最后一道保障 —— 它确保沙箱重定向后的 IPC 路径所有父目录都存在,让沙箱内进程的 IPC 操作能
“透明” 执行,既不感知路径被修改,也不会因路径不存在导致操作失败。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
43
浙公网安备 33010602011771号