From f6a498c475a8c1947696fec821f2a33407bd1a9b Mon Sep 17 00:00:00 2001 From: JSCU-CNI <121175071+JSCU-CNI@users.noreply.github.com> Date: Thu, 30 Jan 2025 15:05:42 +0100 Subject: [PATCH 1/4] refactor cronjob plugin --- dissect/target/plugins/os/unix/cronjobs.py | 164 +++++++++++++-------- tests/plugins/os/unix/test_cronjobs.py | 39 +++++ 2 files changed, 139 insertions(+), 64 deletions(-) create mode 100644 tests/plugins/os/unix/test_cronjobs.py diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py index c1e3a1056..11b768445 100644 --- a/dissect/target/plugins/os/unix/cronjobs.py +++ b/dissect/target/plugins/os/unix/cronjobs.py @@ -1,8 +1,10 @@ from __future__ import annotations import re -from typing import Iterator +from pathlib import Path +from typing import Iterator, get_args +from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, export @@ -29,82 +31,116 @@ ], ) +RE_CRONJOB = re.compile( + r""" + ^ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P.+) + $ + """, + re.VERBOSE, +) +RE_ENVVAR = re.compile( + r""" + ^ + ([a-zA-Z_]+[a-zA-Z[0-9_])=(.*) + """, + re.VERBOSE, +) + class CronjobPlugin(Plugin): """Unix cronjob plugin.""" + CRONTAB_DIRS = [ + "/var/cron/tabs", + "/var/spool/cron", + "/var/spool/cron/crontabs", + "/etc/cron.d", + "/usr/local/etc/cron.d", # FreeBSD + ] + + CRONTAB_FILES = [ + "/etc/crontab", + "/etc/anacrontab", + ] + + def __init__(self, target): + super().__init__(target) + self.crontabs = list(self.find_crontabs()) + def check_compatible(self) -> None: - pass + if not self.crontabs: + raise UnsupportedPluginError("No crontab(s) found on target") - def parse_crontab(self, file_path) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: - for line in file_path.open("rt"): - line = line.strip() - if line.startswith("#") or not len(line): + def find_crontabs(self) -> Iterator[Path]: + for crontab_dir in self.CRONTAB_DIRS: + if not (dir := self.target.fs.path(crontab_dir)).exists(): continue - match = re.search(r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$", line) - if match: - usr = match.group(6) - cmd = match.group(7) - if not str(file_path).startswith("/etc/crontab") or not str(file_path).startswith("/etc/cron.d"): - cmd = usr + " " + cmd - usr = "" - - yield CronjobRecord( - minute=match.group(1), - hour=match.group(2), - day=match.group(3), - month=match.group(4), - weekday=match.group(5), - user=usr, - command=cmd, - source=file_path, - _target=self.target, - ) - - s = re.search(r"^([a-zA-Z_]+[a-zA-Z[0-9_])=(.*)", line) - if s: - yield EnvironmentVariableRecord( - key=s.group(1), - value=s.group(2), - source=file_path, - _target=self.target, - ) - - @export(record=[CronjobRecord, EnvironmentVariableRecord]) + for file in dir.iterdir(): + if file.resolve().is_file(): + yield file + + for crontab_file in self.CRONTAB_FILES: + if (file := self.target.fs.path(crontab_file)).exists(): + yield file + + @export(record=get_args([CronjobRecord, EnvironmentVariableRecord])) def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: - """Yield cronjobs on the unix system. + """Yield cronjobs on a unix system. A cronjob is a scheduled task/command on a Unix based system. Adversaries may use cronjobs to gain persistence on the system. + + Resources: + - https://linux.die.net/man/8/cron + - https://linux.die.net/man/1/crontab + - https://linux.die.net/man/5/crontab + - https://en.wikipedia.org/wiki/Cron """ - tabs = [] - crontab_dirs = [ - "/var/cron/tabs", - "/var/spool/cron", - "/var/spool/cron/crontabs", - "/etc/cron.d", - "/usr/local/etc/cron.d", # FreeBSD - ] - for path in crontab_dirs: - fspath = self.target.fs.path(path) - if not fspath.exists(): - continue - for f in fspath.iterdir(): - if not f.exists(): + for file in self.crontabs: + for line in file.open("rt"): + line = line.strip() + if line.startswith("#") or not len(line): continue - if f.is_file(): - tabs.append(f) - - crontab_file = self.target.fs.path("/etc/crontab") - if crontab_file.exists(): - tabs.append(crontab_file) - - crontab_file = self.target.fs.path("/etc/anacrontab") - if crontab_file.exists(): - tabs.append(crontab_file) - for f in tabs: - for record in self.parse_crontab(f): - yield record + if match := RE_CRONJOB.search(line): + match = match.groupdict() + + # Cronjobs in user crontab files do not have a user field specified. + user = None + if file.is_relative_to("/var/spool/cron/crontabs"): + user = file.name + else: + user, match["command"] = re.split(r"\s", match["command"], maxsplit=1) + + match["command"] = match["command"].strip() + + yield CronjobRecord( + **match, + user=user, + source=file, + _target=self.target, + ) + + elif match := RE_ENVVAR.search(line): + yield EnvironmentVariableRecord( + key=match.group(1), + value=match.group(2), + source=file, + _target=self.target, + ) + + else: + self.target.log.warning("Unable to match cronjob line in %s: '%s'", file, line) diff --git a/tests/plugins/os/unix/test_cronjobs.py b/tests/plugins/os/unix/test_cronjobs.py new file mode 100644 index 000000000..468ea5f5a --- /dev/null +++ b/tests/plugins/os/unix/test_cronjobs.py @@ -0,0 +1,39 @@ +from io import BytesIO + +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.plugins.os.unix.cronjobs import CronjobPlugin +from dissect.target.target import Target + + +def test_unix_cronjobs_system(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test if we correctly infer the username of the cronjob from the command.""" + + fs_unix.map_file_fh("/etc/crontab", BytesIO(b"17 * * * * root cd / && run-parts --report /etc/cron.hourly")) + target_unix_users.add_plugin(CronjobPlugin) + + results = list(target_unix_users.cronjobs()) + assert len(results) == 1 + assert results[0].minute == "17" + assert results[0].hour == "*" + assert results[0].day == "*" + assert results[0].month == "*" + assert results[0].weekday == "*" + assert results[0].user == "root" + assert results[0].command == "cd / && run-parts --report /etc/cron.hourly" + + +def test_unix_cronjobs_user(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test if we correctly infer the username of the crontab from the file path.""" + + fs_unix.map_file_fh("/var/spool/cron/crontabs/user", BytesIO(b"0 0 * * * /path/to/example.sh\n")) + target_unix_users.add_plugin(CronjobPlugin) + + results = list(target_unix_users.cronjobs()) + assert len(results) == 1 + assert results[0].minute == "0" + assert results[0].hour == "0" + assert results[0].day == "*" + assert results[0].month == "*" + assert results[0].weekday == "*" + assert results[0].user == "user" + assert results[0].command == "/path/to/example.sh" From e03cf74fa6455897d4fd5f658026265a3e6c939c Mon Sep 17 00:00:00 2001 From: Computer Network Investigation <121175071+JSCU-CNI@users.noreply.github.com> Date: Thu, 30 Jan 2025 15:17:45 +0100 Subject: [PATCH 2/4] Update dissect/target/plugins/os/unix/cronjobs.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugins/os/unix/cronjobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py index 11b768445..8fd2f7336 100644 --- a/dissect/target/plugins/os/unix/cronjobs.py +++ b/dissect/target/plugins/os/unix/cronjobs.py @@ -95,7 +95,7 @@ def find_crontabs(self) -> Iterator[Path]: if (file := self.target.fs.path(crontab_file)).exists(): yield file - @export(record=get_args([CronjobRecord, EnvironmentVariableRecord])) + @export(record=[CronjobRecord, EnvironmentVariableRecord]) def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: """Yield cronjobs on a unix system. From c90494fc5e4d9c7da8e2075a83e57a17de9a5694 Mon Sep 17 00:00:00 2001 From: JSCU-CNI <121175071+JSCU-CNI@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:45:22 +0100 Subject: [PATCH 3/4] add more tests --- dissect/target/plugins/os/unix/cronjobs.py | 34 ++++--- tests/plugins/os/unix/test_cronjobs.py | 109 ++++++++++++++++++++- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py index 8fd2f7336..154894ad2 100644 --- a/dissect/target/plugins/os/unix/cronjobs.py +++ b/dissect/target/plugins/os/unix/cronjobs.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Iterator, get_args +from typing import Iterator from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.record import TargetRecordDescriptor @@ -49,13 +49,7 @@ """, re.VERBOSE, ) -RE_ENVVAR = re.compile( - r""" - ^ - ([a-zA-Z_]+[a-zA-Z[0-9_])=(.*) - """, - re.VERBOSE, -) +RE_ENVVAR = re.compile(r"^(?P[a-zA-Z_]+[a-zA-Z[0-9_])=(?P.*)") class CronjobPlugin(Plugin): @@ -107,6 +101,9 @@ def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: - https://linux.die.net/man/1/crontab - https://linux.die.net/man/5/crontab - https://en.wikipedia.org/wiki/Cron + - https://linux.die.net/man/8/anacron + - https://manpages.ubuntu.com/manpages/oracular/en/man5/crontab.5.html + - https://www.gnu.org/software/mcron/manual/mcron.html#Guile-Syntax """ for file in self.crontabs: @@ -122,10 +119,20 @@ def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: user = None if file.is_relative_to("/var/spool/cron/crontabs"): user = file.name - else: - user, match["command"] = re.split(r"\s", match["command"], maxsplit=1) - match["command"] = match["command"].strip() + # We try to infer a possible user from the command. This can lead to false positives, + # due to differing implementations of cron across operating systems, which is why + # we choose not to change the 'command' from the cron line - unless the command + # starts with the found username plus a tab character. + else: + try: + inferred_user, _ = re.split(r"\s", match["command"], maxsplit=1) + if not any(i in inferred_user for i in {"/", "="}): + user = inferred_user.strip() + if match["command"].startswith(inferred_user + "\t"): + match["command"] = match["command"].replace(inferred_user + "\t", "", 1) + except ValueError: + pass yield CronjobRecord( **match, @@ -134,10 +141,11 @@ def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: _target=self.target, ) + # Some cron implementations allow for environment variables to be set inside crontab files. elif match := RE_ENVVAR.search(line): + match = match.groupdict() yield EnvironmentVariableRecord( - key=match.group(1), - value=match.group(2), + **match, source=file, _target=self.target, ) diff --git a/tests/plugins/os/unix/test_cronjobs.py b/tests/plugins/os/unix/test_cronjobs.py index 468ea5f5a..ad912a2e4 100644 --- a/tests/plugins/os/unix/test_cronjobs.py +++ b/tests/plugins/os/unix/test_cronjobs.py @@ -1,7 +1,10 @@ +import textwrap from io import BytesIO +import pytest + from dissect.target.filesystem import VirtualFilesystem -from dissect.target.plugins.os.unix.cronjobs import CronjobPlugin +from dissect.target.plugins.os.unix.cronjobs import CronjobPlugin, CronjobRecord from dissect.target.target import Target @@ -37,3 +40,107 @@ def test_unix_cronjobs_user(target_unix_users: Target, fs_unix: VirtualFilesyste assert results[0].weekday == "*" assert results[0].user == "user" assert results[0].command == "/path/to/example.sh" + + +def test_unix_cronjobs_env(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """test if we parse environment variables inside crontab files correctly.""" + + crontab = """ + FOO=bar + PATH=/path/to/some/example + 0 0 * * * example.sh + """ + + fs_unix.map_file_fh("/etc/crontab", BytesIO(textwrap.dedent(crontab).encode())) + + results = list(target_unix.cronjobs()) + assert len(results) == 3 + assert results[0].key == "FOO" + assert results[0].value == "bar" + assert results[1].key == "PATH" + assert results[1].value == "/path/to/some/example" + assert results[2].command == "example.sh" + assert results[2].user is None + + +@pytest.mark.parametrize( + ("cron_line", "expected_output"), + [ + ( + "0 0 * * * FOO=bar /path/to/some/script.sh", + { + "command": "FOO=bar /path/to/some/script.sh", + "day": "*", + "hour": "0", + "minute": "0", + "month": "*", + "source": "/etc/crontab", + "user": None, + "weekday": "*", + }, + ), + ( + "0 * * * * source some-file ; /path/to/some/script.sh", + { + "command": "source some-file ; /path/to/some/script.sh", + "day": "*", + "hour": "*", + "minute": "0", + "month": "*", + "source": "/etc/crontab", + "user": "source", # this is a false-positive + "weekday": "*", + }, + ), + ( + r"0 0 * * * sleep ${RANDOM:0:1} && /path/to/executable", + { + "command": r"sleep ${RANDOM:0:1} && /path/to/executable", + "day": "*", + "hour": "0", + "minute": "0", + "month": "*", + "source": "/etc/crontab", + "user": "sleep", # this is a false-positive + "weekday": "*", + }, + ), + ( + "*/5 * * * * /bin/bash -c 'source /some-file; echo \"FOO: $BAR\" >> /var/log/some.log 2>&1'", + { + "command": "/bin/bash -c 'source /some-file; echo \"FOO: $BAR\" >> /var/log/some.log 2>&1'", + "day": "*", + "hour": "*", + "minute": "*/5", + "month": "*", + "source": "/etc/crontab", + "user": None, + "weekday": "*", + }, + ), + ( + "0 0 * * * example.sh", + { + "command": "example.sh", + "day": "*", + "hour": "0", + "minute": "0", + "month": "*", + "source": "/etc/crontab", + "user": None, + "weekday": "*", + }, + ), + ], +) +def test_unix_cronjobs_fuzz( + cron_line: str, expected_output: dict, target_unix: Target, fs_unix: VirtualFilesystem +) -> None: + """test if we can handle different cronjob line formats without breaking.""" + + fs_unix.map_file_fh("/etc/crontab", BytesIO(cron_line.encode())) + results = list(target_unix.cronjobs()) + assert len(results) == 1 + assert { + k: v for k, v in results[0]._asdict().items() if k in [f for _, f in CronjobRecord.target_fields] + } == expected_output From a4d5cc0f09ccb07104dbf71af2460ec714c33cac Mon Sep 17 00:00:00 2001 From: Computer Network Investigation <121175071+JSCU-CNI@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:24:39 +0100 Subject: [PATCH 4/4] Update dissect/target/plugins/os/unix/cronjobs.py Co-authored-by: Stefan de Reuver <9864602+Horofic@users.noreply.github.com> --- dissect/target/plugins/os/unix/cronjobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py index 154894ad2..b8bf42c58 100644 --- a/dissect/target/plugins/os/unix/cronjobs.py +++ b/dissect/target/plugins/os/unix/cronjobs.py @@ -109,7 +109,7 @@ def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: for file in self.crontabs: for line in file.open("rt"): line = line.strip() - if line.startswith("#") or not len(line): + if line.startswith("#") or not line: continue if match := RE_CRONJOB.search(line):