Skip to content

Commit dd125aa

Browse files
committed
Add NTDS filesystem
1 parent 3c0d8ef commit dd125aa

File tree

3 files changed

+166
-26
lines changed

3 files changed

+166
-26
lines changed

dissect/target/filesystem.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,3 +1852,4 @@ def open_multi_volume(fhs: list[BinaryIO], *args, **kwargs) -> Iterator[Filesyst
18521852
register("cpio", "CpioFilesystem")
18531853
register("vbk", "VbkFilesystem")
18541854
register("ad1", "AD1Filesystem")
1855+
register("ntds", "NtdsFilesystem")

dissect/target/filesystems/ntds.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
import stat
4+
from io import BytesIO
5+
from typing import TYPE_CHECKING, BinaryIO
6+
7+
from dissect.database.ese import ESE
8+
from dissect.database.ese.c_ese import ulDAEMagic
9+
from dissect.database.ese.ntds import NTDS, Object
10+
11+
from dissect.target.exceptions import FileNotFoundError, NotASymlinkError
12+
from dissect.target.filesystem import DirEntry, Filesystem, FilesystemEntry
13+
from dissect.target.helpers import fsutil
14+
15+
if TYPE_CHECKING:
16+
from collections.abc import Iterator
17+
18+
19+
class NtdsFilesystem(Filesystem):
20+
"""Filesystem implementation for NTDS.dit files. Because we can."""
21+
22+
__type__ = "ntds"
23+
24+
def __init__(self, fh: BinaryIO, *args, **kwargs):
25+
self.ntds = NTDS(fh)
26+
super().__init__(fh, *args, case_sensitive=False, **kwargs)
27+
28+
@staticmethod
29+
def _detect(fh: BinaryIO) -> bool:
30+
buf = fh.read(512)
31+
if int.from_bytes(buf[4:8], "little") == ulDAEMagic:
32+
ese = ESE(fh)
33+
tables = [table.name for table in ese.tables()]
34+
return "datatable" in tables and "link_table" in tables
35+
return False
36+
37+
def get(self, path: str) -> NtdsFilesystemEntry:
38+
return NtdsFilesystemEntry(self, path, self._get_object(path))
39+
40+
def _get_object(self, path: str, root: Object | None = None) -> Object:
41+
obj = root or self.ntds.root()
42+
43+
for part in path.split("/"):
44+
if not part or part == ".":
45+
continue
46+
47+
if part == "..":
48+
if obj.parent() is None:
49+
continue
50+
obj = obj.parent()
51+
else:
52+
for child in obj.children():
53+
if child.name == part:
54+
obj = child
55+
break
56+
else:
57+
raise FileNotFoundError(f"Path not found: {path}")
58+
59+
return obj
60+
61+
62+
class NtdsDirEntry(DirEntry):
63+
entry: Object
64+
65+
def get(self) -> NtdsFilesystemEntry:
66+
return NtdsFilesystemEntry(self.fs, self.path, self.entry)
67+
68+
def is_dir(self, *, follow_symlinks: bool = True) -> bool:
69+
return next(self.entry.children(), None) is not None
70+
71+
def is_file(self, *, follow_symlinks: bool = True) -> bool:
72+
return True
73+
74+
def is_symlink(self) -> bool:
75+
return False
76+
77+
def stat(self, *, follow_symlinks: bool = True) -> fsutil.stat_result:
78+
return self.get().stat(follow_symlinks=follow_symlinks)
79+
80+
81+
class NtdsFilesystemEntry(FilesystemEntry):
82+
fs: NtdsFilesystem
83+
entry: Object
84+
85+
def get(self, path: str) -> NtdsFilesystemEntry:
86+
return NtdsFilesystemEntry(
87+
self.fs,
88+
fsutil.join(self.path, path, alt_separator=self.fs.alt_separator),
89+
self.fs._get_object(path, self.entry),
90+
)
91+
92+
def open(self) -> BinaryIO:
93+
info = "\n".join(f"{key}: {value}" for key, value in self.entry.as_dict().items())
94+
return BytesIO(info.encode())
95+
96+
def scandir(self) -> Iterator[NtdsDirEntry]:
97+
for child in self.entry.children():
98+
yield NtdsDirEntry(self.fs, self.path, child.name, child)
99+
100+
def is_dir(self, follow_symlinks: bool = True) -> bool:
101+
return next(self.entry.children(), None) is not None
102+
103+
def is_file(self, follow_symlinks: bool = True) -> bool:
104+
return True
105+
106+
def is_symlink(self) -> bool:
107+
return False
108+
109+
def readlink(self) -> str:
110+
raise NotASymlinkError(self.path)
111+
112+
def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
113+
return self.lstat()
114+
115+
def lstat(self) -> fsutil.stat_result:
116+
mode = stat.S_IFDIR if self.is_dir() else stat.S_IFREG
117+
118+
# mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime
119+
return fsutil.stat_result(
120+
[
121+
mode | 0o777,
122+
self.entry.dnt,
123+
id(self.fs),
124+
1,
125+
0,
126+
0,
127+
0,
128+
0,
129+
self.entry.when_changed.timestamp() if self.entry.when_changed else 0,
130+
self.entry.when_created.timestamp() if self.entry.when_created else 0,
131+
]
132+
)

dissect/target/tools/shell.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -691,10 +691,17 @@ def resolve_glob_path(self, path: str) -> Iterator[fsutil.TargetPath]:
691691

692692
def check_file(self, path: str) -> fsutil.TargetPath | None:
693693
path = self.resolve_path(path)
694+
694695
if not path.exists():
695696
print(f"{path}: No such file")
696697
return None
697698

699+
# Check a special case where a path can be both a file and directory (e.g. NTDS.dit)
700+
# We need to check this on the entry, as the path methods can't detect this because of how stat.S_IS* works
701+
entry = path.get()
702+
if entry.is_file() and entry.is_dir():
703+
return path
704+
698705
if path.is_dir():
699706
print(f"{path}: Is a directory")
700707
return None
@@ -930,21 +937,22 @@ def cmd_attr(self, args: argparse.Namespace, stdout: TextIO) -> bool:
930937
@arg("path")
931938
def cmd_file(self, args: argparse.Namespace, stdout: TextIO) -> bool:
932939
"""determine file type"""
933-
934-
path = self.check_file(args.path)
935-
if not path:
940+
if not (path := self.check_file(args.path)):
936941
return False
937942

938-
fh = path.open()
939-
940-
# We could just alias this to cat <path> | file -, but this is slow for large files
941-
# This way we can explicitly limit to just 512 bytes
942943
p = subprocess.Popen(["file", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
943-
p.stdin.write(fh.read(512))
944+
945+
with path.open() as fh:
946+
# We could just alias this to cat <path> | file -, but this is slow for large files
947+
# This way we can explicitly limit to just 512 bytes
948+
p.stdin.write(fh.read(512))
949+
944950
p.stdin.close()
945951
p.wait()
952+
946953
filetype = p.stdout.read().decode().split(":", 1)[1].strip()
947954
print(f"{path}: {filetype}", file=stdout)
955+
948956
return False
949957

950958
@arg("path", nargs="+")
@@ -1094,12 +1102,11 @@ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
10941102

10951103
stdout = stdout.buffer
10961104
for path in paths:
1097-
path = self.check_file(path)
1098-
if not path:
1105+
if not (path := self.check_file(path)):
10991106
continue
11001107

1101-
fh = path.open()
1102-
shutil.copyfileobj(fh, stdout)
1108+
with path.open() as fh:
1109+
shutil.copyfileobj(fh, stdout)
11031110
stdout.flush()
11041111
print()
11051112
return False
@@ -1115,12 +1122,11 @@ def cmd_zcat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
11151122

11161123
stdout = stdout.buffer
11171124
for path in paths:
1118-
path = self.check_file(path)
1119-
if not path:
1125+
if not (path := self.check_file(path)):
11201126
continue
11211127

1122-
fh = fsutil.open_decompress(path)
1123-
shutil.copyfileobj(fh, stdout)
1128+
with fsutil.open_decompress(path) as fh:
1129+
shutil.copyfileobj(fh, stdout)
11241130
stdout.flush()
11251131

11261132
return False
@@ -1157,36 +1163,38 @@ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
11571163
@alias("shasum")
11581164
def cmd_hash(self, args: argparse.Namespace, stdout: TextIO) -> bool:
11591165
"""print the MD5, SHA1 and SHA256 hashes of a file"""
1160-
path = self.check_file(args.path)
1161-
if not path:
1166+
if not (path := self.check_file(args.path)):
11621167
return False
11631168

11641169
md5, sha1, sha256 = path.get().hash()
11651170
print(f"MD5:\t{md5}\nSHA1:\t{sha1}\nSHA256:\t{sha256}", file=stdout)
1171+
11661172
return False
11671173

11681174
@arg("path")
11691175
@alias("head")
11701176
@alias("more")
11711177
def cmd_less(self, args: argparse.Namespace, stdout: TextIO) -> bool:
11721178
"""open the first 10 MB of a file with less"""
1173-
path = self.check_file(args.path)
1174-
if not path:
1179+
if not (path := self.check_file(args.path)):
11751180
return False
11761181

1177-
pydoc.pager(path.open("rt", errors="ignore").read(10 * 1024 * 1024))
1182+
with path.open("rt", errors="ignore") as fh:
1183+
pydoc.pager(fh.read(10 * 1024 * 1024))
1184+
11781185
return False
11791186

11801187
@arg("path")
11811188
@alias("zhead")
11821189
@alias("zmore")
11831190
def cmd_zless(self, args: argparse.Namespace, stdout: TextIO) -> bool:
11841191
"""open the first 10 MB of a compressed file with zless"""
1185-
path = self.check_file(args.path)
1186-
if not path:
1192+
if not (path := self.check_file(args.path)):
11871193
return False
11881194

1189-
pydoc.pager(fsutil.open_decompress(path, "rt").read(10 * 1024 * 1024))
1195+
with fsutil.open_decompress(path, "rt") as fh:
1196+
pydoc.pager(fh.read(10 * 1024 * 1024))
1197+
11901198
return False
11911199

11921200
@arg("path", nargs="+")
@@ -1212,8 +1220,7 @@ def cmd_registry(self, args: argparse.Namespace, stdout: TextIO) -> bool:
12121220

12131221
clikey = "registry"
12141222
if args.path:
1215-
path = self.check_file(args.path)
1216-
if not path:
1223+
if not (path := self.check_file(args.path)):
12171224
return False
12181225

12191226
hive = regutil.RegfHive(path)

0 commit comments

Comments
 (0)