如果你要实现快速的文件查找与遍历,那么肯定绕不开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文档所在的头文件。