Files
alexisbalbachan 9f2bf6b58c Ntfs read fixes (#2106)
* Fixed files not being listed when in INDEX_ROOT

* Added support for sparse files

* Fix double VNC shift, causing incorrect reads.
handle walk() returning None
null checks in do_cat
error handling in getINode
general refactor

* added tests for ntfs-read

---------

Co-authored-by: Kali <adrian.manrique@gmail.com>
2026-03-05 10:42:52 -03:00

1458 lines
49 KiB
Python
Executable File

#!/usr/bin/env python
# Impacket - Collection of Python classes for working with network protocols.
#
# Copyright Fortra, LLC and its affiliated companies
#
# All rights reserved.
#
# This software is provided under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Description:
# Mini shell for browsing an NTFS volume
#
# Author:
# Alberto Solino (@agsolino)
#
# Reference for:
# Structure. Quick and dirty implementation.. just for fun.. ;)
#
# NOTE: Lots of info (mainly the structs) taken from the NTFS-3G project..
#
# ToDo:
# [] Support compressed, encrypted files
#
from __future__ import division
from __future__ import print_function
import os
import sys
import logging
import struct
import argparse
import cmd
import ntpath
from six import PY2, text_type
from datetime import datetime
from impacket.examples import logger
from impacket import version
from impacket.structure import Structure, hexdump
# Reserved/fixed MFTs
FIXED_MFTS = 16
# Attribute types
UNUSED = 0
STANDARD_INFORMATION = 0x10
ATTRIBUTE_LIST = 0x20
FILE_NAME = 0x30
OBJECT_ID = 0x40
SECURITY_DESCRIPTOR = 0x50
VOLUME_NAME = 0x60
VOLUME_INFORMATION = 0x70
DATA = 0x80
INDEX_ROOT = 0x90
INDEX_ALLOCATION = 0xa0
BITMAP = 0xb0
REPARSE_POINT = 0xc0
EA_INFORMATION = 0xd0
EA = 0xe0
PROPERTY_SET = 0xf0
LOGGED_UTILITY_STREAM = 0x100
FIRST_USER_DEFINED_ATTRIBUTE = 0x1000
END = 0xffffffff
# Attribute flags
ATTR_IS_COMPRESSED = 0x0001
ATTR_COMPRESSION_MASK = 0x00ff
ATTR_IS_ENCRYPTED = 0x4000
ATTR_IS_SPARSE = 0x8000
# FileName type flags
FILE_NAME_POSIX = 0x00
FILE_NAME_WIN32 = 0x01
FILE_NAME_DOS = 0x02
FILE_NAME_WIN32_AND_DOS = 0x03
# MFT Record flags
MFT_RECORD_IN_USE = 0x0001
MFT_RECORD_IS_DIRECTORY = 0x0002
MFT_RECORD_IS_4 = 0x0004
MFT_RECORD_IS_VIEW_INDEX = 0x0008
MFT_REC_SPACE_FILLER = 0xfffff
# File Attribute Flags
FILE_ATTR_READONLY = 0x0001
FILE_ATTR_HIDDEN = 0x0002
FILE_ATTR_SYSTEM = 0x0004
FILE_ATTR_DIRECTORY = 0x0010
FILE_ATTR_ARCHIVE = 0x0020
FILE_ATTR_DEVICE = 0x0040
FILE_ATTR_NORMAL = 0x0080
FILE_ATTR_TEMPORARY = 0x0100
FILE_ATTR_SPARSE_FILE = 0x0200
FILE_ATTR_REPARSE_POINT = 0x0400
FILE_ATTR_COMPRESSED = 0x0800
FILE_ATTR_OFFLINE = 0x1000
FILE_ATTR_NOT_CONTENT_INDEXED = 0x2000
FILE_ATTR_ENCRYPTED = 0x4000
FILE_ATTR_VALID_FLAGS = 0x7fb7
FILE_ATTR_VALID_SET_FLAGS = 0x31a7
FILE_ATTR_I30_INDEX_PRESENT = 0x10000000
FILE_ATTR_VIEW_INDEX_PRESENT = 0x20000000
# NTFS System files
FILE_MFT = 0
FILE_MFTMirr = 1
FILE_LogFile = 2
FILE_Volume = 3
FILE_AttrDef = 4
FILE_Root = 5
FILE_Bitmap = 6
FILE_Boot = 7
FILE_BadClus = 8
FILE_Secure = 9
FILE_UpCase = 10
FILE_Extend = 11
# Index Header Flags
SMALL_INDEX = 0
LARGE_INDEX = 1
LEAF_NODE = 0
INDEX_NODE = 1
NODE_MASK = 0
# Index Entry Flags
INDEX_ENTRY_NODE = 1
INDEX_ENTRY_END = 2
INDEX_ENTRY_SPACE_FILLER = 0xffff
class NTFS_BPB(Structure):
structure = (
('BytesPerSector','<H=0'),
('SectorsPerCluster','B=0'),
('ReservedSectors','<H=0'),
('Reserved','3s=b""'),
('Reserved2','2s=b""'),
('MediaDescription','B=0'),
('Reserved3','2s=b""'),
('Reserved4','<H=0'),
('Reserved5','<H=0'),
('Reserved6','<L=0'),
('Reserved7','4s=b""'),
)
class NTFS_EXTENDED_BPB(Structure):
structure = (
('Reserved','4s=b""'),
('TotalSectors','<Q=0'),
('MFTClusterNumber','<Q=0'),
('MFTMirrClusterNumber','<Q=0'),
('ClusterPerFileRecord','b=0'),
('Reserved2','3s=b""'),
('ClusterPerIndexBuffer','<b=0'),
('Reserved3','3s=b""'),
('VolumeSerialNumber','8s=b""'),
('CheckSum','4s=b""'),
)
class NTFS_BOOT_SECTOR(Structure):
structure = (
('JmpInstr','3s=b""'),
('OEM_ID','8s=b""'),
('BPB','25s=b""'),
('ExtendedBPB','48s=b""'),
('Bootstrap','426s=b""'),
('EOS','<H=0'),
)
class NTFS_MFT_RECORD(Structure):
structure = (
('MagicLabel','4s=b""'),
('USROffset','<H=0'), # Update Sequence Records Offset
('USRSize','<H=0'), # Update Sequence Records Size
('LogSeqNum','<Q=0'),
('SeqNum','<H=0'),
('LinkCount','<H=0'),
('AttributesOffset','<H=0'),
('Flags','<H=0'),
('BytesInUse','<L=0'),
('BytesAllocated','<L=0'),
('BaseMftRecord','<Q=0'),
('NextAttrInstance','<H=0'),
('Reserved','<H=0'),
('RecordNumber','<L=0'),
)
class NTFS_ATTRIBUTE_RECORD(Structure):
commonHdr = (
('Type','<L=0'),
('Length','<L=0'),
('NonResident','B=0'),
('NameLength','B=0'),
('NameOffset','<H=0'),
('Flags','<H=0'),
('Instance','<H=0'),
)
structure = ()
class NTFS_ATTRIBUTE_RECORD_NON_RESIDENT(Structure):
structure = (
('LowestVCN','<Q=0'),
('HighestVCN','<Q=0'),
('DataRunsOffset','<H=0'),
('CompressionUnit','<H=0'),
('Reserved1','4s=""'),
('AllocatedSize','<Q=0'),
('DataSize','<Q=0'),
('InitializedSize','<Q=0'),
# ('CompressedSize','<Q=0'),
)
class NTFS_ATTRIBUTE_RECORD_RESIDENT(Structure):
structure = (
('ValueLen','<L=0'),
('ValueOffset','<H=0'),
('Flags','B=0'),
('Reserved','B=0'),
)
class NTFS_FILE_NAME_ATTR(Structure):
structure = (
('ParentDirectory','<Q=0'),
('CreationTime','<Q=0'),
('LastDataChangeTime','<Q=0'),
('LastMftChangeTime','<Q=0'),
('LastAccessTime','<Q=0'),
('AllocatedSize','<Q=0'),
('DataSize','<Q=0'),
('FileAttributes','<L=0'),
('EaSize','<L=0'),
('FileNameLen','B=0'),
('FileNameType','B=0'),
('_FileName','_-FileName','self["FileNameLen"]*2'),
('FileName',':'),
)
class NTFS_STANDARD_INFORMATION(Structure):
structure = (
('CreationTime','<Q=0'),
('LastDataChangeTime','<Q=0'),
('LastMftChangeTime','<Q=0'),
('LastAccessTime','<Q=0'),
('FileAttributes','<L=0'),
)
class NTFS_INDEX_HEADER(Structure):
structure = (
('EntriesOffset','<L=0'),
('IndexLength','<L=0'),
('AllocatedSize','<L=0'),
('Flags','B=0'),
('Reserved','3s=b""'),
)
class NTFS_INDEX_ROOT(Structure):
structure = (
('Type','<L=0'),
('CollationRule','<L=0'),
('IndexBlockSize','<L=0'),
('ClustersPerIndexBlock','B=0'),
('Reserved','3s=b""'),
('Index',':',NTFS_INDEX_HEADER),
)
class NTFS_INDEX_ALLOCATION(Structure):
structure = (
('Magic','4s=b""'),
('USROffset','<H=0'), # Update Sequence Records Offset
('USRSize','<H=0'), # Update Sequence Records Size
('Lsn','<Q=0'),
('IndexVcn','<Q=0'),
('Index',':',NTFS_INDEX_HEADER),
)
class NTFS_INDEX_ENTRY_HEADER(Structure):
structure = (
('IndexedFile','<Q=0'),
('Length','<H=0'),
('KeyLength','<H=0'),
('Flags','<H=0'),
('Reserved','<H=0'),
)
class NTFS_INDEX_ENTRY(Structure):
alignment = 8
structure = (
('EntryHeader',':',NTFS_INDEX_ENTRY_HEADER),
('_Key','_-Key','self["EntryHeader"]["KeyLength"]'),
('Key',':'),
('_Vcn','_-Vcn','(self["EntryHeader"]["Flags"] & 1)*8'),
('Vcn',':')
)
class NTFS_DATA_RUN(Structure):
structure = (
('LCN','<q=0'),
('Clusters','<Q=0'),
('StartVCN','<Q=0'),
('LastVCN','<Q=0'),
)
class NTFS_ATTRIBUTE_LIST_ENTRY(Structure):
structure = (
('AttributeType', '<L=0'),
('EntryLength', '<H=0'),
('AttributeNameLength', 'B=0'),
('AttributeNameOffset', 'B=0'),
('StartingVCN', '<Q=0'),
('BaseFileRecord', '<Q=0'),
('AttributeID', '<H=0'),
)
def getUnixTime(t):
t -= 116444736000000000
t //= 10000000
return t
class Attribute:
def __init__(self, iNode, data):
self.AttributeName = None
self.NTFSVolume = iNode.NTFSVolume
self.AttributeHeader = NTFS_ATTRIBUTE_RECORD(data)
if self.AttributeHeader['NameLength'] > 0 and self.AttributeHeader['Type'] != END:
self.AttributeName = data[self.AttributeHeader['NameOffset']:][:self.AttributeHeader['NameLength']*2].decode('utf-16le')
def getFlags(self):
return self.AttributeHeader['Flags']
def getName(self):
return self.AttributeName
def isNonResident(self):
return self.AttributeHeader['NonResident']
def dump(self):
return self.AttributeHeader.dump()
def getTotalSize(self):
return self.AttributeHeader['Length']
def getType(self):
return self.AttributeHeader['Type']
class AttributeResident(Attribute):
def __init__(self, iNode, data):
logging.debug("Inside AttributeResident: iNode: %s" % iNode.INodeNumber)
Attribute.__init__(self,iNode,data)
self.ResidentHeader = NTFS_ATTRIBUTE_RECORD_RESIDENT(data[len(self.AttributeHeader):])
self.AttrValue = data[self.ResidentHeader['ValueOffset']:][:self.ResidentHeader['ValueLen']]
def dump(self):
return self.ResidentHeader.dump()
def getFlags(self):
return self.ResidentHeader['Flags']
def getValue(self):
return self.AttrValue
def read(self,offset,length):
logging.debug("Inside Read: offset: %d, length: %d" %(offset,length))
return self.AttrValue[offset:][:length]
def getDataSize(self):
return len(self.AttrValue)
class AttributeNonResident(Attribute):
def __init__(self, iNode, data):
logging.debug("Inside AttributeNonResident: iNode: %s" % iNode.INodeNumber)
Attribute.__init__(self,iNode,data)
self._raw_attr_data = data
self.NonResidentHeader = NTFS_ATTRIBUTE_RECORD_NON_RESIDENT(data[len(self.AttributeHeader):])
self.AttrValue = data[self.NonResidentHeader['DataRunsOffset']:][:self.NonResidentHeader['AllocatedSize']]
self.DataRuns = []
self.ClusterSize = 0
# Effective sizes for OS-like reads; default to on-disk header values.
self.data_size = self.NonResidentHeader['DataSize']
self.initialized_size = self.NonResidentHeader['InitializedSize']
self.parseDataRuns()
def dump(self):
return self.NonResidentHeader.dump()
def getDataSize(self):
return self.data_size
def getValue(self):
return None
def parseDataRuns(self):
"""Parse data run descriptors from the attribute value."""
data = self.AttrValue
if data is None:
return
vcn = 0
prevLcn = 0 # LCN is delta-encoded, track previous
while data and data[0:1] != b'\x00':
if len(data) < 1:
break
header = data[0]
data = data[1:]
lengthBytes = header & 0x0F
offsetBytes = header >> 4
# Parse cluster count (length)
if len(data) < lengthBytes:
break
clusterCount = struct.unpack('<Q', data[:lengthBytes].ljust(8, b'\x00'))[0]
data = data[lengthBytes:]
dr = NTFS_DATA_RUN()
dr['Clusters'] = clusterCount
dr['StartVCN'] = vcn
dr['LastVCN'] = vcn + clusterCount - 1
if offsetBytes == 0:
# Sparse run - no physical location
dr['LCN'] = -1
logging.debug("Sparse run: VCN %d-%d, clusters %d", dr['StartVCN'], dr['LastVCN'], clusterCount)
else:
# Parse LCN offset (signed, delta from previous)
if len(data) < offsetBytes:
break
offsetData = data[:offsetBytes]
# Sign extend
if offsetData[-1] & 0x80:
offsetData = offsetData.ljust(8, b'\xff')
else:
offsetData = offsetData.ljust(8, b'\x00')
lcnDelta = struct.unpack('<q', offsetData)[0]
data = data[offsetBytes:]
prevLcn += lcnDelta
dr['LCN'] = prevLcn
self.DataRuns.append(dr)
vcn += clusterCount
def readClusters(self, clusters, lcn):
logging.debug("Inside ReadClusters: clusters:%d, lcn:%d" % (clusters,lcn))
if lcn == -1:
return b'\x00'*clusters*self.ClusterSize
self.NTFSVolume.volumeFD.seek(lcn*self.ClusterSize,0)
buf = self.NTFSVolume.volumeFD.read(clusters*self.ClusterSize)
while len(buf) < clusters*self.ClusterSize:
buf+= self.NTFSVolume.volumeFD.read((clusters*self.ClusterSize)-len(buf))
if len(buf) == 0:
return None
return buf
def readVCN(self, vcn, numOfClusters):
"""Read clusters starting at VCN, spanning multiple data runs if needed."""
logging.debug("Inside ReadVCN: vcn: %d, numOfClusters: %d" % (vcn, numOfClusters))
buf = b''
clustersLeft = numOfClusters
for dr in self.DataRuns:
if clustersLeft <= 0:
break
# Skip data runs before our target VCN
if vcn > dr['LastVCN']:
continue
# Stop if we've passed all relevant data runs
if vcn < dr['StartVCN']:
break
# Calculate how many clusters to read from this data run
clustersInRun = dr['LastVCN'] - vcn + 1
clustersToRead = min(clustersLeft, clustersInRun)
# For sparse runs, LCN is -1; readClusters handles this
if dr['LCN'] == -1:
lcn = -1
else:
lcn = dr['LCN'] + (vcn - dr['StartVCN'])
tmpBuf = self.readClusters(clustersToRead, lcn)
if tmpBuf is None:
break
buf += tmpBuf
clustersLeft -= clustersToRead
vcn += clustersToRead
return buf
def read(self, offset, length):
"""Read bytes from non-resident attribute, respecting data_size and initialized_size."""
logging.debug("Inside Read: offset: %d, length: %d" % (offset, length))
# Clamp read to data_size (EOF)
if offset >= self.data_size:
return b''
length = min(length, self.data_size - offset)
self.ClusterSize = self.NTFSVolume.BPB['BytesPerSector'] * self.NTFSVolume.BPB['SectorsPerCluster']
buf = b''
curOffset = offset
bytesLeft = length
while bytesLeft > 0:
vcn = curOffset // self.ClusterSize
vcnOffset = curOffset % self.ClusterSize
# Calculate clusters needed for remaining bytes
bytesInFirstCluster = self.ClusterSize - vcnOffset
if bytesLeft <= bytesInFirstCluster:
clustersToRead = 1
else:
clustersToRead = 1 + ((bytesLeft - bytesInFirstCluster + self.ClusterSize - 1) // self.ClusterSize)
clusterData = self.readVCN(vcn, clustersToRead)
if not clusterData:
break
# Extract the portion we need
chunk = clusterData[vcnOffset:vcnOffset + bytesLeft]
buf += chunk
curOffset += len(chunk)
bytesLeft -= len(chunk)
if len(chunk) == 0:
break
if not buf:
return None
# Zero-fill beyond InitializedSize (OS behavior for uninitialized data)
if self.initialized_size < offset + len(buf):
validBytes = max(0, self.initialized_size - offset)
buf = buf[:validBytes] + (b'\x00' * (len(buf) - validBytes))
return buf
class NonResidentDataAttribute(AttributeNonResident):
@classmethod
def _shift_runs(cls, attr, start_vcn):
if start_vcn <= 0:
return
for dr in attr.DataRuns:
dr['StartVCN'] += start_vcn
dr['LastVCN'] += start_vcn
@classmethod
def _base_sizes(cls, attr):
return attr.NonResidentHeader['DataSize'], attr.NonResidentHeader['InitializedSize']
@classmethod
def _ensure_base(cls, collected, base_attr, base_data_size, base_initialized_size):
if base_attr is not None:
return base_attr, base_data_size, base_initialized_size
_, base_attr = collected[0]
base_data_size, base_initialized_size = cls._base_sizes(base_attr)
return base_attr, base_data_size, base_initialized_size
@classmethod
def _collect_extents(cls, iNode, matches, attribute_name):
"""Collect $DATA attributes from extent records, identifying the base extent."""
collected = []
base_attr = None
base_data_size = None
base_initialized_size = None
for entry in matches:
extension_inode = iNode.NTFSVolume.getINode(entry.MftRecordNumber)
attr = extension_inode.searchAttribute(DATA, attribute_name)
if attr is None:
continue
collected.append((entry, attr))
# Base extent has StartingVCN == 0 and contains authoritative sizes
if entry.StartingVCN == 0:
base_attr = attr
if isinstance(attr, AttributeNonResident):
base_data_size, base_initialized_size = cls._base_sizes(attr)
return collected, base_attr, base_data_size, base_initialized_size
def __init__(self, iNode, entries, attribute_name=None):
"""
Build a unified $DATA stream from multiple attribute list entries.
Args:
iNode: Owning inode for volume access
entries: AttributeListEntry items for the target $DATA stream
attribute_name: Stream name (None for default $DATA)
"""
matches = list(entries)
if not matches:
raise ValueError('No $DATA extents found')
# Sort extents by StartingVCN to define logical order
matches.sort(key=lambda e: e.StartingVCN)
# Collect attributes and find base extent (StartingVCN == 0)
collected, base_attr, base_data_size, base_initialized_size = self._collect_extents(
iNode, matches, attribute_name,
)
if not collected:
raise ValueError('No usable $DATA extents found')
# Ensure we have valid base sizes
base_attr, base_data_size, base_initialized_size = self._ensure_base(
collected, base_attr, base_data_size, base_initialized_size,
)
# Initialize from base extent
super(NonResidentDataAttribute, self).__init__(iNode, base_attr._raw_attr_data)
if len(collected) == 1:
# Single extent - apply VCN offset if needed
entry, _ = collected[0]
self._shift_runs(self, entry.StartingVCN)
else:
# Multi-extent - merge all runs with proper VCN offsets
self._merge_extents_from_collected(collected, base_data_size, base_initialized_size)
def _merge_extents_from_collected(self, collected, data_size, init_size):
"""Merge data runs from multiple extents into unified stream."""
merged_runs = []
for entry, attr in collected:
if not isinstance(attr, AttributeNonResident):
continue
for dr in attr.DataRuns:
new_dr = NTFS_DATA_RUN()
new_dr['LCN'] = dr['LCN']
new_dr['Clusters'] = dr['Clusters']
# Apply VCN offset from attribute list entry
new_dr['StartVCN'] = dr['StartVCN'] + entry.StartingVCN
new_dr['LastVCN'] = dr['LastVCN'] + entry.StartingVCN
merged_runs.append(new_dr)
merged_runs.sort(key=lambda dr: dr['StartVCN'])
self.DataRuns = merged_runs
self.data_size = data_size
self.initialized_size = init_size
class AttributeStandardInfo:
def __init__(self, attribute):
logging.debug("Inside AttributeStandardInfo")
self.Attribute = attribute
self.StandardInfo = NTFS_STANDARD_INFORMATION(self.Attribute.AttrValue)
def getFileAttributes(self):
return self.StandardInfo['FileAttributes']
def getFileTime(self):
if self.StandardInfo['LastDataChangeTime'] > 0:
return datetime.fromtimestamp(getUnixTime(self.StandardInfo['LastDataChangeTime']))
else:
return 0
def dump(self):
return self.StandardInfo.dump()
class AttributeFileName:
def __init__(self, attribute):
logging.debug("Inside AttributeFileName")
self.Attribute = attribute
self.FileNameRecord = NTFS_FILE_NAME_ATTR(self.Attribute.AttrValue)
def getFileNameType(self):
return self.FileNameRecord['FileNameType']
def getFileAttributes(self):
return self.FileNameRecord['FileAttributes']
def getFileName(self):
return self.FileNameRecord['FileName'].decode('utf-16le')
def getFileSize(self):
return self.FileNameRecord['DataSize']
def getFlags(self):
return self.FileNameRecord['FileAttributes']
def dump(self):
return self.FileNameRecord.dump()
class AttributeIndexAllocation:
def __init__(self, attribute):
logging.debug("Inside AttributeIndexAllocation")
self.Attribute = attribute
def dump(self):
print(self.Attribute.dump())
for i in self.Attribute.DataRuns:
print(i.dump())
def read(self, offset, length):
return self.Attribute.read(offset, length)
class AttributeIndexRoot:
def __init__(self, attribute):
logging.debug("Inside AttributeIndexRoot")
self.Attribute = attribute
self.IndexRootRecord = NTFS_INDEX_ROOT(attribute.AttrValue)
self.IndexEntries = []
self.parseIndexEntries()
def parseIndexEntries(self):
data = self.Attribute.AttrValue[len(self.IndexRootRecord):]
while True:
ie = IndexEntry(data)
self.IndexEntries.append(ie)
if ie.isLastNode():
break
data = data[ie.getSize():]
def dump(self):
self.IndexRootRecord.dump()
for i in self.IndexEntries:
i.dump()
def getType(self):
return self.IndexRootRecord['Type']
class IndexEntry:
def __init__(self, entry):
self.entry = NTFS_INDEX_ENTRY(entry)
def isSubNode(self):
return self.entry['EntryHeader']['Flags'] & INDEX_ENTRY_NODE
def isLastNode(self):
return self.entry['EntryHeader']['Flags'] & INDEX_ENTRY_END
def getVCN(self):
return struct.unpack('<Q', self.entry['Vcn'])[0]
def getSize(self):
return len(self.entry)
def getKey(self):
return self.entry['Key']
def getINodeNumber(self):
return self.entry['EntryHeader']['IndexedFile'] & 0x0000FFFFFFFFFFFF
def dump(self):
self.entry.dump()
class AttributeListEntry:
def __init__(self, entry_data):
self.EntryHeader = NTFS_ATTRIBUTE_LIST_ENTRY(entry_data)
self.AttributeType = self.EntryHeader['AttributeType']
self.EntryLength = self.EntryHeader['EntryLength']
self.StartingVCN = self.EntryHeader['StartingVCN']
self.AttributeID = self.EntryHeader['AttributeID']
raw_record = self.EntryHeader['BaseFileRecord']
self.MftRecordNumber = raw_record & 0x0000FFFFFFFFFFFF
self.MftSequenceNumber = (raw_record >> 48) & 0xFFFF
self.AttributeName = None
name_len = self.EntryHeader['AttributeNameLength']
if name_len > 0:
name_offset = self.EntryHeader['AttributeNameOffset']
name_bytes = entry_data[name_offset : name_offset + (name_len * 2)]
self.AttributeName = name_bytes.decode('utf-16le')
class AttributeList:
"""Parses ATTRIBUTE_LIST attribute (can be resident or non-resident)."""
def __init__(self, attribute):
self.attribute = attribute
self.Entries = []
self._parseEntries()
def _parseEntries(self):
"""Parse attribute list entries from raw data."""
# getValue() works for resident, read() for non-resident
if hasattr(self.attribute, 'getValue') and self.attribute.getValue() is not None:
data = self.attribute.getValue()
else:
data = self.attribute.read(0, self.attribute.getDataSize())
if not data:
return
offset = 0
while offset < len(data):
entry_data = data[offset:]
if len(entry_data) < 26: # Minimum entry size
break
list_entry = AttributeListEntry(entry_data)
if list_entry.EntryLength == 0:
break
self.Entries.append(list_entry)
offset += list_entry.EntryLength
def getEntries(self):
return self.Entries
class INODE:
def __init__(self, NTFSVolume):
self.NTFSVolume = NTFSVolume
# This is the entire file record
self.INodeNumber = None
self.Attributes = {}
self.AttributesRaw = None
self.AttributesLastPos = None
# Some interesting Attributes to parse
self.FileAttributes = 0
self.LastDataChangeTime = None
self.FileName = None
self.FileSize = 0
# Debug counters for directory listings
self._walk_root_count = 0
self._walk_subnode_count = 0
def isDirectory(self):
return self.FileAttributes & FILE_ATTR_I30_INDEX_PRESENT
def isCompressed(self):
return self.FileAttributes & FILE_ATTR_COMPRESSED
def isEncrypted(self):
return self.FileAttributes & FILE_ATTR_ENCRYPTED
def isSparse(self):
return self.FileAttributes & FILE_ATTR_SPARSE_FILE
def displayName(self):
if self.LastDataChangeTime is not None and self.FileName is not None:
try:
# print "%d - %s %s %s " %( self.INodeNumber, self.getPrintableAttributes(), self.LastDataChangeTime.isoformat(' '), self.FileName)
print("%s %s %15d %s " %( self.getPrintableAttributes(), self.LastDataChangeTime.isoformat(' '), self.FileSize, self.FileName))
except Exception as e:
logging.error('Exception when trying to display inode %d: %s' % (self.INodeNumber,str(e)))
def getPrintableAttributes(self):
mask = ''
if self.FileAttributes & FILE_ATTR_I30_INDEX_PRESENT:
mask += 'd'
else:
mask += '-'
if self.FileAttributes & FILE_ATTR_HIDDEN:
mask += 'h'
else:
mask += '-'
if self.FileAttributes & FILE_ATTR_SYSTEM:
mask += 'S'
else:
mask += '-'
if self.isCompressed():
mask += 'C'
else:
mask += '-'
if self.isEncrypted():
mask += 'E'
else:
mask += '-'
if self.isSparse():
mask += 's'
else:
mask += '-'
return mask
def parseAttributes(self):
# Parse Standard Info
attr = self.searchAttribute(STANDARD_INFORMATION, None)
if attr is not None:
si = AttributeStandardInfo(attr)
self.Attributes[STANDARD_INFORMATION] = si
self.FileAttributes |= si.getFileAttributes()
self.LastDataChangeTime = si.getFileTime()
self.Attributes[STANDARD_INFORMATION] = si
# Parse Filename
attr = self.searchAttribute(FILE_NAME, None)
while attr is not None:
fn = AttributeFileName(attr)
if fn.getFileNameType() != FILE_NAME_DOS:
self.FileName = fn.getFileName()
self.FileSize = fn.getFileSize()
self.FileAttributes |= fn.getFileAttributes()
self.Attributes[FILE_NAME] = fn
break
attr = self.searchAttribute(FILE_NAME, None, True)
# Parse Attribute list before Index Allocation, because it might be there
attr = self.searchAttribute(ATTRIBUTE_LIST, None)
if attr is not None:
al = AttributeList(attr)
self.Attributes[ATTRIBUTE_LIST] = al
# Parse Index Allocation
attr = self.searchAttribute(INDEX_ALLOCATION, u'$I30')
if attr is not None:
ia = AttributeIndexAllocation(attr)
self.Attributes[INDEX_ALLOCATION] = ia
attr = self.searchAttribute(INDEX_ROOT, u'$I30')
if attr is not None:
ir = AttributeIndexRoot(attr)
self.Attributes[INDEX_ROOT] = ir
def searchAttribute(self, attributeType, attributeName, findNext = False):
logging.debug("Inside searchAttribute: type: 0x%x, name: %s" % (attributeType, attributeName))
record = None
if findNext is True:
data = self.AttributesLastPos
else:
data = self.AttributesRaw
while True:
if len(data) <= 8:
record = None
break
record = Attribute(self,data)
if record.getType() == END:
record = None
break
if record.getTotalSize() == 0:
record = None
break
if record.getType() == attributeType and record.getName() == attributeName:
if record.isNonResident() == 1:
record = AttributeNonResident(self, data)
else:
record = AttributeResident(self, data)
self.AttributesLastPos = data[record.getTotalSize():]
break
data = data[record.getTotalSize():]
# Look for attribute on Attribute List
if record is None and ATTRIBUTE_LIST in self.Attributes:
attr_list = self.Attributes[ATTRIBUTE_LIST]
if attributeType == DATA:
entries = [
entry for entry in attr_list.getEntries()
if entry.AttributeType == DATA and entry.AttributeName == attributeName
]
try:
return NonResidentDataAttribute(self, entries, attributeName)
except ValueError:
return None
for entry in attr_list.getEntries():
if entry.AttributeType == attributeType and entry.AttributeName == attributeName:
extension_inode = self.NTFSVolume.getINode(entry.MftRecordNumber)
return extension_inode.searchAttribute(attributeType, attributeName)
return record
def PerformFixUp(self, record, buf, numSectors):
# It fixes the sequence WORDS on every sector of a cluster
# FixUps are used by:
# FILE Records in the $MFT
# INDX Records in directories and other indexes
# RCRD Records in the $LogFile
# RSTR Records in the $LogFile
logging.debug("Inside PerformFixUp..." )
magicNum = struct.unpack('<H',buf[record['USROffset']:][:2])[0]
sequenceArray = buf[record['USROffset']+2:][:record['USRSize']*2]
dataList = list(buf)
index = 0
for i in range(0,numSectors*2, 2):
index += self.NTFSVolume.SectorSize-2
# Let's get the last two bytes of the sector
lastBytes = struct.unpack('<H', buf[index:][:2])[0]
# Is it the same as the magicNum?
if lastBytes != magicNum:
logging.error("Magic number 0x%x doesn't match with 0x%x" % (magicNum,lastBytes))
return None
# Now let's replace the original bytes
dataList[index] = sequenceArray[i]
dataList[index+1] = sequenceArray[i+1]
index += 2
if PY2:
return "".join(dataList)
else:
return bytes(dataList)
def parseIndexBlocks(self, vcn):
IndexEntries = []
#sectors = self.NTFSVolume.IndexBlockSize / self.NTFSVolume.SectorSize
if INDEX_ALLOCATION in self.Attributes:
ia = self.Attributes[INDEX_ALLOCATION]
data = ia.read(vcn*self.NTFSVolume.IndexBlockSize, self.NTFSVolume.IndexBlockSize)
if data:
iaRec = NTFS_INDEX_ALLOCATION(data)
sectorsPerIB = self.NTFSVolume.IndexBlockSize // self.NTFSVolume.SectorSize
data = self.PerformFixUp(iaRec, data, sectorsPerIB)
if data is None:
return []
data = data[len(iaRec)-len(NTFS_INDEX_HEADER())+iaRec['Index']['EntriesOffset']:]
while True:
ie = IndexEntry(data)
IndexEntries.append(ie)
if ie.isLastNode():
break
data = data[ie.getSize():]
return IndexEntries
def walkSubNodes(self, vcn):
logging.debug("Inside walkSubNodes: vcn %s" % vcn)
entries = self.parseIndexBlocks(vcn)
files = []
for entry in entries:
if entry.isSubNode():
files += self.walkSubNodes(entry.getVCN())
else:
if len(entry.getKey()) > 0 and entry.getINodeNumber() > 16:
fn = NTFS_FILE_NAME_ATTR(entry.getKey())
if fn['FileNameType'] != FILE_NAME_DOS:
#inode = INODE(self.NTFSVolume)
#inode.FileAttributes = fn['FileAttributes']
#inode.FileSize = fn['DataSize']
#inode.LastDataChangeTime = datetime.fromtimestamp(getUnixTime(fn['LastDataChangeTime']))
#inode.INodeNumber = entry.getINodeNumber()
#inode.FileName = fn['FileName'].decode('utf-16le')
#inode.displayName()
files.append(fn)
# if inode.FileAttributes & FILE_ATTR_I30_INDEX_PRESENT and entry.getINodeNumber() > 16:
# inode2 = self.NTFSVolume.getINode(entry.getINodeNumber())
# inode2.walk()
return files
def walk(self):
logging.debug("Inside Walk... ")
files = []
self._walk_root_count = 0
self._walk_subnode_count = 0
if INDEX_ROOT in self.Attributes:
ir = self.Attributes[INDEX_ROOT]
if ir.getType() & FILE_NAME:
for ie in ir.IndexEntries:
if ie.isSubNode():
logging.debug("walk: INDEX_ROOT entry points to subnode VCN %d", ie.getVCN())
sub_files = self.walkSubNodes(ie.getVCN())
self._walk_subnode_count += len(sub_files)
files += sub_files
else:
if len(ie.getKey()) > 0 and ie.getINodeNumber() > 16:
fn = NTFS_FILE_NAME_ATTR(ie.getKey())
if fn['FileNameType'] != FILE_NAME_DOS:
logging.debug("walk: INDEX_ROOT inline entry %s", fn['FileName'].decode('utf-16le'))
self._walk_root_count += 1
files.append(fn)
return files
else:
return None
def findFirstSubNode(self, vcn, toSearch):
def getFileName(entry):
if len(entry.getKey()) > 0 and entry.getINodeNumber() > 16:
fn = NTFS_FILE_NAME_ATTR(entry.getKey())
if fn['FileNameType'] != FILE_NAME_DOS:
return fn['FileName'].decode('utf-16le').upper()
return None
entries = self.parseIndexBlocks(vcn)
for ie in entries:
name = getFileName(ie)
if name is not None:
if name == toSearch:
# Found!
return ie
if toSearch < name:
if ie.isSubNode():
res = self.findFirstSubNode(ie.getVCN(), toSearch)
if res is not None:
return res
else:
# Bye bye.. not found
return None
else:
if ie.isSubNode():
res = self.findFirstSubNode(ie.getVCN(), toSearch)
if res is not None:
return res
def findFirst(self, fileName):
# Searches for a file and returns an Index Entry. None if not found
def getFileName(entry):
if len(entry.getKey()) > 0 and entry.getINodeNumber() > 16:
fn = NTFS_FILE_NAME_ATTR(entry.getKey())
if fn['FileNameType'] != FILE_NAME_DOS:
return fn['FileName'].decode('utf-16le').upper()
return None
toSearch = text_type(fileName.upper())
if INDEX_ROOT in self.Attributes:
ir = self.Attributes[INDEX_ROOT]
if ir.getType() & FILE_NAME or 1==1:
for ie in ir.IndexEntries:
name = getFileName(ie)
if name is not None:
if name == toSearch:
# Found!
return ie
if toSearch < name:
if ie.isSubNode():
res = self.findFirstSubNode(ie.getVCN(), toSearch)
if res is not None:
return res
else:
# Bye bye.. not found
return None
else:
if ie.isSubNode():
res = self.findFirstSubNode(ie.getVCN(), toSearch)
if res is not None:
return res
def getStream(self, name):
return self.searchAttribute( DATA, name, findNext = False)
class NTFS:
def __init__(self, volumeName):
self.__volumeName = volumeName
self.__bootSector = None
self.__MFTStart = None
self.volumeFD = None
self.BPB = None
self.ExtendedBPB = None
self.RecordSize = None
self.IndexBlockSize = None
self.SectorSize = None
self.MFTINode = None
self.mountVolume()
def mountVolume(self):
logging.debug("Mounting volume...")
self.volumeFD = open(self.__volumeName,"rb")
self.readBootSector()
self.MFTINode = self.getINode(FILE_MFT)
# Check whether MFT is fragmented
attr = self.MFTINode.searchAttribute(DATA, None)
if attr is None:
# It's not
del self.MFTINode
self.MFTINode = None
def readBootSector(self):
logging.debug("Reading Boot Sector for %s" % self.__volumeName)
self.volumeFD.seek(0,0)
data = self.volumeFD.read(512)
while len(data) < 512:
data += self.volumeFD.read(512)
self.__bootSector = NTFS_BOOT_SECTOR(data)
self.BPB = NTFS_BPB(self.__bootSector['BPB'])
self.ExtendedBPB = NTFS_EXTENDED_BPB(self.__bootSector['ExtendedBPB'])
self.SectorSize = self.BPB['BytesPerSector']
self.__MFTStart = self.BPB['BytesPerSector'] * self.BPB['SectorsPerCluster'] * self.ExtendedBPB['MFTClusterNumber']
if self.ExtendedBPB['ClusterPerFileRecord'] > 0:
self.RecordSize = self.BPB['BytesPerSector'] * self.BPB['SectorsPerCluster'] * self.ExtendedBPB['ClusterPerFileRecord']
else:
self.RecordSize = 1 << (-self.ExtendedBPB['ClusterPerFileRecord'])
if self.ExtendedBPB['ClusterPerIndexBuffer'] > 0:
self.IndexBlockSize = self.BPB['BytesPerSector'] * self.BPB['SectorsPerCluster'] * self.ExtendedBPB['ClusterPerIndexBuffer']
else:
self.IndexBlockSize = 1 << (-self.ExtendedBPB['ClusterPerIndexBuffer'])
logging.debug("MFT should start at position %d" % self.__MFTStart)
def getINode(self, iNodeNum):
logging.debug("Trying to fetch inode %d" % iNodeNum)
newINode = INODE(self)
recordLen = self.RecordSize
# Read MFT record from disk or through fragmented $MFT
if self.MFTINode and iNodeNum > FIXED_MFTS:
# Fragmented $MFT - read through MFT's $DATA attribute
attr = self.MFTINode.searchAttribute(DATA, None)
if attr is None:
logging.error("Cannot find MFT $DATA attribute for inode %d" % iNodeNum)
return newINode
record = attr.read(iNodeNum * self.RecordSize, self.RecordSize)
else:
diskPosition = self.__MFTStart + iNodeNum * self.RecordSize
self.volumeFD.seek(diskPosition, 0)
record = self.volumeFD.read(recordLen)
while len(record) < recordLen:
record += self.volumeFD.read(recordLen - len(record))
if not record or len(record) < recordLen:
logging.error("Failed to read MFT record for inode %d" % iNodeNum)
return newINode
mftRecord = NTFS_MFT_RECORD(record)
record = newINode.PerformFixUp(mftRecord, record, self.RecordSize // self.SectorSize)
if record is None:
logging.error("FixUp failed for inode %d" % iNodeNum)
return newINode
newINode.INodeNumber = iNodeNum
newINode.AttributesRaw = record[mftRecord['AttributesOffset'] - recordLen:]
newINode.parseAttributes()
return newINode
class MiniShell(cmd.Cmd):
def __init__(self, volume):
cmd.Cmd.__init__(self)
self.volumePath = volume
self.volume = NTFS(volume)
self.rootINode = self.volume.getINode(FILE_Root)
self.prompt = '\\>'
self.intro = 'Type help for list of commands'
self.currentINode = self.rootINode
self.completion = []
self.pwd = '\\'
self.do_ls('',False)
self.last_output = ''
def emptyline(self):
pass
def onecmd(self,s):
retVal = False
try:
retVal = cmd.Cmd.onecmd(self,s)
except Exception as e:
logging.debug('Exception:', exc_info=True)
logging.error(str(e))
return retVal
def do_exit(self,line):
return True
def do_shell(self, line):
output = os.popen(line).read()
print(output)
self.last_output = output
def do_help(self,line):
print("""
cd {path} - changes the current directory to {path}
pwd - shows current remote directory
ls - lists all the files in the current directory
lcd - change local directory
get {filename} - downloads the filename from the current path
cat {filename} - prints the contents of filename
hexdump {filename} - hexdumps the contents of filename
exit - terminates the server process (and this session)
""")
def do_lcd(self,line):
if line == '':
print(os.getcwd())
else:
os.chdir(line)
print(os.getcwd())
def do_cd(self, line):
p = line.replace('/','\\')
oldpwd = self.pwd
newPath = ntpath.normpath(ntpath.join(self.pwd,p))
if newPath == self.pwd:
# Nothing changed
return
common = ntpath.commonprefix([newPath,oldpwd])
if common == oldpwd:
res = self.findPathName(ntpath.normpath(p))
else:
res = self.findPathName(newPath)
if res is None:
logging.error("Directory not found")
self.pwd = oldpwd
return
if res.isDirectory() == 0:
logging.error("Not a directory!")
self.pwd = oldpwd
return
else:
self.currentINode = res
self.do_ls('', False)
self.pwd = ntpath.join(self.pwd,p)
self.pwd = ntpath.normpath(self.pwd)
self.prompt = self.pwd + '>'
def findPathName(self, pathName):
if pathName == '\\':
return self.rootINode
tmpINode = self.currentINode
parts = pathName.split('\\')
for part in parts:
if part == '':
tmpINode = self.rootINode
else:
res = tmpINode.findFirst(part)
if res is None:
return res
else:
tmpINode = self.volume.getINode(res.getINodeNumber())
return tmpINode
def do_pwd(self,line):
print(self.pwd)
def do_ls(self, line, display=True):
entries = self.currentINode.walk()
if entries is None:
entries = []
logging.debug(
"ls summary for %s: total=%d index_root=%d index_allocation=%d",
self.pwd,
len(entries),
self.currentINode._walk_root_count,
self.currentINode._walk_subnode_count,
)
self.completion = []
for entry in entries:
inode = INODE(self.volume)
inode.FileAttributes = entry['FileAttributes']
inode.FileSize = entry['DataSize']
inode.LastDataChangeTime = datetime.fromtimestamp(getUnixTime(entry['LastDataChangeTime']))
inode.FileName = entry['FileName'].decode('utf-16le')
if display is True:
inode.displayName()
self.completion.append((inode.FileName,inode.isDirectory()))
def complete_cd(self, text, line, begidx, endidx):
return self.complete_get(text, line, begidx, endidx, include = 2)
def complete_cat(self,text,line,begidx,endidx):
return self.complete_get(text, line, begidx, endidx)
def complete_hexdump(self,text,line,begidx,endidx):
return self.complete_get(text, line, begidx, endidx)
def complete_get(self, text, line, begidx, endidx, include = 1):
# include means
# 1 just files
# 2 just directories
items = []
if include == 1:
mask = 0
else:
mask = FILE_ATTR_I30_INDEX_PRESENT
for i in self.completion:
if i[1] == mask:
items.append(i[0])
if text:
return [
item for item in items
if item.upper().startswith(text.upper())
]
else:
return items
def do_hexdump(self,line):
return self.do_cat(line,command = hexdump)
def do_cat(self, line, command=None):
if command is None:
command = getattr(sys.stdout, 'buffer', sys.stdout).write
pathName = line.replace('/', '\\')
pathName = ntpath.normpath(ntpath.join(self.pwd, pathName))
res = self.findPathName(pathName)
if res is None:
logging.error("Not found!")
return
if res.isDirectory() > 0:
logging.error("It's a directory!")
return
if res.isCompressed() or res.isEncrypted():
logging.error('Cannot handle compressed/encrypted files! :(')
return
stream = res.getStream(None)
if stream is None:
logging.error("Cannot read file stream!")
return
dataSize = stream.getDataSize()
if dataSize == 0:
logging.info("0 bytes read (empty file)")
return
chunkSize = 4096 * 10
offset = 0
while offset < dataSize:
toRead = min(chunkSize, dataSize - offset)
buf = stream.read(offset, toRead)
if not buf:
break
try:
command(buf)
except (BrokenPipeError, OSError):
return
offset += len(buf)
logging.info("%d bytes read" % offset)
def do_get(self, line):
pathName = line.replace('/','\\')
pathName = ntpath.normpath(ntpath.join(self.pwd,pathName))
fh = open(ntpath.basename(pathName),"wb")
self.do_cat(line, command = fh.write)
fh.close()
def main():
print(version.BANNER)
parser = argparse.ArgumentParser(add_help = True, description = "NTFS explorer (read-only)")
parser.add_argument('volume', action='store', help='NTFS volume to open (e.g. \\\\.\\C: or /dev/disk1s1)')
parser.add_argument('-extract', action='store', help='extracts pathname (e.g. \\windows\\system32\\config\\sam)')
parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON')
parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output')
if len(sys.argv)==1:
parser.print_help()
sys.exit(1)
options = parser.parse_args()
# Init the example's logger theme
logger.init(options.ts, options.debug)
shell = MiniShell(options.volume)
if options.extract is not None:
shell.onecmd("get %s"% options.extract)
else:
shell.cmdloop()
if __name__ == '__main__':
main()
sys.exit(1)