From e1d6d376dcdffad89e19d8ca74c5df319d53bd7f Mon Sep 17 00:00:00 2001 From: t3chn0m4g3 Date: Thu, 28 May 2026 14:49:17 +0200 Subject: [PATCH] Enhance Cowrie persona support by adding package manager handling and skip command filtering in protocol.py --- docker/_tests/tests/cowrie.sh | 70 ++++++ docker/cowrie/dist/generate_cowrie_fs.py | 231 +++++++++++++++++- .../dist/patch_cowrie_persona_support.py | 53 ++++ 3 files changed, 351 insertions(+), 3 deletions(-) diff --git a/docker/_tests/tests/cowrie.sh b/docker/_tests/tests/cowrie.sh index 67892c7a..f08f0f67 100755 --- a/docker/_tests/tests/cowrie.sh +++ b/docker/_tests/tests/cowrie.sh @@ -433,6 +433,7 @@ assert_custom_filesystem() { docker exec -i "${TEST_CONTAINER_NAME}" python3 - <<'PY' import configparser import json +import pickle from pathlib import Path import sys @@ -441,7 +442,40 @@ personas_root = root / "personas" metadata_path = personas_root / "personas.json" selected_path = Path("/tmp/cowrie/persona") runtime_config_path = Path("/tmp/cowrie/runtime/cowrie.cfg") +protocol_path = root / "src" / "cowrie" / "shell" / "protocol.py" offenders = [] +expected_package_managers = { + "debian-bookworm-vuln": {"apt", "apt-get", "dpkg"}, + "fedora-36-vuln": {"dnf", "rpm", "yum"}, + "rhel-9-vuln": {"dnf", "rpm", "yum"}, + "dlink-dir859": set(), + "tplink-wr841n": set(), + "zyxel-nas326": set(), + "openwrt-1806": {"opkg"}, + "qnap-qts": {"qpkg_cli"}, + "synology-dsm": {"synopkg"}, + "ubiquiti-edgerouter-x": {"apt-get", "dpkg"}, +} +package_manager_paths = ( + "bin/apt", + "bin/apt-get", + "bin/dnf", + "bin/opkg", + "bin/rpm", + "bin/yum", + "sbin/opkg", + "sbin/qpkg_cli", + "usr/bin/apt", + "usr/bin/apt-get", + "usr/bin/dnf", + "usr/bin/dpkg", + "usr/bin/opkg", + "usr/bin/rpm", + "usr/bin/yum", + "usr/sbin/opkg", + "usr/sbin/qpkg_cli", + "usr/syno/bin/synopkg", +) def read_bytes(path): @@ -474,12 +508,29 @@ def check_forbidden(path): offenders.append(str(path)) +def node_children(node): + return node[7] if len(node) > 7 and isinstance(node[7], list) else [] + + +def find_node(root_node, relative_path): + current = root_node + for part in Path(relative_path).parts: + if part in ("", "/"): + continue + current = next((child for child in node_children(current) if child[0] == part), None) + if current is None: + return None + return current + + if not metadata_path.is_file(): fail(f"Missing Cowrie persona metadata: {metadata_path}") if not selected_path.is_file(): fail(f"Missing selected Cowrie persona file: {selected_path}") if not runtime_config_path.is_file(): fail(f"Missing runtime Cowrie config: {runtime_config_path}") +if "skip_python_commands" not in protocol_path.read_text(encoding="utf-8"): + fail("Cowrie protocol.py does not contain persona command filtering patch") personas = json.loads(metadata_path.read_text(encoding="utf-8")) if len(personas) != 10: @@ -508,6 +559,8 @@ if config.get("honeypot", "txtcmds_path", fallback="") != str(expected_txtcmds): fail("Runtime config does not point to the selected txtcmds") if config.get("ssh", "version", fallback="") != ids[selected]["ssh_banner"]: fail("Runtime config SSH banner does not match selected persona metadata") +if "apt" not in config.get("shell", "skip_python_commands", fallback=""): + fail("Runtime config does not disable generic package-manager Python commands") for persona_id, persona in ids.items(): persona_dir = personas_root / persona_id @@ -531,6 +584,8 @@ for persona_id, persona in ids.items(): fail(f"{persona_id} fs.pickle is unexpectedly small: {pickle_path.stat().st_size} bytes") pickle_bytes = read_bytes(pickle_path) + with pickle_path.open("rb") as handle: + pickle_tree = pickle.load(handle) if persona["user"].encode("utf-8") not in pickle_bytes: fail(f"{persona_id} fs.pickle does not contain persona user {persona['user']}") if persona["hostname"].encode("utf-8") not in pickle_bytes: @@ -540,6 +595,12 @@ for persona_id, persona in ids.items(): cmdoutput = json.loads(cmdoutput_path.read_text(encoding="utf-8")) if not cmdoutput.get("command", {}).get("ps"): fail(f"{persona_id} cmdoutput.json has no ps process list") + config_text = config_path.read_text(encoding="utf-8") + if "skip_python_commands =" not in config_text: + fail(f"{persona_id} config does not define skip_python_commands") + package_managers = set(persona.get("package_managers", [])) + if package_managers != expected_package_managers[persona_id]: + fail(f"{persona_id} package managers do not match persona: {sorted(package_managers)}") for command_path in ( "bin/df", "bin/dmesg", @@ -551,6 +612,15 @@ for persona_id, persona in ids.items(): ): if not (txtcmds_path / command_path).is_file(): fail(f"{persona_id} txtcmds is missing {command_path}") + for command_path in package_manager_paths: + manager_name = Path(command_path).name + should_exist = manager_name in package_managers + exists_in_pickle = find_node(pickle_tree, command_path) is not None + exists_in_txtcmds = (txtcmds_path / command_path).is_file() + if should_exist and exists_in_txtcmds and not exists_in_pickle: + fail(f"{persona_id} package manager is missing from fs.pickle: {command_path}") + if not should_exist and (exists_in_pickle or exists_in_txtcmds): + fail(f"{persona_id} has mismatched package manager command: {command_path}") passwd = read_bytes(honeyfs / "etc" / "passwd") hostname = read_bytes(honeyfs / "etc" / "hostname") diff --git a/docker/cowrie/dist/generate_cowrie_fs.py b/docker/cowrie/dist/generate_cowrie_fs.py index 73323153..a93fb76b 100644 --- a/docker/cowrie/dist/generate_cowrie_fs.py +++ b/docker/cowrie/dist/generate_cowrie_fs.py @@ -34,6 +34,70 @@ TXTCMD_PATHS = [ "usr/bin/top", ] RANDOM = random.SystemRandom() +PACKAGE_MANAGER_PATHS = [ + "bin/apt", + "bin/apt-get", + "bin/dnf", + "bin/opkg", + "bin/rpm", + "bin/yum", + "sbin/opkg", + "sbin/qpkg_cli", + "usr/bin/apt", + "usr/bin/apt-get", + "usr/bin/dnf", + "usr/bin/dpkg", + "usr/bin/opkg", + "usr/bin/rpm", + "usr/bin/yum", + "usr/sbin/opkg", + "usr/sbin/qpkg_cli", + "usr/syno/bin/synopkg", +] +HIGH_VARIANCE_COMMAND_PATHS = [ + "bin/busybox", + "usr/bin/gcc", + "usr/bin/git", + "usr/bin/lspci", + "usr/bin/perl", + "usr/bin/python", + "usr/bin/python3", + "usr/bin/sudo", + "usr/sbin/service", +] +COMMON_SKIP_PYTHON_COMMANDS = [ + "/bin/apt", + "/bin/apt-get", + "/bin/busybox", + "/bin/yum", + "/usr/bin/apt", + "/usr/bin/apt-get", + "/usr/bin/busybox", + "/usr/bin/gcc", + "/usr/bin/lspci", + "/usr/bin/sudo", + "/usr/bin/yum", + "/usr/sbin/service", + "apt", + "apt-get", + "busybox", + "gcc", + "lspci", + "sudo", + "yum", + "service", +] +EMBEDDED_SKIP_PYTHON_COMMANDS = [ + "adduser", + "finger", + "git", + "groups", + "perl", + "python", + "python3", + "scp", + "systemctl", +] A_NAME = 0 A_TYPE = 1 @@ -261,6 +325,144 @@ def random_ps_start(): return day.strftime("%b%d") +def package_manager_txtcmds(persona): + family = persona["family"] + if family == "debian": + apt_version = "apt 2.6.1 (amd64)\n" + apt_help = """apt 2.6.1 (amd64) +Usage: apt [options] command + +apt is a commandline package manager and provides commands for +searching and managing as well as querying information about packages. + +Most used commands: + list - list packages based on package names + search - search in package descriptions + show - show package details + update - update list of available packages + install - install packages + remove - remove packages + upgrade - upgrade the system by installing/upgrading packages +""" + dpkg_version = "Debian 'dpkg' package management program version 1.21.22 (amd64).\n" + return { + "usr/bin/apt": apt_help, + "usr/bin/apt-get": apt_version, + "usr/bin/dpkg": dpkg_version, + } + if family == "fedora": + dnf = """dnf 4.14.0 +usage: dnf [options] COMMAND + +List of Main Commands: +install install a package or packages on your system +remove remove a package or packages from your system +upgrade upgrade a package or packages on your system +search search package details for the given string +repolist display the configured software repositories +""" + rpm = "RPM version 4.17.0\n" + return { + "usr/bin/dnf": dnf, + "usr/bin/yum": dnf, + "usr/bin/rpm": rpm, + } + if family == "rhel": + dnf = """dnf 4.14.0 +usage: dnf [options] COMMAND + +List of Main Commands: +install install a package or packages on your system +remove remove a package or packages from your system +upgrade upgrade a package or packages on your system +repolist display the configured software repositories +module interact with modular content +""" + rpm = "RPM version 4.16.1.3\n" + return { + "usr/bin/dnf": dnf, + "usr/bin/yum": dnf, + "usr/bin/rpm": rpm, + } + if family == "openwrt": + opkg = """opkg must have one sub-command argument +usage: opkg [options...] sub-command [arguments...] + +Package Manipulation: + update Update list of available packages + install Install package(s) + remove Remove package(s) + list-installed List installed packages +""" + return { + "bin/opkg": opkg, + } + if family == "ubiquiti": + apt = """Reading package lists... Done +Building dependency tree... Done +E: Unable to locate package +""" + dpkg = "Debian 'dpkg' package management program version 1.19.8 (mips).\n" + return { + "usr/bin/apt-get": apt, + "usr/bin/dpkg": dpkg, + } + if family == "qnap": + qpkg = """Usage: qpkg_cli [options] + --list list installed packages + --status NAME show package status + --help show this help +""" + return { + "sbin/qpkg_cli": qpkg, + } + if family == "synology": + synopkg = """Copyright (c) 2003-2023 Synology Inc. All rights reserved. +Usage: synopkg [package] + list + status + start + stop +""" + return { + "usr/syno/bin/synopkg": synopkg, + } + return {} + + +def command_inventory_txtcmds(persona): + family = persona["family"] + txtcmds = {} + + if family in {"debian", "fedora", "rhel"}: + txtcmds.update( + { + "usr/bin/lspci": "00:00.0 Host bridge: Intel Corporation 440FX - 82441FX PMC\n00:01.0 ISA bridge: Intel Corporation 82371SB PIIX3 ISA\n00:01.1 IDE interface: Intel Corporation 82371SB PIIX3 IDE\n00:02.0 VGA compatible controller: Device 1234:1111\n", + "usr/bin/sudo": "sudo: a password is required\n", + "usr/sbin/service": "Usage: service < option > | --status-all | [ service_name [ command | --full-restart ] ]\n", + } + ) + elif family in {"qnap", "synology"}: + txtcmds["usr/bin/lspci"] = ( + "00:00.0 Host bridge: Intel Corporation Atom Processor C3000 Host Bridge\n" + "00:14.0 USB controller: Intel Corporation Atom Processor C3000 USB 3.0 xHCI Controller\n" + "00:17.0 SATA controller: Intel Corporation Atom Processor C3000 SATA Controller\n" + ) + + if family in {"iot-router", "iot-nas", "openwrt", "qnap", "synology", "ubiquiti"}: + if "bin/busybox" in persona["files"]: + txtcmds["bin/busybox"] = persona["files"]["bin/busybox"] + + return txtcmds + + +def skip_python_commands_for_family(family): + commands = list(COMMON_SKIP_PYTHON_COMMANDS) + if family in {"iot-router", "iot-nas", "openwrt", "qnap", "synology", "ubiquiti"}: + commands.extend(EMBEDDED_SKIP_PYTHON_COMMANDS) + return sorted(set(commands)) + + def ps_entry(user, pid, command, cpu=0.0, mem=0.1, vsz=0, rss=0, stat="S", tty="?", start=None): return { "USER": user, @@ -418,7 +620,7 @@ cpu time (seconds, -t) unlimited max user processes (-u) 1024 virtual memory (kbytes, -v) unlimited """ - return { + txtcmds = { "bin/df": df, "bin/dmesg": dmesg, "bin/mount": mount, @@ -427,6 +629,9 @@ virtual memory (kbytes, -v) unlimited "usr/bin/nproc": f"{cpu_count}\n", "usr/bin/top": top, } + txtcmds.update(package_manager_txtcmds(persona)) + txtcmds.update(command_inventory_txtcmds(persona)) + return txtcmds def profile( @@ -488,6 +693,7 @@ def profile( "vulnerability": vulnerability, "shell": shell, "process_start": random_ps_start(), + "skip_python_commands": skip_python_commands_for_family(family), } @@ -988,6 +1194,8 @@ def write_text_file(path, text, mode=0o644): def apply_persona_to_tree(tree, persona): for remove_path in persona["remove_paths"]: remove_node(tree, remove_path) + for remove_path in PACKAGE_MANAGER_PATHS + HIGH_VARIANCE_COMMAND_PATHS: + remove_node(tree, remove_path) for relative_path in COMMON_DIRS: mode = 0o755 @@ -1013,7 +1221,7 @@ def apply_persona_to_tree(tree, persona): mode = 0o755 ensure_file(tree, relative_path, text.encode("utf-8"), 0, 0, mode) - for relative_path in TXTCMD_PATHS: + for relative_path in txtcmds_for_persona(persona): ensure_file(tree, relative_path, b"", 0, 0, 0o755) @@ -1093,6 +1301,7 @@ kernel_build_string = {persona["kernel_build_string"]} hardware_platform = {persona["hardware_platform"]} operating_system = {persona["operating_system"]} ssh_version = {persona["shell_ssh_version"]} +skip_python_commands = {",".join(persona["skip_python_commands"])} [ssh] enabled = true @@ -1182,6 +1391,7 @@ def validate_persona(persona, persona_dir, runtime_persona_dir): ) pickle_bytes = pickle_path.read_bytes() + pickle_tree = load_pickle(pickle_path) required = [ persona["user"].encode("utf-8"), persona["hostname"].encode("utf-8"), @@ -1207,9 +1417,21 @@ def validate_persona(persona, persona_dir, runtime_persona_dir): expected_start = persona["process_start"] if any(process.get("START") != expected_start for process in process_list): raise RuntimeError(f"{persona['id']} has inconsistent process start dates") - for relative_path in TXTCMD_PATHS: + expected_txtcmds = txtcmds_for_persona(persona) + for relative_path in expected_txtcmds: if not (txtcmds / relative_path).is_file(): raise RuntimeError(f"{persona['id']} has no txtcmds/{relative_path}") + for relative_path in PACKAGE_MANAGER_PATHS: + has_command = relative_path in expected_txtcmds + in_pickle = find_node(pickle_tree, relative_path) is not None + in_honeyfs = (honeyfs / relative_path).exists() + if has_command and not in_pickle: + raise RuntimeError(f"{persona['id']} package manager missing from pickle: {relative_path}") + if not has_command and (in_pickle or in_honeyfs): + raise RuntimeError(f"{persona['id']} has mismatched package manager path: {relative_path}") + config_text = config_path.read_text(encoding="utf-8") + if "skip_python_commands =" not in config_text: + raise RuntimeError(f"{persona['id']} config does not define skip_python_commands") if not (honeyfs / "etc" / "hostname").is_file(): raise RuntimeError(f"{persona['id']} honeyfs has no /etc/hostname") @@ -1249,6 +1471,9 @@ def write_metadata(personas_root): "user": persona["user"], "arch": persona["arch"], "ssh_banner": persona["ssh_banner"], + "package_managers": sorted( + Path(path).name for path in package_manager_txtcmds(persona) + ), "vulnerability": persona["vulnerability"], } ) diff --git a/docker/cowrie/dist/patch_cowrie_persona_support.py b/docker/cowrie/dist/patch_cowrie_persona_support.py index 505a010a..159b7fab 100755 --- a/docker/cowrie/dist/patch_cowrie_persona_support.py +++ b/docker/cowrie/dist/patch_cowrie_persona_support.py @@ -21,6 +21,50 @@ def replace_all(path, old, new): def patch_protocol(cowrie_root): path = cowrie_root / "src" / "cowrie" / "shell" / "protocol.py" replace_once(path, "import traceback\n", "import traceback\nfrom pathlib import Path\n") + replace_once( + path, + """ if cmd in self.commands: + return self.commands[cmd] + if cmd[0] in (".", "/"): + path = self.fs.resolve_path(cmd, self.cwd) + if not self.fs.exists(path): + return None + else: + for i in [f"{self.fs.resolve_path(x, self.cwd)}/{cmd}" for x in paths]: + if self.fs.exists(i): + path = i + break + + if path is None: + return None +""", + """ skip_python_commands = { + command.strip() + for command in CowrieConfig.get( + "shell", "skip_python_commands", fallback="" + ).split(",") + if command.strip() + } + + def skip_python_command(name): + return name in skip_python_commands or Path(name).name in skip_python_commands + + if cmd in self.commands and not skip_python_command(cmd): + return self.commands[cmd] + if cmd[0] in (".", "/"): + path = self.fs.resolve_path(cmd, self.cwd) + if not self.fs.exists(path): + return None + else: + for i in [f"{self.fs.resolve_path(x, self.cwd)}/{cmd}" for x in paths]: + if self.fs.exists(i): + path = i + break + + if path is None: + return None +""", + ) replace_once( path, """ try: @@ -42,6 +86,15 @@ def patch_protocol(cowrie_root): pass """, ) + replace_once( + path, + """ if path in self.commands: + return self.commands[path] +""", + """ if path in self.commands and not skip_python_command(path): + return self.commands[path] +""", + ) def patch_ssh(cowrie_root):