MftFileSearch.psm1
|
#Requires -Version 5.1 $MftFileSearcherSource = @' using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.ComponentModel; using Microsoft.Win32.SafeHandles; public class MftFileSearcher { [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern SafeFileHandle CreateFile( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DeviceIoControl( SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool ReadFile( SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetFilePointerEx( SafeFileHandle hFile, long liDistanceToMove, out long lpNewFilePointer, uint dwMoveMethod); private const uint GENERIC_READ = 0x80000000; private const uint FILE_SHARE_READ = 0x01; private const uint FILE_SHARE_WRITE = 0x02; private const uint OPEN_EXISTING = 3; private const uint FSCTL_GET_NTFS_VOLUME_DATA = 0x00090064; public class MftSearchResult { public string ComputerName { get; set; } public string FileName { get; set; } public string FullPath { get; set; } public long FileSize { get; set; } public string SizeFormatted { get; set; } public double SizeKB { get; set; } public double SizeMB { get; set; } public double SizeGB { get; set; } public bool IsDirectory { get; set; } public string Type { get; set; } public string Extension { get; set; } public DateTime ScanDate { get; set; } } // Represents one contiguous extent of the MFT on disk private struct MftExtent { public long StartByte; public long LengthBytes; } // Parse NTFS data runs from a non-resident $DATA attribute private static List<MftExtent> ParseDataRuns(byte[] buffer, int dataRunOffset, int attrEnd, uint bytesPerCluster) { var extents = new List<MftExtent>(); int pos = dataRunOffset; long prevLcn = 0; while (pos < attrEnd) { byte header = buffer[pos]; if (header == 0) break; int lengthSize = header & 0x0F; int offsetSize = (header >> 4) & 0x0F; pos++; if (lengthSize == 0 || pos + lengthSize + offsetSize > attrEnd) break; // Read run length (unsigned) long runLength = 0; for (int b = 0; b < lengthSize; b++) runLength |= ((long)buffer[pos + b]) << (b * 8); pos += lengthSize; // Read run offset (signed, relative to previous LCN) long runOffset = 0; if (offsetSize > 0) { for (int b = 0; b < offsetSize; b++) runOffset |= ((long)buffer[pos + b]) << (b * 8); // Sign-extend if the high bit is set if ((buffer[pos + offsetSize - 1] & 0x80) != 0) { for (int b = offsetSize; b < 8; b++) runOffset |= ((long)0xFF) << (b * 8); } pos += offsetSize; } else { // Sparse run - skip continue; } long absoluteLcn = prevLcn + runOffset; prevLcn = absoluteLcn; extents.Add(new MftExtent { StartByte = absoluteLcn * bytesPerCluster, LengthBytes = runLength * bytesPerCluster }); } return extents; } // Read MFT record 0 and extract the $DATA attribute data runs private static List<MftExtent> GetMftExtents(SafeFileHandle hVolume, long mftStartByte, uint bytesPerMftRecord, uint bytesPerCluster) { byte[] rec0 = new byte[bytesPerMftRecord]; long newPos; SetFilePointerEx(hVolume, mftStartByte, out newPos, 0); uint bytesRead; if (!ReadFile(hVolume, rec0, bytesPerMftRecord, out bytesRead, IntPtr.Zero) || bytesRead < bytesPerMftRecord) throw new Exception("Failed to read MFT record 0"); if (rec0[0] != 0x46 || rec0[1] != 0x49 || rec0[2] != 0x4C || rec0[3] != 0x45) throw new Exception("MFT record 0 has invalid signature"); ushort attrOffset = BitConverter.ToUInt16(rec0, 20); int pos = attrOffset; int recEnd = (int)bytesPerMftRecord; while (pos + 4 <= recEnd) { uint attrType = BitConverter.ToUInt32(rec0, pos); if (attrType == 0xFFFFFFFF || attrType == 0) break; uint attrLen = BitConverter.ToUInt32(rec0, pos + 4); if (attrLen == 0 || pos + attrLen > recEnd) break; if (attrType == 0x80) // $DATA { byte nonResident = rec0[pos + 8]; if (nonResident == 1) { ushort dataRunOffset = BitConverter.ToUInt16(rec0, pos + 32); return ParseDataRuns(rec0, pos + dataRunOffset, pos + (int)attrLen, bytesPerCluster); } } pos += (int)attrLen; } throw new Exception("Could not find non-resident $DATA attribute in MFT record 0"); } public static List<MftSearchResult> Search(string driveLetter, string searchTerm, bool caseSensitive, bool searchPath) { string volumePath = @"\\.\" + driveLetter + ":"; var results = new List<MftSearchResult>(); var fileNames = new Dictionary<long, string>(); var parentRefs = new Dictionary<long, long>(); var fileSizes = new Dictionary<long, long>(); var isDirectory = new Dictionary<long, bool>(); string searchLower = caseSensitive ? searchTerm : searchTerm.ToLowerInvariant(); using (SafeFileHandle hVolume = CreateFile(volumePath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero)) { if (hVolume.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open volume " + volumePath + ". Ensure you are running as Administrator."); // Get NTFS volume data byte[] ntfsData = new byte[128]; GCHandle hData = GCHandle.Alloc(ntfsData, GCHandleType.Pinned); uint bytesReturned; try { if (!DeviceIoControl(hVolume, FSCTL_GET_NTFS_VOLUME_DATA, IntPtr.Zero, 0, hData.AddrOfPinnedObject(), (uint)ntfsData.Length, out bytesReturned, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to get NTFS volume data. Drive may not be NTFS formatted."); } finally { hData.Free(); } // NTFS_VOLUME_DATA_BUFFER correct offsets // Offsets 0-39: 5x LARGE_INTEGER (VolumeSerialNumber, NumberSectors, TotalClusters, FreeClusters, TotalReserved) // Offsets 40-55: 4x DWORD (BytesPerSector, BytesPerCluster, BytesPerFileRecordSegment, ClustersPerFileRecordSegment) // Offsets 56+: LARGE_INTEGERs (MftValidDataLength, MftStartLcn, Mft2StartLcn, MftZoneStart, MftZoneEnd) uint bytesPerSector = BitConverter.ToUInt32(ntfsData, 40); uint bytesPerCluster = BitConverter.ToUInt32(ntfsData, 44); uint bytesPerMftRecord = BitConverter.ToUInt32(ntfsData, 48); long mftValidDataLength = BitConverter.ToInt64(ntfsData, 56); long mftStartLcn = BitConverter.ToInt64(ntfsData, 64); long mftStartByte = mftStartLcn * bytesPerCluster; // Parse MFT data runs to handle fragmented MFT List<MftExtent> mftExtents = GetMftExtents(hVolume, mftStartByte, bytesPerMftRecord, bytesPerCluster); // Calculate total MFT bytes across all extents, capped to valid data length long totalMftBytes = 0; foreach (var ext in mftExtents) totalMftBytes += ext.LengthBytes; if (totalMftBytes > mftValidDataLength) totalMftBytes = mftValidDataLength; // Read MFT in chunks, walking through each extent int recordsPerChunk = 4096; uint chunkSize = (uint)(recordsPerChunk * bytesPerMftRecord); byte[] buffer = new byte[chunkSize]; var matchedRecords = new HashSet<long>(); long mftBytesProcessed = 0; int extentIndex = 0; long extentBytesConsumed = 0; while (mftBytesProcessed < totalMftBytes && extentIndex < mftExtents.Count) { MftExtent currentExtent = mftExtents[extentIndex]; long extentRemaining = currentExtent.LengthBytes - extentBytesConsumed; if (extentRemaining <= 0) { extentIndex++; extentBytesConsumed = 0; continue; } long globalRemaining = totalMftBytes - mftBytesProcessed; long toRead = Math.Min(chunkSize, Math.Min(extentRemaining, globalRemaining)); // Align to MFT record boundary toRead = (toRead / bytesPerMftRecord) * bytesPerMftRecord; if (toRead == 0) { extentIndex++; extentBytesConsumed = 0; continue; } long seekPos = currentExtent.StartByte + extentBytesConsumed; long newPos; if (!SetFilePointerEx(hVolume, seekPos, out newPos, 0)) break; uint bytesRead; if (!ReadFile(hVolume, buffer, (uint)toRead, out bytesRead, IntPtr.Zero) || bytesRead == 0) break; int actualRecords = (int)(bytesRead / bytesPerMftRecord); for (int i = 0; i < actualRecords; i++) { int recOffset = (int)(i * bytesPerMftRecord); // Verify FILE signature (0x46494C45) if (buffer[recOffset] != 0x46 || buffer[recOffset + 1] != 0x49 || buffer[recOffset + 2] != 0x4C || buffer[recOffset + 3] != 0x45) continue; // Read the actual MFT record number from the header long recordIndex = BitConverter.ToUInt32(buffer, recOffset + 44); // Check flags - bit 0 = in use ushort flags = BitConverter.ToUInt16(buffer, recOffset + 22); if ((flags & 0x01) == 0) continue; bool isDir = (flags & 0x02) != 0; isDirectory[recordIndex] = isDir; // Parse attributes ushort attrOffset = BitConverter.ToUInt16(buffer, recOffset + 20); int pos = recOffset + attrOffset; int recEnd = recOffset + (int)bytesPerMftRecord; string bestName = null; byte bestNamespace = 0xFF; long parentRef = -1; long dataSize = 0; while (pos + 4 <= recEnd) { uint attrType = BitConverter.ToUInt32(buffer, pos); if (attrType == 0xFFFFFFFF || attrType == 0) break; uint attrLen = BitConverter.ToUInt32(buffer, pos + 4); if (attrLen == 0 || attrLen > bytesPerMftRecord || pos + attrLen > recEnd) break; if (attrType == 0x30) // $FILE_NAME { byte nonResident = buffer[pos + 8]; if (nonResident == 0) { ushort contentOffset = BitConverter.ToUInt16(buffer, pos + 20); int fnStart = pos + contentOffset; if (fnStart + 66 <= recEnd) { long pRef = BitConverter.ToInt64(buffer, fnStart) & 0x0000FFFFFFFFFFFF; byte nameLen = buffer[fnStart + 64]; byte nameSpace = buffer[fnStart + 65]; if (fnStart + 66 + nameLen * 2 <= recEnd && nameLen > 0) { // Prefer Win32 (1) or Win32+DOS (3) over DOS-only (2) if (bestName == null || nameSpace == 0x01 || nameSpace == 0x03 || (nameSpace == 0x00 && bestNamespace == 0x02)) { bestName = System.Text.Encoding.Unicode.GetString( buffer, fnStart + 66, nameLen * 2); bestNamespace = nameSpace; parentRef = pRef; } } } } } else if (attrType == 0x80) // $DATA { byte nonResident = buffer[pos + 8]; if (nonResident == 0) { if (pos + 16 + 4 <= recEnd) dataSize = BitConverter.ToUInt32(buffer, pos + 16); } else { if (pos + 48 + 8 <= recEnd) dataSize = BitConverter.ToInt64(buffer, pos + 48); } } pos += (int)attrLen; } if (bestName != null) { // Store name/parent/size BEFORE skipping $-prefixed entries // so system entries are available for path resolution fileNames[recordIndex] = bestName; if (parentRef >= 0) parentRefs[recordIndex] = parentRef; fileSizes[recordIndex] = dataSize; if (bestName.StartsWith("$")) continue; // Match on filename string nameToCheck = caseSensitive ? bestName : bestName.ToLowerInvariant(); if (nameToCheck.Contains(searchLower)) { matchedRecords.Add(recordIndex); } } } long consumed = (long)actualRecords * bytesPerMftRecord; extentBytesConsumed += consumed; mftBytesProcessed += consumed; } // If searchPath is enabled, also check full paths for all non-matched records if (searchPath) { foreach (var kvp in fileNames) { if (matchedRecords.Contains(kvp.Key)) continue; if (kvp.Value.StartsWith("$")) continue; string fullPath = BuildPath(kvp.Key, fileNames, parentRefs, driveLetter); string pathToCheck = caseSensitive ? fullPath : fullPath.ToLowerInvariant(); if (pathToCheck.Contains(searchLower)) { matchedRecords.Add(kvp.Key); } } } // Build results DateTime scanDate = DateTime.Now; foreach (long recIdx in matchedRecords) { string fullPath = BuildPath(recIdx, fileNames, parentRefs, driveLetter); long size = fileSizes.ContainsKey(recIdx) ? fileSizes[recIdx] : 0; bool dir = isDirectory.ContainsKey(recIdx) && isDirectory[recIdx]; string name = fileNames[recIdx]; string ext = ""; int dotIdx = name.LastIndexOf('.'); if (dotIdx >= 0 && !dir) ext = name.Substring(dotIdx); results.Add(new MftSearchResult { FileName = name, FullPath = fullPath, FileSize = size, SizeFormatted = FormatSize(size), SizeKB = Math.Round(size / 1024.0, 2), SizeMB = Math.Round(size / 1048576.0, 2), SizeGB = Math.Round(size / 1073741824.0, 2), IsDirectory = dir, Type = dir ? "Directory" : "File", Extension = ext, ScanDate = scanDate }); } } results.Sort((a, b) => string.Compare(a.FullPath, b.FullPath, StringComparison.OrdinalIgnoreCase)); return results; } private static string BuildPath(long recordIndex, Dictionary<long, string> names, Dictionary<long, long> parents, string driveLetter) { var parts = new List<string>(); long current = recordIndex; int maxDepth = 512; while (current >= 0 && maxDepth-- > 0) { if (!names.ContainsKey(current)) break; parts.Add(names[current]); if (!parents.ContainsKey(current)) break; long parent = parents[current]; if (parent == current || parent == 5) break; current = parent; } parts.Reverse(); return driveLetter + ":\\" + string.Join("\\", parts); } private static string FormatSize(long bytes) { if (bytes >= 1073741824L) return (bytes / 1073741824.0).ToString("F2") + " GB"; if (bytes >= 1048576L) return (bytes / 1048576.0).ToString("F2") + " MB"; if (bytes >= 1024L) return (bytes / 1024.0).ToString("F2") + " KB"; return bytes + " B"; } } '@ # Load C# type only once per session if (-not ([System.Management.Automation.PSTypeName]'MftFileSearcher').Type) { Add-Type -TypeDefinition $MftFileSearcherSource } function Search-MftFile { <# .SYNOPSIS Blazingly fast file search for Windows using direct MFT (Master File Table) reading. .DESCRIPTION Searches for files and directories by name across an entire NTFS drive in seconds. Instead of using slow Windows file system APIs, this function reads the NTFS Master File Table directly, providing near-instant results even on large drives. Supports fragmented MFT by parsing data runs from MFT record 0, ensuring all file records are found even on heavily fragmented volumes. By default, the search matches against filenames only. Use -SearchPath to also match against the full file path (slower on large drives as it must reconstruct all paths). .PARAMETER SearchTerm The text to search for in filenames (or full paths if -SearchPath is used). Supports partial matches. For example, "test" will match "test123.txt", "mytest.log", and "testing" folder. .PARAMETER DriveLetter The drive letter to search. Default is C. Do not include the colon. .PARAMETER SearchPath When specified, also searches within the full file path, not just the filename. This is slower on large drives because it must reconstruct all file paths. Useful when searching for files within a specific folder structure. .PARAMETER CaseSensitive Perform a case-sensitive search. Default is case-insensitive. .PARAMETER Type Filter results by type: File, Directory, or All (default). .PARAMETER Extension Filter results by file extension. Example: ".log", ".txt" .PARAMETER ComputerName Target computer(s) to search. Requires PowerShell Remoting (WinRM). If not specified, searches the local computer. .PARAMETER Credential Credentials for remote connections. Required if your current account does not have admin access on the remote target. .EXAMPLE Search-MftFile -SearchTerm "test123" Searches for all files/folders containing "test123" in their name on C: drive. .EXAMPLE Search-MftFile -SearchTerm ".log" -DriveLetter D Searches for files with ".log" in their name on D: drive. .EXAMPLE Search-MftFile -SearchTerm "config" -Type File -Extension ".xml" Searches for XML files with "config" in the name. .EXAMPLE Search-MftFile -SearchTerm "users\admin" -SearchPath Searches for files whose full path contains "users\admin". .EXAMPLE Search-MftFile -SearchTerm "notepad" -ComputerName "Server01" Searches for "notepad" on a remote computer. .EXAMPLE "Server01","Server02" | Search-MftFile -SearchTerm "backup" -DriveLetter D Pipeline input for multiple remote computers. .EXAMPLE Search-MftFile -SearchTerm ".tmp" | Where-Object { $_.SizeMB -gt 100 } | Format-Table FileName, SizeFormatted, FullPath Find large temp files over 100 MB. .NOTES Requires Administrator privileges (raw disk access). Requires NTFS formatted drives. Requires PowerShell Remoting on remote targets. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$SearchTerm, [Parameter()] [ValidatePattern('^[A-Za-z]$')] [string]$DriveLetter = 'C', [Parameter()] [switch]$SearchPath, [Parameter()] [switch]$CaseSensitive, [Parameter()] [ValidateSet('File', 'Directory', 'All')] [string]$Type = 'All', [Parameter()] [string]$Extension, [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('CN', 'Computer', 'Name')] [string[]]$ComputerName, [Parameter()] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential ) begin { $DriveLetter = $DriveLetter.ToUpper() $timer = [System.Diagnostics.Stopwatch]::StartNew() # The full script block to execute locally or remotely $searchBlock = { param($src, $drive, $term, $caseSens, $searchP) Add-Type -TypeDefinition $src $results = [MftFileSearcher]::Search($drive, $term, $caseSens, $searchP) return $results } $allResults = [System.Collections.Generic.List[object]]::new() } process { # Determine targets if (-not $ComputerName) { $targets = @($env:COMPUTERNAME) $isLocal = $true } else { $targets = $ComputerName $isLocal = $false } foreach ($target in $targets) { $computerLabel = $target.ToUpper() try { if ($isLocal -or $target -eq $env:COMPUTERNAME -or $target -eq 'localhost' -or $target -eq '.') { # Local execution Write-Verbose "Searching MFT on local computer ($computerLabel) drive $DriveLetter`:..." $results = [MftFileSearcher]::Search($DriveLetter, $SearchTerm, [bool]$CaseSensitive, [bool]$SearchPath) } else { # Remote execution Write-Verbose "Searching MFT on remote computer $computerLabel drive $DriveLetter`:..." $sessionParams = @{ ComputerName = $target ErrorAction = 'Stop' } if ($Credential) { $sessionParams['Credential'] = $Credential } $session = New-PSSession @sessionParams try { $results = Invoke-Command -Session $session -ScriptBlock $searchBlock -ArgumentList @( $MftFileSearcherSource, $DriveLetter, $SearchTerm, [bool]$CaseSensitive, [bool]$SearchPath ) } finally { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } } foreach ($r in $results) { $r.ComputerName = $computerLabel # Apply type filter if ($Type -eq 'File' -and $r.IsDirectory) { continue } if ($Type -eq 'Directory' -and -not $r.IsDirectory) { continue } # Apply extension filter if ($Extension) { $ext = $Extension if (-not $ext.StartsWith('.')) { $ext = ".$ext" } if (-not $r.Extension.Equals($ext, [System.StringComparison]::OrdinalIgnoreCase)) { continue } } $r.PSObject.TypeNames.Insert(0, 'MftFileSearch.SearchResult') $allResults.Add($r) } } catch { Write-Error "Failed to search $computerLabel`: $_" } } } end { $timer.Stop() Write-Verbose "Found $($allResults.Count) result(s) in $($timer.Elapsed.TotalSeconds.ToString('F2'))s" # Default display format $defaultDisplaySet = [System.Management.Automation.PSPropertySet]::new( 'DefaultDisplayPropertySet', [string[]]@('FileName', 'SizeFormatted', 'Type', 'FullPath') ) $memberInfo = [System.Management.Automation.PSMemberInfo[]]@($defaultDisplaySet) foreach ($result in $allResults) { $result | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $memberInfo -Force $result } } } # Export Export-ModuleMember -Function Search-MftFile |