V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
anhkgg
V2EX  ›  Python

度探索:解除文件占用那些坑

  •  
  •   anhkgg ·
    anhkgg · 2021-01-14 11:14:51 +08:00 · 2198 次点击
    这是一个创建于 1415 天前的主题,其中的信息可能已经有所发展或是发生改变。

    了解一点操作系统知识的同学们应该都知道,文件占用无法删除,是因为某些进程正在使用该文件。

    要删除这样的文件,就需要让那些进程关闭文件,然后自然可以删除。

    一句话的事,那究竟要怎么用代码来实现这个功能呢?

    打开和关闭文件

    还记得上大学第一门语言课-C 语言,迄今为止还依然活跃并被一直使用的语言。

    比汇编容易理解,又更接近底层,所以 Windows 操作系统内核大部分代码都是用 C 语言来编写的。

    在 C 的课程里,我们学过通过 FILE 来操作使用文件,比如:

    FILE *fp;
    fp = fopen("c:\\temp\\test.txt", "r") 
    

    通过读的方式打开一个文件,使用非常简单,后续通过 fp 这个结构体指针操作文件即可。

    其实 fopen 并不接近操作系统,他是对 win32 API CreateFile 的封装。

    也就是前者是标准库接口,在 Windows 、linux 、unix 等都是通用接口。

    而后者才是和操作系统关联紧密,由微软自己提供的 API 。

    要更好的理解进程如何使用文件的,我们还得看看CreateFile这个 API 接口。

    HANDLE CreateFileA(
      LPCSTR                lpFileName,
      DWORD                 dwDesiredAccess,
      DWORD                 dwShareMode,
      LPSECURITY_ATTRIBUTES lpSecurityAttributes,
      DWORD                 dwCreationDisposition,
      DWORD                 dwFlagsAndAttributes,
      HANDLE                hTemplateFile
    );
    

    这是 msdn 对 CreateFile 的定义,简单来看我们可以只关注 lpFileName 和返回值,lpFileName 传递你要打开的文件,返回值是操作系统给你的一个代表文件的句柄( handle )。

    HANDLE hFile = CreateFileA("c:\\temp\\test.txt", ...);
    

    要对文件进行读、写等操作都需要这个句柄,也就是说这个句柄至关重要,它表示文件正在被使用。

    然后什么时候结束使用呢,我们需要看另一个 API CloseHandle.

    BOOL CloseHandle(
      HANDLE hObject
    );
    

    CloseHandle 用于关闭一个正在被使用的文件,通过句柄来关闭。

    现在明白过来了吗,只要我们让进程调用 CloseHandle 这个 API,关闭被占用的文件句柄,那么该文件也就被解除占用了。

    哈哈,是不是很简单。

    枚举占用文件的进程

    那么我就想问同学们一个问题,怎么知道哪些进程在使用我们想删除的文件呢?怎么去查找?

    带着这个问题,我们继续往下看。

    我们来想一个问题,操作系统给调用 CreateFile 的用户返回了一个句柄,然后通过句柄来操作文件,那操作系统是如何知道句柄代表哪个文件呢?

    我们简单思考一下,我们要做到这个目的有没有什么方法,比如我用一个数组来存用户打开的文件路径,而数组序号就返回给用户,下次用户就只需要把序号给我,我就知道要操作什么问题了。

    演示代码,忽略细节
    LPWSTR FileTable[100] = {0};
    HANDLE CreateFileA(
      LPCSTR                lpFileName,
      ...)
      {
          for(int i = 0; i < 100; i ++) {
              if(FileTable[i] == NULL) { //还有空位
                  FileTable[i] = lpFileName; //保存路径
                  return (HANDLE)i; //返回句柄
              }
          }
          return NULL;
      }
    BOOL CloseHandle(
      HANDLE hObject
    ) {
        if((int)hObject < 100) {
            if(FileTable[hObject]) {
                FileTable[hObject] = NULL;//找到文件路径
                return TRUE;
            }
        }
        return FALSE;
    }
    

    上面简单的代码演示了一下我们粗略考略的文件和句柄的关系以及句柄的管理,那操作系统是不是这么做的呢?其实也差不多。

    //https://www.cnblogs.com/lsh123/p/8329989.html

    任意进程,只要每打开一个对象(包括文件、进程、线程等等),就会获得一个句柄。

    这个句柄用来标志对某个对象的一次打开,通过句柄,可以直接找到对应的内核对象。

    每个进程都有一个句柄表,用来记录本进程打开的所有内核对象。

    句柄表可以简单看做为一个一维数组,每个表项就是一个句柄,一个结构体,一个句柄描述符。

     struct _HANDLE_TABLE_ENTRY  //句柄描述符
     struct _HANDLE_TABLE    //句柄表描述符
    

    好,更加细节的句柄表的原理我们不用再深究,我们只需要知道每个进程都有一个句柄表,通过句柄表就可以找到打开的文件。

    这就是我们的目的,我们需要查到进程是不是打开了我们要删除的文件,我们需要查句柄表。

    那怎么查呢?

    操作系统给用户提供了一个接口ZwQuerySystemInformation

    NTSTATUS WINAPI ZwQuerySystemInformation(
      _In_      SYSTEM_INFORMATION_CLASS SystemInformationClass,
      _Inout_   PVOID                    SystemInformation,
      _In_      ULONG                    SystemInformationLength,
      _Out_opt_ PULONG                   ReturnLength
    );
    

    它可以获取系统非常多的信息,包括进程、模块、处理器、内存等等各种信息。

    而 SystemHandleInformation = 16 就能获取到系统所有的句柄信息。

    typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO
    {
        USHORT UniqueProcessId;//所属进程
        USHORT CreatorBackTraceIndex;
        UCHAR ObjectTypeIndex;
        UCHAR HandleAttributes;
        USHORT HandleValue; //句柄
        PVOID Object;
        ULONG GrantedAccess;
    } SYSTEM_HANDLE_TABLE_ENTRY_INFO, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO;
    
    typedef struct _SYSTEM_HANDLE_INFORMATION
    {
        ULONG NumberOfHandles;
        SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1];
    } SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION;
    

    既然知道了方法,下面就开始枚举所有句柄,找到我们被占用的文件的进程信息。

    Status = ZwQuerySystemInformation(SystemHandleInformation,
                Information,
                Length,
                &ReturnLength);
    
    for (i = 0; i < Information->NumberOfHandles; i++) {
        if (Information->Handles[i].UniqueProcessId != CurrentProcessId) {//不是当前进程
            Status = ZwQueryObject(TargetHandle, ObjectTypeInformation, &TypeInfo, sizeof(TypeInfo), NULL);
            RtlInitUnicodeString(&TargetType, L"File");
            if (!RtlEqualUnicodeString(&TypeInfo.Info.TypeName, &TargetType, FALSE)) {
                goto __next;
            }
            Status = ZwQueryObject(TargetHandle, ObjectNameInformation, &NameInfo, sizeof(NameInfo), NULL);
            if (RtlEqualUnicodeString(&NameInfo.Info.Name, &FileName, FALSE)) {
                printf("在进程(%d)发现文件占用:(%x) %wZ\n",
                        ProcessId,
                        Information->Handles[i].HandleValue,
                        &NameInfo.Info.Name);
            }
        }
    }
    

    ZwQuerySystemInformation 获取到所有句柄信息,通过循环枚举 Information->Handles,找到句柄类型属于 File,路径是目标文件的进程。

    ZwQueryObject 传入 ObjectTypeInformation 可以获取句柄类型,ZwQueryObject 传入 ObjectNameInformation 可以获取文件路径。

    如此两个条件的对比,就能让我们找到占用文件的进程了。

    是不是感觉还挺简单,不复杂嘛。

    坑一:ZwQueryObject

    前面提到,每个进程都有自己的句柄表,所以 ZwQuerySystemInformation 枚举拿到的句柄并不能直接使用,还需要复制一份到本进程才有效。

    系统也提供了 API 叫做DuplicateHandle:

    BOOL DuplicateHandle(
      HANDLE   hSourceProcessHandle,
      HANDLE   hSourceHandle,
      HANDLE   hTargetProcessHandle,
      LPHANDLE lpTargetHandle,
      DWORD    dwDesiredAccess,
      BOOL     bInheritHandle,
      DWORD    dwOptions
    );
    
    DuplicateHandle(hSrcProc, Information->Handles[i].HandleValue, hCurProc, TargetHandle, ...);
    

    上面我们使用的 TargetHandle 就是通过复制获取的。

    这个地方并不是坑,而是在通过 ZwQueryObject 获取句柄对应的文件路径时,会发生阻塞,导致程序卡死无法继续运行。

    0: kd> kv
     # ChildEBP RetAddr  Args to Child
    00 d7fdb7cc 828aacda 00000000 00000000 a7d73040 nt!KiSwapContext+0x19 (FPO: [Uses EBP] [1,0,4])
    01 d7fdb86c 828aa358 d7fdb930 a7d73120 a7d73040 nt!KiSwapThread+0x4aa (FPO: [Non-Fpo])
    02 d7fdb8c8 828a9d67 00000000 00000000 00000000 nt!KiCommitThreadWait+0x128 (FPO: [Non-Fpo])
    03 d7fdb978 829298a3 8ff18afc 00000000 a7d73300 nt!KeWaitForSingleObject+0x1f7 (FPO: [Non-Fpo])
    04 d7fdb9a4 82c0759f 88c0e801 d7fdba18 8ff18ab0 nt!IopWaitForLockAlertable+0x3f (FPO: [Non-Fpo])
    05 d7fdb9cc 82d3f75c 88c0e800 a7d733f8 d7fdb9ef nt!IopWaitAndAcquireFileObjectLock+0x41 (FPO: [Non-Fpo])
    06 d7fdba1c 82bed31a 000001ee d7fdbb01 9a651dc0 nt!IopQueryXxxInformation+0x150f3e
    07 d7fdba9c 82becf65 00000000 007af7a4 00000210 nt!IopQueryNameInternal+0x31a (FPO: [Non-Fpo])
    08 d7fdbab8 82bece25 8ff18ab0 87ff2400 007af7a4 nt!IopQueryName+0x1b (FPO: [Non-Fpo])
    09 d7fdbb40 82bec6a6 00000210 d7fdbc04 d7fdbb01 nt!ObQueryNameStringMode+0x495 (FPO: [Non-Fpo])
    0a d7fdbbf8 829cce6b 8ff18ab0 00000000 007af7a4 nt!NtQueryObject+0x186 (FPO: [SEH])
    0b d7fdbbf8 77cd5ef0 8ff18ab0 00000000 007af7a4 nt!KiSystemServicePostCall (FPO: [0,3] TrapFrame @ d7fdbc14)
    

    经过一些简单的分析,如果文件被是同步( SYNCHRONIZE )打开的,内核会等待一下锁,等其他线程操作完成,本线程才能拿到所有权。

    //
        // Make a special check here to determine whether this is a synchronous
        // I/O operation.  If it is, then wait here until the file is owned by
        // the current thread.  If this is not a (serialized) synchronous I/O
        // operation, then initialize the local event.
        //
    
        if (FileObject->Flags & FO_SYNCHRONOUS_IO) {
    
            BOOLEAN interrupted;
    
            if (!IopAcquireFastLock( FileObject )) {
                status = IopAcquireFileObjectLock( FileObject,
                                                   Mode,
                                                   (BOOLEAN) ((FileObject->Flags & FO_ALERTABLE_IO) != 0),
                                                   &interrupted );
                if (interrupted) {
                    ObDereferenceObject( FileObject );
                    return status;
                }
            }
            KeClearEvent( &FileObject->Event );
            synchronousIo = TRUE;
        }
    

    所以这里我们就需要通过线程和超时的方式来调用 ZwQueryObject,让程序可以不阻塞正常运行。

    void
    QueryThread(
        IN PQUERY_CONTEXT Context
    ) {
        Status = ZwQueryObject(TargetHandle, ObjectNameInformation, &NameInfo, sizeof(NameInfo), NULL);
    }
    
    ThreadHandle = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)QueryThread, Context, 0, NULL);
    Result = WaitForSingleObject(ThreadHandle, 1000); //等待 1 秒超时,线程退出
    TerminateThread(ThreadHandle, 0);
    CloseHandle(ThreadHandle);
    

    坑二:文件 Map

    解决上面的问题之后,我们基本就解决了文件占用的问题,大部分情况下,我们可以正常删除文件了。

    可是...

    某些时候,我们要删除的文件并不是普通文件,可能是一个 DLL 、或者其他特殊文件。

    关闭所有占用的句柄后,依然无法删除文件,还是提示占用。

    这可怎么办?

    类似于 DLL 这种文件,进程在使用中,操作系统会映射一份内存到进程空间,此时并没有句柄与之对应。

    但是它却关联了文件的内核对象,专业术语说增加了一次文件对象的引用。

    我们要知道,为了能够安全删除一个文件,操作系统需要保证该文件的内核对象在两种引用计数上清零。

    一个是句柄引用计数,一个是对象引用计数。

    前面我们通过枚举句柄,将句柄引用计数清零。

    但是因为共享内存的原因,对象引用计数仍未清零,所以无法删除文件。

    0: kd> !handle 48
    
    PROCESS fffffa801b7c6060
        SessionId: 1  Cid: 0b70    Peb: 7efdf000  ParentCid: 0588
        DirBase: 1bfea000  ObjectTable: fffff8a0029f27e0  HandleCount: 157.
        Image: procexp.exe
    
    Handle table at fffff8a0029f27e0 with 157 entries in use
    
    0004: Object: fffffa801bdcca10  GrantedAccess: 00000003 Entry: fffff8a0020cc010
    Object: fffffa801bdcca10  Type: (fffffa8018dcfa30) File
        ObjectHeader: fffffa801bdcc9e0 (new version)
            HandleCount: 0//句柄引用计数  PointerCount: 1 //对象引用计数
    

    我们通过!vad 俩看看内存 map 。

    0: kd> !vad fffffa8019d34e00
    VAD           Level     Start       End Commit
    fffffa8019d34e00  0      1000      12ce      0 Mapped       READONLY           \Windows\Globalization\Sorting\SortDefault.nls
    
    0: kd> dt _mmvad fffffa8019d34e00
    nt!_MMVAD
       +0x000 u1               : <unnamed-tag>
       +0x008 LeftChild        : (null) 
       +0x010 RightChild       : (null) 
       +0x018 StartingVpn      : 0x1000
       +0x020 EndingVpn        : 0x12ce
       +0x028 u                : <unnamed-tag>
       +0x030 PushLock         : _EX_PUSH_LOCK
       +0x038 u5               : <unnamed-tag>
       +0x040 u2               : <unnamed-tag>
       +0x048 Subsection       : 0xfffffa80`1b56ef90 _SUBSECTION
       +0x048 MappedSubsection : 0xfffffa80`1b56ef90 _MSUBSECTION
       +0x050 FirstPrototypePte : 0xfffff8a0`00b02000 _MMPTE
       +0x058 LastContiguousPte : 0xfffff8a0`00b03670 _MMPTE
       +0x060 ViewLinks        : _LIST_ENTRY [ 0xfffffa80`18ec81c0 - 0xfffffa80`18fcd190 ]
       +0x070 VadsProcess      : 0xfffffa80`1b7c6061 _EPROCESS
    0: kd> dt 0xfffffa80`1b56ef90 _SUBSECTION
    nt!_SUBSECTION
       +0x000 ControlArea      : 0xfffffa80`1b56ef10 _CONTROL_AREA
       +0x008 SubsectionBase   : 0xfffff8a0`00b02000 _MMPTE
       +0x010 NextSubsection   : 0xfffffa80`193a0a60 _SUBSECTION
       +0x018 PtesInSubsection : 0x2cf
       +0x020 UnusedPtes       : 0
       +0x020 GlobalPerSessionHead : (null) 
       +0x028 u                : <unnamed-tag>
       +0x02c StartingSector   : 0
       +0x030 NumberOfFullSectors : 0x2cf
    0: kd> dt 0xfffffa80`1b56ef10 _CONTROL_AREA
    nt!_CONTROL_AREA
       +0x000 Segment          : 0xfffff8a0`03b31fd0 _SEGMENT
       +0x008 DereferenceList  : _LIST_ENTRY [ 0x00000000`00000000 - 0x00000000`00000000 ]
       +0x018 NumberOfSectionReferences : 0
       +0x020 NumberOfPfnReferences : 0x101
       +0x028 NumberOfMappedViews : 0x2a
       +0x030 NumberOfUserReferences : 0x2a
       +0x038 u                : <unnamed-tag>
       +0x03c FlushInProgressCount : 0
       +0x040 FilePointer      : _EX_FAST_REF
       +0x048 ControlAreaLock  : 0n0
       +0x04c ModifiedWriteCount : 0
       +0x04c StartingFrame    : 0
       +0x050 WaitList         : (null) 
       +0x058 u2               : <unnamed-tag>
       +0x068 LockedPages      : 1
       +0x070 ViewList         : _LIST_ENTRY [ 0xfffffa80`1be91570 - 0xfffffa80`1abbe690 ]
    0: kd> dx -id 0,0,fffffa801b7c6060 -r1 (*((ntkrnlmp!_EX_FAST_REF *)0xfffffa801b56ef50))
    (*((ntkrnlmp!_EX_FAST_REF *)0xfffffa801b56ef50))                 [Type: _EX_FAST_REF]
        [+0x000] Object           : 0xfffffa801b61fa14 [Type: void *]
        [+0x000 ( 3: 0)] RefCnt           : 0x4 [Type: unsigned __int64]
        [+0x000] Value            : 0xfffffa801b61fa14 [Type: unsigned __int64]
    0: kd> !object 0xfffffa801b61fa10
    Object: fffffa801b61fa10  Type: (fffffa8018dcfa30) File
        ObjectHeader: fffffa801b61f9e0 (new version)
        HandleCount: 0  PointerCount: 5
        Directory Object: 00000000  Name: \Windows\Globalization\Sorting\SortDefault.nls {HarddiskVolume2}
    
    0: kd> dt _file_object 0xfffffa801b61fa10
    nt!_FILE_OBJECT
       +0x000 Type             : 0n5
       +0x002 Size             : 0n216
       +0x008 DeviceObject     : 0xfffffa80`19e9d530 _DEVICE_OBJECT
       +0x010 Vpb              : 0xfffffa80`19eca270 _VPB
       +0x018 FsContext        : 0xfffff8a0`00ad0140 Void
       +0x020 FsContext2       : 0xfffff8a0`00ad0330 Void
       +0x028 SectionObjectPointer : 0xfffffa80`1b61f808 _SECTION_OBJECT_POINTERS
    0: kd> dx -id 0,0,fffffa801b7c6060 -r1 ((ntkrnlmp!_SECTION_OBJECT_POINTERS *)0xfffffa801b61f808)
    ((ntkrnlmp!_SECTION_OBJECT_POINTERS *)0xfffffa801b61f808)                 : 0xfffffa801b61f808 [Type: _SECTION_OBJECT_POINTERS *]
        [+0x000] DataSectionObject : 0xfffffa801b56ef10 [Type: void *] //其实就是前面的_mmvad->Subsection->ControlArea
        [+0x008] SharedCacheMap   : 0x0 [Type: void *]
        [+0x010] ImageSectionObject : 0x0 [Type: void *]
    

    SortDefault.nls 是被映射到了进程中,通过_mmvad->Subsection->ControlArea->FilePointer 我们可以一步步定位到它引用的文件对象。

    !object 0xfffffa801b61fa10看到确实是该文件,也可以通过 fileobject->SectionObjectPointer->DataSectionObject 找到对应的映射内存。

    如此我们初步理解了文件 map 导致文件占用无法删除文件的原理。

    下面我们就需要找到方法怎么解决这个问题。

    首先,需要枚举进程的虚拟内存,找到是否有我们需要查找的文件的 map,然后对该进程有两种操作:

    1. 非常暴力但是简单的方法,那就是直接关闭进程
    2. 或者 unmap 这块内存,解除对象引用计数(经过测试,未成功,待深入研究,也请大佬指教)

    如何枚举虚拟内存呢,使用ZwQueryVirtualMemory.

    NTSTATUS ZwQueryVirtualMemory(
      _In_      HANDLE                   ProcessHandle,
      _In_opt_  PVOID                    BaseAddress,
      _In_      MEMORY_INFORMATION_CLASS MemoryInformationClass,
      _Out_     PVOID                    MemoryInformation,
      _In_      SIZE_T                   MemoryInformationLength,
      _Out_opt_ PSIZE_T                  ReturnLength
    );
    
    //MemoryBasicInformation
    typedef struct _MEMORY_BASIC_INFORMATION {
      PVOID  BaseAddress;
      PVOID  AllocationBase;
      ULONG  AllocationProtect;
      USHORT PartitionId;
      SIZE_T RegionSize;
      ULONG  State;
      ULONG  Protect;
      ULONG  Type;
    } MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
    
    Type
    The type of pages in the region. The following types are defined.
    MEM_IMAGE 0x1000000	Indicates that the memory pages within the region are mapped into the view of an image section.
    MEM_MAPPED 0x40000	Indicates that the memory pages within the region are mapped into the view of a section.
    MEM_PRIVATE 0x20000	Indicates that the memory pages within the region are private (that is, not shared by other processes).
    

    从 0 地址开始,每次加一个页,获取内存信息,如果内存的 type 是 MEM_IMAGE 或者 MEM_MAPPED,那么就是文件 map,然后获取虚拟内存对应名字,判断是不是目标文件。

    for (;;) {
        Status = ZwQueryVirtualMemory(ProcessHandle, BaseAddress, MemoryBasicInformation,
            &MemoryInfo, sizeof(MemoryInfo), NULL);
        if (MemoryInfo.Type == MEM_IMAGE ||  //image
            MemoryInfo.Type == MEM_MAPPED) { //data
                Status = ZwQueryVirtualMemory(ProcessHandle, BaseAddress, MemoryMappedFilenameInformation, &Name, sizeof(Name), NULL);
                if (RtlEqualUnicodeString(&Name.u, &TargetName, TRUE)) {
                    //找到目标文件
                    break;
                }
            }
        }
    }
    

    找到目标进程后,关闭进程,轻松删除文件。

    代码都在环 3 完成。工具在此:FileLock

    (完)

    欢迎关注 gzh:汉客儿

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2817 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 02:32 · PVG 10:32 · LAX 18:32 · JFK 21:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.