Skip to content

Commit 69c908a

Browse files
authored
Add xattr, suid and capability functionality to WalkFsPlugin (#1144)
1 parent 7560eee commit 69c908a

File tree

4 files changed

+181
-53
lines changed

4 files changed

+181
-53
lines changed

dissect/target/plugins/filesystem/unix/capability.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
if TYPE_CHECKING:
1212
from collections.abc import Iterator
1313

14+
from dissect.target.filesystem import FilesystemEntry
15+
from dissect.target.target import Target
16+
1417
CapabilityRecord = TargetRecordDescriptor(
1518
"filesystem/unix/capability",
1619
[
17-
("datetime", "ts_mtime"),
20+
("datetime", "mtime"),
1821
("path", "path"),
1922
("string[]", "permitted"),
2023
("string[]", "inheritable"),
@@ -104,30 +107,33 @@ def capability_binaries(self) -> Iterator[CapabilityRecord]:
104107
for entry in self.target.fs.recurse("/"):
105108
if not entry.is_file() or entry.is_symlink():
106109
continue
107-
108-
try:
109-
attrs = [attr for attr in entry.lattr() if attr.name == "security.capability"]
110-
except Exception as e:
111-
self.target.log.warning("Failed to get attrs for entry %s", entry)
112-
self.target.log.debug("", exc_info=e)
113-
continue
114-
115-
for attr in attrs:
116-
try:
117-
permitted, inheritable, effective, root_id = parse_attr(attr.value)
118-
except ValueError as e:
119-
self.target.log.warning("Could not parse attributes for entry %s: %s", entry, str(e.value))
120-
self.target.log.debug("", exc_info=e)
121-
122-
yield CapabilityRecord(
123-
ts_mtime=entry.lstat().st_mtime,
124-
path=self.target.fs.path(entry.path),
125-
permitted=permitted,
126-
inheritable=inheritable,
127-
effective=effective,
128-
root_id=root_id,
129-
_target=self.target,
130-
)
110+
yield from parse_entry(entry, self.target)
111+
112+
113+
def parse_entry(entry: FilesystemEntry, target: Target) -> Iterator[CapabilityRecord]:
114+
try:
115+
attrs = [attr for attr in entry.lattr() if attr.name == "security.capability"]
116+
except Exception as e:
117+
target.log.warning("Failed to get attrs for entry %s", entry)
118+
target.log.debug("", exc_info=e)
119+
return
120+
121+
for attr in attrs:
122+
try:
123+
permitted, inheritable, effective, root_id = parse_attr(attr.value)
124+
except ValueError as e:
125+
target.log.warning("Could not parse attributes for entry %s: %s", entry, str(e.value))
126+
target.log.debug("", exc_info=e)
127+
128+
yield CapabilityRecord(
129+
mtime=entry.lstat().st_mtime,
130+
path=target.fs.path(entry.path),
131+
permitted=permitted,
132+
inheritable=inheritable,
133+
effective=effective,
134+
root_id=root_id,
135+
_target=target,
136+
)
131137

132138

133139
def parse_attr(attr: bytes) -> tuple[list[str], list[str], bool, int]:
Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import stat
34
from typing import TYPE_CHECKING
45

56
from dissect.util.ts import from_unix
@@ -8,6 +9,7 @@
89
from dissect.target.filesystem import FilesystemEntry, LayerFilesystemEntry
910
from dissect.target.helpers.record import TargetRecordDescriptor
1011
from dissect.target.plugin import Plugin, arg, export
12+
from dissect.target.plugins.filesystem.unix.capability import parse_entry as parse_capability_entry
1113

1214
if TYPE_CHECKING:
1315
from collections.abc import Iterator
@@ -27,12 +29,14 @@
2729
("uint32", "mode"),
2830
("uint32", "uid"),
2931
("uint32", "gid"),
30-
("string[]", "fstypes"),
32+
("boolean", "is_suid"),
33+
("string[]", "attr"),
34+
("string[]", "fs_types"),
3135
],
3236
)
3337

3438

35-
class WalkFSPlugin(Plugin):
39+
class WalkFsPlugin(Plugin):
3640
"""Filesystem agnostic walkfs plugin."""
3741

3842
def check_compatible(self) -> None:
@@ -41,8 +45,39 @@ def check_compatible(self) -> None:
4145

4246
@export(record=FilesystemRecord)
4347
@arg("--walkfs-path", default="/", help="path to recursively walk")
44-
def walkfs(self, walkfs_path: str = "/") -> Iterator[FilesystemRecord]:
45-
"""Walk a target's filesystem and return all filesystem entries."""
48+
@arg("--capability", action="store_true", help="output capability records")
49+
def walkfs(self, walkfs_path: str = "/", capability: bool = False) -> Iterator[FilesystemRecord]:
50+
"""Walk a target's filesystem and return all filesystem entries.
51+
52+
References:
53+
- https://man7.org/linux/man-pages/man2/lstat.2.html
54+
- https://man7.org/linux/man-pages/man7/inode.7.html
55+
- https://man7.org/linux/man-pages/man7/xattr.7.html
56+
- https://man7.org/linux/man-pages/man2/execve.2.html
57+
- https://steflan-security.com/linux-privilege-escalation-suid-binaries
58+
- https://github.com/torvalds/linux/blob/master/include/uapi/linux/capability.h
59+
60+
Yields FilesystemRecords for every filesystem entry and CapabilityRecords if ``xattr`` security
61+
attributes were found in the filesystem entry and the ``--capability`` flag is set.
62+
63+
.. code-block:: text
64+
65+
hostname (string): The target hostname.
66+
domain (string): The target domain.
67+
mtime (datetime): modified timestamp indicates the last time the contents of a file were modified.
68+
atime (datetime): access timestamp indicates the last time a file was accessed.
69+
ctime (datetime): changed timestamp indicates the last time metadata of a file was modified.
70+
btime (datetime): birth timestamp indicates the time when a file was created.
71+
ino (varint): number of the corresponding underlying filesystem inode.
72+
path (path): path location of the entry.
73+
size (filesize): size of the file in bytes on the filesystem.
74+
mode (uint32): contains the file type and mode.
75+
uid (uint32): the user id of the owner of the entry.
76+
gid (uint32): the group id of the owner of the entry.
77+
is_suid (boolean): denotes if the entry has the set-user-id bit set.
78+
attr (string[]): list of key-value pair attributes separated by '='.
79+
fs_types (string[]): list of filesystem type(s) of the entry.
80+
"""
4681

4782
path = self.target.fs.path(walkfs_path)
4883

@@ -56,45 +91,64 @@ def walkfs(self, walkfs_path: str = "/") -> Iterator[FilesystemRecord]:
5691

5792
for entry in self.target.fs.recurse(walkfs_path):
5893
try:
59-
yield generate_record(self.target, entry)
94+
yield from generate_record(self.target, entry, capability)
6095

6196
except FileNotFoundError as e: # noqa: PERF203
6297
self.target.log.warning("File not found: %s", entry)
6398
self.target.log.debug("", exc_info=e)
6499
except Exception as e:
65-
self.target.log.warning("Exception generating record for: %s", entry)
100+
self.target.log.warning("Exception generating walkfs record for %s: %s", entry, e)
66101
self.target.log.debug("", exc_info=e)
67102
continue
68103

69104

70-
def generate_record(target: Target, entry: FilesystemEntry) -> FilesystemRecord:
71-
"""Generate a :class:`FilesystemRecord` from the given :class:`FilesystemEntry`.
105+
def generate_record(target: Target, entry: FilesystemEntry, capability: bool) -> Iterator[FilesystemRecord]:
106+
"""Generate a :class:`WalkFsRecord` from the given :class:`FilesystemEntry`.
72107
73108
Args:
74109
target: :class:`Target` instance
75110
entry: :class:`FilesystemEntry` instance
76111
77112
Returns:
78-
Generated :class:`FilesystemRecord` for the given :class:`FilesystemEntry`.
113+
Generator of :class:`FilesystemRecord` for the given :class:`FilesystemEntry`.
79114
"""
80-
stat = entry.lstat()
115+
entry_stat = entry.lstat()
81116

82117
if isinstance(entry, LayerFilesystemEntry):
83118
fs_types = [sub_entry.fs.__type__ for sub_entry in entry.entries]
84119
else:
85120
fs_types = [entry.fs.__type__]
86121

87-
return FilesystemRecord(
88-
atime=from_unix(stat.st_atime),
89-
mtime=from_unix(stat.st_mtime),
90-
ctime=from_unix(stat.st_ctime),
91-
btime=from_unix(stat.st_birthtime) if stat.st_birthtime else None,
92-
ino=stat.st_ino,
93-
path=entry.path,
94-
size=stat.st_size,
95-
mode=stat.st_mode,
96-
uid=stat.st_uid,
97-
gid=stat.st_gid,
98-
fstypes=fs_types,
122+
fields = {
123+
"atime": from_unix(entry_stat.st_atime),
124+
"mtime": from_unix(entry_stat.st_mtime),
125+
"ctime": from_unix(entry_stat.st_ctime),
126+
"btime": from_unix(entry_stat.st_birthtime) if entry_stat.st_birthtime else None,
127+
"ino": entry_stat.st_ino,
128+
"path": entry.path,
129+
"size": entry_stat.st_size,
130+
"mode": entry_stat.st_mode,
131+
"uid": entry_stat.st_uid,
132+
"gid": entry_stat.st_gid,
133+
"is_suid": bool(entry_stat.st_mode & stat.S_ISUID),
134+
"fs_types": fs_types,
135+
}
136+
137+
try:
138+
fields["attr"] = [f"{attr.name}={attr.value.hex()}" for attr in entry.lattr()]
139+
140+
except (TypeError, AttributeError, NotImplementedError):
141+
# Suppress lattr calls on VirtualDirectory entries, filesystems without implemented attr's and NTFS attr's.
142+
pass
143+
144+
except Exception as e:
145+
target.log.warning("Unable to expand xattr for entry %s: %s", entry.path, e)
146+
target.log.debug("", exc_info=e)
147+
148+
yield FilesystemRecord(
149+
**fields,
99150
_target=target,
100151
)
152+
153+
if capability and fields.get("attr"):
154+
yield from parse_capability_entry(entry, target)

tests/plugins/filesystem/test_walkfs.py

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

3+
import stat
34
from pathlib import Path
45
from typing import TYPE_CHECKING
6+
from unittest.mock import Mock
57

68
import pytest
79

810
from dissect.target.filesystem import VirtualFile, VirtualFilesystem
11+
from dissect.target.helpers import fsutil
912
from dissect.target.loaders.tar import TarLoader
10-
from dissect.target.plugins.filesystem.walkfs import WalkFSPlugin
13+
from dissect.target.plugins.filesystem.walkfs import WalkFsPlugin
1114
from tests._utils import absolute_path
1215

1316
if TYPE_CHECKING:
@@ -24,11 +27,11 @@ def test_walkfs_plugin(target_unix: Target, fs_unix: VirtualFilesystem) -> None:
2427
fs_unix.map_file_entry("/.test/test.txt", VirtualFile(fs_unix, "test.txt", None))
2528
fs_unix.map_file_entry("/.test/.more.test.txt", VirtualFile(fs_unix, ".more.test.txt", None))
2629

27-
target_unix.add_plugin(WalkFSPlugin)
30+
target_unix.add_plugin(WalkFsPlugin)
2831

29-
results = list(target_unix.walkfs())
32+
results = sorted(target_unix.walkfs(), key=lambda r: r.path)
3033
assert len(results) == 14
31-
assert sorted([r.path for r in results]) == [
34+
assert [r.path for r in results] == [
3235
"/",
3336
"/.test",
3437
"/.test/.more.test.txt",
@@ -54,6 +57,71 @@ def test_benchmark_walkfs(target_bare: Target, benchmark: BenchmarkFixture) -> N
5457
loader.map(target_bare)
5558
target_bare.apply()
5659

57-
result = benchmark(lambda: next(WalkFSPlugin(target_bare).walkfs()))
60+
result = benchmark(lambda: next(WalkFsPlugin(target_bare).walkfs()))
5861

5962
assert result.path == "/"
63+
64+
65+
def test_walkfs_suid(target_unix: Target, fs_unix: VirtualFilesystem) -> None:
66+
"""Test if we detect a SUID binary correctly in the WalkFS plugin."""
67+
68+
vfile = VirtualFile(fs_unix, "binary", None)
69+
vfile.lstat = Mock()
70+
vfile.lstat.return_value = fsutil.stat_result([stat.S_IFREG | stat.S_ISUID, 0, 0, 0, 0, 0, 0, 0, 0, 0])
71+
fs_unix.map_file_entry("/path/to/suid/binary", vfile)
72+
73+
target_unix.add_plugin(WalkFsPlugin)
74+
75+
results = list(target_unix.walkfs())
76+
assert len(results) == 7
77+
78+
assert results[-1].path == "/path/to/suid/binary"
79+
assert results[-1].fs_types == ["virtual"]
80+
assert results[-1].mode == 34816
81+
assert results[-1].is_suid
82+
83+
84+
def test_walkfs_xattr(target_unix: Target, fs_unix: VirtualFilesystem) -> None:
85+
"""Test if we parse xattrs correctly in the WalkFS plugin."""
86+
87+
xattr1 = Mock()
88+
xattr1.name = "security.capability"
89+
xattr1.value = bytes.fromhex("010000010020f00000f00f0f")
90+
91+
xattr2 = Mock()
92+
xattr2.name = "example.attr"
93+
xattr2.value = b"some value"
94+
95+
vfile1 = VirtualFile(fs_unix, "file", None)
96+
vfile1.lattr = Mock()
97+
vfile1.lattr.return_value = [xattr1, xattr2]
98+
vfile1.fs.__type__ = "extfs"
99+
fs_unix.map_file_entry("/path/to/xattr1/file", vfile1)
100+
101+
target_unix.add_plugin(WalkFsPlugin)
102+
103+
results = list(target_unix.walkfs(capability=True))
104+
assert len(results) == 8
105+
106+
assert results[-2].path == "/path/to/xattr1/file"
107+
assert results[-2].fs_types == ["extfs"]
108+
assert results[-2].attr == ["security.capability=010000010020f00000f00f0f", "example.attr=736f6d652076616c7565"]
109+
110+
assert results[-1].mtime
111+
assert results[-1].permitted == ["CAP_NET_RAW", "CAP_SYS_PACCT", "CAP_SYS_ADMIN", "CAP_SYS_BOOT", "CAP_SYS_NICE"]
112+
assert results[-1].inheritable == [
113+
"CAP_NET_ADMIN",
114+
"CAP_NET_RAW",
115+
"CAP_IPC_LOCK",
116+
"CAP_IPC_OWNER",
117+
"CAP_SYS_MODULE",
118+
"CAP_SYS_RAWIO",
119+
"CAP_SYS_CHROOT",
120+
"CAP_SYS_PTRACE",
121+
"CAP_SYS_RESOURCE",
122+
"CAP_SYS_TIME",
123+
"CAP_SYS_TTY_CONFIG",
124+
"CAP_MKNOD",
125+
]
126+
assert results[-1].effective
127+
assert results[-1].root_id is None

tests/tools/test_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ def test_record_stream_write_exception_handling(
426426
with patch("dissect.target.tools.query.record_output", return_value=None):
427427
target_query()
428428

429-
assert "Exception occurred while processing output of WalkFSPlugin.walkfs:" in caplog.text
429+
assert "Exception occurred while processing output of WalkFsPlugin.walkfs:" in caplog.text
430430

431431

432432
@patch("dissect.target.plugin.PLUGINS", new_callable=PluginRegistry)

0 commit comments

Comments
 (0)