Skip to content

Commit

Permalink
Handle hardlink or regular file for /etc/localtime (fox-it#367)
Browse files Browse the repository at this point in the history
A symlink is not always guaranteed for /etc/localtime.
This adds extra handling for hardlinks and regular files.

Issue found via: fox-it/citrix-netscaler-triage#4

Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com>
  • Loading branch information
2 people authored and Zawadidone committed Apr 5, 2024
1 parent cafc4fb commit 5bc6b6c
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 8 deletions.
37 changes: 31 additions & 6 deletions dissect/target/plugins/os/unix/locale.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

from dissect.target.helpers.localeutil import normalize_language
from dissect.target.helpers.record import TargetRecordDescriptor
from dissect.target.plugin import Plugin, export
Expand All @@ -14,6 +16,15 @@
)


def timezone_from_path(path: Path) -> str:
"""Return timezone name for given zoneinfo path.
/usr/share/zoneinfo/Europe/Amsterdam -> Europe/Amsterdam
"""
zoneinfo_path = str(path).split("/")
return "/".join(zoneinfo_path[-2:])


class LocalePlugin(Plugin):
def check_compatible(self):
pass
Expand All @@ -28,13 +39,27 @@ def timezone(self):
for line in path.open("rt"):
return line.strip()

# /etc/localtime should be a symlink to
# /etc/localtime can be a symlink, hardlink or a copy of:
# eg. /usr/share/zoneinfo/America/New_York
# on centos and some other distros
if (zoneinfo := self.target.fs.path("/etc/localtime")).exists():
zoneinfo_path = str(zoneinfo.readlink()).split("/")
timezone = "/".join(zoneinfo_path[-2:])
return timezone
p_localtime = self.target.fs.path("/etc/localtime")

# If it's a symlink, read the path of the symlink
if p_localtime.is_symlink():
return timezone_from_path(p_localtime.readlink())

# If it's a hardlink, try finding the hardlinked zoneinfo file
if p_localtime.exists() and p_localtime.stat().st_nlink > 1:
for path in self.target.fs.path("/usr/share/zoneinfo").rglob("*"):
if p_localtime.samefile(path):
return timezone_from_path(path)

# If it's a regular file (probably copied), we try finding it by matching size and sha1 hash.
if p_localtime.is_file():
size = p_localtime.stat().st_size
sha1 = p_localtime.get().sha1()
for path in self.target.fs.path("/usr/share/zoneinfo").rglob("*"):
if path.is_file() and path.stat().st_size == size and path.get().sha1() == sha1:
return timezone_from_path(path)

@export(property=True)
def language(self):
Expand Down
52 changes: 50 additions & 2 deletions tests/test_plugins_os_unix_locale.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from io import BytesIO
from unittest.mock import patch

from dissect.target.filesystem import (
VirtualDirectory,
VirtualFile,
VirtualFilesystem,
VirtualSymlink,
)
from dissect.target.plugins.os.unix.locale import LocalePlugin as UnixLocalePlugin
from dissect.target.target import Target

from ._utils import absolute_path


def test_locale_plugin_unix(target_unix_users, fs_unix):
def test_locale_plugin_unix(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
# Locale locations originate from Ubuntu 20.
fs_unix.map_file_fh("/etc/timezone", BytesIO(b"Europe/Amsterdam"))
fs_unix.map_file_fh("/etc/default/locale", BytesIO(b"LANG=en_US.UTF-8"))
Expand All @@ -23,9 +31,49 @@ def test_locale_plugin_unix(target_unix_users, fs_unix):
assert keyboard[0].backspace == "guess"


def test_locale_plugin_unix_quotes(target_unix_users, fs_unix):
def test_locale_plugin_unix_quotes(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
# Older Fedora system
fs_unix.map_file_fh("/etc/default/locale", BytesIO(b'LANG="en_US.UTF-8"'))
target_unix_users.add_plugin(UnixLocalePlugin)

assert target_unix_users.language == ["en_US"]


def test_locale_etc_localtime_symlink(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
fs_unix.symlink("/usr/share/zoneinfo/Europe/Amsterdam", "/etc/localtime")
target_unix_users.add_plugin(UnixLocalePlugin)

assert isinstance(fs_unix.get("/etc/localtime"), VirtualSymlink)
assert target_unix_users.timezone == "Europe/Amsterdam"


def test_locale_etc_localtime_hardlink(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
fs_unix.map_file_fh("/usr/share/zoneinfo/Europe/Amsterdam", BytesIO(b"contents of Europe/Amsterdam"))
fs_unix.link("/usr/share/zoneinfo/Europe/Amsterdam", "/etc/localtime")
target_unix_users.add_plugin(UnixLocalePlugin)

assert isinstance(fs_unix.get("/etc/localtime"), VirtualFile)
assert fs_unix.path("/etc/localtime").samefile(fs_unix.path("/usr/share/zoneinfo/Europe/Amsterdam"))

entry = fs_unix.get("/etc/localtime")
stat = entry.stat()
stat.st_nlink = 2

with patch.object(entry, "stat", return_value=stat):
assert entry.stat().st_nlink == 2
assert target_unix_users.timezone == "Europe/Amsterdam"


def test_locale_etc_localtime_regular_file(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
fs_unix.map_file_fh("/etc/localtime", BytesIO(b"contents of Europe/Amsterdam"))
fs_unix.map_file_fh("/usr/share/zoneinfo/UTC", BytesIO(b"contents of UTC"))
fs_unix.map_file_fh("/usr/share/zoneinfo/Europe/Amsterdam", BytesIO(b"contents of Europe/Amsterdam"))
fs_unix.map_file_fh("/usr/share/zoneinfo/America/New_York", BytesIO(b"contents of America/New_York"))
target_unix_users.add_plugin(UnixLocalePlugin)

assert isinstance(fs_unix.get("/etc/localtime"), VirtualFile)
assert isinstance(fs_unix.get("/usr/share/zoneinfo/UTC"), VirtualFile)
assert isinstance(fs_unix.get("/usr/share/zoneinfo/Europe"), VirtualDirectory)
assert isinstance(fs_unix.get("/usr/share/zoneinfo/Europe/Amsterdam"), VirtualFile)
assert isinstance(fs_unix.get("/usr/share/zoneinfo/America/New_York"), VirtualFile)
assert target_unix_users.timezone == "Europe/Amsterdam"

0 comments on commit 5bc6b6c

Please sign in to comment.