找回密码
 立即注册
搜索
查看: 232|回复: 0

[翻译] NTFS 重解析点

[复制链接]

233

主题

0

回帖

2083

积分

管理员

积分
2083
发表于 2025-9-12 14:54:42 | 显示全部楼层 |阅读模式
    嗨,哈勃(Habr,俄罗斯知名技术社区)的读者们。我在这里为大家准备了一份关于NTFS重解析点(以下简称RP)的小指南。这篇文章适合那些刚开始涉足Windows内核驱动程序开发的人。在开头部分,我会结合实例讲解理论,随后会布置一个有趣的任务供大家解决。

    重解析点(RP)是NTFS文件系统的一项关键特性,在解决备份与恢复任务方面颇具效用。因此,Acronis(一家数据备份与灾难恢复解决方案提供商)对这项技术非常感兴趣。

实用链接
    如果你想自行深入了解该主题,不妨查阅以下资源。接下来你将看到的理论部分,正是对这些资料内容的提炼与总结。


一些理论知识
    重解析点(Reparse Point,RP)是一个特定大小的对象,包含程序员自定义的数据以及一个唯一标识符(标签)。该自定义对象由REPARSE_GUID_DATA_BUFFER结构体表示。

    RP数据块的大小最多可达16千字节。

    ReparseTag:32位标签
    ReparseDataLength:数据大小
    DataBuffer:指向用户数据的指针

    标签的格式说明如下:

    M — 微软保留位;若该位被设置,则表明此标签由微软开发。
    L — 延迟位;若该位被设置,则表示RP(可能是某种引用或指针)所指向的数据位于响应速度慢、数据输出延迟长的介质上。
    R — 保留位;
    N — 名称变更位;若该位被设置,则表示该文件或目录在文件系统中代表另一个已命名的实体。
    标签值 — 必须向微软申请获取;

    每次应用程序创建或删除重解析点(RP,Reparse Point)时,NTFS都会更新存储RP记录的\\$Extend\\$Reparse元数据文件。这种集中存储方式使得任何应用程序都能对所需对象进行排序并高效搜索。

    借助重解析点(RP),Windows系统支持符号链接、远程存储系统以及卷和目录的挂载点。

    顺便提一下,Windows中的硬链接并非实际的对象,而仅仅是磁盘上同一文件的别名。它们不是独立的文件系统对象,而只是文件位置表中的另一个文件名。这就是硬链接与符号链接的区别所在。

    要使用重解析点(RP),我们需要编写如下结构体定义:
  1. typedef struct _REPARSE_GUID_DATA_BUFFER {
  2.   ULONG  ReparseTag;        // 重解析点标签
  3.   USHORT ReparseDataLength; // 重解析点数据长度
  4.   USHORT Reserved;          // 保留字段
  5.   GUID   ReparseGuid;       // 重解析点全局唯一标识符(GUID)
  6.   struct {
  7.     UCHAR DataBuffer[1];    // 通用重解析点数据缓冲区(可变长度数组)
  8.   } GenericReparseBuffer;
  9. } REPARSE_GUID_DATA_BUFFER, *PREPARSE_GUID_DATA_BUFFER;
复制代码
08b52cd4-e84a-453d-a289-8a9e6dce8fcf.png

  • 一个具备 SE_BACKUP_NAME 或 SE_RESTORE_NAME 特权的小型应用程序,该程序将创建一个包含重解析点(RP,Reparse Point)结构的文件,设置所需的 ReparseTag 字段,并填充 DataBuffer(数据缓冲区)。
  • 以及一个内核模式驱动程序,该驱动程序将读取缓冲区数据并处理对该文件的调用。

创建我们自己的带重解析点的文件
  • 获取必要的权限。
  • 准备 REPARSE_GUID_DATA_BUFFER 结构。在本示例中,我们将向重解析点数据中写入一个简单的字符串“My reparse data”,不过也可以是更有价值的数据。
  • 创建文件。
  • 使用带有 FSCTL_SET_REPARSE_POINT 参数的 DeviceIoControl 函数,用我们准备好的结构填充该文件。

    该应用程序的完整源代码可在此处找到。

    在编译并运行后,我们会得到该文件。可使用 fsutil 实用工具查看我们创建的文件,并确认数据已正确写入。
  1. void GetPrivilege(LPCTSTR priv)
  2. {
  3.         HANDLE hToken;
  4.         TOKEN_PRIVILEGES tp;
  5.         OpenProcessToken(GetCurrentProcess(),
  6.                 TOKEN_ADJUST_PRIVILEGES, &hToken);
  7.         LookupPrivilegeValue(NULL, priv, &tp.Privileges[0].Luid);
  8.         tp.PrivilegeCount = 1;
  9.         tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
  10.         AdjustTokenPrivileges(hToken, FALSE, &tp,
  11.                 sizeof(TOKEN_PRIVILEGES), NULL, NULL);
  12.         CloseHandle(hToken);
  13. }
  14. GetPrivilege(SE_BACKUP_NAME);
  15. GetPrivilege(SE_RESTORE_NAME);
  16. GetPrivilege(SE_CREATE_SYMBOLIC_LINK_NAME);
  17. TCHAR data[] = _T("My reparse data");
  18. BYTE reparseBuffer[sizeof(REPARSE_GUID_DATA_BUFFER) + sizeof(data)];
  19. PREPARSE_GUID_DATA_BUFFER rd = (PREPARSE_GUID_DATA_BUFFER) reparseBuffer;
  20. ZeroMemory(reparseBuffer, sizeof(REPARSE_GUID_DATA_BUFFER) + sizeof(data));
  21. // {07A869CB-F647-451F-840D-964A3AF8C0B6}
  22. static const GUID my_guid = { 0x7a869cb, 0xf647, 0x451f, { 0x84, 0xd, 0x96, 0x4a, 0x3a, 0xf8, 0xc0, 0xb6 }};
  23. rd->ReparseTag = 0xFF00;
  24. rd->ReparseDataLength = sizeof(data);
  25. rd->Reserved = 0;
  26. rd->ReparseGuid = my_guid;
  27. memcpy(rd->GenericReparseBuffer.DataBuffer, &data, sizeof(data));
  28. LPCTSTR name = _T("TestReparseFile");
  29. _tprintf(_T("Creating empty file\n"));
  30. HANDLE hFile = CreateFile(name,
  31.         GENERIC_READ | GENERIC_WRITE,
  32.         0, NULL,
  33.         CREATE_NEW,
  34.         FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
  35.         NULL);
  36. if (INVALID_HANDLE_VALUE == hFile)
  37. {
  38. _tprintf(_T("Failed to create file\n"));
  39.         return -1;
  40. }
  41. _tprintf(_T("Creating reparse\n"));
  42. if (!DeviceIoControl(hFile, FSCTL_SET_REPARSE_POINT, rd, rd->ReparseDataLength + REPARSE_GUID_DATA_BUFFER_HEADER_SIZE, NULL, 0, &dwLen, NULL))
  43. {
  44.         CloseHandle(hFile);
  45.         DeleteFile(name);
  46.         _tprintf(_T("Failed to create reparse\n"));
  47.         return -1;
  48. }
  49. CloseHandle(hFile);
复制代码
mtzn2-einprbpe9lyirflde36bm.png

处理重解析点
    现在是时候从内核层面来审视这个文件了。我不会深入探讨微过滤器驱动程序(mini-filter driver)的开发细节。微软官方文档中有很好的解释,并且附有代码示例。相反,我们将看一下后置回调(post callback)方法。

    我们需要使用 FILE_OPEN_REPARSE_POINT 参数重新请求 IRP。为此,我们将调用 FltReissueSynchronousIo 函数。该函数将重复请求,但会更新 Create.Options 字段。

    在 PFLT_CALLBACK_DATA 结构体中有一个 TagData 成员。如果我们使用 FSCTL_GET_REPARSE_POINT 参数调用 FltFsControlFile 函数,就能获取到包含所需数据的缓冲区。
  1. // 我们需要检查这是否确实是我们关注的重解析点标签
  2. if (Data->TagData != NULL)
  3. {
  4. if ((Data->Iopb->Parameters.Create.Options & FILE_OPEN_REPARSE_POINT) != FILE_OPEN_REPARSE_POINT)
  5.     {
  6.       Data->Iopb->Parameters.Create.Options |= FILE_OPEN_REPARSE_POINT;
  7.       FltSetCallbackDataDirty(Data);
  8.       FltReissueSynchronousIo(FltObjects->Instance, Data);
  9.     }
  10.     status = FltFsControlFile(
  11.       FltObjects->Instance,
  12.       FltObjects->FileObject,
  13.       FSCTL_GET_REPARSE_POINT,
  14.       NULL,
  15.       0,
  16.       reparseData,
  17.       reparseDataLength,
  18.       NULL
  19.     );
  20. }
复制代码
4xevs8qfb3xqaruyy94c_kickk8.png

    然后,我们可以根据具体任务需求来使用这些重解析点数据。我们可以选择重新请求该 IRP,或者发起一个全新的请求。例如,在 LazyCopy 项目中,原始文件的路径被存储在重解析点数据中。该项目的作者并未在文件打开时立即启动复制操作,而是仅将重解析点中的数据重新保存到该文件的流上下文中。只有当文件被读取或写入时,才会真正开始数据复制。以下是该项目的一些关键亮点:
  1. // Operations.c - PostCreateOperationCallback
  2. NT_IF_FAIL_LEAVE(LcGetReparsePointData(FltObjects, &fileSize, &remotePath, &useCustomHandler));
  3. NT_IF_FAIL_LEAVE(LcFindOrCreateStreamContext(Data, TRUE, &fileSize, &remotePath, useCustomHandler, &streamContext, &contextCreated));
  4. // Operations.c - PreReadWriteOperationCallback
  5. status = LcGetStreamContext(Data, &context);
  6. NT_IF_FAIL_LEAVE(LcGetFileLock(&nameInfo->Name, &fileLockEvent));
  7. NT_IF_FAIL_LEAVE(LcFetchRemoteFile(FltObjects, &context->RemoteFilePath, &nameInfo->Name, context->UseCustomHandler, &bytesFetched));
  8. NT_IF_FAIL_LEAVE(LcUntagFile(FltObjects, &nameInfo->Name));
  9. NT_IF_FAIL_LEAVE(FltDeleteStreamContext(FltObjects->Instance, FltObjects->FileObject, NULL));
复制代码
    重解析点应用场景广泛,为解决各类问题提供了诸多可能性。我们将通过完成以下任务来分析其中一种应用。

任务描述
    游戏《半条命》(Half-life)支持两种运行模式:软件渲染模式(software mode)和硬件渲染模式(hardware mode),二者的核心区别在于游戏中的图形渲染方式。通过IDA Pro反汇编工具进行逆向分析后,可以发现这两种模式的切换机制是通过调用LoadLibrary方法动态加载不同的动态链接库(DLL)实现的:软件模式加载sw.dll,而硬件模式加载hw.dll
i-8c2cwohldfxbmvmbbjqvc0ivg.png
    根据输入参数(例如“-soft”),程序会选择相应的字符串(即库文件名),并将其传递给 LoadLibrary 函数调用。
4nmi1nc6gwymw2fqmgmrqxh2kem.png

    该任务的目标是让游戏始终以硬件模式加载运行,且用户毫无察觉——即无论用户如何选择,游戏最终都会以硬件模式启动。

    当然,我们可以通过直接修改可执行文件、替换DLL文件,甚至简单地将hw.dll复制并重命名为sw.dll来实现这一目的,但我们并不寻求这种简单粗暴的解决方案。此外,如果游戏后续更新或重新安装,这些修改将失效。

解决方案
    我提出以下方案:编写一个小型微过滤器驱动(mini-filter driver)。该驱动将持续运行,且不受游戏重新安装或更新的影响。我们将为驱动注册 IRP_MJ_CREATE 操作的回调处理,因为每次可执行文件调用 LoadLibrary 时,本质上都是在尝试打开对应的库文件。当检测到游戏进程试图打开 sw.dll 库时,我们将返回 STATUS_REPARSE 状态码,并要求系统重新发起请求,但此次改为打开 hw.dll。最终效果是:尽管用户空间请求的是另一个库,但实际加载的却是我们指定的库。

    首先,我们需要确定是哪个进程在尝试打开库文件,因为这一“替换”操作仅需针对游戏进程生效。为此,在驱动的 DriverEntry 入口函数中,我们需要调用 PsSetCreateProcessNotifyRoutine 并注册一个回调方法。该方法将在系统中每次有新进程创建时被调用。
  1. NT_IF_FAIL_LEAVE(PsSetCreateProcessNotifyRoutine(IMCreateProcessNotifyRoutine, FALSE));
复制代码
    在这种方法中,我们必须获取可执行文件的名称。我们可以使用函数ZwQueryInformationProcess来实现。
  1. NT_IF_FAIL_LEAVE(PsLookupProcessByProcessId(ProcessId, &eProcess));
  2. NT_IF_FAIL_LEAVE(ObOpenObjectByPointer(eProcess, OBJ_KERNEL_HANDLE, NULL, 0, 0, KernelMode, &hProcess));
  3. NT_IF_FAIL_LEAVE(ZwQueryInformationProcess(hProcess,
  4.                                                ProcessImageFileName,
  5.                                                buffer,
  6.                                                returnedLength,
  7.                                                &returnedLength));
复制代码
    如果进程名称与目标名称(在我们的例子中是 hl.exe)匹配,我们就需要保存该进程的 PID(进程标识符)。
  1. target = &Globals.TargetProcessInfo[i];
  2. if (RtlCompareUnicodeString(&processNameInfo->Name, &target->TargetName, TRUE) == 0)
  3. {
  4.       target->NameInfo = processNameInfo;
  5.       target->isActive = TRUE;
  6.       target->ProcessId = ProcessId;
  7.       LOG(("[IM] Found process creation: %wZ\n", &processNameInfo->Name));
  8. }
复制代码
    现在,我们已经将游戏进程的PID保存在全局对象中了。接下来,我们可以进入预创建回调(pre create callback)环节。在那里,我们需要获取试图打开的文件的名称。FltGetFileNameInformation函数可以帮助我们实现这一点。不过需要注意的是,该函数不能在DPC中断级别调用(更多关于IRQL(中断请求级别)的信息请参阅相关资料),但我们在预创建阶段进行调用,这可以保证我们的调用级别不会高于APC级别。
  1. status = FltGetFileNameInformation(Data, FLT_FILE_NAME_OPENED | FLT_FILE_NAME_QUERY_FILESYSTEM_ONLY | FLT_FILE_NAME_ALLOW_QUERY_ON_REPARSE, &fileNameInfo);
复制代码
    然后,如果检测到当前尝试打开的文件名是 sw.dll,我们需要在 文件对象(FileObject) 中将其替换为 hw.dll,并返回状态码 STATUS_REPARSE。
  1. // may be it is sw
  2. if (RtlCompareUnicodeString(&FileNameInfo->Name, &strSw, TRUE) == 0)
  3. {
  4. // concat
  5. NT_IF_FAIL_LEAVE(IMConcatStrings(&replacement, &FileNameInfo->ParentDir, &strHw));
  6. // then need to change
  7. NT_IF_FAIL_LEAVE(IoReplaceFileObjectName(FileObject, replacement.Buffer, replacement.Length));
  8. }
  9. Data->IoStatus.Status = STATUS_REPARSE;
  10. Data->IoStatus.Information = IO_REPARSE;
  11. return FLT_PREOP_COMPLETE;
复制代码
    当然,整个项目的实际实现要复杂得多,但我在这里尽量阐述了核心要点。完整项目(含详细实现)可参考此处提供的资料。

测试解决方案
    为简化测试流程,我们不直接使用游戏进行测试,而是编写一个简单的应用程序及配套库文件,其内容如下:
  1. // testapp.exe
  2. #include "TestHeader.h"
  3. int main()
  4. {
  5.         TestFunction();
  6.         return 0;
  7. }
  8. // testdll0.dll
  9. #include "../include/TestHeader.h"
  10. #include <iostream>
  11. // This is an example of an exported function.
  12. int TestFunction()
  13. {
  14. std::cout << "hello from test dll 0" << std::endl;
  15. return 0;
  16. }
  17. // testdll1.dll
  18. #include "../include/TestHeader.h"
  19. #include <iostream>
  20. // This is an example of an exported function.
  21. int TestFunction()
  22. {
  23. std::cout << "hello from test dll 1" << std::endl;
  24. return 0;
  25. }
复制代码
    我们来构建 testapp.exe 并将其与 testdll0.dll 进行链接,然后把它们复制到虚拟机中,同时准备好 testdll1.dll。我们驱动程序的任务是将 testdll0 替换为 testdll1。要是我们在控制台中看到“hello from test dll 1”(而非“hello from test dll 0”)这条消息,就说明我们成功了。我们先在不加载驱动程序的情况下运行一下,以确保我们的测试应用程序能够正常工作:
2fpbs3nxq09mlwteb21jai1gs1a.png
    现在我们来安装并运行该驱动程序:
zo-vnvel8jiu0iydirsic6u785c.png
    运行同一个应用程序时,由于加载了另一个库,我们将在控制台看到截然不同的输出结果:
l6f6b9bxmcx_hy3henexq-ph98m.png
    此外,在为该驱动程序编写的应用程序中,我们能看到日志显示我们确实拦截到了打开文件请求,并将一个文件替换为了另一个文件。测试成功了,现在该在真正的游戏上测试这个解决方案了。
demo3.gif

原文链接
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表