如果你要实现快速的文件查找与遍历,那么肯定绕不开NTFS的MFT记录读取。MFT(Master File Table)是NTFS文件系统中内置的一张表,存放了磁盘上所有文件的文件信息,其标准文件名为$Mft。然而,这个文件无法在文件管理器里直接打开,只能用系统API或者三方的工具读取。

NTFS 官网中的相关文章MSDN上的文章简要地介绍了MFT的基础结构。MFT是一个线性增长的表,其由多个记录(Record)组成(准确来说是 File Record Segment)。每个记录的ID即为其在表中的索引,其结构大致如下图所示:

+----------+-- 0 * Size
| Record 0 |
+----------+-- 1 * Size
| Record 1 |
+----------+-- 2 * Size
| ........ |
+----------+-- n * Size
| Record N |
+----------+-- (n+1) * Size

每个记录对应了一个文件,如果某个文件被删除了,则这个文件记录会被标记为空,而不是直接移除对应记录。这样的设计保证了文件记录是线性增长的。

NTFS中预留了0-15记录的ID用作特殊用途,例如:

  • 0: MFT ($Mft)
  • 1: MFT2
  • 2: Logfile
  • 5: 根目录(/)
  • 6: 已用簇表 ($Bitmap)
  • 8: 坏簇表($BadClus)

在遍历MFT记录时跳过这些记录即可。

为了遍历读取这些记录,首先要打开对应的磁盘:

HANDLE hVolume = CreateFile("\\\\.\\C:", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
                            OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hVolume == INVALID_HANDLE_VALUE)
{
    std::cout << "Failed to open volume." << std::endl;
    return false;
}

遍历一个表需要知道这个表的长度,以及每个单元的大小。Windows 提供了FSCTL_GET_NTFS_VOLUME_DATAAPI 获取MFT表的大小,可以使用以下代码读取:

DWORD bytesRead = 0;
NTFS_VOLUME_DATA_BUFFER volumeData;
if (!DeviceIoControl(hVolume, FSCTL_GET_NTFS_VOLUME_DATA, NULL, 0, &volumeData, sizeof(volumeData), &bytesRead, NULL))
{
    std::cout << "Failed to read NTFS volume data." << std::endl;
    CloseHandle(hVolume);
    return false;
}

使用这个API能够取得一个NTFS_VOLUME_DATA_BUFFER,其数据结构为:

typedef struct {
  LARGE_INTEGER VolumeSerialNumber; // 卷序列号
  LARGE_INTEGER NumberSectors; // 扇区数
  LARGE_INTEGER TotalClusters; // 总簇数
  LARGE_INTEGER FreeClusters; // 空闲簇数
  LARGE_INTEGER TotalReserved; // 保留簇数
  DWORD         BytesPerSector; // 每扇区字节数
  DWORD         BytesPerCluster; // 每簇字节数
  DWORD         BytesPerFileRecordSegment; // 每个文件记录片段的字节数
  DWORD         ClustersPerFileRecordSegment; // 每个文件记录片段的簇数
  LARGE_INTEGER MftValidDataLength; // MFT 表长度(字节数)
  LARGE_INTEGER MftStartLcn; // MFT 起始LCN
  LARGE_INTEGER Mft2StartLcn; // MFT2 表起始LCN
  LARGE_INTEGER MftZoneStart; // MFT 区域起始LCN
  LARGE_INTEGER MftZoneEnd; // MFT 区域结束LCN
} NTFS_VOLUME_DATA_BUFFER, *PNTFS_VOLUME_DATA_BUFFER;

上面的数据中,我们需要的有两个:
– MftValidDataLength: 整个MFT表的长度
– BytesPerFileRecordSegment: MFT表中一个记录的长度

那么,MFT表中所有记录的数量即为:

auto mftCount = volumeData.MftValidDataLength.QuadPart / volumeData.BytesPerFileRecordSegment;

好了,接下来我们要遍历读取MFT记录了。如何读取一个指定ID的MFT记录呢?Windows 也提供了一个API,其名为FSCTL_GET_NTFS_FILE_RECORD。这个API非常智能,它会跳过失效的记录,永远返回一个可用的记录。这个跳过是向下的,即如果你磁盘上的记录1-5和11-15是有效的,6-10是无效的,此时尝试访问记录9的话,它会返回记录5。

这个API在获取到MFT后会在前面加上一个头数据 NTFS_FILE_RECORD_OUTPUT_BUFFER,所以它的参数buffer要求按以下方法定义:

NTFS_FILE_RECORD_INPUT_BUFFER inputBuffer;
auto outputBufferSize = sizeof(NTFS_FILE_RECORD_OUTPUT_BUFFER) + volumeData.BytesPerFileRecordSegment - 1;
PNTFS_FILE_RECORD_OUTPUT_BUFFER outputBuffer = (PNTFS_FILE_RECORD_OUTPUT_BUFFER)malloc(outputBufferSize);
DeviceIoControl(hVolume, FSCTL_GET_NTFS_FILE_RECORD, &inputBuffer, sizeof(inputBuffer), outputBuffer, outputBufferSize, &bytesRead, NULL);

让我们来看看 NTFS_FILE_RECORD_OUTPUT_BUFFER 的数据结构:

typedef struct {
  LARGE_INTEGER FileReferenceNumber; // 实际返回的文件记录的ID
  DWORD         FileRecordLength; // 文件记录长度
  BYTE          FileRecordBuffer[1]; // 文件记录的指针,即 PFILE_RECORD_SEGMENT_HEADER
} NTFS_FILE_RECORD_OUTPUT_BUFFER, *PNTFS_FILE_RECORD_OUTPUT_BUFFER;

由于这个API会跳过不存在的文件记录ID,所以需要返回值提供了其实际读取到的记录的ID。依据这样的特性,我们从后往前遍历记录会更合理,可以跳过很多不存在的记录ID。

除此之外,返回值还包括了我们要的文件记录 FILE_RECORD_SEGMENT_HEADER和对应的属性。

typedef struct _FILE_RECORD_SEGMENT_HEADER
{
    MULTI_SECTOR_HEADER MultiSectorHeader;
    LSN Lsn;
    USHORT SequenceNumber;
    USHORT ReferenceCount;
    USHORT FirstAttributeOffset; // 首个属性相对于本Header起始位置的偏移量
    USHORT Flags; // 标志,FILE_RECORD_SEGMENT_IN_USE (0x0001) 标志着这个记录是有效的,FILE_FILE_NAME_INDEX_PRESENT (0x0002)
    ULONG FirstFreeByte;
    ULONG BytesAvailable;
    FILE_REFERENCE BaseFileRecordSegment;
    USHORT NextAttributeInstance;
    UPDATE_SEQUENCE_ARRAY UpdateSequenceArray;
} FILE_RECORD_SEGMENT_HEADER, *PFILE_RECORD_SEGMENT_HEADER;

这个文件记录里面我们主要关心两个条目。一个是Flags,其标志了记录是否有效;另一个则是FirstAttributeOffset,标记了这个记录的各项属性的起始偏移量。

记录的属性为 ATTRIBUTE_RECORD_HEADER,其结构如下:

typedef struct _ATTRIBUTE_RECORD_HEADER {
  ATTRIBUTE_TYPE_CODE TypeCode; // 类型代码。如果为0xFFFFFFFF,则说明属性列表结束
  ULONG               RecordLength; // 当前属性的长度
  UCHAR               FormCode; // 类型代码。如果是0x00,则说明数据放在此记录内;如果是0x01,则说明数据不放在记录内
  UCHAR               NameLength; // 属性可选名称的长度
  USHORT              NameOffset; // 属性可选名称的偏移量
  USHORT              Flags; // 标记
  USHORT              Instance;
  union {
    struct {
      ULONG  ValueLength; // 数据长度
      USHORT ValueOffset; // 数据偏移量
      UCHAR  Reserved[2];
    } Resident; // 数据在记录内的情形
    struct {
      VCN      LowestVcn;
      VCN      HighestVcn;
      USHORT   MappingPairsOffset;
      UCHAR    Reserved[6];
      LONGLONG AllocatedLength;
      LONGLONG FileSize;
      LONGLONG ValidDataLength;
      LONGLONG TotalAllocated;
    } Nonresident;
  } Form; // 数据在记录外的情形
} ATTRIBUTE_RECORD_HEADER, *PATTRIBUTE_RECORD_HEADER;

当类型代码为0x30时,说明这个记录是文件名,其数据为一个 FILE_NAME_ATTRIBUTE

typedef struct _FILE_NAME_ATTRIBUTE
{
    FILE_REFERENCE ParentDirectory; // 父文件夹的Record ID
    UCHAR Reserved[0x30];
    ULONG FileAttributes; // 文件的Attr,可以与标记 (0x10000000) 做位与运算判断是否为文件夹
    ULONG AlignmentOrReserved;
    UCHAR FileNameLength; // 文件名长度
    UCHAR Flags; // 标记。0x01 为NTFS长文件名,0x02为DOS短文件名
    WCHAR FileName[1]; // 文件名指针
} FILE_NAME_ATTRIBUTE, *PFILE_NAME_ATTRIBUTE;

自此,我们就已经遍历读出MFT的所有文件了。示例代码如下:

for (DWORD index = mftCount - 1; index >= 16; --index)
{
    inputBuffer.FileReferenceNumber.LowPart = index;
    if (!DeviceIoControl(hVolume, FSCTL_GET_NTFS_FILE_RECORD, &inputBuffer, sizeof(inputBuffer), outputBuffer,
                         outputBufferSize, &bytesRead, NULL))
    {
        std::cout << "Failed to read MFT record." << std::endl;
        // todo: 错误处理
        break;
    }

    // 跳过空记录
    index = outputBuffer->FileReferenceNumber.LowPart;

    PFILE_RECORD_SEGMENT_HEADER pHeader = (PFILE_RECORD_SEGMENT_HEADER)outputBuffer->FileRecordBuffer;
    if (pHeader->Flags & 0x0004) // msdn 未定义,跳过
        continue;
    if (!(pHeader->Flags & 0x0001)) // 非标准文件
        continue;
    if (pHeader->SequenceNumber == 0) // 过时条目
        continue;

    PATTRIBUTE_RECORD_HEADER pAttr = (PATTRIBUTE_RECORD_HEADER)((uint32_t)pHeader + pHeader->FirstAttributeOffset);
    while (pAttr->TypeCode != ATTR_END)
    {
        auto typeCode = pAttr->TypeCode;
        auto resident = pAttr->FormCode;

        if (resident == 0x00)
        {
            auto form = pAttr->Form.Resident;
            void *ptr = (void *)((uint32_t)pAttr + form.ValueOffset);

            if (typeCode == ATTR_FILE_NAME)
            {
                PFILE_NAME_ATTRIBUTE pFileName = (PFILE_NAME_ATTRIBUTE)ptr;
                if (pFileName->Flags & 0x01) // NTFS 长文件名
                {
                    auto filename = pFileName->FileName;
                    auto parentID = pFileName->ParentDirectory.SegmentNumberLowPart;
                    bool isDirectory = (pFileName->FileAttributes & FILE_NAME_INDEX_PRESENT) != 0;
                }
            }
        }

        // next
        pAttr = (PATTRIBUTE_RECORD_HEADER)((uint32_t)pAttr + pAttr->RecordLength);
    }
}

上述代码没有处理数据在记录外的情形,写的也很粗糙,仅用于抛砖引玉,欢迎各位指正。


参考资料:
MFT和USN
ParseNTFS/ntfs.h

分类: 编程

2 条评论

pan · 2024 年 6 月 18 日 下午 4:41

我有很多的类型找不到,是否需要引用头文件?

    Jim · 2024 年 6 月 18 日 下午 5:05

    是的,至少windows.h是需要的。其他的话可以看下对应API文档所在的头文件。

发表回复

Avatar placeholder

您的邮箱地址不会被公开。 必填项已用 * 标注