diff --git a/docker/_tests/README.md b/docker/_tests/README.md index 1dbac890..ecec05ab 100644 --- a/docker/_tests/README.md +++ b/docker/_tests/README.md @@ -14,6 +14,7 @@ not touch production `data/` or `data_backup/` paths. ./docker/_tests/run.sh ciscoasa ./docker/_tests/run.sh citrixhoneypot ./docker/_tests/run.sh conpot +./docker/_tests/run.sh cowrie ``` Common options: @@ -31,6 +32,8 @@ Individual tests can also be run directly: ./docker/_tests/tests/ciscoasa.sh --https-port 18443 --ike-port 15000 ./docker/_tests/tests/citrixhoneypot.sh --https-port 1443 ./docker/_tests/tests/conpot.sh --guardian-ast-port 11001 --ipmi-port 1623 +./docker/_tests/tests/cowrie.sh +./docker/_tests/tests/cowrie.sh --ssh-port 2222 --telnet-port 2323 ``` ## Conventions diff --git a/docker/_tests/tests/cowrie.sh b/docker/_tests/tests/cowrie.sh new file mode 100755 index 00000000..ed5ac6fd --- /dev/null +++ b/docker/_tests/tests/cowrie.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +TEST_NAME="cowrie" +DEFAULT_IMAGE="ghcr.io/telekom-security/cowrie:24.04.1" +IMAGE="" +SSH_PORT="" +TELNET_PORT="" +LOG_DIR="" +TTY_LOG_DIR="" +DL_DIR="" +KEYS_DIR="" +JSON_LOG_FILE="" +MAPPED_SSH_PORT="" +MAPPED_TELNET_PORT="" + +usage() { + cat < "${TEST_HARNESS_COMPOSE}" <&1)"; then + printf '%s\n' "${output}" + return 0 + fi + sleep 1 + done + + printf '%s\n' "${output}" >&2 + return 1 +} + +run_telnet_login_probe() { + local token="$1" + + python3 - "${TEST_BIND_IP}" "${MAPPED_TELNET_PORT}" "${token}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys +import time + +IAC = 255 +DONT = 254 +DO = 253 +WONT = 252 +WILL = 251 +SB = 250 +SE = 240 + +host = sys.argv[1] +port = int(sys.argv[2]) +token = sys.argv[3] +timeout = int(sys.argv[4]) +username = f"cowrie-user-{token}" +password = f"cowrie-pass-{token}" +deadline = time.monotonic() + timeout + + +def readable(data): + return data.decode("utf-8", errors="ignore").lower() + + +def read_until(sock, needles): + data = bytearray() + in_subnegotiation = False + while time.monotonic() < deadline: + try: + chunk = sock.recv(256) + except socket.timeout: + continue + if not chunk: + break + + index = 0 + while index < len(chunk): + byte = chunk[index] + if byte == IAC and index + 1 < len(chunk): + command = chunk[index + 1] + if command in (DO, DONT, WILL, WONT) and index + 2 < len(chunk): + option = chunk[index + 2] + if command in (DO, DONT): + sock.sendall(bytes([IAC, WONT, option])) + else: + sock.sendall(bytes([IAC, DONT, option])) + index += 3 + continue + if command == SB: + in_subnegotiation = True + index += 2 + continue + if command == SE: + in_subnegotiation = False + index += 2 + continue + index += 2 + continue + + if not in_subnegotiation: + data.append(byte) + index += 1 + + text = readable(data) + if any(needle in text for needle in needles): + return bytes(data) + + raise RuntimeError(f"Timed out waiting for one of {needles}; received {bytes(data)!r}") + + +try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(1) + login_prompt = read_until(sock, ("login:", "username:")) + sock.sendall((username + "\r\n").encode("ascii")) + password_prompt = read_until(sock, ("password:",)) + sock.sendall((password + "\r\n").encode("ascii")) + time.sleep(0.5) + + print( + "Telnet prompts: " + f"{login_prompt[-80:].decode('utf-8', errors='replace')!r}; " + f"{password_prompt[-80:].decode('utf-8', errors='replace')!r}" + ) +except Exception as exc: + print(f"Telnet probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_telnet_login_probe_with_retries() { + local token="$1" + local deadline=$((SECONDS + TEST_TIMEOUT)) + local output="" + + while (( SECONDS < deadline )); do + if output="$(run_telnet_login_probe "${token}" 2>&1)"; then + printf '%s\n' "${output}" + return 0 + fi + sleep 1 + done + + printf '%s\n' "${output}" >&2 + return 1 +} + +wait_for_json_event_containing() { + local token="$1" + local mode="$2" + + python3 - "${JSON_LOG_FILE}" "${token}" "${mode}" "${TEST_TIMEOUT}" <<'PY' +import json +import sys +import time +from pathlib import Path + +path = Path(sys.argv[1]) +token = sys.argv[2] +mode = sys.argv[3] +timeout = int(sys.argv[4]) +deadline = time.monotonic() + timeout +last_error = None + + +def contains_token(value): + if isinstance(value, str): + return token in value + if isinstance(value, dict): + return any(contains_token(item) for item in value.values()) + if isinstance(value, list): + return any(contains_token(item) for item in value) + return False + + +def matches_mode(event): + eventid = str(event.get("eventid", "")) + if mode == "ssh": + return contains_token(event) and ( + eventid.startswith("cowrie.client.") + or eventid.startswith("cowrie.session.") + or eventid.startswith("cowrie.login.") + ) + if mode == "telnet": + return contains_token(event) and ( + eventid.startswith("cowrie.login.") + or eventid.startswith("cowrie.session.") + or eventid.startswith("cowrie.client.") + ) + raise RuntimeError(f"Unknown mode: {mode}") + + +while time.monotonic() < deadline: + if not path.exists(): + last_error = f"{path} does not exist yet" + time.sleep(1) + continue + + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as exc: + last_error = f"Could not read {path}: {exc}" + time.sleep(1) + continue + + for line_number, line in enumerate(lines, 1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + last_error = f"Invalid JSON in {path}:{line_number}: {exc}" + continue + + if matches_mode(event): + print(f"JSON event found in {path}:{line_number}: {event.get('eventid', '')}") + sys.exit(0) + + last_error = f"No {mode} JSON event found in {path} for token {token}" + time.sleep(1) + +if last_error: + print(last_error, file=sys.stderr) +sys.exit(1) +PY +} + +assert_custom_filesystem() { + docker exec -i "${TEST_CONTAINER_NAME}" python3 - <<'PY' +from pathlib import Path +import sys + +root = Path("/home/cowrie/cowrie") +pickle_path = root / "src" / "cowrie" / "data" / "fs.pickle" +honeyfs = root / "honeyfs" +offenders = [] + + +def read_bytes(path): + try: + return path.read_bytes() + except OSError as exc: + print(f"Could not read {path}: {exc}", file=sys.stderr) + sys.exit(1) + + +if not pickle_path.is_file(): + print(f"Missing Cowrie filesystem pickle: {pickle_path}", file=sys.stderr) + sys.exit(1) +if not honeyfs.is_dir(): + print(f"Missing Cowrie honeyfs directory: {honeyfs}", file=sys.stderr) + sys.exit(1) + +if b"phil" in read_bytes(pickle_path).lower(): + offenders.append(str(pickle_path)) + +for item in honeyfs.rglob("*"): + if "phil" in item.name.lower(): + offenders.append(str(item)) + continue + if item.is_file() and b"phil" in read_bytes(item).lower(): + offenders.append(str(item)) + +if offenders: + print("Cowrie filesystem still contains 'phil': " + ", ".join(offenders), file=sys.stderr) + sys.exit(1) + +pickle_bytes = read_bytes(pickle_path) +pickle_size = pickle_path.stat().st_size +passwd = read_bytes(honeyfs / "etc" / "passwd") +hostname = read_bytes(honeyfs / "etc" / "hostname") +os_release = read_bytes(honeyfs / "etc" / "os-release") + +if pickle_size < 1000000: + print(f"fs.pickle is unexpectedly small: {pickle_size} bytes", file=sys.stderr) + sys.exit(1) + +checks = [ + (b"ubuntu", pickle_bytes, "fs.pickle does not contain ubuntu"), + (b"ubuntu", passwd, "honeyfs /etc/passwd does not contain ubuntu"), + (b"srv01", hostname, "honeyfs /etc/hostname does not contain srv01"), + (b"Ubuntu 22.04", os_release, "honeyfs /etc/os-release does not describe Ubuntu 22.04"), +] + +for needle, haystack, message in checks: + if needle not in haystack: + print(message, file=sys.stderr) + sys.exit(1) + +print("Cowrie filesystem profile validated: ubuntu@srv01 without phil") +PY +} + +assert_no_runtime_errors() { + if grep -R -E "Traceback|NameError|Unhandled Error|Exception" "${LOG_DIR}" >/dev/null 2>&1; then + test_die "Cowrie runtime error found in log files" + fi + + if test_compose logs --no-color 2>/dev/null | grep -E "Traceback|NameError|Unhandled Error|Exception" >/dev/null 2>&1; then + test_die "Cowrie runtime error found in Docker logs" + fi +} + +main() { + parse_args "$@" + validate_args + test_check_dependencies + + if [[ -z "${IMAGE}" ]]; then + IMAGE="$(test_read_compose_image "${TEST_NAME}" "${DEFAULT_IMAGE}")" + fi + + test_info "Using image: ${IMAGE}" + test_require_image "${IMAGE}" "docker compose -f docker/${TEST_NAME}/docker-compose.yml build ${TEST_NAME}" + + if [[ -n "${SSH_PORT}" ]]; then + test_ensure_port_free "${TEST_BIND_IP}" "${SSH_PORT}" || test_die "${TEST_BIND_IP}:${SSH_PORT} is already in use. Try --ssh-port ." + fi + if [[ -n "${TELNET_PORT}" ]]; then + test_ensure_port_free "${TEST_BIND_IP}" "${TELNET_PORT}" || test_die "${TEST_BIND_IP}:${TELNET_PORT} is already in use. Try --telnet-port ." + fi + + prepare_cowrie_harness + test_enable_cleanup + + test_info "Starting isolated Cowrie container" + test_compose up -d --no-build >/dev/null + + test_wait_for_container || test_die "Cowrie container did not stay running" + test_ok "Container is running" + + MAPPED_SSH_PORT="$(test_get_mapped_port "${TEST_NAME}" "22")" || test_die "Could not resolve mapped host port for 22/tcp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_SSH_PORT} maps to container port 22/tcp" + + MAPPED_TELNET_PORT="$(test_get_mapped_port "${TEST_NAME}" "23")" || test_die "Could not resolve mapped host port for 23/tcp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_TELNET_PORT} maps to container port 23/tcp" + + test_info "Validating custom Cowrie filesystem profile" + assert_custom_filesystem || test_die "Custom Cowrie filesystem validation failed" + test_ok "Custom Cowrie filesystem profile is present" + + test_info "Waiting for cowrie.json" + wait_for_json_log || test_die "cowrie.json was not created" + test_ok "cowrie.json exists" + + local ssh_token="ssh-test-$(date +%s)-$$" + test_info "Running SSH banner probe with token: ${ssh_token}" + run_ssh_banner_probe_with_retries "${ssh_token}" || test_die "SSH probe failed on ${TEST_BIND_IP}:${MAPPED_SSH_PORT}" + test_wait_for_container || test_die "Cowrie container stopped after SSH probe" + + test_info "Waiting for SSH probe event in cowrie.json" + wait_for_json_event_containing "${ssh_token}" "ssh" || test_die "SSH probe token was not found in cowrie.json" + test_ok "SSH probe was written to cowrie.json" + + local telnet_token="telnet-test-$(date +%s)-$$" + test_info "Running Telnet login probe with token: ${telnet_token}" + run_telnet_login_probe_with_retries "${telnet_token}" || test_die "Telnet probe failed on ${TEST_BIND_IP}:${MAPPED_TELNET_PORT}" + test_wait_for_container || test_die "Cowrie container stopped after Telnet probe" + + test_info "Waiting for Telnet probe event in cowrie.json" + wait_for_json_event_containing "${telnet_token}" "telnet" || test_die "Telnet probe token was not found in cowrie.json" + test_ok "Telnet probe was written to cowrie.json" + + assert_no_runtime_errors + test_ok "No Cowrie runtime errors found in logs" + + test_ok "Cowrie post-build smoke test completed successfully" +} + +main "$@" diff --git a/docker/cowrie/Dockerfile b/docker/cowrie/Dockerfile index fc7e540a..645d7512 100644 --- a/docker/cowrie/Dockerfile +++ b/docker/cowrie/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.20 +FROM alpine:3.23 # # Include dist COPY dist/ /root/dist/ @@ -41,13 +41,14 @@ RUN apk --no-cache -U upgrade && \ # Install cowrie mkdir -p /home/cowrie && \ cd /home/cowrie && \ - git clone https://github.com/cowrie/cowrie && \ + git clone https://github.com/cowrie/cowrie -b v3.0.0 && \ cd cowrie && \ - git checkout 7b18207485dbfc218082e82c615d948924429973 && \ mkdir -p log && \ # cp /root/dist/requirements.txt . && \ pip3 install --break-system-packages --upgrade --no-cache-dir pip && \ pip3 install --break-system-packages --no-cache-dir -r requirements.txt && \ + pip3 install --break-system-packages --no-cache-dir -e . && \ + python3 /root/dist/generate_cowrie_fs.py --cowrie-root /home/cowrie/cowrie && \ # # Setup configs setcap cap_net_bind_service=+ep $(readlink -f $(type -P python3)) && \ @@ -78,7 +79,7 @@ RUN apk --no-cache -U upgrade && \ /home/cowrie/cowrie/.git # # Start cowrie -ENV PYTHONPATH /home/cowrie/cowrie:/home/cowrie/cowrie/src +ENV PYTHONPATH=/home/cowrie/cowrie:/home/cowrie/cowrie/src WORKDIR /home/cowrie/cowrie USER cowrie:cowrie CMD ["/usr/bin/twistd", "--nodaemon", "-y", "cowrie.tac", "--pidfile", "/tmp/cowrie/cowrie.pid", "cowrie"] diff --git a/docker/cowrie/dist/cowrie.cfg b/docker/cowrie/dist/cowrie.cfg index 83ccc5da..7562c1de 100644 --- a/docker/cowrie/dist/cowrie.cfg +++ b/docker/cowrie/dist/cowrie.cfg @@ -1,5 +1,5 @@ [honeypot] -hostname = ubuntu +hostname = srv01 log_path = log logtype = plain download_path = dl @@ -21,8 +21,7 @@ data_path = src/cowrie/data [shell] filesystem = src/cowrie/data/fs.pickle processes = src/cowrie/data/cmdoutput.json -#arch = linux-x64-lsb -arch = bsd-aarch64-lsb, bsd-aarch64-msb, bsd-bfin-msb, bsd-mips-lsb, bsd-mips-msb, bsd-mips64-lsb, bsd-mips64-msb, bsd-powepc-msb, bsd-powepc64-lsb, bsd-riscv64-lsb, bsd-sparc-msb, bsd-sparc64-msb, bsd-x32-lsb, bsd-x64-lsb, linux-aarch64-lsb, linux-aarch64-msb, linux-alpha-lsb, linux-am33-lsb, linux-arc-lsb, linux-arc-msb, linux-arm-lsb, linux-arm-msb, linux-avr32-lsb, linux-bfin-lsb, linux-c6x-lsb, linux-c6x-msb, linux-cris-lsb, linux-frv-msb, linux-h8300-msb, linux-hppa-msb, linux-hppa64-msb, linux-ia64-lsb, linux-m32r-msb, linux-m68k-msb, linux-microblaze-msb, linux-mips-lsb, linux-mips-msb, linux-mips64-lsb, linux-mips64-msb, linux-mn10300-lsb, linux-nios-lsb, linux-nios-msb, linux-powerpc-lsb, linux-powerpc-msb, linux-powerpc64-lsb, linux-powerpc64-msb, linux-riscv64-lsb, linux-s390x-msb, linux-sh-lsb, linux-sh-msb, linux-sparc-msb, linux-sparc64-msb, linux-tilegx-lsb, linux-tilegx-msb, linux-tilegx64-lsb, linux-tilegx64-msb, linux-x64-lsb, linux-x86-lsb, linux-xtensa-msb, osx-x32-lsb, osx-x64-lsb +arch = linux-x64-lsb kernel_version = 5.15.0-23-generic-amd64 kernel_build_string = #25~22.04-Ubuntu SMP hardware_platform = x86_64 @@ -46,7 +45,7 @@ macs = hmac-sha2-512,hmac-sha2-384,hmac-sha2-56,hmac-sha1,hmac-md5 compression = zlib@openssh.com,zlib,none listen_endpoints = tcp:22:interface=0.0.0.0 sftp_enabled = true -forwarding = true +forwarding = false forward_redirect = false forward_tunnel = false auth_none_enabled = false @@ -57,6 +56,7 @@ auth_publickey_allow_any = true enabled = true listen_endpoints = tcp:23:interface=0.0.0.0 reported_port = 23 +cve_2026_24061_vulnerable = true [output_jsonlog] enabled = true diff --git a/docker/cowrie/dist/generate_cowrie_fs.py b/docker/cowrie/dist/generate_cowrie_fs.py new file mode 100644 index 00000000..734bfdc8 --- /dev/null +++ b/docker/cowrie/dist/generate_cowrie_fs.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 + +import argparse +import os +import pickle +import shutil +import stat +import time +from pathlib import Path + + +HOSTNAME = "srv01" +LOGIN_USER = "ubuntu" +LOGIN_UID = 1000 +LOGIN_GID = 1000 +MIN_PICKLE_SIZE = 1_000_000 + +A_NAME = 0 +A_TYPE = 1 +A_UID = 2 +A_GID = 3 +A_SIZE = 4 +A_MODE = 5 +A_CTIME = 6 +A_CONTENTS = 7 +A_TARGET = 8 +A_REALFILE = 9 + +T_LINK = 0 +T_DIR = 1 +T_FILE = 2 + +TEXT_FILES = { + "etc/passwd": """root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +bin:x:2:2:bin:/bin:/usr/sbin/nologin +sys:x:3:3:sys:/dev:/usr/sbin/nologin +sync:x:4:65534:sync:/bin:/bin/sync +games:x:5:60:games:/usr/games:/usr/sbin/nologin +man:x:6:12:man:/var/cache/man:/usr/sbin/nologin +lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin +mail:x:8:8:mail:/var/mail:/usr/sbin/nologin +news:x:9:9:news:/var/spool/news:/usr/sbin/nologin +uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin +proxy:x:13:13:proxy:/bin:/usr/sbin/nologin +www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin +backup:x:34:34:backup:/var/backups:/usr/sbin/nologin +list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin +irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin +gnats:x:41:41:Gnats Bug-Reporting System:/var/lib/gnats:/usr/sbin/nologin +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin +systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin +systemd-resolve:x:997:997:systemd Resolver:/:/usr/sbin/nologin +messagebus:x:100:102::/nonexistent:/usr/sbin/nologin +sshd:x:101:65534::/run/sshd:/usr/sbin/nologin +ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash +""", + "etc/group": """root:x:0: +daemon:x:1: +bin:x:2: +sys:x:3: +adm:x:4:syslog,ubuntu +tty:x:5: +disk:x:6: +lp:x:7: +mail:x:8: +news:x:9: +uucp:x:10: +man:x:12: +proxy:x:13: +kmem:x:15: +dialout:x:20:ubuntu +fax:x:21: +voice:x:22: +cdrom:x:24:ubuntu +floppy:x:25:ubuntu +tape:x:26: +sudo:x:27:ubuntu +audio:x:29:ubuntu +dip:x:30:ubuntu +www-data:x:33: +backup:x:34: +operator:x:37: +list:x:38: +irc:x:39: +src:x:40: +gnats:x:41: +shadow:x:42: +utmp:x:43: +video:x:44:ubuntu +sasl:x:45: +plugdev:x:46:ubuntu +staff:x:50: +games:x:60: +users:x:100: +nogroup:x:65534: +systemd-network:x:998: +systemd-resolve:x:997: +messagebus:x:102: +ssh:x:103: +ubuntu:x:1000: +""", + "etc/shadow": """root:*:19276:0:99999:7::: +daemon:*:19276:0:99999:7::: +bin:*:19276:0:99999:7::: +sys:*:19276:0:99999:7::: +sync:*:19276:0:99999:7::: +games:*:19276:0:99999:7::: +man:*:19276:0:99999:7::: +lp:*:19276:0:99999:7::: +mail:*:19276:0:99999:7::: +news:*:19276:0:99999:7::: +uucp:*:19276:0:99999:7::: +proxy:*:19276:0:99999:7::: +www-data:*:19276:0:99999:7::: +backup:*:19276:0:99999:7::: +list:*:19276:0:99999:7::: +irc:*:19276:0:99999:7::: +gnats:*:19276:0:99999:7::: +nobody:*:19276:0:99999:7::: +systemd-network:*:19276:0:99999:7::: +systemd-resolve:*:19276:0:99999:7::: +messagebus:*:19276:0:99999:7::: +sshd:*:19276:0:99999:7::: +ubuntu:*:19276:0:99999:7::: +""", + "etc/hostname": f"{HOSTNAME}\n", + "etc/hosts": f"""127.0.0.1 localhost +127.0.1.1 {HOSTNAME} + +# The following lines are desirable for IPv6 capable hosts +::1 ip6-localhost ip6-loopback +fe00::0 ip6-localnet +ff00::0 ip6-mcastprefix +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +""", + "etc/os-release": """PRETTY_NAME="Ubuntu 22.04.4 LTS" +NAME="Ubuntu" +VERSION_ID="22.04" +VERSION="22.04.4 LTS (Jammy Jellyfish)" +VERSION_CODENAME=jammy +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +UBUNTU_CODENAME=jammy +""", + "etc/issue": "Ubuntu 22.04.4 LTS \\n \\l\n", + "etc/issue.net": "Ubuntu 22.04.4 LTS\n", + "etc/motd": """Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-23-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/advantage + +0 updates can be applied immediately. +""", + "proc/version": "Linux version 5.15.0-23-generic (buildd@lcy02-amd64-058) (gcc (Ubuntu 11.2.0-19ubuntu1) 11.2.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #25~22.04-Ubuntu SMP\n", +} + +HOME_FILES = { + ".bash_logout": """# ~/.bash_logout: executed by bash(1) when login shell exits. +if [ "$SHLVL" = 1 ]; then + [ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q +fi +""", + ".bashrc": """# ~/.bashrc: executed by bash(1) for non-login shells. +case $- in + *i*) ;; + *) return;; +esac + +HISTCONTROL=ignoreboth +shopt -s histappend +HISTSIZE=1000 +HISTFILESIZE=2000 + +if [ -f /etc/bash_completion ]; then + . /etc/bash_completion +fi +""", + ".profile": """# ~/.profile: executed by the command interpreter for login shells. +if [ "$BASH" ]; then + if [ -f ~/.bashrc ]; then + . ~/.bashrc + fi +fi + +mesg n 2> /dev/null || true +""", + ".sudo_as_admin_successful": "", +} + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Derive a custom Cowrie fs.pickle and honeyfs overlay from Cowrie defaults." + ) + parser.add_argument("--cowrie-root", required=True, type=Path) + parser.add_argument("--work-dir", default=Path("/tmp/cowrie-custom-fs"), type=Path) + return parser.parse_args() + + +def node_get(node, index, default=None): + return node[index] if len(node) > index else default + + +def node_set(node, index, value): + while len(node) <= index: + node.append(None) + node[index] = value + + +def node_children(node): + return node_get(node, A_CONTENTS, []) + + +def walk(node, path=""): + name = node_get(node, A_NAME, "") + current = "/" if name == "/" else f"{path.rstrip('/')}/{name}" + yield current, node + if node_get(node, A_TYPE) == T_DIR: + for child in node_children(node): + yield from walk(child, current) + + +def find_node(root, relative_path): + if relative_path in ("", "."): + return root + current = root + for part in Path(relative_path).parts: + if part in ("", "/"): + continue + current = next( + (child for child in node_children(current) if node_get(child, A_NAME) == part), + None, + ) + if current is None: + return None + return current + + +def ensure_dir(root, relative_path, uid=0, gid=0, mode=0o755): + current = root + now = int(time.time()) + for part in Path(relative_path).parts: + if part in ("", "/"): + continue + match = next( + (child for child in node_children(current) if node_get(child, A_NAME) == part), + None, + ) + if match is None: + match = [part, T_DIR, uid, gid, 4096, stat.S_IFDIR | mode, now, [], None, None] + node_children(current).append(match) + current = match + return current + + +def ensure_file(root, relative_path, content, uid=0, gid=0, mode=0o644): + parent = ensure_dir(root, str(Path(relative_path).parent), uid=0, gid=0) + name = Path(relative_path).name + node = next( + (child for child in node_children(parent) if node_get(child, A_NAME) == name), + None, + ) + now = int(time.time()) + if node is None: + node = [name, T_FILE, uid, gid, len(content), stat.S_IFREG | mode, now, [], None, None] + node_children(parent).append(node) + + node_set(node, A_NAME, name) + node_set(node, A_TYPE, T_FILE) + node_set(node, A_UID, uid) + node_set(node, A_GID, gid) + node_set(node, A_SIZE, len(content)) + node_set(node, A_MODE, stat.S_IFREG | mode) + if not node_get(node, A_CTIME): + node_set(node, A_CTIME, now) + node_set(node, A_CONTENTS, []) + node_set(node, A_TARGET, None) + node_set(node, A_REALFILE, None) + return node + + +def load_pickle(path): + with path.open("rb") as handle: + try: + return pickle.load(handle) + except UnicodeDecodeError: + handle.seek(0) + return pickle.load(handle, encoding="utf-8") + + +def rename_default_home(root): + phil = find_node(root, "home/phil") + if phil is not None: + node_set(phil, A_NAME, LOGIN_USER) + node_set(phil, A_UID, LOGIN_UID) + node_set(phil, A_GID, LOGIN_GID) + node_set(phil, A_MODE, stat.S_IFDIR | 0o755) + for child in node_children(phil): + node_set(child, A_UID, LOGIN_UID) + node_set(child, A_GID, LOGIN_GID) + + +def update_pickle_metadata(root): + rename_default_home(root) + ensure_dir(root, "home/ubuntu", LOGIN_UID, LOGIN_GID, 0o755) + ensure_dir(root, "etc", 0, 0, 0o755) + ensure_dir(root, "proc", 0, 0, 0o555) + + for relative_path, text in TEXT_FILES.items(): + mode = 0o644 + if relative_path == "etc/shadow": + mode = 0o640 + ensure_file(root, relative_path, text.encode("utf-8"), 0, 0, mode) + + for name, text in HOME_FILES.items(): + ensure_file( + root, + f"home/ubuntu/{name}", + text.encode("utf-8"), + LOGIN_UID, + LOGIN_GID, + 0o644, + ) + + +def write_text_file(path, text, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + os.chmod(path, mode) + + +def copy_and_patch_honeyfs(source, target): + if target.exists(): + shutil.rmtree(target) + if source.is_dir(): + shutil.copytree(source, target, copy_function=shutil.copy2) + else: + target.mkdir(parents=True) + + for relative_path, text in TEXT_FILES.items(): + mode = 0o644 + if relative_path == "etc/shadow": + mode = 0o640 + write_text_file(target / relative_path, text, mode) + + home = target / "home" / LOGIN_USER + home.mkdir(parents=True, exist_ok=True) + os.chmod(home, 0o755) + for name, text in HOME_FILES.items(): + write_text_file(home / name, text, 0o644) + + +def validate_no_phil(pickle_path, honeyfs): + offenders = [] + if b"phil" in pickle_path.read_bytes().lower(): + offenders.append(str(pickle_path)) + + for item in honeyfs.rglob("*"): + if "phil" in item.name.lower(): + offenders.append(str(item)) + continue + if item.is_file() and b"phil" in item.read_bytes().lower(): + offenders.append(str(item)) + + if offenders: + raise RuntimeError("Generated Cowrie filesystem still contains 'phil': " + ", ".join(offenders)) + + +def validate_expected_markers(pickle_path, honeyfs): + pickle_bytes = pickle_path.read_bytes() + if pickle_path.stat().st_size < MIN_PICKLE_SIZE: + raise RuntimeError( + f"Generated pickle is unexpectedly small: {pickle_path.stat().st_size} bytes" + ) + if LOGIN_USER.encode("ascii") not in pickle_bytes: + raise RuntimeError(f"Generated pickle does not contain {LOGIN_USER}") + if HOSTNAME.encode("ascii") not in (honeyfs / "etc" / "hostname").read_bytes(): + raise RuntimeError(f"Generated honeyfs does not contain {HOSTNAME}") + if not (honeyfs / "home" / LOGIN_USER).is_dir(): + raise RuntimeError(f"Generated honeyfs does not contain /home/{LOGIN_USER}") + + +def main(): + args = parse_args() + cowrie_root = args.cowrie_root.resolve() + work_dir = args.work_dir.resolve() + pickle_path = cowrie_root / "src" / "cowrie" / "data" / "fs.pickle" + source_honeyfs = cowrie_root / "honeyfs" + generated_pickle_path = work_dir / "fs.pickle" + generated_honeyfs = work_dir / "honeyfs" + + if work_dir.exists(): + shutil.rmtree(work_dir) + work_dir.mkdir(parents=True) + + tree = load_pickle(pickle_path) + update_pickle_metadata(tree) + with generated_pickle_path.open("wb") as handle: + pickle.dump(tree, handle) + + copy_and_patch_honeyfs(source_honeyfs, generated_honeyfs) + + validate_no_phil(generated_pickle_path, generated_honeyfs) + validate_expected_markers(generated_pickle_path, generated_honeyfs) + + shutil.move(generated_pickle_path, pickle_path) + if source_honeyfs.exists(): + shutil.rmtree(source_honeyfs) + shutil.copytree(generated_honeyfs, source_honeyfs, copy_function=shutil.copy2) + + +if __name__ == "__main__": + main()