65.9K
CodeProject 正在变化。 阅读更多。
Home

消除 Explorer 在删除正在使用的文件时的延迟

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (266投票s)

2005年9月28日

19分钟阅读

viewsIcon

338706

downloadIcon

1887

如何查找并修复Windows资源管理器代码中的一个令人恼火的问题。

问题概述

我注意到在Windows XP上,当使用资源管理器删除被另一个进程占用的文件或目录时,在出现错误消息告诉您无法删除文件之前,会有一个看似永恒的延迟。在此期间,资源管理器没有任何指示表明它已经注意到删除命令。没有沙漏,什么都没有。当消息框出现时,我已经被多次按下删除键,徒劳地试图让资源管理器“做点什么……要么删除文件,要么告诉我你不能……给我点提示”。

在阅读了Mark Russinovich一篇优秀的博客文章后,我决定终于尝试弄清楚这个恼人的、迟缓的“文件占用”错误消息是怎么回事。

关于本文

在本文中,我将讨论如何使用Sysinternals.com的Process Explorer和Microsoft免费的Windows调试工具集中的WinDbg,并编写一个设备驱动程序来修补 offending 的代码。如果Windows是开源的,那会容易得多,但希望在看完本文中介绍的一些技术后,您会明白为什么我喜欢将Windows称为“可撬开的开源”。

我试图以一种即使是非开发者,只要对Windows的架构有良好理解,也能(希望)理解正在发生什么样的方式来解释大多数概念。

开始前的几点说明

  • 要有效使用WinDbg,您需要正确配置调试符号。如果这对您来说毫无意义,请不要担心。您只需要在WinDbg中,转到“文件”->“符号文件路径”。在对话框中,输入
    SRV*c:\websymbols*http://msdl.microsoft.com/download/symbols

    这会告诉WinDbg从Microsoft符号服务器自动下载符号文件,并将其本地缓存到c:\websymbols

  • 为了编译本文末尾提供的驱动程序,您将需要Windows驱动程序开发套件(DDK)。我使用的是Windows Server 2003 DDK。不幸的是,这个套件不是免费的,但如果您或您的雇主拥有MSDN订阅,您应该能获得一份。如果您没有DDK,也不用担心……我已经包含了一个编译好的驱动程序版本在本文的压缩包中。
  • 本文中的所有操作都使用带有Service Pack 2的32位Windows XP Professional完成。如果您使用的配置不同,WinDbg中显示的地址可能与本文中的不匹配。驱动程序也可能在您的系统上无法正常工作。

调查过程

讽刺的是,我抱怨的正是这个行为使得这个问题很容易调查。几秒钟的延迟给了我们(相对而言)足够的时间来深入了解explorer.exe的线程,看看发生了什么。让我们开始设置吧。

  • 启动Process Explorer。
  • 打开您的会话的explorer.exe进程的“属性”窗口。
  • 点击“线程”选项卡,然后按“起始地址”对列表进行排序。默认情况下,列表按CPU时间排序,这使得很难关注有趣的线程。

现在,为了重现这个问题

  • 创建一个新的目录。我将其命名为“undeletable”。
  • 启动cmd.exe,然后“cd”到“undeletable”目录。这会导致cmd.exe打开一个目录句柄,从而阻止资源管理器(或任何其他进程)删除它。
  • 在资源管理器中点击“undeletable”目录图标,然后按Delete键。

当您等待错误消息出现时,您应该会在Process Explorer中看到explorer.exe进程创建了多个线程。Process Explorer贴心地用绿色高亮显示它们。

在出现错误消息之前的延迟期间,双击新线程之一以查看其*调用堆栈*。调用堆栈本质上是线程当前正在执行哪个函数以及当前函数完成后将返回到哪个函数的记录(以及那个函数完成后将返回到哪个函数,依此类推)。您应该会发现一个线程的堆栈看起来特别可疑。

如果您不熟悉在Process Explorer中查看调用堆栈,重要的是要知道列表中看到的任何函数都处于调用列表中*上面*函数的过程中。例如,如果您看上面第4行和第5行,您会看到kernel32.dll!Sleep*正在被*shell32.dll!_IsDirectoryDeletable调用。

即使我不知道调用堆栈中的大多数函数确切的作用,但在看到这个线程堆栈中的“_IsDirectoryDeletable”和“Sleep”之后,我确信我找到了罪魁祸首。我确实知道的是,“Sleep”是一个Windows API函数,它会导致一个线程在指定的时间内什么都不做。我的猜测是,_IsDirectoryDeletable函数在发现该目录无法立即删除后,会小睡一会儿。

让我们通过观察_IsDirectoryDeletable的运行来测试这个猜测。为此,启动WinDbg并附加(使用“文件”->“附加到进程”)到您之前使用Process Explorer监视的同一个explorer.exe进程。

附加到进程后,WinDbg会冻结进程(即,其所有线程都已暂停)。为了看到_IsDirectoryDeletable函数在起作用,我们首先要做的是在其上设置一个“断点”。

bp shell32!_IsDirectoryDeletable

断点将导致WinDbg在shell32.dll中的_IsDirectoryDeletable函数被调用时暂停explorer.exe的执行。一旦调试器冻结了explorer.exe,您就可以逐条指令地单步执行代码,并(希望)对该函数为何执行如此之慢获得一些见解。

设置好断点后,通过输入“g”(也可以选择“调试”->“转到”)来解除explorer.exe的冻结。

现在,再次重复“undeletable”实验。这一次,WinDbg立即响应,表明我们的断点已被命中。

Breakpoint 0 hit
eax=00000001 ebx=018cf084 ecx=7c80f067 edx=1007001e esi=018cee64 edi=00000104
eip=7ca691a9 esp=018cee14 ebp=018cee30 iopl=0         nv up ei pl zr na po nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000246
SHELL32!_IsDirectoryDeletable:
7ca691a9 8bff             mov     edi,edi

您可以安全地忽略大部分这些杂乱的信息。此时唯一重要的是explorer.exe*在*执行_IsDirectoryDeletable函数*之前*被冻结了。这非常有用,因为我们现在可以逐条指令地单步执行该函数。为此,打开反汇编窗口(“视图”->“反汇编”)。我们的断点(在此例中,是下一条要执行的指令)在WinDbg中用紫色高亮显示(如下所示)。红框是我为说明本文添加的。

即使您不精通汇编语言,也可以通过观察高亮显示的行在代码中来回移动学到很多东西。不断按F10(逐条指令执行)并您就会明白我的意思。

您应该会注意到代码本身循环了多次(确切地说,是五次),每次都命中该行。

call dword ptr [SHELL32!_imp__Sleep]

您还应该注意到在F10键在该行上时有一秒钟的延迟。这很可能是UI中延迟的原因。

此外,每次循环时,您都会看到对shell32!IsDirectoryDeletable(一个不同的函数,请注意缺少下划线)的调用。

简而言之,代码似乎循环五次,每次检查目录是否可删除,如果不可删除,则睡眠一秒钟。这解释了为什么如果在延迟期间关闭cmd.exe,那么“undeletable”目录会在不久后消失。由于_IsDirectoryDeletable每秒检查一次目录(最多五秒),一旦它从最近的一次一秒睡眠中醒来,并看到目录不再被占用,它就会立即删除它。

现在,我们已经观察到循环发生了五次,但这个数字是从哪里来的呢?数字5在_IsDirectoryDeletable的代码中出现了两次:一次在我标记为红框的那一行,另一次在那几行之后。弄清楚我们正在寻找哪个5的最简单方法是*改变其中一个*,看看会发生什么。我的希望是,如果我将其中一个5改为0,循环将执行0次而不是通常的5次。(当然,这有风险,但为什么逆向工程被许多人视为极限运动呢?)

要修改_IsDirectoryDeletable,复制要修改的指令的地址(红框所在的那一行,地址为7CA691B4),然后打开内存窗口(“视图”->“内存”)。将地址粘贴到“虚拟”框中。

您会注意到前三个字节(83 ff 05)与反汇编第二列中的内容匹配。如果您将05(我用黄色高亮显示的部分)更改为00,然后返回到反汇编窗口,您会看到该指令现在显示为“cmp edi,0x0”,而不是“cmp edi,0x5”。

是时候看看我们的改变是否会有任何不同了。首先,让我们禁用之前设置的任何断点,这样在测试时我们就不会进入WinDbg。

bd *

接下来,通过输入“g”或选择“调试”->“转到”来解除explorer.exe的冻结。现在,如果您重复该实验,“文件占用”错误消息应该会立即出现。问题解决了!

几点说明

在我的调查过程中,我确定还有一个名为_IsFileDeletable的函数,当使用资源管理器删除文件时会被调用。这个函数几乎与_IsDirectoryDeletable相同,找到它和修补它的方法也是一样的。

另外请注意,上面显示的“修补”只是暂时的。它只会影响被修改的explorer.exe的那个实例。如果进程被终止并重新启动,更改就会丢失。本文的其余部分将介绍一种使这些更改更持久的方法。

让它持久

当考虑如何使我的补丁永久化时,我的第一个想法是直接用十六进制编辑器打开shell32.dll并修改两个字节(_IsFileDeletable_IsDirectoryDeletable中两个05)。这有两个问题。首先,我无法修改实时运行的%SYSTEMROOT%\system32\shell32.dll,因为它似乎被系统上几乎所有运行的进程加载(在Process Explorer中“查找DLL”显示了这一点)。这很可能意味着它“正在使用中”,我们无法修改它。其次,即使我修改了文件的副本,并在启动到恢复控制台后替换了它,Windows在我重启后也会以某种方式将其新版本替换为原始版本。我猜测这是Windows文件保护在起作用,但鉴于这发生在启动过程中,我没有认为原因很容易追踪。此外,这种直接修补文件的方法在服务包或热修复安装后将失效。我想要的是一个实时补丁实用程序,它能在每次系统启动时自动运行。

在我解释我的解决方案之前,我需要谈谈“KnownDLLs”。基本上,Windows在注册表中维护一个DLL列表,这些DLL被大多数Windows应用程序广泛使用(shell32.dll是其中之一)。在系统启动过程中,这些DLL被映射为“段对象”到内存中。当一个进程需要加载这些特殊DLL之一时,系统将使用缓存的段对象而不是创建一个新的。我们将使用对象\KnownDlls\shell32.dll作为应用补丁的起点。(这可能是一个过度简化的解释,但对我们来说应该足够了。有关更详细的介绍,请参阅这里。)

经过进一步思考,我决定编写一个设备驱动程序来在每次启动时修补代码。这对我来说是说得通的,原因有几个:

  1. 驱动程序在内核模式下运行。虽然我没有尝试过,但我假设从用户模式下我无法写入属于Microsoft认为是关键系统组件(在这种情况下是shell32.dll的映射段)的内存部分。内核模式组件可以忽略(或者至少,有意识地禁用)用户模式程序必须遵守的任何内存保护。编写驱动程序是将代码引入内核的最简单方法。(注意:“驱动程序”只是动态加载到Windows内核中的模块的常用术语。我们实际上并没有像网卡驱动程序或显卡驱动程序那样与特定的硬件设备通信。事实上,在Linux上,驱动程序通常被称为“内核模块”,这对于我们正在讨论的内容来说是一个更合适的术语。)
  2. 驱动程序可以由服务控制管理器(与启动“常规”Windows服务(如IIS)的设施相同)在启动时自动启动。驱动程序和服务也支持“停止”命令,该命令可以通过从命令行运行“net stop drivername”来发出。这意味着易于调试和测试,因为驱动程序可以设计成在收到“停止”命令时*撤销*补丁。
  3. 代表“KnownDLLs”的段对象存在于内核的对象管理器命名空间(与文件系统的命名空间无关……您不会在硬盘上找到一个名为KnownDlls的目录)的\KnownDlls目录中。不幸的是,Windows API无法访问\KnownDlls目录。在内核模式下运行将允许我们访问\KnownDlls\shell32.dll段对象。(如果您有兴趣,Sysinternals的WinObj是探索对象管理器命名空间的绝佳实用工具。)
  4. 最后,我参加了今年在拉斯维加斯举行的Black Hat培训的“Rootkit编写”课程。内核模式rootkit本质上是一个修改内核以更改其行为的驱动程序。Rootkit通常旨在隐藏文件、进程和注册表项,以便入侵者可以不被检测地控制系统。虽然这不是这里的目标,但修改系统受保护的内部工作机制的需求是相同的。此外,我测试这些rootkit类技术的愿望可能和任何实际技术因素一样,对我的决定产生了影响。

驱动程序详解

基本上,驱动程序会找到\KnownDlls\shell32.dll段,然后对于我们想要修补的每个地址,它会将该内存映射到虚拟地址空间的内核模式区域,“锁定”它(以防止它被交换出物理内存),并更新它。内存将保持映射和锁定状态,直到驱动程序被卸载,届时更改将被撤销,内存将被取消映射和解锁。

驱动程序有183行,并非最复杂的代码。需要记住的一点是,我将以与源文件中略有不同的顺序呈现代码。这将使我们能够遵循驱动程序的逻辑,而不被C编译器要求在函数开头声明变量这一事实所干扰。

驱动程序的主要入口点,对于驱动程序必须命名为DriverEntry,它只是连接Unload例程,然后调用修补函数。DriverEntry是在服务控制管理器发出“启动”命令时调用的函数。

NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject, 
                     IN PUNICODE_STRING pRegistryPath)
{
     pDriverObject->DriverUnload  = OnUnload;

     return DoPatch(TRUE);
}

DoPatch()函数是真正执行工作的地方。如果我们应用补丁(而不是撤销补丁),首先要做的是获取\KnownDlls\shell32.dll段的句柄,并通过将其映射到当前进程的地址空间来使该内存可供我们访问。撤销补丁时不需要这样做,因为我们将在初始修补阶段保存找到的地址。

// Open the KnownDlls named section object for shell32.dll
RtlInitUnicodeString(&us, L"\\KnownDlls\\shell32.dll");
InitializeObjectAttributes(&oa, &us, OBJ_KERNEL_HANDLE, NULL, NULL);

status = ZwOpenSection(&hSection, SECTION_MAP_READ | SECTION_MAP_WRITE, &oa);

// Get a pointer to the section
status = ZwMapViewOfSection(
     hSection,
     NtCurrentProcess(),
     &pSection,
     0,
     0,
     0,
     &viewSize,
     ViewShare,
     0,
     PAGE_READWRITE
     );

作为ZwMapViewOfSection调用的结果,pSection变量包含了shell32.dll的基地址。

offsets数组(如下所示)包含相对于shell32.dll基地址的内存中要修补的硬编码地址。每行额外的两个NULL字段将包含一些与内存管理相关的数据,我们将在过程中填充。最后一行{0, NULL, NULL}只是一个标记数组结束的占位符。

// We'll patch two addresses in the shell32.dll section.
// This array must be zero-terminated.
PATCH_INFO offsets[] = {
     { 0xA916F, NULL, NULL },
     { 0xA91B6, NULL, NULL },
     { 0, NULL, NULL }
};

让我们看看我是如何确定这些内存偏移量的。首先要做的是找到shell32.dll的基地址。回到我们之前的WinDbg会话,输入:

lm m shell32

您应该看到类似这样的内容:

start    end        module name
7c9c0000 7d1d4000   SHELL32    (pdb symbols)  c:\websymbols\shell32.pdb\
                                              290E0039FDA7491EAB979ECE585EE06D2\
                                              shell32.pdb

我们在这里可以看到shell32的基地址是0x7C9C0000。(“0x”前缀只是C/C++表示十六进制数字的方式。)我们想要在内存中更改的两个“05”字节分别位于0x7CA6916F(对于_IsFileDeletable)和0x7CA691B6(对于_IsDirectoryDeletable)。因此,每个补丁的文件偏移量分别为0xA916F(0x7CA6916F - 0x7C9C0000)和0xA91B6(0x7CA691B6 - 0x7C9C0000)。(注意:如果您无法在脑海中进行此十六进制计算,Windows计算器的科学模式可以为您完成。)

现在我们知道要修补哪些地址,我们需要将这些地址映射到内存的内核模式区域。这将允许我们锁定内存中的页面,从而防止它们被交换到磁盘。在这种情况下,我们正在修改的内存“备份”是shell32.dll。如果它被交换出去了,内存管理器会尝试将更改写回shell32.dll。这很可能会导致Windows文件保护生效,而这是我们想要避免的,因为它很可能会撤销我们即将进行的更改。

首先,我们创建一个内存描述符列表(MDL),它指向我们想要修改的每个地址。然后,我们使用该MDL将页面映射到内核模式内存并锁定它们。

if (isLoading)
{
     pWordToChange = (PULONG)(pSection + pCurrentPatchInfo->offset);

     // Create a Memory Descriptor List (MDL)
     // for the virtual address at "pWordToChange"
     pCurrentPatchInfo->pMdl = IoAllocateMdl(pWordToChange, 
                                  sizeof(ULONG), FALSE, FALSE, NULL);
     if (pCurrentPatchInfo->pMdl == NULL)
     {
          DbgPrint("Unable to allocate MDL for VA %08X\n", pWordToChange);
          retval = STATUS_UNSUCCESSFUL;
          __leave;
     }

     // Lock the pages in memory and get a pointer
     // to the "pWordToChange" in its
     // kernel-memory-mapped location
     MmProbeAndLockPages(pCurrentPatchInfo->pMdl, KernelMode, IoReadAccess);
     pCurrentPatchInfo->pMapped = 
        (PULONG)MmGetSystemAddressForMdlSafe(pCurrentPatchInfo->pMdl, 
        NormalPagePriority);
     if (pCurrentPatchInfo->pMapped == NULL)
     {
          DbgPrint("MmGetSystemAddressForMdlSafe" 
                   " returned NULL for VA %08X\n", 
                   pWordToChange);
          retval = STATUS_UNSUCCESSFUL;
          __leave;
     }
}

注意:只有在第一次加载驱动程序并应用补丁时,我们才会执行MDL构建、映射和锁定。我们将在撤销补丁时保存MDL和指针。(这就是“offsets”数组中多余的NULL的原因。)

接下来,驱动程序将验证我们即将更新的内存位置是否确实包含预期的原始值。如果shell32.dll被服务包或热修复程序更新了,我不想就这样覆盖一些碰巧位于_IsFileDeletable_IsDirectoryDeletable原来位置的不相关的内存。patchedBytesunpatchedBytes常量表示在修补前后,我们期望在这些内存地址看到的内容。

// Depending on whether we're loading or unloading the driver, we expect to find
// one of the following values at each offset. Only if there is a match will we
// do the patch.
const ULONG unpatchedBytes = 0xFF2A7D05;
const ULONG patchedBytes = 0xFF2A7D00;

// We'll change both what we're looking for and what we change to based on
// whether the driver is loading or unloading.
expectedBytes = isLoading ? unpatchedBytes : patchedBytes;
newBytes = isLoading ? patchedBytes : unpatchedBytes;

以下代码用于验证内存位置的当前内容,并为要修补的每个内存地址执行一次。

if (*pCurrentPatchInfo->pMapped != expectedBytes)
{
     DbgPrint(
          "Offset %08X (address %08X) didn't match. " 
          "Actual value: %08X. Expected: %08X\n",
          pCurrentPatchInfo->offset,
          pCurrentPatchInfo->pMapped,
          *pCurrentPatchInfo->pMapped,
          expectedBytes
          );
     retval = STATUS_UNSUCCESSFUL;
     __leave;
}

根据我们传递给它的“isLoading”参数,DoPatch函数将更改它在修补*之前*期望找到的内容以及在*修补过程中*要写入的内容。您会看到DriverEntry调用DoPatch(TRUE),而OnUnload调用DoPatch(FALSE)。这就是我们能够在驱动程序接收到服务控制管理器的“停止”命令时撤销补丁的方法。

现在只剩下实际更改值了。以下代码为我们正在更改的两个地址中的每一个执行一次。

// Do the patch
*pCurrentPatchInfo->pMapped = newBytes;

DbgPrint(
     "Offset %08X at VA %08X changed to %08X\n",
     pCurrentPatchInfo->offset,
     pCurrentPatchInfo->pMapped,
     *pCurrentPatchInfo->pMapped
     );

// If we're unloading (i.e. DoPatch(FALSE)),
// we'll unmap and unlock the memory
if (!isLoading)
{
     MmUnmapLockedPages(pCurrentPatchInfo->pMapped, pCurrentPatchInfo->pMdl);
     MmUnlockPages(pCurrentPatchInfo->pMdl);
     IoFreeMdl(pCurrentPatchInfo->pMdl);
}

另外请注意,如果我们正在撤销补丁,我们将释放每个已修补偏移量的锁定内存和MDL。

构建驱动程序

如果您安装了DDK,您可以自己构建驱动程序。首先,打开DDK命令提示符(我的在“开始”->“程序”->“开发工具包”->“Windows DDK 3790”->“构建环境”->“Windows Server 2003”->“Windows Server 2003 免费构建环境”下)。“cd”到您存放源代码的目录,然后输入:

build -c

就是这样!完成的驱动程序(名为“NoDeleteDelay.sys”)应位于“objfre_wnet_x86\i386”目录中。注意:据说DDK的构建实用程序在您的源文件位于包含空格的目录中时(例如,“c:\Documents and Settings\Administrator\My Documents”可能无法正常工作)存在一些问题。

加载和测试

有几种方法可以将驱动程序加载到内核中。测试期间最简单的方法是使用Greg Hoglund的驱动程序安装程序实用工具

您还会注意到我正在使用Sysinternals的DbgView来查看代码中DbgPrint语句的输出。

现在您可以尝试文章开头的“undeletable”实验,并看到“文件占用”错误消息立即出现。另外,如果您在InstDrv中点击“停止”并重试测试,您将再次观察到通常的五秒钟延迟。

将驱动程序加载到内核中的另一种更持久的方法是使用我在文章压缩包中包含的Installer.exe实用程序。只需将Installer.exeNoDeleteDelay.sys复制到您希望它们存放的目录,然后运行Installer.exeInstaller.exe没有内置卸载功能,但如果您想删除驱动程序,只需使用上面的InstDrv.exe。输入“NoDeleteDelay”作为驱动程序的完整路径名(不,它不是完整路径,但由于驱动程序已安装,所以没关系)。点击“停止”,然后点击“移除”。

一些最终想法

这是一个1.0版本的软件。以下是我思考过但没有花太多时间研究的一些事情。(对这些问题的解释超出了本文的范围,不理解它们也不应该影响本文内容的实用性。)

  1. 虽然我将“offsets”数组指向的内存位置视为字对齐,但它们实际上并非字对齐。据我所知,x86在对齐问题上比较宽容,但在生产级驱动程序中,我们可能需要进行对齐内存访问。
  2. 在处理旧版本或新版本的shell32.dll方面没有灵活性。如果服务包或热修复程序修改了文件,驱动程序将拒绝修补。我确信我可以扫描内存来查找偏移量,或者做一些更棘手的事情,比如以编程方式咨询.pdb(符号)文件,但到目前为止,硬编码的方法对我有用™。
  3. 我不确定为什么内存管理器不尝试将修改后的页面写回shell32.dll。我怀疑是因为代表我们修改的物理页面的页面帧编号数据库中的条目总是有一个“引用计数”至少为1(因为它们被锁定了)。如果我的理解是正确的,这意味着它们不会被移到“已修改列表”中,因此不会被已修改页面写入器写入磁盘。如果有人能对此进行扩展,请留下评论。
  4. 即使这是一种笨拙的打补丁方式,我也希望本文能作为面对此类问题时可用的技术的一个良好入门。

推荐阅读

  • Rootkits: Subverting the Windows Kernel, Greg Hoglund and James Butler
  • Microsoft Windows Internals, 4th Edition, Mark E. Russinovich and David A. Solomon
  • Reversing: Secrets of Reverse Engineering, Eldad Eilam

历史

  • 2005年9月23日:初稿提交。
© . All rights reserved.