Skip to content

Commit 71d1242

Browse files
committed
Refactor lastlog plugin and add support for SQLite3 format
1 parent ab0db26 commit 71d1242

File tree

3 files changed

+158
-40
lines changed

3 files changed

+158
-40
lines changed
Lines changed: 105 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, BinaryIO
3+
from dataclasses import asdict, dataclass
4+
from typing import TYPE_CHECKING
45

56
from dissect.cstruct import cstruct
6-
from dissect.util import ts
7+
from dissect.database.sqlite3 import SQLite3
8+
from dissect.util.ts import from_unix
79

8-
from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError
10+
from dissect.target.exceptions import UnsupportedPluginError
911
from dissect.target.helpers.record import TargetRecordDescriptor
1012
from dissect.target.plugin import Plugin, export
1113

1214
if TYPE_CHECKING:
1315
from collections.abc import Iterator
16+
from datetime import datetime
17+
from pathlib import Path
18+
19+
from dissect.target.target import Target
1420

1521
LastLogRecord = TargetRecordDescriptor(
1622
"linux/log/lastlog",
@@ -20,6 +26,8 @@
2026
("string", "ut_user"), # name
2127
("string", "ut_host"), # source
2228
("string", "ut_tty"), # port
29+
("string", "ut_service"),
30+
("path", "source"),
2331
],
2432
)
2533

@@ -44,56 +52,118 @@
4452
c_lastlog = cstruct().load(lastlog_def)
4553

4654

55+
@dataclass
56+
class LastLogEntry:
57+
ts: datetime
58+
uid: int
59+
ut_user: str | None
60+
ut_tty: str | None
61+
ut_host: str | None
62+
ut_service: str | None # lastlog2 specific
63+
64+
4765
class LastLogFile:
48-
def __init__(self, fh: BinaryIO):
49-
self.fh = fh
66+
"""Lastlog sparse file iterator.
67+
68+
References:
69+
- https://github.com/linux-pam/linux-pam/tree/master/modules/pam_lastlog
70+
"""
71+
72+
def __init__(self, path: Path, users: dict | None):
73+
self.path = path
74+
self.fh = path.open()
75+
self.users = users
5076

51-
def __iter__(self):
77+
def __iter__(self) -> Iterator[LastLogEntry]:
78+
idx = -1
5279
while True:
5380
try:
54-
yield c_lastlog.entry(self.fh)
55-
except EOFError: # noqa: PERF203
81+
idx += 1
82+
entry = c_lastlog.entry(self.fh)
83+
user = self.users.get(idx)
84+
85+
if entry.ll_time.tv_sec == 0:
86+
continue
87+
88+
yield LastLogEntry(
89+
ts=from_unix(entry.ll_time.tv_sec),
90+
uid=idx,
91+
ut_user=user,
92+
ut_tty=entry.ut_user.decode().strip("\x00") or None,
93+
ut_host=entry.ut_host.decode(errors="ignore").strip("\x00") or None,
94+
ut_service=None,
95+
)
96+
97+
except EOFError:
5698
break
5799

58100

101+
class LastLogDb:
102+
"""Lastlog2 database file iterator.
103+
104+
References:
105+
- https://github.com/util-linux/util-linux/tree/master/liblastlog2
106+
- https://github.com/util-linux/util-linux/tree/master/pam_lastlog2
107+
"""
108+
109+
def __init__(self, path: Path, users: dict | None):
110+
self.path = path
111+
self.db = SQLite3(path)
112+
self.users: dict[str, int] = {v: k for k, v in users.items()}
113+
114+
def __iter__(self) -> Iterator[LastLogEntry]:
115+
if not (table := self.db.table("Lastlog2")):
116+
return None
117+
118+
for row in table.rows():
119+
yield LastLogEntry(
120+
ts=from_unix(row.Time),
121+
uid=self.users.get(row.Name),
122+
ut_user=row.Name,
123+
ut_tty=row.TTY,
124+
ut_host=row.RemoteHost or None,
125+
ut_service=row.Service,
126+
)
127+
128+
59129
class LastLogPlugin(Plugin):
60-
"""Unix lastlog plugin."""
130+
"""UNIX lastlog plugin."""
131+
132+
def __init__(self, target: Target):
133+
super().__init__(target)
134+
135+
self.paths = list(self.target.fs.path("/").glob("var/log/lastlog*")) + list(
136+
self.target.fs.path("/").glob("var/lib/lastlog/lastlog2*")
137+
)
61138

62139
def check_compatible(self) -> None:
63-
lastlog = self.target.fs.path("/var/log/lastlog")
64-
if not lastlog.exists():
65-
raise UnsupportedPluginError("No lastlog file found")
140+
if not self.paths:
141+
raise UnsupportedPluginError("No lastlog file(s) found on target")
66142

67143
@export(record=LastLogRecord)
68144
def lastlog(self) -> Iterator[LastLogRecord]:
69-
"""Return last logins information from /var/log/lastlog.
145+
"""Return login information from ``/var/log/lastlog`` and ``/var/lib/lastlog/lastlog2.db`` files.
70146
71-
The lastlog file contains the most recent logins of all users on a Unix based operating system.
147+
Lastlog files contain the most recent logins of all users on a UNIX based operating system.
148+
149+
Newer UNIX distributions use ``lastlog2`` and ``liblastlog2`` which use SQLite3 database files.
72150
73151
References:
74152
- https://www.tutorialspoint.com/unix_commands/lastlog.htm
75153
"""
76-
try:
77-
lastlog = self.target.fs.open("/var/log/lastlog")
78-
except FileNotFoundError:
79-
return
80-
81-
users = {}
82-
for user in self.target.users():
83-
users[user.uid] = user.name
154+
seen = set()
155+
users: dict[int, str] = (
156+
{user.uid: user.name for user in self.target.users()} if self.target.has_function("users") else {}
157+
)
84158

85-
log = LastLogFile(lastlog)
86-
87-
for idx, entry in enumerate(log):
88-
# if ts=0 the uid has never logged in before
89-
if entry.ut_host.strip(b"\x00") == b"" or entry.ll_time.tv_sec == 0:
159+
for path in self.paths:
160+
if any(seen_path.samefile(path) for seen_path in seen):
90161
continue
91162

92-
yield LastLogRecord(
93-
ts=ts.from_unix(entry.ll_time.tv_sec),
94-
uid=idx,
95-
ut_user=users.get(idx),
96-
ut_tty=entry.ut_user.decode().strip("\x00"),
97-
ut_host=entry.ut_host.decode(errors="ignore").strip("\x00"),
98-
_target=self.target,
99-
)
163+
iterator = LastLogDb if path.suffix == ".db" else LastLogFile
164+
for entry in iterator(path, users):
165+
yield LastLogRecord(
166+
**asdict(entry),
167+
source=path,
168+
_target=self.target,
169+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:4ff088b93142ada2715928d9b5ac2c82a4b48cbb7bbd470bb0279c49960c7a1e
3+
size 12288

tests/plugins/os/unix/log/test_lastlog.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,62 @@
1111
from dissect.target.target import Target
1212

1313

14-
def test_lastlog_plugin(target_linux: Target, fs_linux: VirtualFilesystem) -> None:
14+
def test_lastlog_sparse(target_linux_users: Target, fs_linux: VirtualFilesystem) -> None:
15+
"""Test if we can correctly parse a sparse lastlog file."""
16+
1517
data_file = absolute_path("_data/plugins/os/unix/log/lastlog/lastlog")
1618
fs_linux.map_file("/var/log/lastlog", data_file)
1719

18-
target_linux.add_plugin(LastLogPlugin)
20+
target_linux_users.add_plugin(LastLogPlugin)
1921

20-
results = list(target_linux.lastlog())
21-
assert len(results) == 1
22+
results = sorted(target_linux_users.lastlog(), key=lambda r: r.ts)
23+
assert len(results) == 3
2224

2325
assert results[0].ts == datetime(2021, 12, 8, 16, 14, 6, tzinfo=timezone.utc)
2426
assert results[0].uid == 1001
25-
assert results[0].ut_user is None
27+
assert results[0].ut_user is None # since 1001 is not defined in target_linux_users /etc/passwd.
2628
assert results[0].ut_host == "127.0.0.1"
2729
assert results[0].ut_tty == "pts/0"
30+
assert results[0].source == "/var/log/lastlog"
31+
32+
assert results[1].ts == datetime(2021, 12, 8, 16, 20, 23, tzinfo=timezone.utc)
33+
assert results[1].uid == 0
34+
assert results[1].ut_user == "root"
35+
assert results[1].ut_host is None
36+
assert results[1].ut_tty == "tty1"
37+
assert results[1].source == "/var/log/lastlog"
38+
39+
assert results[2].ts == datetime(2022, 12, 7, 16, 33, 30, tzinfo=timezone.utc)
40+
assert results[2].uid == 1000
41+
assert results[2].ut_user == "user"
42+
assert results[2].ut_host is None
43+
assert results[2].ut_tty == "tty1"
44+
assert results[2].source == "/var/log/lastlog"
45+
46+
47+
def test_lastlog_sqlite(target_linux_users: Target, fs_linux: VirtualFilesystem) -> None:
48+
"""Test if we can parse a lastlog2 SQLite3 database."""
49+
50+
fs_linux.map_file("/var/lib/lastlog/lastlog2.db", absolute_path("_data/plugins/os/unix/log/lastlog/lastlog2.db"))
51+
52+
target_linux_users.add_plugin(LastLogPlugin)
53+
54+
records = sorted(target_linux_users.lastlog(), key=lambda r: r.ts)
55+
56+
assert len(records) == 2
57+
58+
assert records[0].ts == datetime(2026, 1, 28, 15, 2, 33, tzinfo=timezone.utc)
59+
assert records[0].uid == 0 # reverse lookup from /etc/passwd
60+
assert records[0].ut_user == "root"
61+
assert records[0].ut_host is None
62+
assert records[0].ut_tty == "pts/0"
63+
assert records[0].ut_service == "su"
64+
assert records[0].source == "/var/lib/lastlog/lastlog2.db"
65+
66+
assert records[1].ts == datetime(2026, 2, 9, 4, 49, 13, tzinfo=timezone.utc)
67+
assert records[1].uid == 1000 # reverse lookup from /etc/passwd
68+
assert records[1].ut_user == "user"
69+
assert records[1].ut_host == "127.0.0.1"
70+
assert records[1].ut_tty == "ssh"
71+
assert records[1].ut_service == "sshd"
72+
assert records[1].source == "/var/lib/lastlog/lastlog2.db"

0 commit comments

Comments
 (0)